fix: prune empty restored panes (#268)

Co-authored-by: wangqiong <wangqiong@yy.com>
This commit is contained in:
wangqiongkaka
2026-08-22 23:11:57 +08:00
committed by GitHub
co-authored by wangqiong
parent 71c5413e5c
commit 38a3d94410
2 changed files with 52 additions and 10 deletions
+24 -5
View File
@@ -825,8 +825,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// bottom tree.
const seen = new Set<string>()
const reid = new Map<string, string>()
const splits = sanitizeNode(record.splits, seen, reid)
if (splits === undefined) return undefined
const restoredSplits = sanitizeNode(record.splits, seen, reid)
if (restoredSplits === undefined) return undefined
const splits = pruneEmptyPanes(restoredSplits)
// Bottom-panel fields arrived in a later build: a missing or malformed
// value on an OLDER persisted state defaults (closed / default height /
// empty pane) so existing layouts keep loading, like nextBrowser.
@@ -840,15 +841,23 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
? record.bottomHeight
: BOTTOM_DEFAULT
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
const requestedActivePane = typeof record.activePane === 'string'
? (reid.get(record.activePane) ?? record.activePane)
: null
const activePane = requestedActivePane === null
? null
: treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
? requestedActivePane
: firstLeaf(splits).id
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
activePane,
nextTerminal: record.nextTerminal,
nextBrowser,
expanded: record.expanded as string[],
@@ -863,6 +872,16 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
}
}
/** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */
function pruneEmptyPanes(node: SplitNode): SplitNode {
const leaves = allLeaves(node)
if (!leaves.some(leaf => leaf.tabs.length > 0)) return node
return leaves.reduce(
(tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree,
node,
)
}
/**
* One tree node id, deduplicated against the ids already seen in this
* state. Duplicates are exactly the pre-seeding counter-reset corruption
+28 -5
View File
@@ -188,6 +188,29 @@ describe('sidebar state', () => {
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
})
it('sanitize removes a pane emptied by ephemeral diff tabs', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:diff',
expanded: [],
splits: {
kind: 'split',
id: 'split:1',
dir: 'col',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:git', active: 'git', tabs: [{ id: 'git', type: 'git', title: 'Git' }] },
{ kind: 'leaf', id: 'pane:diff', active: 'd1', tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }] },
],
},
})
expect(valid?.splits.kind).toBe('leaf')
expect((valid?.splits as { id: string; tabs: SidebarTab[] }).id).toBe('pane:git')
expect(valid?.activePane).toBe('pane:git')
})
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
let s = state()
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
@@ -716,11 +739,11 @@ describe('persisted state sanitization', () => {
const clean = sanitizeState(corrupted)
expect(clean).toBeDefined()
const leaves = allLeaves(clean!.splits)
// The first occurrence keeps its id; the repeat gets a fresh unique one
// (exact suffix depends on the module-level uid counter, so assert shape).
expect(leaves[0]!.id).toBe('pane:1')
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
expect(clean!.activePane).toBe(leaves[1]!.id)
// The empty first occurrence is pruned; the populated repeat keeps its
// fresh unique id and becomes active.
expect(leaves).toHaveLength(1)
expect(leaves[0]!.id).not.toBe('pane:1')
expect(clean!.activePane).toBe(leaves[0]!.id)
// And an open must land in exactly one pane of the healed tree.
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))