dsh-coding-sidebar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +524 -0
  3. package/cordis.patch.yml +49 -0
  4. package/lib/client-editor.js +48229 -0
  5. package/lib/client-mermaid.js +200745 -0
  6. package/lib/client-registry.js +21867 -0
  7. package/lib/client-terminal.js +9361 -0
  8. package/lib/client.js +21867 -0
  9. package/lib/index.js +4556 -0
  10. package/lib/invariant.js +22 -0
  11. package/lib/types/agent-opens.d.ts +65 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +39 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +13 -0
  19. package/lib/types/client/FileTree.d.ts +38 -0
  20. package/lib/types/client/FreeWindow.d.ts +29 -0
  21. package/lib/types/client/GitView.d.ts +10 -0
  22. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  23. package/lib/types/client/OrphanedTab.d.ts +11 -0
  24. package/lib/types/client/PdfView.d.ts +6 -0
  25. package/lib/types/client/RenderBoundary.d.ts +32 -0
  26. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  27. package/lib/types/client/SideCardSection.d.ts +74 -0
  28. package/lib/types/client/SideChatView.d.ts +16 -0
  29. package/lib/types/client/Sidebar.d.ts +6 -0
  30. package/lib/types/client/SubagentView.d.ts +16 -0
  31. package/lib/types/client/TabBar.d.ts +54 -0
  32. package/lib/types/client/TerminalView.d.ts +24 -0
  33. package/lib/types/client/TextEditor.d.ts +10 -0
  34. package/lib/types/client/TreePanel.d.ts +24 -0
  35. package/lib/types/client/UploadOverlay.d.ts +20 -0
  36. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  37. package/lib/types/client/api.d.ts +275 -0
  38. package/lib/types/client/binary-download.d.ts +12 -0
  39. package/lib/types/client/breakpoints.d.ts +17 -0
  40. package/lib/types/client/browser.d.ts +60 -0
  41. package/lib/types/client/builtins/index.d.ts +18 -0
  42. package/lib/types/client/builtins/tabs.d.ts +11 -0
  43. package/lib/types/client/builtins/viewers.d.ts +3 -0
  44. package/lib/types/client/chunk-loader.d.ts +131 -0
  45. package/lib/types/client/chunks/editor.d.ts +9 -0
  46. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  47. package/lib/types/client/chunks/terminal.d.ts +9 -0
  48. package/lib/types/client/cm-themes.d.ts +24 -0
  49. package/lib/types/client/conversation-draft.d.ts +14 -0
  50. package/lib/types/client/desktop-env.d.ts +35 -0
  51. package/lib/types/client/editor-load.d.ts +66 -0
  52. package/lib/types/client/frame-batcher.d.ts +24 -0
  53. package/lib/types/client/icons.d.ts +59 -0
  54. package/lib/types/client/image-types.d.ts +3 -0
  55. package/lib/types/client/ime-guard.d.ts +36 -0
  56. package/lib/types/client/index.d.ts +25 -0
  57. package/lib/types/client/intercept.d.ts +46 -0
  58. package/lib/types/client/lang.d.ts +14 -0
  59. package/lib/types/client/layout-push.d.ts +15 -0
  60. package/lib/types/client/lazy-chunk.d.ts +25 -0
  61. package/lib/types/client/link-intercept.d.ts +42 -0
  62. package/lib/types/client/locales-ar.d.ts +18 -0
  63. package/lib/types/client/locales-de.d.ts +4 -0
  64. package/lib/types/client/locales-fr.d.ts +10 -0
  65. package/lib/types/client/locales-hi.d.ts +17 -0
  66. package/lib/types/client/locales-id.d.ts +15 -0
  67. package/lib/types/client/locales-it.d.ts +8 -0
  68. package/lib/types/client/locales-ja.d.ts +17 -0
  69. package/lib/types/client/locales-ko.d.ts +9 -0
  70. package/lib/types/client/locales-nl.d.ts +16 -0
  71. package/lib/types/client/locales-pl.d.ts +19 -0
  72. package/lib/types/client/locales-pt.d.ts +1 -0
  73. package/lib/types/client/locales-ru.d.ts +13 -0
  74. package/lib/types/client/locales-sv.d.ts +1 -0
  75. package/lib/types/client/locales-th.d.ts +17 -0
  76. package/lib/types/client/locales-tr.d.ts +17 -0
  77. package/lib/types/client/locales-vi.d.ts +17 -0
  78. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  79. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  80. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  81. package/lib/types/client/locales.d.ts +452 -0
  82. package/lib/types/client/markdown-html.d.ts +96 -0
  83. package/lib/types/client/markdown-images.d.ts +45 -0
  84. package/lib/types/client/markdown-labels.d.ts +38 -0
  85. package/lib/types/client/md-toc.d.ts +4 -0
  86. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  87. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  88. package/lib/types/client/mermaid.d.ts +12 -0
  89. package/lib/types/client/open-when-sized.d.ts +20 -0
  90. package/lib/types/client/open-with-settings.d.ts +4 -0
  91. package/lib/types/client/open-with.d.ts +87 -0
  92. package/lib/types/client/openpath-intercept.d.ts +85 -0
  93. package/lib/types/client/paths.d.ts +43 -0
  94. package/lib/types/client/pdf-types.d.ts +2 -0
  95. package/lib/types/client/pinned.d.ts +86 -0
  96. package/lib/types/client/plugin-settings.d.ts +8 -0
  97. package/lib/types/client/plugins-shared.d.ts +27 -0
  98. package/lib/types/client/plugins-tabs.d.ts +3 -0
  99. package/lib/types/client/plugins-viewers.d.ts +3 -0
  100. package/lib/types/client/prefs.d.ts +41 -0
  101. package/lib/types/client/produced-files.d.ts +29 -0
  102. package/lib/types/client/selection-payload.d.ts +27 -0
  103. package/lib/types/client/service.d.ts +439 -0
  104. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  105. package/lib/types/client/shell-presets.d.ts +48 -0
  106. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  107. package/lib/types/client/split-pane.d.ts +36 -0
  108. package/lib/types/client/state.d.ts +432 -0
  109. package/lib/types/client/subagent-detect.d.ts +54 -0
  110. package/lib/types/client/subagent-jobs.d.ts +63 -0
  111. package/lib/types/client/tab-content-memo.d.ts +40 -0
  112. package/lib/types/client/terminal-font.d.ts +78 -0
  113. package/lib/types/client/terminal-links.d.ts +119 -0
  114. package/lib/types/client/theme.d.ts +45 -0
  115. package/lib/types/client/titlebar-strip.d.ts +23 -0
  116. package/lib/types/client/upload.d.ts +69 -0
  117. package/lib/types/client/wco.d.ts +47 -0
  118. package/lib/types/config.d.ts +64 -0
  119. package/lib/types/context-types.d.ts +605 -0
  120. package/lib/types/fs-operations.d.ts +28 -0
  121. package/lib/types/fs-search.d.ts +24 -0
  122. package/lib/types/fs-tree.d.ts +51 -0
  123. package/lib/types/git.d.ts +124 -0
  124. package/lib/types/html-route.d.ts +59 -0
  125. package/lib/types/index.d.ts +47 -0
  126. package/lib/types/invariant.d.ts +15 -0
  127. package/lib/types/jobs-routes.d.ts +45 -0
  128. package/lib/types/open-external.d.ts +24 -0
  129. package/lib/types/path-security.d.ts +20 -0
  130. package/lib/types/prefs-shared.d.ts +236 -0
  131. package/lib/types/pty-deps.d.ts +78 -0
  132. package/lib/types/pty-manager.d.ts +168 -0
  133. package/lib/types/sidechat-core.d.ts +172 -0
  134. package/lib/types/sidechat-routes.d.ts +30 -0
  135. package/lib/types/subagent-activity.d.ts +44 -0
  136. package/lib/types/subagent-live-route.d.ts +44 -0
  137. package/lib/types/tools.d.ts +34 -0
  138. package/lib/types/trust-fence.d.ts +24 -0
  139. package/lib/types/wire.d.ts +38 -0
  140. package/package.json +172 -0
  141. package/scripts/install.ps1 +306 -0
  142. package/scripts/install.sh +287 -0
  143. package/src/agent-opens.ts +265 -0
  144. package/src/agent-pty.ts +527 -0
  145. package/src/browser-probe.ts +26 -0
  146. package/src/bundle-route.ts +129 -0
  147. package/src/client/BrowserView.tsx +289 -0
  148. package/src/client/DiffTab.tsx +114 -0
  149. package/src/client/DiffView.tsx +305 -0
  150. package/src/client/EditorHost.tsx +558 -0
  151. package/src/client/FileTree.tsx +669 -0
  152. package/src/client/FreeWindow.tsx +337 -0
  153. package/src/client/GitView.tsx +717 -0
  154. package/src/client/MarkdownHtml.tsx +296 -0
  155. package/src/client/OrphanedTab.tsx +27 -0
  156. package/src/client/PdfView.tsx +110 -0
  157. package/src/client/RenderBoundary.tsx +49 -0
  158. package/src/client/SandboxStatusBar.tsx +60 -0
  159. package/src/client/SideCardSection.module.css +960 -0
  160. package/src/client/SideCardSection.tsx +1163 -0
  161. package/src/client/SideChatView.module.css +437 -0
  162. package/src/client/SideChatView.tsx +645 -0
  163. package/src/client/Sidebar.tsx +1370 -0
  164. package/src/client/SubagentView.module.css +547 -0
  165. package/src/client/SubagentView.tsx +883 -0
  166. package/src/client/TabBar.tsx +365 -0
  167. package/src/client/TerminalView.tsx +424 -0
  168. package/src/client/TextEditor.tsx +468 -0
  169. package/src/client/TreePanel.tsx +275 -0
  170. package/src/client/UploadOverlay.tsx +62 -0
  171. package/src/client/add-plugin-modal.tsx +199 -0
  172. package/src/client/api.ts +357 -0
  173. package/src/client/binary-download.tsx +23 -0
  174. package/src/client/breakpoints.ts +61 -0
  175. package/src/client/browser.ts +167 -0
  176. package/src/client/builtins/index.ts +37 -0
  177. package/src/client/builtins/tabs.tsx +340 -0
  178. package/src/client/builtins/viewers.tsx +125 -0
  179. package/src/client/chunk-loader.ts +358 -0
  180. package/src/client/chunks/editor.tsx +9 -0
  181. package/src/client/chunks/mermaid.tsx +10 -0
  182. package/src/client/chunks/terminal.tsx +9 -0
  183. package/src/client/cm-themes.ts +129 -0
  184. package/src/client/conversation-draft.ts +29 -0
  185. package/src/client/css-modules.d.ts +5 -0
  186. package/src/client/desktop-env.ts +72 -0
  187. package/src/client/editor-load.ts +92 -0
  188. package/src/client/frame-batcher.ts +56 -0
  189. package/src/client/icons.tsx +152 -0
  190. package/src/client/image-types.ts +8 -0
  191. package/src/client/ime-guard.ts +47 -0
  192. package/src/client/index.tsx +427 -0
  193. package/src/client/intercept.tsx +188 -0
  194. package/src/client/lang.ts +225 -0
  195. package/src/client/layout-push.ts +24 -0
  196. package/src/client/layout.css +90 -0
  197. package/src/client/lazy-chunk.tsx +89 -0
  198. package/src/client/link-intercept.ts +73 -0
  199. package/src/client/locales-ar.ts +368 -0
  200. package/src/client/locales-de.ts +353 -0
  201. package/src/client/locales-fr.ts +360 -0
  202. package/src/client/locales-hi.ts +367 -0
  203. package/src/client/locales-id.ts +365 -0
  204. package/src/client/locales-it.ts +358 -0
  205. package/src/client/locales-ja.ts +367 -0
  206. package/src/client/locales-ko.ts +359 -0
  207. package/src/client/locales-nl.ts +365 -0
  208. package/src/client/locales-pl.ts +369 -0
  209. package/src/client/locales-pt.ts +350 -0
  210. package/src/client/locales-ru.ts +365 -0
  211. package/src/client/locales-sv.ts +350 -0
  212. package/src/client/locales-th.ts +367 -0
  213. package/src/client/locales-tr.ts +367 -0
  214. package/src/client/locales-vi.ts +367 -0
  215. package/src/client/locales-zh-HK.ts +382 -0
  216. package/src/client/locales-zh-MO.ts +382 -0
  217. package/src/client/locales-zh-TW.ts +382 -0
  218. package/src/client/locales.ts +894 -0
  219. package/src/client/markdown-html.ts +331 -0
  220. package/src/client/markdown-images.ts +137 -0
  221. package/src/client/markdown-labels.tsx +52 -0
  222. package/src/client/md-toc.tsx +127 -0
  223. package/src/client/mermaid-blocks.ts +110 -0
  224. package/src/client/mermaid-sanitize.ts +93 -0
  225. package/src/client/mermaid.tsx +406 -0
  226. package/src/client/open-when-sized.ts +43 -0
  227. package/src/client/open-with-settings.tsx +135 -0
  228. package/src/client/open-with.ts +221 -0
  229. package/src/client/openpath-intercept.ts +143 -0
  230. package/src/client/paths.ts +63 -0
  231. package/src/client/pdf-types.ts +4 -0
  232. package/src/client/pinned.ts +183 -0
  233. package/src/client/plugin-settings.ts +43 -0
  234. package/src/client/plugins-shared.ts +29 -0
  235. package/src/client/plugins-tabs.ts +96 -0
  236. package/src/client/plugins-viewers.ts +30 -0
  237. package/src/client/prefs.ts +210 -0
  238. package/src/client/produced-files.ts +120 -0
  239. package/src/client/selection-payload.ts +86 -0
  240. package/src/client/service.ts +859 -0
  241. package/src/client/settings-nav-icon.ts +45 -0
  242. package/src/client/shell-presets.ts +83 -0
  243. package/src/client/sidebar.module.css +2788 -0
  244. package/src/client/sidechat-transcript.ts +321 -0
  245. package/src/client/split-pane.tsx +331 -0
  246. package/src/client/state.ts +1497 -0
  247. package/src/client/subagent-detect.ts +137 -0
  248. package/src/client/subagent-jobs.ts +161 -0
  249. package/src/client/tab-content-memo.ts +55 -0
  250. package/src/client/terminal-font.ts +224 -0
  251. package/src/client/terminal-links.ts +194 -0
  252. package/src/client/theme.ts +110 -0
  253. package/src/client/titlebar-strip.ts +37 -0
  254. package/src/client/upload.ts +187 -0
  255. package/src/client/wco.ts +120 -0
  256. package/src/config.ts +154 -0
  257. package/src/context-types.ts +611 -0
  258. package/src/fs-operations.ts +95 -0
  259. package/src/fs-search.ts +113 -0
  260. package/src/fs-tree.ts +158 -0
  261. package/src/git.ts +446 -0
  262. package/src/html-route.ts +106 -0
  263. package/src/index.ts +1291 -0
  264. package/src/invariant.ts +32 -0
  265. package/src/jobs-routes.ts +257 -0
  266. package/src/open-external.ts +90 -0
  267. package/src/path-security.ts +75 -0
  268. package/src/prefs-shared.ts +280 -0
  269. package/src/pty-deps.ts +240 -0
  270. package/src/pty-manager.ts +376 -0
  271. package/src/sidechat-core.ts +468 -0
  272. package/src/sidechat-routes.ts +344 -0
  273. package/src/subagent-activity.ts +91 -0
  274. package/src/subagent-live-route.ts +93 -0
  275. package/src/tools.ts +482 -0
  276. package/src/trust-fence.ts +84 -0
  277. package/src/wire.ts +100 -0
