From d5b8dcbddc0e9178b3caa20c8f31c9514d5ef919 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 4 Sep 2026 01:58:00 +0800 Subject: [PATCH 1/5] fix(file-tree): long-press opens the row context menu on touch devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three ContextMenuTrigger wrappers around FileTreeFile, FileTreeFolder, and the RootDropFolder root-drop menu rendered as bare elements. HTML parser rules disallow from containing
children, so the row div ended up as the span's sibling and Radix's pointerdown / contextmenu handlers — and its WebkitTouchCallout: none style — never reached the actual row. Effect on touch: * iOS Safari showed its native long-press callout (Copy / Share / Save Image), eating the gesture before Radix's 700ms long-press timer could open the menu. * Android Chrome and desktop touchscreens likewise had no path to open the per-row menu. Desktop right-click worked because onContextMenu propagates by capture and bubble independently of the parent-child DOM relationship. Fix: pass asChild to each of the three ContextMenuTrigger instances so Radix's Slot mechanism merges the trigger props onto the row's own div. This matches every other ContextMenuTrigger usage in the codebase (tabs/tab-item, automations-page, settings/skills-settings, settings/mcp-settings, conversations/sidebar-conversation-list, etc.). --- .../layout/aux-panel-file-tree-tab.tsx | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index beb9a76a07..5556b28a36 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -736,7 +736,16 @@ function RenderNode({ return ( - + {/* + asChild merges the Radix trigger's pointerdown / contextmenu handlers + and `WebkitTouchCallout: none` style into the FileTreeFile's own div — + without it, the trigger renders a bare span around a div (which the + HTML parser splits into siblings) and iOS Safari's native callout + eats the long-press gesture before the 700ms Radix timer can open the + menu. See aux-panel-file-tree-tab.tsx around the RootDropFolder + wrapper for the same pattern. + */} + - + {/* + asChild merges the Radix trigger's pointerdown / contextmenu handlers + and `WebkitTouchCallout: none` style into the FileTreeFolder's own div + — same reasoning as the FileTreeFile wrapper above. + */} + {folder?.path && ( - + {/* + asChild merges the Radix trigger's pointerdown / contextmenu + handlers and `WebkitTouchCallout: none` style into the + RootDropFolder's own div. Without it the trigger renders a + bare span, whose HTML parser rules disallow div children — + the div ends up as the span's sibling, iOS Safari shows its + native callout on long-press, and the gesture never reaches + the Radix long-press timer. + */} + {nodes.map((node) => ( From eb3e6377fc36ba022ec5283b2078bb3c42ad3ed5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 4 Sep 2026 11:38:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(file-tree):=20add=20=E2=8B=AF=20menu?= =?UTF-8?q?=20button=20on=20each=20row=20to=20open=20the=20context=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch devices don't get a free way to open the file-tree context menu now that the long-press gesture is reserved for drag. Add a small horizontal three-dots button (MoreHorizontal) on the right of every file, folder, and workspace-root row. Clicking it dispatches a synthetic contextmenu MouseEvent on the row so Radix's existing ContextMenuTrigger opens the same menu the right-click and long-press paths open — no menu items duplicated. - FileTreeFile / FileTreeFolder gain an optional actions?: ReactNode prop rendered inside the existing FileTreeActions wrapper (which already stops click bubbling). - RowMoreButton (new component) walks up to the nearest [data-tree-row-path] ancestor and dispatches the contextmenu event. - Folder.fileTreeTab.moreActions added to all 10 locales. - useLongPressToOpenMenu is kept (and its tests restored) for reuse elsewhere; the file tree now relies on the more button + native right-click instead of the long-press hook. --- src/components/ai-elements/file-tree.tsx | 18 ++ .../layout/aux-panel-file-tree-tab.tsx | 14 +- .../layout/row-more-button.test.tsx | 73 ++++++ src/components/layout/row-more-button.tsx | 76 ++++++ .../use-long-press-to-open-menu.test.tsx | 225 ++++++++++++++++++ src/hooks/use-long-press-to-open-menu.ts | 115 +++++++++ src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 16 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 src/components/layout/row-more-button.test.tsx create mode 100644 src/components/layout/row-more-button.tsx create mode 100644 src/hooks/use-long-press-to-open-menu.test.tsx create mode 100644 src/hooks/use-long-press-to-open-menu.ts diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index ce839f8d3d..f30b1ae11f 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -190,6 +190,13 @@ export type FileTreeFolderProps = HTMLAttributes & { iconClassName?: string suffix?: ReactNode suffixClassName?: string + /** + * Right-aligned trailing widget (e.g. a "more" menu button). Rendered + * inside a `FileTreeActions` wrapper so click/keydown don't bubble up to + * the row's own handlers (e.g. the expand/collapse click). Clicks on the + * widget are the caller's responsibility. + */ + actions?: ReactNode /** * Props applied to the folder's header row (the trigger button) — e.g. * `draggable` and drag/drop handlers for file-tree DnD. Placed on the header @@ -225,6 +232,7 @@ export const FileTreeFolder = ({ iconClassName, suffix, suffixClassName, + actions, rowProps, dropActive, dropTargetDir, @@ -331,6 +339,7 @@ export const FileTreeFolder = ({ {suffix} ) : null} + {actions ? {actions} : null} @@ -365,6 +374,13 @@ export type FileTreeFileProps = HTMLAttributes & { /** Nesting depth (0 = top level). See {@link FileTreeFolderProps.depth}: when * provided the row is full-width and indents its content via padding. */ depth?: number + /** + * Right-aligned trailing widget (e.g. a "more" menu button). Rendered + * inside a `FileTreeActions` wrapper so click/keydown don't bubble up to + * the row's own handlers (e.g. opening a file preview). Clicks on the + * widget are the caller's responsibility. + */ + actions?: ReactNode } export const FileTreeFile = ({ @@ -374,6 +390,7 @@ export const FileTreeFile = ({ depth, className, style, + actions, children, ...props }: FileTreeFileProps) => { @@ -427,6 +444,7 @@ export const FileTreeFile = ({ {name} )} + {actions ? {actions} : null}
) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 5556b28a36..da103ea5c4 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -22,6 +22,7 @@ import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTabStore } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useIsMobile } from "@/hooks/use-mobile" +import { useLongPressToOpenMenu } from "@/hooks/use-long-press-to-open-menu" import { useWorkspaceActions, useWorkspaceFileTabs, @@ -33,6 +34,7 @@ import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-e import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner" import { WorkspaceUploadDialog } from "@/components/layout/workspace-upload-dialog" import { OpenInSubContent } from "@/components/layout/open-in-menu" +import { RowMoreButton } from "@/components/layout/row-more-button" import { createFileTreeEntry, deleteFileTreeEntry, @@ -578,6 +580,7 @@ function RootDropFolder({ path={FILE_TREE_ROOT_PATH} name={name} className="font-medium" + actions={} dropActive={dropActive || desktopDropActive} dropTargetDir="" depth={0} @@ -689,6 +692,11 @@ function RenderNode({ const isGitignoreIgnored = ancestorGitignoreIgnored || gitignoreIgnoredPaths.has(node.path) + // Touch / pen long-press opens this row's context menu. Desktop right-click + // is handled by Radix's own contextmenu listener on the trigger; this hook + // composes alongside it (mouse pointers are ignored). + const longPressHandlers = useLongPressToOpenMenu() + const systemExplorerLabel = typeof navigator === "undefined" ? t("openInFileManager") @@ -745,7 +753,7 @@ function RenderNode({ menu. See aux-panel-file-tree-tab.tsx around the RootDropFolder wrapper for the same pattern. */} - + } /> @@ -929,10 +938,11 @@ function RenderNode({ and `WebkitTouchCallout: none` style into the FileTreeFolder's own div — same reasoning as the FileTreeFile wrapper above. */} - + } suffix={ isLinkedDir ? ( ({ + useTranslations: () => (key: string) => `tr:${key}`, +})) + +interface Fixture { + row: HTMLElement + button: HTMLElement + onContextMenu: ReturnType + onRowClick: ReturnType +} + +function renderInRow(): Fixture { + const onContextMenu = vi.fn() + const onRowClick = vi.fn() + const utils = render( +
+ +
+ ) + // The RowMoreButton needs to find a row ancestor carrying + // `data-tree-row-path` — wrap the rendered tree in that for the dispatched + // event to bubble to. jsdom won't bubble a `contextmenu` event from a + // `div` to its `oncontextmenu` listener unless React registered it, so we + // wire one on the parent ourselves. + const row = utils.container.querySelector( + "[data-tree-row-path]" + ) as HTMLElement + row.addEventListener("contextmenu", onContextMenu as EventListener) + const button = utils.getByLabelText("tr:moreActions") + return { row, button, onContextMenu, onRowClick } +} + +describe("RowMoreButton", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("renders a button labelled with the moreActions translation key", () => { + const { button } = renderInRow() + expect(button.tagName).toBe("BUTTON") + expect(button.getAttribute("aria-label")).toBe("tr:moreActions") + // The icon is hidden from AT — only the label announces the control. + const icon = button.querySelector("svg") + expect(icon?.getAttribute("aria-hidden")).not.toBeNull() + }) + + it("dispatches a contextmenu MouseEvent on the row when clicked", () => { + const { button, onContextMenu } = renderInRow() + fireEvent.click(button, { clientX: 12, clientY: 34 }) + expect(onContextMenu).toHaveBeenCalledTimes(1) + const event = onContextMenu.mock.calls[0][0] as MouseEvent + expect(event.type).toBe("contextmenu") + expect(event.button).toBe(2) + expect(event.clientX).toBe(12) + expect(event.clientY).toBe(34) + expect(event.bubbles).toBe(true) + expect(event.cancelable).toBe(true) + }) + + it("does not bubble the click up to the row's own onClick", () => { + const { button, onRowClick } = renderInRow() + fireEvent.click(button) + expect(onRowClick).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/layout/row-more-button.tsx b/src/components/layout/row-more-button.tsx new file mode 100644 index 0000000000..74c6dce8e2 --- /dev/null +++ b/src/components/layout/row-more-button.tsx @@ -0,0 +1,76 @@ +"use client" + +import { MoreHorizontal } from "lucide-react" +import type { MouseEvent as ReactMouseEvent } from "react" +import { useTranslations } from "next-intl" + +import { cn } from "@/lib/utils" + +interface RowMoreButtonProps { + /** Optional className overrides. */ + className?: string + /** + * Translation namespace override. Defaults to `Folder.fileTreeTab`. Exposed + * because the same button is reused in places whose menus live under a + * different translation key (e.g. the git-changes tab). + */ + i18nNamespace?: "Folder.fileTreeTab" | "Folder.gitChangesTab" +} + +/** + * Tiny horizontal-three-dots button rendered on the right of a tree row. + * Clicking it dispatches a synthetic `contextmenu` MouseEvent on the row so + * the existing Radix `ContextMenu` opens at the button's coordinates. + * + * The row itself owns the context menu (it's the `ContextMenuTrigger` via + * `asChild`); this button is just an alternate, always-visible entry point — + * primarily so touch users have a way to open the menu without resorting to + * long-press (which we want to keep free for drag). + * + * The click is `stopPropagation`-ed so it doesn't fire the row's own + * `onClick` (which would open the file preview / toggle the folder). + */ +export function RowMoreButton({ + className, + i18nNamespace = "Folder.fileTreeTab", +}: RowMoreButtonProps) { + const t = useTranslations(i18nNamespace) + return ( + + ) +} diff --git a/src/hooks/use-long-press-to-open-menu.test.tsx b/src/hooks/use-long-press-to-open-menu.test.tsx new file mode 100644 index 0000000000..33ea125d3e --- /dev/null +++ b/src/hooks/use-long-press-to-open-menu.test.tsx @@ -0,0 +1,225 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { useLongPressToOpenMenu } from "./use-long-press-to-open-menu" + +/** + * jsdom's `fireEvent.pointerDown` drops `pointerType` (it builds a plain + * MouseEvent), so the tests below construct MouseEvent objects directly and + * attach `pointerType` via `Object.defineProperty` — same pattern as + * chat-input.test.tsx. + */ +function makePointerEvent( + type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel", + target: Element, + init: { + clientX?: number + clientY?: number + pointerType: "mouse" | "touch" | "pen" + } +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX, + clientY: init.clientY, + }) + Object.defineProperty(event, "pointerType", { value: init.pointerType }) + target.dispatchEvent(event) + return event +} + +/** Advance vi's fake timers and flush React state queued by their callbacks. */ +function advance(ms: number) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +interface Fixture { + host: HTMLElement + onContextMenu: ReturnType +} + +function renderHost( + options?: Parameters[0] +): Fixture { + const onContextMenu = vi.fn() + function Harness() { + const gesture = useLongPressToOpenMenu(options) + return
+ } + const utils = render() + const host = utils.container.querySelector( + "[data-testid='host']" + ) as HTMLElement + return { host, onContextMenu } +} + +describe("useLongPressToOpenMenu", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("ignores mouse pointerdown entirely", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "mouse", + clientX: 10, + clientY: 20, + }) + advance(1000) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("dispatches a synthetic contextmenu after longPressMs of a still touch", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 50, + clientY: 60, + }) + advance(499) + expect(onContextMenu).not.toHaveBeenCalled() + + advance(1) + expect(onContextMenu).toHaveBeenCalledTimes(1) + const event = onContextMenu.mock.calls[0][0] as MouseEvent + expect(event.button).toBe(2) + expect(event.clientX).toBe(50) + expect(event.clientY).toBe(60) + expect(event.bubbles).toBe(true) + expect(event.cancelable).toBe(true) + }) + + it("also opens on a pen pointer", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "pen", + clientX: 5, + clientY: 5, + }) + advance(500) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("cancels when the touch moves past the move threshold", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 100, + clientY: 100, + }) + advance(300) + makePointerEvent("pointermove", host, { + pointerType: "touch", + clientX: 120, + clientY: 100, + }) + advance(500) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("tolerates micro-moves under the threshold (a still touch)", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 100, + clientY: 100, + }) + advance(200) + makePointerEvent("pointermove", host, { + pointerType: "touch", + clientX: 103, + clientY: 101, + }) + makePointerEvent("pointermove", host, { + pointerType: "touch", + clientX: 105, + clientY: 99, + }) + advance(300) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("cancels on pointerup", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(200) + makePointerEvent("pointerup", host, { pointerType: "touch" }) + advance(500) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("cancels on pointercancel", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(200) + makePointerEvent("pointercancel", host, { pointerType: "touch" }) + advance(500) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("a second touch during a still hold resets the timer", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(400) + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(400) + // First timer (500ms from t=0) would have fired at t=500 — but it was + // cleared by the second pointerdown, and a fresh 500ms timer was armed. + expect(onContextMenu).not.toHaveBeenCalled() + advance(100) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("disabled hook never fires", () => { + const { host, onContextMenu } = renderHost({ enabled: false }) + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(1000) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("mouse pointermove after a touch hold doesn't cancel — pointerType is filtered", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(200) + // A mouse move that happens to bubble through the same element must not + // cancel an in-flight touch gesture. + makePointerEvent("pointermove", host, { + pointerType: "mouse", + clientX: 1000, + clientY: 1000, + }) + advance(300) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/use-long-press-to-open-menu.ts b/src/hooks/use-long-press-to-open-menu.ts new file mode 100644 index 0000000000..16a926923d --- /dev/null +++ b/src/hooks/use-long-press-to-open-menu.ts @@ -0,0 +1,115 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" +import type { PointerEvent as ReactPointerEvent } from "react" + +interface UseLongPressToOpenMenuOptions { + /** When false the hook ignores every gesture and never opens the menu. */ + enabled?: boolean + /** Hold duration before the synthetic contextmenu fires. */ + longPressMs?: number + /** + * Movement in either axis beyond this cancels the in-flight gesture. + * Mirrors the threshold used by `useLongPressDrag` so both gestures behave + * the same way when both hooks are attached to the same element. + */ + moveThresholdPx?: number +} + +/** + * Pointer handlers that open a Radix `ContextMenu` from a touch / pen + * long-press, while leaving desktop right-click to Radix's own contextmenu + * handler. + * + * Spread the four handlers onto a `` (or any + * element that Radix already listens on). The pointerdown handler composes + * with Radix's via `composeEventHandlers` — Radix's own 700ms long-press + * timer keeps running in parallel, but it clears on any `pointermove`, + * including the micro-moves a stationary touch can produce on mobile + * browsers. This hook tolerates movement below `moveThresholdPx` and only + * fires after the finger has been still for the full `longPressMs`. + * + * On fire, it dispatches a synthetic `MouseEvent("contextmenu", { button: 2, + * clientX, clientY })` from the current target. The synthetic event bubbles + * to Radix's `onContextMenu`, which opens the same menu the desktop right- + * click does — single source of truth, no duplication. Mouse pointers are + * ignored so desktop right-click keeps using Radix's native handler. + */ +export function useLongPressToOpenMenu({ + enabled = true, + longPressMs = 500, + moveThresholdPx = 10, +}: UseLongPressToOpenMenuOptions = {}) { + const timerRef = useRef(null) + const startRef = useRef<{ x: number; y: number } | null>(null) + + const clear = useCallback(() => { + if (timerRef.current != null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + startRef.current = null + }, []) + + useEffect( + () => () => { + clear() + }, + [clear] + ) + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!enabled) return + // Desktop right-click has its own contextmenu event — keep Radix's + // native handler in charge of opening the menu there. + if (event.pointerType === "mouse") return + clear() + // Capture the target now — `event.currentTarget` is nulled out by React + // after the handler returns, and we need it 500ms down the line. + const target = event.currentTarget + startRef.current = { x: event.clientX, y: event.clientY } + timerRef.current = window.setTimeout(() => { + timerRef.current = null + target.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + button: 2, + clientX: event.clientX, + clientY: event.clientY, + }) + ) + }, longPressMs) + }, + [enabled, longPressMs, clear] + ) + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + if (!enabled) return + if (event.pointerType === "mouse") return + const start = startRef.current + if (!start) return + const dx = Math.abs(event.clientX - start.x) + const dy = Math.abs(event.clientY - start.y) + if (dx > moveThresholdPx || dy > moveThresholdPx) clear() + }, + [enabled, moveThresholdPx, clear] + ) + + const onPointerUp = useCallback(() => { + clear() + }, [clear]) + + const onPointerCancel = useCallback(() => { + clear() + }, [clear]) + + return { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 9467e11064..f6aca6f20f 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2586,6 +2586,7 @@ "openInTerminal": "الطرفية", "openInCode": "VS Code", "linkedFolder": "مجلد مرتبط", + "moreActions": "المزيد من الإجراءات", "copyPath": "نسخ المسار", "upload": "رفع ملفات/مجلد", "download": "تنزيل ملف", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 203a880c6c..d5dbb8346c 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2586,6 +2586,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Verknüpfter Ordner", + "moreActions": "Weitere Aktionen", "copyPath": "Pfad kopieren", "upload": "Dateien/Ordner hochladen", "download": "Datei herunterladen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7651349bb2..c637c86ae3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2586,6 +2586,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Linked folder", + "moreActions": "More actions", "copyPath": "Copy path", "upload": "Upload files/folder", "download": "Download file", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index ebc43a9c9b..77d07b24c6 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2586,6 +2586,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Carpeta vinculada", + "moreActions": "Más acciones", "copyPath": "Copiar ruta", "upload": "Subir archivos/carpeta", "download": "Descargar archivo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1657772d74..8277c91606 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2586,6 +2586,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Dossier lié", + "moreActions": "Plus d'actions", "copyPath": "Copier le chemin", "upload": "Téléverser fichiers/dossier", "download": "Télécharger le fichier", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index b8bca8be86..1bf9c98c88 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2586,6 +2586,7 @@ "openInTerminal": "ターミナル", "openInCode": "VS Code", "linkedFolder": "リンク済みフォルダー", + "moreActions": "その他のアクション", "copyPath": "パスをコピー", "upload": "ファイル/フォルダをアップロード", "download": "ファイルをダウンロード", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 27afd71192..92bc5a2d52 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2586,6 +2586,7 @@ "openInTerminal": "터미널", "openInCode": "VS Code", "linkedFolder": "연결된 폴더", + "moreActions": "더 많은 작업", "copyPath": "경로 복사", "upload": "파일/폴더 업로드", "download": "파일 다운로드", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c50e325095..a5373fa1d0 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2586,6 +2586,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Pasta vinculada", + "moreActions": "Mais ações", "copyPath": "Copiar caminho", "upload": "Enviar arquivos/pasta", "download": "Baixar arquivo", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b5c056792a..65bb9e1f5e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2586,6 +2586,7 @@ "openInTerminal": "终端", "openInCode": "VS Code", "linkedFolder": "关联的文件夹", + "moreActions": "更多操作", "copyPath": "复制路径", "upload": "上传文件/目录", "download": "下载文件", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index acabf26acf..e018ec8f19 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2586,6 +2586,7 @@ "openInTerminal": "終端", "openInCode": "VS Code", "linkedFolder": "已連結的資料夾", + "moreActions": "更多操作", "copyPath": "複製路徑", "upload": "上傳檔案/目錄", "download": "下載檔案", From 58f90c9f0f49e32aff96a46d7ac6cc175234c217 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 4 Sep 2026 18:30:48 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(file-tree):=20make=20the=20row=20?= =?UTF-8?q?=E2=8B=AF=20button=20open=20the=20menu=20it=20promises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in the row-menu button, three of them regressions the PR introduces. The workspace-root row lost its context menu entirely. `asChild` was added to a `ContextMenuTrigger` whose child is a `DesktopDropDirContext. Provider`: Radix's Slot clones the child ELEMENT and hands it the trigger's props, and a Context.Provider drops every prop it doesn't know — so the trigger rendered NO element at all, and right-click, long-press and the new ⋯ all did nothing on the root row. `RootDropFolder` swallowed its props too. The provider now sits outside the menu and `RootDropFolder` spreads `...props` onto the row. The folder ⋯ was a ` + } + > + + ⋯ + + } + /> + + + ) + return { ...view, consoleError } + } + + it("keeps the folder's action out of the header button", () => { + // The folder header is a native + + ) + return ( @@ -282,66 +372,26 @@ export const FileTreeFolder = ({ tabIndex={keyboardNavigation ? -1 : 0} {...props} > - - - + {header} + + {actions} + +
+ ) : ( + header + )} {/* With explicit `depth`, descendants indent themselves via padding, so this wrapper adds NO left inset (keeping their @@ -378,7 +428,8 @@ export type FileTreeFileProps = HTMLAttributes & { * Right-aligned trailing widget (e.g. a "more" menu button). Rendered * inside a `FileTreeActions` wrapper so click/keydown don't bubble up to * the row's own handlers (e.g. opening a file preview). Clicks on the - * widget are the caller's responsibility. + * widget are the caller's responsibility. See + * {@link FileTreeFolderProps.actions} for the row-hover group name. */ actions?: ReactNode } @@ -417,7 +468,7 @@ export const FileTreeFile = ({
{ }) }) +describe("aux file tree row context menus stay reachable", () => { + // Radix's `asChild` clones the child ELEMENT and hands it the trigger's + // props. A child that drops unknown props — a Context.Provider, or a + // component that doesn't spread `...props` — leaves the trigger with no DOM + // element at all: no listener, no menu, on right-click, long-press, or the + // row's ⋯ button. That failure is silent, so lock the two shapes it needs. + it("never hands an asChild trigger a context provider", () => { + expect(auxSource).not.toMatch( + /]*asChild[^>]*>\s*(\{\/\*[\s\S]*?\*\/\}\s*)?<[A-Z][\w]*\.Provider\b/ + ) + }) + + it("gives the workspace-root trigger the row component itself", () => { + expect(auxSource).toMatch( + /\s* { + const start = auxSource.indexOf("function RootDropFolder(") + expect(start).toBeGreaterThan(-1) + const body = auxSource.slice(start, start + 1200) + // Collected off the signature... + expect(body).toMatch(/\.\.\.props\s*\n\s*\}:/) + // ...and spread onto the FileTreeFolder that renders the row's div. + expect(body).toMatch(/ { it("offers VS Code next to Explorer and Terminal", () => { expect(auxSource).toMatch(/OpenInSubContent/) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index da103ea5c4..8acef2642a 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -9,6 +9,7 @@ import { useMemo, useRef, useState, + type HTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react" @@ -566,20 +567,26 @@ function RootDropFolder({ name, dnd, children, + ...props }: { name: string dnd: TreeDndHandlers children: ReactNode -}) { +} & HTMLAttributes) { const [dropActive, setDropActive] = useState(false) // On desktop the DOM dragover never reaches this row, so also honor the // native-drag highlight broadcast for the workspace root (""). const desktopDropActive = useContext(DesktopDropDirContext) === "" return ( } dropActive={dropActive || desktopDropActive} dropTargetDir="" @@ -2916,18 +2923,23 @@ export function FileTreeTab() { onSelect={handleTreeSelect} > {folder?.path && ( - - {/* - asChild merges the Radix trigger's pointerdown / contextmenu - handlers and `WebkitTouchCallout: none` style into the - RootDropFolder's own div. Without it the trigger renders a - bare span, whose HTML parser rules disallow div children — - the div ends up as the span's sibling, iOS Safari shows its - native callout on long-press, and the gesture never reaches - the Radix long-press timer. - */} - - + + + {/* + asChild merges the Radix trigger's pointerdown / + contextmenu handlers and its `WebkitTouchCallout: none` + style onto the row itself instead of a wrapper , + matching every other ContextMenuTrigger in the codebase. + + The child MUST be a component that forwards the props it + is handed down to a real DOM element. Radix's Slot only + clones the child element — hand it a Context.Provider (or + any component that drops unknown props) and the trigger + renders NOTHING: no listener, no menu, on right-click or + long-press or the ⋯ button. Hence the provider sits + outside, and RootDropFolder spreads `...props`. + */} + {nodes.map((node) => ( ))} - - - - - {t("new")} - - handleRequestCreate("", "file")} - > - {t("newFile")} - - handleRequestCreate("", "dir")} - > - {t("newDirectory")} - - - - - - {t("git")} - - - handleOpenCommitWindow()} - disabled={!gitEnabled} - > - {t("actions.commitCode")} - - void handleAddToVcs(rootTarget)} - disabled={!gitEnabled} - > - {t("actions.addToVcs")} - - - void openWorkingTreeDiff(".", { - mode: "overview", - }) - } - disabled={!gitEnabled} - > - {tCommon("viewDiff")} - - - handleRequestCompareWithBranch(rootTarget) - } - disabled={!gitEnabled} - > - {t("compareWithBranch")} - - handleRequestRollback(rootTarget)} - disabled={!gitEnabled} - > - {t("actions.rollback")} - - - - { - void fetchTree() - }} - > - {t("reloadFromDisk")} - - - - {t("openIn")} - - { - void revealItemInDir(folder.path) - }} - onOpenTerminal={() => { - void handleOpenDirInTerminal( - folder.path, - rootNodeName - ) + + + + + {t("new")} + + + handleRequestCreate("", "file")} + > + {t("newFile")} + + handleRequestCreate("", "dir")} + > + {t("newDirectory")} + + + + + + {t("git")} + + + handleOpenCommitWindow()} + disabled={!gitEnabled} + > + {t("actions.commitCode")} + + void handleAddToVcs(rootTarget)} + disabled={!gitEnabled} + > + {t("actions.addToVcs")} + + + void openWorkingTreeDiff(".", { + mode: "overview", + }) + } + disabled={!gitEnabled} + > + {tCommon("viewDiff")} + + + handleRequestCompareWithBranch(rootTarget) + } + disabled={!gitEnabled} + > + {t("compareWithBranch")} + + handleRequestRollback(rootTarget)} + disabled={!gitEnabled} + > + {t("actions.rollback")} + + + + { + void fetchTree() }} - onOpenCode={() => { - void openInCode(folder.path).catch((error) => { - toast.error(t("toasts.openInCodeFailed"), { - description: toErrorMessage(error), + > + {t("reloadFromDisk")} + + + + {t("openIn")} + + { + void revealItemInDir(folder.path) + }} + onOpenTerminal={() => { + void handleOpenDirInTerminal( + folder.path, + rootNodeName + ) + }} + onOpenCode={() => { + void openInCode(folder.path).catch((error) => { + toast.error(t("toasts.openInCodeFailed"), { + description: toErrorMessage(error), + }) }) + }} + /> + + + void copyPathToClipboard(folder.path, { + success: t("toasts.pathCopied"), + failure: t("toasts.copyPathFailed"), }) - }} - /> - - - void copyPathToClipboard(folder.path, { - success: t("toasts.pathCopied"), - failure: t("toasts.copyPathFailed"), - }) - } - > - {t("copyPath")} - - {webMode && ( - <> - handleRequestUpload("")} - > - {t("upload")} - - - void handleRequestDownloadDir(rootTarget) - } - > - {t("downloadAsZip")} - - - )} - - + } + > + {t("copyPath")} + + {webMode && ( + <> + handleRequestUpload("")} + > + {t("upload")} + + + void handleRequestDownloadDir(rootTarget) + } + > + {t("downloadAsZip")} + + + )} + + + )} diff --git a/src/components/layout/row-more-button.test.tsx b/src/components/layout/row-more-button.test.tsx index 621c515e31..cadfce9b12 100644 --- a/src/components/layout/row-more-button.test.tsx +++ b/src/components/layout/row-more-button.test.tsx @@ -1,41 +1,72 @@ -import { fireEvent, render } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" +import type { ReactNode } from "react" import { afterEach, describe, expect, it, vi } from "vitest" -import { RowMoreButton } from "./row-more-button" - // `next-intl`'s `useTranslations` returns the leaf string for the requested -// key. Stub it to a fixed value so the test only checks button behaviour, not +// key. Stub it to a fixed value so the tests only check button behaviour, not // translation plumbing. vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => `tr:${key}`, })) -interface Fixture { - row: HTMLElement - button: HTMLElement - onContextMenu: ReturnType - onRowClick: ReturnType +import { + FileTree, + FileTreeFile, + FileTreeFolder, +} from "@/components/ai-elements/file-tree" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" + +import { RowMoreButton } from "./row-more-button" + +/** + * The button only makes sense inside the thing it opens, so every test renders + * a real Radix `ContextMenu` around a real file-tree row — the same wiring the + * file tree uses. A test that fires at a bare `
` cannot tell a working + * button from one whose trigger never made it into the DOM. + */ +function renderRow( + row: (actions: ReactNode) => ReactNode, + options: { keyboardNavigation?: boolean } = {} +) { + // `onSelect` is what a click on the row itself fires (open the preview / + // select the folder) — the handler the button must not leak into. + const onRowSelect = vi.fn() + render( + + + + {row()} + + + rename + + + + ) + return { button: screen.getByLabelText("tr:moreActions"), onRowSelect } } -function renderInRow(): Fixture { - const onContextMenu = vi.fn() - const onRowClick = vi.fn() - const utils = render( -
- -
+const fileRow = (actions: ReactNode) => ( + +) + +const folderRow = (actions: ReactNode) => ( + +) + +function openMenuTexts(): string[] { + return [...document.querySelectorAll("[data-slot=context-menu-content]")].map( + (node) => node.textContent ?? "" ) - // The RowMoreButton needs to find a row ancestor carrying - // `data-tree-row-path` — wrap the rendered tree in that for the dispatched - // event to bubble to. jsdom won't bubble a `contextmenu` event from a - // `div` to its `oncontextmenu` listener unless React registered it, so we - // wire one on the parent ourselves. - const row = utils.container.querySelector( - "[data-tree-row-path]" - ) as HTMLElement - row.addEventListener("contextmenu", onContextMenu as EventListener) - const button = utils.getByLabelText("tr:moreActions") - return { row, button, onContextMenu, onRowClick } } describe("RowMoreButton", () => { @@ -43,31 +74,64 @@ describe("RowMoreButton", () => { vi.restoreAllMocks() }) - it("renders a button labelled with the moreActions translation key", () => { - const { button } = renderInRow() + it("renders a labelled menu button with the icon hidden from AT", () => { + const { button } = renderRow(fileRow) expect(button.tagName).toBe("BUTTON") - expect(button.getAttribute("aria-label")).toBe("tr:moreActions") - // The icon is hidden from AT — only the label announces the control. - const icon = button.querySelector("svg") - expect(icon?.getAttribute("aria-hidden")).not.toBeNull() + expect(button).toHaveAttribute("aria-label", "tr:moreActions") + expect(button).toHaveAttribute("aria-haspopup", "menu") + expect(button.querySelector("svg")).toHaveAttribute("aria-hidden") }) - it("dispatches a contextmenu MouseEvent on the row when clicked", () => { - const { button, onContextMenu } = renderInRow() - fireEvent.click(button, { clientX: 12, clientY: 34 }) - expect(onContextMenu).toHaveBeenCalledTimes(1) - const event = onContextMenu.mock.calls[0][0] as MouseEvent + it.each([ + ["file", fileRow], + ["folder", folderRow], + ])("opens the row's own context menu on a %s row", (_kind, row) => { + const { button } = renderRow(row) + expect(openMenuTexts()).toEqual([]) + fireEvent.click(button) + expect(openMenuTexts()).toEqual(["rename"]) + }) + + it("anchors the menu at the button's box, not at the click point", () => { + const { button } = renderRow(fileRow) + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + bottom: 48, + left: 120, + } as DOMRect) + const seen = vi.fn() + button.addEventListener("contextmenu", seen as EventListener) + + // A keyboard activation (Enter/Space on a focused button) reports + // clientX/clientY as 0 — anchoring on those would park the menu in the + // viewport's top-left corner instead of next to the row. + fireEvent.click(button, { clientX: 0, clientY: 0 }) + + const event = seen.mock.calls[0][0] as MouseEvent expect(event.type).toBe("contextmenu") expect(event.button).toBe(2) - expect(event.clientX).toBe(12) - expect(event.clientY).toBe(34) expect(event.bubbles).toBe(true) expect(event.cancelable).toBe(true) + expect([event.clientX, event.clientY]).toEqual([120, 48]) }) - it("does not bubble the click up to the row's own onClick", () => { - const { button, onRowClick } = renderInRow() + it.each([ + ["file", fileRow], + ["folder", folderRow], + ])("does not leak the click into the %s row's own handler", (_kind, row) => { + const { button, onRowSelect } = renderRow(row) fireEvent.click(button) - expect(onRowClick).not.toHaveBeenCalled() + expect(onRowSelect).not.toHaveBeenCalled() + }) + + it("stays out of the tab order inside a roving-focus tree", () => { + // The tree container is the single tab stop and owns the arrow keys; one + // focusable widget per row would put every row back in the tab sequence. + const { button } = renderRow(fileRow, { keyboardNavigation: true }) + expect(button.tabIndex).toBe(-1) + }) + + it("keeps its default tab stop in trees without roving focus", () => { + const { button } = renderRow(fileRow) + expect(button.tabIndex).toBe(0) }) }) diff --git a/src/components/layout/row-more-button.tsx b/src/components/layout/row-more-button.tsx index 74c6dce8e2..acccb1c727 100644 --- a/src/components/layout/row-more-button.tsx +++ b/src/components/layout/row-more-button.tsx @@ -4,69 +4,64 @@ import { MoreHorizontal } from "lucide-react" import type { MouseEvent as ReactMouseEvent } from "react" import { useTranslations } from "next-intl" +import { useFileTreeRovingFocus } from "@/components/ai-elements/file-tree" import { cn } from "@/lib/utils" interface RowMoreButtonProps { /** Optional className overrides. */ className?: string - /** - * Translation namespace override. Defaults to `Folder.fileTreeTab`. Exposed - * because the same button is reused in places whose menus live under a - * different translation key (e.g. the git-changes tab). - */ - i18nNamespace?: "Folder.fileTreeTab" | "Folder.gitChangesTab" } /** * Tiny horizontal-three-dots button rendered on the right of a tree row. - * Clicking it dispatches a synthetic `contextmenu` MouseEvent on the row so - * the existing Radix `ContextMenu` opens at the button's coordinates. + * Clicking it dispatches a synthetic `contextmenu` MouseEvent that bubbles to + * the enclosing Radix `ContextMenuTrigger`, which opens the very same menu + * right-click opens — one source of truth, nothing duplicated. Same trick as + * the sidebar conversation row's ⋯ button. * - * The row itself owns the context menu (it's the `ContextMenuTrigger` via - * `asChild`); this button is just an alternate, always-visible entry point — - * primarily so touch users have a way to open the menu without resorting to - * long-press (which we want to keep free for drag). + * The menu is anchored at the button's own box rather than at the click point: + * a keyboard or programmatic activation reports `clientX/clientY` as 0, which + * would park the menu in the viewport's top-left corner. * - * The click is `stopPropagation`-ed so it doesn't fire the row's own - * `onClick` (which would open the file preview / toggle the folder). + * The click is `stopPropagation`-ed so it doesn't fire the row's own `onClick` + * (which would open the file preview / toggle the folder). + * + * Hidden at rest on pointer devices — right-click is the primary affordance + * there and one ⋯ per file-tree row is a lot of ink. Pinned visible where there + * is no hover to reveal it, which is exactly the touch case this exists for. */ -export function RowMoreButton({ - className, - i18nNamespace = "Folder.fileTreeTab", -}: RowMoreButtonProps) { - const t = useTranslations(i18nNamespace) +export function RowMoreButton({ className }: RowMoreButtonProps) { + const t = useTranslations("Folder.fileTreeTab") + // In roving-focus trees the container is the single tab stop; a focusable + // widget per row would break that (and `FileTreeActions` swallows keydown, so + // the arrow keys would die on it too). + const rovingFocus = useFileTreeRovingFocus() return ( + } + /> + + ) + + const action = screen.getByLabelText("dimmed action") + expect(action.closest(".opacity-70")).not.toBeNull() + }) + it("publishes the row-hover group both rows' actions can reveal from", () => { // The action is hidden at rest and revealed on row hover; :hover only // propagates to ancestors, so the group has to sit on an element that diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index 014a61fc71..6db4fa66b1 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -1,12 +1,6 @@ "use client" -import type { - ButtonHTMLAttributes, - CSSProperties, - HTMLAttributes, - ReactNode, - Ref, -} from "react" +import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from "react" import { Collapsible, @@ -217,13 +211,18 @@ export type FileTreeFolderProps = HTMLAttributes & { */ actions?: ReactNode /** - * Props applied to the folder's header row (the trigger button) — e.g. - * `draggable` and drag/drop handlers for file-tree DnD. Placed on the header - * (not the outer wrapper, which also contains the child rows) so a drop - * targets THIS folder rather than its whole subtree. `onClick`/`type` are - * owned by the folder and are not overridable here. + * Props applied to the folder's header row — e.g. `draggable` and drag/drop + * handlers for file-tree DnD. Placed on the header (not the outer wrapper, + * which also contains the child rows) so a drop targets THIS folder rather + * than its whole subtree. `onClick`/`type` are owned by the folder and are + * not overridable here. + * + * The header row is the `