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');
|
||||
|
||||
Reference in New Issue
Block a user