import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Card } from '@/components/ui/card'; import { RefreshCw, FileText, Image as ImageIcon, Trash2, Save, Upload, File, BookOpen, Eye, Pencil, FilePlus, FileUp, Loader2, MoreVertical, } from 'lucide-react'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, KeyboardShortcut, } from '@/hooks/use-keyboard-shortcuts'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import { sanitizeFilename } from '@/lib/image-utils'; import { Markdown } from '../ui/markdown'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Textarea } from '@/components/ui/textarea'; interface ContextFile { name: string; type: 'text' | 'image'; content?: string; path: string; description?: string; } interface ContextMetadata { files: Record; } export function ContextView() { const { currentProject } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [contextFiles, setContextFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [editedContent, setEditedContent] = useState(''); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [renameFileName, setRenameFileName] = useState(''); const [isDropHovering, setIsDropHovering] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadingFileName, setUploadingFileName] = useState(null); // Create Markdown modal state const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false); const [newMarkdownName, setNewMarkdownName] = useState(''); const [newMarkdownDescription, setNewMarkdownDescription] = useState(''); const [newMarkdownContent, setNewMarkdownContent] = useState(''); // Track files with generating descriptions (async) const [generatingDescriptions, setGeneratingDescriptions] = useState>(new Set()); // Edit description modal state const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false); const [editDescriptionValue, setEditDescriptionValue] = useState(''); const [editDescriptionFileName, setEditDescriptionFileName] = useState(''); // File input ref for import const fileInputRef = useRef(null); // Get images directory path const getImagesPath = useCallback(() => { if (!currentProject) return null; return `${currentProject.path}/.automaker/images`; }, [currentProject]); // Keyboard shortcuts for this view const contextShortcuts: KeyboardShortcut[] = useMemo( () => [ { key: shortcuts.addContextFile, action: () => setIsCreateMarkdownOpen(true), description: 'Create new markdown file', }, ], [shortcuts] ); useKeyboardShortcuts(contextShortcuts); // Get context directory path for user-added context files const getContextPath = useCallback(() => { if (!currentProject) return null; return `${currentProject.path}/.automaker/context`; }, [currentProject]); const isMarkdownFile = (filename: string): boolean => { const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')); return ext === '.md' || ext === '.markdown'; }; // Determine if a file is an image based on extension const isImageFile = (filename: string): boolean => { const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']; const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')); return imageExtensions.includes(ext); }; // Load context metadata const loadMetadata = useCallback(async (): Promise => { const contextPath = getContextPath(); if (!contextPath) return { files: {} }; try { const api = getElectronAPI(); const metadataPath = `${contextPath}/context-metadata.json`; const result = await api.readFile(metadataPath); if (result.success && result.content) { return JSON.parse(result.content); } } catch { // Metadata file doesn't exist yet } return { files: {} }; }, [getContextPath]); // Save context metadata const saveMetadata = useCallback( async (metadata: ContextMetadata) => { const contextPath = getContextPath(); if (!contextPath) return; try { const api = getElectronAPI(); const metadataPath = `${contextPath}/context-metadata.json`; await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); } catch (error) { console.error('Failed to save metadata:', error); } }, [getContextPath] ); // Load context files const loadContextFiles = useCallback(async () => { const contextPath = getContextPath(); if (!contextPath) return; setIsLoading(true); try { const api = getElectronAPI(); // Ensure context directory exists await api.mkdir(contextPath); // Load metadata for descriptions const metadata = await loadMetadata(); // Read directory contents const result = await api.readdir(contextPath); if (result.success && result.entries) { const files: ContextFile[] = result.entries .filter((entry) => entry.isFile && entry.name !== 'context-metadata.json') .map((entry) => ({ name: entry.name, type: isImageFile(entry.name) ? 'image' : 'text', path: `${contextPath}/${entry.name}`, description: metadata.files[entry.name]?.description, })); setContextFiles(files); } } catch (error) { console.error('Failed to load context files:', error); } finally { setIsLoading(false); } }, [getContextPath, loadMetadata]); useEffect(() => { loadContextFiles(); }, [loadContextFiles]); // Load selected file content const loadFileContent = useCallback(async (file: ContextFile) => { try { const api = getElectronAPI(); const result = await api.readFile(file.path); if (result.success && result.content !== undefined) { setEditedContent(result.content); setSelectedFile({ ...file, content: result.content }); setHasChanges(false); } } catch (error) { console.error('Failed to load file content:', error); } }, []); // Select a file const handleSelectFile = (file: ContextFile) => { if (hasChanges) { // Could add a confirmation dialog here } loadFileContent(file); setIsPreviewMode(isMarkdownFile(file.name)); }; // Save current file const saveFile = async () => { if (!selectedFile) return; setIsSaving(true); try { const api = getElectronAPI(); await api.writeFile(selectedFile.path, editedContent); setSelectedFile({ ...selectedFile, content: editedContent }); setHasChanges(false); } catch (error) { console.error('Failed to save file:', error); } finally { setIsSaving(false); } }; // Handle content change const handleContentChange = (value: string) => { setEditedContent(value); setHasChanges(true); }; // Generate description for a file const generateDescription = async ( filePath: string, fileName: string, isImage: boolean ): Promise => { try { const httpClient = getHttpApiClient(); const result = isImage ? await httpClient.context.describeImage(filePath) : await httpClient.context.describeFile(filePath); if (result.success && result.description) { return result.description; } const message = result.error || `Automaker couldn't generate a description for “${fileName}”.`; toast.error('Failed to generate description', { description: message }); } catch (error) { console.error('Failed to generate description:', error); const message = error instanceof Error ? error.message : 'An unexpected error occurred while generating the description.'; toast.error('Failed to generate description', { description: message }); } return undefined; }; // Generate description in background and update metadata const generateDescriptionAsync = useCallback( async (filePath: string, fileName: string, isImage: boolean) => { // Add to generating set setGeneratingDescriptions((prev) => new Set(prev).add(fileName)); try { const description = await generateDescription(filePath, fileName, isImage); if (description) { const metadata = await loadMetadata(); metadata.files[fileName] = { description }; await saveMetadata(metadata); // Reload files to update UI with new description await loadContextFiles(); // Also update selectedFile if it's the one that just got described setSelectedFile((current) => { if (current?.name === fileName) { return { ...current, description }; } return current; }); } } catch (error) { console.error('Failed to generate description:', error); } finally { // Remove from generating set setGeneratingDescriptions((prev) => { const next = new Set(prev); next.delete(fileName); return next; }); } }, [loadMetadata, saveMetadata, loadContextFiles] ); // Upload a file and generate description asynchronously const uploadFile = async (file: globalThis.File) => { const contextPath = getContextPath(); if (!contextPath) return; setIsUploading(true); setUploadingFileName(file.name); try { const api = getElectronAPI(); const isImage = isImageFile(file.name); let filePath: string; let fileName: string; let imagePathForDescription: string | undefined; if (isImage) { // For images: sanitize filename, store in .automaker/images fileName = sanitizeFilename(file.name); // Read file as base64 const dataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = (event) => resolve(event.target?.result as string); reader.readAsDataURL(file); }); // Extract base64 data without the data URL prefix const base64Data = dataUrl.split(',')[1] || dataUrl; // Determine mime type from original file const mimeType = file.type || 'image/png'; // Use saveImageToTemp to properly save as binary file in .automaker/images const saveResult = await api.saveImageToTemp?.( base64Data, fileName, mimeType, currentProject!.path ); if (!saveResult?.success || !saveResult.path) { throw new Error(saveResult?.error || 'Failed to save image'); } // The saved image path is used for description imagePathForDescription = saveResult.path; // Also save to context directory for display in the UI // (as a data URL for inline display) filePath = `${contextPath}/${fileName}`; await api.writeFile(filePath, dataUrl); } else { // For non-images: keep original behavior fileName = file.name; filePath = `${contextPath}/${fileName}`; const content = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = (event) => resolve(event.target?.result as string); reader.readAsText(file); }); await api.writeFile(filePath, content); } // Reload files immediately (file appears in list without description) await loadContextFiles(); // Start description generation in background (don't await) // For images, use the path in the images directory generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage); } catch (error) { console.error('Failed to upload file:', error); toast.error('Failed to upload file', { description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsUploading(false); setUploadingFileName(null); } }; // Handle file drop const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDropHovering(false); const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; // Process files sequentially for (const file of files) { await uploadFile(file); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDropHovering(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDropHovering(false); }; // Handle file import via button const handleImportClick = () => { fileInputRef.current?.click(); }; const handleFileInputChange = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; for (const file of Array.from(files)) { await uploadFile(file); } // Reset input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // Handle create markdown const handleCreateMarkdown = async () => { const contextPath = getContextPath(); if (!contextPath || !newMarkdownName.trim()) return; try { const api = getElectronAPI(); let filename = newMarkdownName.trim(); // Add .md extension if not provided if (!filename.includes('.')) { filename += '.md'; } const filePath = `${contextPath}/${filename}`; // Write markdown file await api.writeFile(filePath, newMarkdownContent); // Save description if provided if (newMarkdownDescription.trim()) { const metadata = await loadMetadata(); metadata.files[filename] = { description: newMarkdownDescription.trim() }; await saveMetadata(metadata); } // Reload files await loadContextFiles(); // Reset and close modal setIsCreateMarkdownOpen(false); setNewMarkdownName(''); setNewMarkdownDescription(''); setNewMarkdownContent(''); } catch (error) { console.error('Failed to create markdown:', error); } }; // Delete selected file const handleDeleteFile = async () => { if (!selectedFile) return; try { const api = getElectronAPI(); await api.deleteFile(selectedFile.path); // Remove from metadata const metadata = await loadMetadata(); delete metadata.files[selectedFile.name]; await saveMetadata(metadata); setIsDeleteDialogOpen(false); setSelectedFile(null); setEditedContent(''); setHasChanges(false); await loadContextFiles(); } catch (error) { console.error('Failed to delete file:', error); } }; // Rename selected file const handleRenameFile = async () => { const contextPath = getContextPath(); if (!selectedFile || !contextPath || !renameFileName.trim()) return; const newName = renameFileName.trim(); if (newName === selectedFile.name) { setIsRenameDialogOpen(false); return; } try { const api = getElectronAPI(); const newPath = `${contextPath}/${newName}`; // Check if file with new name already exists const exists = await api.exists(newPath); if (exists) { console.error('A file with this name already exists'); return; } // Read current file content const result = await api.readFile(selectedFile.path); if (!result.success || result.content === undefined) { console.error('Failed to read file for rename'); return; } // Write to new path await api.writeFile(newPath, result.content); // Delete old file await api.deleteFile(selectedFile.path); // Update metadata const metadata = await loadMetadata(); if (metadata.files[selectedFile.name]) { metadata.files[newName] = metadata.files[selectedFile.name]; delete metadata.files[selectedFile.name]; await saveMetadata(metadata); } setIsRenameDialogOpen(false); setRenameFileName(''); // Reload files and select the renamed file await loadContextFiles(); // Update selected file with new name and path const renamedFile: ContextFile = { name: newName, type: isImageFile(newName) ? 'image' : 'text', path: newPath, content: result.content, description: metadata.files[newName]?.description, }; setSelectedFile(renamedFile); } catch (error) { console.error('Failed to rename file:', error); } }; // Save edited description const handleSaveDescription = async () => { if (!editDescriptionFileName) return; try { const metadata = await loadMetadata(); metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() }; await saveMetadata(metadata); // Update selected file if it's the one being edited if (selectedFile?.name === editDescriptionFileName) { setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() }); } // Reload files to update list await loadContextFiles(); setIsEditDescriptionOpen(false); setEditDescriptionValue(''); setEditDescriptionFileName(''); } catch (error) { console.error('Failed to save description:', error); } }; // Open edit description dialog const handleEditDescription = (file: ContextFile) => { setEditDescriptionFileName(file.name); setEditDescriptionValue(file.description || ''); setIsEditDescriptionOpen(true); }; // Delete file from list (used by dropdown) const handleDeleteFromList = async (file: ContextFile) => { try { const api = getElectronAPI(); await api.deleteFile(file.path); // Remove from metadata const metadata = await loadMetadata(); delete metadata.files[file.name]; await saveMetadata(metadata); // Clear selection if this was the selected file if (selectedFile?.path === file.path) { setSelectedFile(null); setEditedContent(''); setHasChanges(false); } await loadContextFiles(); } catch (error) { console.error('Failed to delete file:', error); } }; if (!currentProject) { return (

No project selected

); } if (isLoading) { return (
); } return (
{/* Hidden file input for import */} {/* Header */}

Context Files

Add context files to include in AI prompts

setIsCreateMarkdownOpen(true)} hotkey={shortcuts.addContextFile} hotkeyActive={false} data-testid="create-markdown-button" > Create Markdown
{/* Main content area with file list and editor */}
{/* Drop overlay */} {isDropHovering && (
Drop files to upload Files will be analyzed automatically
)} {/* Uploading overlay */} {isUploading && (
Uploading {uploadingFileName}...
)} {/* Left Panel - File List */}

