From 1a06f6e9cd36031b4c92ed946d0cd760442239a6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:58:13 +0800 Subject: [PATCH] fix(toolwindows): let a maximized tool window fill the workbench (#2176) Maximize Tool Window sized a window to a percentage, which left a strip of the editor and could shrink a window already larger. A maximized tool window now fills the workbench and hides the editor and other anchors, keeping saved sizes and split ratios for the restore. Opening or focusing an editor restores the layout, and closing the window returns focus to the editor. Fixes #2162 --- e2e/diff-navigation.spec.ts | 12 ++-- e2e/toolwindows.spec.ts | 59 +++++++++++++++++++ src/editor/core/controller.ts | 11 ++++ src/workbench/focus.test.ts | 14 ++++- src/workbench/focus.ts | 9 +++ .../toolwindows/ToolWindows.module.css | 11 +++- src/workbench/toolwindows/ToolWindows.tsx | 38 +++++++++--- src/workbench/toolwindows/header.test.tsx | 17 +++++- src/workbench/toolwindows/store.test.ts | 52 ++++++++++++++++ src/workbench/toolwindows/store.ts | 25 ++++++-- 10 files changed, 227 insertions(+), 21 deletions(-) diff --git a/e2e/diff-navigation.spec.ts b/e2e/diff-navigation.spec.ts index 9e74754bf..e297bd950 100644 --- a/e2e/diff-navigation.spec.ts +++ b/e2e/diff-navigation.spec.ts @@ -56,10 +56,7 @@ test("skips binary and unchanged siblings and restores after changing preview fi const toolbar = await openChain(page); await page.evaluate(async () => { const inputPath = "/src/features/vcs/diffInput.ts"; - const layoutPath = "/src/workbench/toolwindows/store.ts"; const { openDiff, withChain } = await import(inputPath); - const { useToolWindowLayout } = await import(layoutPath); - useToolWindowLayout.setState({ maximized: "left" }); const files = [ ["a.ts", "after\n"], ["binary.dat", "\0binary"], @@ -73,7 +70,13 @@ test("skips binary and unchanged siblings and restores after changing preview fi await openDiff(withChain(files, 0), { preview: true }); }); await expect(toolbar).toContainText("1 difference"); - await toolbar.getByRole("button", { name: "Maximize Editor", exact: true }).click(); + await page.evaluate(async () => { + const layoutPath = "/src/workbench/toolwindows/store.ts"; + const { useToolWindowLayout, hideAllToolWindows } = await import(layoutPath); + useToolWindowLayout.setState({ maximized: "left" }); + hideAllToolWindows(); + }); + await toolbar.locator("..").locator(".cm-content").last().focus(); const selected = page.getByRole("tablist", { name: "Editor tabs" }).locator('[aria-selected="true"]'); await page.keyboard.press("F7"); await page.keyboard.press("F7"); @@ -90,5 +93,6 @@ test("skips binary and unchanged siblings and restores after changing preview fi return (await import(layoutPath)).useToolWindowLayout.getState().maximized; }); expect(maximized).toBe("left"); + await expect(toolbar).toBeHidden(); await expect(page.getByRole("tree", { name: "Project", exact: true })).toBeVisible(); }); diff --git a/e2e/toolwindows.spec.ts b/e2e/toolwindows.spec.ts index dcf00a8db..5b7c41131 100644 --- a/e2e/toolwindows.spec.ts +++ b/e2e/toolwindows.spec.ts @@ -1,5 +1,64 @@ import { expect, ipcCalls, stubDesktop, test, visibleToolWindows } from "./fixtures"; +for (const anchor of ["left", "right", "bottom"] as const) { + test(`maximized ${anchor} Terminal fills the workbench and restores the layout`, async ({ workbench: page }) => { + await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick(); + await expect(page.locator("[data-editor-area] .cm-content")).toBeFocused(); + await page.evaluate(async (anchor) => { + const path = "/src/workbench/toolwindows/store.ts"; + const layout = await import(path); + layout.moveToolWindow("terminal", anchor); + layout.setToolWindowSize(anchor, 650); + layout.showToolWindow("terminal", true); + }, anchor); + const terminal = page.getByRole("region", { name: "Terminal", exact: true }); + const editor = page.locator("[data-editor-area]"); + const input = terminal.getByRole("textbox", { name: "Terminal input" }); + await expect(input).toBeFocused(); + const before = (await terminal.boundingBox())!; + const editorBefore = (await editor.boundingBox())!; + await page.keyboard.press("Meta+Shift+'"); + await expect(editor).toBeHidden(); + await expect(input).toBeFocused(); + const fillsGrid = async () => + terminal.evaluate((el) => { + const panel = el.getBoundingClientRect(); + const grid = el.closest("[data-maximized]")!.getBoundingClientRect(); + return Math.abs(panel.width - grid.width) + Math.abs(panel.height - grid.height); + }); + await expect.poll(fillsGrid).toBeLessThan(2); + await page.setViewportSize({ width: 1100, height: 750 }); + await expect.poll(fillsGrid).toBeLessThan(2); + await page.setViewportSize({ width: 1400, height: 900 }); + await terminal.getByRole("button", { name: "Options", exact: true }).click(); + await page.getByRole("menuitem", { name: "Restore Size", exact: true }).click(); + await expect(editor).toBeVisible(); + await expect.poll(async () => Math.abs((await terminal.boundingBox())!.width - before.width)).toBeLessThan(2); + await expect.poll(async () => Math.abs((await terminal.boundingBox())!.height - before.height)).toBeLessThan(2); + await expect.poll(async () => Math.abs((await editor.boundingBox())!.width - editorBefore.width)).toBeLessThan(2); + await terminal.getByRole("button", { name: "Options", exact: true }).click(); + await page.getByRole("menuitem", { name: "Maximize", exact: true }).click(); + await expect(editor).toBeHidden(); + const title = terminal.locator("header").first().getByText("Terminal", { exact: true }); + await title.dblclick(); + await expect(editor).toBeVisible(); + await title.dblclick(); + await expect(editor).toBeHidden(); + await page.evaluate(async () => { + const path = "/src/workbench/contributions.ts"; + await (await import(path)).editorService().openFile("/dev/acme-api/README.md"); + }); + await expect(editor).toBeVisible(); + await expect(editor.locator(".cm-content")).toBeFocused(); + await input.focus(); + await page.keyboard.press("Meta+Shift+'"); + await expect(editor).toBeHidden(); + await terminal.getByRole("button", { name: "Hide", exact: true }).click(); + await expect(editor).toBeVisible(); + await expect(editor.locator(".cm-content")).toBeFocused(); + }); +} + test("⌘1 focuses, hides and re-shows the Project tool window", async ({ workbench: page }) => { const project = page.getByRole("region", { name: "Project" }); await page.keyboard.press("Meta+1"); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 7073fdf13..cd913ba58 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -35,6 +35,7 @@ import { type TextEditorWrapperContribution, } from "@/workbench/contributions"; import { useDistractionFree } from "@/workbench/distractionFree"; +import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store"; import { ask } from "@/workbench/messages"; import { t } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; @@ -1071,6 +1072,7 @@ export class EditorController implements SlotHost { try { let s = useTabs.getState(); const activate = this.isCurrentOpening(generation); + if (activate) restoreMaximizedToolWindow(); const preview = !!o.preview && editorPrefs().tabs.previewTab && @@ -1180,9 +1182,14 @@ export class EditorController implements SlotHost { } activate(paneId: string, tabId: string, focus = true) { + const restored = restoreMaximizedToolWindow(); ++this.activationGeneration; useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true); if (focus) { + if (restored) { + requestAnimationFrame(() => this.focus()); + return; + } const slot = this.slots.get(tabId); if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; @@ -1594,6 +1601,10 @@ export class EditorController implements SlotHost { // --- service helpers ------------------------------------------------------------------ focus() { + if (restoreMaximizedToolWindow()) { + requestAnimationFrame(() => this.focus()); + return; + } const tab = this.activeTabOf(); if (!tab) { document.querySelector("[data-editor-area]")?.focus({ preventScroll: true }); diff --git a/src/workbench/focus.test.ts b/src/workbench/focus.test.ts index f32b3e590..af1ee43de 100644 --- a/src/workbench/focus.test.ts +++ b/src/workbench/focus.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { setContext } from "@/platform/context"; -import { EDITOR_AREA_SELECTOR, installFocusTracking } from "./focus"; +import { EDITOR_AREA_SELECTOR, focusEditor, installFocusTracking } from "./focus"; describe("focus after popups", () => { afterEach(() => { @@ -14,6 +14,18 @@ describe("focus after popups", () => { return document.querySelector(`${EDITOR_AREA_SELECTOR} .cm-content`)!; } + it("waits for the restored editor to become visible before focusing it", () => { + vi.useFakeTimers(); + const editor = setup(); + const area = editor.parentElement!; + area.hidden = true; + focusEditor(); + expect(document.activeElement).not.toBe(editor); + area.hidden = false; + vi.runAllTimers(); + expect(document.activeElement).toBe(editor); + }); + it("returns focus to the editor when the last popup closes and focus was left nowhere", () => { vi.useFakeTimers(); const editor = setup(); diff --git a/src/workbench/focus.ts b/src/workbench/focus.ts index 55ace3ead..fb824bb66 100644 --- a/src/workbench/focus.ts +++ b/src/workbench/focus.ts @@ -6,6 +6,15 @@ export const EDITOR_AREA_SELECTOR = "[data-editor-area]"; /** Moves focus to the active editor (or the editor area when none is open). */ export function focusEditor() { + if (typeof document !== "undefined" && document.querySelector(EDITOR_AREA_SELECTOR)?.closest("[hidden]")) { + // Restoring the layout commits the editor's visibility on the next render. + requestAnimationFrame(focusEditorNow); + return; + } + focusEditorNow(); +} + +function focusEditorNow() { const service = maybeEditorService(); if (service?.focus) { service.focus(); diff --git a/src/workbench/toolwindows/ToolWindows.module.css b/src/workbench/toolwindows/ToolWindows.module.css index 27d3a5f78..32db496a2 100644 --- a/src/workbench/toolwindows/ToolWindows.module.css +++ b/src/workbench/toolwindows/ToolWindows.module.css @@ -73,6 +73,14 @@ .editorIsland { grid-area: editor; } +.grid[data-maximized] { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + grid-template-areas: none; +} +.grid[data-maximized] > [data-anchor] { + grid-area: 1 / 1; +} /* A side: primary and split areas, each an island; stacked on the left and right, side by side at the bottom. */ .side { @@ -84,7 +92,8 @@ .sideRow { flex-direction: row; } -.island[hidden] { +.island[hidden], +.side[hidden] { display: none; } .side > .splitGap { diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index 64af94552..0a80e408f 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -78,14 +78,18 @@ export function useToolWindows(): ToolWindowContribution[] { /** Visible window per area, ignoring ids that aren't registered (yet) or don't dock there. */ export function useEffectiveVisible(windows: ToolWindowContribution[]): SlotRecord { const visible = useToolWindowLayout((s) => s.visible); + const maximized = useToolWindowLayout((s) => s.maximized); return useMemo(() => { const out = {} as SlotRecord; for (const slot of SLOTS) { const id = visible[slot]; - out[slot] = id && windows.some((w) => w.id === id && slotOf(w) === slot) ? id : null; + out[slot] = + id && windows.some((w) => w.id === id && slotOf(w) === slot && (!maximized || w.anchor === maximized)) + ? id + : null; } return out; - }, [visible, windows]); + }, [visible, windows, maximized]); } // --------------------------------------------------------------------------- @@ -710,7 +714,10 @@ function HeaderRow({ return (
e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)} + onDoubleClick={(e) => { + if (!(e.target as Element).closest('button, a, input, [role="tablist"], [role="separator"]')) + toggleMaximizeToolWindow(tw.id); + }} onMouseDown={(e) => { // A middle click on the header hides the window (like the Hide button). // Not a press on the header's tabs or buttons, which close and act @@ -837,7 +844,6 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo } const MIN: Record = { left: 140, right: 160, bottom: 90 }; -const MAXIMIZED: Record = { left: "82%", right: "82%", bottom: "88%" }; /** The editor keeps at least this much room when a tool window is resized (see the grid template). */ const EDITOR_MIN = { width: 200, height: 60 }; /** Smallest area when a side is split: stacked (left / right) and side by side (bottom). */ @@ -960,7 +966,11 @@ function SidePanel({ ); }; return ( -
+