fix(workbench): focus a tool window's content once it loads (#1280)

Activating a tool window focuses its `[data-autofocus]` element or first
content field one frame later, else its body. A lazily loaded view such as
the Terminal renders its field after that, so the first ⌥F12 left focus on
the body. When no target exists yet, the tool window now watches its body
and focuses the target when it appears, unless focus has moved on.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 22:52:53 +08:00
1 джерело aa33d2eeab
коміт b440370f2d
2 files changed
+65 -7

No files matched your search

+28
Переглянути файл
@@ -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<void>((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,
}) => {
+37 -7
Переглянути файл
@@ -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<HTMLElement>(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<HTMLElement>("[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<HTMLElement>(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<HTMLElement>("[data-autofocus]") ?? content ?? body ?? root;
target.focus({ preventScroll: true });
const contentTarget = () =>
body?.querySelector<HTMLElement>("[data-autofocus]") ??
[...(body?.querySelectorAll<HTMLElement>(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 (