From bde2aecbe8089a453d73999dfea753263cca3935 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 3 Sep 2026 21:43:14 +0800 Subject: [PATCH 1/6] feat(terminal): mobile virtual key bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an on-screen virtual key bar for the terminal panel so mobile users can send escape / tab / control / arrow / page keys without a hardware keyboard. The bar mirrors the pios implementation: two rows of 14 buttons (ESC / / — HOME ↑ END PGUP | TAB CTRL ALT ← ↓ → PGDN) with a CTRL/ALT latch that wraps the next soft-keyboard input as a control byte or ESC prefix respectively. Pure encoding lives in lib/terminal/keybar.ts — 12 keys + termKeySeq + applyTermMods + kbdLiftPx — covered by 18 unit tests. The new component is purely presentational and uses shadcn Button + Tailwind utilities, so there is no raw CSS. Wiring: - terminal-panel.tsx adds useMediaQuery('(max-width: 768px)') and a localStorage-backed collapse flag (codeg:term-keybar) shared across all tabs, then forwards keybarVisible down. - terminal-tab-bar.tsx gains a ⌨ toggle button (mobile-only, with tooltip + active highlight) that flips the flag. - terminal-view.tsx exposes writeQueueRef so the key bar's bytes feed the existing ordered single-flight write pump (without this the key bar would bypass the queue and scramble with onData on slow transport). onData is wrapped with applyTermMods to honor the latch. The visualViewport observer drives a kbdLift max-height on the outer flex column so the bar lifts above the soft keyboard and xterm auto-refits via the existing ResizeObserver. - workspace/layout.tsx bumps the mobile Drawer from 70vh to 95vh to give the new bar enough room when folded in. i18n: 16 new keys under Folder.terminal.keybar across all 10 locales (symbols are language-neutral; show/hide/label are translated for zh-CN/zh-TW/ja/ko/es/de/fr/pt/ar). English fallbacks keep the type contract aligned. Desktop path is untouched: useMediaQuery stays false, the toggle and the bar never render, and the writeQueueRef sits at null. --- src/app/workspace/layout.tsx | 2 +- src/components/terminal/term-keybar.test.tsx | 99 +++++++++ src/components/terminal/term-keybar.tsx | 201 +++++++++++++++++++ src/components/terminal/terminal-panel.tsx | 51 ++++- src/components/terminal/terminal-tab-bar.tsx | 37 +++- src/components/terminal/terminal-view.tsx | 130 +++++++++++- src/i18n/messages/ar.json | 21 +- src/i18n/messages/de.json | 21 +- src/i18n/messages/en.json | 21 +- src/i18n/messages/es.json | 21 +- src/i18n/messages/fr.json | 21 +- src/i18n/messages/ja.json | 21 +- src/i18n/messages/ko.json | 21 +- src/i18n/messages/pt.json | 21 +- src/i18n/messages/zh-CN.json | 21 +- src/i18n/messages/zh-TW.json | 21 +- src/lib/terminal/keybar.test.ts | 144 +++++++++++++ src/lib/terminal/keybar.ts | 114 +++++++++++ 18 files changed, 972 insertions(+), 16 deletions(-) create mode 100644 src/components/terminal/term-keybar.test.tsx create mode 100644 src/components/terminal/term-keybar.tsx create mode 100644 src/lib/terminal/keybar.test.ts create mode 100644 src/lib/terminal/keybar.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 8f45055ac4..cef8021bb8 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -683,7 +683,7 @@ function MobileFolderWorkspaceShell({ swipeDirection="down" disablePointerDismissal={false} > - + Terminal
diff --git a/src/components/terminal/term-keybar.test.tsx b/src/components/terminal/term-keybar.test.tsx new file mode 100644 index 0000000000..1914519299 --- /dev/null +++ b/src/components/terminal/term-keybar.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { TermKeybar } from "./term-keybar" + +// next-intl is mocked at module level via vitest config; provide the minimum +// shape used by `useTranslations` so `t("...")` returns the key path. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +const NO_MODS = { ctrl: false, alt: false } + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe("", () => { + it("renders all 12 keys + 2 modifier buttons", () => { + render( + {}} onPressKey={() => {}} /> + ) + // Two-row layout, 7 + 7 buttons. + const all = screen.getAllByRole("button") + expect(all).toHaveLength(14) + }) + + it("fires onPressKey with the right key on pointerdown, and skips click", () => { + const onPressKey = vi.fn() + render( + {}} + onPressKey={onPressKey} + /> + ) + + // Find the "up" key by its localized label (we mocked useTranslations to + // return the key path, so labels are "up", "down", etc.). + const upBtn = screen.getByRole("button", { name: "up" }) + fireEvent.pointerDown(upBtn) + + expect(onPressKey).toHaveBeenCalledTimes(1) + expect(onPressKey).toHaveBeenCalledWith("up") + + // Click after pointerdown would double-fire without the swallowClick. + fireEvent.click(upBtn) + expect(onPressKey).toHaveBeenCalledTimes(1) + }) + + it("fires onToggleMod('ctrl' | 'alt') on the modifier buttons", () => { + const onToggleMod = vi.fn() + render( + {}} + /> + ) + + fireEvent.pointerDown(screen.getByRole("button", { name: "ctrl" })) + expect(onToggleMod).toHaveBeenCalledWith("ctrl") + + fireEvent.pointerDown(screen.getByRole("button", { name: "alt" })) + expect(onToggleMod).toHaveBeenCalledWith("alt") + }) + + it("marks CTRL/ALT active when mods prop is true", () => { + render( + {}} + onPressKey={() => {}} + /> + ) + // active class includes `bg-primary text-primary-foreground border-primary` + const ctrl = screen.getByRole("button", { name: "ctrl" }) + expect(ctrl.className).toContain("bg-primary") + + // ALT not armed — no accent. + const alt = screen.getByRole("button", { name: "alt" }) + expect(alt.className).not.toContain("bg-primary") + }) + + it("disables every button when disabled=true", () => { + render( + {}} + onPressKey={() => {}} + disabled + /> + ) + const all = screen.getAllByRole("button") + for (const btn of all) { + expect(btn).toBeDisabled() + } + }) +}) diff --git a/src/components/terminal/term-keybar.tsx b/src/components/terminal/term-keybar.tsx new file mode 100644 index 0000000000..b6a641787e --- /dev/null +++ b/src/components/terminal/term-keybar.tsx @@ -0,0 +1,201 @@ +"use client" + +import { useTranslations } from "next-intl" +import type { MouseEvent, PointerEvent } from "react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { type TermKeyBarKey, type TermMods } from "@/lib/terminal/keybar" + +interface TermKeybarProps { + /** 当前 CTRL/ALT 闩锁状态(驱动按钮高亮)。 */ + mods: TermMods + /** 切换 CTRL/ALT 闩锁。 */ + onToggleMod: (mod: "ctrl" | "alt") => void + /** 按下普通键(ESC/TAB/方向键 等)——不消费闩锁,由父级在发完字节后复位。 */ + onPressKey: (key: TermKeyBarKey) => void + /** IME 组合进行中时禁用整组按钮(避免送出误触发的控制码)。 */ + disabled?: boolean +} + +/** + * 移动端终端虚拟键栏:两行按钮,覆盖 ESC/TAB/方向键/PgUp/PgDn + CTRL/ALT 闩锁。 + * + * 关键细节: + * · `onPointerDown` 触发 + `preventDefault`:按钮不夺走 xterm helper textarea + * 的焦点,软键盘保持弹出。 + * · CTRL/ALT 互斥闩锁:armed 状态由父级 state 控制,本组件只渲染高亮。 + * · 桌面端由父级用媒体查询(`useMediaQuery("(max-width: 768px)")`)判定, + * 此处不再二次过滤,避免 SSR/CSR 不一致导致水合闪烁。 + */ +export function TermKeybar({ + mods, + onToggleMod, + onPressKey, + disabled = false, +}: TermKeybarProps) { + const t = useTranslations("Folder.terminal.keybar") + + // onPointerDown handler — preventDefault keeps the soft keyboard open and the + // xterm helper textarea focused. Calling preventDefault on the React synthetic + // event is enough to suppress the default focus shift; we don't need a native + // addEventListener here. + const handlePointerDown = + (key: TermKeyBarKey) => (e: PointerEvent) => { + e.preventDefault() + onPressKey(key) + } + + const handleModPointerDown = + (mod: "ctrl" | "alt") => (e: PointerEvent) => { + e.preventDefault() + onToggleMod(mod) + } + + // Same handler for click — onPointerDown above already fires the action, so a + // click after touchend would re-fire and double the byte. Suppress it. + const swallowClick = (e: MouseEvent) => { + e.preventDefault() + } + + return ( +
+
+ + + + + + + +
+
+ + + + + + + +
+
+ ) +} + +interface KeyBtnProps { + label: string + active?: boolean + disabled?: boolean + onPointerDown: (e: PointerEvent) => void + onClick: (e: MouseEvent) => void +} + +/** + * 键栏单键: + * · `flex-1 min-w-0`:等宽均分剩余空间,超长 label 截断。 + * · `active`:CTRL/ALT 闩锁 armed 高亮(accent 背景)。 + * · `tabIndex={-1}`:跳过 Tab 焦点环(虚拟键栏不该拦截方向键导航)。 + */ +function KeyBtn({ + label, + active = false, + disabled = false, + onPointerDown, + onClick, +}: KeyBtnProps) { + return ( + + ) +} diff --git a/src/components/terminal/terminal-panel.tsx b/src/components/terminal/terminal-panel.tsx index 40665ee4e3..1dd81bf685 100644 --- a/src/components/terminal/terminal-panel.tsx +++ b/src/components/terminal/terminal-panel.tsx @@ -1,18 +1,66 @@ "use client" +import { useCallback, useEffect, useState } from "react" import { useTerminalContext } from "@/contexts/terminal-context" +import { useMediaQuery } from "@/hooks/use-media-query" import { TerminalTabBar } from "./terminal-tab-bar" import { TerminalView } from "./terminal-view" +const KEYBAR_COLLAPSED_STORAGE_KEY = "codeg:term-keybar" +const MOBILE_BREAKPOINT = "(max-width: 768px)" + +/** + * 终端面板:顶栏(tab + 折叠键栏开关)+ 所有挂载的 xterm。 + * + * 移动端键栏的可见性在面板层决定: + * · `useMediaQuery` 监听窗口宽度; + * · 折叠态持久化到 localStorage,避免多个终端 tab 各自记一份导致切 tab + * 后展开/折叠不一致(与 pios 同样的踩坑修复)。 + */ export function TerminalPanel() { const { isOpen, tabs, activeTabId, markTerminalExited } = useTerminalContext() + const isMobile = useMediaQuery(MOBILE_BREAKPOINT) + + const [keybarCollapsed, setKeybarCollapsed] = useState(() => { + if (typeof window === "undefined") return false + try { + return window.localStorage.getItem(KEYBAR_COLLAPSED_STORAGE_KEY) === "1" + } catch { + return false + } + }) + + // SSR / 初次客户端渲染时 window.matchMedia 默认 matches=false(test-setup.ts + // 的 polyfill),桌面态 hydration 一致后 useMediaQuery 才返回真实值。 + // localStorage 在 SSR 也是 undefined,所以初次 client render 与 SSR 都 + // 是「未折叠」——只有用户主动折叠过才改。 + useEffect(() => { + try { + window.localStorage.setItem( + KEYBAR_COLLAPSED_STORAGE_KEY, + keybarCollapsed ? "1" : "0" + ) + } catch { + // best effort(隐私模式 / 配额超限时静默放弃) + } + }, [keybarCollapsed]) + + const toggleKeybar = useCallback(() => { + setKeybarCollapsed((prev) => !prev) + }, []) + + const keybarVisible = isMobile && !keybarCollapsed return (
- +
{tabs.map((tab) => ( ))} diff --git a/src/components/terminal/terminal-tab-bar.tsx b/src/components/terminal/terminal-tab-bar.tsx index 281c8c73ec..63daa7ce15 100644 --- a/src/components/terminal/terminal-tab-bar.tsx +++ b/src/components/terminal/terminal-tab-bar.tsx @@ -26,7 +26,20 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" -export function TerminalTabBar() { +interface TerminalTabBarProps { + /** 移动端折叠/展开键栏的开关。仅 isMobile 时父级才传 true。 */ + showKeybarToggle?: boolean + /** 当前是否已折叠(驱动 ⌨ 按钮的 active 高亮)。 */ + keybarCollapsed?: boolean + /** 点击 ⌨ 按钮的回调。 */ + onToggleKeybar?: () => void +} + +export function TerminalTabBar({ + showKeybarToggle = false, + keybarCollapsed = false, + onToggleKeybar, +}: TerminalTabBarProps) { const t = useTranslations("Folder.terminal") const ime = useImeGuard() const { shortcuts } = useShortcutSettings() @@ -156,6 +169,28 @@ export function TerminalTabBar() { )} + {showKeybarToggle && onToggleKeybar && ( + + + + + + + {keybarCollapsed ? t("keybar.show") : t("keybar.hide")} + + + + )} @@ -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 4/6] =?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 `