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
+100
View File
@@ -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
View File
@@ -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