file browser: close the accidental gaps in the context menu

The audit split the menu's omissions in two. Three were deliberate — OCR,
Transcribe and Extract Audio were removed in July because the task system does
them better, and those stay gone. The rest were nobody's decision. This is the
rest.

── One menu body instead of two ──

`DropdownMenuItems` and `ContextMenuItems` were character-identical apart from the
component prefix: 159 lines duplicated, including the same comment twice. Radix
gives both families the same props, so the components are now a parameter and
there is one list. That is why this diff removes more than it adds while adding
seven items — and why the next item only has to be written once.

── Added to the row menu ──

- Open — double-click was the only way. Every file manager puts it first.
- Open in editor — `/code-editor` is on the SAME `files` permission as `/files`,
  so anyone who can browse could already edit; there was just no way to get there
  from a file. Narrower than Read Aloud on purpose: that one uses the `text`
  FALLBACK type, which matches .exe and .bin.
- Paste into folder — a move was only expressible as cut → navigate → paste.
  Right-clicking the destination is the obvious gesture and did not exist.
- Duplicate — one call away the whole time; `/copy` already resolves collisions
  to " (copy 2)".
- Compress — the missing half of Extract. `/download` shelled out to zip but
  streamed the result away, so nothing could make an archive and keep it. New
  `POST /file-browser/compress` writes it beside the original.
- Pin / Unpin — pinning existed only in the widget, which is not where you meet
  a file worth keeping to hand.

── Added to the background menu ──

- Clone repository and Show/Hide hidden files. Both were toolbar-only, and the
  toolbar hides them under `md:` — so on a phone neither existed. All four
  desktop-only toolbar actions are now in this menu, which long-press reaches.
- Paste is gated on the clipboard, as both toolbars already were. Ungated it fell
  through to the OS clipboard path and either toasted a secure-context error or
  silently did nothing — an item that looked available and was not.

── Removed ──

`pcm-worklet-processor.js`, orphaned when cliamp left for the music plugin this
morning. Verified unreferenced; the plugin ships its own copy inline.

The `Play` hole itself is NOT closed and cannot be from here: there is no way for
a plugin to register a context-menu item. Tasks and agents are the only extension
mechanism and a task can only contribute a task entry. That is a platform gap and
wants its own decision.

Verified the zip invocation by hand for all three shapes — single file, directory
(recursive, contents included), and a multi-item selection. Archive naming strips
the extension only for a file, so a directory called `my.photos` stays
`my.photos.zip` rather than becoming `my.zip`.

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

