From 53b9ccba6c32f015fafe67bc2c1fdad1a04a4d45 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:24:47 -0400 Subject: [PATCH] refactor(terminal): add in-place pane layout geometry apply (unused) (#25671) * refactor(terminal): add in-place pane layout geometry apply (unused) PaneManager.applyLayoutGeometry applies a layout's split orientation and ratios to the mounted pane tree without remounting panes, and never fires onLayoutChanged. Nothing calls it yet; the live-layout reconciler adopts it in a later change. * fix(lint): merge duplicate removal import in delete-worktree failure toast Main (#25668) introduced a duplicate import that fails the focused code-quality lint. * refactor(terminal): share split ratio read; refuse zoomed or dividerless trees in geometry apply --- .../terminal-pane/layout-serialization.ts | 14 +- .../pane-layout-geometry-apply.test.ts | 238 ++++++++++++++++++ .../pane-layout-geometry-apply.ts | 119 +++++++++ .../src/lib/pane-manager/pane-manager.ts | 15 +- .../src/lib/pane-manager/pane-split-ratio.ts | 10 + 5 files changed, 381 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-split-ratio.ts diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index a1b05fcaa3e..a39fe4d78b9 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -12,6 +12,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' import { isXtermInstanceDisposed } from '@/lib/pane-manager/xterm-instance-disposed' +import { readSplitRatio, SPLIT_RATIO_TOLERANCE } from '@/lib/pane-manager/pane-split-ratio' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { getLeftmostLeafId, @@ -65,15 +66,10 @@ export function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutN // Capture the flex ratio so resized panes survive serialization round-trips. let ratio: number | undefined if (first && second) { - const firstGrow = Number.parseFloat(first.style.flex) || 1 - const secondGrow = Number.parseFloat(second.style.flex) || 1 - const total = firstGrow + secondGrow - if (total > 0) { - const r = firstGrow / total - // Only store if meaningfully different from 0.5 (default equal split) - if (Math.abs(r - 0.5) > 0.005) { - ratio = Math.round(r * 1000) / 1000 - } + const r = readSplitRatio(first, second) + // Only store if meaningfully different from 0.5 (default equal split) + if (Math.abs(r - 0.5) > SPLIT_RATIO_TOLERANCE) { + ratio = Math.round(r * 1000) / 1000 } } diff --git a/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.test.ts b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.test.ts new file mode 100644 index 00000000000..dbb36028eaa --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { PaneManagerOptions } from './pane-manager-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import { serializePaneTree } from '@/components/terminal-pane/layout-serialization' +import { applyExpandedLayoutTo } from '@/components/terminal-pane/expand-collapse' + +// Why: happy-dom has no canvas, so xterm cannot open; geometry never touches the terminal. +vi.mock('./pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('./pane-manager') + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) + +describe('PaneManager.applyLayoutGeometry', () => { + let root: HTMLDivElement + let onLayoutChanged: Mock> + let onPaneCreated: Mock> + let onPaneClosed: Mock> + let manager: InstanceType + + function mountTwoPanes(direction: 'vertical' | 'horizontal', ratio?: number): void { + const first = manager.createInitialPane({ leafId: LEAF_A }) + manager.splitPane(first.id, direction, { leafId: LEAF_B, ratio }) + clearCallbacks() + } + + function mountThreePanes(): void { + const first = manager.createInitialPane({ leafId: LEAF_A }) + const second = manager.splitPane(first.id, 'vertical', { leafId: LEAF_B, ratio: 0.6 }) + expect(second).not.toBeNull() + manager.splitPane(second!.id, 'horizontal', { leafId: LEAF_C }) + clearCallbacks() + } + + // Why: mounting itself fires these, so only calls made by the apply count. + function clearCallbacks(): void { + onLayoutChanged.mockClear() + onPaneCreated.mockClear() + onPaneClosed.mockClear() + } + + function serialized(): TerminalPaneLayoutNode | null { + const top = root.firstElementChild + return top instanceof HTMLElement ? serializePaneTree(top) : null + } + + function paneIdentity(): unknown[] { + return manager.getPanes().flatMap((pane) => [pane.id, pane.container, pane.terminal]) + } + + function recordMutations(): () => MutationRecord[] { + const observer = new MutationObserver(() => {}) + observer.observe(root, { subtree: true, childList: true, attributes: true }) + return () => observer.takeRecords() + } + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', () => {}) + root = document.createElement('div') + document.body.appendChild(root) + onLayoutChanged = vi.fn() + onPaneCreated = vi.fn() + onPaneClosed = vi.fn() + manager = new PaneManager(root, { + linkOpenHint: () => '', + onLayoutChanged, + onPaneCreated, + onPaneClosed + }) + }) + + afterEach(() => { + manager.destroy() + root.remove() + vi.unstubAllGlobals() + }) + + it('applies a same-leaf-set ratio change without remounting panes', () => { + mountTwoPanes('vertical') + const before = paneIdentity() + + const applied = manager.applyLayoutGeometry({ + type: 'split', + direction: 'vertical', + first: leaf(LEAF_A), + second: leaf(LEAF_B), + ratio: 0.3 + }) + + expect(applied).toBe(true) + expect(serialized()).toEqual({ + type: 'split', + direction: 'vertical', + first: leaf(LEAF_A), + second: leaf(LEAF_B), + ratio: 0.3 + }) + expect(paneIdentity()).toEqual(before) + expect(manager.getPanes().every((pane) => pane.container.isConnected)).toBe(true) + expect(onPaneCreated).not.toHaveBeenCalled() + expect(onPaneClosed).not.toHaveBeenCalled() + expect(onLayoutChanged).not.toHaveBeenCalled() + }) + + it('flips orientation in place and swaps the divider to the new axis', () => { + mountTwoPanes('vertical', 0.4) + const before = paneIdentity() + const split = root.firstElementChild + const oldDivider = root.querySelector('.pane-divider') + + const applied = manager.applyLayoutGeometry({ + type: 'split', + direction: 'horizontal', + first: leaf(LEAF_A), + second: leaf(LEAF_B), + ratio: 0.4 + }) + + expect(applied).toBe(true) + expect(root.firstElementChild).toBe(split) + expect(serialized()).toMatchObject({ direction: 'horizontal', ratio: 0.4 }) + const dividers = root.querySelectorAll('.pane-divider') + expect(dividers).toHaveLength(1) + expect(dividers[0]).not.toBe(oldDivider) + expect(dividers[0].classList.contains('is-horizontal')).toBe(true) + expect(paneIdentity()).toEqual(before) + expect(onLayoutChanged).not.toHaveBeenCalled() + }) + + it('makes no DOM writes for a layout identical to the mounted tree', () => { + mountThreePanes() + const layout = serialized() + expect(layout).not.toBeNull() + const takeRecords = recordMutations() + + expect(manager.applyLayoutGeometry(layout!)).toBe(false) + + expect(takeRecords()).toEqual([]) + expect(onLayoutChanged).not.toHaveBeenCalled() + }) + + it('changes only the nested split whose ratio moved', () => { + mountThreePanes() + const before = paneIdentity() + + const next: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: leaf(LEAF_A), + second: { + type: 'split', + direction: 'horizontal', + first: leaf(LEAF_B), + second: leaf(LEAF_C), + ratio: 0.25 + }, + ratio: 0.6 + } + expect(manager.applyLayoutGeometry(next)).toBe(true) + + expect(serialized()).toEqual(next) + expect(paneIdentity()).toEqual(before) + expect(onLayoutChanged).not.toHaveBeenCalled() + }) + + it('leaves the DOM untouched when the layout names a different tree', () => { + mountTwoPanes('vertical') + const takeRecords = recordMutations() + + const swapped = manager.applyLayoutGeometry({ + type: 'split', + direction: 'horizontal', + first: leaf(LEAF_B), + second: leaf(LEAF_A), + ratio: 0.3 + }) + const extraLeaf = manager.applyLayoutGeometry({ + type: 'split', + direction: 'horizontal', + first: leaf(LEAF_A), + second: { type: 'split', direction: 'vertical', first: leaf(LEAF_B), second: leaf(LEAF_C) } + }) + + expect(swapped).toBe(false) + expect(extraLeaf).toBe(false) + expect(takeRecords()).toEqual([]) + }) + + it('leaves a zoomed pane tree untouched', () => { + mountThreePanes() + const zoomed = manager.getPanes().find((pane) => pane.leafId === LEAF_C) + expect(zoomed).toBeDefined() + const expanded = applyExpandedLayoutTo(zoomed!.id, { + managerRef: { current: manager }, + containerRef: { current: root }, + expandedStyleSnapshotRef: { current: new Map() } + }) + expect(expanded).toBe(true) + const takeRecords = recordMutations() + + const applied = manager.applyLayoutGeometry({ + type: 'split', + direction: 'horizontal', + first: leaf(LEAF_A), + second: { type: 'split', direction: 'vertical', first: leaf(LEAF_B), second: leaf(LEAF_C) }, + ratio: 0.3 + }) + + expect(applied).toBe(false) + expect(takeRecords()).toEqual([]) + expect(onLayoutChanged).not.toHaveBeenCalled() + }) + + it('treats a split without a divider as a different tree', () => { + mountTwoPanes('vertical') + root.querySelector('.pane-divider')?.remove() + const takeRecords = recordMutations() + + const applied = manager.applyLayoutGeometry({ + type: 'split', + direction: 'horizontal', + first: leaf(LEAF_A), + second: leaf(LEAF_B) + }) + + expect(applied).toBe(false) + expect(takeRecords()).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts new file mode 100644 index 00000000000..8fe8e23de6f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts @@ -0,0 +1,119 @@ +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { PaneManagerHost } from './pane-manager-host' +import { applyDividerStyles, disposeDivider } from './pane-divider' +import { findPaneChildren } from './pane-tree-equalization' +import { refitPanesUnder } from './pane-tree-ops' +import { readSplitRatio, SPLIT_RATIO_TOLERANCE } from './pane-split-ratio' + +export type { TerminalPaneLayoutNode } + +type GeometryWrite = { + split: HTMLElement + first: HTMLElement + second: HTMLElement + divider: HTMLElement + isVertical: boolean + ratio: number + flipOrientation: boolean + writeRatio: boolean +} + +function findDividers(split: HTMLElement): HTMLElement[] { + return Array.from(split.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && child.classList.contains('pane-divider') + ) +} + +/** False when the DOM's tree differs from `node`, so a mismatch never applies partially. */ +function planGeometryWrites( + node: TerminalPaneLayoutNode, + el: HTMLElement, + writes: GeometryWrite[] +): boolean { + if (node.type === 'leaf') { + return el.classList.contains('pane') && el.dataset.leafId === node.leafId + } + if (!el.classList.contains('pane-split')) { + return false + } + const [first, second, extra] = findPaneChildren(el) + const [divider, extraDivider] = findDividers(el) + if (!first || !second || extra || !divider || extraDivider) { + return false + } + // Why: a zoomed pane hides its siblings and owns their flex; collapse restores saved styles over any write. + if (first.style.display === 'none' || second.style.display === 'none') { + return false + } + if (!planGeometryWrites(node.first, first, writes)) { + return false + } + if (!planGeometryWrites(node.second, second, writes)) { + return false + } + const isVertical = node.direction === 'vertical' + const flipOrientation = el.classList.contains('is-vertical') !== isVertical + // Why: replay ignores an out-of-range ratio too (wrapInSplit), leaving an equal split. + const ratio = node.ratio !== undefined && node.ratio > 0 && node.ratio < 1 ? node.ratio : 0.5 + const writeRatio = Math.abs(readSplitRatio(first, second) - ratio) > SPLIT_RATIO_TOLERANCE + if (flipOrientation || writeRatio) { + writes.push({ + split: el, + first, + second, + divider, + isVertical, + ratio, + flipOrientation, + writeRatio + }) + } + return true +} + +function flipSplitOrientation( + write: GeometryWrite, + createDivider: (isVertical: boolean) => HTMLElement +): void { + const { split, isVertical } = write + split.classList.toggle('is-vertical', isVertical) + split.classList.toggle('is-horizontal', !isVertical) + split.style.flexDirection = isVertical ? 'row' : 'column' + // Why: a divider binds its drag axis at creation, so an orientation flip needs a new one. + disposeDivider(write.divider) + split.replaceChild(createDivider(isVertical), write.divider) +} + +/** + * Applies split orientation and ratios from `layout` to the mounted pane tree + * in place. Only applies when the DOM already holds exactly the layout's + * leaves in the same tree shape, unzoomed; returns whether anything changed. + */ +export function applyPaneLayoutGeometry( + host: PaneManagerHost, + layout: TerminalPaneLayoutNode +): boolean { + const top = host.root.firstElementChild + if (!(top instanceof HTMLElement)) { + return false + } + const writes: GeometryWrite[] = [] + if (!planGeometryWrites(layout, top, writes) || writes.length === 0) { + return false + } + for (const write of writes) { + if (write.flipOrientation) { + flipSplitOrientation(write, host.createDivider) + } + if (write.writeRatio) { + write.first.style.flex = `${write.ratio} 1 0%` + write.second.style.flex = `${1 - write.ratio} 1 0%` + } + } + applyDividerStyles(host.root, host.getStyleOptions()) + for (const write of writes) { + refitPanesUnder(write.split, host.panes) + } + return true +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 732d2a5409a..4c9b94ec9dd 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -57,6 +57,7 @@ import { refreshAllPaneTerminals } from './pane-manager-layout-sweeps' import { collectPaneRenderingDiagnostics } from './pane-rendering-diagnostics' +import { applyPaneLayoutGeometry, type TerminalPaneLayoutNode } from './pane-layout-geometry-apply' import { FIRST_PANE_ID } from '../../../../shared/pane-key' export type { @@ -108,15 +109,11 @@ export class PaneManager { publishPaneCreated: (pane, spawnHints) => publishManagedPaneCreated(this.host, pane, spawnHints), getDragCallbacks: () => createPaneDragCallbacks(this.host), - setActivePane: (paneId, opts) => { - this.setActivePane(paneId, opts) - }, + setActivePane: (paneId, opts) => this.setActivePane(paneId, opts), setActivePaneId: (paneId) => { this.activePaneId = paneId }, - requestPaneReparentFrame: (callback) => { - this.reparentFrames.request(callback) - } + requestPaneReparentFrame: (callback) => this.reparentFrames.request(callback) } // Why: atlas recovery must reach every live manager — see // resetAndRefreshAllTerminalWebglAtlases for the shared-atlas rationale. @@ -188,6 +185,12 @@ export class PaneManager { equalizeManagedPaneSizes(this.panes, this.root, this.options.onLayoutChanged) } + /** Applies a host layout's split orientation and ratios to mounted panes in place. + * Why no onLayoutChanged: an applied host geometry must never persist or push back. */ + applyLayoutGeometry(layout: TerminalPaneLayoutNode): boolean { + return applyPaneLayoutGeometry(this.host, layout) + } + getActivePane(): ManagedPane | null { if (this.activePaneId === null) { return null diff --git a/src/renderer/src/lib/pane-manager/pane-split-ratio.ts b/src/renderer/src/lib/pane-manager/pane-split-ratio.ts new file mode 100644 index 00000000000..fb8bf9b6cf6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-split-ratio.ts @@ -0,0 +1,10 @@ +// Why: serialization drops ratios this close to 0.5, so an apply within it must not write either. +export const SPLIT_RATIO_TOLERANCE = 0.005 + +/** First child's share of a split, read from the two children's flex-grow. */ +export function readSplitRatio(first: HTMLElement, second: HTMLElement): number { + const firstGrow = Number.parseFloat(first.style.flex) || 1 + const secondGrow = Number.parseFloat(second.style.flex) || 1 + const total = firstGrow + secondGrow + return total > 0 ? firstGrow / total : 0.5 +}