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 {