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 ( -
+