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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
+177
-226
@@ -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' : '';
|
||||
|
||||
+51
-5
@@ -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>
|
||||
{/* 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
|
||||
|
||||
+10
-2
@@ -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 (
|
||||
<>
|
||||
|
||||
+95
-21
@@ -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,7 +146,8 @@ export function usePipelineRunner() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleEvent = useCallback((msg: ServerMessage) => {
|
||||
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;
|
||||
|
||||
@@ -107,7 +159,12 @@ export function usePipelineRunner() {
|
||||
|
||||
case 'job:state':
|
||||
// Reconnection to a completed/failed job
|
||||
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
|
||||
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);
|
||||
@@ -135,7 +192,7 @@ export function usePipelineRunner() {
|
||||
case 'step:complete':
|
||||
flushStream();
|
||||
setWaitingStatus(null);
|
||||
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
|
||||
setCurrentStep((prev) => (prev ? { ...prev, status: 'complete', cost: msg.cost } : null));
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
break;
|
||||
|
||||
@@ -165,9 +222,7 @@ export function usePipelineRunner() {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'running' } : it,
|
||||
),
|
||||
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
|
||||
};
|
||||
});
|
||||
break;
|
||||
@@ -266,7 +321,9 @@ export function usePipelineRunner() {
|
||||
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,7 +365,8 @@ export function usePipelineRunner() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => {
|
||||
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');
|
||||
@@ -333,7 +391,9 @@ export function usePipelineRunner() {
|
||||
}, 1000);
|
||||
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user