Files
orca/src/renderer/src/runtime/workspace-layout-frame-sync.ts
T
Jinwoo HongandE2E Test 98e7dbecfe The window applies each layout update record by record instead of rebuilding from its whole session (dark, layout-owner build only) (#31152)
* The window shows each layout records frame record by record, without reading its session back (dark, layout-owner build only)

A records frame no longer re-serializes the window's whole session and runs the
Loader. Panes' terminal bindings and live titles apply alone, to the tabs the
frame names; any other record builds only its own workspace's slices, with the
view and facts read from the store. Only a workspace the store does not show
from frames yet (startup, a reopened stream, a first visit, one sent whole)
takes the full build.

Tests install an apply check that, after every flush, compares the store with
the full build for the same cache and pre-flush store; the shared layout-apply
transport now sends main's records frames and fails a flush that differs.

* A frame of only tab rows shows without reading the window's session back or running the Loader (test, layout-owner build only)

* The account-restart test's fake binding runtime carries per-pane binding generations, so it passes with the switch on (test only)

* The account-restart test types its store writes, so its five old e2e type errors are gone (test only)

* A workspace build reads the editor selection and groups through the save's own writers, as the full build does (dark, layout-owner build only)

* A tab released from the window's hold shows the binding main wrote meanwhile (test, layout-owner build only)

* The frame-apply check still checks a flush's other workspaces when it builds one in full, and the shared transport keeps a test's own check (test only)

* The frame-apply check is proven to catch a mismatch beside a full build, and the shared transport restores only the check it installed (test only)

* Name the pane tree the window compares by what it holds: the tree without its panes' bindings or live titles (dark, layout-owner build only)

---------

Co-authored-by: E2E Test <e2e@test.local>
2026-10-20 11:28:45 -04:00

359 lines
12 KiB
TypeScript

// The window's layout slices drawn from main's layout stream (design phase E2b), in a layout-owner
// build only. Main publishes every workspace it holds; this window shows a frame's workspace when
// it writes that workspace to a partition this machine owns (a paired server's stay the mirror's)
// and holds it (startup restore keeps only workspaces the catalog knows; a first visit adds one).
//
// A tab the window drew, closed or moved before main answered stays as drawn until the answer is
// in (layout-owner-drawn-tab-holds); everything else shows as each frame arrives.
//
// A records frame shows only what it changed (design E2a): panes' bindings and live titles apply
// alone, and any other record builds its workspace from the store's view. Only a workspace the
// store does not show from frames yet (startup, a reopened stream, a first visit, one sent whole)
// takes the full build, whose view the window reads back through its save and the Loader.
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache'
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
import { terminalLayoutsPatch } from '@/store/workspace-layout-frame-store-patch'
import {
framesDrawWorkspace,
layoutHoldsBoundPane,
onWorkspaceStartsDrawing
} from './workspace-layout-frame-drawn-workspaces'
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
import { liveTitleUpdates } from './workspace-layout-frame-changes'
import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings'
import { terminalLayoutsWithFramePaneTrees } from './workspace-layout-frame-pane-trees'
import {
fullLayoutFrameBuildPatch,
workspaceLayoutFrameBuildPatch,
type LayoutFrameBuildHolds,
type LayoutFrameBuildPatch
} from './workspace-layout-frame-builds'
import {
notePendingRecordsFrame,
pendingWorkspaceChanges,
type PendingWorkspaceChanges
} from './workspace-layout-frame-pending'
import { layoutFrameApplyObserver } from './workspace-layout-frame-apply-observer'
import {
beginLayoutFramePositions,
endLayoutFramePositions,
noteLayoutFramePosition
} from './layout-frame-position'
import {
provideLayoutFrameTerminalPanes,
type LayoutFrameTerminalQueries
} from './layout-frame-terminal-panes'
import {
isTabPaneTreeHeld,
onTabPaneTreeRedraw,
takeTabRowFields
} from './layout-owner-pane-intents'
import {
isDrawnGroupLayoutHeld,
isDrawnTabHeld,
onDrawnTabsRedraw
} from './layout-owner-drawn-tab-holds'
import { subscribeRuntimeLayout } from './runtime-layout-client'
type FrameSyncStore = {
getState: () => AppState
setState: (patch: Partial<AppState>) => void
subscribe: (listener: (state: AppState) => void) => () => void
}
export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & {
/** Shows every workspace the frames own once the snapshot is in, then keeps them current. */
start: () => void
close: () => void
}
export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync {
let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE
/** Workspaces the store shows from frames; any other the frames draw takes a full build. */
const shown = new Set<string>()
/** Workspace key → what its records frames changed since the last flush. */
const pending = new Map<string, PendingWorkspaceChanges>()
let started = false
let live = false
let snapshotReceived = false
let flushQueued = false
let streamOpen = true
/** Workspaces whose pane trees the store may still lack: a build ran for them, or a tab the
* window's own command held skipped one. */
let paneTreeKeys = new Set<string>()
const show = (patch: LayoutFrameBuildPatch): void => {
if (Object.keys(patch).length > 0) {
store.setState(patch)
}
}
// Each change against the cache's copy it applies to, before the frame replaces it.
const noteFrame = (before: WorkspaceLayoutCache, frame: WorkspaceLayoutChangeFrame): void => {
switch (frame.type) {
case 'snapshot':
// A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile.
if (snapshotReceived) {
shown.clear()
}
return
case 'workspace':
case 'removed':
// An older host's whole workspace: only a full build shows it.
shown.delete(frame.key)
return
case 'records':
notePendingRecordsFrame(pending, shown, before, cache, frame)
}
}
// Pane trees show at once; only a tab the window's own pane command holds waits.
const showPaneTrees = (state: AppState): void => {
const shownTrees = terminalLayoutsWithFramePaneTrees(
state.terminalLayoutsByTabId,
cache,
paneTreeKeys,
framesDrawWorkspace(state)
)
paneTreeKeys = shownTrees.waiting
show(terminalLayoutsPatch(state, shownTrees.layouts))
}
// Only panes' bindings changed: the pane trees take them, unless only a build shows them so.
const showFrameBindings = (rebound: ReadonlyMap<string, ReadonlySet<string>>): string[] => {
const state = store.getState()
let layouts = state.terminalLayoutsByTabId
const unshown: string[] = []
for (const [key, tabIds] of rebound) {
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds)
if (next) {
layouts = next
} else {
unshown.push(key)
}
}
show(terminalLayoutsPatch(state, layouts))
return unshown
}
const flush = (): void => {
flushQueued = false
if (!live) {
pending.clear()
return
}
const state = store.getState()
const drawn = framesDrawWorkspace(state)
const rowFieldsTaken = new Map<string, Partial<TerminalTab>>()
const holds: LayoutFrameBuildHolds = {
...FRAME_BUILD_HOLDS,
newRowFields: (tabId) => {
const fields = takeTabRowFields(tabId)
if (fields) {
rowFieldsTaken.set(tabId, fields)
}
return fields
}
}
const changed: string[] = []
const fullBuilt: string[] = []
const workspaceBuilt: string[] = []
const rebound = new Map<string, ReadonlySet<string>>()
const titleUpdates: TerminalTabTitleUpdate[] = []
for (const [key, layout] of Object.entries(cache)) {
const changes = pending.get(key)
if (!drawn(key)) {
// Frames the window did not draw: its next draw takes the full build.
if (changes) {
shown.delete(key)
}
continue
}
if (shown.has(key) && !changes) {
continue
}
changed.push(key)
if (!shown.has(key)) {
fullBuilt.push(key)
} else if (changes?.rebuild) {
workspaceBuilt.push(key)
} else if (changes) {
titleUpdates.push(
...liveTitleUpdates(
changes.titled,
layout,
(tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId
)
)
if (changes.rebound.size > 0) {
rebound.set(key, changes.rebound)
}
}
}
pending.clear()
// A workspace main stopped publishing keeps what the window shows; main has nothing to say.
for (const key of shown) {
if (!(key in cache)) {
shown.delete(key)
}
}
if (fullBuilt.length > 0) {
show(fullLayoutFrameBuildPatch(state, cache, fullBuilt, holds))
}
if (rebound.size > 0) {
workspaceBuilt.push(...showFrameBindings(rebound))
}
if (workspaceBuilt.length > 0) {
show(workspaceLayoutFrameBuildPatch(store.getState(), cache, workspaceBuilt, holds))
}
if (titleUpdates.length > 0) {
store.getState().updateTabTitles(titleUpdates)
}
for (const key of [...fullBuilt, ...workspaceBuilt]) {
shown.add(key)
// Against the store, not the frame: a held tab's tree may lag the pane trees drawn since.
paneTreeKeys.add(key)
}
// A closed stream's cache is frozen: the window draws its own answers then.
if (!streamOpen) {
return
}
if (paneTreeKeys.size > 0) {
showPaneTrees(store.getState())
}
layoutFrameApplyObserver()?.({
before: state,
after: store.getState(),
cache,
changed,
fullBuilt,
workspaceBuilt,
rowFieldsTaken
})
}
const scheduleFlush = (): void => {
if (live && !flushQueued) {
flushQueued = true
// Coalesces the frames of one task.
queueMicrotask(flush)
}
}
// A released tab shows main's copy: its workspace builds from the cache again.
const rebuildWorkspaces = (keys: Iterable<string>): void => {
for (const key of keys) {
if (shown.has(key)) {
pendingWorkspaceChanges(pending, key).rebuild = true
}
}
scheduleFlush()
}
beginLayoutFramePositions()
const stream = subscribeRuntimeLayout(
{ kind: 'local' },
{},
(frame) => {
const before = cache
cache = applyLayoutFrame(cache, frame)
noteFrame(before, frame)
if (frame.type === 'snapshot') {
snapshotReceived = true
live = started
}
scheduleFlush()
},
(status) => {
if (status.state === 'lost' || status.state === 'refused') {
console.warn('[layout-owner] layout stream stopped:', status)
}
if (status.state === 'connecting' && !streamOpen) {
// The local stream reopens after a loss; replies wait for its frames again.
streamOpen = true
beginLayoutFramePositions()
} else if (status.state !== 'connecting' && status.state !== 'live') {
streamOpen = false
endLayoutFramePositions()
}
},
// After the frame's flush. A held tab may still show as the window drew it, so a waiting reply
// checks the store rather than assume the frame shows.
(seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq))
)
const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => {
if (streamOpen) {
rebuildWorkspaces(workspaces)
}
})
// Against the store: the window may have drawn over main's tree for this tab since.
const stopReleaseListener = onTabPaneTreeRedraw((tabId) => {
// A closed stream's cache is frozen: the window draws its own answers then.
if (!streamOpen) {
return
}
rebuildWorkspaces(
Object.entries(cache).flatMap(([key, layout]) =>
layout.tabs.some((tab) => tab.entityId === tabId) ? [key] : []
)
)
})
// A workspace only main held (a CLI-started tab) shows on the window's first visit, so the
// activation finds main's tab rather than seeding a second one.
const stopHeldWorkspacesListener = onWorkspaceStartsDrawing(
store,
() => Object.keys(cache).filter((key) => !shown.has(key)),
() => streamOpen && scheduleFlush()
)
return {
start: () => {
started = true
// Before its snapshot, the snapshot starts it; a stream that ends first leaves restore's.
live = snapshotReceived
// A re-run restore rebuilt the slices, so every workspace is shown again.
shown.clear()
flush()
},
close: () => {
live = false
stopHeldWorkspacesListener()
stopHeldTabsListener()
stopReleaseListener()
stream.close()
},
// Only a pane flush draws: the same workspaces it shows.
holdsTerminalPane: (worktreeId, pane) =>
framesDrawWorkspace(store.getState())(worktreeId) &&
layoutHoldsBoundPane(cache[worktreeId], pane),
// A closed stream's cache is frozen: main may have closed the tab since.
terminalTab: (worktreeId, tabId) =>
streamOpen
? cache[worktreeId]?.tabs.find((tab) => tab.kind === 'terminal' && tab.entityId === tabId)
: undefined
}
}
const FRAME_BUILD_HOLDS: Omit<LayoutFrameBuildHolds, 'newRowFields'> = {
keepsTerminalLayout: isTabPaneTreeHeld,
held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld }
}
let startupFrames: WorkspaceLayoutFrameSync | null = null
/** After restore: the window's one layout stream shows the frames' workspaces from now on. */
export function showStartupLayoutFrames(): void {
// Kept for the document's life: a local stream ends only with main, and a re-run boot reuses it.
if (!startupFrames) {
startupFrames = openWorkspaceLayoutFrameSync(useAppStore)
provideLayoutFrameTerminalPanes(startupFrames)
}
startupFrames.start()
}