diff --git a/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx b/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx index ab592757..f6b3130c 100644 --- a/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx +++ b/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useSyncExternalStore } from 'react'; +import { useState, useCallback, useSyncExternalStore, useRef, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; @@ -108,6 +108,25 @@ export function BottomDock({ className }: BottomDockProps) { const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null; const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0; + // Ref for click-outside detection + const dockRef = useRef(null); + + // Handle click outside to close the panel + useEffect(() => { + if (!isExpanded) return; + + const handleClickOutside = (event: MouseEvent) => { + if (dockRef.current && !dockRef.current.contains(event.target as Node)) { + setIsExpanded(false); + setIsMaximized(false); + } + }; + + // Use mousedown for more responsive feel + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isExpanded]); + const handleTabClick = useCallback( (tab: DockTab) => { if (activeTab === tab) { @@ -214,6 +233,7 @@ export function BottomDock({ className }: BottomDockProps) { return (
(null); const [fileContent, setFileContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); const [isDropHovering, setIsDropHovering] = useState(false); const [isUploading, setIsUploading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isPreviewMode, setIsPreviewMode] = useState(false); const [generatingDescriptions, setGeneratingDescriptions] = useState>(new Set()); const fileInputRef = useRef(null); + // Dialog states + const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false); + + // Dialog form values + const [newMarkdownName, setNewMarkdownName] = useState(''); + const [newMarkdownDescription, setNewMarkdownDescription] = useState(''); + const [newMarkdownContent, setNewMarkdownContent] = useState(''); + const [renameFileName, setRenameFileName] = useState(''); + const [editDescriptionValue, setEditDescriptionValue] = useState(''); + const [editDescriptionFileName, setEditDescriptionFileName] = useState(''); + + const hasChanges = fileContent !== originalContent; + // Helper functions const isImageFile = (filename: string): boolean => { const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']; @@ -37,6 +88,11 @@ export function ContextPanel() { return imageExtensions.includes(ext); }; + const isMarkdownFile = (filename: string): boolean => { + const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')); + return ext === '.md' || ext === '.markdown'; + }; + const getContextPath = useCallback(() => { if (!currentProject) return null; return `${currentProject.path}/.automaker/context`; @@ -116,24 +172,38 @@ export function ContextPanel() { }, [loadContextFiles]); const handleSelectFile = useCallback(async (file: ContextFile) => { - if (file.type === 'image') { - setSelectedFile(file); - setFileContent(''); - return; - } - try { const api = getElectronAPI(); const result = await api.readFile(file.path); - if (result.success && result.content) { + if (result.success && result.content !== undefined) { setSelectedFile(file); setFileContent(result.content); + setOriginalContent(result.content); + setIsPreviewMode(isMarkdownFile(file.name)); } } catch (error) { console.error('Error reading file:', error); } }, []); + // Save file content + const handleSaveFile = useCallback(async () => { + if (!selectedFile || !hasChanges) return; + + setIsSaving(true); + try { + const api = getElectronAPI(); + await api.writeFile(selectedFile.path, fileContent); + setOriginalContent(fileContent); + toast.success('File saved'); + } catch (error) { + console.error('Failed to save file:', error); + toast.error('Failed to save file'); + } finally { + setIsSaving(false); + } + }, [selectedFile, fileContent, hasChanges]); + // Generate description for a file const generateDescription = async ( filePath: string, @@ -299,6 +369,175 @@ export function ContextPanel() { } }; + // Create markdown file + const handleCreateMarkdown = async () => { + const contextPath = getContextPath(); + if (!contextPath || !newMarkdownName.trim()) return; + + try { + const api = getElectronAPI(); + let filename = newMarkdownName.trim(); + + if (!filename.includes('.')) { + filename += '.md'; + } + + const filePath = `${contextPath}/${filename}`; + await api.writeFile(filePath, newMarkdownContent); + + if (newMarkdownDescription.trim()) { + const metadata = await loadMetadata(); + metadata.files[filename] = { description: newMarkdownDescription.trim() }; + await saveMetadata(metadata); + } + + await loadContextFiles(); + setIsCreateMarkdownOpen(false); + setNewMarkdownName(''); + setNewMarkdownDescription(''); + setNewMarkdownContent(''); + toast.success('Markdown file created'); + } catch (error) { + console.error('Failed to create markdown:', error); + toast.error('Failed to create file'); + } + }; + + // Delete selected file + const handleDeleteFile = async () => { + if (!selectedFile) return; + + try { + const api = getElectronAPI(); + await api.deleteFile(selectedFile.path); + + const metadata = await loadMetadata(); + delete metadata.files[selectedFile.name]; + await saveMetadata(metadata); + + setIsDeleteDialogOpen(false); + setSelectedFile(null); + setFileContent(''); + setOriginalContent(''); + await loadContextFiles(); + toast.success('File deleted'); + } catch (error) { + console.error('Failed to delete file:', error); + toast.error('Failed to delete file'); + } + }; + + // 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}`; + + const exists = await api.exists(newPath); + if (exists) { + toast.error('A file with this name already exists'); + return; + } + + const result = await api.readFile(selectedFile.path); + if (!result.success || result.content === undefined) { + toast.error('Failed to read file for rename'); + return; + } + + await api.writeFile(newPath, result.content); + await api.deleteFile(selectedFile.path); + + 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(''); + await loadContextFiles(); + + const renamedFile: ContextFile = { + name: newName, + type: isImageFile(newName) ? 'image' : 'text', + path: newPath, + description: metadata.files[newName]?.description, + }; + setSelectedFile(renamedFile); + toast.success('File renamed'); + } catch (error) { + console.error('Failed to rename file:', error); + toast.error('Failed to rename file'); + } + }; + + // Save edited description + const handleSaveDescription = async () => { + if (!editDescriptionFileName) return; + + try { + const metadata = await loadMetadata(); + metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() }; + await saveMetadata(metadata); + + if (selectedFile?.name === editDescriptionFileName) { + setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() }); + } + + await loadContextFiles(); + setIsEditDescriptionOpen(false); + setEditDescriptionValue(''); + setEditDescriptionFileName(''); + toast.success('Description saved'); + } catch (error) { + console.error('Failed to save description:', error); + toast.error('Failed to save description'); + } + }; + + // Delete file from list (dropdown action) + const handleDeleteFromList = async (file: ContextFile) => { + try { + const api = getElectronAPI(); + await api.deleteFile(file.path); + + const metadata = await loadMetadata(); + delete metadata.files[file.name]; + await saveMetadata(metadata); + + if (selectedFile?.path === file.path) { + setSelectedFile(null); + setFileContent(''); + setOriginalContent(''); + } + + await loadContextFiles(); + toast.success('File deleted'); + } catch (error) { + console.error('Failed to delete file:', error); + toast.error('Failed to delete file'); + } + }; + + // Go back to file list + const handleBack = useCallback(() => { + setSelectedFile(null); + setFileContent(''); + setOriginalContent(''); + setIsPreviewMode(false); + }, []); + if (loading) { return (
@@ -309,7 +548,10 @@ export function ContextPanel() { return (
)} - {/* File List */} -
-
- Files - -
-
-
- {files.length === 0 ? ( -
- -

- No context files. -
- Drop files here or click + -

-
- ) : ( - files.map((file) => { - const isGenerating = generatingDescriptions.has(file.name); - return ( - - ); - }) - )} + {/* Single View: Either File List OR File Content */} + {!selectedFile ? ( + /* File List View */ + <> +
+ Context Files +
+ + +
-
-
- - {/* Content Preview */} -
-
- {selectedFile?.name || 'Select a file'} -
-
-
- {selectedFile ? ( - selectedFile.type === 'image' ? ( +
+ {files.length === 0 ? ( +
- -

- Image preview not available in panel + +

No context files

+

+ Drop files here or click + to add

- ) : ( -
-                  {fileContent || 'No content'}
-                
- ) +
) : ( -
- -

Select a file to preview

-

Or drop files to add them

+
+ {files.map((file) => { + const isGenerating = generatingDescriptions.has(file.name); + return ( +
handleSelectFile(file)} + > + {file.type === 'image' ? ( + + ) : ( + + )} +
+ {file.name} + {isGenerating ? ( + + + Generating description... + + ) : file.description ? ( + + {file.description} + + ) : null} +
+ + + + + + { + e.stopPropagation(); + setRenameFileName(file.name); + setSelectedFile(file); + setIsRenameDialogOpen(true); + }} + > + + Rename + + { + e.stopPropagation(); + setEditDescriptionFileName(file.name); + setEditDescriptionValue(file.description || ''); + setIsEditDescriptionOpen(true); + }} + > + + Edit Description + + { + e.stopPropagation(); + handleDeleteFromList(file); + }} + className="text-red-500 focus:text-red-500" + > + + Delete + + + +
+ ); + })}
)}
-
-
+ + ) : ( + /* File Content View */ + <> +
+
+ + {selectedFile.name} + {hasChanges && Unsaved} +
+
+ {selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && ( + + )} + {selectedFile.type === 'text' && hasChanges && ( + + )} + +
+
+ + {/* Description section */} +
+
+
+
+ + Description + + {generatingDescriptions.has(selectedFile.name) ? ( +
+ + Generating... +
+ ) : selectedFile.description ? ( +

{selectedFile.description}

+ ) : ( +

No description

+ )} +
+ +
+
+
+ + {/* Content area */} +
+ {selectedFile.type === 'image' ? ( +
+ {selectedFile.name} +
+ ) : isPreviewMode && isMarkdownFile(selectedFile.name) ? ( + + {fileContent} + + ) : ( +