diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx index a3b895b7..56f641ce 100644 --- a/canvas/src/components/tabs/FilesTab.tsx +++ b/canvas/src/components/tabs/FilesTab.tsx @@ -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} diff --git a/canvas/src/components/tabs/FilesTab/FileTree.tsx b/canvas/src/components/tabs/FilesTab/FileTree.tsx index c1de6d09..32d56ebe 100644 --- a/canvas/src/components/tabs/FilesTab/FileTree.tsx +++ b/canvas/src/components/tabs/FilesTab/FileTree.tsx @@ -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; 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 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 (
{nodes.map((node) => ( ))} + {menu && ( + setMenu(null)} + /> + )}
); } @@ -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)} > {isLoading ? "…" : expanded ? "▼" : "▶"} 📁 @@ -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)} > {getIcon(node.name, false)} {node.name} diff --git a/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx b/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx new file mode 100644 index 00000000..ee50001e --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx @@ -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(null); + // First item gets initial focus for keyboard ↓/↑/Enter nav. + const firstItemRef = useRef(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( + "[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 ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} diff --git a/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx new file mode 100644 index 00000000..73a4c4b1 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx @@ -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> = {}) { + const defaults = { + nodes: [file, dir], + selectedPath: null, + onSelect: vi.fn(), + onDelete: vi.fn(), + onDownload: vi.fn(), + canDelete: true, + expandedDirs: new Set(), + onToggleDir: vi.fn(), + loadingDir: null, + }; + const merged = { ...defaults, ...props }; + return { ...render(), 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); + }); +}); diff --git a/canvas/src/components/tabs/FilesTab/useFilesApi.ts b/canvas/src/components/tabs/FilesTab/useFilesApi.ts index 0f2967c3..b1aabbf6 100644 --- a/canvas/src/components/tabs/FilesTab/useFilesApi.ts +++ b/canvas/src/components/tabs/FilesTab/useFilesApi.ts @@ -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,