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:
@@ -311,6 +311,13 @@ const CORE_REGISTRY: Permission[] = [
|
||||
kind: 'confined',
|
||||
api: ['/file-browser', '/upload'],
|
||||
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',
|
||||
|
||||
+51
-12
@@ -152,10 +152,19 @@ const BINARY_EXTS = new Set([
|
||||
'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() ?? '');
|
||||
|
||||
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>>;
|
||||
Sub: React.ComponentType<{ children: React.ReactNode }>;
|
||||
SubTrigger: React.ComponentType<{ className?: string; children: React.ReactNode }>;
|
||||
@@ -225,7 +234,10 @@ const MenuItems = ({
|
||||
}: MenuItemsProps & { ui: MenuPrimitives }) => {
|
||||
const isDir = entry.type === 'directory';
|
||||
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';
|
||||
// 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,
|
||||
// while a `text` test that is too strict costs the feature.
|
||||
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 hasAgents = agentGroups.length > 0;
|
||||
@@ -258,9 +271,17 @@ const MenuItems = ({
|
||||
Open
|
||||
</Item>
|
||||
{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" />
|
||||
Open in editor
|
||||
{tooBigToEdit ? 'Open in editor (too large)' : 'Open in editor'}
|
||||
</Item>
|
||||
)}
|
||||
{/* 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 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()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={(open) => open && onEnsureSelected()}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||
<MoreVertical className="h-4 w-4 text-duck-dark/60" />
|
||||
@@ -613,11 +642,21 @@ export const FileItem = ({
|
||||
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) => {
|
||||
ev.stopPropagation();
|
||||
if (!selected) {
|
||||
onSelect(entry, { ...ev, ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent);
|
||||
}
|
||||
ensureSelected(ev);
|
||||
};
|
||||
|
||||
const menuProps: MenuItemsProps = {
|
||||
@@ -685,7 +724,7 @@ export const FileItem = ({
|
||||
{formatDate(entry.modifiedAt)}
|
||||
</span>
|
||||
<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>
|
||||
</ContextMenuTrigger>
|
||||
@@ -713,8 +752,8 @@ export const FileItem = ({
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
</div>
|
||||
|
||||
<div className="file-item-ellipsis absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<EllipsisMenu {...menuProps} />
|
||||
<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} onEnsureSelected={ensureSelected} />
|
||||
</div>
|
||||
|
||||
{iconLarge}
|
||||
|
||||
+159
-114
@@ -12,6 +12,7 @@ import {
|
||||
MessageSquare,
|
||||
Download,
|
||||
Mic,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -47,6 +48,8 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
loading,
|
||||
handlePaste,
|
||||
handleCopyCurrentPath,
|
||||
handleCopyAbsPath,
|
||||
handleDownloadPath,
|
||||
handleChatHere,
|
||||
handleCreateDir,
|
||||
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 (
|
||||
<div
|
||||
className="flex-1 min-h-0 relative"
|
||||
@@ -87,41 +175,77 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
</div>
|
||||
)}
|
||||
{searchQuery.trim() ? (
|
||||
<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" />
|
||||
// Until now every menu vanished the moment a search was open: this branch had no ContextMenu and
|
||||
// its rows are not FileItems, so right-click fell through to the browser's own menu.
|
||||
<ContextMenu>
|
||||
<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>
|
||||
) : searchResults && searchResults.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
{searchResults.map((entry) => {
|
||||
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>
|
||||
</ContextMenuTrigger>
|
||||
{folderMenu}
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -135,86 +259,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<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>
|
||||
{folderMenu}
|
||||
</ContextMenu>
|
||||
)}
|
||||
<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 [showDictate, setShowDictate] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const { getMatchingTasks, getMatchingTaskGroups } = useTasks();
|
||||
const { getMatchingTaskGroups } = useTasks();
|
||||
const { getMatchingAgentGroups } = useAgents();
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | 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 path = entryPath(entry.name);
|
||||
const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
|
||||
try {
|
||||
await files.download([path]);
|
||||
await files.download(paths);
|
||||
} catch {
|
||||
toast.error('Failed to download');
|
||||
}
|
||||
@@ -468,10 +475,30 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
const handleDownloadPath = async (path: string) => {
|
||||
try {
|
||||
await files.download([path]);
|
||||
} catch {
|
||||
toast.error('Failed to download');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCurrentPath = () => {
|
||||
copyToClipboard(`~${currentPath}`);
|
||||
toast.success('Path copied');
|
||||
@@ -813,7 +840,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
// Task runner
|
||||
runningTask,
|
||||
setRunningTask,
|
||||
getMatchingTasks,
|
||||
getMatchingTaskGroups,
|
||||
// Agent runner
|
||||
runningAgent,
|
||||
@@ -841,6 +867,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
handleDeleteSelected,
|
||||
handleChat,
|
||||
handleCopyPath,
|
||||
handleCopyAbsPath,
|
||||
handleDownloadPath,
|
||||
handleCopyCurrentPath,
|
||||
handleChatHere,
|
||||
handleDownload,
|
||||
|
||||
@@ -46,5 +46,5 @@ export const useAgents = () => {
|
||||
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[] =>
|
||||
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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user