diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 9df244bb..4e7ce503 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -11,6 +11,7 @@ import { RiFolder3Fill, RiFolderAddLine, RiFolderOpenFill, + RiFolderReceivedLine, RiLoader4Line, RiMore2Fill, RiRefreshLine, @@ -186,11 +187,13 @@ interface FileRowProps { canCreateFile: boolean; canCreateFolder: boolean; canDelete: boolean; + canReveal: boolean; }; contextMenuPath: string | null; setContextMenuPath: (path: string | null) => void; onSelect: (node: FileNode) => void; onToggle: (path: string) => void; + onRevealPath: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; } @@ -205,16 +208,17 @@ const FileRow: React.FC = ({ setContextMenuPath, onSelect, onToggle, + onRevealPath, onOpenDialog, }) => { const isDir = node.type === 'directory'; - const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions; + const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) return; + if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return; event?.preventDefault(); setContextMenuPath(node.path); - }, [canRename, canCreateFile, canCreateFolder, canDelete, node.path, setContextMenuPath]); + }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setContextMenuPath]); const handleInteraction = React.useCallback(() => { if (isDir) { @@ -263,7 +267,7 @@ const FileRow: React.FC = ({ )} - {(canRename || canCreateFile || canCreateFolder || canDelete) && ( + {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
= ({ }}> Copy Path + {canReveal && ( + { e.stopPropagation(); onRevealPath(node.path); }}> + Reveal in Finder + + )} {isDir && (canCreateFile || canCreateFolder) && ( <> @@ -375,6 +384,14 @@ export const SidebarFilesTree: React.FC = () => { const canCreateFolder = Boolean(files.createDirectory); const canRename = Boolean(files.rename); const canDelete = Boolean(files.delete); + const canReveal = Boolean(files.revealPath); + + const handleRevealPath = React.useCallback((targetPath: string) => { + if (!files.revealPath) return; + void files.revealPath(targetPath).catch(() => { + toast.error('Failed to reveal path'); + }); + }, [files]); const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { setActiveDialog(type); @@ -737,11 +754,12 @@ export const SidebarFilesTree: React.FC = () => { isActive={isActive} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} - permissions={{ canRename, canCreateFile, canCreateFolder, canDelete }} + permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }} contextMenuPath={contextMenuPath} setContextMenuPath={setContextMenuPath} onSelect={handleOpenFile} onToggle={toggleDirectory} + onRevealPath={handleRevealPath} onOpenDialog={handleOpenDialog} /> {isDir && isExpanded && ( diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index b80de034..5787d540 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -12,6 +12,7 @@ import { RiCheckLine, RiFolder3Fill, RiFolderOpenFill, + RiFolderReceivedLine, RiFullscreenExitLine, RiFullscreenLine, RiLoader4Line, @@ -335,11 +336,13 @@ interface FileRowProps { canCreateFile: boolean; canCreateFolder: boolean; canDelete: boolean; + canReveal: boolean; }; contextMenuPath: string | null; setContextMenuPath: (path: string | null) => void; onSelect: (node: FileNode) => void; onToggle: (path: string) => void; + onRevealPath: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; } @@ -355,18 +358,19 @@ const FileRow: React.FC = ({ setContextMenuPath, onSelect, onToggle, + onRevealPath, onOpenDialog, }) => { const isDir = node.type === 'directory'; - const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions; + const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) { + if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) { return; } event?.preventDefault(); setContextMenuPath(node.path); - }, [canRename, canCreateFile, canCreateFolder, canDelete, node.path, setContextMenuPath]); + }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setContextMenuPath]); const handleInteraction = React.useCallback(() => { if (isDir) { @@ -418,7 +422,7 @@ const FileRow: React.FC = ({ )} - {(canRename || canCreateFile || canCreateFolder || canDelete) && ( + {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
= ({ }}> Copy Path + {canReveal && ( + { e.stopPropagation(); onRevealPath(node.path); }}> + Reveal in Finder + + )} {isDir && (canCreateFile || canCreateFolder) && ( <> @@ -610,6 +619,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const canCreateFolder = Boolean(files.createDirectory); const canRename = Boolean(files.rename); const canDelete = Boolean(files.delete); + const canReveal = Boolean(files.revealPath); + + const handleRevealPath = React.useCallback((targetPath: string) => { + if (!files.revealPath) return; + void files.revealPath(targetPath).catch(() => { + toast.error('Failed to reveal path'); + }); + }, [files]); const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { setActiveDialog(type); @@ -1537,11 +1554,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { isMobile={isMobile} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} - permissions={{ canRename, canCreateFile, canCreateFolder, canDelete }} + permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }} contextMenuPath={contextMenuPath} setContextMenuPath={setContextMenuPath} onSelect={handleSelectFile} onToggle={toggleDirectory} + onRevealPath={handleRevealPath} onOpenDialog={handleOpenDialog} /> {isDir && isExpanded && ( @@ -1552,7 +1570,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ); }); - }, [childrenByDir, expandedPaths, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath, isMobile, getFileStatus, getFolderBadge]); + }, [childrenByDir, expandedPaths, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, handleRevealPath, canCreateFile, canCreateFolder, canRename, canDelete, canReveal, contextMenuPath, setContextMenuPath, isMobile, getFileStatus, getFolderBadge]); const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 6b5fd23d..8bd62828 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -480,6 +480,7 @@ export interface FilesAPI { writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>; delete?(path: string): Promise<{ success: boolean }>; rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>; + revealPath?(path: string): Promise<{ success: boolean }>; execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 083605f0..c65c902d 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -10604,6 +10604,49 @@ Context: } }); + // Reveal a file or folder in the system file manager (Finder on macOS, Explorer on Windows, etc.) + app.post('/api/fs/reveal', async (req, res) => { + const { path: targetPath } = req.body || {}; + if (!targetPath || typeof targetPath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = path.resolve(targetPath.trim()); + + // Verify path exists + await fsPromises.access(resolved); + + const platform = process.platform; + if (platform === 'darwin') { + // macOS: open -R selects the file in Finder; open opens a folder + const stat = await fsPromises.stat(resolved); + if (stat.isDirectory()) { + spawn('open', [resolved], { stdio: 'ignore', detached: true }).unref(); + } else { + spawn('open', ['-R', resolved], { stdio: 'ignore', detached: true }).unref(); + } + } else if (platform === 'win32') { + // Windows: explorer /select, highlights the file + spawn('explorer', ['/select,', resolved], { stdio: 'ignore', detached: true }).unref(); + } else { + // Linux: xdg-open opens the parent directory + const stat = await fsPromises.stat(resolved); + const dir = stat.isDirectory() ? resolved : path.dirname(resolved); + spawn('xdg-open', [dir], { stdio: 'ignore', detached: true }).unref(); + } + + res.json({ success: true, path: resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Path not found' }); + } + console.error('Failed to reveal path:', error); + res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' }); + } + }); + // Execute shell commands in a directory (for worktree setup) // NOTE: This route supports background execution to avoid tying up browser connections. const execJobs = new Map(); diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 9de32771..a6a6d7a8 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -197,4 +197,20 @@ export const createWebFilesAPI = (): FilesAPI => ({ path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : newPath, }; }, + + async revealPath(targetPath: string): Promise<{ success: boolean }> { + const response = await fetch('/api/fs/reveal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: normalizePath(targetPath) }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || 'Failed to reveal path'); + } + + const result = await response.json().catch(() => ({})); + return { success: Boolean((result as { success?: boolean }).success) }; + }, });