file browser: the eight defects from the audit

1. Kebab Cut/Copy silently did nothing, and sometimes cut the wrong rows.
   `EllipsisMenu` stops click propagation so opening it does not toggle
   selection — but Cut, Copy, Download, Delete and Compress all act on the
   SELECTION, which meant the kebab operated on whatever was selected elsewhere,
   or returned early with no toast at all. Right-click never had this because it
   selects first. Both now call one `ensureSelected`, on open.

2. Every menu vanished during a search. That branch had no ContextMenu and its
   rows are not FileItems, so right-click fell through to the browser. The folder
   menu is now declared once and mounted in both branches — it acts on
   `currentPath`, which a search does not change, so it stays meaningful.

   Search ROWS get their own three-item menu rather than the row menu: a hit
   carries an absolute path from anywhere in the tree, while every row-menu
   handler builds its path from the folder being browsed. Reusing it would have
   acted on a different file with the same name. `DirEntry.path` is optional —
   only search results carry one — so it is guarded, not asserted.

3. Download and Copy path now fan out over a multi-selection, the rule Delete and
   Compress already used. The menu downloaded one of six selected files while the
   toolbar zipped all six.

   Chat is deliberately NOT included: `chatContext` is a single path that the
   provider string-splits into a working directory, so a list would need Chat's
   contract changed. Left alone rather than half-done.

4. Read Aloud stopped offering TTS on binaries — same guard the editor got, since
   `text` is `getFileType`'s fallback and caught .exe and .sqlite.

5. `POST /file-browser/download` is filed as a read. It zips a selection and
   writes nothing; it is a POST only because a list of paths does not fit a query
   string. A read-level member could download a whole folder but not two files.
   Proved by hand that `readOnlyWrites: ['/download']` permits GET and POST
   /download, and still denies /download-video, /rm and /write — `isPrefixOf` is
   segment-aware, so the sibling route does not leak.

6. `disabled` had no use anywhere in the feature — the shared item type did not
   even expose it. It does now, and the first real case: `/file-browser/read`
   refuses over 5 MB, so Open in editor on a 40 MB log opened an editor that then
   failed. Hiding it would have suggested the file is not text; disabled says the
   true thing.

7. The grid kebab could fade out while its own dropdown was open — list view had
   `data-[state=open]:opacity-100` and grid did not.

8. Dead exports `getMatchingTasks` / `getMatchingAgents` removed. Both were the
   ungrouped input to the grouped form the menu actually renders; exporting them
   invited a second code path that never arrived.

9 is untouched on purpose — a plugin cannot register a menu item, and the music
plugin is where that should be designed.

