ux(canvas/files): right-click context menu — Open / Download / Delete (#2999 PR-C)

## Why

User asked for a VSCode-style right-click menu on file rows (#2999):
"right click to have a menu to download". Today the only download
affordance is the toolbar's Export-all (bulk JSON dump), and the
inline ✕ button is the only delete UX (small click target, easy to
miss).

## Fix

1. New `FileTreeContextMenu` component — fixed-position popover with
   Open / Download / Delete items composed per-row (files get all
   three; directories get Delete only since "open a directory in the
   editor" doesn't apply). Esc + outside-click + Tab + scroll
   dismiss. ↓/↑ arrow keys rove focus between menu items. role=menu
   + role=menuitem + autofocus on first item for a11y.

2. Menu state lifted to the top-level `FileTree` (not per-row) so
   opening a second row's menu auto-closes the first — only one
   menu open at a time, matching VSCode/Theia. Pinned by the
   `replaces the first` test.

3. New `downloadFileByPath(path)` in `useFilesApi` — fetches via the
   existing GET /workspaces/<id>/files/<path>?root= endpoint and
   triggers a browser download. Distinct from the existing
   `handleDownloadFile` which downloads the in-editor buffer
   (round-trips unsaved edits to disk); the context-menu download
   targets arbitrary tree rows the user hasn't opened.

4. `canDelete` prop threaded from FilesTab → FileTree → menu →
   item. Same gate as the toolbar (Clear/New/Upload all gated to
   /configs); context menu's Delete renders as disabled with a
   muted background on other roots, matching the "feature exists
   but isn't applicable here" pattern.

## Test coverage

`FileTreeContextMenu.test.tsx` (8 tests):

- File row → menu opens with Open + Download + Delete.
- Directory row → menu opens with Delete only.
- Click Download → onDownload(path) fires + menu closes.
- Click Delete (canDelete=true) → onDelete(path) fires.
- Click Delete (canDelete=false) → onDelete NOT called + menu stays
  open (disabled-state UX).
- Esc dismisses.
- Outside-click (mousedown on document.body) dismisses.
- Opening second context menu replaces the first (only-one-open
  invariant).

Each test uses fireEvent + screen.getByRole, so they fail on a
deleted-code regression — none would pass on the pre-PR shape.

## Three weakest spots (hostile self-review)

1. The menu is positioned at `clientX/clientY` without viewport
   clamping. If the user right-clicks at the very bottom-right of
   the panel, part of the menu may overflow off-screen. VSCode
   handles this by flipping the anchor; we don't yet. Acceptable
   v1 because the FilesTab is fixed-width (≤ side-panel width)
   and the menu is small (140×~80px); the overflow would be a few
   pixels of one item. Filed as a follow-up.

2. Auto-focus on the first item shifts keyboard focus away from
   the row that opened the menu. Closing with Esc returns focus
   to the body, not the row. Same behavior as TerminalTab's
   placeholder + the canvas's other context menus; consistent
   isn't ideal but at least uniform. Documented inline.

3. The download request reuses the API client's 15s default
   timeout — large config files (multi-MB skill bundles) on a
   slow connection could time out. Same risk applies to the
   existing toolbar Export. If we see real download failures we
   can add a `timeoutMs` override at the call site without
   touching the menu.

## Verification

- `npx tsc --noEmit` clean
- 176/176 canvas tab tests pass
- Manual on local dev: right-click a config.yaml row → menu opens
  → click Download → file lands in Downloads. Right-click on
  /home root → Delete renders disabled.

Refs #2999. Pairs with PR-A (backend EIC) — without PR-A the tree
is empty and there's nothing to right-click on a SaaS workspace.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Hongming Wang 2026-05-05 20:26:04 -07:00
parent 3dfeb180ab
commit d88fbb90fb
5 changed files with 408 additions and 7 deletions

View File

@ -45,6 +45,7 @@ export function FilesTab({ workspaceId }: Props) {
readFile,
writeFile,
deleteFile,
downloadFileByPath,
downloadAllFiles,
uploadFiles,
deleteAllFiles,
@ -216,7 +217,15 @@ export function FilesTab({ workspaceId }: Props) {
nodes={tree}
selectedPath={selectedFile}
onSelect={openFile}
// Delete is currently gated to /configs to match the
// toolbar's New / Upload / Clear affordances. Context
// menu and inline ✕ both honour the gate. PR-A made the
// backend EIC delete work on all roots — keeping the
// canvas gate conservative until we want to expose
// /home /workspace deletion intentionally.
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
onDownload={downloadFileByPath}
canDelete={root === "/configs"}
expandedDirs={expandedDirs}
onToggleDir={toggleDir}
loadingDir={loadingDir}

View File

@ -1,41 +1,108 @@
"use client";
import { useState } from "react";
import { type TreeNode, getIcon } from "./tree";
import { FileTreeContextMenu, type MenuItem } from "./FileTreeContextMenu";
interface TreeCallbacks {
selectedPath: string | null;
onSelect: (path: string) => void;
onDelete: (path: string) => void;
/** PR-C: right-click → Download. Files only — directories ignore. */
onDownload: (path: string) => void;
/** Whether the active root permits delete. Wire into the Delete
* context-menu item's `disabled` flag so the user gets the same
* affordance as the toolbar (which gates Clear/New on /configs). */
canDelete: boolean;
expandedDirs: Set<string>;
onToggleDir: (path: string) => void;
loadingDir: string | null;
}
/**
* FileTree renders the workspace tree + owns the right-click
* context-menu state. Lifting the menu state to the tree (vs each
* row) means only one menu is open at a time opening a new row's
* menu auto-closes the prior one. Same UX as VSCode / Theia.
*/
export function FileTree({
nodes,
selectedPath,
onSelect,
onDelete,
onDownload,
canDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth = 0,
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
const [menu, setMenu] = useState<{
x: number;
y: number;
items: MenuItem[];
} | null>(null);
const openContextMenu = (e: React.MouseEvent, node: TreeNode) => {
e.preventDefault();
// Items composed per-row so the available actions reflect the
// node type (files get Download; directories don't have a
// useful per-tree download — the Export toolbar covers bulk).
const items: MenuItem[] = [];
if (!node.isDir) {
items.push({
id: "open",
label: "Open",
icon: "⤴",
onClick: () => onSelect(node.path),
});
items.push({
id: "download",
label: "Download",
icon: "↓",
onClick: () => onDownload(node.path),
});
}
items.push({
id: "delete",
label: "Delete",
icon: "✕",
destructive: true,
disabled: !canDelete,
onClick: () => onDelete(node.path),
});
setMenu({ x: e.clientX, y: e.clientY, items });
};
// Single state lifted to the top-level tree; nested <FileTree>s
// (rendered for expanded directories below) do NOT instantiate
// their own menus — they call the SAME openContextMenu via prop
// drilling. This keeps "only one menu open" the structural
// invariant rather than a render-order coincidence.
const childCallbacks: TreeCallbacks = {
selectedPath, onSelect, onDelete, onDownload, canDelete,
expandedDirs, onToggleDir, loadingDir,
};
return (
<div>
{nodes.map((node) => (
<TreeItem
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
node={node}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
openContextMenu={openContextMenu}
depth={depth}
{...childCallbacks}
/>
))}
{menu && (
<FileTreeContextMenu
x={menu.x}
y={menu.y}
items={menu.items}
onClose={() => setMenu(null)}
/>
)}
</div>
);
}
@ -45,11 +112,18 @@ function TreeItem({
selectedPath,
onSelect,
onDelete,
onDownload,
canDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth,
}: TreeCallbacks & { node: TreeNode; depth: number }) {
openContextMenu,
}: TreeCallbacks & {
node: TreeNode;
depth: number;
openContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
}) {
const isSelected = selectedPath === node.path;
const expanded = expandedDirs.has(node.path);
const isLoading = loadingDir === node.path;
@ -61,6 +135,7 @@ function TreeItem({
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onToggleDir(node.path)}
onContextMenu={(e) => openContextMenu(e, node)}
>
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
@ -82,6 +157,8 @@ function TreeItem({
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
onDownload={onDownload}
canDelete={canDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
@ -99,6 +176,7 @@ function TreeItem({
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
onContextMenu={(e) => openContextMenu(e, node)}
>
<span className="text-[9px]">{getIcon(node.name, false)}</span>
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>

View File

@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef } from "react";
/**
* FileTreeContextMenu VSCode-style right-click menu for a single
* file-tree row. Pops at the cursor's viewport coords; dismisses on
* outside-click, Esc, blur, or scroll.
*
* Why a custom component (no library): the menu is one of several
* "small popovers" in canvas; pulling in a dnd / popover lib for one
* surface adds 10x the bytes of this implementation. The patterns
* (outside-click + Esc + portal-free fixed position) match the
* ContextMenu used in canvas/Toolbar so the keyboard-nav muscle
* memory is uniform.
*
* Items are rendered from a `MenuItem[]` so callers can add/remove
* actions without touching this component (e.g. PR-D will add an
* "Upload to this folder" item for directory rows).
*
* Accessibility:
* - role="menu" + role="menuitem" so screen readers announce the
* surface as a menu, not a generic div.
* - First item gets autofocus so keyboard users can //Enter without
* reaching for the mouse.
* - Esc + outside-click + Tab dismisses; behaves like every other
* menu the user has touched on the canvas.
*/
export interface MenuItem {
/** Stable identifier for testing + analytics. */
id: string;
label: string;
/** Optional left icon glyph; not load-bearing. */
icon?: string;
/** Destructive (rendered in red) — for Delete-class actions. */
destructive?: boolean;
/** Item-specific click handler. The menu auto-closes after onClick
* fires so handlers don't have to call onClose themselves. */
onClick: () => void;
/** Disabled items render but don't fire onClick (useful for
* Delete-on-non-/configs case where the caller wants to surface
* the item but explain it's gated). Currently unused placeholder
* for future options. */
disabled?: boolean;
}
interface Props {
/** Viewport-coordinate position of the cursor that opened the menu. */
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
// First item gets initial focus for keyboard ↓/↑/Enter nav.
const firstItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
firstItemRef.current?.focus();
}, []);
// Outside-click + Esc dismiss. Per memory
// (feedback_abort_controller_for_rerendered_listeners), use an
// AbortController so re-mounts (caller toggles the menu) don't leak
// listeners.
useEffect(() => {
const ctrl = new AbortController();
const onPointerDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
// Roving focus across .menuitem buttons. Doing this with
// tabindex management because Tab / Shift+Tab leave the menu
// (which is the right thing — the user is escaping the menu).
e.preventDefault();
const buttons = ref.current?.querySelectorAll<HTMLButtonElement>(
"[role='menuitem']:not([disabled])",
);
if (!buttons || buttons.length === 0) return;
const arr = Array.from(buttons);
const cur = arr.indexOf(document.activeElement as HTMLButtonElement);
const next =
e.key === "ArrowDown"
? (cur + 1) % arr.length
: (cur - 1 + arr.length) % arr.length;
arr[next].focus();
}
};
// `mousedown` (not `click`) so the menu dismisses BEFORE the
// tree-row's click handler would fire — otherwise clicking
// outside also selects a different row, which is not what the
// user expected when "outside-click closes the menu".
document.addEventListener("mousedown", onPointerDown, { signal: ctrl.signal });
document.addEventListener("keydown", onKeyDown, { signal: ctrl.signal });
// Scroll inside any ancestor also dismisses — the fixed-position
// menu would otherwise stay anchored to viewport coords while the
// row it points at scrolled away. Use capture so we catch scroll
// on inner panels (FileTree's overflow-y-auto wrapper).
document.addEventListener("scroll", onClose, { signal: ctrl.signal, capture: true });
return () => ctrl.abort();
}, [onClose]);
return (
<div
ref={ref}
role="menu"
aria-label="File actions"
className="fixed z-[1000] min-w-[140px] py-1 bg-surface-elevated border border-line/60 rounded-md shadow-xl shadow-black/30 text-[11px]"
style={{ left: x, top: y }}
>
{items.map((item, i) => (
<button
key={item.id}
ref={i === 0 ? firstItemRef : undefined}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
className={
item.destructive
? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus:bg-red-900/30 focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
}
>
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
{item.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,135 @@
// @vitest-environment jsdom
//
// Pins the right-click context menu added in PR-C of issue #2999.
// VSCode-style affordance: Open / Download / Delete on file rows,
// Delete on directory rows. Delete is gated by `canDelete` (parent
// only enables on /configs root, matching the toolbar's gate).
//
// Pinned branches:
// 1. Right-click on a file row opens the menu at the click coords
// with Open + Download + Delete items.
// 2. Right-click on a directory row opens the menu with Delete
// only (no Open/Download — directories don't have one-click
// semantics in this surface).
// 3. Clicking Download fires the onDownload callback with the
// row's path.
// 4. Clicking Delete fires onDelete with the row's path (when
// canDelete=true).
// 5. Delete is disabled in the rendered menu when canDelete=false
// and clicking it does NOT fire onDelete (gate is real).
// 6. Esc dismisses the menu.
// 7. Click outside the menu dismisses it.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
import React from "react";
import { FileTree } from "../FileTree";
import type { TreeNode } from "../tree";
afterEach(cleanup);
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [] };
const dir: TreeNode = {
name: "skills",
path: "skills",
isDir: true,
children: [],
};
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
const defaults = {
nodes: [file, dir],
selectedPath: null,
onSelect: vi.fn(),
onDelete: vi.fn(),
onDownload: vi.fn(),
canDelete: true,
expandedDirs: new Set<string>(),
onToggleDir: vi.fn(),
loadingDir: null,
};
const merged = { ...defaults, ...props };
return { ...render(<FileTree {...merged} />), props: merged };
}
describe("FileTree right-click context menu", () => {
it("right-click on a file row opens menu with Open/Download/Delete", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), {
clientX: 50,
clientY: 100,
});
expect(screen.getByRole("menu")).not.toBeNull();
expect(screen.getByRole("menuitem", { name: /Open/i })).not.toBeNull();
expect(screen.getByRole("menuitem", { name: /Download/i })).not.toBeNull();
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
});
it("right-click on a directory row opens menu with Delete only (no Open/Download)", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 60, clientY: 120 });
expect(screen.getByRole("menu")).not.toBeNull();
expect(screen.queryByRole("menuitem", { name: /Open/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /Download/i })).toBeNull();
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
});
it("clicking Download fires onDownload with the row's path", () => {
const { props } = renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
fireEvent.click(screen.getByRole("menuitem", { name: /Download/i }));
expect(props.onDownload).toHaveBeenCalledWith("config.yaml");
// Menu auto-closes after click.
expect(screen.queryByRole("menu")).toBeNull();
});
it("clicking Delete fires onDelete with the row's path when canDelete=true", () => {
const { props } = renderTree({ canDelete: true });
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
fireEvent.click(screen.getByRole("menuitem", { name: /Delete/i }));
expect(props.onDelete).toHaveBeenCalledWith("config.yaml");
});
it("Delete is disabled when canDelete=false; clicking does not fire onDelete", () => {
const { props } = renderTree({ canDelete: false });
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
const del = screen.getByRole("menuitem", { name: /Delete/i }) as HTMLButtonElement;
expect(del.disabled).toBe(true);
fireEvent.click(del);
expect(props.onDelete).not.toHaveBeenCalled();
// Menu stays open on disabled click — same as VSCode (the user
// can read the disabled-state hint without losing the menu).
expect(screen.getByRole("menu")).not.toBeNull();
});
it("Esc dismisses the menu", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
expect(screen.getByRole("menu")).not.toBeNull();
act(() => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(screen.queryByRole("menu")).toBeNull();
});
it("click outside the menu dismisses it", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
expect(screen.getByRole("menu")).not.toBeNull();
// mousedown on document.body — outside the menu.
act(() => {
fireEvent.mouseDown(document.body);
});
expect(screen.queryByRole("menu")).toBeNull();
});
it("opening a second context menu replaces the first (only one open at a time)", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 10, clientY: 10 });
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 20, clientY: 20 });
// Only one menu in the DOM. The second open replaced the first
// because the menu state is lifted to the FileTree, not per-row.
const menus = screen.getAllByRole("menu");
expect(menus.length).toBe(1);
});
});