@@ -0,0 +1,1497 @@
1
+ /**
2
+ * Per-session sidebar state: the panel geometry, the split-pane workbench
3
+ * tree, open tabs, and the explorer expansion set. One state instance per
4
+ * conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
5
+ * a reload restores the exact layout of the session it belongs to — switching
6
+ * conversations swaps the whole state (memory + isolation).
7
+ *
8
+ * The split tree is a recursive structure: a leaf holds a tab group, a split
9
+ * divides the space row- or column-wise with fractional sizes. All tree
10
+ * operations are pure functions over the node, unit-tested in tests/state.spec.ts.
11
+ */
12
+ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
13
+ import { isNarrowWidth } from './breakpoints.ts'
14
+
15
+ /**
16
+ * Tab type identifier. Builtins register their ids (editor / git / terminal
17
+ * / subagent / browser / diff) through the sidebar service; external
18
+ * plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
19
+ * the registry stays open.
20
+ */
21
+ export type TabType = string
22
+
23
+ /** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
24
+ export type SidebarDiffRef =
25
+ | { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean; worktree?: string; repoRoot?: string }
26
+ | { kind: 'commit'; hash: string; hashFull: string; subject: string; worktree?: string; repoRoot?: string }
27
+
28
+ /** One open tab. `path` carries the file (editor) or is absent (git/terminal);
29
+ * `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
30
+ * plugin-owned JSON-serializable state, preserved across reloads. */
31
+ export interface SidebarTab {
32
+ id: string
33
+ type: TabType
34
+ title: string
35
+ path?: string
36
+ diff?: SidebarDiffRef
37
+ /** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
38
+ * persisted with the layout and restored verbatim on reload. */
39
+ meta?: unknown
40
+ /** Pinned-terminal marker (v0.17.0+): a pinned terminal tab survives a
41
+ * session switch in its home session's state and surfaces in the
42
+ * PinnedRail of every session the scope allows. `homeCwd` is the cwd
43
+ * snapshot at pin time — a `workspace`-scoped pin is only visible to
44
+ * sessions whose cwd matches it. Absent = unpinned (legacy states). */
45
+ pin?: { scope: 'workspace' | 'global'; homeCwd?: string }
46
+ }
47
+
48
+ /** A tab group. */
49
+ export interface SidebarLeaf {
50
+ kind: 'leaf'
51
+ id: string
52
+ tabs: SidebarTab[]
53
+ active: string | null
54
+ }
55
+
56
+ /** A recursive split between child panes (fractional sizes summing to 1). */
57
+ export interface SidebarSplit {
58
+ kind: 'split'
59
+ id: string
60
+ dir: 'row' | 'col'
61
+ sizes: number[]
62
+ children: SplitNode[]
63
+ }
64
+
65
+ export type SplitNode = SidebarLeaf | SidebarSplit
66
+
67
+ /**
68
+ * One free window: a tab dragged out of the workbench onto the conversation
69
+ * area floats in the panel host at viewport coordinates. The tab is OWNED by
70
+ * the window exactly like a pane owns its tabs (moved, not copied); geometry
71
+ * persists with the session so a reload restores the window in place.
72
+ * Stacking order is the array order (last = topmost).
73
+ */
74
+ export interface FloatWindow {
75
+ id: string
76
+ tab: SidebarTab
77
+ /** Viewport coordinates of the window's top-left corner. */
78
+ x: number
79
+ y: number
80
+ w: number
81
+ h: number
82
+ }
83
+
84
+ /** The full per-session state. */
85
+ export interface SidebarState {
86
+ panelOpen: boolean
87
+ width: number
88
+ /** The pane receiving newly opened tabs (last pane the user touched).
89
+ * Referenced by id so a re-id survives sanitize — see {@link treeOf}. */
90
+ activePane: string | null
91
+ /** Monotonic terminal tab counter (ids survive reloads). */
92
+ nextTerminal: number
93
+ /** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
94
+ nextBrowser: number
95
+ /** Explorer expansion set (absolute directory paths). */
96
+ expanded: string[]
97
+ /**
98
+ * Explorer rows highlighted by a "Show in folder" reveal (absolute paths).
99
+ * Transient by design: sanitizeState never restores it, so a reload starts
100
+ * unhighlighted.
101
+ */
102
+ revealed: string[]
103
+ /** The right sidebar's split tree (the original workbench). */
104
+ splits: SplitNode
105
+ /** Free windows (tabs dragged out onto the conversation area). */
106
+ floats: FloatWindow[]
107
+ }
108
+
109
+ export const PANEL_MIN = 280
110
+ export const PANEL_MAX = 640
111
+ export const PANEL_DEFAULT = 400
112
+ export const TAB_MAX_WIDTH = 160
113
+ /** Free-window geometry contract: the floor keeps the window usable (a
114
+ * header plus some content), the ceiling is the viewport. */
115
+ export const FLOAT_MIN_W = 320
116
+ export const FLOAT_MIN_H = 200
117
+ /** Geometry a fresh free window starts with: a phone-like portrait ratio
118
+ * (390×780 ≈ 1:2). The creation path additionally caps the size to the
119
+ * viewport (minus a 24px margin), so a short viewport gets a shorter —
120
+ * not overflowing — window instead of an exact ratio. */
121
+ export const FLOAT_DEFAULT_W = 390
122
+ export const FLOAT_DEFAULT_H = 780
123
+
124
+ let nextIdCounter = 0
125
+ /** Unique pane/tab id within one state instance. */
126
+ function uid(prefix: string): string {
127
+ nextIdCounter += 1
128
+ return `${prefix}:${nextIdCounter}`
129
+ }
130
+
131
+ /** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
132
+ * covers openSidebarFile opens (per-path dedupe); opens that must not
133
+ * dedupe (the tree's "open to the side") mint through here. */
134
+ export function mintTabId(): string {
135
+ return uid('tab')
136
+ }
137
+
138
+ /**
139
+ * The largest numeric suffix across a raw persisted state's counter ids
140
+ * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
141
+ * resets on every reload, so a split minted AFTER a reload would collide
142
+ * with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
143
+ * mapLeaf would then visit BOTH leaves and every open would land in both
144
+ * panes of the split. Seeding the counter past the persisted ids keeps
145
+ * fresh ids disjoint.
146
+ */
147
+ function maxCounterId(parsed: unknown): number {
148
+ let max = 0
149
+ const consider = (id: unknown): void => {
150
+ if (typeof id !== 'string') return
151
+ const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
152
+ if (match !== null) max = Math.max(max, Number(match[1]))
153
+ }
154
+ const walk = (node: unknown): void => {
155
+ if (node === null || typeof node !== 'object') return
156
+ const record = node as Record<string, unknown>
157
+ consider(record.id)
158
+ if (Array.isArray(record.tabs)) {
159
+ for (const tab of record.tabs) {
160
+ if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
161
+ }
162
+ }
163
+ if (Array.isArray(record.children)) {
164
+ for (const child of record.children) walk(child)
165
+ }
166
+ }
167
+ // Legacy persisted states may still carry a `bottomSplits` tree (removed
168
+ // in v1.0.0); its ids join the same counter seed so re-id stays disjoint.
169
+ walk((parsed as Record<string, unknown> | null)?.splits)
170
+ walk((parsed as Record<string, unknown> | null)?.bottomSplits)
171
+ // Free windows mint through the same shared counter; their ids must seed it
172
+ // too (a persisted float:N with a lower pane max would collide otherwise).
173
+ const floats = (parsed as Record<string, unknown> | null)?.floats
174
+ if (Array.isArray(floats)) {
175
+ for (const float of floats) {
176
+ if (float !== null && typeof float === 'object') consider((float as Record<string, unknown>).id)
177
+ }
178
+ }
179
+ return max
180
+ }
181
+
182
+ /** The default tab a fresh session seeds. */
183
+ export type DefaultSeed = 'editor-home' | 'none'
184
+
185
+ /** A fresh default state: one seeded tab in one pane, open per the caller's
186
+ * preference. `width` is the caller's preferred panel width (default
187
+ * PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
188
+ * true); the store seeds new sessions from the user's side card prefs.
189
+ * `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
190
+ * (an editor tab with no path whose tree panel starts open,
191
+ * `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
192
+ * file explorer page — and 'none' starts with an empty pane (the store
193
+ * passes it when the user disabled the editor tab type in settings). */
194
+ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
195
+ const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
196
+ if (seed === 'editor-home') {
197
+ // No path: the editor host renders its empty-state hint and the docked
198
+ // tree panel (treeOpen defaults open for path-less tabs; meta pins it).
199
+ leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
200
+ leaf.active = leaf.tabs[0]!.id
201
+ }
202
+ return {
203
+ panelOpen,
204
+ width,
205
+ activePane: leaf.id,
206
+ nextTerminal: 1,
207
+ nextBrowser: 1,
208
+ expanded: [],
209
+ revealed: [],
210
+ splits: leaf,
211
+ floats: [],
212
+ }
213
+ }
214
+
215
+ /** Whether a tree node (or any descendant) carries the given pane/split id. */
216
+ function treeHasId(node: SplitNode, id: string): boolean {
217
+ if (node.id === id) return true
218
+ if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
219
+ return false
220
+ }
221
+
222
+ /** Which tree owns a pane/split id. The bottom panel was removed in v1.0.0 —
223
+ * only the right tree remains; kept as a function so call sites stay
224
+ * written against the (formerly two-tree) resolution seam. */
225
+ export function treeOf(_state: SidebarState, _id: string): 'splits' {
226
+ return 'splits'
227
+ }
228
+
229
+ /** Walk the tree and apply `visit` to the leaf with the given id. */
230
+ export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
231
+ if (node.kind === 'leaf') {
232
+ if (node.id === paneId) {
233
+ const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
234
+ visit(copy)
235
+ return copy
236
+ }
237
+ return node
238
+ }
239
+ const split = node
240
+ return {
241
+ ...split,
242
+ sizes: [...split.sizes],
243
+ children: split.children.map(child => mapLeaf(child, paneId, visit)),
244
+ }
245
+ }
246
+
247
+ /** The first leaf of the tree (fallback pane when activePane is gone). */
248
+ export function firstLeaf(node: SplitNode): SidebarLeaf {
249
+ if (node.kind === 'leaf') return node
250
+ return firstLeaf(node.children[0]!)
251
+ }
252
+
253
+ /** Find the leaf containing a tab id, if any. */
254
+ export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
255
+ if (node.kind === 'leaf') {
256
+ return node.tabs.some(tab => tab.id === tabId) ? node : undefined
257
+ }
258
+ for (const child of node.children) {
259
+ const found = leafWithTab(child, tabId)
260
+ if (found !== undefined) return found
261
+ }
262
+ return undefined
263
+ }
264
+
265
+ /** All leaves of the tree, depth-first. */
266
+ export function allLeaves(node: SplitNode): SidebarLeaf[] {
267
+ if (node.kind === 'leaf') return [node]
268
+ return node.children.flatMap(allLeaves)
269
+ }
270
+
271
+ /** Whether a tab exists anywhere in a state (any pane, or any free window —
272
+ * a floating tab is as open as a docked one). */
273
+ export function tabOpenIn(state: SidebarState, tabId: string): boolean {
274
+ return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
275
+ || state.floats.some(float => float.tab.id === tabId)
276
+ }
277
+
278
+ /** The free window holding a tab id, if any. */
279
+ export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
280
+ return state.floats.find(float => float.tab.id === tabId)
281
+ }
282
+
283
+ /** The free window with the given window id, if any. */
284
+ export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
285
+ return state.floats.find(float => float.id === floatId)
286
+ }
287
+
288
+ /** Replace a leaf with a split of it plus a fresh empty leaf. */
289
+ export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
290
+ const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
291
+ return mapLeaf(node, paneId, (leaf) => {
292
+ const target: SidebarLeaf = { ...leaf }
293
+ const split: SidebarSplit = {
294
+ kind: 'split',
295
+ id: uid('split'),
296
+ dir,
297
+ sizes: [0.5, 0.5],
298
+ children: [target, fresh],
299
+ }
300
+ Object.assign(leaf, split)
301
+ })
302
+ }
303
+
304
+ /**
305
+ * Split a leaf by inserting a fresh leaf holding `tab` beside it — the
306
+ * VSCode drag-to-edge gesture. `dir` is the split direction ('row' for
307
+ * left/right, 'col' for up/down); `front` places the new leaf first (left/
308
+ * up) or second (right/down).
309
+ * @returns the new tree plus the fresh leaf's id (the drop's active pane).
310
+ */
311
+ export function insertLeafAt(
312
+ node: SplitNode,
313
+ paneId: string,
314
+ dir: 'row' | 'col',
315
+ tab: SidebarTab,
316
+ front: boolean,
317
+ ): { node: SplitNode; leafId: string } {
318
+ const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id }
319
+ const leafId = fresh.id
320
+ const next = mapLeaf(node, paneId, (leaf) => {
321
+ const target: SidebarLeaf = { ...leaf }
322
+ const split: SidebarSplit = {
323
+ kind: 'split',
324
+ id: uid('split'),
325
+ dir,
326
+ sizes: [0.5, 0.5],
327
+ children: front ? [fresh, target] : [target, fresh],
328
+ }
329
+ Object.assign(leaf, split)
330
+ })
331
+ return { node: next, leafId }
332
+ }
333
+
334
+ /** Where a tab drop lands on a pane: an edge creates a split, center merges. */
335
+ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
336
+
337
+ /**
338
+ * The VSCode drag gesture: move a tab out of its pane and either merge it
339
+ * into the target pane (center) or split the target pane with the tab in a
340
+ * fresh leaf (edge). The source pane collapses when it empties.
341
+ */
342
+ export function moveTabToEdge(
343
+ state: SidebarState,
344
+ fromPane: string,
345
+ tabId: string,
346
+ toPane: string,
347
+ zone: DropZone,
348
+ ): SidebarState {
349
+ if (fromPane === toPane && zone === 'center') {
350
+ // Dropped back onto its own pane's center: reorder to the end.
351
+ return moveTab(state, fromPane, tabId, toPane, -1)
352
+ }
353
+ const node = state[treeOf(state, fromPane)]
354
+ const source = leafWithTab(node, tabId)
355
+ if (source === undefined) return state
356
+ const tab = source.tabs.find(candidate => candidate.id === tabId)!
357
+ let emptied = false
358
+ let splits = mapLeaf(node, source.id, (leaf) => {
359
+ leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
360
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
361
+ if (leaf.tabs.length === 0) emptied = true
362
+ })
363
+ if (emptied) splits = removeLeafAt(splits, source.id)
364
+ if (zone === 'center') {
365
+ splits = mapLeaf(splits, toPane, (leaf) => {
366
+ leaf.tabs = [...leaf.tabs, tab]
367
+ leaf.active = tab.id
368
+ })
369
+ return { ...state, splits, activePane: toPane }
370
+ }
371
+ const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
372
+ const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
373
+ return { ...state, splits: result.node, activePane: result.leafId }
374
+ }
375
+
376
+ /**
377
+ * Remove a leaf from the tree. A split left with one child promotes that
378
+ * child; removing the last leaf yields an empty leaf.
379
+ */
380
+ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
381
+ if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
382
+ const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
383
+ if (children.length === node.children.length) {
384
+ return {
385
+ ...node,
386
+ sizes: [...node.sizes],
387
+ children: node.children.map(child => removeLeafAt(child, paneId)),
388
+ }
389
+ }
390
+ if (children.length === 1) return children[0]!
391
+ return { ...node, sizes: [...node.sizes], children }
392
+ }
393
+
394
+ /** Close a tab; an emptied leaf is removed (unless it is the only pane). */
395
+ export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
396
+ const key = treeOf(state, paneId)
397
+ let emptied = false
398
+ const splits = mapLeaf(state[key], paneId, (leaf) => {
399
+ leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
400
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
401
+ if (leaf.tabs.length === 0) emptied = true
402
+ })
403
+ return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits }
404
+ }
405
+
406
+ /** Activate a tab in its pane (the pane's own tree). */
407
+ export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
408
+ const key = treeOf(state, paneId)
409
+ return {
410
+ ...state,
411
+ activePane: paneId,
412
+ [key]: mapLeaf(state[key], paneId, (leaf) => {
413
+ if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
414
+ }),
415
+ }
416
+ }
417
+
418
+ /** Update the display fields of one open tab (title / path / meta) without
419
+ * re-opening it. The browser tab persists its current URL and hostname
420
+ * title through this reducer so a reload restores the visited page. A
421
+ * missing tab id is a no-op. The tab may live in any pane or a free
422
+ * window. */
423
+ export function patchTab(
424
+ state: SidebarState,
425
+ tabId: string,
426
+ patch: { title?: string; path?: string; meta?: unknown },
427
+ ): SidebarState {
428
+ let changed = false
429
+ const apply = (tab: SidebarTab): SidebarTab => {
430
+ changed = true
431
+ return {
432
+ ...tab,
433
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
434
+ ...(patch.path !== undefined ? { path: patch.path } : {}),
435
+ ...(patch.meta !== undefined ? { meta: patch.meta } : {}),
436
+ }
437
+ }
438
+ const walk = (node: SplitNode): SplitNode => {
439
+ if (node.kind === 'leaf') {
440
+ const tabs = node.tabs.map(tab => (tab.id === tabId ? apply(tab) : tab))
441
+ return tabs === node.tabs ? node : { ...node, tabs }
442
+ }
443
+ const children = node.children.map(walk)
444
+ return children === node.children ? node : { ...node, children }
445
+ }
446
+ const splits = walk(state.splits)
447
+ const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
448
+ return changed ? { ...state, splits, floats } : state
449
+ }
450
+
451
+ /**
452
+ * Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is
453
+ * structural metadata (NOT display fields like title/path), so it walks
454
+ * the split tree AND the free windows exactly like {@link patchTab} —
455
+ * the tab may live in a pane or float. Passing `null` clears the pin
456
+ * (the tab stays open in its home session); passing a `{ scope, homeCwd }`
457
+ * object sets it. An unknown tab id is a strict no-op (same reference
458
+ * returned) so a stale pin request never churns the state or rewrites
459
+ * localStorage.
460
+ * @param state - the current per-session sidebar state.
461
+ * @param tabId - the tab to pin/unpin.
462
+ * @param pin - the pin marker to set, or null to clear.
463
+ * @returns the next state (or the same reference when the tab is missing
464
+ * or the pin marker is already the requested value).
465
+ */
466
+ export function setTabPin(
467
+ state: SidebarState,
468
+ tabId: string,
469
+ pin: { scope: 'workspace' | 'global'; homeCwd?: string } | null,
470
+ ): SidebarState {
471
+ let changed = false
472
+ const apply = (tab: SidebarTab): SidebarTab => {
473
+ // Pin is terminal-only (design YAGNI): a defensive guard keeps the
474
+ // invariant even if a caller accidentally targets a non-terminal tab.
475
+ if (tab.type !== 'terminal') return tab
476
+ // Idempotent: setting the same pin (deep-equal on scope + homeCwd) is a
477
+ // no-op so re-clicking the menu item never churns the state.
478
+ if (pin === null) {
479
+ if (tab.pin === undefined) return tab
480
+ } else if (
481
+ tab.pin !== undefined
482
+ && tab.pin.scope === pin.scope
483
+ && tab.pin.homeCwd === pin.homeCwd
484
+ ) {
485
+ return tab
486
+ }
487
+ changed = true
488
+ const { pin: _omit, ...rest } = tab
489
+ return pin === null ? rest : { ...rest, pin }
490
+ }
491
+ const walk = (node: SplitNode): SplitNode => {
492
+ if (node.kind === 'leaf') {
493
+ // Find the target tab without rebuilding the whole array: only clone
494
+ // when the tab is actually here and apply changed it (idempotent
495
+ // no-ops return the same tab reference, so === holds).
496
+ const idx = node.tabs.findIndex(tab => tab.id === tabId)
497
+ if (idx < 0) return node
498
+ const oldTab = node.tabs[idx]!
499
+ const newTab = apply(oldTab)
500
+ if (newTab === oldTab) return node
501
+ const tabs = node.tabs.slice()
502
+ tabs[idx] = newTab
503
+ return { ...node, tabs }
504
+ }
505
+ const children = node.children.map(walk)
506
+ // Only rebuild if at least one child actually changed reference.
507
+ if (children.every((child, i) => child === node.children[i])) return node
508
+ return { ...node, children }
509
+ }
510
+ const splits = walk(state.splits)
511
+ const floatIdx = state.floats.findIndex(f => f.tab.id === tabId)
512
+ const floats = floatIdx < 0 ? state.floats : (() => {
513
+ const oldFloat = state.floats[floatIdx]!
514
+ const newTab = apply(oldFloat.tab)
515
+ if (newTab === oldFloat.tab) return state.floats
516
+ const next = state.floats.slice()
517
+ next[floatIdx] = { ...oldFloat, tab: newTab }
518
+ return next
519
+ })()
520
+ return changed ? { ...state, splits, floats } : state
521
+ }
522
+
523
+ /**
524
+ * Land a tab in the active pane (or focus its existing instance by id).
525
+ * Dedup strategies (single-instance, per-path, per-change) are owned by the
526
+ * tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
527
+ * this reducer only handles the id-based safety net (reconcile and
528
+ * openDiffTab already check existence before calling) and the landing
529
+ * itself — the service's dedupe path delegates here after its dedupeKey
530
+ * check misses.
531
+ *
532
+ * A stale activePane id (its pane was closed since) falls back to the
533
+ * right tree's first pane instead of swallowing the open.
534
+ */
535
+ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
536
+ let targetId = state.activePane ?? firstLeaf(state.splits).id
537
+ // A stale activePane (its pane was closed since) must not swallow the
538
+ // open: fall back to the first pane of the right tree instead of dropping
539
+ // the tab.
540
+ if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
541
+ targetId = firstLeaf(state.splits).id
542
+ }
543
+ const targetKey = treeOf(state, targetId)
544
+ // Id-based safety net: if a tab with the same id exists, focus it — in a
545
+ // pane (activate) or in a free window (raise, no panel switch).
546
+ for (const leaf of allLeaves(state.splits)) {
547
+ const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
548
+ if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
549
+ }
550
+ const floated = floatWithTab(state, tab.id)
551
+ if (floated !== undefined) return raiseFloat(state, floated.id)
552
+ return {
553
+ ...state,
554
+ activePane: targetId,
555
+ [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
556
+ leaf.tabs = [...leaf.tabs, tab]
557
+ leaf.active = tab.id
558
+ }),
559
+ }
560
+ }
561
+
562
+ /** Move a tab from one pane to another (insert at index; -1 appends). */
563
+ export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
564
+ let moved: SidebarTab | undefined
565
+ let emptied = false
566
+ let splits = mapLeaf(state.splits, fromPane, (leaf) => {
567
+ const found = leaf.tabs.find(tab => tab.id === tabId)
568
+ if (found === undefined) return
569
+ moved = found
570
+ leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
571
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
572
+ if (leaf.tabs.length === 0) emptied = true
573
+ })
574
+ if (moved === undefined) return state
575
+ if (emptied) splits = removeLeafAt(splits, fromPane)
576
+ splits = mapLeaf(splits, toPane, (leaf) => {
577
+ const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
578
+ leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
579
+ leaf.active = moved!.id
580
+ })
581
+ return { ...state, splits, activePane: toPane }
582
+ }
583
+
584
+ /** Split the active pane (or the pane containing the active tab). */
585
+ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
586
+ const paneId = state.activePane ?? firstLeaf(state.splits).id
587
+ const key = treeOf(state, paneId)
588
+ return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
589
+ }
590
+
591
+ /**
592
+ * Open a diff tab the VSCode way: an existing instance of the same change is
593
+ * focused wherever it lives; otherwise the tab joins the first pane that
594
+ * already holds diff tabs (diff panes are sticky — repeated clicks stack
595
+ * there); on the FIRST diff of a layout the source pane splits vertically so
596
+ * the diff lands in a fresh pane below it ("默认在下半栏新增一个").
597
+ *
598
+ * This is split-tree placement surgery, not registry dispatch: the diff tab
599
+ * descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
600
+ * check below is exactly that rule — the two agree by construction (asserted
601
+ * in tests). Diff tabs minted by the Git view carry change-derived ids, so
602
+ * the id check is the per-change dedupe.
603
+ * @returns the new state, with the diff pane active.
604
+ */
605
+ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
606
+ const existingLeaf = leafWithTab(state.splits, tab.id)
607
+ if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
608
+ const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
609
+ if (diffLeaf !== undefined) {
610
+ return {
611
+ ...state,
612
+ activePane: diffLeaf.id,
613
+ splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
614
+ leaf.tabs = [...leaf.tabs, tab]
615
+ leaf.active = tab.id
616
+ }),
617
+ }
618
+ }
619
+ // First diff: split the source pane, the diff tab in the new LOWER leaf.
620
+ // (A stale sourcePaneId — its pane closed meanwhile — degrades to the
621
+ // regular open path instead of dropping the tab into an orphaned leaf.)
622
+ if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
623
+ return openTabInActivePane(state, tab)
624
+ }
625
+ const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
626
+ return { ...state, splits: result.node, activePane: result.leafId }
627
+ }
628
+
629
+ /** Toggle the panel open/closed (opening restores the previous layout). */
630
+ export function togglePanel(state: SidebarState): SidebarState {
631
+ return { ...state, panelOpen: !state.panelOpen }
632
+ }
633
+
634
+ /** Set the panel width (clamped to the contract range; the upper bound is
635
+ * the viewport so the fullscreen expansion can fill the window). */
636
+ export function setWidth(state: SidebarState, width: number): SidebarState {
637
+ const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
638
+ return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
639
+ }
640
+
641
+ /** Toggle a directory in the explorer expansion set. */
642
+ export function toggleExpanded(state: SidebarState, path: string): SidebarState {
643
+ const expanded = state.expanded.includes(path)
644
+ ? state.expanded.filter(item => item !== path)
645
+ : [...state.expanded, path]
646
+ return { ...state, expanded }
647
+ }
648
+
649
+ /**
650
+ * Reveal files in the explorer: expand every ancestor directory between the
651
+ * explorer root and each file (so the lazy tree actually shows the row) and
652
+ * record the paths for highlighting. The reveal set is transient —
653
+ * sanitizeState never restores it, so a reload starts unhighlighted.
654
+ * @param state - current sidebar state.
655
+ * @param cwd - the explorer's root (session working directory).
656
+ * @param files - absolute paths to highlight (parent dirs are expanded).
657
+ * @returns the next state, or the same reference when nothing is revealed.
658
+ */
659
+ export function revealPaths(state: SidebarState, cwd: string | undefined, files: readonly string[]): SidebarState {
660
+ const expanded = new Set(state.expanded)
661
+ const revealed: string[] = []
662
+ const rootParts = (cwd ?? '').split(/[\\/]+/).filter(part => part !== '')
663
+ for (const file of files) {
664
+ if (typeof file !== 'string' || file === '') continue
665
+ revealed.push(file)
666
+ const parts = file.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
667
+ const separator = file.includes('\\') ? '\\' : '/'
668
+ // Keep the original leading separator(s) when rebuilding ancestor dirs:
669
+ // FileTree matches expansion against ABSOLUTE paths, so dropping the
670
+ // root (POSIX `/w/src` �W `w/src`) or a UNC prefix (`\\server\share`)
671
+ // would leave every ancestor collapsed and the row unreachable.
672
+ const prefix = file.startsWith('/') ? '/' : file.startsWith('\\\\') ? '\\\\' : file.startsWith('\\') ? '\\' : ''
673
+ for (let i = rootParts.length; i < parts.length - 1; i++) {
674
+ expanded.add(prefix + parts.slice(0, i + 1).join(separator))
675
+ }
676
+ }
677
+ if (revealed.length === 0) return state
678
+ return { ...state, expanded: [...expanded], revealed }
679
+ }
680
+
681
+ /** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
682
+ export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
683
+ if (node.kind === 'leaf') return node
684
+ if (node.id === splitId) {
685
+ const sizes = [...node.sizes]
686
+ const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
687
+ const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
688
+ sizes[index] = left
689
+ sizes[index + 1] = right
690
+ return { ...node, sizes }
691
+ }
692
+ return {
693
+ ...node,
694
+ sizes: [...node.sizes],
695
+ children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
696
+ }
697
+ }
698
+
699
+ /** State-level {@link resizeSplit} route: the divider may live in either
700
+ * tree (split ids are globally unique). */
701
+ export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
702
+ const key = treeOf(state, splitId)
703
+ return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
704
+ }
705
+
706
+ // ── Free windows ────────────────────────────────────────────────────────────
707
+
708
+ /** The viewport size, or Infinity where there is no (usable) window — unit
709
+ * tests stub partial window objects, and a NaN bound would poison geometry. */
710
+ function viewportW(): number {
711
+ return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
712
+ }
713
+
714
+ function viewportH(): number {
715
+ return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
716
+ }
717
+
718
+ /** Clamp free-window geometry: sizes respect the floor and the viewport, and
719
+ * the position keeps the whole window inside the viewport. Without a window
720
+ * (unit tests) only the floor applies — the caller's values pass through. */
721
+ export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick<FloatWindow, 'x' | 'y' | 'w' | 'h'> {
722
+ const vw = viewportW()
723
+ const vh = viewportH()
724
+ const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
725
+ const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
726
+ return {
727
+ x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
728
+ y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
729
+ w: width,
730
+ h: height,
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Float a docked tab: remove it from its pane (an emptied pane collapses
736
+ * like any move) and append a free window centered on the drop
737
+ * point, with the default size clamped to the viewport. The stacking order
738
+ * is the array order, so a fresh window is born topmost. An unknown tab id
739
+ * (or one already floating) is a strict no-op.
740
+ */
741
+ export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
742
+ const source = leafWithTab(state.splits, tabId)
743
+ if (source === undefined) return state
744
+ const key = 'splits' as const
745
+ const tab = source.tabs.find(candidate => candidate.id === tabId)!
746
+ let emptied = false
747
+ let node = mapLeaf(state[key], source.id, (leaf) => {
748
+ leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
749
+ if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
750
+ if (leaf.tabs.length === 0) emptied = true
751
+ })
752
+ if (emptied) node = removeLeafAt(node, source.id)
753
+ // Phone-ratio default, capped to the viewport before centering so the
754
+ // clamped position never leaves the window's bottom past the fold.
755
+ const vw = viewportW()
756
+ const vh = viewportH()
757
+ const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24))
758
+ const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24))
759
+ const window = clampFloatGeometry(
760
+ x - width / 2,
761
+ y - height / 2,
762
+ width,
763
+ height,
764
+ )
765
+ const next: SidebarState = {
766
+ ...state,
767
+ [key]: node,
768
+ floats: [...state.floats, { id: uid('float'), tab, ...window }],
769
+ }
770
+ // The pane the user was working in may have just collapsed with the tab.
771
+ if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
772
+ return next
773
+ }
774
+
775
+ /** Move a free window (clamped to the viewport); unknown ids are a no-op. */
776
+ export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
777
+ const float = floatById(state, floatId)
778
+ if (float === undefined) return state
779
+ const geo = clampFloatGeometry(x, y, float.w, float.h)
780
+ if (geo.x === float.x && geo.y === float.y) return state
781
+ return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
782
+ }
783
+
784
+ /** Resize a free window from its SE corner: the top-left corner stays
785
+ * anchored, sizes clamp to the floor and to the viewport's remaining room. */
786
+ export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
787
+ const float = floatById(state, floatId)
788
+ if (float === undefined) return state
789
+ const vw = viewportW()
790
+ const vh = viewportH()
791
+ const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
792
+ const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
793
+ if (width === float.w && height === float.h) return state
794
+ return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
795
+ }
796
+
797
+ /** Bring a free window to the top (the array's end). Already topmost (or the
798
+ * only window) returns the same reference — no persist churn on every click. */
799
+ export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
800
+ if (state.floats.length < 2) return state
801
+ const index = state.floats.findIndex(f => f.id === floatId)
802
+ if (index < 0 || index === state.floats.length - 1) return state
803
+ const floats = [...state.floats]
804
+ const [raised] = floats.splice(index, 1)
805
+ floats.push(raised!)
806
+ return { ...state, floats }
807
+ }
808
+
809
+ /** Dock a free window back into a pane (center merge): the tab joins the
810
+ * target pane and activates. `toPane` defaults to the active pane with the
811
+ * right tree's first leaf as the stale-id fallback (mirrors
812
+ * {@link openTabInActivePane}). Unknown window ids are a no-op. */
813
+ export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
814
+ const float = floatById(state, floatId)
815
+ if (float === undefined) return state
816
+ let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
817
+ if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
818
+ targetId = firstLeaf(state.splits).id
819
+ }
820
+ const targetKey = treeOf(state, targetId)
821
+ return {
822
+ ...state,
823
+ floats: state.floats.filter(f => f.id !== floatId),
824
+ activePane: targetId,
825
+ [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
826
+ leaf.tabs = [...leaf.tabs, float.tab]
827
+ leaf.active = float.tab.id
828
+ }),
829
+ }
830
+ }
831
+
832
+ /** Close the free window holding a tab (the tab closes WITH the window —
833
+ * the caller fires the descriptor's onClose lifecycle). */
834
+ export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
835
+ if (!state.floats.some(f => f.tab.id === tabId)) return state
836
+ return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
837
+ }
838
+
839
+ /** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
840
+ export const AGENT_TAB_PREFIX = 'agent:'
841
+
842
+ /** Whether a tab id refers to an agent-owned terminal. */
843
+ export function isAgentTabId(tabId: string): boolean {
844
+ return tabId.startsWith(AGENT_TAB_PREFIX)
845
+ }
846
+
847
+ /** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
848
+ export function agentUuidOf(tabId: string): string {
849
+ return tabId.slice(AGENT_TAB_PREFIX.length)
850
+ }
851
+
852
+ /** Build the sidebar tab id for one agent terminal uuid. */
853
+ export function agentTabId(uuid: string): string {
854
+ return `${AGENT_TAB_PREFIX}${uuid}`
855
+ }
856
+
857
+ /**
858
+ * Reconcile the sidebar's agent-terminal tabs with the host's live list.
859
+ * The host pushes the current list of agent terminals (created by the model
860
+ * through the `terminal_create` tool) over a dedicated WebSocket; this
861
+ * reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
862
+ * lose theirs. The agent owns the lifetime — the user closing a tab sends a
863
+ * WS close frame that kills the pty, which fires a change, which converges
864
+ * the view. Idempotent: a no-op when the lists already match.
865
+ * @param state - the current per-session sidebar state.
866
+ * @param agentTerminals - the live agent terminal snapshots from the host.
867
+ * @returns the next state (or the same reference if no change was needed).
868
+ */
869
+ export function reconcileAgentTerminals(
870
+ state: SidebarState,
871
+ agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
872
+ ): SidebarState {
873
+ const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
874
+ .concat(state.floats.map(float => float.tab))
875
+ const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
876
+ const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
877
+ const serverUuids = new Set(agentTerminals.map(t => t.uuid))
878
+ const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
879
+ // Pinned agent terminals (v0.17.0+) are EXEMPT from removal: the agent
880
+ // closed them or the pty exited, but the user pinned them so the tab
881
+ // stays as a disconnected surface. The xterm view's reconnect-failure
882
+ // banner is the user-visible "disconnected" signal (the design's M3
883
+ // convergence: no title suffix, no meta write — the tab keeps its uuid
884
+ // so a later reconcile push revives it if the agent reopens the same one).
885
+ const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined)
886
+ if (toAdd.length === 0 && toRemove.length === 0) return state
887
+ // Remove tabs whose uuids vanished from the server list (the agent closed
888
+ // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
889
+ // a FLOATED agent terminal leaves with its window.
890
+ let splits = state.splits
891
+ let floats = state.floats
892
+ for (const tab of toRemove) {
893
+ const leaf = leafWithTab(splits, tab.id)
894
+ if (leaf !== undefined) {
895
+ splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
896
+ }
897
+ if (floats.some(float => float.tab.id === tab.id)) {
898
+ floats = floats.filter(float => float.tab.id !== tab.id)
899
+ }
900
+ }
901
+ // Add tabs for new uuids (the agent created a terminal). They land in the
902
+ // active pane via openTabInActivePane; the next reconcile is a no-op for them.
903
+ let next: SidebarState = { ...state, splits, floats }
904
+ for (const terminal of toAdd) {
905
+ const tab: SidebarTab = {
906
+ id: agentTabId(terminal.uuid),
907
+ type: 'terminal',
908
+ title: terminal.title,
909
+ }
910
+ next = openTabInActivePane(next, tab)
911
+ }
912
+ return next
913
+ }
914
+
915
+ // ── The per-session store ──────────────────────────────────────────────────
916
+
917
+ const STORAGE_PREFIX = 'dsh-sidebar:v1'
918
+
919
+ /**
920
+ * Cross-session panel width: the last dragged width, shared by EVERY
921
+ * conversation (the panel width is a layout preference, not per-session
922
+ * content). Written on every persist, read at session load and on
923
+ * cache-hit session switches, so a drag in one conversation carries to all
924
+ * the others (last drag wins).
925
+ */
926
+ const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width'
927
+
928
+ /** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */
929
+ function clampWidth(width: number): number {
930
+ const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
931
+ return Math.min(max, Math.max(PANEL_MIN, Math.round(width)))
932
+ }
933
+
934
+ /** Read the cross-session panel width (undefined when never dragged). */
935
+ function readGlobalWidth(): number | undefined {
936
+ try {
937
+ const raw = localStorage.getItem(GLOBAL_WIDTH_KEY)
938
+ if (raw !== null) {
939
+ const parsed = Number(raw)
940
+ if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed)
941
+ }
942
+ } catch {
943
+ // Storage unavailable: fall back to the per-session behavior.
944
+ }
945
+ return undefined
946
+ }
947
+
948
+ /** Persist the cross-session panel width (best-effort, like the session states). */
949
+ function writeGlobalWidth(width: number): void {
950
+ try {
951
+ localStorage.setItem(GLOBAL_WIDTH_KEY, String(width))
952
+ } catch {
953
+ // Storage full or unavailable: layout memory is best-effort.
954
+ }
955
+ }
956
+
957
+ /** Immutable snapshot handed to React (replaced only on real changes). */
958
+ export interface SidebarSnapshot {
959
+ sessionId: string | undefined
960
+ state: SidebarState | undefined
961
+ /**
962
+ * The current side card prefs. Carried IN the snapshot (not a separate
963
+ * subscription) so prefs changes re-render the consumers that gate on
964
+ * them — the + menu hides a tab type the moment its switch flips.
965
+ */
966
+ prefs: SidebarPrefs
967
+ }
968
+
969
+ /** Default panel width for one viewport: the prefs percent of the window,
970
+ * clamped to the panel floor (a tiny percent must stay usable) and to the
971
+ * viewport (a large one must never cover the whole window). */
972
+ export function defaultWidthFor(viewport: number, percent: number): number {
973
+ return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
974
+ }
975
+
976
+ /**
977
+ * URL escape hatch (#369): loading the app with `?dsh-sidebar-reset` drops
978
+ * the persisted layout for the session instead of restoring it. When a
979
+ * restored tab hangs the page on mount (the #369 freeze loop), reloading
980
+ * into the same state replays the hang forever; this param starts from the
981
+ * default layout and clears the stored copy, breaking the loop. Persisting
982
+ * resumes as soon as the param is gone from the URL.
983
+ */
984
+ const RESET_PARAM = 'dsh-sidebar-reset'
985
+
986
+ /** Whether the current page load asked for a persisted-state reset. */
987
+ function resetRequested(): boolean {
988
+ try {
989
+ return new URLSearchParams(window.location.search).has(RESET_PARAM)
990
+ } catch {
991
+ return false
992
+ }
993
+ }
994
+
995
+ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
996
+ const reset = resetRequested()
997
+ if (reset) {
998
+ try {
999
+ localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
1000
+ localStorage.removeItem(GLOBAL_WIDTH_KEY)
1001
+ } catch {
1002
+ // Storage unavailable: the default layout below is still the escape.
1003
+ }
1004
+ }
1005
+ // The panel width is a cross-session preference: the last dragged width
1006
+ // wins over a session's own persisted value, so switching conversations
1007
+ // keeps the width the user chose anywhere.
1008
+ const globalWidth = reset ? undefined : readGlobalWidth()
1009
+ if (!reset) {
1010
+ try {
1011
+ const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
1012
+ if (raw !== null) {
1013
+ const parsed = JSON.parse(raw) as unknown
1014
+ // Seed the uid counter past the persisted ids (it resets on reload);
1015
+ // sanitize re-ids any duplicates the pre-seeding counter left behind.
1016
+ nextIdCounter = maxCounterId(parsed)
1017
+ const sanitized = sanitizeState(parsed)
1018
+ if (sanitized !== undefined) {
1019
+ return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
1020
+ }
1021
+ }
1022
+ } catch {
1023
+ // Corrupt or unavailable storage: fall through to the default.
1024
+ }
1025
+ }
1026
+ // New sessions seed from the user's side card prefs: the width is the
1027
+ // chosen percent of the window (clamped to the panel floor and the
1028
+ // viewport so a huge percent can never crush the app shell), the panel
1029
+ // starts open only when the preference says so, and the seed tab is the
1030
+ // empty files window (tree panel open) in BOTH editorExplorer modes — a
1031
+ // disabled editor type seeds nothing. On a NARROW viewport a brand-new
1032
+ // session starts collapsed instead — the panel is a full-screen drawer
1033
+ // there, and auto-opening it on first paint would cover the conversation
1034
+ // before the user asked. Only the first seeding is affected: once the
1035
+ // user expands the drawer, `panelOpen: true` persists like any other
1036
+ // state.
1037
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
1038
+ const width = globalWidth ?? (viewport === undefined
1039
+ ? PANEL_DEFAULT
1040
+ : defaultWidthFor(viewport, prefs.defaultWidthPercent))
1041
+ const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
1042
+ const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
1043
+ return makeDefaultState(width, openByDefault, seed)
1044
+ }
1045
+
1046
+ /**
1047
+ * Structural validation of one persisted state. A malformed or stale shape
1048
+ * (older layouts, hand-edited storage) must fall back to the default instead
1049
+ * of crashing the panel on every reload; the restored width is also clamped
1050
+ * to the current viewport so a stale fullscreen width can never crush the
1051
+ * app shell (margin-right larger than the window) or cover the whole screen.
1052
+ * @returns a clean state, or undefined to fall back to the default.
1053
+ */
1054
+ export function sanitizeState(parsed: unknown): SidebarState | undefined {
1055
+ if (parsed === null || typeof parsed !== 'object') return undefined
1056
+ const record = parsed as Record<string, unknown>
1057
+ if (typeof record.panelOpen !== 'boolean') return undefined
1058
+ if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
1059
+ if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
1060
+ return undefined
1061
+ }
1062
+ // nextBrowser arrived in a later build; a missing or malformed value on an
1063
+ // OLDER persisted state defaults to 1 so existing layouts keep loading
1064
+ // (unlike nextTerminal, which is strict — it predates the v1 shape).
1065
+ const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
1066
+ ? record.nextBrowser
1067
+ : 1
1068
+ if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
1069
+ if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
1070
+ // The seen/reid maps are SHARED across the right tree and the legacy
1071
+ // bottom tree: pane/split ids must be globally unique (the runtime uid
1072
+ // counter is shared too), so a duplicate seen first in the right tree
1073
+ // gets a fresh id when it reappears in the legacy bottom tree.
1074
+ const seen = new Set<string>()
1075
+ const reid = new Map<string, string>()
1076
+ const restoredSplits = sanitizeNode(record.splits, seen, reid)
1077
+ if (restoredSplits === undefined) return undefined
1078
+ const splits = pruneEmptyPanes(restoredSplits)
1079
+ // LEGACY MIGRATION (v1.0.0 removed the bottom panel): an older persisted
1080
+ // state may carry a `bottomSplits` tree with open tabs. Sanitize it with
1081
+ // the SAME seen/reid maps (ids stay globally unique), then its tabs are
1082
+ // thrown into the right tree's first leaf below — the old narrow-viewport
1083
+ // migration semantics, so the upgrade never swallows a user's tabs. The
1084
+ // tree itself (empty panes and all) is discarded.
1085
+ const legacyBottomSplits = pruneEmptyPanes(
1086
+ sanitizeNode(record.bottomSplits, seen, reid)
1087
+ ?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null },
1088
+ )
1089
+ // Free windows arrived with the float feature: a missing field on an older
1090
+ // persisted state defaults to none (like the legacy bottom fields above).
1091
+ // Malformed ENTRIES drop individually — unlike the split trees, where
1092
+ // corruption resets the whole state, a bad window must not cost the user
1093
+ // their whole layout. Geometry clamps into the CURRENT viewport so a stale
1094
+ // off-screen window stays reachable; the window ids join the same
1095
+ // uniqueness set as the pane/split ids (the uid counter mints for both).
1096
+ const floats: FloatWindow[] = []
1097
+ if (Array.isArray(record.floats)) {
1098
+ for (const entry of record.floats) {
1099
+ if (entry === null || typeof entry !== 'object') continue
1100
+ const candidate = entry as Record<string, unknown>
1101
+ if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
1102
+ const tab = sanitizePersistedTab(candidate.tab)
1103
+ if (tab === undefined || tab === 'diff') continue
1104
+ if (
1105
+ typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
1106
+ || typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
1107
+ || typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
1108
+ || typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
1109
+ ) continue
1110
+ seen.add(candidate.id)
1111
+ floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
1112
+ }
1113
+ }
1114
+ const requestedActivePane = typeof record.activePane === 'string'
1115
+ ? (reid.get(record.activePane) ?? record.activePane)
1116
+ : null
1117
+ const activePane = requestedActivePane === null
1118
+ ? null
1119
+ : treeHasId(splits, requestedActivePane)
1120
+ ? requestedActivePane
1121
+ : firstLeaf(splits).id
1122
+ const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
1123
+ // Throw the legacy bottom tree's tabs into the right tree's FIRST leaf
1124
+ // (depth-first order), matching the old narrow-viewport migration; the
1125
+ // active pane follows so the migrated tabs are what the user sees.
1126
+ const legacyTabs = allLeaves(legacyBottomSplits).flatMap(leaf => leaf.tabs)
1127
+ const migratedSplits = legacyTabs.length > 0
1128
+ ? mapLeaf(splits, firstLeaf(splits).id, (leaf) => {
1129
+ leaf.tabs = [...leaf.tabs, ...legacyTabs]
1130
+ })
1131
+ : splits
1132
+ return {
1133
+ panelOpen: record.panelOpen,
1134
+ width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
1135
+ // A stale duplicate pane id may have been re-ided; follow the rename so
1136
+ // new tabs still land in the pane the user was using. A legacy bottom
1137
+ // activePane never survives the migration (its pane is gone) and falls
1138
+ // back to the right tree's first leaf — where the tabs just landed.
1139
+ activePane,
1140
+ nextTerminal: record.nextTerminal,
1141
+ nextBrowser,
1142
+ expanded: record.expanded as string[],
1143
+ revealed: [],
1144
+ splits: migratedSplits,
1145
+ floats,
1146
+ }
1147
+ }
1148
+
1149
+ /** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */
1150
+ function pruneEmptyPanes(node: SplitNode): SplitNode {
1151
+ const leaves = allLeaves(node)
1152
+ if (!leaves.some(leaf => leaf.tabs.length > 0)) return node
1153
+ return leaves.reduce(
1154
+ (tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree,
1155
+ node,
1156
+ )
1157
+ }
1158
+
1159
+ /**
1160
+ * One tree node id, deduplicated against the ids already seen in this
1161
+ * state. Duplicates are exactly the pre-seeding counter-reset corruption
1162
+ * (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
1163
+ * keeping both would make mapLeaf visit two leaves at once and every open
1164
+ * would land in both panes, so the repeat gets a fresh id.
1165
+ * @returns the id to use (the original, or a fresh uid for repeats).
1166
+ */
1167
+ function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
1168
+ if (!seen.has(id)) {
1169
+ seen.add(id)
1170
+ return id
1171
+ }
1172
+ const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
1173
+ const fresh = uid(prefix)
1174
+ seen.add(fresh)
1175
+ reid.set(id, fresh)
1176
+ return fresh
1177
+ }
1178
+
1179
+ /**
1180
+ * Validate one persisted tab record. @returns the clean tab, `'diff'` for an
1181
+ * ephemeral diff tab (dropped everywhere — diff tabs never survive a reload),
1182
+ * or undefined when the record is malformed (structural corruption when it
1183
+ * comes from a split-tree leaf; a malformed FLOAT tab only drops the window).
1184
+ */
1185
+ function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
1186
+ if (tab === null || typeof tab !== 'object') return undefined
1187
+ const candidate = tab as Record<string, unknown>
1188
+ if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
1189
+ if (candidate.type === 'diff') return 'diff'
1190
+ // Tab types are an open set (external plugins register their own); accept
1191
+ // any string type here — an unregistered type renders an <OrphanedTab/> at
1192
+ // view time and recovers if its plugin loads later.
1193
+ if (typeof candidate.type !== 'string') return undefined
1194
+ // The standalone explorer tab type merged INTO the editor (the single
1195
+ // files window): a persisted explorer tab reopens as an editor home tab —
1196
+ // no path, tree panel open (an existing meta object survives).
1197
+ if (candidate.type === 'explorer') {
1198
+ const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
1199
+ ? candidate.meta as Record<string, unknown>
1200
+ : undefined
1201
+ return { id: candidate.id, type: 'editor', title: 'Files', meta: { treeOpen: true, ...meta } }
1202
+ }
1203
+ // `meta` is plugin-owned JSON-serializable state (v0.12.0+): the persisted
1204
+ // value already went through JSON.parse, so it is inherently serializable —
1205
+ // carry it through verbatim (absent on older states).
1206
+ const result: SidebarTab = {
1207
+ id: candidate.id,
1208
+ type: candidate.type,
1209
+ title: candidate.title,
1210
+ ...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
1211
+ ...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
1212
+ }
1213
+ // `pin` (v0.17.0+): a pinned-terminal marker. Whitelist-validate the
1214
+ // shape so a hand-edited / corrupted pin never crashes the rail's
1215
+ // resolver: an unknown scope or a non-string homeCwd drops the pin
1216
+ // silently (the tab survives, just unpinned — the legacy behavior).
1217
+ // Pin is terminal-only: a non-terminal tab carrying a persisted pin
1218
+ // (e.g. from a hand-edited state) has it stripped here.
1219
+ const pin = (candidate as Record<string, unknown>).pin
1220
+ if (pin !== null && typeof pin === 'object' && !Array.isArray(pin) && result.type === 'terminal') {
1221
+ const pinRecord = pin as Record<string, unknown>
1222
+ if (pinRecord.scope === 'workspace' || pinRecord.scope === 'global') {
1223
+ const homeCwd = pinRecord.homeCwd
1224
+ result.pin = homeCwd === undefined || typeof homeCwd === 'string'
1225
+ ? { scope: pinRecord.scope, ...(typeof homeCwd === 'string' ? { homeCwd } : {}) }
1226
+ : { scope: pinRecord.scope }
1227
+ }
1228
+ }
1229
+ return result
1230
+ }
1231
+
1232
+ /** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
1233
+ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
1234
+ if (node === null || typeof node !== 'object') return undefined
1235
+ const record = node as Record<string, unknown>
1236
+ if (record.kind === 'leaf') {
1237
+ if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
1238
+ const tabs: SidebarTab[] = []
1239
+ let droppedDiff = false
1240
+ for (const tab of record.tabs) {
1241
+ const clean = sanitizePersistedTab(tab)
1242
+ if (clean === undefined) return undefined
1243
+ if (clean === 'diff') {
1244
+ droppedDiff = true
1245
+ continue
1246
+ }
1247
+ tabs.push(clean)
1248
+ }
1249
+ const active = typeof record.active === 'string' ? record.active : null
1250
+ // An active pointer into a dropped diff tab is expected after the drop;
1251
+ // any other missing active is structural corruption → reset the state.
1252
+ if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
1253
+ return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
1254
+ }
1255
+ if (record.kind === 'split') {
1256
+ if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
1257
+ if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
1258
+ const children: SplitNode[] = []
1259
+ for (const child of record.children) {
1260
+ const clean = sanitizeNode(child, seen, reid)
1261
+ if (clean === undefined) return undefined
1262
+ children.push(clean)
1263
+ }
1264
+ if (children.length < 2) return undefined
1265
+ if (
1266
+ record.sizes.length !== children.length
1267
+ || record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
1268
+ ) {
1269
+ return undefined
1270
+ }
1271
+ return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
1272
+ }
1273
+ return undefined
1274
+ }
1275
+
1276
+ /** The session-scoped store: one state per conversation, localStorage-backed. */
1277
+ export class SidebarStore {
1278
+ private readonly bySession = new Map<string, SidebarState>()
1279
+ private snapshot: SidebarSnapshot = {
1280
+ sessionId: undefined,
1281
+ state: undefined,
1282
+ prefs: { ...SIDEBAR_PREFS_DEFAULTS },
1283
+ }
1284
+ private readonly listeners = new Set<() => void>()
1285
+ /** Per-session persist debounce timers (v0.12.0+: one per session, so a
1286
+ * targeted open never cancels another session's pending write). */
1287
+ private readonly persistTimers = new Map<string, number>()
1288
+ /** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
1289
+ private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
1290
+ /**
1291
+ * External disable (the dsh-web-ui family's aionui-panel provider choice):
1292
+ * while true the sidebar must not mount at all. Not part of the snapshot —
1293
+ * nothing renders on it; the mount gate and the intercept predicates read
1294
+ * it directly.
1295
+ */
1296
+ private suspended = false
1297
+
1298
+ /**
1299
+ * Set the external-disable flag (from the settings route) and remember it
1300
+ * for the mount gate and the intercept predicates.
1301
+ */
1302
+ setSuspended(suspended: boolean): void {
1303
+ this.suspended = suspended
1304
+ }
1305
+
1306
+ /** Whether the sidebar is externally disabled (aionui-panel chosen). */
1307
+ getSuspended(): boolean {
1308
+ return this.suspended
1309
+ }
1310
+
1311
+ /**
1312
+ * Replace the side card prefs (the settings RPC result / settings page
1313
+ * write). Notifies like any store change: the snapshot carries the prefs,
1314
+ * so consumers that gate on enable switches (the + menu, derived flows)
1315
+ * re-render with the new values immediately.
1316
+ */
1317
+ setPrefs(prefs: SidebarPrefs): void {
1318
+ this.prefs = { ...prefs }
1319
+ this.snapshot = { ...this.snapshot, prefs: this.prefs }
1320
+ this.notify()
1321
+ }
1322
+
1323
+ /** The current side card prefs (seeds new sessions; persisted states win). */
1324
+ getPrefs(): SidebarPrefs {
1325
+ return { ...this.prefs }
1326
+ }
1327
+
1328
+ /** Select a session (or none); loads its persisted state. */
1329
+ setSession(sessionId: string | undefined): void {
1330
+ if (this.snapshot.sessionId === sessionId) return
1331
+ if (sessionId === undefined) {
1332
+ this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
1333
+ } else {
1334
+ let state = this.bySession.get(sessionId)
1335
+ if (state === undefined) {
1336
+ state = loadState(sessionId, this.prefs)
1337
+ this.bySession.set(sessionId, state)
1338
+ } else {
1339
+ // Cache hit: another session's load/ops may have left the uid
1340
+ // counter below THIS session's persisted ids — re-seed so fresh
1341
+ // pane/split ids can never collide with its tree.
1342
+ nextIdCounter = maxCounterId(state)
1343
+ // The panel width is cross-session: adopt the latest dragged width
1344
+ // (a cached session keeps its own layout otherwise).
1345
+ const globalWidth = readGlobalWidth()
1346
+ if (globalWidth !== undefined && state.width !== globalWidth) {
1347
+ state = { ...state, width: globalWidth }
1348
+ this.bySession.set(sessionId, state)
1349
+ }
1350
+ }
1351
+ this.snapshot = { sessionId, state, prefs: this.prefs }
1352
+ }
1353
+ this.notify()
1354
+ }
1355
+
1356
+ subscribe(listener: () => void): () => void {
1357
+ this.listeners.add(listener)
1358
+ return () => { this.listeners.delete(listener) }
1359
+ }
1360
+
1361
+ getSnapshot(): SidebarSnapshot {
1362
+ return this.snapshot
1363
+ }
1364
+
1365
+ /** Mutate the current session's state (no-op without a session). */
1366
+ update(mutator: (draft: SidebarState) => void): void {
1367
+ const sessionId = this.snapshot.sessionId
1368
+ const state = this.snapshot.state
1369
+ if (sessionId === undefined || state === undefined) return
1370
+ const draft = structuredClone(state)
1371
+ mutator(draft)
1372
+ this.bySession.set(sessionId, draft)
1373
+ this.snapshot = { sessionId, state: draft, prefs: this.prefs }
1374
+ this.schedulePersist(sessionId, draft)
1375
+ this.notify()
1376
+ }
1377
+
1378
+ /**
1379
+ * Whether a tab still exists in its session's state. Views use this on
1380
+ * unmount to tell "the tab was closed" (release the terminal now) from
1381
+ * "the tree re-rendered / the conversation switched" (the tab is still
1382
+ * open — keep the terminal alive through the host's reconnect grace).
1383
+ * Checks the session's own map entry (the current snapshot may already
1384
+ * point at another session when a conversation switch unmounts the old
1385
+ * one's tabs).
1386
+ */
1387
+ tabOpen(sessionId: string, tabId: string): boolean {
1388
+ const state = this.bySession.get(sessionId)
1389
+ ?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
1390
+ return state !== undefined && tabOpenIn(state, tabId)
1391
+ }
1392
+
1393
+ /**
1394
+ * Read-only view of EVERY cached session's state (v0.17.0+). The
1395
+ * PinnedRail uses this to collect pinned terminals across sessions
1396
+ * without each render reading private fields. The map is the live
1397
+ * `bySession` reference — callers MUST treat it as read-only (mutations
1398
+ * go through {@link reduce} / {@link reduceFor}). A session that has
1399
+ * never been visited in this run is absent (its pinned tabs are not
1400
+ * visible until first load — accepted as YAGNI by the design).
1401
+ */
1402
+ getSessionStates(): ReadonlyMap<string, SidebarState> {
1403
+ return new Map(this.bySession)
1404
+ }
1405
+
1406
+ /** Apply a pure reducer (returns the next state). */
1407
+ reduce(reducer: (state: SidebarState) => SidebarState): void {
1408
+ const sessionId = this.snapshot.sessionId
1409
+ const state = this.snapshot.state
1410
+ if (sessionId === undefined || state === undefined) return
1411
+ const next = reducer(state)
1412
+ // A reducer returning the SAME reference means "no change": skip the
1413
+ // persist + notify entirely — strict no-op paths (unknown tab ids,
1414
+ // patchTab on a missing tab) must not churn the state or rewrite
1415
+ // localStorage.
1416
+ if (next === state) return
1417
+ this.bySession.set(sessionId, next)
1418
+ this.snapshot = { sessionId, state: next, prefs: this.prefs }
1419
+ this.schedulePersist(sessionId, next)
1420
+ this.notify()
1421
+ }
1422
+
1423
+ /**
1424
+ * Apply a pure reducer to a TARGET session's state (not the active one),
1425
+ * loading it on demand and persisting the result — WITHOUT switching the
1426
+ * active snapshot or notifying (the UI must not follow along). Used by the
1427
+ * service's targeted `openTab(seed, scope)`: the open lands in the target
1428
+ * session's layout and is visible whenever the user switches to it.
1429
+ */
1430
+ reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
1431
+ // The uid counter is SHARED across sessions, and the ACTIVE session's
1432
+ // safety requires it to never drop below the ids IT minted. Seeding it
1433
+ // from the target's max may LOWER it (a cached target older than the
1434
+ // active session): restoring the pre-call level afterwards keeps the
1435
+ // active session's next mint collision-free — ids minted for the target
1436
+ // only need to exceed the target's own max, which the seed guaranteed.
1437
+ const counterBefore = nextIdCounter
1438
+ let state = this.bySession.get(sessionId)
1439
+ if (state === undefined) {
1440
+ state = loadState(sessionId, this.prefs)
1441
+ this.bySession.set(sessionId, state)
1442
+ } else {
1443
+ // Re-seed the uid counter past THIS session's persisted ids, exactly
1444
+ // like setSession's cache-hit path.
1445
+ nextIdCounter = maxCounterId(state)
1446
+ }
1447
+ const next = reducer(state)
1448
+ // Same-reference result = no change: keep the counter restore (it may
1449
+ // have been seeded down) but skip the write.
1450
+ nextIdCounter = Math.max(nextIdCounter, counterBefore)
1451
+ if (next === state) return
1452
+ this.bySession.set(sessionId, next)
1453
+ this.schedulePersist(sessionId, next)
1454
+ }
1455
+
1456
+ private schedulePersist(sessionId: string, state: SidebarState): void {
1457
+ // Keep the cross-session width in sync: any width change (drag, fullscreen
1458
+ // toggle) on the ACTIVE session becomes the shared width for every
1459
+ // conversation. A targeted open persists an INACTIVE session (reduceFor)
1460
+ // and must not clobber that global — its width is stale by definition,
1461
+ // so writing it would break "last drag wins".
1462
+ if (sessionId === this.snapshot.sessionId) {
1463
+ writeGlobalWidth(state.width)
1464
+ }
1465
+ // Per-session debounce timers: one session's pending write must never
1466
+ // cancel another's (targeted opens schedule writes for INACTIVE
1467
+ // sessions while the active session may already have one pending —
1468
+ // a shared timer would drop the earlier write and the reload would
1469
+ // lose that session's layout).
1470
+ const existing = this.persistTimers.get(sessionId)
1471
+ if (existing !== undefined) window.clearTimeout(existing)
1472
+ const timer = window.setTimeout(() => {
1473
+ this.persistTimers.delete(sessionId)
1474
+ try {
1475
+ localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
1476
+ } catch {
1477
+ // Storage full or unavailable: layout memory is best-effort.
1478
+ }
1479
+ }, 200)
1480
+ this.persistTimers.set(sessionId, timer)
1481
+ }
1482
+
1483
+ private notify(): void {
1484
+ for (const listener of [...this.listeners]) listener()
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Create one sidebar store instance. Production code calls this only from
1490
+ * the client plugin's `apply` (the instance is handed to components as a
1491
+ * prop); tests call it directly. No module-level singleton: the store's
1492
+ * lifetime belongs to the plugin activation, exactly like the official
1493
+ * `createXXXStore()` factory rule.
1494
+ */
1495
+ export function createSidebarStore(): SidebarStore {
1496
+ return new SidebarStore()
1497
+ }