diff --git a/src/main/preload.ts b/src/main/preload.ts index 075f160b5..a6c5dd99a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -319,6 +319,16 @@ contextBridge.exposeInMainWorld('electronAPI', { relPath: string, remote?: { connectionId: string; remotePath: string } ) => ipcRenderer.invoke('fs:remove', { root, relPath, ...remote }), + fsRename: ( + root: string, + oldName: string, + newName: string, + remote?: { connectionId: string; remotePath: string } + ) => ipcRenderer.invoke('fs:rename', { root, oldName, newName, ...remote }), + fsMkdir: (root: string, relPath: string, remote?: { connectionId: string; remotePath: string }) => + ipcRenderer.invoke('fs:mkdir', { root, relPath, ...remote }), + fsRmdir: (root: string, relPath: string, remote?: { connectionId: string; remotePath: string }) => + ipcRenderer.invoke('fs:rmdir', { root, relPath, ...remote }), getProjectConfig: (projectPath: string) => ipcRenderer.invoke('fs:getProjectConfig', { projectPath }), saveProjectConfig: (projectPath: string, content: string) => diff --git a/src/main/services/fs/LocalFileSystem.ts b/src/main/services/fs/LocalFileSystem.ts index 578dced44..a252383bb 100644 --- a/src/main/services/fs/LocalFileSystem.ts +++ b/src/main/services/fs/LocalFileSystem.ts @@ -594,4 +594,45 @@ export class LocalFileSystem implements IFileSystem { return { success: false, error: err.message }; } } + + /** + * Rename a file or directory + */ + async rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> { + try { + const fullOldPath = this.resolvePath(oldPath); + const fullNewPath = this.resolvePath(newPath); + + try { + await fs.stat(fullOldPath); + } catch { + return { success: false, error: 'Source does not exist' }; + } + + try { + await fs.stat(fullNewPath); + return { success: false, error: 'Destination already exists' }; + } catch { + // Destination doesn't exist - good + } + + await fs.rename(fullOldPath, fullNewPath); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; + } + } + + /** + * Create a directory + */ + async mkdir(dirPath: string): Promise<{ success: boolean; error?: string }> { + try { + const fullPath = this.resolvePath(dirPath); + await fs.mkdir(fullPath, { recursive: true }); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; + } + } } diff --git a/src/main/services/fs/RemoteFileSystem.ts b/src/main/services/fs/RemoteFileSystem.ts index 1f0535948..acdbdeef1 100644 --- a/src/main/services/fs/RemoteFileSystem.ts +++ b/src/main/services/fs/RemoteFileSystem.ts @@ -549,6 +549,41 @@ export class RemoteFileSystem implements IFileSystem { return { success: false, error: message }; } } + /** + * Rename a file or directory + */ + async rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> { + try { + const sftp = await this.sshService.getSftp(this.connectionId); + const fullOldPath = this.resolveRemotePath(oldPath); + const fullNewPath = this.resolveRemotePath(newPath); + return new Promise((resolve) => { + sftp.rename(fullOldPath, fullNewPath, (err) => { + if (err) { + resolve({ success: false, error: err.message }); + } else { + resolve({ success: true }); + } + }); + }); + } catch (error) { + return { success: false, error: String(error) }; + } + } + /** + * Create a directory + */ + async mkdir(dirPath: string): Promise<{ success: boolean; error?: string }> { + try { + const sftp = await this.sshService.getSftp(this.connectionId); + const fullPath = this.resolveRemotePath(dirPath); + + await this.ensureRemoteDir(sftp, fullPath); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + } /** * Read image file as base64 data URL via SFTP diff --git a/src/main/services/fs/types.ts b/src/main/services/fs/types.ts index a34047fe2..b9602b9b4 100644 --- a/src/main/services/fs/types.ts +++ b/src/main/services/fs/types.ts @@ -183,6 +183,14 @@ export interface IFileSystem { */ remove?(path: string): Promise<{ success: boolean; error?: string }>; + /** + * Rename a file or directory + */ + rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }>; + /** + * Create a directory + */ + mkdir(path: string): Promise<{ success: boolean; error?: string }>; /** * Read image file as base64 data URL * @param path - Image file path relative to project root diff --git a/src/main/services/fsIpc.ts b/src/main/services/fsIpc.ts index 71b26107c..9259e0e7d 100644 --- a/src/main/services/fsIpc.ts +++ b/src/main/services/fsIpc.ts @@ -879,4 +879,110 @@ export function registerFsIpc(): void { } } ); + + // rename a file or directory + + ipcMain.handle( + 'fs:rename', + async (_event, args: { root: string; oldName: string; newName: string } & RemoteParams) => { + try { + if (isRemoteRequest(args)) { + try { + const rfs = createRemoteFs(args); + const oldPath = path.posix.join(args.remotePath, args.oldName); + const newPath = path.posix.join(args.remotePath, args.newName); + return await rfs.rename(oldPath, newPath); + } catch (error) { + console.error('fs:rename failed:', error); + return { success: false, error: 'Failed to rename file or directory' }; + } + } + + // local path + + const { root, oldName, newName } = args; + if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' }; + if (!oldName || !newName) return { success: false, error: 'Invalid file names' }; + const oldAbs = path.resolve(root, oldName); + const newAbs = path.resolve(root, newName); + const normRoot = path.resolve(root) + path.sep; + if (!oldAbs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' }; + if (!newAbs.startsWith(normRoot)) return { success: false, error: 'New path escapes root' }; + if (!fs.existsSync(oldAbs)) return { success: false, error: 'Source does not exist' }; + if (fs.existsSync(newAbs)) return { success: false, error: 'Destination already exists' }; + fs.renameSync(oldAbs, newAbs); + return { success: true }; + } catch (error) { + console.error('fs:rename failed:', error); + return { success: false, error: 'Failed to rename file or directory' }; + } + } + ); + + // Create a directory + ipcMain.handle( + 'fs:mkdir', + async (_event, args: { root: string; relPath: string } & RemoteParams) => { + try { + // --- Remote path --- + if (isRemoteRequest(args)) { + try { + const rfs = createRemoteFs(args); + const targetPath = path.posix.join(args.remotePath, args.relPath); + return await rfs.mkdir(targetPath); + } catch (error) { + console.error('fs:mkdir remote failed:', error); + return { success: false, error: 'Failed to create remote directory' }; + } + } + // --- Local path --- + const { root, relPath } = args; + if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' }; + if (!relPath) return { success: false, error: 'Invalid path' }; + const abs = path.resolve(root, relPath); + const normRoot = path.resolve(root) + path.sep; + if (!abs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' }; + fs.mkdirSync(abs, { recursive: true }); + return { success: true }; + } catch (error) { + console.error('fs:mkdir failed:', error); + return { success: false, error: 'Failed to create directory' }; + } + } + ); + + // Remove a directory (recursive) + ipcMain.handle( + 'fs:rmdir', + async (_event, args: { root: string; relPath: string } & RemoteParams) => { + try { + // --- Remote path --- + if (isRemoteRequest(args)) { + try { + const rfs = createRemoteFs(args); + const targetPath = path.posix.join(args.remotePath, args.relPath); + return await rfs.remove(targetPath); + } catch (error) { + console.error('fs:rmdir remote failed:', error); + return { success: false, error: 'Failed to remove remote directory' }; + } + } + // --- Local path --- + const { root, relPath } = args; + if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' }; + if (!relPath) return { success: false, error: 'Invalid path' }; + const abs = path.resolve(root, relPath); + const normRoot = path.resolve(root) + path.sep; + if (!abs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' }; + if (!fs.existsSync(abs)) return { success: true }; + const st = safeStat(abs); + if (!st || !st.isDirectory()) return { success: false, error: 'Not a directory' }; + fs.rmSync(abs, { recursive: true, force: true }); + return { success: true }; + } catch (error) { + console.error('fs:rmdir failed:', error); + return { success: false, error: 'Failed to remove directory' }; + } + } + ); } diff --git a/src/renderer/components/EditorMode.tsx b/src/renderer/components/EditorMode.tsx index f0ef601e0..9fed79831 100644 --- a/src/renderer/components/EditorMode.tsx +++ b/src/renderer/components/EditorMode.tsx @@ -195,10 +195,10 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr const result = await window.electronAPI.fsList(taskPath, { includeDirs: true }); if (result.success && result.items) { - // Filter using whitelist approach - const filteredItems = showIgnoredFiles - ? result.items - : result.items.filter((item) => shouldIncludeFile(item.path)); + // Filter out null/invalid items and apply whitelist approach + const filteredItems = result.items + .filter((item) => item && item.type) + .filter((item) => showIgnoredFiles || shouldIncludeFile(item.path)); // Sort items: directories first, then files, both alphabetically const sortedItems = filteredItems.sort((a, b) => { @@ -329,10 +329,10 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr const result = await window.electronAPI.fsList(fullPath, { includeDirs: true }); if (result.success && result.items) { - // Filter using whitelist approach - const filteredItems = showIgnoredFiles - ? result.items - : result.items.filter((item) => shouldIncludeFile(item.path)); + // Filter out null/invalid items and apply whitelist approach + const filteredItems = result.items + .filter((item) => item && item.type) + .filter((item) => showIgnoredFiles || shouldIncludeFile(item.path)); // Sort items: directories first, then files, both alphabetically const sortedItems = filteredItems.sort((a, b) => { @@ -412,7 +412,8 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr }; // Render file tree recursively - const renderFileTree = (node: FileNode, level: number = 0) => { + const renderFileTree = (node: FileNode | null, level: number = 0) => { + if (!node) return null; const isExpanded = expandedDirs.has(node.path); const isSelected = selectedFile === node.path; @@ -449,7 +450,11 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr {node.name} {node.type === 'directory' && isExpanded && node.children && ( -
{node.children.map((child) => renderFileTree(child, level + 1))}
+
+ {node.children + .filter((child) => child) + .map((child) => renderFileTree(child, level + 1))} +
)} ); diff --git a/src/renderer/components/FileExplorer/FileExplorerToolbar.tsx b/src/renderer/components/FileExplorer/FileExplorerToolbar.tsx new file mode 100644 index 000000000..0913539a0 --- /dev/null +++ b/src/renderer/components/FileExplorer/FileExplorerToolbar.tsx @@ -0,0 +1,52 @@ +import { ChevronDown, CopyMinus, FilePlus, FolderPlus, Search } from 'lucide-react'; +import { Button } from '../ui/button'; + +interface FileExplorerToolbarProps { + projectName: string; + onSearch: () => void; + onNewFile: () => void; + onNewFolder: () => void; + onCollapse: () => void; +} + +export const FileExplorerToolbar: React.FC = ({ + projectName, + onSearch, + onNewFile, + onNewFolder, + onCollapse, +}) => { + return ( +
+
+ {/* search button */} + + + + {/* file actions */} + +
+ {/* new file */} + + + {/* new folder */} + + + + {/* collapsable */} + + +
+
+
+ ); +}; diff --git a/src/renderer/components/FileExplorer/FileSearchModal.tsx b/src/renderer/components/FileExplorer/FileSearchModal.tsx new file mode 100644 index 000000000..f420ba174 --- /dev/null +++ b/src/renderer/components/FileExplorer/FileSearchModal.tsx @@ -0,0 +1,130 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Search, File, X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +interface FileSearchModalProps { + isOpen: boolean; + onClose: () => void; + onSelectFile: (filePath: string) => void; + rootPath: string; + connectionId?: string | null; + remotePath?: string | null; +} +export const FileSearchModal: React.FC = ({ + isOpen, + onClose, + onSelectFile, + rootPath, + connectionId, + remotePath, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [files, setFiles] = useState>([]); + const [selectedIndex, setSelectedIndex] = useState(0); + // Fetch files when modal opens + useEffect(() => { + if (!isOpen) return; + + const fetchFiles = async () => { + const opts: any = { includeDirs: true }; + if (connectionId && remotePath) { + opts.connectionId = connectionId; + opts.remotePath = remotePath; + } + + const result = await window.electronAPI.fsList(rootPath, opts); + if (result.success && result.items) { + const fileList = result.items + .filter((item: any) => item && item.path && item.type) + .map((item: any) => ({ + name: item.path.split('/').pop() || item.path, + path: item.path, + type: item.type, + })); + setFiles(fileList); + } + }; + + fetchFiles(); + }, [isOpen, rootPath, connectionId, remotePath]); + // Filter files based on search query (fuzzy match) + const filteredFiles = searchQuery + ? files.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase())) + : files.slice(0, 50); // Show first 50 if no search + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filteredFiles.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filteredFiles[selectedIndex]) { + onSelectFile(filteredFiles[selectedIndex].path); + onClose(); + } + } else if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, filteredFiles, selectedIndex, onClose, onSelectFile]); + if (!isOpen) return null; + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Search Input */} +
+ + { + setSearchQuery(e.target.value); + setSelectedIndex(0); + }} + placeholder="Search files..." + className="border-0 focus-visible:ring-0" + autoFocus + /> + +
+ + {/* Results */} +
+ {filteredFiles.length === 0 ? ( +
No files found
+ ) : ( + filteredFiles.map((file, index) => ( +
{ + onSelectFile(file.path); + onClose(); + }} + className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${ + index === selectedIndex ? 'bg-accent' : 'hover:bg-accent' + }`} + > + + {file.name} + {file.path} +
+ )) + )} +
+
+
+ ); +}; diff --git a/src/renderer/components/FileExplorer/FileTree.tsx b/src/renderer/components/FileExplorer/FileTree.tsx index 5b5b0dea4..1448b6acd 100644 --- a/src/renderer/components/FileExplorer/FileTree.tsx +++ b/src/renderer/components/FileExplorer/FileTree.tsx @@ -7,6 +7,39 @@ import { SearchInput } from './SearchInput'; import { ContentSearchResults } from './ContentSearchResults'; import { getEditorState, saveEditorState } from '@/lib/editorStateStorage'; import type { FileChange } from '@/hooks/useFileChanges'; +import { FileExplorerToolbar } from './FileExplorerToolbar'; +import { FileSearchModal } from './FileSearchModal'; +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, +} from '@/components/ui/context-menu'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from '@/components/ui/alert-dialog'; +import { Input } from '@/components/ui/input'; +// Browser-compatible path utilities (renderer can't use Node's 'path' module) +const pathUtils = { + dirname: (p: string) => p.substring(0, p.lastIndexOf('/')) || '.', + join: (...parts: string[]) => parts.filter(Boolean).join('/').replace(/\/+/g, '/'), + relative: (from: string, to: string) => { + const fromParts = from.split('/').filter(Boolean); + const toParts = to.split('/').filter(Boolean); + let i = 0; + while (i < fromParts.length && i < toParts.length && fromParts[i] === toParts[i]) i++; + const up = fromParts.slice(i).map(() => '..'); + return [...up, ...toParts.slice(i)].join('/') || '.'; + }, +}; export interface FileNode { id: string; @@ -40,6 +73,7 @@ function findNode(nodes: FileNode[], path: string): FileNode | null { interface FileTreeProps { taskId: string; rootPath: string; + projectName?: string; selectedFile?: string | null; onSelectFile: (path: string) => void; onOpenFile?: (path: string) => void; @@ -56,12 +90,22 @@ const TreeNode: React.FC<{ node: FileNode; level: number; selectedPath?: string | null; + onSelectNode?: (node: FileNode) => void; expandedPaths: Set; onToggleExpand: (path: string) => void; onSelect: (path: string) => void; onOpen?: (path: string) => void; onLoadChildren: (node: FileNode) => Promise; fileChanges: FileChange[]; + + onContextMenuNewFile?: (node: FileNode) => void; + onContextMenuNewFolder?: (node: FileNode) => void; + onContextMenuRename?: (node: FileNode) => void; + onContextMenuDelete?: (node: FileNode) => void; + onContextMenuCopyPath?: (node: FileNode) => void; + onContextMenuCopyRelPath?: (node: FileNode) => void; + onContextMenuOpenTerminal?: (node: FileNode) => void; + onContextMenuReveal?: (node: FileNode) => void; }> = ({ node, level, @@ -72,7 +116,21 @@ const TreeNode: React.FC<{ onOpen, onLoadChildren, fileChanges, + onSelectNode, + onContextMenuNewFile, + onContextMenuNewFolder, + onContextMenuRename, + onContextMenuDelete, + onContextMenuCopyPath, + onContextMenuCopyRelPath, + onContextMenuOpenTerminal, + onContextMenuReveal, }) => { + // Guard: if node is null or missing type, don't render + if (!node || !node.type) { + return null; + } + const isExpanded = expandedPaths.has(node.path); const isSelected = selectedPath === node.path; @@ -81,6 +139,10 @@ const TreeNode: React.FC<{ const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); + + if (onSelectNode) { + onSelectNode(node); + } if (node.type === 'directory') { // If not expanded and not loaded, load children if (!isExpanded && !node.isLoaded) { @@ -100,72 +162,119 @@ const TreeNode: React.FC<{ }; return ( -
-
- {node.type === 'directory' && ( - - {isExpanded ? ( - - ) : ( - + + +
+
+ {node.type === 'directory' && ( + + {isExpanded ? ( + + ) : ( + + )} + )} - + {node.type === 'file' && ( + + + + )} + + {node.name} + +
+ + {node.type === 'directory' && isExpanded && node.children && node.children.length > 0 && ( +
+ {node.children + .filter((child) => child && child.id && child.type) + .map((child) => ( + + ))} +
+ )} +
+
+ + {node.type === 'directory' && ( + <> + onContextMenuNewFile?.(node)}> + New File + + onContextMenuNewFolder?.(node)}> + New Folder + + + )} + onContextMenuRename?.(node)}>Rename + onContextMenuDelete?.(node)}>Delete + + onContextMenuCopyPath?.(node)}>Copy Path + onContextMenuCopyRelPath?.(node)}> + Copy Relative Path + {node.type === 'file' && ( - - - + <> + + onContextMenuOpenTerminal?.(node)}> + Open in Terminal + + onContextMenuReveal?.(node)}> + Reveal in Finder + + )} - - {node.name} - -
- - {node.type === 'directory' && isExpanded && node.children && ( -
- {node.children.map((child) => ( - - ))} -
- )} -
+ + ); }; export const FileTree: React.FC = ({ taskId, rootPath, + projectName, selectedFile, onSelectFile, onOpenFile, @@ -186,6 +295,17 @@ export const FileTree: React.FC = ({ const [allFiles, setAllFiles] = useState([]); const restoringRef = useRef(true); const loadingPathsRef = useRef(new Set()); + const [isFileSearchOpen, setIsFileSearchOpen] = useState(false); + + // Context menu state + const [selectedNode, setSelectedNode] = useState(null); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isNewFileDialogOpen, setIsNewFileDialogOpen] = useState(false); + const [isNewFolderDialogOpen, setIsNewFolderDialogOpen] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const [newItemValue, setNewItemValue] = useState(''); + const [renamingNode, setRenamingNode] = useState(null); // Use the clean content search hook const { @@ -244,6 +364,14 @@ export const FileTree: React.FC = ({ [defaultExcludePatterns, excludePatterns] ); + const remoteArgs = useMemo( + () => + connectionId && remotePath + ? { connectionId: connectionId, remotePath: remotePath } + : undefined, + [connectionId, remotePath] + ); + // Check if an item should be excluded const shouldExclude = useCallback( (path: string): boolean => { @@ -275,6 +403,9 @@ export const FileTree: React.FC = ({ const immediateChildren = new Map(); files.forEach((item) => { + if (!item || !item.path || !item.type) { + return; + } // Skip excluded items if (shouldExclude(item.path)) { return; @@ -327,6 +458,10 @@ export const FileTree: React.FC = ({ // Convert map to array of FileNodes const nodes: FileNode[] = []; immediateChildren.forEach((itemInfo, itemName) => { + // Guard: skip if itemInfo is null + if (!itemInfo || !itemInfo.type) { + return; + } const nodePath = dirPath ? `${dirPath}/${itemName}` : itemName; nodes.push({ id: nodePath, @@ -357,47 +492,80 @@ export const FileTree: React.FC = ({ ); // Load all files once at the beginning - only when rootPath changes - useEffect(() => { - const loadAllFiles = async () => { - setLoading(true); - setError(null); + const loadAllFiles = useCallback(async () => { + setLoading(true); + setError(null); - try { - const opts: { - includeDirs: boolean; - connectionId?: string; - remotePath?: string; - } = { includeDirs: true }; + try { + const opts: { + includeDirs: boolean; + connectionId?: string; + remotePath?: string; + } = { includeDirs: true }; - if (connectionId && remotePath) { - opts.connectionId = connectionId; - opts.remotePath = remotePath; - } + if (connectionId && remotePath) { + opts.connectionId = connectionId; + opts.remotePath = remotePath; + } - const result = await window.electronAPI.fsList(rootPath, { - ...opts, - recursive: false, - }); + const result = await window.electronAPI.fsList(rootPath, { + ...opts, + recursive: false, + }); - if (result.canceled) { - return; - } + if (result.canceled) { + return; + } + + if (!result.success || !result.items) { + throw new Error(result.error || 'Failed to load files'); + } + + // Store all files for later use (filter out null/undefined items) + const validItems = result.items.filter((item: any) => item && item.path && item.type); + setAllFiles(validItems); - if (!result.success || !result.items) { - throw new Error(result.error || 'Failed to load files'); + // After files are loaded, reload children for all expanded folders + const reloadExpandedFolders = async () => { + const expandedArray = Array.from(expandedPaths); + + for (const path of expandedArray) { + // Find the node in the new file list + const item = validItems.find((f: any) => f.path === path); + if (item) { + // Construct a proper FileNode with required properties + const node: FileNode = { + id: item.path, + name: item.path.split('/').pop() || item.path, + path: item.path, + type: item.type === 'dir' ? 'directory' : item.type, + }; + await loadChildren(node); + } } + }; + // Trigger reload of expanded folders + setTimeout(reloadExpandedFolders, 0); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load files'); + } finally { + setLoading(false); + } + }, [rootPath, connectionId, remotePath]); // Reload when rootPath or remote info changes - // Store all files for later use - setAllFiles(result.items); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load files'); - } finally { - setLoading(false); - } + useEffect(() => { + void loadAllFiles(); + }, [loadAllFiles]); + + // Listen for file search shortcut event + useEffect(() => { + const handleOpenFileSearch = () => { + setIsFileSearchOpen(true); }; - loadAllFiles(); - }, [rootPath, connectionId, remotePath]); // Reload when rootPath or remote info changes + window.addEventListener('emdash:openFileSearch', handleOpenFileSearch); + return () => window.removeEventListener('emdash:openFileSearch', handleOpenFileSearch); + }, []); // Build tree when files or filters change useEffect(() => { @@ -436,6 +604,162 @@ export const FileTree: React.FC = ({ }); }, [allFiles, buildNodesFromPath]); // Rebuild tree when files or filter function changes + // Context menu handlers + const getParentPath = (node: FileNode): string => { + const dir = pathUtils.dirname(node.path); + return dir === '.' ? '' : dir; + }; + + const getTargetDirectoryPath = (node: FileNode): string => { + return node.type === 'directory' ? node.path : getParentPath(node); + }; + + const handleCopyPath = async (node: FileNode) => { + const absPath = pathUtils.join(rootPath, node.path); + await window.electronAPI.clipboardWriteText(absPath); + }; + + const handleCopyRelativePath = async (node: FileNode) => { + await window.electronAPI.clipboardWriteText(node.path); + }; + + const handleOpenTerminal = async (node: FileNode) => { + const dirPath = + node.type === 'directory' + ? pathUtils.join(rootPath, node.path) + : pathUtils.join(rootPath, pathUtils.dirname(node.path)); + await window.electronAPI.openIn({ app: 'terminal', path: dirPath }); + }; + + const handleRevealInFinder = async (node: FileNode) => { + const filePath = pathUtils.join(rootPath, node.path); + await window.electronAPI.openIn({ app: 'finder', path: filePath }); + }; + + const handleRenameClick = (node: FileNode) => { + setRenamingNode(node); + setRenameValue(node.name); + setIsRenameDialogOpen(true); + }; + + const handleDeleteClick = (node: FileNode) => { + setSelectedNode(node); + setIsDeleteDialogOpen(true); + }; + + const handleNewFileClick = (node: FileNode) => { + setSelectedNode(node); + setNewItemValue(''); + setIsNewFileDialogOpen(true); + }; + + const handleNewFolderClick = (node: FileNode) => { + setSelectedNode(node); + setNewItemValue(''); + setIsNewFolderDialogOpen(true); + }; + + // file collapable + const handleCollapseAll = () => { + setExpandedPaths(new Set()); + }; + + const handleFileSearch = useCallback(() => { + setIsFileSearchOpen(true); + }, []); + const handleToolbarSearch = () => { + // Focus the search input + const handleToolbarSearch = () => { + // Detect platform + const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + + // Create keyboard event for Shift+Cmd+P (Mac) or Ctrl+Shift+P (Windows) + const key = isMac ? 'p' : 'p'; + const event = new KeyboardEvent('keydown', { + key: key, + metaKey: isMac, + ctrlKey: !isMac, + shiftKey: true, + bubbles: true, + }); + + // Dispatch the event to trigger CommandPalette + document.dispatchEvent(event); + }; + }; + + const confirmRename = async () => { + if (!renamingNode || !renameValue.trim() || renameValue === renamingNode.name) { + setIsRenameDialogOpen(false); + return; + } + const parentPath = getParentPath(renamingNode); + const oldRelPath = renamingNode.path; + const newRelPath = parentPath ? `${parentPath}/${renameValue.trim()}` : renameValue.trim(); + const result = await window.electronAPI.fsRename(rootPath, oldRelPath, newRelPath, remoteArgs); + if (!result.success) { + setError(result.error ?? 'Failed to rename file or directory'); + return; + } + await loadAllFiles(); + setIsRenameDialogOpen(false); + setRenamingNode(null); + }; + + const confirmDelete = async () => { + if (!selectedNode) return; + if (selectedNode.type === 'directory') { + const result = await window.electronAPI.fsRmdir(rootPath, selectedNode.path, remoteArgs); + if (!result.success) { + setError(result.error ?? 'Failed to remove directory'); + return; + } + } else { + const result = await window.electronAPI.fsRemove(rootPath, selectedNode.path, remoteArgs); + if (!result.success) { + setError(result.error ?? 'Failed to remove file'); + return; + } + } + await loadAllFiles(); + setIsDeleteDialogOpen(false); + setSelectedNode(null); + }; + + const confirmNewFile = async () => { + if (!newItemValue.trim()) { + setIsNewFileDialogOpen(false); + return; + } + const parentPath = selectedNode ? getTargetDirectoryPath(selectedNode) : ''; + const relPath = parentPath ? `${parentPath}/${newItemValue.trim()}` : newItemValue.trim(); + const result = await window.electronAPI.fsWriteFile(rootPath, relPath, '', true, remoteArgs); + if (!result.success) { + setError(result.error ?? 'Failed to create file'); + return; + } + await loadAllFiles(); + setIsNewFileDialogOpen(false); + setSelectedNode(null); + }; + + const confirmNewFolder = async () => { + if (!newItemValue.trim()) { + setIsNewFolderDialogOpen(false); + return; + } + const parentPath = selectedNode ? getTargetDirectoryPath(selectedNode) : ''; + const relPath = parentPath ? `${parentPath}/${newItemValue.trim()}` : newItemValue.trim(); + const result = await window.electronAPI.fsMkdir(rootPath, relPath, remoteArgs); + if (!result.success) { + setError(result.error ?? 'Failed to create directory'); + return; + } + await loadAllFiles(); + setIsNewFolderDialogOpen(false); + setSelectedNode(null); + }; + // Load children for a node const loadChildren = useCallback( async (node: FileNode) => { @@ -465,10 +789,13 @@ export const FileTree: React.FC = ({ // Process new items: // 1. Prefix their paths so they are relative to project root // 2. Add them to allFiles - const newItems = result.items.map((item: any) => ({ - ...item, - path: `${node.path}/${item.path}`, // item.path from fsList is relative to subRoot - })); + // Guard: filter out null/undefined items + const newItems = result.items + .filter((item: any) => item && item.path && item.type) + .map((item: any) => ({ + ...item, + path: `${node.path}/${item.path}`, // item.path from fsList is relative to subRoot + })); setAllFiles((prev) => { // Remove any existing children of this node to avoid duplicates (optional but good) @@ -582,6 +909,20 @@ export const FileTree: React.FC = ({ />
+ { + setNewItemValue(''); + setIsNewFileDialogOpen(true); + }} + onNewFolder={() => { + setNewItemValue(''); + setIsNewFolderDialogOpen(true); + }} + onCollapse={handleCollapseAll} + /> +
{searchQuery ? ( // Search results view @@ -596,23 +937,141 @@ export const FileTree: React.FC = ({ ) : ( // File tree view
- {tree.map((child) => ( - - ))} + {tree + .filter((child) => child && child.id && child.type) + .map((child) => ( + setSelectedNode(node)} + onContextMenuNewFile={handleNewFileClick} + onContextMenuNewFolder={handleNewFolderClick} + onContextMenuRename={handleRenameClick} + onContextMenuDelete={handleDeleteClick} + onContextMenuCopyPath={handleCopyPath} + onContextMenuCopyRelPath={handleCopyRelativePath} + onContextMenuOpenTerminal={handleOpenTerminal} + onContextMenuReveal={handleRevealInFinder} + /> + ))}
)}
+ + {/* Rename Dialog */} + + + + Rename + + Enter a new name for "{renamingNode?.name}" + + + setRenameValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && confirmRename()} + autoFocus + /> + + Cancel + Rename + + + + + {/* Delete Dialog */} + + + + + Delete {selectedNode?.type === 'directory' ? 'Folder' : 'File'}? + + + Are you sure you want to delete "{selectedNode?.name}"? + {selectedNode?.type === 'directory' && ' This will delete all contents.'} + + + + Cancel + + Delete + + + + + + {/* New File Dialog */} + + + + New File + + Create a new file in " + {(selectedNode ? getTargetDirectoryPath(selectedNode) : '') || 'root'}" + + + setNewItemValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && confirmNewFile()} + placeholder="filename.ext" + autoFocus + /> + + Cancel + Create + + + + + {/* New Folder Dialog */} + + + + New Folder + + Create a new folder in " + {(selectedNode ? getTargetDirectoryPath(selectedNode) : '') || 'root'}" + + + setNewItemValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && confirmNewFolder()} + placeholder="folder name" + autoFocus + /> + + Cancel + Create + + + + + {/* File Search Modal */} + setIsFileSearchOpen(false)} + onSelectFile={(filePath) => { + onSelectFile(filePath); + if (onOpenFile) onOpenFile(filePath); + }} + rootPath={rootPath} + connectionId={connectionId} + remotePath={remotePath} + /> ); }; diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 8a3f22f17..88cd54e0a 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -22,7 +22,8 @@ export type ShortcutSettingsKey = | 'newTask' | 'nextAgent' | 'prevAgent' - | 'openInEditor'; + | 'openInEditor' + | 'fileSearch'; export interface AppShortcut { key: string; @@ -106,6 +107,14 @@ export const APP_SHORTCUTS: Record = { category: 'Navigation', settingsKey: 'commandPalette', }, + FILE_SEARCH: { + key: 'p', + modifier: 'cmd+shift', + label: 'File Search', + description: 'Search files in the current project', + category: 'Navigation', + settingsKey: 'fileSearch', + }, SETTINGS: { key: ',', @@ -374,6 +383,7 @@ export function useKeyboardShortcuts(handlers: GlobalShortcutHandlers) { const custom = handlers.customKeyboardSettings; return { commandPalette: getEffectiveConfig(APP_SHORTCUTS.COMMAND_PALETTE, custom), + fileSearch: getEffectiveConfig(APP_SHORTCUTS.FILE_SEARCH, custom), settings: getEffectiveConfig(APP_SHORTCUTS.SETTINGS, custom), toggleLeftSidebar: getEffectiveConfig(APP_SHORTCUTS.TOGGLE_LEFT_SIDEBAR, custom), toggleRightSidebar: getEffectiveConfig(APP_SHORTCUTS.TOGGLE_RIGHT_SIDEBAR, custom), @@ -480,6 +490,14 @@ export function useKeyboardShortcuts(handlers: GlobalShortcutHandlers) { priority: 'global', requiresClosed: true, }, + { + config: effectiveShortcuts.fileSearch, + handler: () => { + window.dispatchEvent(new CustomEvent('emdash:openFileSearch')); + }, + priority: 'global', + isCommandPalette: true, + }, ]; const handleKeyDown = (event: KeyboardEvent) => { diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 5f0d888ec..d4a1da2e4 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -777,6 +777,24 @@ declare global { relPath: string, remote?: { connectionId: string; remotePath: string } ) => Promise<{ success: boolean; error?: string }>; + + fsRename: ( + root: string, + oldName: string, + newName: string, + remote?: { connectionId: string; remotePath: string } + ) => Promise<{ success: boolean; error?: string }>; + fsMkdir: ( + root: string, + relPath: string, + remote?: { connectionId: string; remotePath: string } + ) => Promise<{ success: boolean; error?: string }>; + fsRmdir: ( + root: string, + relPath: string, + remote?: { connectionId: string; remotePath: string } + ) => Promise<{ success: boolean; error?: string }>; + getProjectConfig: ( projectPath: string ) => Promise<{ success: boolean; path?: string; content?: string; error?: string }>; diff --git a/src/renderer/types/shortcuts.ts b/src/renderer/types/shortcuts.ts index 2fb51bcaa..80410618b 100644 --- a/src/renderer/types/shortcuts.ts +++ b/src/renderer/types/shortcuts.ts @@ -27,6 +27,7 @@ export interface KeyboardSettings { nextAgent?: ShortcutBinding; prevAgent?: ShortcutBinding; openInEditor?: ShortcutBinding; + fileSearch?: ShortcutBinding; } export interface ShortcutConfig {