SelectionActions.tsx and usePipelineRunner.ts are prettier reflowing long lines —
no semantic change, caught by formatting the folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:09:57 +00:00
co-authored by Claude Opus 5
parent 640529ccba
commit cc889a864c
9 changed files with 681 additions and 478 deletions
+53
View File
@@ -1498,6 +1498,59 @@ router.post('/download', async (ctx) => {
});
});
/**
* Compress items into a zip ON DISK, beside them.
*
* Distinct from GET/POST /download, which also shell out to `zip` but stream the result to the browser
* and leave nothing behind. There was no way to make an archive and keep it — the file browser could
* unpack an archive (`/extract`) but not create one, which is a strange half of a pair.
*
* The name comes from the selection: one item gives `<name>.zip`, several give `archive.zip`, and
* `resolveCollision` handles the rest — so compressing twice yields `archive (copy).zip` rather than
* silently overwriting the first one.
*
* `-r` because a directory has to go in whole, and paths are relative to `rootDir` so the archive holds
* `photos/a.jpg` rather than the absolute path of somebody's home directory.
*/
router.post('/compress', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { paths } = ctx.get('body') as { paths: string[] };
if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required');
const items: string[] = [];
let onlyIsDir = false;
for (const p of paths) {
const relPath = p.replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
const st = await stat(absPath); // throws if not found
onlyIsDir = st.isDirectory();
items.push(relPath);
}
// Everything must sit in one directory for the archive to land beside it, which the UI guarantees —
// a selection is always within the folder being viewed. Checked rather than assumed.
const parents = new Set(items.map((i) => dirname(resolveUserPath(rootDir, i))));
if (parents.size !== 1) throw errors.BAD_REQUEST('all items must be in the same folder');
const parent = [...parents][0]!;
// Strip the extension only for a FILE: `report.pdf` → `report.zip`, but a directory called
// `my.photos` must stay `my.photos.zip` rather than becoming `my.zip`.
const only = items.length === 1 ? (items[0]!.split('/').pop() ?? 'archive') : null;
const base = only === null ? 'archive' : onlyIsDir ? only : only.replace(/\.[^.]+$/, '');
const target = await resolveCollision(resolve(parent, `${base}.zip`));
const proc = Bun.spawn(['zip', '-r', '-q', target, ...items.map((i) => i.split('/').pop()!)], {
cwd: parent,
stdout: 'ignore',
stderr: 'pipe',
});
const err = await new Response(proc.stderr).text();
if ((await proc.exited) !== 0) throw errors.BAD_REQUEST(`zip failed: ${err.trim().slice(-200)}`);
return ctx.json({ ok: true, path: target.slice(rootDir.length), name: target.split('/').pop() });
});
// Delete file or directory
router.delete('/rm', async (ctx) => {
const user = ctx.get('user');
@@ -71,6 +71,12 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
handleRunTask,
handleRunAgent,
handleCreateDashboard,
handleOpenInEditor,
handleCompress,
handleDuplicate,
handlePasteInto,
handleTogglePin,
isPinned,
fileScrollRef: scrollRef,
setSearchQuery,
searchInputRef,
@@ -248,6 +254,13 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
onRunAgent={handleRunAgent}
onCreateDashboard={handleCreateDashboard}
onOpenInEditor={handleOpenInEditor}
onCompress={handleCompress}
onDuplicate={handleDuplicate}
onPasteInto={handlePasteInto}
onTogglePin={handleTogglePin}
isPinned={isPinned(entryPath(entry.name))}
canPaste={clipboard !== null}
/>
);
@@ -14,7 +14,15 @@ import {
Volume2,
FolderArchive,
ClipboardCopy,
ClipboardPaste,
Bot,
FolderOpen,
ExternalLink,
Code,
CopyPlus,
FileArchive,
Pin,
PinOff,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
@@ -68,6 +76,13 @@ export type FileItemProps = {
agentGroups: AgentGroup[];
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
onCreateDashboard: (entry: DirEntry) => void;
onOpenInEditor: (entry: DirEntry) => void;
onCompress: (entry: DirEntry) => void;
onDuplicate: (entry: DirEntry) => void;
onPasteInto: (entry: DirEntry) => void;
onTogglePin: (entry: DirEntry) => void;
isPinned: boolean;
canPaste: boolean;
};
function formatSize(bytes: number): string {
@@ -84,9 +99,21 @@ function formatDate(ms: number): string {
});
}
type MenuPrimitives = {
Item: React.ComponentType<{ onClick?: () => void; 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 }>;
SubContent: React.ComponentType<{ className?: string; children: React.ReactNode }>;
};
type MenuItemsProps = {
entry: DirEntry;
multiSelected: boolean;
isPinned: boolean;
canPaste: boolean;
onOpen: (e: DirEntry) => void;
onOpenInEditor: (e: DirEntry) => void;
onDelete: (e: DirEntry) => void;
onStartRename: () => void;
onChat: (e: DirEntry) => void;
@@ -94,6 +121,10 @@ type MenuItemsProps = {
onDownload: (e: DirEntry) => void;
onReadAloud: (e: DirEntry) => void;
onExtract: (e: DirEntry) => void;
onCompress: (e: DirEntry) => void;
onDuplicate: (e: DirEntry) => void;
onPasteInto: (e: DirEntry) => void;
onTogglePin: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
taskGroups: TaskGroup[];
@@ -103,9 +134,21 @@ type MenuItemsProps = {
onCreateDashboard: (e: DirEntry) => void;
};
const DropdownMenuItems = ({
/**
* The row menu, once.
*
* This body existed twice — character-identical apart from the `ContextMenu*`/`DropdownMenu*` prefix —
* so every change had to be made in both and the two drifting apart was a matter of time. Radix gives
* the two families the same props, so the components are a parameter and there is one list again.
*/
const MenuItems = ({
ui: { Item, Separator, Sub, SubTrigger, SubContent },
entry,
multiSelected,
isPinned,
canPaste,
onOpen,
onOpenInEditor,
onDelete,
onStartRename,
onChat,
@@ -113,6 +156,10 @@ const DropdownMenuItems = ({
onDownload,
onReadAloud,
onExtract,
onCompress,
onDuplicate,
onPasteInto,
onTogglePin,
onCut,
onCopy,
taskGroups,
@@ -120,10 +167,15 @@ const DropdownMenuItems = ({
agentGroups,
onRunAgent,
onCreateDashboard,
}: MenuItemsProps) => {
}: 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';
const showExtract = fileType === 'archive';
// Editable in the code editor. Deliberately narrower than `showReadAloud`, which uses the same set
// plus the `text` FALLBACK — that fallback matches .exe and .bin, and opening those in a text editor
// is worse than not offering it.
const showEdit = fileType === 'markdown' || fileType === 'code';
const hasTasks = taskGroups.length > 0;
const hasAgents = agentGroups.length > 0;
@@ -137,289 +189,173 @@ const DropdownMenuItems = ({
return (
<>
{/* Opening was double-click only, with no menu entry at all — the one action every file manager
puts first. */}
<Item onClick={() => onOpen(entry)} className="cursor-pointer">
{isDir ? <FolderOpen className="mr-2 h-4 w-4" /> : <ExternalLink className="mr-2 h-4 w-4" />}
Open
</Item>
{showEdit && (
<Item onClick={() => onOpenInEditor(entry)} className="cursor-pointer">
<Code className="mr-2 h-4 w-4" />
Open in editor
</Item>
)}
{/* Paste INTO a folder. The background menu could only paste into the folder already open, so a
move meant cut → navigate → paste. Hidden rather than disabled when the clipboard is empty. */}
{isDir && canPaste && (
<Item onClick={() => onPasteInto(entry)} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste into folder
</Item>
)}
<Separator />
{showReadAloud && (
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Item onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</DropdownMenuItem>
</Item>
)}
{showExtract && (
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<Item onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</DropdownMenuItem>
</Item>
)}
{hasTasks && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Sub>
<SubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
</SubTrigger>
<SubContent className="z-[600]">
{nestTasks
? taskGroups.map((group) => (
<DropdownMenuSub key={group.category}>
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
<Sub key={group.category}>
<SubTrigger className="cursor-pointer">{group.category}</SubTrigger>
<SubContent className="z-[600]">
{group.tasks.map((task) => (
<DropdownMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
<Item key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</DropdownMenuItem>
</Item>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</SubContent>
</Sub>
))
: flatTasks.map((task) => (
<DropdownMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
<Item key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</DropdownMenuItem>
</Item>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</SubContent>
</Sub>
)}
{hasAgents && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Sub>
<SubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" />
Run Agent
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
</SubTrigger>
<SubContent className="z-[600]">
{nestAgents
? agentGroups.map((group) => (
<DropdownMenuSub key={group.category}>
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
<Sub key={group.category}>
<SubTrigger className="cursor-pointer">{group.category}</SubTrigger>
<SubContent className="z-[600]">
{group.agents.map((agent) => (
<DropdownMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
<Item key={agent.dirName} onClick={() => onRunAgent(agent, entry)} className="cursor-pointer">
{agent.name}
</DropdownMenuItem>
</Item>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</SubContent>
</Sub>
))
: flatAgents.map((agent) => (
<DropdownMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
<Item key={agent.dirName} onClick={() => onRunAgent(agent, entry)} className="cursor-pointer">
{agent.name}
</DropdownMenuItem>
</Item>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</SubContent>
</Sub>
)}
{hasActions && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
{hasActions && <Separator />}
<Item onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
</Item>
<Item onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</DropdownMenuItem>
</Item>
{!multiSelected && (
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
<Item onClick={() => onDuplicate(entry)} className="cursor-pointer">
<CopyPlus className="mr-2 h-4 w-4" />
Duplicate
</Item>
)}
{!multiSelected && (
<Item onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
</Item>
)}
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
{/* Compress, the missing half of Extract. `/download` already shelled out to zip but streamed the
result away; nothing could make an archive and keep it. */}
<Item onClick={() => onCompress(entry)} className="cursor-pointer">
<FileArchive className="mr-2 h-4 w-4" />
{multiSelected ? 'Compress selection' : 'Compress'}
</Item>
<Item onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
</Item>
<Item onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
</Item>
<Separator />
{/* Pinning lived only in the widget, which is not where you meet a file worth keeping to hand. */}
<Item onClick={() => onTogglePin(entry)} className="cursor-pointer">
{isPinned ? <PinOff className="mr-2 h-4 w-4" /> : <Pin className="mr-2 h-4 w-4" />}
{isPinned ? 'Unpin' : 'Pin'}
</Item>
<Item onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
{entry.type === 'directory' && (
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
</Item>
{isDir && (
<Item onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</DropdownMenuItem>
</Item>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Separator />
<Item onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</Item>
</>
);
};
const ContextMenuItems = ({
entry,
multiSelected,
onDelete,
onStartRename,
onChat,
onCopyPath,
onDownload,
onReadAloud,
onExtract,
onCut,
onCopy,
taskGroups,
onRunTask,
agentGroups,
onRunAgent,
onCreateDashboard,
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showExtract = fileType === 'archive';
const DROPDOWN_UI = {
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
} as unknown as MenuPrimitives;
const hasTasks = taskGroups.length > 0;
const hasAgents = agentGroups.length > 0;
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
// Only nest when more than one category matched — a file usually matches a single category, and
// Run Task > Video > Convert would just add a hop.
const nestTasks = taskGroups.length > 1;
const flatTasks = taskGroups[0]?.tasks ?? [];
const nestAgents = agentGroups.length > 1;
const flatAgents = agentGroups[0]?.agents ?? [];
const CONTEXT_UI = {
Item: ContextMenuItem,
Separator: ContextMenuSeparator,
Sub: ContextMenuSub,
SubTrigger: ContextMenuSubTrigger,
SubContent: ContextMenuSubContent,
} as unknown as MenuPrimitives;
return (
<>
{showReadAloud && (
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</ContextMenuItem>
)}
{showExtract && (
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</ContextMenuItem>
)}
{hasTasks && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{nestTasks
? taskGroups.map((group) => (
<ContextMenuSub key={group.category}>
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{group.tasks.map((task) => (
<ContextMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
))
: flatTasks.map((task) => (
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasAgents && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" />
Run Agent
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{nestAgents
? agentGroups.map((group) => (
<ContextMenuSub key={group.category}>
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{group.agents.map((agent) => (
<ContextMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
))
: flatAgents.map((agent) => (
<ContextMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasActions && <ContextMenuSeparator />}
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</ContextMenuItem>
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</ContextMenuItem>
{!multiSelected && (
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
{entry.type === 'directory' && (
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</>
);
};
const DropdownMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={DROPDOWN_UI} />;
const ContextMenuItems = (props: MenuItemsProps) => <MenuItems {...props} ui={CONTEXT_UI} />;
const EllipsisMenu = (props: MenuItemsProps) => (
<div onClick={(ev) => ev.stopPropagation()}>
@@ -549,6 +485,13 @@ export const FileItem = ({
agentGroups,
onRunAgent,
onCreateDashboard,
onOpenInEditor,
onCompress,
onDuplicate,
onPasteInto,
onTogglePin,
isPinned,
canPaste,
}: FileItemProps) => {
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -632,6 +575,14 @@ export const FileItem = ({
agentGroups,
onRunAgent,
onCreateDashboard,
onOpen,
onOpenInEditor,
onCompress,
onDuplicate,
onPasteInto,
onTogglePin,
isPinned,
canPaste,
};
const cutOpacity = isCut ? 'opacity-50' : '';
@@ -1,7 +1,28 @@
import { useRef } from 'react';
import { Loader2, Folder, ClipboardPaste, FolderPlus, FolderUp, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react';
import {
Loader2,
Folder,
ClipboardPaste,
FolderPlus,
FolderUp,
LayoutGrid,
Upload,
ClipboardCopy,
MessageSquare,
Download,
Mic,
GitBranch,
Eye,
EyeOff,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { FileGrid } from './FileGrid';
@@ -31,6 +52,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
setShowVideoDownload,
setShowDictate,
handleUpload,
handleGitCloneUrl,
showHidden,
setShowHidden,
clipboard,
} = fileBrowserManager;
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -92,9 +117,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
})}
</div>
) : searchResults ? (
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
No results found
</div>
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
) : null}
</div>
) : (
@@ -119,10 +142,15 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<MessageSquare className="mr-2 h-4 w-4" />
Chat about this
</ContextMenuItem>
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</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>
)}
<ContextMenuItem
onClick={() => {
const name = prompt('Folder name');
@@ -141,6 +169,24 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<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
@@ -6,8 +6,16 @@ type SelectionActionsProps = {
};
export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => {
const { selected, clipboard, handleCut, handleCopy, handlePaste, handleDownloadSelected, handleDeleteSelected, setSelected } =
fileBrowserManager;
const {
selected,
clipboard,
handleCut,
handleCopy,
handlePaste,
handleDownloadSelected,
handleDeleteSelected,
setSelected,
} = fileBrowserManager;
return (
<>
@@ -33,19 +33,61 @@ type ParallelStep = {
type ServerMessage =
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| {
jobId: string;
type: 'step:start';
stepIndex: number;
taskName: string;
iteration?: { current: number; total: number; label: string };
}
| {
jobId: string;
type: 'step:complete';
stepIndex: number;
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
}
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| {
jobId: string;
type: 'step:parallel';
stepIndex: number;
taskName: string;
iterations: string[];
concurrency: number;
}
| { jobId: string; type: 'step:waiting'; stepIndex: number; iterationLabel?: string; elapsed: number }
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| {
jobId: string;
type: 'iteration:complete';
stepIndex: number;
label: string;
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
}
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string }
| { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string }
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; iterationLabel?: string }
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; iterationLabel?: string }
| { jobId: string; type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
| {
jobId: string;
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
iterationLabel?: string;
}
| {
jobId: string;
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
iterationLabel?: string;
}
| {
jobId: string;
type: 'pipeline:complete';
totalCost: { inputTokens: number; outputTokens: number; totalUSD: number };
}
| { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' }
| { type: 'job:created'; jobId: string }
@@ -61,12 +103,18 @@ export function usePipelineRunner() {
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null);
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(
null,
);
const [runningCost, setRunningCost] = useState({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
const [hasError, setHasError] = useState(false);
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
const [elapsed, setElapsed] = useState(0);
const [waitingStatus, setWaitingStatus] = useState<{ stepIndex: number; elapsed: number; iterationLabel?: string } | null>(null);
const [waitingStatus, setWaitingStatus] = useState<{
stepIndex: number;
elapsed: number;
iterationLabel?: string;
} | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const streamBufferRef = useRef('');
const startTimeRef = useRef<number>(0);
@@ -84,7 +132,10 @@ export function usePipelineRunner() {
}, []);
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const addCost = useCallback((cost: { inputTokens: number; outputTokens: number; totalUSD: number }) => {
@@ -95,178 +146,184 @@ export function usePipelineRunner() {
}));
}, []);
const handleEvent = useCallback((msg: ServerMessage) => {
// Filter events by jobId (ignore events from other jobs)
if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return;
const handleEvent = useCallback(
(msg: ServerMessage) => {
// Filter events by jobId (ignore events from other jobs)
if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return;
switch (msg.type) {
case 'job:created':
jobIdRef.current = msg.jobId;
setJobId(msg.jobId);
break;
switch (msg.type) {
case 'job:created':
jobIdRef.current = msg.jobId;
setJobId(msg.jobId);
break;
case 'job:state':
// Reconnection to a completed/failed job
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
case 'job:state':
// Reconnection to a completed/failed job
if (
msg.status === 'completed' ||
msg.status === 'failed' ||
msg.status === 'stopped' ||
msg.status === 'interrupted'
) {
setPhase('done');
if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number });
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
}
break;
case 'pipeline:init':
setSteps(msg.steps);
break;
case 'step:start':
flushStream();
setMessages([]);
setParallelStep(null);
setWaitingStatus(null);
inParallelRef.current = false;
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setWaitingStatus(null);
setCurrentStep((prev) => (prev ? { ...prev, status: 'complete', cost: msg.cost } : null));
if (msg.cost) addCost(msg.cost);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:waiting':
setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel });
break;
case 'step:parallel':
flushStream();
setMessages([]);
setCurrentStep(null);
inParallelRef.current = true;
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
break;
case 'assistant:delta':
// Skip messages from parallel sub-agents (shown in iteration grid instead)
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
}
case 'tool:start':
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: randomId(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
if (inParallelRef.current && msg.iterationLabel) break;
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done');
if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number });
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
}
break;
break;
case 'pipeline:init':
setSteps(msg.steps);
break;
case 'error':
flushStream();
setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]);
setHasError(true);
setPhase('done');
stopTimer();
break;
case 'step:start':
flushStream();
setMessages([]);
setParallelStep(null);
setWaitingStatus(null);
inParallelRef.current = false;
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setWaitingStatus(null);
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
if (msg.cost) addCost(msg.cost);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:waiting':
setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel });
break;
case 'step:parallel':
flushStream();
setMessages([]);
setCurrentStep(null);
inParallelRef.current = true;
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'running' } : it,
),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
break;
case 'assistant:delta':
// Skip messages from parallel sub-agents (shown in iteration grid instead)
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
case 'stopped':
flushStream();
setPhase('done');
stopTimer();
break;
}
case 'tool:start':
if (inParallelRef.current && msg.iterationLabel) break;
setWaitingStatus(null);
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: randomId(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
if (inParallelRef.current && msg.iterationLabel) break;
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done');
stopTimer();
break;
case 'error':
flushStream();
setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]);
setHasError(true);
setPhase('done');
stopTimer();
break;
case 'stopped':
flushStream();
setPhase('done');
stopTimer();
break;
}
}, [flushStream, stopTimer, addCost]);
},
[flushStream, stopTimer, addCost],
);
// The socket below is opened once and must stay open, so its listener is registered once too — and would
// hold the first render's `handleEvent` forever. That closure carries `flushStream`'s captured
@@ -308,32 +365,35 @@ export function usePipelineRunner() {
};
}, []);
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const run = useCallback(
(taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setPhase('running');
setMessages([]);
setStreamingText('');
setTotalCost(null);
setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
setHasError(false);
setSkippedItems([]);
setCurrentStep(null);
setParallelStep(null);
setWaitingStatus(null);
setElapsed(0);
setJobId(null);
jobIdRef.current = null;
streamBufferRef.current = '';
setPhase('running');
setMessages([]);
setStreamingText('');
setTotalCost(null);
setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
setHasError(false);
setSkippedItems([]);
setCurrentStep(null);
setParallelStep(null);
setWaitingStatus(null);
setElapsed(0);
setJobId(null);
jobIdRef.current = null;
streamBufferRef.current = '';
startTimeRef.current = Date.now();
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
startTimeRef.current = Date.now();
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd, model, startAt }));
}, []);
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd, model, startAt }));
},
[],
);
const stop = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return;
@@ -341,7 +401,21 @@ export function usePipelineRunner() {
}, []);
return {
phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText,
totalCost, runningCost, hasError, skippedItems, elapsed, waitingStatus, run, stop,
phase,
isConnected,
jobId,
steps,
currentStep,
parallelStep,
messages,
streamingText,
totalCost,
runningCost,
hasError,
skippedItems,
elapsed,
waitingStatus,
run,
stop,
};
}
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
import { usePinnedFiles } from '../usePinnedFiles';
import { useTasks, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState';
@@ -65,6 +66,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
// Pinning existed only in the widget. The browser is where you meet a file you want to keep to hand.
const { togglePin, isPinned } = usePinnedFiles();
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [clipboard, setClipboard] = useState<ClipboardState>(null);
@@ -500,6 +503,90 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
}
};
/**
* Copy one item next to itself. The server resolves the collision, so this is `copy` into the folder
* it already lives in — `report.pdf` becomes `report (copy).pdf` with no name to invent here.
*
* Deliberately single-entry: it acts on the clicked row, not the selection, because "duplicate these
* six things" is a batch operation whose failure mode (four succeeded) needs reporting this does not do.
*/
const handleDuplicate = async (entry: DirEntry) => {
try {
await files.copy([entryPath(entry.name)], currentPath);
await refresh();
toast.success(`Duplicated "${entry.name}"`);
} catch {
toast.error('Failed to duplicate');
}
};
/** Zip the selection (or the clicked entry) into an archive beside it, and keep it. */
const handleCompress = async (entry: DirEntry) => {
const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
const toastId = toast.loading('Compressing...');
try {
const { name } = await files.compress(paths);
await refresh();
toast.success(`Created "${name}"`, { id: toastId });
} catch {
toast.error('Failed to compress', { id: toastId });
}
};
/**
* Paste INTO a folder rather than into the current one.
*
* The background menu's paste targets `currentPath`, which meant a move was only expressible as
* cut → open the folder → paste. Right-clicking the destination is the obvious gesture and it did
* not exist.
*/
const handlePasteInto = async (entry: DirEntry) => {
if (!clipboard || entry.type !== 'directory') return;
const destination = entryPath(entry.name);
try {
if (clipboard.mode === 'copy') await files.copy(clipboard.paths, destination);
else {
await files.move(clipboard.paths, destination);
setClipboard(null);
}
await refresh();
toast.success(`Pasted into "${entry.name}"`);
} catch {
toast.error('Failed to paste');
}
};
/** Clone straight into the browsed folder, without the toolbar's inline input. */
const handleGitCloneUrl = async (url: string) => {
const trimmed = url.trim();
if (!trimmed) return;
const toastId = toast.loading('Cloning...');
try {
await files.gitClone(trimmed, currentPath);
await refresh();
toast.success('Repository cloned', { id: toastId });
} catch {
toast.error('Failed to clone repository', { id: toastId });
}
};
/**
* Open a text file in the code editor rather than the viewer.
*
* `/code-editor` is on the SAME `files` permission as `/files` (registry.ts), so anyone who can browse
* can edit — there was simply no way to get there from a file. `?file=` is the editor's own param.
*/
const handleOpenInEditor = (entry: DirEntry) => {
navigate(`/code-editor?file=${encodeURIComponent(entryPath(entry.name))}`);
};
const handleTogglePin = (entry: DirEntry) => {
const path = entryPath(entry.name);
const wasPinned = isPinned(path);
togglePin(path, entry.name);
toast.success(wasPinned ? `Unpinned "${entry.name}"` : `Pinned "${entry.name}"`);
};
const handleCut = () => {
const paths = selected.size > 0 ? selectedPaths() : [];
if (paths.length === 0) return;
@@ -736,6 +823,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
handleCut,
handleCopy,
handlePaste,
handleDuplicate,
handleOpenInEditor,
handleTogglePin,
handleCompress,
handlePasteInto,
handleGitCloneUrl,
togglePin,
isPinned,
handleDragEnter,
handleDragLeave,
handleDragOver,
@@ -1,41 +0,0 @@
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.port.onmessage = (e) => {
const incoming = e.data;
const merged = new Float32Array(this.buffer.length + incoming.length);
merged.set(this.buffer);
merged.set(incoming, this.buffer.length);
this.buffer = merged;
};
}
process(_inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channels = output.length;
const frameSize = output[0].length;
const samplesNeeded = frameSize * channels;
if (this.buffer.length >= samplesNeeded) {
// Deinterleave: buffer is interleaved L R L R ...
for (let i = 0; i < frameSize; i++) {
for (let ch = 0; ch < channels; ch++) {
output[ch][i] = this.buffer[i * channels + ch];
}
}
this.buffer = this.buffer.slice(samplesNeeded);
} else {
// Not enough data — output silence
for (let ch = 0; ch < channels; ch++) {
output[ch].fill(0);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
@@ -46,6 +46,10 @@ export const useFilesAPI = (root: string = 'home') => {
})),
}),
/** Zip items into an archive beside them. Unlike `download`, this keeps the result on disk. */
compress: (paths: string[]) =>
client.post<{ ok: boolean; path: string; name: string }>(withRoot('/file-browser/compress'), { paths }),
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
downloadVideo: (url: string, path: string, audioOnly: boolean) =>