mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(layout): draw local and SSH layouts from main's layout stream behind the layout-owner build switch (#29221)
* feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the window's layout command to the stored partition through the shared applyLayoutCommand, minting new ids. A user split then waits for main's answer and mounts the pane id main minted. Every other build substitutes 'window', so neither branch is compiled in. * fix(layout): write only the records a layout.apply command touched A load-then-save rewrite also normalizes records the command never touched. The command's effect is now the diff between the save before and after it, written onto the stored partition record by record. A split whose main reply says a paired server holds the tab splits as today. * fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result * fix(layout): leave a split to today's path only when the workspace's row names a paired server The split switch point asked which host to route to with a helper that falls back to the focused server, so a focused server could claim a workspace whose row names no owner. Use the same owner test the tab mirror uses. * test(layout): type the split owner mock without a cast * feat(layout): draw local and SSH workspace layouts from main's layout stream behind ORCA_LAYOUT_OWNER * fix(layout): keep the frame sync out of window-owned builds' live code * fix(layout): keep each row's live terminal binding and pane state across frames * chore(layout): count the frame applier's layout writes and drop test casts * fix(layout): hold frames while any save is owed, scope the view read-back to rebuilt workspaces * fix(layout): keep the frame sync's module init out of window-owned builds * docs(layout): name the read-back bucket and the failed-save hold
This commit is contained in:
@@ -118,3 +118,4 @@
|
||||
2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts
|
||||
2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts
|
||||
4 src/renderer/src/store/workspace-layout-frame-slices.ts
|
||||
12 src/renderer/src/store/workspace-layout-frame-store-patch.ts
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { showStartupLayoutFrames } from '../runtime/workspace-layout-frame-sync'
|
||||
|
||||
/** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */
|
||||
export function reconcileHydratedWorkspaceTabModels(
|
||||
@@ -11,5 +13,11 @@ export function reconcileHydratedWorkspaceTabModels(
|
||||
if (reconciled.length > 0) {
|
||||
reconcileWorktreeTabModels(reconciled)
|
||||
}
|
||||
// Switch point: from main's first layout snapshot on, the frames' workspaces replace what restore
|
||||
// built for them. Records frames do not carry (sleeping agents, closed tabs, default-tab marks,
|
||||
// what reattaches) and the view stay restore's.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
showStartupLayoutFrames()
|
||||
}
|
||||
return reconciled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { createSessionWriteSubscriber } from './session-write-subscriber'
|
||||
import { isWindowSessionWriteBusy } from './window-session-write-activity'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
describe('session write activity in a layout-owner build', () => {
|
||||
let initial: AppState
|
||||
let dispose: (() => void) | undefined
|
||||
beforeEach(() => {
|
||||
initial = useAppStore.getState()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initial, true)
|
||||
})
|
||||
|
||||
it('is busy from a change until main acknowledges its write', async () => {
|
||||
let acknowledge!: () => void
|
||||
const persist = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
acknowledge = resolve
|
||||
})
|
||||
)
|
||||
dispose = createSessionWriteSubscriber({ store: useAppStore, persist })
|
||||
useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true })
|
||||
// The first write after the gate opens.
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
acknowledge()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isWindowSessionWriteBusy()).toBe(false)
|
||||
|
||||
useAppStore.setState({ activeTabId: 'tab-2' })
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
acknowledge()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isWindowSessionWriteBusy()).toBe(false)
|
||||
})
|
||||
|
||||
it('is busy before the window may save, and after a failed write until it resends', async () => {
|
||||
const persist = vi.fn(() => Promise.reject(new Error('main unavailable')))
|
||||
dispose = createSessionWriteSubscriber({
|
||||
store: useAppStore,
|
||||
persist,
|
||||
onPersistError: () => {}
|
||||
})
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true })
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
})
|
||||
|
||||
it('is idle once disposed', () => {
|
||||
dispose = createSessionWriteSubscriber({ store: useAppStore, persist: vi.fn() })
|
||||
useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true })
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
dispose()
|
||||
dispose = undefined
|
||||
expect(isWindowSessionWriteBusy()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@ import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-st
|
||||
import { SESSION_RELEVANT_FIELDS, shouldPersistWorkspaceSession } from './workspace-session'
|
||||
import { buildWorkspaceSessionPatch } from './workspace-session-patch'
|
||||
import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { reportWindowSessionWriteActivity } from './window-session-write-activity'
|
||||
|
||||
type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number]
|
||||
type TabsByWorktree = AppState['tabsByWorktree']
|
||||
@@ -124,10 +126,21 @@ export function createSessionWriteSubscriber({
|
||||
const pendingChangedFields = new Set<SessionRelevantField>()
|
||||
const terminalTabsProjection = createTerminalSessionTabsProjection()
|
||||
const unifiedTabsProjection = createUnifiedSessionTabsProjection()
|
||||
// Switch point (each report below, inline so a window-owned build keeps none of it): layout
|
||||
// frames wait while this window owes main a write, including before it may save at all.
|
||||
const owesWrite = (): boolean =>
|
||||
!disposed &&
|
||||
(timer !== null ||
|
||||
writing ||
|
||||
pendingChangedFields.size > 0 ||
|
||||
!shouldPersistWorkspaceSession(store.getState()))
|
||||
|
||||
const flushPendingWrite = (): void => {
|
||||
timer = null
|
||||
if (disposed || writing || pendingChangedFields.size === 0) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: rebuild from the freshest store state rather than the snapshot
|
||||
@@ -148,6 +161,9 @@ export function createSessionWriteSubscriber({
|
||||
pendingChangedFields.clear()
|
||||
const patch = buildWorkspaceSessionPatch(fresh, changed)
|
||||
if (Object.keys(patch).length === 0) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
return
|
||||
}
|
||||
writing = true
|
||||
@@ -165,6 +181,10 @@ export function createSessionWriteSubscriber({
|
||||
} else if (pendingChangedFields.size > 0) {
|
||||
armFlushTimer()
|
||||
}
|
||||
// A failed write stays owed, so structure frames wait for the next store wake's resend.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = persist({ patch })
|
||||
@@ -189,6 +209,9 @@ export function createSessionWriteSubscriber({
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(flushPendingWrite, debounceMs)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,6 +242,9 @@ export function createSessionWriteSubscriber({
|
||||
|
||||
const evaluateSessionState = (state: AppState): void => {
|
||||
if (!shouldPersistWorkspaceSession(state)) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: this fires on every store write and almost none of them touch a session field. Scan
|
||||
@@ -281,5 +307,8 @@ export function createSessionWriteSubscriber({
|
||||
clearTimeout(timer)
|
||||
}
|
||||
pendingChangedFields.clear()
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Whether this window owes main a session write: one is scheduled or not yet acknowledged. Fed by
|
||||
// the session write subscriber in a layout-owner build only; layout frames wait while it is busy,
|
||||
// so a frame never replaces a change main has not received yet.
|
||||
|
||||
let busy = false
|
||||
let idleListener: (() => void) | null = null
|
||||
|
||||
export function reportWindowSessionWriteActivity(nextBusy: boolean): void {
|
||||
if (busy === nextBusy) {
|
||||
return
|
||||
}
|
||||
busy = nextBusy
|
||||
if (!busy) {
|
||||
idleListener?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function isWindowSessionWriteBusy(): boolean {
|
||||
return busy
|
||||
}
|
||||
|
||||
/** The one listener: the window's layout frame sync. */
|
||||
export function onWindowSessionWriteIdle(listener: () => void): () => void {
|
||||
idleListener = listener
|
||||
return () => {
|
||||
if (idleListener === listener) {
|
||||
idleListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import {
|
||||
localDesktopSession,
|
||||
WORKSPACE_LAYOUT_PROFILES
|
||||
} from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { reportWindowSessionWriteActivity } from '../lib/window-session-write-activity'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import type { LayoutFrameSlices } from '../store/workspace-layout-frame-slices'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
type StreamDouble = {
|
||||
onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null
|
||||
onStatus: ((status: { state: string }) => void) | null
|
||||
close: () => void
|
||||
}
|
||||
const stream = vi.hoisted((): StreamDouble => ({ onFrame: null, onStatus: null, close: () => {} }))
|
||||
vi.mock('./runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void,
|
||||
onStatus: (status: { state: string }) => void
|
||||
) => {
|
||||
stream.onFrame = onFrame
|
||||
stream.onStatus = onStatus
|
||||
return { close: stream.close }
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const SLICES: (keyof LayoutFrameSlices)[] = [
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId',
|
||||
'activeTabIdByWorktree',
|
||||
'unifiedTabsByWorktree',
|
||||
'groupsByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'layoutByWorktree',
|
||||
'openFiles',
|
||||
'editorDrafts',
|
||||
'markdownFrontmatterVisible',
|
||||
'activeFileIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'browserPagesByWorkspace',
|
||||
'remoteBrowserPageHandlesByPageId',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree'
|
||||
]
|
||||
|
||||
/** What main publishes for one stored partition. */
|
||||
function published(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState
|
||||
): Record<string, PublishedWorkspaceLayout> {
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext())
|
||||
return Object.fromEntries(
|
||||
Object.entries(layout.workspaces).map(([key, workspace]) => [
|
||||
key,
|
||||
publishWorkspaceLayout(workspace, hostId)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(layouts: Record<string, PublishedWorkspaceLayout>): WorkspaceLayoutChangeFrame {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout }))
|
||||
}
|
||||
}
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSessionState) {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId, session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const layouts = published(hostId, session)
|
||||
stream.onFrame!(snapshot(layouts))
|
||||
return { store, sync, layouts }
|
||||
}
|
||||
|
||||
/** Fixtures whose frames differ from the restore, as the frame parity test lists. */
|
||||
const FRAME_DIFFERENCE_FIXTURES = new Set([
|
||||
'row without a tab-bar entry or a terminal',
|
||||
'legacy surface tombstone on a pane still showing its terminal',
|
||||
'tab-bar label differing from the row title',
|
||||
'row without a tab-bar entry, still bound to a terminal',
|
||||
'legacy split row sharing one pane’s terminal with a tab-bar tab',
|
||||
'session from before the tab bar, with an open file'
|
||||
])
|
||||
|
||||
beforeEach(() => {
|
||||
reportWindowSessionWriteActivity(false)
|
||||
})
|
||||
|
||||
describe('the window drawn from layout frames', () => {
|
||||
it.each(
|
||||
[...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES]
|
||||
.filter((profile) => !FRAME_DIFFERENCE_FIXTURES.has(profile.name))
|
||||
.map((profile) => [profile.name, profile] as const)
|
||||
)('startup from the first snapshot changes nothing restore showed: %s', async (_, profile) => {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: profile.hostId, session: profile.build() }])
|
||||
const before = store.getState()
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
stream.onFrame!(snapshot(published(profile.hostId, profile.build())))
|
||||
sync.start()
|
||||
const after = store.getState()
|
||||
expect(SLICES.filter((slice) => after[slice] !== before[slice])).toEqual([])
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('shows a structure change main publishes, keeping what only the window knows', async () => {
|
||||
const session = localDesktopSession()
|
||||
const { store, sync, layouts } = await bootWithFrames('local', session)
|
||||
sync.start()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
...store.getState().tabsByWorktree,
|
||||
[GIT_KEY]: store.getState().tabsByWorktree[GIT_KEY]!.map((tab) =>
|
||||
tab.id === 'tab-shell'
|
||||
? {
|
||||
...tab,
|
||||
ptyId: 'live-pty',
|
||||
pendingActivationSpawn: true,
|
||||
restoredFromSession: undefined
|
||||
}
|
||||
: tab
|
||||
)
|
||||
}
|
||||
})
|
||||
const renamed = structuredClone(layouts[GIT_KEY]!)
|
||||
renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed'
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed })
|
||||
await flushMicrotasks()
|
||||
const shell = store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!
|
||||
expect(shell).toMatchObject({
|
||||
customTitle: 'renamed',
|
||||
ptyId: 'live-pty',
|
||||
pendingActivationSpawn: true
|
||||
})
|
||||
// Restore marks every row it builds; a row whose terminal bound since must not get it back.
|
||||
expect(shell).not.toHaveProperty('restoredFromSession')
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('holds a structure frame while the window owes main a write, then shows it', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
const renamed = structuredClone(layouts[GIT_KEY]!)
|
||||
renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed'
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed })
|
||||
await flushMicrotasks()
|
||||
const shellTitle = () =>
|
||||
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.customTitle
|
||||
expect(shellTitle()).not.toBe('renamed')
|
||||
reportWindowSessionWriteActivity(false)
|
||||
await flushMicrotasks()
|
||||
expect(shellTitle()).toBe('renamed')
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('shows a live title change of the focused pane at once, even while a write is owed', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
const titled = structuredClone(layouts[GIT_KEY]!)
|
||||
const shell = titled.tabs.find((tab) => tab.id === 'tab-shell')!
|
||||
const focused = store.getState().terminalLayoutsByTabId['tab-shell']!.activeLeafId
|
||||
shell.terminal!.panes = shell.terminal!.panes.map((pane) => ({
|
||||
...pane,
|
||||
liveTitle: pane.leafId === focused ? 'vim notes.md' : 'other pane'
|
||||
}))
|
||||
const before = store.getState()
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: titled })
|
||||
await flushMicrotasks()
|
||||
const after = store.getState()
|
||||
expect(after.tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.title).toBe(
|
||||
'vim notes.md'
|
||||
)
|
||||
expect(after.groupsByWorktree).toBe(before.groupsByWorktree)
|
||||
expect(after.terminalLayoutsByTabId).toBe(before.terminalLayoutsByTabId)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('leaves a paired server’s workspaces to the mirror', async () => {
|
||||
const profile = WORKSPACE_LAYOUT_PROFILES.find(
|
||||
(entry) => entry.name === 'remote server partition'
|
||||
)!
|
||||
const { store, sync, layouts } = await bootWithFrames(profile.hostId, profile.build())
|
||||
sync.start()
|
||||
const before = store.getState()
|
||||
const [key, layout] = Object.entries(layouts)[0]!
|
||||
stream.onFrame!({ type: 'workspace', key, layout: { ...layout, tabs: [], groups: [] } })
|
||||
await flushMicrotasks()
|
||||
expect(SLICES.filter((slice) => store.getState()[slice] !== before[slice])).toEqual([])
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('leaves restore in place when the stream ends before its snapshot', async () => {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }])
|
||||
const before = store.getState()
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
sync.start()
|
||||
stream.onStatus!({ state: 'refused' })
|
||||
await flushMicrotasks()
|
||||
expect(SLICES.filter((slice) => store.getState()[slice] !== before[slice])).toEqual([])
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('started before its snapshot, shows the snapshot when it arrives', async () => {
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
sync.start()
|
||||
const layouts = published('local', localDesktopSession())
|
||||
layouts[GIT_KEY]!.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'from main'
|
||||
stream.onFrame!(snapshot(layouts))
|
||||
await flushMicrotasks()
|
||||
expect(
|
||||
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.customTitle
|
||||
).toBe('from main')
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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 already holds it (startup restore keeps only workspaces the catalog knows).
|
||||
//
|
||||
// Frames wait while this window owes main a session write: until main has the window's own change,
|
||||
// a frame would put back what the window just changed. Main sends a write's frames before it
|
||||
// acknowledges the write, so the cache is current by the time the window is idle again.
|
||||
// A frame that changes only live terminal titles never waits: it touches no structure.
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { buildLayoutFrameSlices, shownLiveTitle } from '@/store/workspace-layout-frame-slices'
|
||||
import { buildLayoutFrameStorePatch } from '@/store/workspace-layout-frame-store-patch'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { buildHostIdByWorktreeId } from '@/lib/workspace-session-host-persistence'
|
||||
import {
|
||||
splitWorkspaceSessionByHost,
|
||||
type HostIdByWorktreeId
|
||||
} from '@/lib/workspace-session-host-split'
|
||||
import {
|
||||
isWindowSessionWriteBusy,
|
||||
onWindowSessionWriteIdle
|
||||
} from '@/lib/window-session-write-activity'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { subscribeRuntimeLayout } from './runtime-layout-client'
|
||||
|
||||
type FrameSyncStore = {
|
||||
getState: () => AppState
|
||||
setState: (patch: Partial<AppState>) => void
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutFrameSync = {
|
||||
/** Shows every workspace the frames own once the snapshot is in, then keeps them current. */
|
||||
start: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */
|
||||
const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces'
|
||||
|
||||
const structureJsonByLayout = new WeakMap<PublishedWorkspaceLayout, string>()
|
||||
|
||||
/** A frame's JSON without live titles, kept per frame: title frames arrive at spinner rate. */
|
||||
function structureJson(layout: PublishedWorkspaceLayout): string {
|
||||
let json = structureJsonByLayout.get(layout)
|
||||
if (json === undefined) {
|
||||
json = withoutLiveTitles(layout)
|
||||
structureJsonByLayout.set(layout, json)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
function withoutLiveTitles(layout: PublishedWorkspaceLayout): string {
|
||||
return stableJson({
|
||||
...layout,
|
||||
tabs: layout.tabs.map((tab) =>
|
||||
tab.terminal
|
||||
? {
|
||||
...tab,
|
||||
terminal: {
|
||||
...tab.terminal,
|
||||
panes: tab.terminal.panes.map(({ liveTitle: _liveTitle, ...pane }) => pane)
|
||||
}
|
||||
}
|
||||
: tab
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Title changes between two frames of one workspace, for the pane each tab shows. */
|
||||
function liveTitleUpdates(
|
||||
shown: PublishedWorkspaceLayout,
|
||||
next: PublishedWorkspaceLayout,
|
||||
focusedLeafId: (tabId: string) => string | null | undefined
|
||||
): TerminalTabTitleUpdate[] {
|
||||
const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab]))
|
||||
const updates: TerminalTabTitleUpdate[] = []
|
||||
for (const tab of next.tabs) {
|
||||
const shownTab = shownById.get(tab.id)
|
||||
if (!tab.terminal || !shownTab) {
|
||||
continue
|
||||
}
|
||||
const focused = focusedLeafId(tab.entityId)
|
||||
const title = shownLiveTitle(tab, focused)
|
||||
if (title !== undefined && title !== shownLiveTitle(shownTab, focused)) {
|
||||
updates.push({ tabId: tab.entityId, title })
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
function holdsWorkspace(state: AppState, key: string): boolean {
|
||||
return key in state.groupsByWorktree || key in state.unifiedTabsByWorktree
|
||||
}
|
||||
|
||||
export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync {
|
||||
let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE
|
||||
/** Workspace key → the frame the store shows. */
|
||||
let shown: Record<string, PublishedWorkspaceLayout> = {}
|
||||
let started = false
|
||||
let live = false
|
||||
let snapshotReceived = false
|
||||
let flushQueued = false
|
||||
|
||||
const rebuild = (state: AppState, keys: string[]): void => {
|
||||
// The view and content facts are this window's: read them back out of what it saves.
|
||||
const rebuilt = new Set(keys)
|
||||
const own = splitWorkspaceSessionByHost(buildWorkspaceSessionPayload(state), (key) =>
|
||||
rebuilt.has(key) ? LOCAL_EXECUTION_HOST_ID : OTHER_WORKSPACES
|
||||
)[LOCAL_EXECUTION_HOST_ID]!
|
||||
const { facts, desktopView } = loadWorkspaceLayoutWithoutReport(
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
own,
|
||||
nameBasedLoadContext()
|
||||
)
|
||||
const built = buildLayoutFrameSlices({
|
||||
layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])),
|
||||
facts,
|
||||
view: desktopView
|
||||
})
|
||||
const patch = buildLayoutFrameStorePatch(state, built, keys)
|
||||
if (Object.keys(patch).length > 0) {
|
||||
store.setState(patch)
|
||||
}
|
||||
}
|
||||
|
||||
const flush = (): void => {
|
||||
flushQueued = false
|
||||
if (!live) {
|
||||
return
|
||||
}
|
||||
const state = store.getState()
|
||||
let hostIdOf: HostIdByWorktreeId | null = null
|
||||
const ownedByPairedServer = (key: string): boolean => {
|
||||
hostIdOf ??= buildHostIdByWorktreeId(state)
|
||||
return parseExecutionHostId(hostIdOf(key))?.kind === 'runtime'
|
||||
}
|
||||
const busy = isWindowSessionWriteBusy()
|
||||
const nextShown: Record<string, PublishedWorkspaceLayout> = {}
|
||||
const structural: string[] = []
|
||||
const titleUpdates: TerminalTabTitleUpdate[] = []
|
||||
for (const [key, layout] of Object.entries(cache)) {
|
||||
const previous = shown[key]
|
||||
if (layout === previous || !holdsWorkspace(state, key) || ownedByPairedServer(key)) {
|
||||
if (previous) {
|
||||
nextShown[key] = previous
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (previous && structureJson(previous) === structureJson(layout)) {
|
||||
titleUpdates.push(
|
||||
...liveTitleUpdates(
|
||||
previous,
|
||||
layout,
|
||||
(tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId
|
||||
)
|
||||
)
|
||||
nextShown[key] = layout
|
||||
} else if (busy) {
|
||||
if (previous) {
|
||||
nextShown[key] = previous
|
||||
}
|
||||
} else {
|
||||
structural.push(key)
|
||||
nextShown[key] = layout
|
||||
}
|
||||
}
|
||||
// A workspace main stopped publishing keeps what the window shows; main has nothing to say.
|
||||
shown = nextShown
|
||||
if (structural.length > 0) {
|
||||
rebuild(state, structural)
|
||||
}
|
||||
if (titleUpdates.length > 0) {
|
||||
store.getState().updateTabTitles(titleUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleFlush = (): void => {
|
||||
if (live && !flushQueued) {
|
||||
flushQueued = true
|
||||
// Coalesces the frames of one task; also lets a write that is about to start report busy.
|
||||
queueMicrotask(flush)
|
||||
}
|
||||
}
|
||||
|
||||
const stream = subscribeRuntimeLayout(
|
||||
{ kind: 'local' },
|
||||
{},
|
||||
(frame) => {
|
||||
cache = applyLayoutFrame(cache, 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
const stopIdleListener = onWindowSessionWriteIdle(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 = {}
|
||||
flush()
|
||||
},
|
||||
close: () => {
|
||||
live = false
|
||||
stopIdleListener()
|
||||
stream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
startupFrames ??= openWorkspaceLayoutFrameSync(useAppStore)
|
||||
startupFrames.start()
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import type {
|
||||
LayoutContentFacts
|
||||
} from '../../../shared/workspace-layout/workspace-layout-beside'
|
||||
import { workspaceLayoutModelFromPublished } from '../../../shared/workspace-layout/workspace-layout-from-published'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type {
|
||||
PublishedTab,
|
||||
PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { projectHydratedBrowserSession } from './slices/browser/browser-hydration-state'
|
||||
@@ -63,6 +66,14 @@ const NOTHING_CARRIED: CarriedSessionFields = {
|
||||
}
|
||||
|
||||
/** A terminal's title is the live title of the pane this view focuses, when main reports one. */
|
||||
export function shownLiveTitle(
|
||||
tab: PublishedTab,
|
||||
focusedLeafId: string | null | undefined
|
||||
): string | undefined {
|
||||
const panes = tab.terminal?.panes ?? []
|
||||
return (panes.find((pane) => pane.leafId === focusedLeafId) ?? panes[0])?.liveTitle
|
||||
}
|
||||
|
||||
function withLiveTitles(
|
||||
layouts: WorkspaceLayoutCache,
|
||||
facts: LayoutContentFacts,
|
||||
@@ -70,9 +81,7 @@ function withLiveTitles(
|
||||
): LayoutContentFacts {
|
||||
const terminalRows = { ...facts.terminalRows }
|
||||
for (const tab of Object.values(layouts).flatMap((layout) => layout.tabs)) {
|
||||
const panes = tab.terminal?.panes ?? []
|
||||
const focused = view.panes[tab.entityId]?.activeLeafId
|
||||
const liveTitle = (panes.find((pane) => pane.leafId === focused) ?? panes[0])?.liveTitle
|
||||
const liveTitle = shownLiveTitle(tab, view.panes[tab.entityId]?.activeLeafId)
|
||||
if (liveTitle !== undefined) {
|
||||
terminalRows[tab.entityId] = { ...terminalRows[tab.entityId], title: liveTitle }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from './slices/store-session-test-harness'
|
||||
import { createTestStore } from './slices/store-test-helpers'
|
||||
import type { AppState } from './types'
|
||||
import { buildLayoutFrameSlices } from './workspace-layout-frame-slices'
|
||||
import { buildLayoutFrameStorePatch } from './workspace-layout-frame-store-patch'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
async function restored(): Promise<{
|
||||
state: AppState
|
||||
session: WorkspaceSessionState
|
||||
frame: PublishedWorkspaceLayout
|
||||
}> {
|
||||
const session = localDesktopSession()
|
||||
const state = await bootWindowFromPartitions(createTestStore(), [{ hostId: 'local', session }])
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext())
|
||||
return { state, session, frame: publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local') }
|
||||
}
|
||||
|
||||
/** The patch that shows `frame` for the git workspace, with the stored session's view and facts. */
|
||||
function patchFor(
|
||||
state: AppState,
|
||||
session: WorkspaceSessionState,
|
||||
frame: PublishedWorkspaceLayout
|
||||
) {
|
||||
const { facts, desktopView } = loadWorkspaceLayoutWithoutReport(
|
||||
'local',
|
||||
session,
|
||||
nameBasedLoadContext()
|
||||
)
|
||||
const built = buildLayoutFrameSlices({ layouts: { [GIT_KEY]: frame }, facts, view: desktopView })
|
||||
return buildLayoutFrameStorePatch(state, built, [GIT_KEY])
|
||||
}
|
||||
|
||||
function withoutTab(frame: PublishedWorkspaceLayout, tabId: string): PublishedWorkspaceLayout {
|
||||
return {
|
||||
...frame,
|
||||
groups: frame.groups.map((group) => ({
|
||||
...group,
|
||||
tabIds: group.tabIds.filter((id) => id !== tabId)
|
||||
})),
|
||||
tabs: frame.tabs.filter((tab) => tab.id !== tabId)
|
||||
}
|
||||
}
|
||||
|
||||
describe('laying frame slices over the store', () => {
|
||||
it('changes nothing for the frame the store already shows', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
expect(patchFor(state, session, frame)).toEqual({})
|
||||
})
|
||||
|
||||
it('drops a terminal tab main closed, with its pane layout', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
const patch = patchFor(state, session, withoutTab(frame, 'tab-shell'))
|
||||
expect(patch.tabsByWorktree?.[GIT_KEY]?.map((tab) => tab.id)).not.toContain('tab-shell')
|
||||
expect(patch.terminalLayoutsByTabId).not.toHaveProperty('tab-shell')
|
||||
expect(patch.unifiedTabsByWorktree?.[GIT_KEY]?.map((tab) => tab.id)).not.toContain('tab-shell')
|
||||
})
|
||||
|
||||
it('keeps an open file’s editor state and place, and drops a file main closed', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
const file = state.openFiles.find((entry) => entry.worktreeId === GIT_KEY)!
|
||||
const edited = { ...state, openFiles: [{ ...file, isDirty: true }] }
|
||||
expect(patchFor(edited, session, frame).openFiles).toBeUndefined()
|
||||
const closed = patchFor(edited, session, {
|
||||
...withoutTab(frame, 'editor:src/app.ts'),
|
||||
editorFiles: []
|
||||
})
|
||||
expect(closed.openFiles).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a browser tab’s page state, and drops the pages of a tab main closed', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
const browser = state.browserTabsByWorktree[GIT_KEY]![0]!
|
||||
const browsing = {
|
||||
...state,
|
||||
browserTabsByWorktree: { [GIT_KEY]: [{ ...browser, url: 'https://example.com/next' }] }
|
||||
}
|
||||
expect(patchFor(browsing, session, frame).browserTabsByWorktree).toBeUndefined()
|
||||
const closed = patchFor(browsing, session, {
|
||||
...withoutTab(frame, 'browser-1'),
|
||||
browserTabs: []
|
||||
})
|
||||
expect(closed.browserPagesByWorkspace).not.toHaveProperty('browser-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
// Lays slices rebuilt from layout frames over the store for the workspaces frames replaced. A record
|
||||
// equal to the store's keeps the store's object, so an unchanged frame re-renders nothing; a
|
||||
// record the store already holds keeps what only this window knows about it (a terminal row's
|
||||
// in-flight spawn and recovery state, an open file's editor state, a browser tab's page state).
|
||||
|
||||
import { BROWSER_TAB_LAYOUT_FIELDS } from '../../../shared/workspace-layout/workspace-layout-beside'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { AppState } from './types'
|
||||
import type { LayoutFrameSlices } from './workspace-layout-frame-slices'
|
||||
|
||||
/** Row fields this window owns: what its mounted panes are doing, and which terminal each row
|
||||
* binds (main publishes the saved binding; the live one stays the window's until main owns it). */
|
||||
const WINDOW_TERMINAL_TAB_FIELDS = [
|
||||
'ptyId',
|
||||
'pendingActivationSpawn',
|
||||
'recovery',
|
||||
'restoredFromSession',
|
||||
'agentLaunchPane'
|
||||
] as const satisfies readonly (keyof TerminalTab)[]
|
||||
|
||||
type KeyedSlice =
|
||||
| 'tabsByWorktree'
|
||||
| 'unifiedTabsByWorktree'
|
||||
| 'groupsByWorktree'
|
||||
| 'layoutByWorktree'
|
||||
| 'activeGroupIdByWorktree'
|
||||
| 'activeTabIdByWorktree'
|
||||
| 'activeFileIdByWorktree'
|
||||
| 'activeBrowserTabIdByWorktree'
|
||||
| 'activeTabTypeByWorktree'
|
||||
| 'browserTabsByWorktree'
|
||||
|
||||
export type LayoutFrameStoreState = Pick<
|
||||
AppState,
|
||||
KeyedSlice | 'terminalLayoutsByTabId' | 'openFiles' | 'browserPagesByWorkspace'
|
||||
> &
|
||||
Pick<AppState, 'remoteBrowserPageHandlesByPageId'>
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
return a === b || stableJson(a) === stableJson(b)
|
||||
}
|
||||
|
||||
function withoutWindowFields(tab: TerminalTab): Partial<TerminalTab> {
|
||||
const fields: Partial<TerminalTab> = { ...tab }
|
||||
for (const field of WINDOW_TERMINAL_TAB_FIELDS) {
|
||||
delete fields[field]
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
function mergeTerminalRow(existing: TerminalTab | undefined, built: TerminalTab): TerminalTab {
|
||||
if (!existing) {
|
||||
return built
|
||||
}
|
||||
if (same(withoutWindowFields(existing), withoutWindowFields(built))) {
|
||||
return existing
|
||||
}
|
||||
const merged: TerminalTab = { ...built, ptyId: existing.ptyId }
|
||||
for (const field of WINDOW_TERMINAL_TAB_FIELDS) {
|
||||
if (field === 'ptyId') {
|
||||
continue
|
||||
}
|
||||
if (existing[field] === undefined) {
|
||||
delete merged[field]
|
||||
} else {
|
||||
Object.assign(merged, { [field]: existing[field] })
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Each built record, or the store's when equal; the store's array when every record is. */
|
||||
function mergeById<T extends { id: string }>(
|
||||
existing: T[] | undefined,
|
||||
built: readonly T[] | undefined,
|
||||
merge: (existing: T | undefined, built: T) => T = (current, next) =>
|
||||
current && same(current, next) ? current : next
|
||||
): T[] | undefined {
|
||||
if (!built) {
|
||||
return undefined
|
||||
}
|
||||
const byId = new Map(existing?.map((record) => [record.id, record]))
|
||||
const merged = built.map((record) => merge(byId.get(record.id), record))
|
||||
return existing &&
|
||||
existing.length === merged.length &&
|
||||
merged.every((record, index) => record === existing[index])
|
||||
? existing
|
||||
: merged
|
||||
}
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
value === undefined || value === null || (Array.isArray(value) && value.length === 0)
|
||||
|
||||
function setKey<T>(
|
||||
map: Record<string, T>,
|
||||
key: string,
|
||||
value: T | undefined
|
||||
): Record<string, T> | null {
|
||||
// A workspace with no records or selection of a kind may be listed empty or left out.
|
||||
if (isEmpty(map[key]) && isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
if (map[key] === value || (value !== undefined && key in map && same(map[key], value))) {
|
||||
return null
|
||||
}
|
||||
if (value === undefined) {
|
||||
if (!(key in map)) {
|
||||
return null
|
||||
}
|
||||
const { [key]: _removed, ...rest } = map
|
||||
return rest
|
||||
}
|
||||
return { ...map, [key]: value }
|
||||
}
|
||||
|
||||
/**
|
||||
* The store patch that shows `built` for `keys`; slices of other workspaces are untouched.
|
||||
* Returns only the slices that changed.
|
||||
*/
|
||||
export function buildLayoutFrameStorePatch(
|
||||
state: LayoutFrameStoreState,
|
||||
built: LayoutFrameSlices,
|
||||
keys: readonly string[]
|
||||
): Partial<LayoutFrameStoreState> {
|
||||
const patch: Partial<LayoutFrameStoreState> = {}
|
||||
const keyed = (slice: KeyedSlice, value: (key: string) => unknown): void => {
|
||||
let next: Record<string, unknown> = state[slice]
|
||||
for (const key of keys) {
|
||||
next = setKey(next, key, value(key)) ?? next
|
||||
}
|
||||
if (next !== state[slice]) {
|
||||
Object.assign(patch, { [slice]: next })
|
||||
}
|
||||
}
|
||||
|
||||
keyed('tabsByWorktree', (key) =>
|
||||
mergeById(state.tabsByWorktree[key], built.tabsByWorktree[key], mergeTerminalRow)
|
||||
)
|
||||
keyed('unifiedTabsByWorktree', (key) =>
|
||||
mergeById(state.unifiedTabsByWorktree[key], built.unifiedTabsByWorktree[key])
|
||||
)
|
||||
keyed('groupsByWorktree', (key) =>
|
||||
mergeById(state.groupsByWorktree[key], built.groupsByWorktree[key])
|
||||
)
|
||||
keyed('browserTabsByWorktree', (key) =>
|
||||
mergeById(
|
||||
state.browserTabsByWorktree[key],
|
||||
built.browserTabsByWorktree[key],
|
||||
(existing, next) => {
|
||||
if (!existing) {
|
||||
return next
|
||||
}
|
||||
const layoutFields = (tab: typeof next) =>
|
||||
BROWSER_TAB_LAYOUT_FIELDS.map((field) => tab[field])
|
||||
// Page state is this window's; main holds only the tab's own fields.
|
||||
return same(layoutFields(existing), layoutFields(next))
|
||||
? existing
|
||||
: {
|
||||
...existing,
|
||||
...Object.fromEntries(BROWSER_TAB_LAYOUT_FIELDS.map((f) => [f, next[f]]))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
for (const slice of [
|
||||
'layoutByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'activeTabIdByWorktree',
|
||||
'activeFileIdByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree'
|
||||
] as const) {
|
||||
keyed(slice, (key) => built[slice][key])
|
||||
}
|
||||
|
||||
const tabIdsOf = (tabs: Record<string, readonly TerminalTab[]>): Set<string> =>
|
||||
new Set(keys.flatMap((key) => (tabs[key] ?? []).map((tab) => tab.id)))
|
||||
let terminalLayouts = state.terminalLayoutsByTabId
|
||||
for (const tabId of new Set([
|
||||
...tabIdsOf(state.tabsByWorktree),
|
||||
...tabIdsOf(built.tabsByWorktree)
|
||||
])) {
|
||||
terminalLayouts =
|
||||
setKey(terminalLayouts, tabId, built.terminalLayoutsByTabId[tabId]) ?? terminalLayouts
|
||||
}
|
||||
if (terminalLayouts !== state.terminalLayoutsByTabId) {
|
||||
patch.terminalLayoutsByTabId = terminalLayouts
|
||||
}
|
||||
|
||||
const keySet = new Set(keys)
|
||||
// An open file's editor state is this window's; a frame decides only which files are open.
|
||||
const builtFileIds = new Set(built.openFiles.map((file) => file.id))
|
||||
const heldFileIds = new Set(state.openFiles.map((file) => file.id))
|
||||
const files = [
|
||||
...state.openFiles.filter((file) => !keySet.has(file.worktreeId) || builtFileIds.has(file.id)),
|
||||
...built.openFiles.filter((file) => !heldFileIds.has(file.id))
|
||||
]
|
||||
if (
|
||||
files.length !== state.openFiles.length ||
|
||||
files.some((file, index) => file !== state.openFiles[index])
|
||||
) {
|
||||
patch.openFiles = files
|
||||
}
|
||||
|
||||
// Pages and handles are this window's: a frame brings those of a tab it has not seen and drops
|
||||
// those of a tab main closed.
|
||||
const builtBrowserTabIds = new Set(
|
||||
keys.flatMap((key) => (built.browserTabsByWorktree[key] ?? []).map((tab) => tab.id))
|
||||
)
|
||||
const closedBrowserTabIds = keys
|
||||
.flatMap((key) => state.browserTabsByWorktree[key] ?? [])
|
||||
.map((tab) => tab.id)
|
||||
.filter((id) => !builtBrowserTabIds.has(id))
|
||||
const closedPageIds = new Set(
|
||||
closedBrowserTabIds.flatMap((id) =>
|
||||
(state.browserPagesByWorkspace[id] ?? []).map((page) => page.id)
|
||||
)
|
||||
)
|
||||
const pages = { ...state.browserPagesByWorkspace }
|
||||
for (const id of closedBrowserTabIds) {
|
||||
delete pages[id]
|
||||
}
|
||||
const handles = Object.fromEntries(
|
||||
Object.entries(state.remoteBrowserPageHandlesByPageId).filter(([id]) => !closedPageIds.has(id))
|
||||
)
|
||||
const addedPages = Object.entries(built.browserPagesByWorkspace).filter(([id]) => !(id in pages))
|
||||
const addedHandles = Object.entries(built.remoteBrowserPageHandlesByPageId).filter(
|
||||
([id]) => !(id in handles)
|
||||
)
|
||||
if (closedBrowserTabIds.length > 0 || addedPages.length > 0) {
|
||||
patch.browserPagesByWorkspace = { ...pages, ...Object.fromEntries(addedPages) }
|
||||
}
|
||||
if (closedPageIds.size > 0 || addedHandles.length > 0) {
|
||||
patch.remoteBrowserPageHandlesByPageId = { ...handles, ...Object.fromEntries(addedHandles) }
|
||||
}
|
||||
return patch
|
||||
}
|
||||
@@ -60,9 +60,9 @@ export type ViewSessionField = FieldsOwnedBy<'view'>
|
||||
export type FactSessionField = FieldsOwnedBy<'fact'>
|
||||
export type CarriedSessionField = FieldsOwnedBy<'carried'>
|
||||
|
||||
export const VIEW_SESSION_FIELDS = sessionFieldsOwnedBy('view')
|
||||
export const FACT_SESSION_FIELDS = sessionFieldsOwnedBy('fact')
|
||||
export const CARRIED_SESSION_FIELDS = sessionFieldsOwnedBy('carried')
|
||||
export const VIEW_SESSION_FIELDS = /* @__PURE__ */ sessionFieldsOwnedBy('view')
|
||||
export const FACT_SESSION_FIELDS = /* @__PURE__ */ sessionFieldsOwnedBy('fact')
|
||||
export const CARRIED_SESSION_FIELDS = /* @__PURE__ */ sessionFieldsOwnedBy('carried')
|
||||
|
||||
function sessionFieldsOwnedBy<Owner extends SessionFieldOwner>(
|
||||
owner: Owner
|
||||
|
||||
Reference in New Issue
Block a user