- BrowserView: 即使 host 在白名单,GUI 自身 origin 永不给 allow-same-origin(裸 host 条目覆盖所有端口时可提权) - intercept: revealInExplorer 相对路径经 resolveSidebarPath 解析后再 revealPaths(交付物路径是相对的) - intercept: ctx.betterSidebar 改为 ctx.get(#357 纤维链读取约定) - FileTree/EditorHost/TreePanel/tabs: 把 revealed 贯通到树行渲染 + 高亮类 + scrollIntoView(此前高亮永不渲染) - state.revealPaths: 重建祖先目录时保留前导分隔符(POSIX/UNC 此前目录永远折叠) - state.schedulePersist: 仅活动会话写入全局宽度(targeted open 不再用非活动会话的过期宽度覆盖拖拽值) - browser.probe: GET 回退显式取消 body(否则流式/大响应挂住 socket) - terminal-font: leading generic 前只放 symbols-only 字体;fully-patched 放 generic 后(避免 hijack 度量字体) - sidebar.module.css: 恢复拖拽反馈(#130 的 silent resize 与 PR 描述冲突,取 HEAD) - Sidebar: 剩余 4 处 ctx.betterSidebar 改 ctx.get - tests: 补 revealPaths/iframeSandboxFor/terminal-font 回归测试 + EditorHost/FileTree 测试补 revealed prop
1502 lines
65 KiB
TypeScript
1502 lines
65 KiB
TypeScript
/**
|
||
* Per-session sidebar state: the panel geometry, the split-pane workbench
|
||
* tree, open tabs, and the explorer expansion set. One state instance per
|
||
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
|
||
* a reload restores the exact layout of the session it belongs to — switching
|
||
* conversations swaps the whole state (memory + isolation).
|
||
*
|
||
* The split tree is a recursive structure: a leaf holds a tab group, a split
|
||
* divides the space row- or column-wise with fractional sizes. All tree
|
||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||
*/
|
||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||
import { isNarrowWidth } from './breakpoints.ts'
|
||
|
||
/**
|
||
* Tab type identifier. Builtins register their ids (editor / git / terminal
|
||
* / subagent / browser / diff) through the sidebar service; external
|
||
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
|
||
* the registry stays open.
|
||
*/
|
||
export type TabType = string
|
||
|
||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||
export type SidebarDiffRef =
|
||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean; worktree?: string; repoRoot?: string }
|
||
| { kind: 'commit'; hash: string; hashFull: string; subject: string; worktree?: string; repoRoot?: string }
|
||
|
||
/** One open tab. `path` carries the file (editor) or is absent (git/terminal);
|
||
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
|
||
* plugin-owned JSON-serializable state, preserved across reloads. */
|
||
export interface SidebarTab {
|
||
id: string
|
||
type: TabType
|
||
title: string
|
||
path?: string
|
||
diff?: SidebarDiffRef
|
||
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
|
||
* persisted with the layout and restored verbatim on reload. */
|
||
meta?: unknown
|
||
}
|
||
|
||
/** A tab group. */
|
||
export interface SidebarLeaf {
|
||
kind: 'leaf'
|
||
id: string
|
||
tabs: SidebarTab[]
|
||
active: string | null
|
||
}
|
||
|
||
/** A recursive split between child panes (fractional sizes summing to 1). */
|
||
export interface SidebarSplit {
|
||
kind: 'split'
|
||
id: string
|
||
dir: 'row' | 'col'
|
||
sizes: number[]
|
||
children: SplitNode[]
|
||
}
|
||
|
||
export type SplitNode = SidebarLeaf | SidebarSplit
|
||
|
||
/**
|
||
* One free window: a tab dragged out of the workbench onto the conversation
|
||
* area floats in the panel host at viewport coordinates. The tab is OWNED by
|
||
* the window exactly like a pane owns its tabs (moved, not copied); geometry
|
||
* persists with the session so a reload restores the window in place.
|
||
* Stacking order is the array order (last = topmost).
|
||
*/
|
||
export interface FloatWindow {
|
||
id: string
|
||
tab: SidebarTab
|
||
/** Viewport coordinates of the window's top-left corner. */
|
||
x: number
|
||
y: number
|
||
w: number
|
||
h: number
|
||
}
|
||
|
||
/** The full per-session state. */
|
||
export interface SidebarState {
|
||
panelOpen: boolean
|
||
width: number
|
||
/** The pane receiving newly opened tabs (last pane the user touched).
|
||
* Pane ids are globally unique across BOTH trees (shared uid counter), so
|
||
* one field resolves into either tree — see {@link treeOf}. */
|
||
activePane: string | null
|
||
/** Monotonic terminal tab counter (ids survive reloads). */
|
||
nextTerminal: number
|
||
/** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
|
||
nextBrowser: number
|
||
/** Explorer expansion set (absolute directory paths). */
|
||
expanded: string[]
|
||
/**
|
||
* Explorer rows highlighted by a "Show in folder" reveal (absolute paths).
|
||
* Transient by design: sanitizeState never restores it, so a reload starts
|
||
* unhighlighted.
|
||
*/
|
||
revealed: string[]
|
||
/** The right sidebar's split tree (the original workbench). */
|
||
splits: SplitNode
|
||
/** Whether the bottom panel (a second, independent workbench) is open. */
|
||
bottomOpen: boolean
|
||
/** The bottom panel's height (clamped to the contract range). */
|
||
bottomHeight: number
|
||
/**
|
||
* Whether the bottom panel has been expanded at least once in this
|
||
* session — the FIRST expansion tries to auto-open a terminal tab (gated
|
||
* on the bottomPanelAutoTerminal pref); later expansions never do.
|
||
*/
|
||
bottomOpenedOnce: boolean
|
||
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
|
||
* tabs never cross panels — the two panels only share panel-size drags). */
|
||
bottomSplits: SplitNode
|
||
/** Free windows (tabs dragged out onto the conversation area). */
|
||
floats: FloatWindow[]
|
||
}
|
||
|
||
export const PANEL_MIN = 280
|
||
export const PANEL_MAX = 640
|
||
export const PANEL_DEFAULT = 400
|
||
export const TAB_MAX_WIDTH = 160
|
||
/** Bottom panel geometry contract (mirrors the width contract; the upper
|
||
* bound is the viewport, enforced by {@link setBottomHeight}). */
|
||
export const BOTTOM_MIN = 120
|
||
export const BOTTOM_DEFAULT = 220
|
||
/** Free-window geometry contract: the floor keeps the window usable (a
|
||
* header plus some content), the ceiling is the viewport. */
|
||
export const FLOAT_MIN_W = 320
|
||
export const FLOAT_MIN_H = 200
|
||
/** Geometry a fresh free window starts with: a phone-like portrait ratio
|
||
* (390×780 ≈ 1:2). The creation path additionally caps the size to the
|
||
* viewport (minus a 24px margin), so a short viewport gets a shorter —
|
||
* not overflowing — window instead of an exact ratio. */
|
||
export const FLOAT_DEFAULT_W = 390
|
||
export const FLOAT_DEFAULT_H = 780
|
||
|
||
let nextIdCounter = 0
|
||
/** Unique pane/tab id within one state instance. */
|
||
function uid(prefix: string): string {
|
||
nextIdCounter += 1
|
||
return `${prefix}:${nextIdCounter}`
|
||
}
|
||
|
||
/** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
|
||
* covers openSidebarFile opens (per-path dedupe); opens that must not
|
||
* dedupe (the tree's "open to the side") mint through here. */
|
||
export function mintTabId(): string {
|
||
return uid('tab')
|
||
}
|
||
|
||
/**
|
||
* The largest numeric suffix across a raw persisted state's counter ids
|
||
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
|
||
* resets on every reload, so a split minted AFTER a reload would collide
|
||
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
|
||
* mapLeaf would then visit BOTH leaves and every open would land in both
|
||
* panes of the split. Seeding the counter past the persisted ids keeps
|
||
* fresh ids disjoint.
|
||
*/
|
||
function maxCounterId(parsed: unknown): number {
|
||
let max = 0
|
||
const consider = (id: unknown): void => {
|
||
if (typeof id !== 'string') return
|
||
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
|
||
if (match !== null) max = Math.max(max, Number(match[1]))
|
||
}
|
||
const walk = (node: unknown): void => {
|
||
if (node === null || typeof node !== 'object') return
|
||
const record = node as Record<string, unknown>
|
||
consider(record.id)
|
||
if (Array.isArray(record.tabs)) {
|
||
for (const tab of record.tabs) {
|
||
if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
|
||
}
|
||
}
|
||
if (Array.isArray(record.children)) {
|
||
for (const child of record.children) walk(child)
|
||
}
|
||
}
|
||
walk((parsed as Record<string, unknown> | null)?.splits)
|
||
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
|
||
// Free windows mint through the same shared counter; their ids must seed it
|
||
// too (a persisted float:N with a lower pane max would collide otherwise).
|
||
const floats = (parsed as Record<string, unknown> | null)?.floats
|
||
if (Array.isArray(floats)) {
|
||
for (const float of floats) {
|
||
if (float !== null && typeof float === 'object') consider((float as Record<string, unknown>).id)
|
||
}
|
||
}
|
||
return max
|
||
}
|
||
|
||
/** The default tab a fresh session seeds. */
|
||
export type DefaultSeed = 'editor-home' | 'none'
|
||
|
||
/** A fresh default state: one seeded tab in one pane, open per the caller's
|
||
* preference. `width` is the caller's preferred panel width (default
|
||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||
* true); the store seeds new sessions from the user's side card prefs.
|
||
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
|
||
* (an editor tab with no path whose tree panel starts open,
|
||
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
|
||
* file explorer page — and 'none' starts with an empty pane (the store
|
||
* passes it when the user disabled the editor tab type in settings). */
|
||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
|
||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||
if (seed === 'editor-home') {
|
||
// No path: the editor host renders its empty-state hint and the docked
|
||
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
|
||
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
|
||
leaf.active = leaf.tabs[0]!.id
|
||
}
|
||
// The bottom panel starts closed with an empty pane (its welcome cards
|
||
// offer the openable types on first use).
|
||
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||
return {
|
||
panelOpen,
|
||
width,
|
||
activePane: leaf.id,
|
||
nextTerminal: 1,
|
||
nextBrowser: 1,
|
||
expanded: [],
|
||
revealed: [],
|
||
splits: leaf,
|
||
bottomOpen: false,
|
||
bottomHeight: BOTTOM_DEFAULT,
|
||
bottomOpenedOnce: false,
|
||
bottomSplits: bottomLeaf,
|
||
floats: [],
|
||
}
|
||
}
|
||
|
||
/** Whether a tree node (or any descendant) carries the given pane/split id. */
|
||
function treeHasId(node: SplitNode, id: string): boolean {
|
||
if (node.id === id) return true
|
||
if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
|
||
return false
|
||
}
|
||
|
||
/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the
|
||
* bottom panel's tree, else 'splits' (the right panel's tree). Ids are
|
||
* globally unique (the shared uid counter), so an id in neither tree falls
|
||
* back to the right tree, where tree operations no-op on a missing node —
|
||
* the pre-bottom-panel behavior. */
|
||
export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' {
|
||
return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits'
|
||
}
|
||
|
||
/** Walk the tree and apply `visit` to the leaf with the given id. */
|
||
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
|
||
if (node.kind === 'leaf') {
|
||
if (node.id === paneId) {
|
||
const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
|
||
visit(copy)
|
||
return copy
|
||
}
|
||
return node
|
||
}
|
||
const split = node
|
||
return {
|
||
...split,
|
||
sizes: [...split.sizes],
|
||
children: split.children.map(child => mapLeaf(child, paneId, visit)),
|
||
}
|
||
}
|
||
|
||
/** The first leaf of the tree (fallback pane when activePane is gone). */
|
||
export function firstLeaf(node: SplitNode): SidebarLeaf {
|
||
if (node.kind === 'leaf') return node
|
||
return firstLeaf(node.children[0]!)
|
||
}
|
||
|
||
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
|
||
function clearAllTabs(node: SplitNode): SplitNode {
|
||
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
|
||
return { ...node, children: node.children.map(clearAllTabs) }
|
||
}
|
||
|
||
/**
|
||
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
|
||
* right sidebar — the "merged display" on mobile is the right panel alone,
|
||
* whose tab strips now carry the bottom tree's tabs (depth-first order,
|
||
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
|
||
* structure stays — the desktop bottom panel re-renders its welcome cards)
|
||
* and the panel closes. The active pane moves to the right tree's first
|
||
* leaf so every new tab lands in the visible panel.
|
||
*
|
||
* Idempotent: a bottom tree with no tabs and a closed panel returns the
|
||
* same reference. Runs when the viewport enters narrow (see the Sidebar
|
||
* shell); migrating is permanent for the session — the tabs now live in the
|
||
* right tree, exactly like the user "threw them in".
|
||
*/
|
||
export function migrateBottomTabs(state: SidebarState): SidebarState {
|
||
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
|
||
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
|
||
const target = firstLeaf(state.splits)
|
||
return {
|
||
...state,
|
||
activePane: target.id,
|
||
bottomOpen: false,
|
||
splits: bottomTabs.length > 0
|
||
? mapLeaf(state.splits, target.id, leaf => {
|
||
leaf.tabs = [...leaf.tabs, ...bottomTabs]
|
||
})
|
||
: state.splits,
|
||
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
|
||
}
|
||
}
|
||
|
||
/** Find the leaf containing a tab id, if any. */
|
||
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
||
if (node.kind === 'leaf') {
|
||
return node.tabs.some(tab => tab.id === tabId) ? node : undefined
|
||
}
|
||
for (const child of node.children) {
|
||
const found = leafWithTab(child, tabId)
|
||
if (found !== undefined) return found
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** All leaves of the tree, depth-first. */
|
||
export function allLeaves(node: SplitNode): SidebarLeaf[] {
|
||
if (node.kind === 'leaf') return [node]
|
||
return node.children.flatMap(allLeaves)
|
||
}
|
||
|
||
/** Whether a tab exists anywhere in a state (either tree, any pane, or any
|
||
* free window — a floating tab is as open as a docked one). */
|
||
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
|
||
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||
|| state.floats.some(float => float.tab.id === tabId)
|
||
}
|
||
|
||
/** The free window holding a tab id, if any. */
|
||
export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
|
||
return state.floats.find(float => float.tab.id === tabId)
|
||
}
|
||
|
||
/** The free window with the given window id, if any. */
|
||
export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
|
||
return state.floats.find(float => float.id === floatId)
|
||
}
|
||
|
||
/** Replace a leaf with a split of it plus a fresh empty leaf. */
|
||
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
|
||
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||
return mapLeaf(node, paneId, (leaf) => {
|
||
const target: SidebarLeaf = { ...leaf }
|
||
const split: SidebarSplit = {
|
||
kind: 'split',
|
||
id: uid('split'),
|
||
dir,
|
||
sizes: [0.5, 0.5],
|
||
children: [target, fresh],
|
||
}
|
||
Object.assign(leaf, split)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Split a leaf by inserting a fresh leaf holding `tab` beside it — the
|
||
* VSCode drag-to-edge gesture. `dir` is the split direction ('row' for
|
||
* left/right, 'col' for up/down); `front` places the new leaf first (left/
|
||
* up) or second (right/down).
|
||
* @returns the new tree plus the fresh leaf's id (the drop's active pane).
|
||
*/
|
||
export function insertLeafAt(
|
||
node: SplitNode,
|
||
paneId: string,
|
||
dir: 'row' | 'col',
|
||
tab: SidebarTab,
|
||
front: boolean,
|
||
): { node: SplitNode; leafId: string } {
|
||
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id }
|
||
const leafId = fresh.id
|
||
const next = mapLeaf(node, paneId, (leaf) => {
|
||
const target: SidebarLeaf = { ...leaf }
|
||
const split: SidebarSplit = {
|
||
kind: 'split',
|
||
id: uid('split'),
|
||
dir,
|
||
sizes: [0.5, 0.5],
|
||
children: front ? [fresh, target] : [target, fresh],
|
||
}
|
||
Object.assign(leaf, split)
|
||
})
|
||
return { node: next, leafId }
|
||
}
|
||
|
||
/** Where a tab drop lands on a pane: an edge creates a split, center merges. */
|
||
export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
|
||
|
||
/**
|
||
* The VSCode drag gesture: move a tab out of its pane and either merge it
|
||
* into the target pane (center) or split the target pane with the tab in a
|
||
* fresh leaf (edge). The source pane collapses when it empties.
|
||
*
|
||
* The panes may live in DIFFERENT trees (dragging a tab between the two
|
||
* panels): the tab then leaves its own tree and lands in the other one.
|
||
*/
|
||
export function moveTabToEdge(
|
||
state: SidebarState,
|
||
fromPane: string,
|
||
tabId: string,
|
||
toPane: string,
|
||
zone: DropZone,
|
||
): SidebarState {
|
||
if (fromPane === toPane && zone === 'center') {
|
||
// Dropped back onto its own pane's center: reorder to the end.
|
||
return moveTab(state, fromPane, tabId, toPane, -1)
|
||
}
|
||
const key = treeOf(state, fromPane)
|
||
const toKey = treeOf(state, toPane)
|
||
if (key !== toKey) {
|
||
// Cross-panel drop: remove the tab from its own tree, then merge (center)
|
||
// or split (edge) a pane of the OTHER tree with the tab.
|
||
const source = leafWithTab(state[key], tabId)
|
||
if (source === undefined) return state
|
||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||
let emptied = false
|
||
let sourceNode = mapLeaf(state[key], source.id, (leaf) => {
|
||
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
|
||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||
if (leaf.tabs.length === 0) emptied = true
|
||
})
|
||
if (emptied) sourceNode = removeLeafAt(sourceNode, source.id)
|
||
let targetNode = state[toKey]
|
||
let activePane: string
|
||
if (zone === 'center') {
|
||
targetNode = mapLeaf(targetNode, toPane, (leaf) => {
|
||
leaf.tabs = [...leaf.tabs, tab]
|
||
leaf.active = tab.id
|
||
})
|
||
activePane = toPane
|
||
} else {
|
||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||
const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||
targetNode = result.node
|
||
activePane = result.leafId
|
||
}
|
||
return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane }
|
||
}
|
||
const node = state[key]
|
||
const source = leafWithTab(node, tabId)
|
||
if (source === undefined) return state
|
||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||
let emptied = false
|
||
let splits = mapLeaf(node, source.id, (leaf) => {
|
||
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
|
||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||
if (leaf.tabs.length === 0) emptied = true
|
||
})
|
||
if (emptied) splits = removeLeafAt(splits, source.id)
|
||
if (zone === 'center') {
|
||
splits = mapLeaf(splits, toPane, (leaf) => {
|
||
leaf.tabs = [...leaf.tabs, tab]
|
||
leaf.active = tab.id
|
||
})
|
||
return { ...state, [key]: splits, activePane: toPane }
|
||
}
|
||
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
|
||
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
|
||
return { ...state, [key]: result.node, activePane: result.leafId }
|
||
}
|
||
|
||
/**
|
||
* Remove a leaf from the tree. A split left with one child promotes that
|
||
* child; removing the last leaf yields an empty leaf.
|
||
*/
|
||
export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
|
||
if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
|
||
const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
|
||
if (children.length === node.children.length) {
|
||
return {
|
||
...node,
|
||
sizes: [...node.sizes],
|
||
children: node.children.map(child => removeLeafAt(child, paneId)),
|
||
}
|
||
}
|
||
if (children.length === 1) return children[0]!
|
||
return { ...node, sizes: [...node.sizes], children }
|
||
}
|
||
|
||
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
|
||
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||
const key = treeOf(state, paneId)
|
||
let emptied = false
|
||
const splits = mapLeaf(state[key], paneId, (leaf) => {
|
||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||
if (leaf.tabs.length === 0) emptied = true
|
||
})
|
||
return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits }
|
||
}
|
||
|
||
/** Activate a tab in its pane (the pane's own tree). */
|
||
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
|
||
const key = treeOf(state, paneId)
|
||
return {
|
||
...state,
|
||
activePane: paneId,
|
||
[key]: mapLeaf(state[key], paneId, (leaf) => {
|
||
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
|
||
}),
|
||
}
|
||
}
|
||
|
||
/** Update the display fields of one open tab (title / path / meta) without
|
||
* re-opening it. The browser tab persists its current URL and hostname
|
||
* title through this reducer so a reload restores the visited page. A
|
||
* missing tab id is a no-op. The tab may live in either tree or a free
|
||
* window. */
|
||
export function patchTab(
|
||
state: SidebarState,
|
||
tabId: string,
|
||
patch: { title?: string; path?: string; meta?: unknown },
|
||
): SidebarState {
|
||
let changed = false
|
||
const apply = (tab: SidebarTab): SidebarTab => {
|
||
changed = true
|
||
return {
|
||
...tab,
|
||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||
}
|
||
}
|
||
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
|
||
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
|
||
* this reducer only handles the id-based safety net (reconcile and
|
||
* openDiffTab already check existence before calling) and the landing
|
||
* itself — the service's dedupe path delegates here after its dedupeKey
|
||
* check misses.
|
||
*
|
||
* The active pane may live in EITHER tree (pane ids are globally unique):
|
||
* a stale id that survives in neither tree falls back to the right tree's
|
||
* first pane instead of swallowing the open.
|
||
*/
|
||
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
|
||
let targetId = state.activePane ?? firstLeaf(state.splits).id
|
||
// A stale activePane (its pane was closed since) must not swallow the
|
||
// open: fall back to the first pane of the right tree instead of dropping
|
||
// the tab.
|
||
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
|
||
targetId = firstLeaf(state.splits).id
|
||
}
|
||
const targetKey = treeOf(state, targetId)
|
||
// Id-based safety net: if a tab with the same id exists, focus it — in a
|
||
// pane (activate) or in a free window (raise, no panel switch).
|
||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
|
||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||
}
|
||
const floated = floatWithTab(state, tab.id)
|
||
if (floated !== undefined) return raiseFloat(state, floated.id)
|
||
return {
|
||
...state,
|
||
activePane: targetId,
|
||
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
|
||
leaf.tabs = [...leaf.tabs, tab]
|
||
leaf.active = tab.id
|
||
}),
|
||
}
|
||
}
|
||
|
||
/** Move a tab from one pane to another (insert at index; -1 appends).
|
||
* The panes may live in DIFFERENT trees — dragging a tab between the two
|
||
* panels removes it from its own tree and lands it in the other one. */
|
||
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
|
||
const fromKey = treeOf(state, fromPane)
|
||
const toKey = treeOf(state, toPane)
|
||
if (fromKey !== toKey) {
|
||
let moved: SidebarTab | undefined
|
||
let emptied = false
|
||
const source = mapLeaf(state[fromKey], fromPane, (leaf) => {
|
||
const found = leaf.tabs.find(tab => tab.id === tabId)
|
||
if (found === undefined) return
|
||
moved = found
|
||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||
if (leaf.tabs.length === 0) emptied = true
|
||
})
|
||
if (moved === undefined) return state
|
||
const target = mapLeaf(state[toKey], toPane, (leaf) => {
|
||
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
|
||
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
|
||
leaf.active = moved!.id
|
||
})
|
||
return {
|
||
...state,
|
||
[fromKey]: emptied ? removeLeafAt(source, fromPane) : source,
|
||
[toKey]: target,
|
||
activePane: toPane,
|
||
}
|
||
}
|
||
let moved: SidebarTab | undefined
|
||
let emptied = false
|
||
let splits = mapLeaf(state[fromKey], fromPane, (leaf) => {
|
||
const found = leaf.tabs.find(tab => tab.id === tabId)
|
||
if (found === undefined) return
|
||
moved = found
|
||
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
|
||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||
if (leaf.tabs.length === 0) emptied = true
|
||
})
|
||
if (moved === undefined) return state
|
||
if (emptied) splits = removeLeafAt(splits, fromPane)
|
||
splits = mapLeaf(splits, toPane, (leaf) => {
|
||
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
|
||
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
|
||
leaf.active = moved!.id
|
||
})
|
||
return { ...state, [fromKey]: splits, activePane: toPane }
|
||
}
|
||
|
||
/** Split the active pane (or the pane containing the active tab). */
|
||
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
|
||
const paneId = state.activePane ?? firstLeaf(state.splits).id
|
||
const key = treeOf(state, paneId)
|
||
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
|
||
}
|
||
|
||
/**
|
||
* Open a diff tab the VSCode way: an existing instance of the same change is
|
||
* focused wherever it lives; otherwise the tab joins the first pane that
|
||
* already holds diff tabs (diff panes are sticky — repeated clicks stack
|
||
* there); on the FIRST diff of a layout the source pane splits vertically so
|
||
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
|
||
*
|
||
* This is split-tree placement surgery, not registry dispatch: the diff tab
|
||
* descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
|
||
* check below is exactly that rule — the two agree by construction (asserted
|
||
* in tests). Diff tabs minted by the Git view carry change-derived ids, so
|
||
* the id check is the per-change dedupe.
|
||
* @returns the new state, with the diff pane active.
|
||
*/
|
||
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
|
||
const existingLeaf = leafWithTab(state.splits, tab.id)
|
||
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
|
||
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
|
||
if (diffLeaf !== undefined) {
|
||
return {
|
||
...state,
|
||
activePane: diffLeaf.id,
|
||
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
|
||
leaf.tabs = [...leaf.tabs, tab]
|
||
leaf.active = tab.id
|
||
}),
|
||
}
|
||
}
|
||
// First diff: split the source pane, the diff tab in the new LOWER leaf.
|
||
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
|
||
// regular open path instead of dropping the tab into an orphaned leaf.)
|
||
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
|
||
return openTabInActivePane(state, tab)
|
||
}
|
||
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
|
||
return { ...state, splits: result.node, activePane: result.leafId }
|
||
}
|
||
|
||
/** Toggle the panel open/closed (opening restores the previous layout). */
|
||
export function togglePanel(state: SidebarState): SidebarState {
|
||
return { ...state, panelOpen: !state.panelOpen }
|
||
}
|
||
|
||
/** Toggle the bottom panel open/closed (independent of the right panel). */
|
||
export function toggleBottomPanel(state: SidebarState): SidebarState {
|
||
return { ...state, bottomOpen: !state.bottomOpen }
|
||
}
|
||
|
||
/** Set the panel width (clamped to the contract range; the upper bound is
|
||
* the viewport so the fullscreen expansion can fill the window). */
|
||
export function setWidth(state: SidebarState, width: number): SidebarState {
|
||
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
|
||
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
|
||
}
|
||
|
||
/** Set the bottom panel height (clamped to the contract range). The upper
|
||
* bound leaves the center column (the agent output area) at least PANEL_MIN
|
||
* tall — without the cap the bottom panel could swallow the whole viewport
|
||
* and squeeze the conversation to zero height. */
|
||
export function setBottomHeight(state: SidebarState, height: number): SidebarState {
|
||
const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity
|
||
const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN)
|
||
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
|
||
}
|
||
|
||
/** Toggle a directory in the explorer expansion set. */
|
||
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
|
||
const expanded = state.expanded.includes(path)
|
||
? state.expanded.filter(item => item !== path)
|
||
: [...state.expanded, path]
|
||
return { ...state, expanded }
|
||
}
|
||
|
||
/**
|
||
* Reveal files in the explorer: expand every ancestor directory between the
|
||
* explorer root and each file (so the lazy tree actually shows the row) and
|
||
* record the paths for highlighting. The reveal set is transient —
|
||
* sanitizeState never restores it, so a reload starts unhighlighted.
|
||
* @param state - current sidebar state.
|
||
* @param cwd - the explorer's root (session working directory).
|
||
* @param files - absolute paths to highlight (parent dirs are expanded).
|
||
* @returns the next state, or the same reference when nothing is revealed.
|
||
*/
|
||
export function revealPaths(state: SidebarState, cwd: string | undefined, files: readonly string[]): SidebarState {
|
||
const expanded = new Set(state.expanded)
|
||
const revealed: string[] = []
|
||
const rootParts = (cwd ?? '').split(/[\\/]+/).filter(part => part !== '')
|
||
for (const file of files) {
|
||
if (typeof file !== 'string' || file === '') continue
|
||
revealed.push(file)
|
||
const parts = file.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
|
||
const separator = file.includes('\\') ? '\\' : '/'
|
||
// Keep the original leading separator(s) when rebuilding ancestor dirs:
|
||
// FileTree matches expansion against ABSOLUTE paths, so dropping the
|
||
// root (POSIX `/w/src` �W `w/src`) or a UNC prefix (`\\server\share`)
|
||
// would leave every ancestor collapsed and the row unreachable.
|
||
const prefix = file.startsWith('/') ? '/' : file.startsWith('\\\\') ? '\\\\' : file.startsWith('\\') ? '\\' : ''
|
||
for (let i = rootParts.length; i < parts.length - 1; i++) {
|
||
expanded.add(prefix + parts.slice(0, i + 1).join(separator))
|
||
}
|
||
}
|
||
if (revealed.length === 0) return state
|
||
return { ...state, expanded: [...expanded], revealed }
|
||
}
|
||
|
||
/** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
|
||
export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
|
||
if (node.kind === 'leaf') return node
|
||
if (node.id === splitId) {
|
||
const sizes = [...node.sizes]
|
||
const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
|
||
const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
|
||
sizes[index] = left
|
||
sizes[index + 1] = right
|
||
return { ...node, sizes }
|
||
}
|
||
return {
|
||
...node,
|
||
sizes: [...node.sizes],
|
||
children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
|
||
}
|
||
}
|
||
|
||
/** State-level {@link resizeSplit} route: the divider may live in either
|
||
* tree (split ids are globally unique). */
|
||
export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
|
||
const key = treeOf(state, splitId)
|
||
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
|
||
}
|
||
|
||
// ── Free windows ────────────────────────────────────────────────────────────
|
||
|
||
/** The viewport size, or Infinity where there is no (usable) window — unit
|
||
* tests stub partial window objects, and a NaN bound would poison geometry. */
|
||
function viewportW(): number {
|
||
return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
|
||
}
|
||
|
||
function viewportH(): number {
|
||
return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
|
||
}
|
||
|
||
/** Clamp free-window geometry: sizes respect the floor and the viewport, and
|
||
* the position keeps the whole window inside the viewport. Without a window
|
||
* (unit tests) only the floor applies — the caller's values pass through. */
|
||
export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick<FloatWindow, 'x' | 'y' | 'w' | 'h'> {
|
||
const vw = viewportW()
|
||
const vh = viewportH()
|
||
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
|
||
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
|
||
return {
|
||
x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
|
||
y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
|
||
w: width,
|
||
h: height,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Float a docked tab: remove it from its pane (either tree; an emptied pane
|
||
* collapses like any move) and append a free window centered on the drop
|
||
* point, with the default size clamped to the viewport. The stacking order
|
||
* is the array order, so a fresh window is born topmost. An unknown tab id
|
||
* (or one already floating) is a strict no-op.
|
||
*/
|
||
export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
|
||
let source: SidebarLeaf | undefined
|
||
let key: 'splits' | 'bottomSplits' | undefined
|
||
for (const treeKey of ['splits', 'bottomSplits'] as const) {
|
||
source = leafWithTab(state[treeKey], tabId)
|
||
if (source !== undefined) { key = treeKey; break }
|
||
}
|
||
if (key === undefined || source === undefined) return state
|
||
const tab = source.tabs.find(candidate => candidate.id === tabId)!
|
||
let emptied = false
|
||
let node = mapLeaf(state[key], source.id, (leaf) => {
|
||
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
|
||
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
|
||
if (leaf.tabs.length === 0) emptied = true
|
||
})
|
||
if (emptied) node = removeLeafAt(node, source.id)
|
||
// Phone-ratio default, capped to the viewport before centering so the
|
||
// clamped position never leaves the window's bottom past the fold.
|
||
const vw = viewportW()
|
||
const vh = viewportH()
|
||
const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24))
|
||
const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24))
|
||
const window = clampFloatGeometry(
|
||
x - width / 2,
|
||
y - height / 2,
|
||
width,
|
||
height,
|
||
)
|
||
const next: SidebarState = {
|
||
...state,
|
||
[key]: node,
|
||
floats: [...state.floats, { id: uid('float'), tab, ...window }],
|
||
}
|
||
// The pane the user was working in may have just collapsed with the tab.
|
||
if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
|
||
return next
|
||
}
|
||
|
||
/** Move a free window (clamped to the viewport); unknown ids are a no-op. */
|
||
export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
|
||
const float = floatById(state, floatId)
|
||
if (float === undefined) return state
|
||
const geo = clampFloatGeometry(x, y, float.w, float.h)
|
||
if (geo.x === float.x && geo.y === float.y) return state
|
||
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
|
||
}
|
||
|
||
/** Resize a free window from its SE corner: the top-left corner stays
|
||
* anchored, sizes clamp to the floor and to the viewport's remaining room. */
|
||
export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
|
||
const float = floatById(state, floatId)
|
||
if (float === undefined) return state
|
||
const vw = viewportW()
|
||
const vh = viewportH()
|
||
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
|
||
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
|
||
if (width === float.w && height === float.h) return state
|
||
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
|
||
}
|
||
|
||
/** Bring a free window to the top (the array's end). Already topmost (or the
|
||
* only window) returns the same reference — no persist churn on every click. */
|
||
export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
|
||
if (state.floats.length < 2) return state
|
||
const index = state.floats.findIndex(f => f.id === floatId)
|
||
if (index < 0 || index === state.floats.length - 1) return state
|
||
const floats = [...state.floats]
|
||
const [raised] = floats.splice(index, 1)
|
||
floats.push(raised!)
|
||
return { ...state, floats }
|
||
}
|
||
|
||
/** Dock a free window back into a pane (center merge): the tab joins the
|
||
* target pane and activates. `toPane` defaults to the active pane with the
|
||
* right tree's first leaf as the stale-id fallback (mirrors
|
||
* {@link openTabInActivePane}). Unknown window ids are a no-op. */
|
||
export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
|
||
const float = floatById(state, floatId)
|
||
if (float === undefined) return state
|
||
let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
|
||
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
|
||
targetId = firstLeaf(state.splits).id
|
||
}
|
||
const targetKey = treeOf(state, targetId)
|
||
return {
|
||
...state,
|
||
floats: state.floats.filter(f => f.id !== floatId),
|
||
activePane: targetId,
|
||
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
|
||
leaf.tabs = [...leaf.tabs, float.tab]
|
||
leaf.active = float.tab.id
|
||
}),
|
||
}
|
||
}
|
||
|
||
/** Close the free window holding a tab (the tab closes WITH the window —
|
||
* the caller fires the descriptor's onClose lifecycle). */
|
||
export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
|
||
if (!state.floats.some(f => f.tab.id === tabId)) return state
|
||
return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
|
||
}
|
||
|
||
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
|
||
export const AGENT_TAB_PREFIX = 'agent:'
|
||
|
||
/** Whether a tab id refers to an agent-owned terminal. */
|
||
export function isAgentTabId(tabId: string): boolean {
|
||
return tabId.startsWith(AGENT_TAB_PREFIX)
|
||
}
|
||
|
||
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
|
||
export function agentUuidOf(tabId: string): string {
|
||
return tabId.slice(AGENT_TAB_PREFIX.length)
|
||
}
|
||
|
||
/** Build the sidebar tab id for one agent terminal uuid. */
|
||
export function agentTabId(uuid: string): string {
|
||
return `${AGENT_TAB_PREFIX}${uuid}`
|
||
}
|
||
|
||
/**
|
||
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
|
||
* The host pushes the current list of agent terminals (created by the model
|
||
* through the `terminal_create` tool) over a dedicated WebSocket; this
|
||
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
|
||
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
|
||
* WS close frame that kills the pty, which fires a change, which converges
|
||
* the view. Idempotent: a no-op when the lists already match.
|
||
* @param state - the current per-session sidebar state.
|
||
* @param agentTerminals - the live agent terminal snapshots from the host.
|
||
* @returns the next state (or the same reference if no change was needed).
|
||
*/
|
||
export function reconcileAgentTerminals(
|
||
state: SidebarState,
|
||
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
|
||
): SidebarState {
|
||
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||
.concat(state.floats.map(float => float.tab))
|
||
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
|
||
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
|
||
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
|
||
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
|
||
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
|
||
if (toAdd.length === 0 && toRemove.length === 0) return state
|
||
// Remove tabs whose uuids vanished from the server list (the agent closed
|
||
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
|
||
// a FLOATED agent terminal leaves with its window.
|
||
let splits = state.splits
|
||
let floats = state.floats
|
||
for (const tab of toRemove) {
|
||
const leaf = leafWithTab(splits, tab.id)
|
||
if (leaf !== undefined) {
|
||
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
|
||
}
|
||
if (floats.some(float => float.tab.id === tab.id)) {
|
||
floats = floats.filter(float => float.tab.id !== tab.id)
|
||
}
|
||
}
|
||
// Add tabs for new uuids (the agent created a terminal). They land in the
|
||
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
|
||
let next: SidebarState = { ...state, splits, floats }
|
||
for (const terminal of toAdd) {
|
||
const tab: SidebarTab = {
|
||
id: agentTabId(terminal.uuid),
|
||
type: 'terminal',
|
||
title: terminal.title,
|
||
}
|
||
next = openTabInActivePane(next, tab)
|
||
}
|
||
return next
|
||
}
|
||
|
||
// ── The per-session store ──────────────────────────────────────────────────
|
||
|
||
const STORAGE_PREFIX = 'dsh-sidebar:v1'
|
||
|
||
/**
|
||
* Cross-session panel width: the last dragged width, shared by EVERY
|
||
* conversation (the panel width is a layout preference, not per-session
|
||
* content). Written on every persist, read at session load and on
|
||
* cache-hit session switches, so a drag in one conversation carries to all
|
||
* the others (last drag wins).
|
||
*/
|
||
const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width'
|
||
|
||
/** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */
|
||
function clampWidth(width: number): number {
|
||
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
|
||
return Math.min(max, Math.max(PANEL_MIN, Math.round(width)))
|
||
}
|
||
|
||
/** Read the cross-session panel width (undefined when never dragged). */
|
||
function readGlobalWidth(): number | undefined {
|
||
try {
|
||
const raw = localStorage.getItem(GLOBAL_WIDTH_KEY)
|
||
if (raw !== null) {
|
||
const parsed = Number(raw)
|
||
if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed)
|
||
}
|
||
} catch {
|
||
// Storage unavailable: fall back to the per-session behavior.
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** Persist the cross-session panel width (best-effort, like the session states). */
|
||
function writeGlobalWidth(width: number): void {
|
||
try {
|
||
localStorage.setItem(GLOBAL_WIDTH_KEY, String(width))
|
||
} catch {
|
||
// Storage full or unavailable: layout memory is best-effort.
|
||
}
|
||
}
|
||
|
||
/** Immutable snapshot handed to React (replaced only on real changes). */
|
||
export interface SidebarSnapshot {
|
||
sessionId: string | undefined
|
||
state: SidebarState | undefined
|
||
/**
|
||
* The current side card prefs. Carried IN the snapshot (not a separate
|
||
* subscription) so prefs changes re-render the consumers that gate on
|
||
* them — the + menu hides a tab type the moment its switch flips.
|
||
*/
|
||
prefs: SidebarPrefs
|
||
}
|
||
|
||
/** Default panel width for one viewport: the prefs percent of the window,
|
||
* clamped to the panel floor (a tiny percent must stay usable) and to the
|
||
* viewport (a large one must never cover the whole window). */
|
||
export function defaultWidthFor(viewport: number, percent: number): number {
|
||
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
|
||
}
|
||
|
||
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||
// The panel width is a cross-session preference: the last dragged width
|
||
// wins over a session's own persisted value, so switching conversations
|
||
// keeps the width the user chose anywhere.
|
||
const globalWidth = readGlobalWidth()
|
||
try {
|
||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||
if (raw !== null) {
|
||
const parsed = JSON.parse(raw) as unknown
|
||
// Seed the uid counter past the persisted ids (it resets on reload);
|
||
// sanitize re-ids any duplicates the pre-seeding counter left behind.
|
||
nextIdCounter = maxCounterId(parsed)
|
||
const sanitized = sanitizeState(parsed)
|
||
if (sanitized !== undefined) {
|
||
return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
|
||
}
|
||
}
|
||
} catch {
|
||
// Corrupt or unavailable storage: fall through to the default.
|
||
}
|
||
// New sessions seed from the user's side card prefs: the width is the
|
||
// chosen percent of the window (clamped to the panel floor and the
|
||
// viewport so a huge percent can never crush the app shell), the panel
|
||
// starts open only when the preference says so, and the seed tab is the
|
||
// empty files window (tree panel open) in BOTH editorExplorer modes — a
|
||
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
|
||
// session starts collapsed instead — the panel is a full-screen drawer
|
||
// there, and auto-opening it on first paint would cover the conversation
|
||
// before the user asked. Only the first seeding is affected: once the
|
||
// user expands the drawer, `panelOpen: true` persists like any other
|
||
// state.
|
||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||
const width = globalWidth ?? (viewport === undefined
|
||
? PANEL_DEFAULT
|
||
: defaultWidthFor(viewport, prefs.defaultWidthPercent))
|
||
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
|
||
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
|
||
return makeDefaultState(width, openByDefault, seed)
|
||
}
|
||
|
||
/**
|
||
* Structural validation of one persisted state. A malformed or stale shape
|
||
* (older layouts, hand-edited storage) must fall back to the default instead
|
||
* of crashing the panel on every reload; the restored width is also clamped
|
||
* to the current viewport so a stale fullscreen width can never crush the
|
||
* app shell (margin-right larger than the window) or cover the whole screen.
|
||
* @returns a clean state, or undefined to fall back to the default.
|
||
*/
|
||
export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||
if (parsed === null || typeof parsed !== 'object') return undefined
|
||
const record = parsed as Record<string, unknown>
|
||
if (typeof record.panelOpen !== 'boolean') return undefined
|
||
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
|
||
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
|
||
return undefined
|
||
}
|
||
// nextBrowser arrived in a later build; a missing or malformed value on an
|
||
// OLDER persisted state defaults to 1 so existing layouts keep loading
|
||
// (unlike nextTerminal, which is strict — it predates the v1 shape).
|
||
const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
|
||
? record.nextBrowser
|
||
: 1
|
||
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
||
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
||
// The seen/reid maps are SHARED across both trees: pane/split ids must be
|
||
// globally unique (the runtime uid counter is shared too), so a duplicate
|
||
// seen first in the right tree gets a fresh id when it reappears in the
|
||
// bottom tree.
|
||
const seen = new Set<string>()
|
||
const reid = new Map<string, string>()
|
||
const restoredSplits = sanitizeNode(record.splits, seen, reid)
|
||
if (restoredSplits === undefined) return undefined
|
||
const splits = pruneEmptyPanes(restoredSplits)
|
||
// Bottom-panel fields arrived in a later build: a missing or malformed
|
||
// value on an OLDER persisted state defaults (closed / default height /
|
||
// empty pane) so existing layouts keep loading, like nextBrowser.
|
||
const bottomOpen = record.bottomOpen === true
|
||
// Cap the persisted height so the center column (the agent output area)
|
||
// keeps at least PANEL_MIN tall (a stale full-height bottom panel from an
|
||
// older build must never squeeze the conversation to zero).
|
||
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
|
||
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
|
||
const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight)
|
||
? record.bottomHeight
|
||
: BOTTOM_DEFAULT
|
||
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
|
||
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
|
||
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
|
||
// Free windows arrived with the float feature: a missing field on an older
|
||
// persisted state defaults to none (like the bottom-panel fields above).
|
||
// Malformed ENTRIES drop individually — unlike the split trees, where
|
||
// corruption resets the whole state, a bad window must not cost the user
|
||
// their whole layout. Geometry clamps into the CURRENT viewport so a stale
|
||
// off-screen window stays reachable; the window ids join the same
|
||
// uniqueness set as the pane/split ids (the uid counter mints for both).
|
||
const floats: FloatWindow[] = []
|
||
if (Array.isArray(record.floats)) {
|
||
for (const entry of record.floats) {
|
||
if (entry === null || typeof entry !== 'object') continue
|
||
const candidate = entry as Record<string, unknown>
|
||
if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
|
||
const tab = sanitizePersistedTab(candidate.tab)
|
||
if (tab === undefined || tab === 'diff') continue
|
||
if (
|
||
typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
|
||
|| typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
|
||
|| typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
|
||
|| typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
|
||
) continue
|
||
seen.add(candidate.id)
|
||
floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
|
||
}
|
||
}
|
||
const requestedActivePane = typeof record.activePane === 'string'
|
||
? (reid.get(record.activePane) ?? record.activePane)
|
||
: null
|
||
const activePane = requestedActivePane === null
|
||
? null
|
||
: treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
|
||
? requestedActivePane
|
||
: firstLeaf(splits).id
|
||
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
||
return {
|
||
panelOpen: record.panelOpen,
|
||
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
|
||
// A stale duplicate pane id may have been re-ided; follow the rename so
|
||
// new tabs still land in the pane the user was using.
|
||
activePane,
|
||
nextTerminal: record.nextTerminal,
|
||
nextBrowser,
|
||
expanded: record.expanded as string[],
|
||
revealed: [],
|
||
splits,
|
||
bottomOpen,
|
||
bottomHeight,
|
||
// An older persisted state never expanded the bottom panel (the field
|
||
// arrived later): defaulting to false gives it the first-expansion
|
||
// auto-terminal exactly once after the upgrade.
|
||
bottomOpenedOnce: record.bottomOpenedOnce === true,
|
||
bottomSplits,
|
||
floats,
|
||
}
|
||
}
|
||
|
||
/** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */
|
||
function pruneEmptyPanes(node: SplitNode): SplitNode {
|
||
const leaves = allLeaves(node)
|
||
if (!leaves.some(leaf => leaf.tabs.length > 0)) return node
|
||
return leaves.reduce(
|
||
(tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree,
|
||
node,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* One tree node id, deduplicated against the ids already seen in this
|
||
* state. Duplicates are exactly the pre-seeding counter-reset corruption
|
||
* (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
|
||
* keeping both would make mapLeaf visit two leaves at once and every open
|
||
* would land in both panes, so the repeat gets a fresh id.
|
||
* @returns the id to use (the original, or a fresh uid for repeats).
|
||
*/
|
||
function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
|
||
if (!seen.has(id)) {
|
||
seen.add(id)
|
||
return id
|
||
}
|
||
const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
|
||
const fresh = uid(prefix)
|
||
seen.add(fresh)
|
||
reid.set(id, fresh)
|
||
return fresh
|
||
}
|
||
|
||
/**
|
||
* Validate one persisted tab record. @returns the clean tab, `'diff'` for an
|
||
* ephemeral diff tab (dropped everywhere — diff tabs never survive a reload),
|
||
* or undefined when the record is malformed (structural corruption when it
|
||
* comes from a split-tree leaf; a malformed FLOAT tab only drops the window).
|
||
*/
|
||
function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
|
||
if (tab === null || typeof tab !== 'object') return undefined
|
||
const candidate = tab as Record<string, unknown>
|
||
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
|
||
if (candidate.type === 'diff') return 'diff'
|
||
// Tab types are an open set (external plugins register their own); accept
|
||
// any string type here — an unregistered type renders an <OrphanedTab/> at
|
||
// view time and recovers if its plugin loads later.
|
||
if (typeof candidate.type !== 'string') return undefined
|
||
// The standalone explorer tab type merged INTO the editor (the single
|
||
// files window): a persisted explorer tab reopens as an editor home tab —
|
||
// no path, tree panel open (an existing meta object survives).
|
||
if (candidate.type === 'explorer') {
|
||
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
|
||
? candidate.meta as Record<string, unknown>
|
||
: undefined
|
||
return { id: candidate.id, type: 'editor', title: 'Files', meta: { treeOpen: true, ...meta } }
|
||
}
|
||
// `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 {
|
||
id: candidate.id,
|
||
type: candidate.type,
|
||
title: candidate.title,
|
||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
|
||
}
|
||
}
|
||
|
||
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
|
||
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
|
||
if (node === null || typeof node !== 'object') return undefined
|
||
const record = node as Record<string, unknown>
|
||
if (record.kind === 'leaf') {
|
||
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
|
||
const tabs: SidebarTab[] = []
|
||
let droppedDiff = false
|
||
for (const tab of record.tabs) {
|
||
const clean = sanitizePersistedTab(tab)
|
||
if (clean === undefined) return undefined
|
||
if (clean === 'diff') {
|
||
droppedDiff = true
|
||
continue
|
||
}
|
||
tabs.push(clean)
|
||
}
|
||
const active = typeof record.active === 'string' ? record.active : null
|
||
// An active pointer into a dropped diff tab is expected after the drop;
|
||
// any other missing active is structural corruption → reset the state.
|
||
if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
|
||
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
|
||
}
|
||
if (record.kind === 'split') {
|
||
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
|
||
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
|
||
const children: SplitNode[] = []
|
||
for (const child of record.children) {
|
||
const clean = sanitizeNode(child, seen, reid)
|
||
if (clean === undefined) return undefined
|
||
children.push(clean)
|
||
}
|
||
if (children.length < 2) return undefined
|
||
if (
|
||
record.sizes.length !== children.length
|
||
|| record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
|
||
) {
|
||
return undefined
|
||
}
|
||
return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** The session-scoped store: one state per conversation, localStorage-backed. */
|
||
export class SidebarStore {
|
||
private readonly bySession = new Map<string, SidebarState>()
|
||
private snapshot: SidebarSnapshot = {
|
||
sessionId: undefined,
|
||
state: undefined,
|
||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||
}
|
||
private readonly listeners = new Set<() => void>()
|
||
/** Per-session persist debounce timers (v0.12.0+: one per session, so a
|
||
* targeted open never cancels another session's pending write). */
|
||
private readonly persistTimers = new Map<string, number>()
|
||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||
/**
|
||
* External disable (the dsh-web-ui family's aionui-panel provider choice):
|
||
* while true the sidebar must not mount at all. Not part of the snapshot —
|
||
* nothing renders on it; the mount gate and the intercept predicates read
|
||
* it directly.
|
||
*/
|
||
private suspended = false
|
||
|
||
/**
|
||
* Set the external-disable flag (from the settings route) and remember it
|
||
* for the mount gate and the intercept predicates.
|
||
*/
|
||
setSuspended(suspended: boolean): void {
|
||
this.suspended = suspended
|
||
}
|
||
|
||
/** Whether the sidebar is externally disabled (aionui-panel chosen). */
|
||
getSuspended(): boolean {
|
||
return this.suspended
|
||
}
|
||
|
||
/**
|
||
* Replace the side card prefs (the settings RPC result / settings page
|
||
* write). Notifies like any store change: the snapshot carries the prefs,
|
||
* so consumers that gate on enable switches (the + menu, derived flows)
|
||
* re-render with the new values immediately.
|
||
*/
|
||
setPrefs(prefs: SidebarPrefs): void {
|
||
this.prefs = { ...prefs }
|
||
this.snapshot = { ...this.snapshot, prefs: this.prefs }
|
||
this.notify()
|
||
}
|
||
|
||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||
getPrefs(): SidebarPrefs {
|
||
return { ...this.prefs }
|
||
}
|
||
|
||
/** Select a session (or none); loads its persisted state. */
|
||
setSession(sessionId: string | undefined): void {
|
||
if (this.snapshot.sessionId === sessionId) return
|
||
if (sessionId === undefined) {
|
||
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
|
||
} else {
|
||
let state = this.bySession.get(sessionId)
|
||
if (state === undefined) {
|
||
state = loadState(sessionId, this.prefs)
|
||
this.bySession.set(sessionId, state)
|
||
} else {
|
||
// Cache hit: another session's load/ops may have left the uid
|
||
// counter below THIS session's persisted ids — re-seed so fresh
|
||
// pane/split ids can never collide with its tree.
|
||
nextIdCounter = maxCounterId(state)
|
||
// The panel width is cross-session: adopt the latest dragged width
|
||
// (a cached session keeps its own layout otherwise).
|
||
const globalWidth = readGlobalWidth()
|
||
if (globalWidth !== undefined && state.width !== globalWidth) {
|
||
state = { ...state, width: globalWidth }
|
||
this.bySession.set(sessionId, state)
|
||
}
|
||
}
|
||
this.snapshot = { sessionId, state, prefs: this.prefs }
|
||
}
|
||
this.notify()
|
||
}
|
||
|
||
subscribe(listener: () => void): () => void {
|
||
this.listeners.add(listener)
|
||
return () => { this.listeners.delete(listener) }
|
||
}
|
||
|
||
getSnapshot(): SidebarSnapshot {
|
||
return this.snapshot
|
||
}
|
||
|
||
/** Mutate the current session's state (no-op without a session). */
|
||
update(mutator: (draft: SidebarState) => void): void {
|
||
const sessionId = this.snapshot.sessionId
|
||
const state = this.snapshot.state
|
||
if (sessionId === undefined || state === undefined) return
|
||
const draft = structuredClone(state)
|
||
mutator(draft)
|
||
this.bySession.set(sessionId, draft)
|
||
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
|
||
this.schedulePersist(sessionId, draft)
|
||
this.notify()
|
||
}
|
||
|
||
/**
|
||
* Whether a tab still exists in its session's state. Views use this on
|
||
* unmount to tell "the tab was closed" (release the terminal now) from
|
||
* "the tree re-rendered / the conversation switched" (the tab is still
|
||
* open — keep the terminal alive through the host's reconnect grace).
|
||
* Checks the session's own map entry (the current snapshot may already
|
||
* point at another session when a conversation switch unmounts the old
|
||
* one's tabs).
|
||
*/
|
||
tabOpen(sessionId: string, tabId: string): boolean {
|
||
const state = this.bySession.get(sessionId)
|
||
?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
|
||
return state !== undefined && tabOpenIn(state, tabId)
|
||
}
|
||
|
||
/** Apply a pure reducer (returns the next state). */
|
||
reduce(reducer: (state: SidebarState) => SidebarState): void {
|
||
const sessionId = this.snapshot.sessionId
|
||
const state = this.snapshot.state
|
||
if (sessionId === undefined || state === undefined) return
|
||
const next = reducer(state)
|
||
// A reducer returning the SAME reference means "no change": skip the
|
||
// persist + notify entirely — strict no-op paths (unknown tab ids,
|
||
// patchTab on a missing tab) must not churn the state or rewrite
|
||
// localStorage.
|
||
if (next === state) return
|
||
this.bySession.set(sessionId, next)
|
||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||
this.schedulePersist(sessionId, next)
|
||
this.notify()
|
||
}
|
||
|
||
/**
|
||
* Apply a pure reducer to a TARGET session's state (not the active one),
|
||
* loading it on demand and persisting the result — WITHOUT switching the
|
||
* active snapshot or notifying (the UI must not follow along). Used by the
|
||
* service's targeted `openTab(seed, scope)`: the open lands in the target
|
||
* session's layout and is visible whenever the user switches to it.
|
||
*/
|
||
reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
|
||
// The uid counter is SHARED across sessions, and the ACTIVE session's
|
||
// safety requires it to never drop below the ids IT minted. Seeding it
|
||
// from the target's max may LOWER it (a cached target older than the
|
||
// active session): restoring the pre-call level afterwards keeps the
|
||
// active session's next mint collision-free — ids minted for the target
|
||
// only need to exceed the target's own max, which the seed guaranteed.
|
||
const counterBefore = nextIdCounter
|
||
let state = this.bySession.get(sessionId)
|
||
if (state === undefined) {
|
||
state = loadState(sessionId, this.prefs)
|
||
this.bySession.set(sessionId, state)
|
||
} else {
|
||
// Re-seed the uid counter past THIS session's persisted ids, exactly
|
||
// like setSession's cache-hit path.
|
||
nextIdCounter = maxCounterId(state)
|
||
}
|
||
const next = reducer(state)
|
||
// Same-reference result = no change: keep the counter restore (it may
|
||
// have been seeded down) but skip the write.
|
||
nextIdCounter = Math.max(nextIdCounter, counterBefore)
|
||
if (next === state) return
|
||
this.bySession.set(sessionId, next)
|
||
this.schedulePersist(sessionId, next)
|
||
}
|
||
|
||
private schedulePersist(sessionId: string, state: SidebarState): void {
|
||
// Keep the cross-session width in sync: any width change (drag, fullscreen
|
||
// toggle) on the ACTIVE session becomes the shared width for every
|
||
// conversation. A targeted open persists an INACTIVE session (reduceFor)
|
||
// and must not clobber that global — its width is stale by definition,
|
||
// so writing it would break "last drag wins".
|
||
if (sessionId === this.snapshot.sessionId) {
|
||
writeGlobalWidth(state.width)
|
||
}
|
||
// Per-session debounce timers: one session's pending write must never
|
||
// cancel another's (targeted opens schedule writes for INACTIVE
|
||
// sessions while the active session may already have one pending —
|
||
// a shared timer would drop the earlier write and the reload would
|
||
// lose that session's layout).
|
||
const existing = this.persistTimers.get(sessionId)
|
||
if (existing !== undefined) window.clearTimeout(existing)
|
||
const timer = window.setTimeout(() => {
|
||
this.persistTimers.delete(sessionId)
|
||
try {
|
||
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
|
||
} catch {
|
||
// Storage full or unavailable: layout memory is best-effort.
|
||
}
|
||
}, 200)
|
||
this.persistTimers.set(sessionId, timer)
|
||
}
|
||
|
||
private notify(): void {
|
||
for (const listener of [...this.listeners]) listener()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create one sidebar store instance. Production code calls this only from
|
||
* the client plugin's `apply` (the instance is handed to components as a
|
||
* prop); tests call it directly. No module-level singleton: the store's
|
||
* lifetime belongs to the plugin activation, exactly like the official
|
||
* `createXXXStore()` factory rule.
|
||
*/
|
||
export function createSidebarStore(): SidebarStore {
|
||
return new SidebarStore()
|
||
}
|