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
This commit is contained in:
Jinwoo Hong
2026-10-06 19:24:47 -04:00
committed by GitHub
parent 31f103535f
commit 53b9ccba6c
5 changed files with 381 additions and 15 deletions
@@ -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
}
}
@@ -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<NonNullable<PaneManagerOptions['onLayoutChanged']>>
let onPaneCreated: Mock<NonNullable<PaneManagerOptions['onPaneCreated']>>
let onPaneClosed: Mock<NonNullable<PaneManagerOptions['onPaneClosed']>>
let manager: InstanceType<typeof PaneManager>
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([])
})
})
@@ -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
}
@@ -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
@@ -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
}