fix: split panes duplicate when a file opens — stale uid collisions with persisted ids

The uid counter is module-global and resets on every reload, while the
persisted split tree keeps its old ids (pane:1, split:1, ...). A split
created AFTER a reload minted a fresh "pane:1" beside the persisted
"pane:1"; mapLeaf then visited BOTH leaves, so every open (explorer click,
produced-files chip) landed the tab in both panes of a top-bottom split.

- loadState seeds the counter past the raw persisted ids before sanitize
- setSession re-seeds on cache hits (another session's smaller max must
  not leave the counter below this session's ids)
- sanitize re-ids stale duplicate pane/split ids (heals states already
  corrupted) and follows the activePane rename
- openTab falls back to the first pane when the active pane is gone
  instead of silently dropping the open

Unit tests cover the re-id heal and the stale-activePane fallback;
verified end-to-end in headless Chrome: a state with two "pane:1" leaves
loads healed and a file click lands in exactly one pane.
This commit is contained in:
Menghuan1918
2026-08-09 00:14:27 +08:00
parent c81de3374f
commit fec53cef0d
2 changed files with 127 additions and 10 deletions
+49 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import {
activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab,
resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
activateTab, allLeaves, closeTab, insertLeafAt, makeDefaultState, moveTab, moveTabToEdge,
openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SplitNode,
} from '../src/client/state.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
@@ -317,6 +317,53 @@ describe('persisted state sanitization', () => {
withBadActive.splits.active = 'ghost-tab'
expect(sanitizeState(withBadActive)).toBeUndefined()
})
it('re-ids stale duplicate pane/split ids and follows the activePane rename', () => {
// The pre-seeding counter reset could mint a fresh "pane:1" beside the
// persisted "pane:1": mapLeaf then hit BOTH leaves and every open landed
// in both panes. Sanitize must give the repeat a fresh id.
const corrupted = JSON.parse(JSON.stringify(makeDefaultState(400)))
corrupted.activePane = 'pane:1'
corrupted.splits = {
kind: 'split',
id: 'split:1',
dir: 'col',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:1', tabs: [], active: null },
{ kind: 'leaf', id: 'pane:1', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
],
}
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)
// And an open must land in exactly one pane of the healed tree.
const opened = openTab(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'))
expect(owners).toHaveLength(1)
})
it('falls back from a stale active pane instead of dropping the open', () => {
let s = makeDefaultState()
const paneA = allLeaves(s.splits)[0]!.id
const explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id
s = closeTab(s, paneA, explorerTab)
s = openTab(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
s = { ...s, splits: split.node, activePane: paneA }
// Closing the editor empties paneA; the pane is removed but activePane
// still points at it. The next open must land in the surviving pane.
s = closeTab(s, paneA, 'editor:/a.ts')
s = openTab(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
expect(owners).toHaveLength(1)
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
})
})
describe('path helpers', () => {