feat: pin data model + cross-session resolver

M1 of pinned terminals (v0.17.0):
- SidebarTab.pin?: { scope: 'workspace' | 'global'; homeCwd?: string }
- setTabPin reducer (walks splits + bottomSplits + floats, idempotent)
- sanitizePersistedTab whitelist-validates pin (illegal scope drops pin, keeps tab)
- SidebarStore.getSessionStates() read-only view for the rail
- new src/client/pinned.ts: pinnedVisibleTo + collectPinnedTabs (excludes viewer's own session, stable tree order)
This commit is contained in:
HuanLinOTO
2026-08-26 18:27:32 +08:00
parent d052bd7fb2
commit 68f771e65f
4 changed files with 443 additions and 2 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* Pinned-terminal resolver (v0.17.0) — pure functions over cached session
* states. Covers the visibility matrix (global / workspace × cwd match /
* mismatch / both undefined / viewer-only undefined) and the cross-session
* collection (multi-session, exclusion of the viewer's own session, stable
* tree order, floats).
*/
import { describe, expect, it } from 'vitest'
import {
floatTab, makeDefaultState, openTabInActivePane, setTabPin, toggleBottomPanel,
type SidebarState,
} from '../src/client/state.ts'
import { collectPinnedTabs, pinnedVisibleTo, type PinnedViewer } from '../src/client/pinned.ts'
/** A pinned terminal tab in a fresh state, opened and pinned in one go. */
function stateWithPinnedTerminal(
id: string,
pin: { scope: 'workspace' | 'global'; homeCwd?: string },
): SidebarState {
let s = makeDefaultState()
s = openTabInActivePane(s, { id, type: 'terminal', title: id })
return setTabPin(s, id, pin)
}
const viewer = (sessionId: string, cwd?: string): PinnedViewer => ({ sessionId, cwd })
describe('pinnedVisibleTo', () => {
it('returns false for an unpinned tab', () => {
const tab = { id: 't', type: 'terminal', title: 'T' }
expect(pinnedVisibleTo(tab, viewer('s', '/p'))).toBe(false)
})
it('global pin is visible to any session regardless of cwd', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'global' as const } }
expect(pinnedVisibleTo(tab, viewer('s', '/other'))).toBe(true)
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
it('workspace pin matches when cwds are equal', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const, homeCwd: '/p' } }
expect(pinnedVisibleTo(tab, viewer('s', '/p'))).toBe(true)
expect(pinnedVisibleTo(tab, viewer('s', '/q'))).toBe(false)
})
it('workspace pin without homeCwd is visible everywhere (pin set before cwd resolved)', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const } }
expect(pinnedVisibleTo(tab, viewer('s', '/anywhere'))).toBe(true)
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
it('workspace pin is conservatively visible when viewer.cwd is unknown (no hydration flash)', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const, homeCwd: '/p' } }
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
it('both undefined cwds match (legacy pin + unhydrated viewer)', () => {
const tab = { id: 't', type: 'terminal', title: 'T', pin: { scope: 'workspace' as const, homeCwd: undefined } }
expect(pinnedVisibleTo(tab, viewer('s', undefined))).toBe(true)
})
})
describe('collectPinnedTabs', () => {
it('returns an empty array when no other session has pinned terminals', () => {
const bySession = new Map<string, SidebarState>([
['s1', stateWithPinnedTerminal('terminal:1', { scope: 'global' })],
])
expect(collectPinnedTabs(bySession, viewer('s1', '/p'))).toEqual([])
})
it('returns an empty array for an empty session cache', () => {
expect(collectPinnedTabs(new Map(), viewer('s1', '/p'))).toEqual([])
})
it('collects a global pin from another session', () => {
const bySession = new Map<string, SidebarState>([
['home', stateWithPinnedTerminal('terminal:1', { scope: 'global' })],
])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/anywhere'))
expect(entries).toHaveLength(1)
expect(entries[0]!.tab.id).toBe('terminal:1')
expect(entries[0]!.homeSessionId).toBe('home')
})
it('collects a workspace pin only when the viewer cwd matches homeCwd', () => {
const home = stateWithPinnedTerminal('terminal:1', { scope: 'workspace', homeCwd: '/proj' })
const bySession = new Map<string, SidebarState>([['home', home]])
expect(collectPinnedTabs(bySession, viewer('viewer', '/proj'))).toHaveLength(1)
expect(collectPinnedTabs(bySession, viewer('viewer', '/elsewhere'))).toHaveLength(0)
})
it('excludes the viewer\'s own session (its pinned tabs are on its own strip)', () => {
const bySession = new Map<string, SidebarState>([
['viewer', stateWithPinnedTerminal('terminal:1', { scope: 'global' })],
['home', stateWithPinnedTerminal('terminal:2', { scope: 'global' })],
])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:2'])
})
it('collects in stable tree order: splits → bottomSplits → floats', () => {
let home = makeDefaultState()
// Right tree tab first.
home = openTabInActivePane(home, { id: 'terminal:right', type: 'terminal', title: 'R' })
home = setTabPin(home, 'terminal:right', { scope: 'global' })
// Bottom tree tab second.
home = toggleBottomPanel(home)
const bottomPane = (home.bottomSplits as { id: string }).id
home = { ...home, activePane: bottomPane }
home = openTabInActivePane(home, { id: 'terminal:bottom', type: 'terminal', title: 'B' })
home = setTabPin(home, 'terminal:bottom', { scope: 'global' })
// Float last.
home = openTabInActivePane(home, { id: 'terminal:float', type: 'terminal', title: 'F' })
home = setTabPin(home, 'terminal:float', { scope: 'global' })
home = floatTab(home, 'terminal:float', 50, 50)
const bySession = new Map<string, SidebarState>([['home', home]])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:right', 'terminal:bottom', 'terminal:float'])
})
it('keeps stable insertion order across multiple home sessions', () => {
const bySession = new Map<string, SidebarState>([
['homeA', stateWithPinnedTerminal('terminal:a', { scope: 'global' })],
['homeB', stateWithPinnedTerminal('terminal:b', { scope: 'global' })],
])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:a', 'terminal:b'])
expect(entries.map(e => e.homeSessionId)).toEqual(['homeA', 'homeB'])
})
it('ignores unpinned terminals and non-terminal tabs in other sessions', () => {
let home = makeDefaultState()
home = openTabInActivePane(home, { id: 'terminal:unpinned', type: 'terminal', title: 'U' })
home = openTabInActivePane(home, { id: 'editor:1', type: 'editor', title: 'E', path: '/e' })
home = setTabPin(home, 'editor:1', { scope: 'global' }) // defensive: pin only targets terminals
home = openTabInActivePane(home, { id: 'terminal:pinned', type: 'terminal', title: 'P' })
home = setTabPin(home, 'terminal:pinned', { scope: 'global' })
const bySession = new Map<string, SidebarState>([['home', home]])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
expect(entries.map(e => e.tab.id)).toEqual(['terminal:pinned'])
})
})
+115 -1
View File
@@ -4,7 +4,7 @@ import {
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,
resizeSplitIn, revealPaths, sanitizeState, setBottomHeight, setTabPin,
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
@@ -1214,6 +1214,120 @@ describe('free windows (v0.16.0)', () => {
})
})
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<string, unknown>
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('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).