diff --git a/e2e/toolwindows.spec.ts b/e2e/toolwindows.spec.ts index 2c1beaf81..dcf00a8db 100644 --- a/e2e/toolwindows.spec.ts +++ b/e2e/toolwindows.spec.ts @@ -31,6 +31,34 @@ test("⌥F12 opens the Terminal tool window and toggles it closed", async ({ wor await expect(terminal).toBeHidden(); }); +test("the first ⌥F12 focuses the terminal input once the lazily loaded view mounts", async ({ workbench: page }) => { + await page.keyboard.press("Alt+F12"); + const terminal = page.getByRole("region", { name: "Terminal", exact: true }); + await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeFocused(); + // Reopened with the view already mounted, it focuses the input as well. + await page.keyboard.press("Alt+F12"); + await expect(terminal).toBeHidden(); + await page.keyboard.press("Alt+F12"); + await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeFocused(); +}); + +test("a terminal view that loads late doesn't take focus moved elsewhere meanwhile", async ({ workbench: page }) => { + let release!: () => void; + const loaded = new Promise((resolve) => (release = resolve)); + await page.route(/\/src\/features\/terminal\/runtime\.ts/, async (route) => { + await loaded; + await route.continue(); + }); + await page.keyboard.press("Alt+F12"); + const terminal = page.getByRole("region", { name: "Terminal", exact: true }); + await expect(terminal).toBeVisible(); + const tree = page.getByRole("tree", { name: "Project" }); + await tree.getByRole("treeitem", { name: "README.md" }).click(); + release(); + await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeAttached(); + await expect(tree).toBeFocused(); +}); + test("Split Right gives the Terminal a tab group with its own header; Unsplit merges it back", async ({ workbench: page, }) => { diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index 07e7afd81..b70cd00df 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -716,6 +716,9 @@ function HeaderRow({ ); } +/** How long an activated tool window waits for lazily loaded content to focus. */ +const LATE_FOCUS_MS = 5000; + function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: boolean }) { const ref = useRef(null); const focusRequest = useToolWindowLayout((s) => s.focusRequest); @@ -726,20 +729,47 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo useEffect(() => { if (!focusRequest || focusRequest.id !== tw.id || hidden) return; + let observer: MutationObserver | null = null; + let timer = 0; + const stop = () => { + observer?.disconnect(); + observer = null; + window.clearTimeout(timer); + }; const raf = requestAnimationFrame(() => { const root = ref.current; if (!root) return; const body = root.querySelector("[data-toolwindow-body]"); // Like IDEA, activation focuses the content (a field, list or tree), never a button or // link: a scripted focus on one draws its focus ring (an empty view's "New…" link). - const content = [...(body?.querySelectorAll(FOCUSABLE) ?? [])].find( - (el) => !el.matches('button, a[href], [role="button"], [role="link"]'), - ); - if (body && !content && !body.hasAttribute("tabindex")) body.tabIndex = -1; - const target = body?.querySelector("[data-autofocus]") ?? content ?? body ?? root; - target.focus({ preventScroll: true }); + const contentTarget = () => + body?.querySelector("[data-autofocus]") ?? + [...(body?.querySelectorAll(FOCUSABLE) ?? [])].find( + (el) => !el.matches('button, a[href], [role="button"], [role="link"]'), + ); + const target = contentTarget(); + if (target || !body) { + (target ?? root).focus({ preventScroll: true }); + return; + } + if (!body.hasAttribute("tabindex")) body.tabIndex = -1; + body.focus({ preventScroll: true }); + // Lazily loaded views (the Terminal) render their field after this: focus it when it + // appears, unless focus has moved on meanwhile. + observer = new MutationObserver(() => { + if (document.activeElement !== body) return stop(); + const late = contentTarget(); + if (!late) return; + stop(); + late.focus({ preventScroll: true }); + }); + observer.observe(body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-autofocus"] }); + timer = window.setTimeout(stop, LATE_FOCUS_MS); }); - return () => cancelAnimationFrame(raf); + return () => { + cancelAnimationFrame(raf); + stop(); + }; }, [focusRequest, tw.id, hidden]); return (