mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user