diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index c1282f642..b9ba814d8 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -3,6 +3,8 @@ import { createRef } from "react"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { Tree, type TreeHandle } from "./Tree"; import { getContext } from "@/platform/context"; +import { registerAction } from "@/platform/actions"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; interface N { id: string; @@ -251,6 +253,30 @@ describe("Tree speed search", () => { expect(labels(tree)).toContain("App.tsx"); }); + it("⌘⌫ erases the query and ⌥⌫ its last word instead of running the view's ⌘⌫ action", () => { + const drop = vi.fn(); + useKeymap.setState({ preset: "intellij-macos", overrides: { "test.drop": ["Meta+Backspace", "Alt+Backspace"] } }); + const dispose = [registerAction({ id: "test.drop", title: "Drop", run: drop }), installKeyboardDispatcher()]; + try { + const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] }); + key("ArrowDown"); + for (const c of "index.t") key(c); + expect(lead()).toBe("index.ts"); + key("Backspace", { altKey: true }); + expect(screen.getByRole("status")).toHaveTextContent(/^index\.$/); + key("Backspace", { metaKey: true }); + expect(screen.queryByRole("status")).toBeNull(); + expect(drop).not.toHaveBeenCalled(); + // Without a query the shortcut reaches the view again. + key("Backspace", { metaKey: true }); + expect(drop).toHaveBeenCalledOnce(); + expect(tree).toHaveFocus(); + } finally { + dispose.forEach((d) => d()); + useKeymap.setState({ overrides: {} }); + } + }); + it("filter mode hides non-matching rows but keeps ancestors", () => { const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" }); key("ArrowDown"); diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index 99b5bf3a1..2f337523e 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -31,6 +31,7 @@ import { acquirePopup } from "./popupState"; import styles from "./Tree.module.css"; import { T } from "./T"; import { setContext } from "@/platform/context"; +import { registerKeyClaim } from "@/platform/keymap"; import { useGivenTx } from "@/platform/i18n"; import { useTreeReorder, type TreeReorder } from "./treeReorder"; import { startPointerDrag, type DragData } from "./pointerDrag"; @@ -367,10 +368,18 @@ export function Tree(props: TreeProps) { pendingScrollToLead.current = null; }, [lead, indexOfKey, virtualizer]); - // Speed search owns Escape while active. + // Speed search owns Escape while active, and ⌫ with any modifier: the query is edited like a + // text field instead of the view's shortcuts running (⌘⌫ is Drop… in the Database view). useEffect(() => { if (!query) return; - return acquirePopup(); + const release = acquirePopup(); + const unclaim = registerKeyClaim( + (e) => e.key === "Backspace" && e.target instanceof Node && !!scrollRef.current?.contains(e.target), + ); + return () => { + release(); + unclaim(); + }; }, [query]); const setExpanded = (row: Row, open: boolean) => { @@ -524,7 +533,8 @@ export function Tree(props: TreeProps) { break; case "Backspace": if (!query) return; - setQuery(query.slice(0, -1)); + // ⌘⌫ erases the query, ⌥⌫ / Ctrl+⌫ its last word, as in a text field. + setQuery(e.metaKey ? "" : e.altKey || e.ctrlKey ? query.replace(/\w*\W*$/, "") : query.slice(0, -1)); break; case "ArrowDown": case "ArrowUp": {