tsgo clean, frontend builds, 808 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:31:05 +00:00
co-authored by Claude Opus 5
parent e2b0d5c06b
commit 84d6d7e7ce
6 changed files with 254 additions and 133 deletions
+7
View File
@@ -311,6 +311,13 @@ const CORE_REGISTRY: Permission[] = [
kind: 'confined', kind: 'confined',
api: ['/file-browser', '/upload'], api: ['/file-browser', '/upload'],
routes: ['/files', '/code-editor'], routes: ['/files', '/code-editor'],
// `POST /file-browser/download` zips a multi-selection and streams it back. It writes nothing — it is
// a POST only because a list of paths does not fit a query string. Without this a read-level member
// could download a whole folder (GET /download) but not two files, which is not a rule anyone chose.
//
// `isPrefixOf` is segment-aware, so this does NOT also permit `/download-video` — which really is a
// write, fetching a video onto the disk.
readOnlyWrites: ['/download'],
}, },
{ {
key: 'tasks', key: 'tasks',
@@ -152,10 +152,19 @@ const BINARY_EXTS = new Set([
'ods', 'ods',
]); ]);
/** `/file-browser/read` refuses past this, so the editor cannot open it either. Keep the two in step. */
const MAX_EDITABLE_BYTES = 5 * 1024 * 1024;
const isBinaryName = (name: string): boolean => BINARY_EXTS.has(name.split('.').pop()?.toLowerCase() ?? ''); const isBinaryName = (name: string): boolean => BINARY_EXTS.has(name.split('.').pop()?.toLowerCase() ?? '');
type MenuPrimitives = { type MenuPrimitives = {
Item: React.ComponentType<{ onClick?: () => void; className?: string; children: React.ReactNode }>; Item: React.ComponentType<{
onClick?: () => void;
/** Radix supports it on both families; the shared type just never surfaced it, so nothing used it. */
disabled?: boolean;
className?: string;
children: React.ReactNode;
}>;
Separator: React.ComponentType<Record<string, never>>; Separator: React.ComponentType<Record<string, never>>;
Sub: React.ComponentType<{ children: React.ReactNode }>; Sub: React.ComponentType<{ children: React.ReactNode }>;
SubTrigger: React.ComponentType<{ className?: string; children: React.ReactNode }>; SubTrigger: React.ComponentType<{ className?: string; children: React.ReactNode }>;
@@ -225,7 +234,10 @@ const MenuItems = ({
}: MenuItemsProps & { ui: MenuPrimitives }) => { }: MenuItemsProps & { ui: MenuPrimitives }) => {
const isDir = entry.type === 'directory'; const isDir = entry.type === 'directory';
const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; // Same binary guard as the editor. `text` is the FALLBACK type, so without this Read Aloud offered to
// speak .exe and .sqlite — it read the bytes as UTF-8 and sent the mojibake to a TTS engine.
const showReadAloud =
(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && !isBinaryName(entry.name);
const showExtract = fileType === 'archive'; const showExtract = fileType === 'archive';
// Editable in the code editor. // Editable in the code editor.
// //
@@ -238,6 +250,7 @@ const MenuItems = ({
// are named rather than guessed at, because a denylist that is too short only ever costs a bad render, // are named rather than guessed at, because a denylist that is too short only ever costs a bad render,
// while a `text` test that is too strict costs the feature. // while a `text` test that is too strict costs the feature.
const showEdit = (fileType === 'markdown' || fileType === 'code' || fileType === 'text') && !isBinaryName(entry.name); const showEdit = (fileType === 'markdown' || fileType === 'code' || fileType === 'text') && !isBinaryName(entry.name);
const tooBigToEdit = entry.type === 'file' && entry.size > MAX_EDITABLE_BYTES;
const hasTasks = taskGroups.length > 0; const hasTasks = taskGroups.length > 0;
const hasAgents = agentGroups.length > 0; const hasAgents = agentGroups.length > 0;
@@ -258,9 +271,17 @@ const MenuItems = ({
Open Open
</Item> </Item>
{showEdit && ( {showEdit && (
<Item onClick={() => onOpenInEditor(entry)} className="cursor-pointer"> // The first place `disabled` is used rather than hiding. `/file-browser/read` refuses anything
// over 5 MB, so a 40 MB log would open an editor that then fails — and hiding the item would
// just as wrongly suggest the file is not text. Disabled says the true thing: this is editable,
// but not here.
<Item
onClick={tooBigToEdit ? undefined : () => onOpenInEditor(entry)}
disabled={tooBigToEdit}
className={tooBigToEdit ? '' : 'cursor-pointer'}
>
<Code className="mr-2 h-4 w-4" /> <Code className="mr-2 h-4 w-4" />
Open in editor {tooBigToEdit ? 'Open in editor (too large)' : 'Open in editor'}
</Item> </Item>
)} )}
{/* Paste INTO a folder. The background menu could only paste into the folder already open, so a {/* Paste INTO a folder. The background menu could only paste into the folder already open, so a
@@ -419,9 +440,17 @@ const CONTEXT_UI = {
const DropdownMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={DROPDOWN_UI} />; const DropdownMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={DROPDOWN_UI} />;
const ContextMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={CONTEXT_UI} />; const ContextMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={CONTEXT_UI} />;
const EllipsisMenu = (props: MenuItemsProps) => ( /**
* The overflow menu.
*
* `stopPropagation` keeps opening the menu from toggling the row's selection — but it also meant the row
* was never selected, and `Cut`/`Copy` act on the SELECTION. So the kebab's Cut did nothing on a fresh
* listing, and worse, cut a different row's selection when one existed. Right-click never had this
* because `handleContextMenu` selects first; this now does the same on open.
*/
const EllipsisMenu = ({ onEnsureSelected, ...props }: MenuItemsProps & { onEnsureSelected: () => void }) => (
<div onClick={(ev) => ev.stopPropagation()}> <div onClick={(ev) => ev.stopPropagation()}>
<DropdownMenu> <DropdownMenu onOpenChange={(open) => open && onEnsureSelected()}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"> <button className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<MoreVertical className="h-4 w-4 text-duck-dark/60" /> <MoreVertical className="h-4 w-4 text-duck-dark/60" />
@@ -613,11 +642,21 @@ export const FileItem = ({
onOpen(entry); onOpen(entry);
}; };
/**
* Collapse the selection onto this row unless it is already part of one.
*
* Both menus need this and for the same reason — Cut, Copy, Download, Delete and Compress all read the
* SELECTION, so a menu opened on an unselected row would act on something else entirely. The modifier
* keys are stripped so opening a menu never extends a range.
*/
const ensureSelected = (ev?: React.MouseEvent) => {
if (selected) return;
onSelect(entry, { ...(ev ?? {}), ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent);
};
const handleContextMenu = (ev: React.MouseEvent) => { const handleContextMenu = (ev: React.MouseEvent) => {
ev.stopPropagation(); ev.stopPropagation();
if (!selected) { ensureSelected(ev);
onSelect(entry, { ...ev, ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent);
}
}; };
const menuProps: MenuItemsProps = { const menuProps: MenuItemsProps = {
@@ -685,7 +724,7 @@ export const FileItem = ({
{formatDate(entry.modifiedAt)} {formatDate(entry.modifiedAt)}
</span> </span>
<div className="file-item-ellipsis opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 transition-opacity shrink-0"> <div className="file-item-ellipsis opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 transition-opacity shrink-0">
<EllipsisMenu {...menuProps} /> <EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div> </div>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
@@ -713,8 +752,8 @@ export const FileItem = ({
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} /> <Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
</div> </div>
<div className="file-item-ellipsis absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="file-item-ellipsis absolute top-1 right-1 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 transition-opacity">
<EllipsisMenu {...menuProps} /> <EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div> </div>
{iconLarge} {iconLarge}
@@ -12,6 +12,7 @@ import {
MessageSquare, MessageSquare,
Download, Download,
Mic, Mic,
ExternalLink,
GitBranch, GitBranch,
Eye, Eye,
EyeOff, EyeOff,
@@ -47,6 +48,8 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
loading, loading,
handlePaste, handlePaste,
handleCopyCurrentPath, handleCopyCurrentPath,
handleCopyAbsPath,
handleDownloadPath,
handleChatHere, handleChatHere,
handleCreateDir, handleCreateDir,
handleCreateFile, handleCreateFile,
@@ -70,6 +73,91 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
} }
}; };
// The folder menu, shared by the browse view and the search view. It acts on `currentPath`, which does
// not change while a search is open — so "New file", "Paste" and the rest stay meaningful there.
const folderMenu = (
<ContextMenuContent className="z-[600]">
<ContextMenuItem onClick={handleCopyCurrentPath} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
<ContextMenuItem onClick={handleChatHere} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat about this
</ContextMenuItem>
{/* Gated on the clipboard, as both toolbars already were. Ungated it fell through to the
OS clipboard path, which on an insecure origin toasts an error and otherwise silently
did nothing — an item that looks available and is not. */}
{clipboard !== null && (
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
)}
{/* Name taken whole, extension included — the extension is what the editor uses to pick a
language, so `notes.txt`, `deploy.sh` and `.env` all do the right thing. Opens straight
into the editor: creating an empty file you then have to find is half a feature. */}
<ContextMenuItem
onClick={() => {
const name = prompt('File name (with extension)');
if (name?.trim()) handleCreateFile(name.trim());
}}
className="cursor-pointer"
>
<FilePlus className="mr-2 h-4 w-4" />
New file
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const name = prompt('Folder name');
if (name?.trim()) handleCreateDir(name.trim());
}}
className="cursor-pointer"
>
<FolderPlus className="mr-2 h-4 w-4" />
New folder
</ContextMenuItem>
<ContextMenuItem onClick={() => fileInputRef.current?.click()} className="cursor-pointer">
<Upload className="mr-2 h-4 w-4" />
Upload files
</ContextMenuItem>
<ContextMenuItem onClick={() => folderInputRef.current?.click()} className="cursor-pointer">
<FolderUp className="mr-2 h-4 w-4" />
Upload folder
</ContextMenuItem>
{/* Was toolbar-only, and the toolbar hides it under `md:` — so on a phone there was no way
to clone at all. */}
<ContextMenuItem
onClick={() => {
const url = prompt('Repository URL');
if (url?.trim()) handleGitCloneUrl(url.trim());
}}
className="cursor-pointer"
>
<GitBranch className="mr-2 h-4 w-4" />
Clone repository
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => setShowHidden(!showHidden)} className="cursor-pointer">
{showHidden ? <EyeOff className="mr-2 h-4 w-4" /> : <Eye className="mr-2 h-4 w-4" />}
{showHidden ? 'Hide hidden files' : 'Show hidden files'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCreateDashboardHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowDictate(true)} className="cursor-pointer">
<Mic className="mr-2 h-4 w-4" />
Dictate
</ContextMenuItem>
</ContextMenuContent>
);
return ( return (
<div <div
className="flex-1 min-h-0 relative" className="flex-1 min-h-0 relative"
@@ -87,41 +175,77 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
</div> </div>
)} )}
{searchQuery.trim() ? ( {searchQuery.trim() ? (
<div className="h-full overflow-auto p-4"> // Until now every menu vanished the moment a search was open: this branch had no ContextMenu and
{searching ? ( // its rows are not FileItems, so right-click fell through to the browser's own menu.
<div className="flex items-center justify-center h-full"> <ContextMenu>
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" /> <ContextMenuTrigger asChild>
<div className="h-full overflow-auto p-4">
{searching ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
</div>
) : searchResults && searchResults.length > 0 ? (
<div className="flex flex-col">
{searchResults.map((entry) => {
const isDir = entry.type === 'directory';
// `DirEntry.path` is optional because a plain listing entry has none — only search
// results carry one. Guarded rather than asserted, so the two shapes stay honest.
const absPath = entry.path;
return (
<ContextMenu key={entry.path}>
<ContextMenuTrigger asChild>
<div
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
onClick={() => handleSearchResultClick(entry)}
onContextMenu={(ev) => ev.stopPropagation()}
>
{isDir ? (
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
) : (
<span
className="inline-flex h-5 w-5 shrink-0"
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
/>
)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
</div>
</div>
</ContextMenuTrigger>
{/* Deliberately three items, not the full row menu. A search hit carries an absolute
path from anywhere in the tree, while every handler behind the row menu builds its
path from the folder being browsed — reusing it would act on the wrong file. These
three take the path as given. */}
<ContextMenuContent className="z-[600]">
<ContextMenuItem onClick={() => handleSearchResultClick(entry)} className="cursor-pointer">
<ExternalLink className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
{absPath && (
<ContextMenuItem onClick={() => handleCopyAbsPath(absPath)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
)}
{absPath && !isDir && (
<ContextMenuItem onClick={() => handleDownloadPath(absPath)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
) : searchResults ? (
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
) : null}
</div> </div>
) : searchResults && searchResults.length > 0 ? ( </ContextMenuTrigger>
<div className="flex flex-col"> {folderMenu}
{searchResults.map((entry) => { </ContextMenu>
const isDir = entry.type === 'directory';
return (
<div
key={entry.path}
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
onClick={() => handleSearchResultClick(entry)}
>
{isDir ? (
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
) : (
<span
className="inline-flex h-5 w-5 shrink-0"
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
/>
)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
</div>
</div>
);
})}
</div>
) : searchResults ? (
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
) : null}
</div>
) : ( ) : (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -135,86 +259,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
)} )}
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-[600]"> {folderMenu}
<ContextMenuItem onClick={handleCopyCurrentPath} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
<ContextMenuItem onClick={handleChatHere} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat about this
</ContextMenuItem>
{/* Gated on the clipboard, as both toolbars already were. Ungated it fell through to the
OS clipboard path, which on an insecure origin toasts an error and otherwise silently
did nothing — an item that looks available and is not. */}
{clipboard !== null && (
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
)}
{/* Name taken whole, extension included — the extension is what the editor uses to pick a
language, so `notes.txt`, `deploy.sh` and `.env` all do the right thing. Opens straight
into the editor: creating an empty file you then have to find is half a feature. */}
<ContextMenuItem
onClick={() => {
const name = prompt('File name (with extension)');
if (name?.trim()) handleCreateFile(name.trim());
}}
className="cursor-pointer"
>
<FilePlus className="mr-2 h-4 w-4" />
New file
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const name = prompt('Folder name');
if (name?.trim()) handleCreateDir(name.trim());
}}
className="cursor-pointer"
>
<FolderPlus className="mr-2 h-4 w-4" />
New folder
</ContextMenuItem>
<ContextMenuItem onClick={() => fileInputRef.current?.click()} className="cursor-pointer">
<Upload className="mr-2 h-4 w-4" />
Upload files
</ContextMenuItem>
<ContextMenuItem onClick={() => folderInputRef.current?.click()} className="cursor-pointer">
<FolderUp className="mr-2 h-4 w-4" />
Upload folder
</ContextMenuItem>
{/* Was toolbar-only, and the toolbar hides it under `md:` — so on a phone there was no way
to clone at all. */}
<ContextMenuItem
onClick={() => {
const url = prompt('Repository URL');
if (url?.trim()) handleGitCloneUrl(url.trim());
}}
className="cursor-pointer"
>
<GitBranch className="mr-2 h-4 w-4" />
Clone repository
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => setShowHidden(!showHidden)} className="cursor-pointer">
{showHidden ? <EyeOff className="mr-2 h-4 w-4" /> : <Eye className="mr-2 h-4 w-4" />}
{showHidden ? 'Hide hidden files' : 'Show hidden files'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCreateDashboardHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowDictate(true)} className="cursor-pointer">
<Mic className="mr-2 h-4 w-4" />
Dictate
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu> </ContextMenu>
)} )}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} /> <input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
@@ -90,7 +90,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const [showVideoDownload, setShowVideoDownload] = useState(false); const [showVideoDownload, setShowVideoDownload] = useState(false);
const [showDictate, setShowDictate] = useState(false); const [showDictate, setShowDictate] = useState(false);
const dragCounter = useRef(0); const dragCounter = useRef(0);
const { getMatchingTasks, getMatchingTaskGroups } = useTasks(); const { getMatchingTaskGroups } = useTasks();
const { getMatchingAgentGroups } = useAgents(); const { getMatchingAgentGroups } = useAgents();
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null); const searchInputRef = useRef<HTMLInputElement | null>(null);
@@ -430,10 +430,17 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
}); });
}; };
/**
* Download the clicked entry — or the whole selection, when the click landed inside one.
*
* Same rule `handleDelete` and `handleCompress` already use. It was single-entry only, so the menu
* quietly downloaded one of six selected files while the toolbar's button zipped all six: two controls
* for one intent that disagreed.
*/
const handleDownload = async (entry: DirEntry) => { const handleDownload = async (entry: DirEntry) => {
const path = entryPath(entry.name); const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
try { try {
await files.download([path]); await files.download(paths);
} catch { } catch {
toast.error('Failed to download'); toast.error('Failed to download');
} }
@@ -468,10 +475,30 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
}; };
const handleCopyPath = (entry: DirEntry) => { const handleCopyPath = (entry: DirEntry) => {
copyToClipboard(`~${entryPath(entry.name)}`); const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
// Newline-separated, which is what a shell, an editor and a chat box all accept as a list of paths.
copyToClipboard(paths.map((p) => `~${p}`).join('\n'));
toast.success(paths.length > 1 ? `${paths.length} paths copied` : 'Path copied');
};
/**
* The same two, for a SEARCH result — which carries an absolute path rather than a name in the folder
* being browsed. `entryPath` would build the wrong path for a result from another directory, which is
* why search rows could not simply reuse the row menu.
*/
const handleCopyAbsPath = (path: string) => {
copyToClipboard(`~${path}`);
toast.success('Path copied'); toast.success('Path copied');
}; };
const handleDownloadPath = async (path: string) => {
try {
await files.download([path]);
} catch {
toast.error('Failed to download');
}
};
const handleCopyCurrentPath = () => { const handleCopyCurrentPath = () => {
copyToClipboard(`~${currentPath}`); copyToClipboard(`~${currentPath}`);
toast.success('Path copied'); toast.success('Path copied');
@@ -813,7 +840,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
// Task runner // Task runner
runningTask, runningTask,
setRunningTask, setRunningTask,
getMatchingTasks,
getMatchingTaskGroups, getMatchingTaskGroups,
// Agent runner // Agent runner
runningAgent, runningAgent,
@@ -841,6 +867,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
handleDeleteSelected, handleDeleteSelected,
handleChat, handleChat,
handleCopyPath, handleCopyPath,
handleCopyAbsPath,
handleDownloadPath,
handleCopyCurrentPath, handleCopyCurrentPath,
handleChatHere, handleChatHere,
handleDownload, handleDownload,
@@ -46,5 +46,5 @@ export const useAgents = () => {
agents: items, agents: items,
})); }));
return { agents, categoryOrder, getMatchingAgents, getMatchingAgentGroups }; return { agents, categoryOrder, getMatchingAgentGroups };
}; };
@@ -98,5 +98,7 @@ export const useTasks = () => {
const getMatchingTaskGroups = (fileName: string, entryType: 'file' | 'directory'): TaskGroup[] => const getMatchingTaskGroups = (fileName: string, entryType: 'file' | 'directory'): TaskGroup[] =>
groupTasksByCategory(getMatchingTasks(fileName, entryType), categoryOrder); groupTasksByCategory(getMatchingTasks(fileName, entryType), categoryOrder);
return { tasks, categoryOrder, getMatchingTasks, getMatchingTaskGroups }; // `getMatchingTasks` stays internal: it is the input to the grouped form, and the menu only ever
// renders groups. Exporting it invited a second, ungrouped code path that never arrived.
return { tasks, categoryOrder, getMatchingTaskGroups };
}; };