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:
+78
-8
@@ -66,6 +66,39 @@ function uid(prefix: string): string {
|
|||||||
return `${prefix}:${nextIdCounter}`
|
return `${prefix}:${nextIdCounter}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest numeric suffix across a raw persisted state's counter ids
|
||||||
|
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
||||||
|
* resets on every reload, so a split minted AFTER a reload would collide
|
||||||
|
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
|
||||||
|
* mapLeaf would then visit BOTH leaves and every open would land in both
|
||||||
|
* panes of the split. Seeding the counter past the persisted ids keeps
|
||||||
|
* fresh ids disjoint.
|
||||||
|
*/
|
||||||
|
function maxCounterId(parsed: unknown): number {
|
||||||
|
let max = 0
|
||||||
|
const consider = (id: unknown): void => {
|
||||||
|
if (typeof id !== 'string') return
|
||||||
|
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
|
||||||
|
if (match !== null) max = Math.max(max, Number(match[1]))
|
||||||
|
}
|
||||||
|
const walk = (node: unknown): void => {
|
||||||
|
if (node === null || typeof node !== 'object') return
|
||||||
|
const record = node as Record<string, unknown>
|
||||||
|
consider(record.id)
|
||||||
|
if (Array.isArray(record.tabs)) {
|
||||||
|
for (const tab of record.tabs) {
|
||||||
|
if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(record.children)) {
|
||||||
|
for (const child of record.children) walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk((parsed as Record<string, unknown> | null)?.splits)
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
/** A fresh default state: open, one explorer tab in one pane. `width` is
|
/** A fresh default state: open, one explorer tab in one pane. `width` is
|
||||||
* the caller's preferred panel width (default PANEL_DEFAULT); the store
|
* the caller's preferred panel width (default PANEL_DEFAULT); the store
|
||||||
* seeds new sessions with half the viewport. */
|
* seeds new sessions with half the viewport. */
|
||||||
@@ -268,7 +301,12 @@ function isSingle(type: TabType): boolean {
|
|||||||
* in the active pane (or the first pane when the active one is gone).
|
* in the active pane (or the first pane when the active one is gone).
|
||||||
*/
|
*/
|
||||||
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||||
const targetId = state.activePane ?? firstLeaf(state.splits).id
|
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||||||
|
// A stale activePane (its pane was closed since) must not swallow the
|
||||||
|
// open: fall back to the first pane instead of dropping the tab.
|
||||||
|
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
|
||||||
|
targetId = firstLeaf(state.splits).id
|
||||||
|
}
|
||||||
if (isSingle(tab.type)) {
|
if (isSingle(tab.type)) {
|
||||||
for (const leaf of allLeaves(state.splits)) {
|
for (const leaf of allLeaves(state.splits)) {
|
||||||
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
|
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
|
||||||
@@ -371,7 +409,11 @@ function loadState(sessionId: string): SidebarState {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||||
if (raw !== null) {
|
if (raw !== null) {
|
||||||
const sanitized = sanitizeState(JSON.parse(raw) as unknown)
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
// Seed the uid counter past the persisted ids (it resets on reload);
|
||||||
|
// sanitize re-ids any duplicates the pre-seeding counter left behind.
|
||||||
|
nextIdCounter = maxCounterId(parsed)
|
||||||
|
const sanitized = sanitizeState(parsed)
|
||||||
if (sanitized !== undefined) return sanitized
|
if (sanitized !== undefined) return sanitized
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -404,21 +446,44 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
|||||||
}
|
}
|
||||||
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
||||||
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
||||||
const splits = sanitizeNode(record.splits)
|
const reid = new Map<string, string>()
|
||||||
|
const splits = sanitizeNode(record.splits, new Set(), reid)
|
||||||
if (splits === undefined) return undefined
|
if (splits === undefined) return undefined
|
||||||
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
||||||
return {
|
return {
|
||||||
panelOpen: record.panelOpen,
|
panelOpen: record.panelOpen,
|
||||||
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
|
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
|
||||||
activePane: typeof record.activePane === 'string' ? record.activePane : null,
|
// 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,
|
||||||
nextTerminal: record.nextTerminal,
|
nextTerminal: record.nextTerminal,
|
||||||
expanded: record.expanded as string[],
|
expanded: record.expanded as string[],
|
||||||
splits,
|
splits,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tree node id, deduplicated against the ids already seen in this
|
||||||
|
* state. Duplicates are exactly the pre-seeding counter-reset corruption
|
||||||
|
* (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
|
||||||
|
* keeping both would make mapLeaf visit two leaves at once and every open
|
||||||
|
* would land in both panes, so the repeat gets a fresh id.
|
||||||
|
* @returns the id to use (the original, or a fresh uid for repeats).
|
||||||
|
*/
|
||||||
|
function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
|
||||||
|
const fresh = uid(prefix)
|
||||||
|
seen.add(fresh)
|
||||||
|
reid.set(id, fresh)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
|
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
|
||||||
function sanitizeNode(node: unknown): SplitNode | undefined {
|
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
|
||||||
if (node === null || typeof node !== 'object') return undefined
|
if (node === null || typeof node !== 'object') return undefined
|
||||||
const record = node as Record<string, unknown>
|
const record = node as Record<string, unknown>
|
||||||
if (record.kind === 'leaf') {
|
if (record.kind === 'leaf') {
|
||||||
@@ -443,14 +508,14 @@ function sanitizeNode(node: unknown): SplitNode | undefined {
|
|||||||
}
|
}
|
||||||
const active = typeof record.active === 'string' ? record.active : null
|
const active = typeof record.active === 'string' ? record.active : null
|
||||||
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
|
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
|
||||||
return { kind: 'leaf', id: record.id, tabs, active }
|
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active }
|
||||||
}
|
}
|
||||||
if (record.kind === 'split') {
|
if (record.kind === 'split') {
|
||||||
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
|
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
|
||||||
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
|
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
|
||||||
const children: SplitNode[] = []
|
const children: SplitNode[] = []
|
||||||
for (const child of record.children) {
|
for (const child of record.children) {
|
||||||
const clean = sanitizeNode(child)
|
const clean = sanitizeNode(child, seen, reid)
|
||||||
if (clean === undefined) return undefined
|
if (clean === undefined) return undefined
|
||||||
children.push(clean)
|
children.push(clean)
|
||||||
}
|
}
|
||||||
@@ -461,7 +526,7 @@ function sanitizeNode(node: unknown): SplitNode | undefined {
|
|||||||
) {
|
) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return { kind: 'split', id: record.id, dir: record.dir, sizes: record.sizes as number[], children }
|
return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -483,6 +548,11 @@ export class SidebarStore {
|
|||||||
if (state === undefined) {
|
if (state === undefined) {
|
||||||
state = loadState(sessionId)
|
state = loadState(sessionId)
|
||||||
this.bySession.set(sessionId, state)
|
this.bySession.set(sessionId, state)
|
||||||
|
} else {
|
||||||
|
// Cache hit: another session's load/ops may have left the uid
|
||||||
|
// counter below THIS session's persisted ids — re-seed so fresh
|
||||||
|
// pane/split ids can never collide with its tree.
|
||||||
|
nextIdCounter = maxCounterId(state)
|
||||||
}
|
}
|
||||||
this.snapshot = { sessionId, state }
|
this.snapshot = { sessionId, state }
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-2
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||||
import {
|
import {
|
||||||
activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab,
|
activateTab, allLeaves, closeTab, insertLeafAt, makeDefaultState, moveTab, moveTabToEdge,
|
||||||
resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||||
type SidebarState, type SplitNode,
|
type SidebarState, type SplitNode,
|
||||||
} from '../src/client/state.ts'
|
} from '../src/client/state.ts'
|
||||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||||
@@ -317,6 +317,53 @@ describe('persisted state sanitization', () => {
|
|||||||
withBadActive.splits.active = 'ghost-tab'
|
withBadActive.splits.active = 'ghost-tab'
|
||||||
expect(sanitizeState(withBadActive)).toBeUndefined()
|
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', () => {
|
describe('path helpers', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user