feat(files): add 'Reveal in Finder' to file tree context menus (#482)
* feat(session-folders): drag-to-folder DnD, sort by activity, and UX improvements - Add DraggableSessionRow wrapping each session row so the whole row is draggable; stopPropagation prevents outer group-reorder DnD from firing - Add DroppableFolderWrapper + SessionFolderDndScope (inner DndContext scoped per group) with closestCenter collision detection - DragOverlay matches exact width/height of dragged row so cursor stays aligned - Folder header highlights (ring + primary colour) when a session hovers over it during drag - + button on folder header opens a dropdown: 'New session' / 'New folder' - + button on each folder row creates a session scoped to that folder - Empty folders are no longer auto-deleted (removed .filter(sessionIds.length>0) from addSessionToFolder / removeSessionFromFolder / cleanupSessions) - Sessions inside a folder are sorted by most-recent activity (same compareSessionsByPinnedAndTime logic used everywhere else) - Sort comparator now takes sessionAttentionStates so lastUserMessageAt / lastStatusChangeAt is used when newer than session.time.updated; all sort call-sites and their useMemo/useCallback deps updated accordingly - Remove foldersMap from cleanup effect deps to prevent cascade re-renders when folders change; read current value via getState() instead * fix(session-folders): new session is placed into the correct folder sendMessage() was calling useSessionManagementStore.createSession() directly, bypassing the targetFolderId logic in useSessionStore.createSession. Fix: read targetFolderId from draft at the top of the draft branch in sendMessage, then call addSessionToFolder immediately after the session is created and before the draft is closed. Also propagate targetFolderId through openNewSessionDraft options and NewSessionDraftState type. * feat(session-folders): add sub-folder support (one level deep) - SessionFolder gains optional parentId field for hierarchy - createFolder accepts parentId to create sub-folders - deleteFolder cascades to remove all child sub-folders - SessionFolderItem renders sub-folders before sessions in body; new sub-folder button (RiFolderAddLine) visible at depth 0 only - renderOneFolderItem in SessionSidebar builds the tree recursively; sub-folders are indented via depth prop (ml-3 on root's children) - Persist/hydrate parentId correctly from localStorage * feat(session): add delete confirm dialogs and improve subtitle UX - Add confirmation dialogs before deleting sessions or folders - Show relative time (e.g., '2h ago', '35min ago') for recent sessions - Replace +/- diff numbers with file change count (e.g., '3 files changed') - New folders use default name without forcing rename - Cleaner, less cluttered session list UI * fix(session-folders): skip folder cleanup while sessions are loading Prevents race condition on reload where cleanupSessions() runs before the server returns the full session list, causing folder-session assignments to be incorrectly wiped from localStorage. * feat(files): add 'Reveal in Finder' to file tree context menus Add a new context menu action to reveal files and folders in the system file manager (Finder on macOS, Explorer on Windows, xdg-open on Linux). - Add POST /api/fs/reveal server endpoint with cross-platform support - Add revealPath() to FilesAPI interface and web implementation - Add 'Reveal in Finder' menu item to SidebarFilesTree and FilesView - Files are highlighted in Finder (open -R), folders are opened directly
This commit is contained in:
committed by
GitHub
parent
2a3254495f
commit
989593ed72
@@ -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<FileRowProps> = ({
|
||||
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<FileRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
|
||||
<DropdownMenu
|
||||
open={contextMenuPath === node.path}
|
||||
@@ -297,6 +301,11 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
}}>
|
||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||
</DropdownMenuItem>
|
||||
{canReveal && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> Reveal in Finder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isDir && (canCreateFile || canCreateFolder) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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<FileRowProps> = ({
|
||||
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<FileRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
|
||||
<div className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2",
|
||||
!isMobile && "opacity-0 focus-within:opacity-100 group-hover:opacity-100",
|
||||
@@ -456,6 +460,11 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
}}>
|
||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||
</DropdownMenuItem>
|
||||
{canReveal && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> Reveal in Finder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isDir && (canCreateFile || canCreateFolder) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -610,6 +619,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}, [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'));
|
||||
|
||||
@@ -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[] }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user