View File

@ -90,6 +90,43 @@ export function useFilesApi(workspaceId: string, root: string) {
[workspaceId]
);
/**
* Fetch a file's content from the server and trigger a browser
* download. Used by the right-click "Download" context-menu item
* (PR-C of issue #2999) distinct from `handleDownloadFile` in
* FilesTab which downloads the CURRENTLY-OPEN-IN-EDITOR file from
* the in-memory `editContent` buffer (so unsaved edits round-trip
* to disk). This helper downloads the on-server content, suitable
* for arbitrary tree rows the user hasn't opened.
*/
const downloadFileByPath = useCallback(
async (path: string) => {
try {
const res = await api.get<{ content: string }>(
`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`,
);
// text/plain is correct for the canvas's text-only file
// surface (config.yaml, prompts, skill markdown). Binary
// files would need an Accept-arraybuffer path; the API
// returns string today so this matches the wire shape.
const blob = new Blob([res.content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = path.split("/").pop() || "file";
a.click();
URL.revokeObjectURL(url);
showToast(`Downloaded ${a.download}`, "success");
} catch (e) {
showToast(
`Download failed: ${e instanceof Error ? e.message : "unknown error"}`,
"error",
);
}
},
[workspaceId, root],
);
const downloadAllFiles = useCallback(async () => {
const fileEntries = files.filter((f) => !f.dir);
const results = await Promise.allSettled(
@ -165,6 +202,7 @@ export function useFilesApi(workspaceId: string, root: string) {
readFile,
writeFile,
deleteFile,
downloadFileByPath,
downloadAllFiles,
uploadFiles,
deleteAllFiles,