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');