mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
Save-divergence detector: skip empty workspaces, see dropped tab-bar entries and kept pane trees (dark, layout-owner build only) (#30125)
* Save-divergence detector: skip empty workspaces, see dropped tab-bar entries and kept pane trees (dark, layout-owner build only) - A workspace key that names nothing (or only places a stored closed-tab record) is no longer reported as "workspace added/removed" or "terminal row owner changed". - A save that drops a terminal tab's tab-bar entry while keeping its row is reported, read from the stored session before the Loader rebuilds the entry. - A pane tree main keeps over the window's (windowSaveForMain) is reported, so a window-side pane repair shows up. * Detector: keep reporting empty-row flips, cover every overridden pane tree and one-sided closed tabs (dark, layout-owner build only) * Detector: a save with no pane tree for a tab main keeps one for is reported too (dark, layout-owner build only) * Detector: a closed tab record moved to another worktree in one save is reported (dark, layout-owner build only)
This commit is contained in:
@@ -68,7 +68,8 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
|
||||
() => store.getWorkspaceSession(hostId),
|
||||
hostId,
|
||||
stage,
|
||||
'shutdown-checkpoint'
|
||||
'shutdown-checkpoint',
|
||||
state.terminalLayoutsByTabId
|
||||
)
|
||||
} else {
|
||||
stage()
|
||||
|
||||
+12
-4
@@ -33,13 +33,19 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
|
||||
|
||||
// Switch point: main keeps its own pane trees over the window's, and reports any layout the
|
||||
// save still changes.
|
||||
const saveFromWindow = (hostId: string | null | undefined, write: () => void, source: string) =>
|
||||
const saveFromWindow = (
|
||||
hostId: string | null | undefined,
|
||||
write: () => void,
|
||||
source: string,
|
||||
save: WorkspaceSessionState | WorkspaceSessionPatch
|
||||
) =>
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? writeWindowSaveWatchingLayout(
|
||||
() => store.getWorkspaceSession(hostId),
|
||||
hostId,
|
||||
write,
|
||||
source
|
||||
source,
|
||||
save.terminalLayoutsByTabId
|
||||
)
|
||||
: write()
|
||||
const forMain = <T extends WorkspaceSessionState | WorkspaceSessionPatch>(
|
||||
@@ -55,7 +61,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
|
||||
saveFromWindow(
|
||||
hostId,
|
||||
() => store.setWorkspaceSession(forMain(args, hostId), hostId),
|
||||
'session:set'
|
||||
'session:set',
|
||||
args
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -65,7 +72,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
|
||||
saveFromWindow(
|
||||
hostId,
|
||||
() => store.patchWorkspaceSession(forMain(args, hostId), hostId),
|
||||
'session:patch'
|
||||
'session:patch',
|
||||
args
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
leaf
|
||||
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { writeWindowSaveWatchingLayout } from './window-save-divergence'
|
||||
import { windowSaveForMain } from './window-save-pane-trees'
|
||||
|
||||
function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState {
|
||||
return addWorkspace(emptySession(), GIT_KEY, [
|
||||
@@ -185,16 +186,16 @@ describe('writeWindowSaveWatchingLayout', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a default-tab record of a workspace with no tabs, and a terminal row owner', () => {
|
||||
it('reports a default-tab record of a workspace with no tabs', () => {
|
||||
const stored = emptySession()
|
||||
const next = {
|
||||
...emptySession(),
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const },
|
||||
tabsByWorktree: { 'repo-1::/Users/dev/empty': [] }
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const }
|
||||
}
|
||||
|
||||
save(stored, next)
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
@@ -203,16 +204,209 @@ describe('writeWindowSaveWatchingLayout', () => {
|
||||
paths: ['defaultTabsAppliedByWorkspace changed']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('stays quiet for a save that only names a workspace with nothing in it', () => {
|
||||
const key = 'repo-1::/Users/dev/empty'
|
||||
const next = { ...emptySession(), unifiedTabs: { [key]: [] }, tabGroups: { [key]: [] } }
|
||||
|
||||
save(emptySession(), next)
|
||||
save(next, emptySession())
|
||||
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an empty terminal row a save adds or drops: a relaunch reads it there', () => {
|
||||
const key = 'repo-1::/Users/dev/emptied'
|
||||
const emptied = { ...emptySession(), tabsByWorktree: { [key]: [] } }
|
||||
|
||||
save(emptySession(), emptied)
|
||||
save(emptied, emptySession())
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({ workspace: key, paths: ['terminal row owner changed'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a closed tab record only when the save adds or drops it, not when a key places it', () => {
|
||||
const key = 'repo-1::/Users/dev/empty'
|
||||
const closed = {
|
||||
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }
|
||||
}
|
||||
const stored = { ...emptySession(), ...closed }
|
||||
const named = { ...stored, unifiedTabs: { [key]: [] } }
|
||||
|
||||
save(stored, named)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
|
||||
save(named, { ...named, closedTerminalTabTombstonesByTabId: {} })
|
||||
save({ ...emptySession(), unifiedTabs: {} }, named)
|
||||
for (const path of ['closed tab tab-x removed', 'closed tab tab-x added']) {
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({ workspace: key, paths: [path] })
|
||||
)
|
||||
}
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a closed tab record the save moves into a workspace only it names', () => {
|
||||
const from = 'repo-1::/Users/dev/old'
|
||||
const to = 'repo-1::/Users/dev/new'
|
||||
const closedIn = (key: string) => ({
|
||||
...emptySession(),
|
||||
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }
|
||||
})
|
||||
|
||||
save(closedIn(from), { ...closedIn(to), unifiedTabs: { [to]: [] } })
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({ workspace: to, paths: ['closed tab tab-x added'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('still reports a workspace with tabs that the save adds or drops', () => {
|
||||
save(emptySession(), session([{ id: 'tab-a' }]))
|
||||
save(session([{ id: 'tab-a' }]), emptySession())
|
||||
|
||||
for (const path of ['workspace added', 'workspace removed']) {
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({ workspace: GIT_KEY, paths: expect.arrayContaining([path]) })
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a save that drops a terminal tab’s tab-bar entry but keeps its row', () => {
|
||||
const stored = session([{ id: 'tab-a' }, { id: 'tab-b' }])
|
||||
const next = structuredClone(stored)
|
||||
next.unifiedTabs![GIT_KEY] = next.unifiedTabs![GIT_KEY]!.filter((tab) => tab.id !== 'tab-b')
|
||||
|
||||
save(stored, next)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({
|
||||
workspace: 'repo-1::/Users/dev/empty',
|
||||
paths: expect.arrayContaining(['terminal row owner changed'])
|
||||
workspace: GIT_KEY,
|
||||
paths: expect.arrayContaining(['terminal tab-b tab-bar entry removed'])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('stays quiet for a save that carries no tab bar for the workspace yet', () => {
|
||||
const stored = session([{ id: 'tab-a' }])
|
||||
const next = structuredClone(stored)
|
||||
delete next.unifiedTabs![GIT_KEY]
|
||||
|
||||
save(stored, next)
|
||||
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a pane tree main keeps over the save’s, and still writes the save', () => {
|
||||
const stored = session([{ id: 'tab-a' }])
|
||||
const incoming = structuredClone(stored)
|
||||
incoming.terminalLayoutsByTabId['tab-a'] = {
|
||||
...incoming.terminalLayoutsByTabId['tab-a']!,
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', leafId: leaf(1) },
|
||||
second: { type: 'leaf', leafId: leaf(2) }
|
||||
}
|
||||
}
|
||||
let current = stored
|
||||
writeWindowSaveWatchingLayout(
|
||||
() => current,
|
||||
'local',
|
||||
() => {
|
||||
current = windowSaveForMain(current, incoming, 'local')
|
||||
},
|
||||
'session:set',
|
||||
incoming.terminalLayoutsByTabId
|
||||
)
|
||||
|
||||
expect(current.terminalLayoutsByTabId['tab-a']?.root).toEqual(
|
||||
stored.terminalLayoutsByTabId['tab-a']?.root
|
||||
)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({
|
||||
workspace: GIT_KEY,
|
||||
paths: ['pane tree tab-a kept over the save’s']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a pane tree main keeps for a tab no workspace places', () => {
|
||||
const stored = session([{ id: 'tab-a' }])
|
||||
const layout = stored.terminalLayoutsByTabId['tab-a']!
|
||||
stored.terminalLayoutsByTabId.orphan = { ...layout, root: { type: 'leaf', leafId: leaf(7) } }
|
||||
const saved = { orphan: { ...layout, root: { type: 'leaf' as const, leafId: leaf(8) } } }
|
||||
writeWindowSaveWatchingLayout(
|
||||
() => stored,
|
||||
'local',
|
||||
() => {},
|
||||
'session:set',
|
||||
saved
|
||||
)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({
|
||||
workspace: '(no workspace)',
|
||||
paths: ['pane tree orphan kept over the save’s']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a save that sends no pane tree for a tab main keeps one for', () => {
|
||||
const stored = session([{ id: 'tab-a' }])
|
||||
const saved = { 'tab-a': { ...stored.terminalLayoutsByTabId['tab-a']!, root: null } }
|
||||
writeWindowSaveWatchingLayout(
|
||||
() => stored,
|
||||
'local',
|
||||
() => {},
|
||||
'session:set',
|
||||
saved
|
||||
)
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] window save changed main layout:',
|
||||
'session:set',
|
||||
expect.objectContaining({
|
||||
workspace: GIT_KEY,
|
||||
paths: ['pane tree tab-a kept over the save’s']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('stays quiet for a save whose pane trees match main’s', () => {
|
||||
const stored = session([{ id: 'tab-a' }])
|
||||
const incoming = structuredClone(stored)
|
||||
writeWindowSaveWatchingLayout(
|
||||
() => stored,
|
||||
'local',
|
||||
() => {},
|
||||
'session:set',
|
||||
incoming.terminalLayoutsByTabId
|
||||
)
|
||||
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a paired server’s partition alone: its layout is the server’s', () => {
|
||||
const next = session([{ id: 'tab-c' }])
|
||||
expect(save(session([{ id: 'tab-a' }]), next, 'runtime:env-1')).toBe(next)
|
||||
|
||||
@@ -18,6 +18,13 @@ import type {
|
||||
} from '../../shared/workspace-layout/workspace-layout-model'
|
||||
import type { TabGroupLayoutNode } from '../../shared/tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import {
|
||||
closedTabsOfOneSidedWorkspaces,
|
||||
droppedTabBarEntries,
|
||||
overriddenPaneTrees,
|
||||
terminalTabBarEntries,
|
||||
type SavedPaneTrees
|
||||
} from './window-save-raw-session-divergence'
|
||||
|
||||
/** Set by evidence runs: each divergence is appended there as one JSON line. */
|
||||
const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG'
|
||||
@@ -117,6 +124,30 @@ function reboundLeaves(
|
||||
return { rebound, was, now }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a workspace holds anything. The Loader makes one for every key a save names and places a
|
||||
* stored closed-tab record by worktree id into it, so neither makes one by itself.
|
||||
*/
|
||||
function hasContent(workspace: WorkspaceLayout | undefined): workspace is WorkspaceLayout {
|
||||
return Boolean(
|
||||
workspace &&
|
||||
(workspace.tabs.length > 0 ||
|
||||
workspace.groups.length > 0 ||
|
||||
workspace.groupLayout ||
|
||||
[workspace.editorFiles, workspace.browserTabs, workspace.editorViews, workspace.leaves].some(
|
||||
(items) => items && Object.keys(items).length > 0
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
function diffClosedTabs(before: WorkspaceLayout, after: WorkspaceLayout): string[] {
|
||||
return diffKeyed(
|
||||
'closed tab',
|
||||
recordById(before.closedTerminalTabs),
|
||||
recordById(after.closedTerminalTabs)
|
||||
)
|
||||
}
|
||||
|
||||
/** A lone group with no stored tree is that group's leaf, as main's own save writes it. */
|
||||
function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null {
|
||||
const [only, ...rest] = workspace.groups
|
||||
@@ -130,11 +161,12 @@ export function diffWorkspaceLayouts(
|
||||
before: WorkspaceLayout | undefined,
|
||||
after: WorkspaceLayout | undefined
|
||||
): WorkspaceDiff {
|
||||
if (!before || !after) {
|
||||
return {
|
||||
paths: before === after ? [] : [after ? 'workspace added' : 'workspace removed'],
|
||||
rebound: 0
|
||||
if (!hasContent(before) || !hasContent(after)) {
|
||||
if (hasContent(before) || hasContent(after)) {
|
||||
return { paths: [hasContent(after) ? 'workspace added' : 'workspace removed'], rebound: 0 }
|
||||
}
|
||||
// Both hold no tab: only a closed-tab record of a workspace both copies name can differ.
|
||||
return { paths: before && after ? diffClosedTabs(before, after) : [], rebound: 0 }
|
||||
}
|
||||
const leaves = reboundLeaves(before.leaves, after.leaves)
|
||||
const paths = [
|
||||
@@ -159,11 +191,7 @@ export function diffWorkspaceLayouts(
|
||||
listById(before.browserTabs, (b) => b.id),
|
||||
listById(after.browserTabs, (b) => b.id)
|
||||
),
|
||||
...diffKeyed(
|
||||
'closed tab',
|
||||
recordById(before.closedTerminalTabs),
|
||||
recordById(after.closedTerminalTabs)
|
||||
),
|
||||
...diffClosedTabs(before, after),
|
||||
...diffKeyed('leaf', leaves.was, leaves.now)
|
||||
]
|
||||
if (stableJson(groupTreeOf(before)) !== stableJson(groupTreeOf(after))) {
|
||||
@@ -172,29 +200,33 @@ export function diffWorkspaceLayouts(
|
||||
return { paths, rebound: leaves.rebound.size }
|
||||
}
|
||||
|
||||
/** Each workspace whose part of main's model differs between two copies of one partition. */
|
||||
/**
|
||||
* Each workspace whose part of main's model differs between two copies of one partition, plus
|
||||
* `rawPaths`: what only the stored session itself shows, by workspace.
|
||||
*/
|
||||
export function windowSaveDivergences(
|
||||
was: WorkspaceLayoutModel | null,
|
||||
now: WorkspaceLayoutModel | null
|
||||
now: WorkspaceLayoutModel | null,
|
||||
rawPaths: ReadonlyMap<string, string[]> = new Map()
|
||||
): WindowSaveDivergence[] {
|
||||
// Records count for a workspace with no tabs too.
|
||||
const workspaces = new Set(
|
||||
[was, now].flatMap((model) =>
|
||||
const workspaces = new Set([
|
||||
...[was, now].flatMap((model) =>
|
||||
model
|
||||
? [
|
||||
...Object.keys(model.workspaces),
|
||||
...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {})),
|
||||
...Object.keys(model.legacy.terminalRowOwners)
|
||||
...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {}))
|
||||
]
|
||||
: []
|
||||
)
|
||||
)
|
||||
),
|
||||
...rawPaths.keys()
|
||||
])
|
||||
const result: WindowSaveDivergence[] = []
|
||||
for (const workspace of workspaces) {
|
||||
const { paths, rebound } = diffWorkspaceLayouts(
|
||||
was?.workspaces[workspace],
|
||||
now?.workspaces[workspace]
|
||||
)
|
||||
const before = was?.workspaces[workspace]
|
||||
const after = now?.workspaces[workspace]
|
||||
const { paths: modelPaths, rebound } = diffWorkspaceLayouts(before, after)
|
||||
const paths = [...modelPaths, ...(rawPaths.get(workspace) ?? [])]
|
||||
for (const record of RECORDS) {
|
||||
if (
|
||||
stableJson(was?.records[record]?.[workspace] ?? null) !==
|
||||
@@ -203,6 +235,7 @@ export function windowSaveDivergences(
|
||||
paths.push(`${record} changed`)
|
||||
}
|
||||
}
|
||||
// Counted for an empty workspace too: a relaunch reads it there to skip the first terminal.
|
||||
if (
|
||||
Boolean(was?.legacy.terminalRowOwners[workspace]) !==
|
||||
Boolean(now?.legacy.terminalRowOwners[workspace])
|
||||
@@ -238,15 +271,17 @@ function report(source: string, partition: ExecutionHostId, divergence: WindowSa
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the window's save `write` and reports what it changed in main's layout model. A paired
|
||||
* server's partition is skipped: its layout is the server's, mirrored by the window. Nothing here
|
||||
* can fail the save: the write's own errors propagate, the check's are only logged.
|
||||
* Runs the window's save `write` and reports what it changed in main's layout model, and which of
|
||||
* its `savedPaneTrees` (as the window sent them) main keeps its own tree over. A paired server's
|
||||
* partition is skipped: its layout is the server's, mirrored by the window. Nothing here can fail
|
||||
* the save: the write's own errors propagate, the check's are only logged.
|
||||
*/
|
||||
export function writeWindowSaveWatchingLayout(
|
||||
read: () => WorkspaceSessionState | null | undefined,
|
||||
hostId: string | null | undefined,
|
||||
write: () => void,
|
||||
source: string
|
||||
source: string,
|
||||
savedPaneTrees?: SavedPaneTrees
|
||||
): void {
|
||||
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
|
||||
if (parseExecutionHostId(partition)?.kind === 'runtime') {
|
||||
@@ -254,17 +289,35 @@ export function writeWindowSaveWatchingLayout(
|
||||
return
|
||||
}
|
||||
let was: WorkspaceLayoutModel | null | undefined
|
||||
let wasEntries: Map<string, Set<string>> | undefined
|
||||
let wasClosed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId']
|
||||
const rawPaths = new Map<string, string[]>()
|
||||
try {
|
||||
was = layoutModelOf(read(), partition)
|
||||
const stored = read()
|
||||
was = layoutModelOf(stored, partition)
|
||||
wasEntries = stored ? terminalTabBarEntries(stored) : new Map()
|
||||
wasClosed = stored?.closedTerminalTabTombstonesByTabId
|
||||
if (savedPaneTrees) {
|
||||
overriddenPaneTrees(stored, was, savedPaneTrees, rawPaths)
|
||||
}
|
||||
} catch (error) {
|
||||
was = undefined
|
||||
console.warn('[layout-owner] save divergence check failed:', error)
|
||||
}
|
||||
write()
|
||||
if (was === undefined) {
|
||||
if (was === undefined || wasEntries === undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
for (const divergence of windowSaveDivergences(was, layoutModelOf(read(), partition))) {
|
||||
const now = read()
|
||||
const nowModel = layoutModelOf(now, partition)
|
||||
droppedTabBarEntries(wasEntries, now, rawPaths)
|
||||
closedTabsOfOneSidedWorkspaces(
|
||||
{ model: was, closed: wasClosed },
|
||||
{ model: nowModel, closed: now?.closedTerminalTabTombstonesByTabId },
|
||||
rawPaths
|
||||
)
|
||||
for (const divergence of windowSaveDivergences(was, nowModel, rawPaths)) {
|
||||
report(source, partition, divergence)
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// What a window's save changes that main's layout model cannot show, read from the stored session
|
||||
// itself: the Loader rebuilds a dropped terminal tab-bar entry from its row, places closed-tab
|
||||
// records into whatever workspace a key names, and main keeps its own pane trees over the save's.
|
||||
// Each finding is added to `paths` under its workspace.
|
||||
|
||||
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model'
|
||||
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
|
||||
/** The report key of a pane tree whose tab no workspace names. */
|
||||
const UNPLACED = '(no workspace)'
|
||||
|
||||
export type SavedPaneTrees = Readonly<Record<string, TerminalLayoutSnapshot>>
|
||||
|
||||
/** The terminal tabs (by entity id) each workspace's stored tab bar names. */
|
||||
export function terminalTabBarEntries(session: WorkspaceSessionState): Map<string, Set<string>> {
|
||||
return new Map(
|
||||
Object.entries(session.unifiedTabs ?? {}).map(([workspace, entries]) => [
|
||||
workspace,
|
||||
new Set(
|
||||
entries.flatMap((entry) => (entry.contentType === 'terminal' ? [entry.entityId] : []))
|
||||
)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function addPath(paths: Map<string, string[]>, workspace: string, path: string): void {
|
||||
paths.set(workspace, [...(paths.get(workspace) ?? []), path])
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab-bar entry the save dropped from a tab bar it still writes, while the row stays: the Loader
|
||||
* rebuilds that entry from the row, so main's model shows no change.
|
||||
*/
|
||||
export function droppedTabBarEntries(
|
||||
wasEntries: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
now: WorkspaceSessionState | null | undefined,
|
||||
paths: Map<string, string[]>
|
||||
): void {
|
||||
const nowEntries = now ? terminalTabBarEntries(now) : new Map<string, Set<string>>()
|
||||
for (const [workspace, entities] of wasEntries) {
|
||||
const kept = nowEntries.get(workspace)
|
||||
// No tab bar at all: a save that predates main's first frame for the workspace.
|
||||
if (!kept) {
|
||||
continue
|
||||
}
|
||||
const rows = new Set((now?.tabsByWorktree?.[workspace] ?? []).map((row) => row.id))
|
||||
for (const entityId of entities) {
|
||||
if (!kept.has(entityId) && rows.has(entityId)) {
|
||||
addPath(paths, workspace, `terminal ${entityId} tab-bar entry removed`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The save's pane trees that differ from main's: main keeps its own for every tab it has one for
|
||||
* (windowSaveForMain), so each is reported under the workspace whose tab or row names it.
|
||||
*/
|
||||
export function overriddenPaneTrees(
|
||||
stored: WorkspaceSessionState | null | undefined,
|
||||
model: WorkspaceLayoutModel | null,
|
||||
saved: SavedPaneTrees,
|
||||
paths: Map<string, string[]>
|
||||
): void {
|
||||
const homes = new Map<string, string>()
|
||||
for (const [workspace, rows] of Object.entries(stored?.tabsByWorktree ?? {})) {
|
||||
rows.forEach((row) => homes.set(row.id, workspace))
|
||||
}
|
||||
for (const [workspace, layout] of Object.entries(model?.workspaces ?? {})) {
|
||||
layout.tabs.forEach((tab) => homes.set(tab.entityId, workspace))
|
||||
}
|
||||
for (const [tabId, layout] of Object.entries(saved)) {
|
||||
const mains = stored?.terminalLayoutsByTabId?.[tabId]?.root
|
||||
if (mains && stableJson(mains) !== stableJson(layout.root ?? null)) {
|
||||
addPath(paths, homes.get(tabId) ?? UNPLACED, `pane tree ${tabId} kept over the save’s`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PartitionCopy = {
|
||||
model: WorkspaceLayoutModel | null
|
||||
closed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId']
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed-tab records of a workspace only one copy has: the model diff cannot tell a record the
|
||||
* Loader just placed there (already stored, unplaced) from one the save added or dropped.
|
||||
*/
|
||||
export function closedTabsOfOneSidedWorkspaces(
|
||||
was: PartitionCopy,
|
||||
now: PartitionCopy,
|
||||
paths: Map<string, string[]>
|
||||
): void {
|
||||
const oneSided = (from: PartitionCopy, other: PartitionCopy, change: string) => {
|
||||
for (const [workspace, layout] of Object.entries(from.model?.workspaces ?? {})) {
|
||||
if (other.model?.workspaces[workspace]) {
|
||||
continue
|
||||
}
|
||||
for (const tabId of Object.keys(layout.closedTerminalTabs ?? {})) {
|
||||
if (other.closed?.[tabId]?.worktreeId !== layout.worktreeId) {
|
||||
addPath(paths, workspace, `closed tab ${tabId} ${change}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
oneSided(now, was, 'added')
|
||||
oneSided(was, now, 'removed')
|
||||
}
|
||||
Reference in New Issue
Block a user