diff --git a/src/client/pinned.ts b/src/client/pinned.ts new file mode 100644 index 0000000..c489d57 --- /dev/null +++ b/src/client/pinned.ts @@ -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, + 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) +} diff --git a/src/client/state.ts b/src/client/state.ts index f596eec..1c84ded 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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).pin + if (pin !== null && typeof pin === 'object' && !Array.isArray(pin)) { + const pinRecord = pin as Record + 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 { + return this.bySession + } + /** Apply a pure reducer (returns the next state). */ reduce(reducer: (state: SidebarState) => SidebarState): void { const sessionId = this.snapshot.sessionId diff --git a/tests/pinned.spec.ts b/tests/pinned.spec.ts new file mode 100644 index 0000000..5428280 --- /dev/null +++ b/tests/pinned.spec.ts @@ -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([ + ['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([ + ['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([['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([ + ['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([['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([ + ['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([['home', home]]) + const entries = collectPinnedTabs(bySession, viewer('viewer', '/p')) + expect(entries.map(e => e.tab.id)).toEqual(['terminal:pinned']) + }) +}) diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 48ad578..6167938 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -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 + 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).