Files
apes-sidebar/src/client/pinned.ts
T
HuanLinOTO e7c4578791 feat: inline pinned tabs in TabBar (replaces PinnedRail)
M4 rewrite per user feedback: pinned terminals should display in the
Tabs bar itself, not a separate rail. Clicking activates in-place
(cross-session terminal rendering), no session jump.

Changes:
- Delete PinnedRail.tsx + tests/pinned-rail.spec.tsx
- pinned.ts: add createPinnedVirtualTab (virtual id = pinned:<sess>:<id>,
  meta.__pinnedHome = { sessionId, cwd, tabId }), injectPinnedIntoTree
  (appends virtual tabs to first leaf, overrides active), isPinnedVirtualId,
  parsePinnedVirtualId, isPinnedVirtualTab, getPinnedHomeScope
- tab-content-memo.ts: add effectiveTabId to memo key — TabContent passes
  the original tab id (not the virtual id) to the descriptor component so
  TerminalView's WS URL resolves to the home PTY
- Sidebar.tsx: augmented tree (injectPinnedIntoTree), activePinnedTabId
  state, wrappedActions (intercept virtual ids for activate/close/unpin/
  drag/float), renderTab passes home scope + effectiveTabId for pinned tabs,
  pinnedRevision replaces railRevision
- TabBar.tsx: pinned virtual tabs non-draggable, pin icon prefix, stripped
  context menu (Unpin/Close only, no float/close-others/left/right)
- sidebar.module.css: remove rail styles, add .pinnedTab (italic + tertiary)
- Docs: README/README_EN/AGENTS.md/design doc updated for inline approach

157/157 client-side tests pass; typecheck + build clean
2026-08-26 21:40:32 +08:00

185 lines
6.9 KiB
TypeScript

/**
* 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 viewer session's TabBar renders the tabs OTHER sessions have pinned
* as VIRTUAL tabs appended to the first leaf's tab list, so the user sees
* them inline with their own tabs. Clicking a virtual tab activates it
* in-place: TerminalView connects to the home session's PTY via WebSocket
* (sessionId + tab query params resolve to the home PTY on the host side),
* so the terminal renders in the current workbench without jumping sessions.
*
* 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
* own tab strip, so rendering them again as virtual tabs 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, SplitNode } 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
}
/** The home-session scope stored on a pinned virtual tab's meta, so
* TerminalView connects to the home session's PTY (not the viewer's). */
export interface PinnedHomeScope {
sessionId: string
cwd: string | undefined
/** The original tab id in the home session (TerminalView's `tab` param). */
tabId: string
}
const PINNED_META_KEY = '__pinnedHome'
const PINNED_VID_PREFIX = 'pinned:'
/** Whether a tab id is a pinned virtual id (prefixed). */
export function isPinnedVirtualId(tabId: string): boolean {
return tabId.startsWith(PINNED_VID_PREFIX)
}
/** Parse a pinned virtual id into its home session id and original tab id.
* Format: `pinned:<homeSessionId>:<originalTabId>` — session ids are UUIDs
* (no colons), so the first colon after the prefix delimits the session. */
export function parsePinnedVirtualId(tabId: string): { homeSessionId: string; tabId: string } {
const rest = tabId.slice(PINNED_VID_PREFIX.length)
const sep = rest.indexOf(':')
if (sep < 0) return { homeSessionId: rest, tabId: '' }
return { homeSessionId: rest.slice(0, sep), tabId: rest.slice(sep + 1) }
}
/** Extract the home scope from a pinned virtual tab's meta (undefined for
* regular tabs). */
export function getPinnedHomeScope(tab: SidebarTab): PinnedHomeScope | undefined {
const meta = tab.meta as Record<string, unknown> | undefined
return (meta?.[PINNED_META_KEY] as PinnedHomeScope | undefined) ?? undefined
}
/** Whether a tab is a pinned virtual tab (injected from another session). */
export function isPinnedVirtualTab(tab: SidebarTab): boolean {
return getPinnedHomeScope(tab) !== undefined
}
/** Create a virtual SidebarTab for a pinned entry. The virtual id is unique
* (prefixed with home session) to avoid collision with the viewer's own
* tab ids; the original id is stored in meta for TerminalView. */
export function createPinnedVirtualTab(entry: PinnedTabEntry): SidebarTab {
const { tab, homeSessionId } = entry
const home: PinnedHomeScope = {
sessionId: homeSessionId,
cwd: tab.pin?.homeCwd,
tabId: tab.id,
}
return {
...tab,
id: PINNED_VID_PREFIX + homeSessionId + ':' + tab.id,
meta: { ...(tab.meta as Record<string, unknown> | undefined ?? {}), [PINNED_META_KEY]: home },
}
}
/** Inject pinned virtual tabs into the first leaf of a split tree, and
* override that leaf's `active` when a pinned tab is activated. Returns
* the original tree when there are no pinned tabs and no active override. */
export function injectPinnedIntoTree(
tree: SplitNode,
pinned: readonly SidebarTab[],
activePinnedId: string | null,
): SplitNode {
if (pinned.length === 0 && activePinnedId === null) return tree
if (tree.kind === 'leaf') {
return {
...tree,
tabs: pinned.length > 0 ? [...tree.tabs, ...pinned] : tree.tabs,
active: activePinnedId ?? tree.active,
}
}
return {
...tree,
children: [
injectPinnedIntoTree(tree.children[0]!, pinned, activePinnedId),
...tree.children.slice(1),
],
}
}
/**
* 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)
}