diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 6f6a79d5..9dc7b88f 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -13,6 +13,35 @@ test("opens a file from the project tree and types into it", async ({ workbench: await expect(editor.locator(".cm-content")).not.toContainText("// typed by e2e"); }); +test("Delete edits text after the focused Project tree is remounted", async ({ workbench: page }) => { + const editor = await openFromTree(page, "README.md"); + const content = editor.locator(".cm-content"); + await content.press("Meta+End"); + await page.keyboard.press("Enter"); + await page.keyboard.insertText("delete probe abc"); + const line = editor.locator(".cm-line", { hasText: "delete probe" }); + await expect(line).toHaveText("delete probe abc"); + + const tree = page.getByRole("tree", { name: "Project" }); + await tree.focus(); + await page.evaluate(async () => { + const keymapModule = "/src/platform/keymap.ts"; + const layoutModule = "/src/workbench/toolwindows/store.ts"; + const { applyKeymapSettings } = await import(/* @vite-ignore */ keymapModule); + const { moveToolWindow } = await import(/* @vite-ignore */ layoutModule); + applyKeymapSettings("intellij-windows", {}); + moveToolWindow("project", "right"); + }); + await expect(page.locator('[data-toolwindow="project"]')).toBeVisible(); + await expect(tree).not.toBeFocused(); + + await content.press("ArrowLeft"); + await page.keyboard.press("Delete"); + await expect(line).toHaveText("delete probe ab"); + await page.keyboard.press("Backspace"); + await expect(line).toHaveText("delete probe a"); +}); + test("⌘F opens the find bar and highlights matches; Escape returns to the editor", async ({ workbench: page }) => { const editor = await openFromTree(page, "build.gradle.kts"); await editor.locator(".cm-content").click(); diff --git a/src/features/database/DatabaseView.tsx b/src/features/database/DatabaseView.tsx index 36abea84..4a71fabb 100644 --- a/src/features/database/DatabaseView.tsx +++ b/src/features/database/DatabaseView.tsx @@ -7,7 +7,6 @@ */ import { useEffect, useMemo, useRef, type CSSProperties } from "react"; import type { DbDatabases, DbTableDetails } from "@/platform/ipc"; -import { setContext } from "@/platform/context"; import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; import { @@ -378,13 +377,10 @@ export function DatabaseView() { return (
-
setContext(DB_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree")} - onBlur={() => setContext(DB_VIEW_FOCUS, false)} - > +
aria-label="Data sources" + focusContext={DB_VIEW_FOCUS} roots={roots} handleRef={tree} expanded={st.treeExpanded} diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 0d796cfa..7ff4a721 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -20,7 +20,6 @@ import { createPortal } from "react-dom"; import clsx from "clsx"; import { revealRange, useEditorRegistry } from "@/editor/api"; import { executeAction } from "@/platform/actions"; -import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { isTauri } from "@/platform/ipc"; import { nativeDropPoint } from "@/platform/nativeDrop"; @@ -779,8 +778,6 @@ export function ProjectView() { onCopy={(e) => onClipboard(e, "project.copy")} onCut={(e) => onClipboard(e, "project.cut")} onPaste={(e) => onClipboard(e, "project.paste")} - onFocus={(e) => setContext("projectViewFocus", (e.target as HTMLElement).getAttribute("role") === "tree")} - onBlur={() => setContext("projectViewFocus", false)} > {mode === "unity" && unity && !unity.projects.length && (
@@ -795,6 +792,7 @@ export function ProjectView() { horizontalScroll aria-label="Project" + focusContext="projectViewFocus" handleRef={setTreeHandle} roots={model.roots} getKey={getKey} diff --git a/src/features/services/ServicesTree.tsx b/src/features/services/ServicesTree.tsx index b259ebf8..83924764 100644 --- a/src/features/services/ServicesTree.tsx +++ b/src/features/services/ServicesTree.tsx @@ -5,7 +5,6 @@ */ import { useEffect, useMemo, useRef } from "react"; import clsx from "clsx"; -import { setContext } from "@/platform/context"; import { useTxTemplate, tx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; @@ -208,15 +207,10 @@ export function ServicesTreeView() { ) : ( -
- setContext(SERVICES_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree") - } - onBlur={() => setContext(SERVICES_VIEW_FOCUS, false)} - > +
key={location} + focusContext={SERVICES_VIEW_FOCUS} reorder={reorder} roots={roots} getKey={(n) => n.key} diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index 38855aef..a89ff4b4 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -30,6 +30,7 @@ import { fuzzyMatch } from "./match"; import { acquirePopup } from "./popupState"; import styles from "./Tree.module.css"; import { T } from "./T"; +import { setContext } from "@/platform/context"; import { tx } from "@/platform/i18n"; import { useTreeReorder, type TreeReorder } from "./treeReorder"; import { useTreeDirectoryContext } from "./treeDirectoryContext"; @@ -116,6 +117,8 @@ export interface TreeProps { rowClassName?: (node: T) => string | undefined; "aria-label"?: string; handleRef?: Ref; + /** Context key set while the tree itself has keyboard focus. */ + focusContext?: string; empty?: ReactNode; /** Rows wider than the pane scroll horizontally instead of clipping. */ horizontalScroll?: boolean; @@ -592,6 +595,31 @@ export function Tree(props: TreeProps) { ); const noMatch = !!query && matchMap.size === 0; + const { focusContext } = props; + useLayoutEffect(() => { + const element = scrollRef.current; + if (!focusContext || !element) return; + let focused = false; + const focus = () => { + focused = true; + setContext(focusContext, true); + }; + const blur = () => { + if (!focused) return; + focused = false; + setContext(focusContext, false); + }; + element.addEventListener("focus", focus); + element.addEventListener("blur", blur); + if (element.ownerDocument.activeElement === element) focus(); + return () => { + element.removeEventListener("focus", focus); + element.removeEventListener("blur", blur); + // Removing a focused tree can skip blur, leaving its shortcuts active. + blur(); + }; + }, [emptyState, focusContext]); + if (emptyState) return
{empty}
; return (