Context Files ({contextFiles.length})

{contextFiles.length === 0 ? (

No context files yet.
Drop files here or use the buttons above.

) : (
{contextFiles.map((file) => { const isGenerating = generatingDescriptions.has(file.name); return (
handleSelectFile(file)} className={cn( 'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors cursor-pointer', selectedFile?.path === file.path ? 'bg-primary/20 text-foreground border border-primary/30' : 'text-muted-foreground hover:bg-accent hover:text-foreground' )} data-testid={`context-file-${file.name}`} > {file.type === 'image' ? ( ) : ( )}
{file.name} {isGenerating ? ( Generating description... ) : file.description ? ( {file.description} ) : null}
{ setRenameFileName(file.name); setSelectedFile(file); setIsRenameDialogOpen(true); }} data-testid={`rename-context-file-${file.name}`} > Rename handleDeleteFromList(file)} className="text-red-500 focus:text-red-500" data-testid={`delete-context-file-${file.name}`} > Delete
); })}
)}
{/* Right Panel - Editor/Preview */}
{selectedFile ? ( <> {/* File toolbar */}
{selectedFile.type === 'image' ? ( ) : ( )} {selectedFile.name}
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && ( )} {selectedFile.type === 'text' && ( )}
{/* Description section */}
Description {generatingDescriptions.has(selectedFile.name) ? (
Generating description with AI...
) : selectedFile.description ? (

{selectedFile.description}

) : (

No description. Click edit to add one.

)}
{/* Content area */}
{selectedFile.type === 'image' ? (
{selectedFile.name}
) : isPreviewMode ? ( {editedContent} ) : (