import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeFloatByTab, closeTab, createSidebarStore, dockFloat, FLOAT_MIN_H, FLOAT_MIN_W, floatTab, floatWithTab, insertLeafAt, makeDefaultState, migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplit, resizeSplitIn, revealPaths, sanitizeState, setBottomHeight, setTabPin, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' describe('sidebar state', () => { const state = (): SidebarState => makeDefaultState() it('makeDefaultState seeds per the seed enum (editor-home / none)', () => { // Default and explicit 'editor-home' seed an EMPTY editor tab (the files // window) with the tree panel pinned open. for (const s of [makeDefaultState(), makeDefaultState(400, true, 'editor-home')]) { const leaf = s.splits as { tabs: SidebarTab[]; active: string | null } expect(leaf.tabs).toHaveLength(1) expect(leaf.tabs[0]!.type).toBe('editor') expect(leaf.tabs[0]!.title).toBe('Files') expect(leaf.tabs[0]!.path).toBeUndefined() expect(leaf.tabs[0]!.meta).toEqual({ treeOpen: true }) expect(leaf.active).toBe(leaf.tabs[0]!.id) } // The seeded home tab survives the persist round-trip (meta intact). const restored = sanitizeState(JSON.parse(JSON.stringify(makeDefaultState()))) const restoredLeaf = restored!.splits as { tabs: SidebarTab[] } expect(restoredLeaf.tabs[0]!.meta).toEqual({ treeOpen: true }) // 'none' seeds an empty pane. const bare = makeDefaultState(400, true, 'none') const bareLeaf = bare.splits as { tabs: SidebarTab[]; active: string | null } expect(bareLeaf.tabs).toHaveLength(0) expect(bareLeaf.active).toBeNull() }) it('sanitizeState migrates persisted explorer tabs to editor home tabs (both trees)', () => { const valid = sanitizeState({ panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: 'ex-right', tabs: [{ id: 'ex-right', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }], }, bottomSplits: { kind: 'leaf', id: 'pane:b', active: 'ex-bottom', tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer' }], }, }) const right = (valid?.splits as { tabs: SidebarTab[] }).tabs expect(right).toHaveLength(1) // Migrated: editor home tab (no path), tree pinned open, prior meta kept. expect(right[0]).toMatchObject({ id: 'ex-right', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } }) expect(right[0]!.path).toBeUndefined() const bottom = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs expect(bottom).toHaveLength(1) expect(bottom[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true } }) }) it('opens tabs into the active pane and dedupes by id (safety net)', () => { let s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } s = openTabInActivePane(s, gitTab) expect(s.splits.kind).toBe('leaf') expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) // Reopening with the SAME id focuses the existing tab instead of duplicating. const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' }) expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) // A different id opens a new tab (type-level dedupe is the service's job). const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' }) expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3) }) it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => { let s = state() const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2']) }) const diffTab = (id: string): SidebarTab => ({ id, type: 'diff', title: id, diff: { kind: 'worktree', path: 'src/a.ts', staged: false }, }) it('first diff splits the source pane vertically (diff below)', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } const withGit = openTabInActivePane(s, gitTab) const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) expect(after.splits.kind).toBe('split') const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] } expect(split.dir).toBe('col') expect(split.children).toHaveLength(2) // The source stays on TOP (first child), the diff lands in the new bottom leaf. expect(split.children[0]!.id).toBe(sourcePane) expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts']) expect(after.activePane).toBe(split.children[1]!.id) }) it('reopening the same diff focuses its existing tab', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } const withGit = openTabInActivePane(s, gitTab) const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts')) // No new panes, no duplicate tabs. expect(second.splits.kind).toBe('split') const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] } const allTabs = split.children.flatMap(child => child.tabs ?? []) expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1) }) it('subsequent diffs stack into the existing diff pane', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } const withGit = openTabInActivePane(s, gitTab) const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678')) // Still one split: the second diff joins the bottom leaf instead of splitting again. expect(second.splits.kind).toBe('split') const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] } const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff')) expect(diffLeaves).toHaveLength(1) expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([ 'diff:w:u:src/a.ts', 'diff:c:abc1234def5678abc1234def5678abc1234def5678', ]) }) it('openDiffTab degrades to a regular open when the source pane is gone', () => { const s = state() const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts')) expect(after.splits.kind).toBe('leaf') expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts') }) it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => { const valid = sanitizeState({ panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: 'd1', tabs: [ { id: 'explorer-tab', type: 'explorer', title: 'Explorer' }, { id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } }, ], }, }) expect(valid?.splits.kind).toBe('leaf') const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab']) // The dropped diff tab was the active one: the leaf falls back to a null // active instead of resetting the whole state. expect((valid?.splits as { active: string | null }).active).toBeNull() // A leaf of ONLY diff tabs survives as an empty pane (welcome cards). const onlyDiff = sanitizeState({ panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: 'd1', tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }], }, }) expect(onlyDiff?.splits.kind).toBe('leaf') 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' }) expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) // Reopening (e.g. the auto-activation effect) focuses the existing tab. const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1) }) it('splits panes and moves tabs between them', () => { let s = state() s = splitPane(s, 'row') expect(s.splits.kind).toBe('split') const split = s.splits as Extract expect(split.children).toHaveLength(2) const explorerId = (split.children[0] as { id: string }).id const otherId = (split.children[1] as { id: string }).id expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0) const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id s = moveTab(s, explorerId, explorerTab, otherId) // The source pane emptied and was removed; the target leaf is promoted. expect(s.splits.kind).toBe('leaf') expect((s.splits as { id: string }).id).toBe(otherId) expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab]) }) it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => { let s = state() s = splitPane(s, 'row') const split = s.splits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string; tabs: { id: string }[] } const tabId = paneA.tabs[0]!.id // 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。 s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' }) s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right') const after = s.splits as Extract // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 const bSplit = after.children.find(child => child.kind === 'split') as Extract | undefined expect(bSplit).toBeDefined() expect(bSplit!.dir).toBe('row') const newLeaf = bSplit!.children[1] as { tabs: { id: string }[] } expect(newLeaf.tabs.map(t => t.id)).toContain(tabId) }) it('dragging a tab to a pane center merges it into the pane', () => { let s = state() s = splitPane(s, 'col') const split = s.splits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string; tabs: { id: string }[] } const tabId = paneA.tabs[0]!.id s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center') // paneA 空了被移除,树退化为 paneB(含 tab)。 expect(s.splits.kind).toBe('leaf') expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId]) }) it('dragging a tab back onto its own pane center reorders it', () => { let s = state() s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' }) const leaf = s.splits as { id: string; tabs: { id: string }[] } const first = leaf.tabs[0]!.id s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center') const after = s.splits as { tabs: { id: string }[] } expect(after.tabs[after.tabs.length - 1]!.id).toBe(first) expect(after.tabs).toHaveLength(2) }) it('closing the last tab removes the pane (promotes the sibling)', () => { let s = state() s = splitPane(s, 'col') const split = s.splits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string } const explorerId = paneA.tabs[0]!.id // paneA gets a terminal; the explorer moves to paneB; closing the // terminal empties paneA, which is removed, promoting paneB. s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' }) s = moveTab(s, paneA.id, explorerId, paneB.id) s = activateTab(s, paneA.id, 't') s = closeTab(s, paneA.id, 't') expect(s.splits.kind).toBe('leaf') expect((s.splits as { id: string }).id).toBe(paneB.id) }) it('resizes splits within the clamp range', () => { let s = state() s = splitPane(s, 'row') const split = s.splits as Extract const id = split.id s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) } const after = s.splits as Extract expect(after.sizes[0]).toBeCloseTo(0.7) expect(after.sizes[1]).toBeCloseTo(0.3) }) it('tracks explorer expansion and tab activation', () => { let s = state() s = toggleExpanded(s, '/p/a') s = toggleExpanded(s, '/p/b') expect(s.expanded).toEqual(['/p/a', '/p/b']) s = toggleExpanded(s, '/p/a') expect(s.expanded).toEqual(['/p/b']) const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null } const tabId = leaf.tabs[0]!.id const after = activateTab(s, leaf.id, tabId) expect((after.splits as { active: string | null }).active).toBe(tabId) }) it('patchTab updates the title and path of one open tab (browser persistence)', () => { let s = state() const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] } s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' }) const browserId = 'browser:1' s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' }) const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId) expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' }) // A partial patch leaves the other field untouched. s = patchTab(s, browserId, { title: 'example.org' }) const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId) expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' }) // Other tabs are untouched. expect(leaf.tabs[0]).toBeDefined() }) it('patchTab is a no-op for a missing tab id', () => { const s = state() const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' }) expect(after).toBe(s) }) it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => { const base = { panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: null, tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }], }, } // Older persisted states lack the field: they must keep loading. expect(sanitizeState(base)?.nextBrowser).toBe(1) // A present valid value survives; a malformed one falls back to 1. expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7) expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1) expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1) // The default state seeds 1. expect(makeDefaultState().nextBrowser).toBe(1) }) it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => { let s = state() const leaf = s.splits as { id: string; tabs: { id: string }[] } const explorerId = leaf.tabs[0]!.id expect(tabOpenIn(s, explorerId)).toBe(true) // Moving the tab to another pane keeps it open. s = splitPane(s, 'row') const split = s.splits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string } s = moveTab(s, paneA.id, explorerId, paneB.id) expect(tabOpenIn(s, explorerId)).toBe(true) // Closing it removes it from the whole tree. const target = s.splits as { id: string; tabs: { id: string }[] } s = closeTab(s, target.id, explorerId) expect(tabOpenIn(s, explorerId)).toBe(false) // A terminal tab added later is open too. s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' }) expect(tabOpenIn(s, 'terminal:9')).toBe(true) }) // ── Bottom panel (the second, independent workbench) ─────────────────── it('toggleBottomPanel flips the bottom panel independently of the right panel', () => { let s = state() expect(s.bottomOpen).toBe(false) s = toggleBottomPanel(s) expect(s.bottomOpen).toBe(true) // Collapsing the right panel leaves the bottom panel open (independent toggles). s = togglePanel(s) expect(s.panelOpen).toBe(false) expect(s.bottomOpen).toBe(true) }) it('setBottomHeight clamps to the contract range', () => { expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN) const g = globalThis as Record const previous = g.window g.window = { innerHeight: 800 } try { // The bottom panel must leave the center column at least PANEL_MIN // tall (800 - 280), regardless of the right panel's open state. expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280) expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280) } finally { if (previous === undefined) delete g.window else g.window = previous } }) // ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ── it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id // Two bottom tabs in their own pane; the right pane holds explorer. s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' }) const migrated = migrateBottomTabs(s) // All tabs now live in the right tree's first leaf, bottom tabs appended. expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id)) .toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2']) // The bottom tree is emptied (structure stays), the panel closes, and // new tabs land in the right tree. expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) expect(migrated.bottomOpen).toBe(false) expect(migrated.activePane).toBe((migrated.splits as { id: string }).id) // The migrated tabs are fully functional: closing one works through the // right tree. expect(tabOpenIn(migrated, 'terminal:1')).toBe(true) expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false) }) it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => { let s = state() s = splitPane(s, 'row') // splits the active pane into two leaves s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' }) const migrated = migrateBottomTabs(s) // The first (leftmost) leaf carries the bottom tab; the second leaf // keeps its own tabs untouched. const leaves = allLeaves(migrated.splits) expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9') expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0) }) it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => { const s = state() expect(migrateBottomTabs(s)).toBe(s) // With the panel open but no tabs, the migration only closes the panel. const open = toggleBottomPanel(s) const migrated = migrateBottomTabs(open) expect(migrated).not.toBe(open) expect(migrated.bottomOpen).toBe(false) expect(migrateBottomTabs(migrated)).toBe(migrated) }) it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => { let s = state() const bottomPane = (s.bottomSplits as { id: string }).id s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed const migrated = migrateBottomTabs(s) expect(migrated.activePane).toBe((migrated.splits as { id: string }).id) // A tab opened after the migration lands in the VISIBLE right tree. const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' }) expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git') }) it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = { ...s, activePane: bottomPane } const tab = { id: 'git', type: 'git' as const, title: 'Git' } s = openTabInActivePane(s, tab) expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git') // The right tree is untouched (its seeded files-window home tab stays). expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['editor']) expect(s.activePane).toBe(bottomPane) // The id safety net works across trees: reopening the same id focuses it. const after = openTabInActivePane(s, tab) expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git']) }) it('openTabInActivePane falls back to the right tree when the active pane is stale', () => { let s = state() s = toggleBottomPanel(s) s = { ...s, activePane: 'pane:gone' } const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' }) expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git') }) it('closeTab routes to the bottom tree', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) expect(tabOpenIn(s, 'terminal:1')).toBe(true) s = closeTab(s, bottomPane, 'terminal:1') expect(tabOpenIn(s, 'terminal:1')).toBe(false) // The right tree is untouched. expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true) }) it('moveTabToEdge splits within the bottom tree', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right') expect(s.bottomSplits.kind).toBe('split') expect(s.splits.kind).toBe('leaf') expect(tabOpenIn(s, 'terminal:1')).toBe(true) // The fresh leaf (the drop's active pane) differs from the source pane. expect(s.activePane).not.toBe(bottomPane) }) it('resizeSplitIn routes a divider to its own tree', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = splitPane({ ...s, activePane: bottomPane }, 'row') const split = s.bottomSplits as Extract s = resizeSplitIn(s, split.id, 0, 0.1) const next = s.bottomSplits as Extract expect(next.sizes[0]).toBeCloseTo(0.6) expect(s.splits.kind).toBe('leaf') }) it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => { const base = { panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: null, tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }], }, } // Older persisted states lack the bottom fields: defaults, state kept. const s = sanitizeState(base) expect(s?.bottomOpen).toBe(false) expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT) expect(s?.bottomSplits.kind).toBe('leaf') expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) // A malformed bottom tree is replaced with a fresh empty pane. const broken = sanitizeState({ ...base, bottomSplits: 'junk' }) expect(broken?.splits).toBeDefined() expect(broken?.bottomSplits.kind).toBe('leaf') // A valid persisted bottom tree survives. const withBottom = sanitizeState({ ...base, bottomOpen: true, bottomHeight: 300, bottomSplits: { kind: 'leaf', id: 'pane:9', active: 'b1', tabs: [{ id: 'b1', type: 'terminal', title: 'T' }], }, }) expect(withBottom?.bottomOpen).toBe(true) expect(withBottom?.bottomHeight).toBe(300) expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1']) // Heights are clamped to the contract range. expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN) // A stale full-height bottom panel must not squeeze the center column // (the agent output area) to zero: the cap leaves it at least PANEL_MIN // tall, regardless of the right panel's open state. const g = globalThis as Record const previous = g.window g.window = { innerHeight: 800 } try { expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280) expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280) } finally { if (previous === undefined) delete g.window else g.window = previous } }) it('tabOpenIn and patchTab see tabs in the bottom tree', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = openTabInActivePane( { ...s, activePane: bottomPane }, { id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' }, ) expect(tabOpenIn(s, 'browser:1')).toBe(true) s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' }) const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1') expect(tab?.title).toBe('other.com') }) it('moves a tab across panels (center merge into the other tree)', () => { let s = state() s = toggleBottomPanel(s) const rightPane = (s.splits as { id: string }).id const bottomPane = (s.bottomSplits as { id: string }).id const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id // Drag the explorer tab from the right panel into the bottom panel (center). s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center') expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId) expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) expect(s.activePane).toBe(bottomPane) // And back, inserted at an index. s = moveTab(s, bottomPane, explorerId, rightPane, 0) expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId) expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) }) it('moves a tab across panels by splitting the target pane (edge drop)', () => { let s = state() s = toggleBottomPanel(s) const rightPane = (s.splits as { id: string }).id const bottomPane = (s.bottomSplits as { id: string }).id const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right') // The source tree empties back to a leaf; the target tree splits. expect(s.splits.kind).toBe('leaf') expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) expect(s.bottomSplits.kind).toBe('split') expect(tabOpenIn(s, explorerId)).toBe(true) const split = s.bottomSplits as Extract expect(split.children.some( child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId), )).toBe(true) // The fresh leaf (the drop's active pane) differs from the source pane. expect(s.activePane).not.toBe(rightPane) }) it('moveTab with a non-existent source or target pane is safe', () => { const s = state() const pane = (s.splits as { id: string }).id // Missing source: returns unchanged state expect(moveTab(s, 'pane:ghost', 'tab:1', pane)).toBe(s) // Missing target: returns unchanged state expect(moveTab(s, pane, 'tab:1', 'pane:ghost')).toBe(s) }) it('closeTab with non-existent tab or pane returns equivalent state without throwing', () => { const s = state() const pane = (s.splits as { id: string }).id expect(closeTab(s, 'pane:ghost', 'tab:1')).toEqual(s) expect(closeTab(s, pane, 'tab:ghost')).toEqual(s) }) it('moveTabToEdge with non-existent pane returns unchanged state', () => { const s = state() const pane = (s.splits as { id: string }).id expect(moveTabToEdge(s, 'pane:ghost', 'tab:1', pane, 'right')).toBe(s) expect(moveTabToEdge(s, pane, 'tab:1', 'pane:ghost', 'right')).toBe(s) }) }) describe('persisted state sanitization', () => { it('accepts a well-formed state unchanged (node environment: no width clamp)', () => { const state = makeDefaultState(400) const clean = sanitizeState(JSON.parse(JSON.stringify(state))) expect(clean).toEqual(state) }) it('accepts a subagent tab as a known type', () => { const raw = JSON.parse(JSON.stringify(makeDefaultState(400))) raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' }) raw.splits.active = 'tab:9' const clean = sanitizeState(raw) expect(clean).toBeDefined() const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs expect(tabs.some(tab => tab.type === 'subagent')).toBe(true) }) it('clamps undersized widths to the panel minimum', () => { const state = { ...makeDefaultState(400), width: 10 } const clean = sanitizeState(JSON.parse(JSON.stringify(state))) expect(clean?.width).toBe(280) }) it('rejects malformed shapes instead of crashing the panel', () => { expect(sanitizeState(null)).toBeUndefined() expect(sanitizeState('nope')).toBeUndefined() expect(sanitizeState({})).toBeUndefined() expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined() expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined() // A split whose sizes do not match its children is rejected. const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400))) withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] } expect(sanitizeState(withSplit)).toBeUndefined() // Unknown tab types (external plugins not yet loaded) are accepted — // they render as at view time and recover if the plugin // loads later. Only diff tabs are dropped (ephemeral). const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400))) withExternalTab.splits.tabs[0].type = 'my-plugin:db' const externalClean = sanitizeState(withExternalTab) expect(externalClean).toBeDefined() if (externalClean !== undefined && externalClean.splits.kind === 'leaf') { expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db') } // An active id that no tab carries is rejected. const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400))) 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 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')) 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 seededTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'editor')!.id s = closeTab(s, paneA, seededTab) s = openTabInActivePane(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 = openTabInActivePane(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) }) it('handles state with deeply corrupted split children gracefully', () => { const corrupted = { ...makeDefaultState(400), splits: { kind: 'split', id: 's1', dir: 'row', sizes: [0.5, 0.5], children: [ { kind: 'leaf', id: 'pane:1', tabs: null, active: null }, { kind: 'leaf', id: 'pane:2', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' }, ], }, } expect(sanitizeState(corrupted)).toBeUndefined() }) }) describe('v0.12.0 store additions', () => { // These blocks exercise store reduce/reduceFor (which schedule the // localStorage persist through window timers) and sanitizeState (which // reads window.innerHeight). Stub the browser globals ONLY inside this // scope so the earlier describes keep their window-less environment. beforeEach(() => { const g = globalThis as Record g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 } g.localStorage = { getItem: () => null, setItem: () => {} } }) afterEach(() => { const g = globalThis as Record delete g.window delete g.localStorage }) describe('store.reduceFor (targeted opens, v0.12.0)', () => { it('mutates the target session, persists it, and leaves the active snapshot untouched', () => { const store = createSidebarStore() store.setSession('s1') let calls = 0 store.subscribe(() => { calls++ }) store.reduceFor('s2', (state) => ({ ...state, expanded: ['/x'] })) // No notify, no snapshot switch. expect(calls).toBe(0) expect(store.getSnapshot().sessionId).toBe('s1') // The target session's state updated and loads back on switch. store.setSession('s2') expect(store.getSnapshot().state?.expanded).toEqual(['/x']) }) it('loads a fresh state for a never-visited target session', () => { const store = createSidebarStore() store.setSession('s1') store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false })) store.setSession('brand-new') expect(store.getSnapshot().state?.panelOpen).toBe(false) expect(store.getSnapshot().state?.splits).toBeDefined() }) it('reduceFor never lowers the shared uid counter below the active session needs (no pane-id collision)', () => { const store = createSidebarStore() // Session 'b' is cached FIRST with a LOW id range (pane:1 / tab:2). store.setSession('b') // Session 'a' then loads and its operations raise the shared counter // well above b's max (default pane, plus fresh pane ids from splits). store.setSession('a') store.reduce(s => splitPane(s, 'row')) const before = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id).sort() // A targeted reduce into the OLD, low-id session must not lower the // counter: the next split in the ACTIVE session would otherwise mint // an id that already exists (mapLeaf visits both leaves → corruption). store.reduceFor('b', (state) => state) store.reduce(s => splitPane(s, 'row')) const after = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id) expect(new Set(after).size).toBe(after.length) // The active session's pre-existing pane ids all survived untouched. for (const id of before) expect(after).toContain(id) }) it('persists each session independently (per-session debounce timers)', () => { const g = globalThis as Record let seq = 0 const timers = new Map void>() const writes: string[] = [] g.window = { clearTimeout: (id: number) => { timers.delete(id) }, setTimeout: (fn: () => void) => { const id = ++seq; timers.set(id, fn); return id }, innerWidth: 1024, innerHeight: 800, } g.localStorage = { getItem: () => null, setItem: (key: string) => { writes.push(key) }, } try { const store = createSidebarStore() store.setSession('a') store.reduce(s => ({ ...s, expanded: ['/a'] })) // schedules persist(a) store.reduceFor('b', s => ({ ...s, expanded: ['/b'] })) // schedules persist(b) // A shared timer would have cancelled persist(a) — with per-session // timers BOTH writes are pending and both land when they fire. expect(timers.size).toBe(2) for (const [, fn] of [...timers]) fn() // Each persist also syncs the shared cross-session width key (PR #36). expect(writes.filter(key => key !== 'dsh-sidebar:v1:width')) .toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b']) expect(writes).toContain('dsh-sidebar:v1:width') } finally { delete g.window delete g.localStorage } }) }) describe('tab meta persistence (v0.12.0)', () => { it('sanitizeState carries plugin meta through a reload round-trip', () => { const store = createSidebarStore() store.setSession('s1') store.reduce(s => ({ ...s, splits: { kind: 'leaf' as const, id: 'pane:1', tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }], active: 'tab:1', }, })) const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!))) const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs) expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 }) }) }) }) describe('free windows (v0.16.0)', () => { // Node environment: no window → the viewport clamps are Infinity and only // the size floors apply; the geometry tests stub a window where clamping // is the point under test. const state = (): SidebarState => makeDefaultState() it('floatTab moves a docked tab into floats (pane keeps its other tabs)', () => { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } const term = { id: 'term:1', type: 'terminal' as const, title: 'Term' } s = openTabInActivePane(s, git) s = openTabInActivePane(s, term) const floated = floatTab(s, git.id, 300, 200) const leaf = floated.splits as SidebarLeaf expect(leaf.tabs).toHaveLength(2) expect(leaf.tabs.some(tab => tab.id === 'git')).toBe(false) // The pane's active pointer fell to the last remaining tab. expect(leaf.active).toBe('term:1') expect(floated.floats).toHaveLength(1) expect(floated.floats[0]!.tab).toEqual(git) // Phone-ratio default (390x780) centered on the drop point (no viewport // → size cap never bites; only the negative y clamps to 0). expect(floated.floats[0]).toMatchObject({ x: 300 - 195, y: 0, w: 390, h: 780 }) expect(floated.activePane).toBe(leaf.id) }) it('floatTab collapses a pane it empties and repoints the active pane', () => { let s = state() const paneId = (s.splits as SidebarLeaf).id s = splitPane(s, 'row') const fresh = allLeaves(s.splits).find(leaf => leaf.id !== paneId)! s = { ...s, activePane: fresh.id } s = openTabInActivePane(s, { id: 'term:1', type: 'terminal', title: 'T' }) expect(allLeaves(s.splits).find(leaf => leaf.id === fresh.id)!.tabs).toHaveLength(1) const floated = floatTab(s, 'term:1', 100, 100) expect(floated.floats).toHaveLength(1) expect(allLeaves(floated.splits).some(leaf => leaf.id === fresh.id)).toBe(false) expect(floated.activePane).toBe(paneId) }) it('floatTab with an unknown or already-floating tab id is a strict no-op', () => { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } s = openTabInActivePane(s, git) expect(floatTab(s, 'nope', 0, 0)).toBe(s) const floated = floatTab(s, 'git', 50, 50) expect(floatTab(floated, 'git', 60, 60)).toBe(floated) }) it('moveFloat / resizeFloat clamp to the viewport (floors always)', () => { const g = globalThis as Record const previous = g.window g.window = { innerWidth: 1024, innerHeight: 768 } try { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } s = floatTab(openTabInActivePane(s, git), 'git', 512, 384) const id = s.floats[0]!.id // Negative position clamps to 0; the window stays fully inside. let moved = moveFloat(s, id, -50, -50) expect(moved.floats[0]).toMatchObject({ x: 0, y: 0 }) // Beyond the right/bottom edge clamps the top-left so the window fits. moved = moveFloat(moved, id, 5000, 5000) // The float was CREATED capped to the viewport: w 390, h 768-24=744. expect(moved.floats[0]!.x).toBe(1024 - 390) expect(moved.floats[0]!.y).toBe(768 - 744) // No-op move returns the same reference (no persist churn). expect(moveFloat(moved, id, moved.floats[0]!.x, moved.floats[0]!.y)).toBe(moved) // Resize: floors, viewport ceiling, and the SE-corner anchor (x/y keep). let resized = resizeFloat(moved, id, 10, 10) expect(resized.floats[0]).toMatchObject({ w: FLOAT_MIN_W, h: FLOAT_MIN_H }) resized = resizeFloat(resized, id, 5000, 5000) expect(resized.floats[0]!.w).toBe(1024 - resized.floats[0]!.x) expect(resized.floats[0]!.h).toBe(768 - resized.floats[0]!.y) // No-op resize and unknown ids return the same reference. expect(resizeFloat(resized, id, resized.floats[0]!.w, resized.floats[0]!.h)).toBe(resized) expect(resizeFloat(resized, 'nope', 400, 400)).toBe(resized) } finally { if (previous === undefined) delete g.window else g.window = previous } }) it('raiseFloat moves a window to the top and is idempotent at the top', () => { let s = state() s = floatTab(openTabInActivePane(s, { id: 'a', type: 'db', title: 'a' }), 'a', 100, 100) s = floatTab(openTabInActivePane(s, { id: 'b', type: 'db', title: 'b' }), 'b', 100, 100) s = floatTab(openTabInActivePane(s, { id: 'c', type: 'db', title: 'c' }), 'c', 100, 100) expect(s.floats.map(f => f.tab.id)).toEqual(['a', 'b', 'c']) const raised = raiseFloat(s, s.floats[0]!.id) expect(raised.floats.map(f => f.tab.id)).toEqual(['b', 'c', 'a']) // Already topmost / unknown id → same reference (no notify churn). expect(raiseFloat(raised, raised.floats[2]!.id)).toBe(raised) expect(raiseFloat(raised, 'float:none')).toBe(raised) // A single window has nothing to raise: same reference. let single = state() single = floatTab(single, (single.splits as SidebarLeaf).tabs[0]!.id, 0, 0) expect(single.floats).toHaveLength(1) expect(raiseFloat(single, single.floats[0]!.id)).toBe(single) }) it('dockFloat lands the tab in the target pane (or the active pane) and activates it', () => { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } s = floatTab(openTabInActivePane(s, git), 'git', 100, 100) const floatId = s.floats[0]!.id // Explicit target pane. s = splitPane(s, 'row') const target = allLeaves(s.splits).find(leaf => leaf.tabs.length === 0)! const docked = dockFloat(s, floatId, target.id) expect(docked.floats).toHaveLength(0) expect(allLeaves(docked.splits).find(leaf => leaf.id === target.id)!.tabs.map(tab => tab.id)).toEqual(['git']) expect(docked.activePane).toBe(target.id) // Default target: the active pane. let s2 = state() const home = (s2.splits as SidebarLeaf).tabs[0]! s2 = splitPane(s2, 'row') s2 = floatTab(s2, home.id, 100, 100) const active = s2.activePane! const docked2 = dockFloat(s2, s2.floats[0]!.id) expect(docked2.floats).toHaveLength(0) expect(allLeaves(docked2.splits).find(leaf => leaf.id === active)!.tabs.some(tab => tab.id === home.id)).toBe(true) expect(docked2.activePane).toBe(active) // Stale target falls back to the right tree's first leaf. let s3 = state() const home3 = (s3.splits as SidebarLeaf).tabs[0]! s3 = floatTab(s3, home3.id, 0, 0) const docked3 = dockFloat(s3, s3.floats[0]!.id, 'pane:gone') expect(docked3.floats).toHaveLength(0) expect(allLeaves(docked3.splits).some(leaf => leaf.tabs.some(tab => tab.id === home3.id))).toBe(true) // Unknown float id is a strict no-op. expect(dockFloat(docked3, 'float:gone')).toBe(docked3) }) it('tabOpenIn / patchTab / floatWithTab see floating tabs', () => { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } s = floatTab(openTabInActivePane(s, git), 'git', 100, 100) expect(tabOpenIn(s, 'git')).toBe(true) expect(floatWithTab(s, 'git')!.id).toBe(s.floats[0]!.id) const patched = patchTab(s, 'git', { title: 'Git 2', path: '/x' }) expect(patched.floats[0]!.tab).toMatchObject({ title: 'Git 2', path: '/x' }) expect(allLeaves(patched.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false) }) it('closeFloatByTab removes exactly the window holding the tab', () => { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } const term = { id: 'term:1', type: 'terminal' as const, title: 'T' } s = openTabInActivePane(s, git) s = openTabInActivePane(s, term) s = floatTab(s, 'git', 50, 50) s = floatTab(s, 'term:1', 60, 60) expect(s.floats).toHaveLength(2) const after = closeFloatByTab(s, 'git') expect(after.floats.map(f => f.tab.id)).toEqual(['term:1']) expect(closeFloatByTab(after, 'git')).toBe(after) }) it('openTabInActivePane focuses a floating tab by raising (id safety net)', () => { let s = state() const git = { id: 'git', type: 'git' as const, title: 'Git' } const term = { id: 'term:1', type: 'terminal' as const, title: 'T' } s = floatTab(openTabInActivePane(s, git), 'git', 50, 50) s = floatTab(openTabInActivePane(s, term), 'term:1', 60, 60) // Reopening the floated id must NOT duplicate: it raises the window. const after = openTabInActivePane(s, { id: 'git', type: 'git', title: 'Git' }) expect(after.floats.map(f => f.tab.id)).toEqual(['term:1', 'git']) expect(allLeaves(after.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false) }) it('reconcileAgentTerminals removes a vanished FLOATED agent terminal with its window', () => { let s = state() s = openTabInActivePane(s, { id: 'agent:u1', type: 'terminal', title: 'A1' }) s = floatTab(s, 'agent:u1', 50, 50) s = openTabInActivePane(s, { id: 'agent:u2', type: 'terminal', title: 'A2' }) expect(s.floats).toHaveLength(1) const after = reconcileAgentTerminals(s, [{ uuid: 'u2', title: 'A2' }]) expect(after.floats).toHaveLength(0) expect(allLeaves(after.splits).flatMap(leaf => leaf.tabs).some(tab => tab.id === 'agent:u2')).toBe(true) }) describe('revealPaths (show in folder)', () => { it('expands ancestors with their ABSOLUTE path (leading separator preserved)', () => { // POSIX: the root (/w/src) is not itself expanded, but the subdirs // below it must be recorded as ABSOLUTE paths so FileTree's // `expanded.includes(entry.path)` matches. const base = makeDefaultState() const next = revealPaths(base, '/w/src', ['/w/src/sub/deep/a.ts']) expect(next.revealed).toEqual(['/w/src/sub/deep/a.ts']) expect(next.expanded).toContain('/w/src/sub') expect(next.expanded).toContain('/w/src/sub/deep') expect(next.expanded).not.toContain('w/src/sub') expect(next.expanded).not.toContain('/w/src') }) it('keeps a Windows drive-letter root and a UNC prefix', () => { const drive = revealPaths(makeDefaultState(), 'C:\\work', ['C:\\work\\src\\a.ts']) expect(drive.expanded).toContain('C:\\work\\src') const unc = revealPaths(makeDefaultState(), '\\\\server\\share', ['\\\\server\\share\\sub\\a.ts']) expect(unc.expanded).toContain('\\\\server\\share\\sub') }) it('resolves nothing to the same reference (no churn)', () => { const base = makeDefaultState() expect(revealPaths(base, '/w', [])).toBe(base) }) }) describe('sanitizeState (floats)', () => { const g = globalThis as Record beforeEach(() => { g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 768 } g.localStorage = { getItem: () => null, setItem: () => {} } }) afterEach(() => { delete g.window delete g.localStorage }) const base = (): Record => ({ panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: null, tabs: [] }, }) it('a missing floats field defaults to none (older persisted states load)', () => { const restored = sanitizeState(base())! expect(restored.floats).toEqual([]) }) it('drops malformed entries individually and keeps the layout (unlike tree corruption)', () => { const parsed = base() parsed.floats = [ 'garbage', { id: 'float:1' }, // no tab { id: 'float:2', tab: { id: 't', type: 'git', title: 'G' }, x: 10, y: 10 }, // no w/h { id: 'float:3', tab: { id: 't3', type: 'git', title: 'G3' }, x: 10, y: 10, w: 500, h: 400 }, { id: 'float:3', tab: { id: 't3b', type: 'git', title: 'G3b' }, x: 0, y: 0, w: 400, h: 300 }, // duplicate id ] const restored = sanitizeState(parsed)! expect(restored.floats).toHaveLength(1) expect(restored.floats[0]).toMatchObject({ id: 'float:3', tab: { id: 't3' }, x: 10, y: 10 }) expect(restored.splits).toBeDefined() }) it('drops ephemeral diff tabs and migrates explorer tabs inside floats', () => { const parsed = base() parsed.floats = [ { id: 'float:1', tab: { id: 'd', type: 'diff', title: 'D' }, x: 0, y: 0, w: 400, h: 300 }, { id: 'float:2', tab: { id: 'ex', type: 'explorer', title: 'Explorer' }, x: 0, y: 0, w: 400, h: 300 }, ] const restored = sanitizeState(parsed)! expect(restored.floats.map(f => f.tab.type)).toEqual(['editor']) expect(restored.floats[0]!.tab).toMatchObject({ title: 'Files', meta: { treeOpen: true } }) }) it('clamps stale off-screen geometry into the current viewport', () => { const parsed = base() parsed.floats = [ { id: 'float:1', tab: { id: 't', type: 'git', title: 'G' }, x: -200, y: 900, w: 5000, h: 5000 }, ] const restored = sanitizeState(parsed)! // Sizes cap at the viewport, and the capped window lands at 0,0 (the // position clamp sees no room beyond it). expect(restored.floats[0]).toMatchObject({ x: 0, y: 0, w: 1024, h: 768 }) }) it('the uid counter seeds past persisted float ids (no collision on re-float)', () => { // Persisted: the pane max is 1, but floats reach float:2 — the counter // must seed to 2 so a fresh float mints float:3+, never a duplicate // float:2. (Intermediate mints during sanitize may raise it further; // the guarantee under test is uniqueness, not an exact value.) const parsed = base() parsed.floats = [ { id: 'float:2', tab: { id: 't2', type: 'git', title: 'G2' }, x: 0, y: 0, w: 400, h: 300 }, ] const saved = new Map() saved.set('dsh-sidebar:v1:seedtest', JSON.stringify(parsed)) g.localStorage = { getItem: (key: string) => saved.get(key) ?? null, setItem: (key: string, value: string) => { saved.set(key, value) }, } const store = createSidebarStore() store.setSession('seedtest') const s = store.getSnapshot().state! expect(s.floats[0]!.id).toBe('float:2') store.reduce(cur => openTabInActivePane(cur, { id: 'tab:9', type: 'git', title: 'G9' })) store.reduce(cur => floatTab(cur, 'tab:9', 30, 30)) const next = store.getSnapshot().state! expect(next.floats).toHaveLength(2) expect(new Set(next.floats.map(f => f.id)).size).toBe(2) const freshId = next.floats.map(f => f.id).find(id => id !== 'float:2')! expect(freshId).toMatch(/^float:\d+$/) expect(Number(freshId.slice('float:'.length))).toBeGreaterThan(2) }) it('floats survive a full persist round-trip (geometry verbatim when in-viewport)', () => { const parsed = base() parsed.floats = [ { id: 'float:1', tab: { id: 't', type: 'git', title: 'G', meta: { k: 1 } }, x: 12, y: 34, w: 480, h: 360 }, ] const restored = sanitizeState(parsed)! const again = sanitizeState(JSON.parse(JSON.stringify(restored)))! expect(again.floats).toEqual(restored.floats) }) }) }) describe('pinned terminals (v0.17.0)', () => { // setTabPin reads neither window nor localStorage directly, but the // pinnedTab-aware sanitize round-trip below uses JSON.parse/stringify // only — keep this block window-less for parity with the main describe. const state = (): SidebarState => makeDefaultState() it('setTabPin marks a docked terminal in the right tree', () => { let s = state() s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) s = setTabPin(s, 'terminal:1', { scope: 'workspace', homeCwd: '/proj' }) const tab = allLeaves(s.splits).flatMap(l => l.tabs).find(t => t.id === 'terminal:1')! expect(tab.pin).toEqual({ scope: 'workspace', homeCwd: '/proj' }) }) it('setTabPin marks a terminal in the bottom tree and a floated terminal', () => { let s = state() s = toggleBottomPanel(s) const bottomPane = (s.bottomSplits as { id: string }).id s = { ...s, activePane: bottomPane } s = openTabInActivePane(s, { id: 'terminal:b', type: 'terminal', title: 'B' }) s = setTabPin(s, 'terminal:b', { scope: 'global' }) const bottomTab = allLeaves(s.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'terminal:b')! expect(bottomTab.pin).toEqual({ scope: 'global' }) // Float path: a pinned tab that is then floated keeps its pin marker. s = floatTab(s, 'terminal:b', 50, 50) s = setTabPin(s, 'terminal:b', { scope: 'workspace', homeCwd: '/x' }) expect(s.floats.find(f => f.tab.id === 'terminal:b')!.tab.pin).toEqual({ scope: 'workspace', homeCwd: '/x' }) }) it('setTabPin with null clears the pin marker but keeps the tab', () => { let s = state() s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) s = setTabPin(s, 'terminal:1', { scope: 'global' }) s = setTabPin(s, 'terminal:1', null) const tab = allLeaves(s.splits).flatMap(l => l.tabs).find(t => t.id === 'terminal:1')! expect(tab.pin).toBeUndefined() expect(tabOpenIn(s, 'terminal:1')).toBe(true) }) it('setTabPin on an unknown tab id is a strict same-reference no-op', () => { const s = state() expect(setTabPin(s, 'ghost', { scope: 'global' })).toBe(s) expect(setTabPin(s, 'ghost', null)).toBe(s) }) it('setTabPin is idempotent: setting the same pin twice returns the same reference', () => { let s = state() s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) s = setTabPin(s, 'terminal:1', { scope: 'workspace', homeCwd: '/p' }) const once = s s = setTabPin(s, 'terminal:1', { scope: 'workspace', homeCwd: '/p' }) expect(s).toBe(once) }) it('sanitizeState preserves a legal pin and strips an illegal scope (keeps the tab)', () => { const g = globalThis as Record g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 768 } g.localStorage = { getItem: () => null, setItem: () => {} } try { const legal = JSON.parse(JSON.stringify(makeDefaultState())) as { splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } legal.splits.tabs.push({ id: 'terminal:1', type: 'terminal', title: 'T', pin: { scope: 'workspace', homeCwd: '/proj' }, } as SidebarTab) legal.splits.active = 'terminal:1' const restored = sanitizeState(legal)! const tab = (restored.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:1')! expect(tab.pin).toEqual({ scope: 'workspace', homeCwd: '/proj' }) // Illegal scope drops the pin, keeps the tab. const illegal = JSON.parse(JSON.stringify(makeDefaultState())) as { splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } illegal.splits.tabs.push({ id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'bogus', homeCwd: '/x' }, } as unknown as SidebarTab) illegal.splits.active = 'terminal:2' const cleaned = sanitizeState(illegal)! const tab2 = (cleaned.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:2')! expect(tab2.pin).toBeUndefined() expect(tab2.id).toBe('terminal:2') // Non-string homeCwd drops homeCwd but keeps a global pin. const weirdHome = JSON.parse(JSON.stringify(makeDefaultState())) as { splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } weirdHome.splits.tabs.push({ id: 'terminal:3', type: 'terminal', title: 'T3', pin: { scope: 'global', homeCwd: 42 }, } as unknown as SidebarTab) weirdHome.splits.active = 'terminal:3' const weird = sanitizeState(weirdHome)! const tab3 = (weird.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:3')! expect(tab3.pin).toEqual({ scope: 'global' }) // Older state without pin loads unchanged. const legacy = JSON.parse(JSON.stringify(makeDefaultState())) as { splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } legacy.splits.tabs.push({ id: 'terminal:4', type: 'terminal', title: 'T4' } as SidebarTab) legacy.splits.active = 'terminal:4' const legacyRestored = sanitizeState(legacy)! const tab4 = (legacyRestored.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:4')! expect(tab4.pin).toBeUndefined() } finally { delete g.window delete g.localStorage } }) }) describe('narrow persisted-layout visibility (issue #162)', () => { const persistedLayout = JSON.stringify({ panelOpen: true, width: 420, nextTerminal: 2, nextBrowser: 2, activePane: 'pane:2', expanded: ['/workspace/src'], splits: { kind: 'split', id: 'split:1', dir: 'row', sizes: [0.5, 0.5], children: [ { kind: 'leaf', id: 'pane:1', active: 'editor:readme', tabs: [{ id: 'editor:readme', type: 'editor', title: 'README.md', path: '/workspace/README.md' }], }, { kind: 'leaf', id: 'pane:2', active: 'terminal:1', tabs: [{ id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }], }, ], }, bottomOpen: true, bottomHeight: 300, bottomOpenedOnce: true, bottomSplits: { kind: 'leaf', id: 'pane:3', active: 'browser:1', tabs: [{ id: 'browser:1', type: 'browser', title: 'Docs', path: 'https://example.com' }], }, floats: [{ id: 'float:4', tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } }, x: 16, y: 24, w: 320, h: 240, }], }) afterEach(() => { const g = globalThis as Record delete g.window delete g.localStorage }) it.each([ { width: 390, expectedOpen: false }, { width: 768, expectedOpen: true }, ])('restores panelOpen=$expectedOpen at $width px without changing pane or free-window state', ({ width, expectedOpen }) => { const g = globalThis as Record g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: width, innerHeight: 800, location: { search: '' }, } g.localStorage = { getItem: (key: string) => key === 'dsh-sidebar:v1:restored' ? persistedLayout : null, setItem: () => {}, removeItem: () => {}, } const store = createSidebarStore() store.setSession('restored') const state = store.getSnapshot().state! expect(state.panelOpen).toBe(expectedOpen) expect(state.activePane).toBe('pane:2') expect(allLeaves(state.splits).map(leaf => ({ id: leaf.id, active: leaf.active, tabs: leaf.tabs.map(tab => tab.id), }))).toEqual([ { id: 'pane:1', active: 'editor:readme', tabs: ['editor:readme'] }, { id: 'pane:2', active: 'terminal:1', tabs: ['terminal:1'] }, ]) expect(state.bottomOpen).toBe(true) expect(allLeaves(state.bottomSplits).map(leaf => ({ id: leaf.id, tabs: leaf.tabs.map(tab => tab.id) }))) .toEqual([{ id: 'pane:3', tabs: ['browser:1'] }]) expect(state.floats).toEqual([{ id: 'float:4', tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } }, x: 16, y: 24, w: 320, h: 240, }]) }) }) describe('URL reset escape hatch (issue #369)', () => { // Same browser-global stubs as the v0.12.0 block above; loadState reads // window.location.search (reset param) and localStorage (persisted state). beforeEach(() => { const g = globalThis as Record g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800, location: { search: '' } } g.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} } }) afterEach(() => { const g = globalThis as Record delete g.window delete g.localStorage }) /** A persisted layout whose restored git tab would re-hang the page. */ const frozenState = JSON.stringify({ panelOpen: true, width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], splits: { kind: 'leaf', id: 'pane:1', active: 'g1', tabs: [{ id: 'g1', type: 'git', title: 'Git' }] }, bottomSplits: { kind: 'leaf', id: 'pane:b', active: null, tabs: [] }, }) const searchOf = (): { search: string } => (globalThis as unknown as { window: { location: { search: string } } }).window.location it('restores the persisted layout when the param is absent', () => { const g = globalThis as Record g.localStorage = { getItem: (key: string) => (key === 'dsh-sidebar:v1:s1' ? frozenState : null), setItem: () => {}, removeItem: () => {}, } const store = createSidebarStore() store.setSession('s1') const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] } expect(leaf.tabs.map(tab => tab.type)).toEqual(['git']) }) it('?dsh-sidebar-reset drops the persisted layout and clears the stored copy', () => { const g = globalThis as Record const removed: string[] = [] g.localStorage = { getItem: (key: string) => (key === 'dsh-sidebar:v1:s1' ? frozenState : null), setItem: () => {}, removeItem: (key: string) => { removed.push(key) }, } searchOf().search = '?dsh-sidebar-reset' const store = createSidebarStore() store.setSession('s1') // The default layout (editor home tab) — NOT the frozen git-only state. const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] } expect(leaf.tabs.map(tab => tab.type)).toEqual(['editor']) // The stored copy is gone, so reloading without the param cannot restore // the hanging layout either. expect(removed).toContain('dsh-sidebar:v1:s1') expect(removed).toContain('dsh-sidebar:v1:width') }) it('the reset param tolerates a value (?dsh-sidebar-reset=1)', () => { searchOf().search = '?foo=bar&dsh-sidebar-reset=1' const store = createSidebarStore() store.setSession('s1') const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] } expect(leaf.tabs.map(tab => tab.type)).toEqual(['editor']) }) })