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:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Cross-session pinned-terminal resolution (v0.17.0+).
|
||||
*
|
||||
* A pinned terminal tab lives in its HOME session's state (the only
|
||||
* authoritative copy) — switching sessions never copies or projects it.
|
||||
* The PinnedRail renders the tabs OTHER sessions have pinned that should
|
||||
* surface in the CURRENT (viewer) session, so it needs a read-only
|
||||
* resolution pass over every cached session state.
|
||||
*
|
||||
* Visibility rule:
|
||||
*
|
||||
* | pin.scope | visible when |
|
||||
* |-----------|--------------|
|
||||
* | `global` | any session (cwd-independent) |
|
||||
* | `workspace` | `viewer.cwd === tab.pin.homeCwd` (both undefined match; viewer.cwd unknown → conservative visible) |
|
||||
*
|
||||
* The "viewer.cwd unknown → visible" branch is intentional: during
|
||||
* hydration the session summary may carry no cwd yet, and hiding pinned
|
||||
* workspace tabs on first paint would flash them away. Once the cwd
|
||||
* resolves, the next store notify re-runs the resolver with the real cwd.
|
||||
*
|
||||
* The viewer's OWN session is excluded: its pinned tabs are already on its
|
||||
* tab strip, so rendering them again in the rail would double-show. Tabs
|
||||
* whose `pin` field is missing or whose `type` is not `'terminal'` are
|
||||
* ignored — only terminal tabs can be pinned.
|
||||
*/
|
||||
import type { SidebarState, SidebarTab } from './state.ts'
|
||||
|
||||
/** A pinned terminal surfaced to the viewer, paired with its home session. */
|
||||
export interface PinnedTabEntry {
|
||||
tab: SidebarTab
|
||||
homeSessionId: string
|
||||
}
|
||||
|
||||
/** A viewer's session identity for visibility resolution. */
|
||||
export interface PinnedViewer {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pinned tab is visible to the viewer session. Conservative on
|
||||
* unknown cwd: a `workspace` pin with no `homeCwd` is visible everywhere
|
||||
* (the pin was set before the home session's cwd resolved), and a viewer
|
||||
* whose cwd is unknown sees every workspace pin (avoids hydration flash).
|
||||
*/
|
||||
export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean {
|
||||
const pin = tab.pin
|
||||
if (pin === undefined) return false
|
||||
if (pin.scope === 'global') return true
|
||||
// workspace scope
|
||||
const home = pin.homeCwd
|
||||
if (home === undefined) return true
|
||||
if (viewer.cwd === undefined) return true
|
||||
return viewer.cwd === home
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every pinned terminal visible to the viewer across ALL cached
|
||||
* session states. Excludes the viewer's own session (those tabs are on its
|
||||
* own strip). Order is stable: sessions in the cache's insertion order,
|
||||
* tabs in tree order (splits → bottomSplits → floats) within each session
|
||||
* — the order tabs were opened/pinned, so the rail never reorders between
|
||||
* renders.
|
||||
*/
|
||||
export function collectPinnedTabs(
|
||||
bySession: ReadonlyMap<string, SidebarState>,
|
||||
viewer: PinnedViewer,
|
||||
): PinnedTabEntry[] {
|
||||
const entries: PinnedTabEntry[] = []
|
||||
for (const [homeSessionId, state] of bySession) {
|
||||
if (homeSessionId === viewer.sessionId) continue
|
||||
collectFromTree(state.splits, homeSessionId, viewer, entries)
|
||||
collectFromTree(state.bottomSplits, homeSessionId, viewer, entries)
|
||||
for (const float of state.floats) {
|
||||
if (float.tab.type === 'terminal' && pinnedVisibleTo(float.tab, viewer)) {
|
||||
entries.push({ tab: float.tab, homeSessionId })
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Walk one split tree depth-first, collecting visible pinned terminals. */
|
||||
function collectFromTree(
|
||||
node: SidebarState['splits'],
|
||||
homeSessionId: string,
|
||||
viewer: PinnedViewer,
|
||||
out: PinnedTabEntry[],
|
||||
): void {
|
||||
if (node.kind === 'leaf') {
|
||||
for (const tab of node.tabs) {
|
||||
if (tab.type === 'terminal' && pinnedVisibleTo(tab, viewer)) {
|
||||
out.push({ tab, homeSessionId })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const child of node.children) collectFromTree(child, homeSessionId, viewer, out)
|
||||
}
|
||||
+86
-1
@@ -37,6 +37,12 @@ export interface SidebarTab {
|
||||
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
|
||||
* persisted with the layout and restored verbatim on reload. */
|
||||
meta?: unknown
|
||||
/** Pinned-terminal marker (v0.17.0+): a pinned terminal tab survives a
|
||||
* session switch in its home session's state and surfaces in the
|
||||
* PinnedRail of every session the scope allows. `homeCwd` is the cwd
|
||||
* snapshot at pin time — a `workspace`-scoped pin is only visible to
|
||||
* sessions whose cwd matches it. Absent = unpinned (legacy states). */
|
||||
pin?: { scope: 'workspace' | 'global'; homeCwd?: string }
|
||||
}
|
||||
|
||||
/** A tab group. */
|
||||
@@ -541,6 +547,57 @@ export function patchTab(
|
||||
return changed ? { ...state, splits, bottomSplits, floats } : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is
|
||||
* structural metadata (NOT display fields like title/path), so it walks
|
||||
* both split trees AND the free windows exactly like {@link patchTab} —
|
||||
* the tab may live in either tree or float. Passing `null` clears the pin
|
||||
* (the tab stays open in its home session); passing a `{ scope, homeCwd }`
|
||||
* object sets it. An unknown tab id is a strict no-op (same reference
|
||||
* returned) so a stale pin request never churns the state or rewrites
|
||||
* localStorage.
|
||||
* @param state - the current per-session sidebar state.
|
||||
* @param tabId - the tab to pin/unpin.
|
||||
* @param pin - the pin marker to set, or null to clear.
|
||||
* @returns the next state (or the same reference when the tab is missing
|
||||
* or the pin marker is already the requested value).
|
||||
*/
|
||||
export function setTabPin(
|
||||
state: SidebarState,
|
||||
tabId: string,
|
||||
pin: { scope: 'workspace' | 'global'; homeCwd?: string } | null,
|
||||
): SidebarState {
|
||||
let changed = false
|
||||
const apply = (tab: SidebarTab): SidebarTab => {
|
||||
// Idempotent: setting the same pin (deep-equal on scope + homeCwd) is a
|
||||
// no-op so re-clicking the menu item never churns the state.
|
||||
if (pin === null) {
|
||||
if (tab.pin === undefined) return tab
|
||||
} else if (
|
||||
tab.pin !== undefined
|
||||
&& tab.pin.scope === pin.scope
|
||||
&& tab.pin.homeCwd === pin.homeCwd
|
||||
) {
|
||||
return tab
|
||||
}
|
||||
changed = true
|
||||
const { pin: _omit, ...rest } = tab
|
||||
return pin === null ? rest : { ...rest, pin }
|
||||
}
|
||||
const walk = (node: SplitNode): SplitNode => {
|
||||
if (node.kind === 'leaf') {
|
||||
const tabs = node.tabs.map(tab => (tab.id === tabId ? apply(tab) : tab))
|
||||
return tabs === node.tabs ? node : { ...node, tabs }
|
||||
}
|
||||
const children = node.children.map(walk)
|
||||
return children === node.children ? node : { ...node, children }
|
||||
}
|
||||
const splits = walk(state.splits)
|
||||
const bottomSplits = walk(state.bottomSplits)
|
||||
const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
|
||||
return changed ? { ...state, splits, bottomSplits, floats } : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Land a tab in the active pane (or focus its existing instance by id).
|
||||
* Dedup strategies (single-instance, per-path, per-change) are owned by the
|
||||
@@ -1267,13 +1324,28 @@ function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
|
||||
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the persisted
|
||||
// value already went through JSON.parse, so it is inherently serializable —
|
||||
// carry it through verbatim (absent on older states).
|
||||
return {
|
||||
const result: SidebarTab = {
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
title: candidate.title,
|
||||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||||
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
|
||||
}
|
||||
// `pin` (v0.17.0+): a pinned-terminal marker. Whitelist-validate the
|
||||
// shape so a hand-edited / corrupted pin never crashes the rail's
|
||||
// resolver: an unknown scope or a non-string homeCwd drops the pin
|
||||
// silently (the tab survives, just unpinned — the legacy behavior).
|
||||
const pin = (candidate as Record<string, unknown>).pin
|
||||
if (pin !== null && typeof pin === 'object' && !Array.isArray(pin)) {
|
||||
const pinRecord = pin as Record<string, unknown>
|
||||
if (pinRecord.scope === 'workspace' || pinRecord.scope === 'global') {
|
||||
const homeCwd = pinRecord.homeCwd
|
||||
result.pin = homeCwd === undefined || typeof homeCwd === 'string'
|
||||
? { scope: pinRecord.scope, ...(typeof homeCwd === 'string' ? { homeCwd } : {}) }
|
||||
: { scope: pinRecord.scope }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
|
||||
@@ -1437,6 +1509,19 @@ export class SidebarStore {
|
||||
return state !== undefined && tabOpenIn(state, tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of EVERY cached session's state (v0.17.0+). The
|
||||
* PinnedRail uses this to collect pinned terminals across sessions
|
||||
* without each render reading private fields. The map is the live
|
||||
* `bySession` reference — callers MUST treat it as read-only (mutations
|
||||
* go through {@link reduce} / {@link reduceFor}). A session that has
|
||||
* never been visited in this run is absent (its pinned tabs are not
|
||||
* visible until first load — accepted as YAGNI by the design).
|
||||
*/
|
||||
getSessionStates(): ReadonlyMap<string, SidebarState> {
|
||||
return this.bySession
|
||||
}
|
||||
|
||||
/** Apply a pure reducer (returns the next state). */
|
||||
reduce(reducer: (state: SidebarState) => SidebarState): void {
|
||||
const sessionId = this.snapshot.sessionId
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user