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,859 @@
1
+ /**
2
+ * The BetterSidebar client service: a registry that external plugins use
3
+ * to contribute sidebar tab types and file previewers. The service is
4
+ * published to the cordis context as `ctx.betterSidebar` (see
5
+ * {@link ../context-types.ts}); consumers declare it in `inject` and call
6
+ * `registerTab` / `registerFileViewer`, both returning a disposer that
7
+ * cordis auto-invokes on fiber disposal (HMR-safe).
8
+ *
9
+ * Design notes:
10
+ * - The registry is synchronous-snapshot (Map + listener set) so React
11
+ * can read it through `useSyncExternalStore` without tearing.
12
+ * - `dedupeKey` unifies the three open-tab strategies the builtins used to
13
+ * hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
14
+ * and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
15
+ * `single: true` is sugar for `dedupeKey: () => id`.
16
+ * - `createTab` lets a descriptor own tab instantiation (the terminal
17
+ * builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
18
+ * - `matchFileViewer` walks descriptors in priority order (desc, stable):
19
+ * per descriptor it tries `detect` first (when `head` bytes are given),
20
+ * then `exts`; `exts: []` is a catch-all that matches any path.
21
+ */
22
+ import type { ReactNode } from 'react'
23
+ import type { Context } from '../context-types.ts'
24
+ import {
25
+ activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
26
+ leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
27
+ type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
28
+ } from './state.ts'
29
+ import type { SessionScope } from './api.ts'
30
+ import type { SidebarPrefs } from '../prefs-shared.ts'
31
+
32
+ /**
33
+ * Public state vocabulary re-exported for consumers (type-only; the values
34
+ * stay internal). External plugins name these types in their descriptors —
35
+ * e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
36
+ * or `badge: (…, state: SidebarState) => …`.
37
+ */
38
+ export type {
39
+ SidebarTab,
40
+ SidebarState,
41
+ SidebarStore,
42
+ SidebarSnapshot,
43
+ SidebarDiffRef,
44
+ TabType,
45
+ } from './state.ts'
46
+ export type { SessionScope } from './api.ts'
47
+ export type { SidebarPrefs } from '../prefs-shared.ts'
48
+
49
+ /** The row control a declarative setting renders as in the settings popup. */
50
+ export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select'
51
+
52
+ /** One option of a `type: 'select'` setting row. */
53
+ export interface SidebarSettingSelectOption {
54
+ /** The value written to the setting key when this option is picked
55
+ * (JSON-serializable: string / number / boolean). */
56
+ value: string | number | boolean
57
+ /** Option title (i18n friendly: string or () => string). */
58
+ title: string | (() => string)
59
+ /** Option description (i18n friendly); rendered under the title in the
60
+ * icon dropdown. */
61
+ desc?: string | (() => string)
62
+ /** Option icon: when ANY option declares one, the dropdown renders
63
+ * big-icon option cards and the closed control shows the selected
64
+ * option's icon too; without icons both are a single line of text. */
65
+ icon?: ReactNode | ((size: number) => ReactNode)
66
+ }
67
+
68
+ /** One declarative setting of a tab/viewer, rendered as a nested row in the
69
+ * Side card settings page (e.g. the Subagent page's "auto-open when a
70
+ * subagent appears" switch, or the terminal's custom font rows). `type`
71
+ * selects the control: 'switch' (default) renders the custom switch,
72
+ * 'text' a free-form input committed on blur/Enter, 'number' a numeric
73
+ * input clamped to `min`/`max`, 'select' a dropdown over the declared
74
+ * `options` (single-pick writes the option's value; `multi: true` writes
75
+ * the array of picked values and defaults to false). */
76
+ export interface SidebarSettingToggle {
77
+ /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
78
+ key: string
79
+ /** Row title (i18n friendly: string or () => string). */
80
+ title: string | (() => string)
81
+ /** Row description (i18n friendly). */
82
+ desc?: string | (() => string)
83
+ /** Row control type; defaults to 'switch' (backward compatible). */
84
+ type?: SidebarSettingToggleType
85
+ /** Lower bound for `type: 'number'` rows (clamped on commit). */
86
+ min?: number
87
+ /** Upper bound for `type: 'number'` rows (clamped on commit). */
88
+ max?: number
89
+ /** Input placeholder for `type: 'text'` rows. */
90
+ placeholder?: string
91
+ /** Unit suffix rendered after the input (e.g. 'px' for a size row). */
92
+ unit?: string
93
+ /** Options of a `type: 'select'` row. */
94
+ options?: readonly SidebarSettingSelectOption[]
95
+ /** Whether a `type: 'select'` row allows picking several options (the
96
+ * stored value is then an array of option values); defaults to false. */
97
+ multi?: boolean
98
+ }
99
+
100
+ /** Props of a descriptor's custom settings panel (`settings.render`). */
101
+ export interface SidebarSettingsRenderProps {
102
+ store: SidebarStore
103
+ service: BetterSidebarService
104
+ prefs: SidebarPrefs
105
+ /** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
106
+ pluginSettings: Record<string, unknown>
107
+ /** Persist one plugin-owned setting of this descriptor. */
108
+ updatePluginSetting(key: string, value: unknown): void
109
+ /** Close the settings popup. */
110
+ close(): void
111
+ }
112
+
113
+ /** Declarative settings of one registered tab or file viewer. */
114
+ export interface SidebarSettingsDeclaration {
115
+ /**
116
+ * Extra settings rows rendered under the feature's own row in the
117
+ * settings page (only while the feature is enabled). Keys must be fields
118
+ * of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
119
+ * 'agentTerminalTools', 'agentOpenTools', 'terminalFontFamily'); unknown
120
+ * keys are dropped by the settings seam.
121
+ */
122
+ toggles?: readonly SidebarSettingToggle[]
123
+ /**
124
+ * Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
125
+ * (switch/text/number), but the keys are plugin-local and persisted in
126
+ * the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
127
+ * — no host PrefsSchema field needed. Values must be JSON-serializable
128
+ * (the row controls produce strings / numbers / booleans).
129
+ */
130
+ pluginToggles?: readonly SidebarSettingToggle[]
131
+ /**
132
+ * Custom settings panel (v0.12.0+): when given, the gear popup renders
133
+ * this instead of the row lists (`toggles` / `pluginToggles`). Receives
134
+ * the shared store/service, the live prefs, the descriptor's own
135
+ * `pluginSettings` blob, and a persistence helper.
136
+ */
137
+ render?: (props: SidebarSettingsRenderProps) => ReactNode
138
+ }
139
+
140
+ /** Props every tab component receives (builtins and external alike). */
141
+ export interface TabComponentProps {
142
+ ctx: Context
143
+ store: SidebarStore
144
+ scope: SessionScope
145
+ tab: SidebarTab
146
+ /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
147
+ visible: boolean
148
+ /** The explorer's expanded directory set (ExplorerView). */
149
+ expanded?: string[]
150
+ /** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
151
+ revealed?: string[]
152
+ onToggleDir?: (path: string) => void
153
+ onReferenceFile?: (path: string) => void
154
+ onOpenFile?: (path: string) => void
155
+ onOpenDiff?: (tab: SidebarTab) => void
156
+ onSubagentJump?: (childSessionId: string) => void
157
+ }
158
+
159
+ /** Describes one kind of sidebar tab (builtins register themselves too). */
160
+ export interface TabDescriptor {
161
+ /** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
162
+ id: string
163
+ title: string | (() => string)
164
+ icon?: ReactNode | ((size: number) => ReactNode)
165
+ /** + menu sort order (ascending); default 100. */
166
+ order?: number
167
+ /** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
168
+ hidden?: boolean
169
+ /**
170
+ * + menu disabled predicate (e.g. terminal at capacity). Receives the
171
+ * session scope and the live sidebar state (counts, expansions).
172
+ */
173
+ available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
174
+ /**
175
+ * Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
176
+ * (opening the tab focuses an existing one of the same type instead of
177
+ * creating a duplicate). An explicit `dedupeKey` always wins when both
178
+ * are given. Builtins: explorer/git/subagent use `single: true`.
179
+ */
180
+ single?: boolean
181
+ /**
182
+ * If provided, opening a tab whose `dedupeKey(tab)` matches an existing
183
+ * tab's key focuses the existing one instead of creating a new one.
184
+ * Returning `undefined` means "no dedup — always open a new tab".
185
+ * Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
186
+ * (openDiffTab mints change-derived ids).
187
+ */
188
+ dedupeKey?: (tab: SidebarTab) => string | undefined
189
+ /**
190
+ * Custom tab creation (minting the `SidebarTab` and any state patches).
191
+ * Return `null` to refuse creation. The terminal builtin uses this to
192
+ * mint `terminal:<n>` ids and bump `nextTerminal`.
193
+ * When omitted, a default `{ id, type, title }` tab is created.
194
+ */
195
+ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
196
+ /**
197
+ * External-link target claim (v0.13.0+): when a GUI external-link click
198
+ * is taken over (the `browserInterceptLinks` master AND the URL's
199
+ * protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
200
+ * are on), the first registered tab whose `urlTarget(url)` returns true
201
+ * is opened with `openTab({ type, url, title: hostname })` — the URL is
202
+ * the whole payload (the tab reads it from `tab.path`). Registration
203
+ * order wins (first claim first served); a disabled tab type is skipped;
204
+ * a throwing predicate is swallowed (console.error, the type is skipped).
205
+ * The built-in browser tab declares NO urlTarget — it stays the implicit
206
+ * fallback target, so plugins can never be shadowed by it. To host more
207
+ * than one URL at a time, mint per-URL ids through `createTab` (the
208
+ * browser builtin's pattern); otherwise the id safety net focuses the
209
+ * existing tab of the same type and the new URL is not applied.
210
+ */
211
+ urlTarget?: (url: URL) => boolean
212
+ /**
213
+ * Declarative settings shown in the Side card settings page: every
214
+ * registered tab gets an enable/disable switch (icon + title + id), and
215
+ * `settings.toggles` adds nested switches tied to SidebarPrefs fields
216
+ * (e.g. the subagent tab's 'autoOpenSubagent').
217
+ */
218
+ settings?: SidebarSettingsDeclaration
219
+ /**
220
+ * Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
221
+ * the icon — a number renders as a count (99+ capped), a string renders
222
+ * as-is, null/undefined hides the badge. Called on every tab-bar render,
223
+ * so keep it cheap; a throw is swallowed (no badge shown).
224
+ */
225
+ badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
226
+ /**
227
+ * Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
228
+ * `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
229
+ * focus is NOT an open — it fires `onActivate` instead), `onActivate`
230
+ * when a tab is focused (dedupe focus, id-safety-net focus, or the
231
+ * tab-bar activation), `onClose` when a tab is closed through
232
+ * `closeTab`. Builtin-only flows that mutate state directly (the diff
233
+ * split placement, agent-terminal reconcile) never touch external tabs
234
+ * and fire no callbacks. A throwing callback is logged and never breaks
235
+ * the open/close/activate flow.
236
+ */
237
+ onOpen?: (tab: SidebarTab, scope: SessionScope) => void
238
+ onActivate?: (tab: SidebarTab, scope: SessionScope) => void
239
+ onClose?: (tab: SidebarTab, scope: SessionScope) => void
240
+ component: (props: TabComponentProps) => ReactNode
241
+ }
242
+
243
+ /** How the host loads a file's bytes for one viewer. */
244
+ export type FileFetchStrategy =
245
+ | 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
246
+ | 'fsRead' // text read through /sidebar/api fs.read
247
+ | 'mediaUrl' // the viewer gets a media URL string
248
+ | 'custom' // the viewer's load() fetches its own bytes
249
+ | 'binary-download' // show a download button (no client-side renderer)
250
+
251
+ /** Props every file viewer component receives. */
252
+ export interface FileViewerProps {
253
+ ctx: Context
254
+ store: SidebarStore
255
+ scope: SessionScope
256
+ path: string
257
+ title: string
258
+ /** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
259
+ viewerId: string
260
+ /** fsRead text content (fetchStrategy='fsRead'). */
261
+ content?: string
262
+ truncated?: boolean
263
+ /** mediaUrl for the path (fetchStrategy='mediaUrl'). */
264
+ mediaUrl?: string
265
+ /** custom load() return value (fetchStrategy='custom'). */
266
+ customData?: unknown
267
+ /** Internal (built-in text editor): 'host' asks the viewer to skip its own
268
+ * toolbar row — the editor host's merged-mode header renders it instead,
269
+ * fed through the two callbacks below. Viewers that ignore these fields
270
+ * render exactly as before. */
271
+ toolbar?: 'self' | 'host'
272
+ /** Internal: the viewer reports its toolbar state (mode/dirty/save). */
273
+ onToolbarState?: (state: EditorToolbarState) => void
274
+ /** Internal: the viewer registers its toolbar commands on mount (null on
275
+ * unmount). */
276
+ onToolbarControls?: (controls: EditorToolbarControls | null) => void
277
+ }
278
+
279
+ /** The toolbar state a text editor reports to the host's merged-mode header. */
280
+ export interface EditorToolbarState {
281
+ /** Whether the preview/edit mode toggle applies (markdown/html). */
282
+ modes: boolean
283
+ mode: 'preview' | 'edit'
284
+ dirty: boolean
285
+ /** Whether saving applies (text content loaded). */
286
+ editable: boolean
287
+ saveState: 'idle' | 'saving' | 'saved' | 'failed'
288
+ }
289
+
290
+ /** The commands the host's merged-mode header sends back to the viewer. */
291
+ export interface EditorToolbarControls {
292
+ setMode(mode: 'preview' | 'edit'): void
293
+ save(): void
294
+ }
295
+
296
+ /** Describes one file previewer (builtins register themselves too). */
297
+ export interface FileViewerDescriptor {
298
+ /** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
299
+ id: string
300
+ /** Display name for the settings inventory (falls back to `id` when absent). */
301
+ title?: string | (() => string)
302
+ /** Icon shown in the settings inventory. */
303
+ icon?: ReactNode | ((size: number) => ReactNode)
304
+ /** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
305
+ exts: readonly string[]
306
+ /** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
307
+ priority?: number
308
+ fetchStrategy: FileFetchStrategy
309
+ /**
310
+ * Content sniff: when `head` bytes are available the descriptor's `detect`
311
+ * is consulted before its `exts` (per-descriptor, in priority order).
312
+ */
313
+ detect?: (path: string, head: Uint8Array) => boolean
314
+ /** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
315
+ * teardown / re-match; loaders that ignore it keep working. */
316
+ load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
317
+ /**
318
+ * Declarative settings shown in the Side card settings page: every
319
+ * registered viewer gets an enable/disable switch (icon + title + exts).
320
+ */
321
+ settings?: SidebarSettingsDeclaration
322
+ component: (props: FileViewerProps) => ReactNode
323
+ }
324
+
325
+ /** One `openTab` request. */
326
+ export interface OpenTabSeed {
327
+ type: string
328
+ /** Overrides the descriptor's title when given (the editor tab shows the file name). */
329
+ title?: string
330
+ /** A file path (the editor tab's content seed). */
331
+ path?: string
332
+ /** A diff reference (the diff tab's content seed). */
333
+ diff?: SidebarTab['diff']
334
+ /** Explicit tab id (defaults to the type). */
335
+ id?: string
336
+ /** A URL the tab navigates to on mount (the browser tab's seed). */
337
+ url?: string
338
+ /** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
339
+ meta?: unknown
340
+ }
341
+
342
+ /**
343
+ * The registry service published as `ctx.betterSidebar`.
344
+ */
345
+ export interface BetterSidebarService {
346
+ registerTab(descriptor: TabDescriptor): () => void
347
+ registerFileViewer(descriptor: FileViewerDescriptor): () => void
348
+ getTabs(): readonly TabDescriptor[]
349
+ getFileViewers(): readonly FileViewerDescriptor[]
350
+ /** Find a tab descriptor by id (undefined if not registered). */
351
+ getTab(id: string): TabDescriptor | undefined
352
+ /**
353
+ * Whether a tab type is enabled in the side card prefs. An absent
354
+ * `tabsEnabled[id]` entry means enabled — only an explicit `false`
355
+ * disables the type (hidden from the + menu, `openTab` refuses, and
356
+ * derived flows gate on it).
357
+ */
358
+ isTabEnabled(id: string): boolean
359
+ /** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
360
+ isViewerEnabled(id: string): boolean
361
+ /**
362
+ * Find a file viewer for a path (priority desc; detect first, then exts).
363
+ * Disabled viewers are skipped, so files fall through to the next match.
364
+ */
365
+ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
366
+ /**
367
+ * Open a tab (used by external tabs and the + menu). `title` overrides
368
+ * the descriptor's title when given (the editor tab shows the file name);
369
+ * when the descriptor provides `createTab` it mints the tab itself and
370
+ * `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
371
+ * pre-set to the URL (the browser tab's navigation seed; the caller
372
+ * usually pairs it with a hostname `title`). A disabled tab type is a
373
+ * no-op.
374
+ *
375
+ * `scope` (v0.12.0+) targets a specific session: when given, the open
376
+ * lands in THAT session's sidebar state (loading it if it has none yet)
377
+ * without switching the UI's active session; when absent the open lands
378
+ * in the currently active session (the pre-0.12 behavior).
379
+ *
380
+ * A CONTENT open (a `path` or `url` seed) must land in sight: when the
381
+ * panel is collapsed, it is expanded automatically. Type-only opens (the
382
+ * + menu, agent-terminal auto-tabs) never expand — the panel behavior is
383
+ * their caller's business.
384
+ *
385
+ * Note: `available` gates the + menu's disabled state only — it does NOT
386
+ * refuse `openTab` (only the settings disable switch does).
387
+ */
388
+ openTab(seed: OpenTabSeed, scope?: SessionScope): void
389
+ /**
390
+ * Close a tab by id (fires descriptor.onClose). An unknown tab id is a
391
+ * strict no-op (no state churn, no callbacks). `scope` (v0.12.0+) rides
392
+ * to the callback (its optional cwd included); absent, the callback gets
393
+ * `{ sessionId }` of the active session.
394
+ */
395
+ closeTab(tabId: string, scope?: SessionScope): void
396
+ /** Subscribe to registry changes (register/dispose). */
397
+ subscribe(listener: () => void): () => void
398
+ /** The plugin version this service instance was built from ('0.12.0'). */
399
+ readonly version: string
400
+ /**
401
+ * Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
402
+ * 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
403
+ * 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
404
+ * gate new API usage on membership.
405
+ */
406
+ readonly features: readonly string[]
407
+ /**
408
+ * The current sidebar snapshot: the active session id, its state (panel
409
+ * geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
410
+ * `state`/`sessionId` are undefined until a session becomes active.
411
+ */
412
+ getSnapshot(): SidebarSnapshot
413
+ /** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
414
+ subscribeState(listener: () => void): () => void
415
+ /** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
416
+ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
417
+ /**
418
+ * Activate an open tab (the tab-bar activation path; fires
419
+ * descriptor.onActivate). An unknown tab id is a strict no-op. `scope`
420
+ * (v0.12.0+) rides to the callback like `closeTab`'s.
421
+ */
422
+ activateTab(tabId: string, scope?: SessionScope): void
423
+ /** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
424
+ openFile(scope: SessionScope, path: string, title?: string): void
425
+ }
426
+
427
+ /** Extract the lowercase extension without leading dot from a path. */
428
+ function extOfPath(path: string): string {
429
+ const at = path.lastIndexOf('.')
430
+ if (at === -1) return ''
431
+ const base = path.slice(at + 1).toLowerCase()
432
+ return base.includes('/') || base.includes('\\') ? '' : base
433
+ }
434
+
435
+ /** The file name of a path (both separators). */
436
+ function baseNameOf(path: string): string {
437
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
438
+ return at === -1 ? path : path.slice(at + 1)
439
+ }
440
+
441
+ /**
442
+ * Find the tab type that claims an intercepted external-link URL (v0.13.0+).
443
+ * Walks the descriptors in REGISTRATION order and returns the first one
444
+ * that declares `urlTarget` and matches `url`; a throwing predicate is
445
+ * swallowed (console.error, type skipped) so one broken plugin can never
446
+ * break the whole link pipeline. The caller passes the ENABLED tab
447
+ * descriptors (enablement is the caller's prefs domain — filter
448
+ * `service.getTabs()` through `tabsEnabled` before matching) and falls
449
+ * back to the built-in browser tab when nothing claims the URL (the
450
+ * browser never declares `urlTarget` itself, so it can never shadow a
451
+ * plugin claim).
452
+ */
453
+ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
454
+ for (const tab of tabs) {
455
+ if (tab.urlTarget === undefined) continue
456
+ let claimed = false
457
+ try {
458
+ claimed = tab.urlTarget(url) === true
459
+ } catch (error) {
460
+ console.error('[dsh-coding-sidebar] urlTarget error:', error)
461
+ continue
462
+ }
463
+ if (claimed) return tab
464
+ }
465
+ return undefined
466
+ }
467
+
468
+ /**
469
+ * The plugin version this service instance reports. Injected at build time
470
+ * (tsdown `define`) from `package.json`'s `version` — the single source, so
471
+ * the constant can never drift from the published package again (the
472
+ * upstream 0.17.1-constant-on-a-0.17.2-package mismatch is structurally
473
+ * impossible now).
474
+ */
475
+ declare const __SIDEBAR_VERSION__: string
476
+ export const SIDEBAR_SERVICE_VERSION = __SIDEBAR_VERSION__
477
+
478
+ /**
479
+ * Monotonic capability list consumers use to gate new API usage (features
480
+ * are never removed). Each string names a v0.12.0+ capability:
481
+ * - 'badge': TabDescriptor.badge
482
+ * - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
483
+ * - 'updateTab': BetterSidebarService.updateTab
484
+ * - 'openFile': BetterSidebarService.openFile
485
+ * - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
486
+ * - 'stateSubscription': getSnapshot/subscribeState
487
+ * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
488
+ * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
489
+ * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
490
+ * - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
491
+ * - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
492
+ * id focus targets RAISE the floating window (never duplicate the tab or
493
+ * expand panels), closeTab on a floating tab closes it with its window.
494
+ */
495
+ export const SIDEBAR_FEATURES = [
496
+ 'badge',
497
+ 'tabLifecycle',
498
+ 'updateTab',
499
+ 'openFile',
500
+ 'targetedOpen',
501
+ 'stateSubscription',
502
+ 'tabMeta',
503
+ 'pluginSettings',
504
+ 'urlTarget',
505
+ 'settingSelect',
506
+ 'floatWindows',
507
+ ] as const
508
+
509
+ /** Run one plugin callback; a throw is logged and never breaks the caller. */
510
+ function safeCall(fn: () => void): void {
511
+ try {
512
+ fn()
513
+ } catch (error) {
514
+ console.error('[dsh-coding-sidebar] plugin callback error:', error)
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Create one BetterSidebar service bound to a store. The service owns the
520
+ * tab/viewer registries (Map + listener set) and proxies openTab/closeTab
521
+ * to the store's reducer. One instance per client plugin activation.
522
+ */
523
+ export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
524
+ const tabs = new Map<string, TabDescriptor>()
525
+ const viewers = new Map<string, FileViewerDescriptor>()
526
+ const listeners = new Set<() => void>()
527
+
528
+ const notify = (): void => {
529
+ for (const fn of [...listeners]) fn()
530
+ }
531
+
532
+ const subscribe = (listener: () => void): (() => void) => {
533
+ listeners.add(listener)
534
+ return () => { listeners.delete(listener) }
535
+ }
536
+
537
+ const registerTab = (descriptor: TabDescriptor): (() => void) => {
538
+ if (tabs.has(descriptor.id)) {
539
+ throw new Error(`[dsh-coding-sidebar] tab type "${descriptor.id}" already registered`)
540
+ }
541
+ tabs.set(descriptor.id, descriptor)
542
+ notify()
543
+ return () => {
544
+ if (tabs.get(descriptor.id) === descriptor) {
545
+ tabs.delete(descriptor.id)
546
+ notify()
547
+ }
548
+ }
549
+ }
550
+
551
+ const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
552
+ if (viewers.has(descriptor.id)) {
553
+ throw new Error(`[dsh-coding-sidebar] file viewer "${descriptor.id}" already registered`)
554
+ }
555
+ viewers.set(descriptor.id, descriptor)
556
+ notify()
557
+ return () => {
558
+ if (viewers.get(descriptor.id) === descriptor) {
559
+ viewers.delete(descriptor.id)
560
+ notify()
561
+ }
562
+ }
563
+ }
564
+
565
+ const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
566
+ const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
567
+ const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
568
+
569
+ // The enable switches come from the user's side card prefs (the shared
570
+ // store the service is bound to): an absent key means enabled.
571
+ const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
572
+ const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
573
+
574
+ const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
575
+ const ext = extOfPath(path)
576
+ // Single pass in priority order (descending; stable for equal
577
+ // priorities — insertion order). Each descriptor gets first refusal in
578
+ // its own turn: `detect` (when head bytes are available) beats its own
579
+ // `exts`, and `exts: []` is a catch-all matching any path — so the
580
+ // catch-all `code` viewer (-100) only sees paths no higher-priority
581
+ // descriptor claimed. Disabled viewers are skipped entirely.
582
+ for (const v of Array.from(viewers.values()).sort(
583
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
584
+ )) {
585
+ if (!isViewerEnabled(v.id)) continue
586
+ // Content sniff first (only when head bytes are available).
587
+ if (head !== undefined && v.detect !== undefined) {
588
+ if (v.detect(path, head)) return v
589
+ // A catch-all with detect is SNIFF-ONLY: it must not blind-claim
590
+ // paths it never sniffed (a magic-number viewer must not swallow
591
+ // every file before the real viewers get their turn).
592
+ if (v.exts.length === 0) continue
593
+ } else if (v.exts.length === 0) {
594
+ // Blind catch-all (no detect) claims anything; a sniff-only
595
+ // catch-all (detect defined, no head yet) yields this round.
596
+ if (v.detect === undefined) return v
597
+ continue
598
+ }
599
+ if (v.exts.includes(ext)) return v
600
+ }
601
+ return undefined
602
+ }
603
+
604
+ const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => {
605
+ // A type the user disabled in settings never opens — neither from the
606
+ // + menu nor from derived flows (file opens, subagent auto-open,
607
+ // external plugins). Already-open tabs keep rendering.
608
+ if (!isTabEnabled(seed.type)) {
609
+ console.warn(`[dsh-coding-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
610
+ return
611
+ }
612
+ const descriptor = tabs.get(seed.type)
613
+ if (descriptor === undefined) return
614
+ // A scope targets another session: the open lands in THAT session's
615
+ // state (loaded on demand) without switching the UI's active session.
616
+ const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
617
+ if (targetSessionId === undefined) return
618
+ const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
619
+ // Whether this open targets a session that is NOT the one on screen: a
620
+ // targeted open must not auto-expand panels the user cannot see (the
621
+ // expansion is about landing "in sight" for the CURRENT viewer).
622
+ const activeSessionId = store.getSnapshot().sessionId
623
+ const targetsInactiveSession = scope !== undefined && scope.sessionId !== activeSessionId
624
+ // Lifecycle capture: `created` when the open minted a NEW tab (a
625
+ // dedupe/id-safety-net focus is an ACTIVATION, not an open).
626
+ let created: SidebarTab | undefined
627
+ let activated: SidebarTab | undefined
628
+ const reducer = (state: SidebarState): SidebarState => {
629
+ // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
630
+ let tab: SidebarTab
631
+ let next: SidebarState
632
+ if (descriptor.createTab !== undefined) {
633
+ const result = descriptor.createTab(state)
634
+ if (result === null) return state
635
+ tab = result.tab
636
+ next = applyDedupe(state, result.tab, descriptor)
637
+ if (result.patch !== undefined) next = { ...next, ...result.patch }
638
+ } else {
639
+ tab = {
640
+ id: seed.id ?? seed.type,
641
+ type: seed.type,
642
+ // A caller-provided title wins (the editor shows the file name);
643
+ // otherwise the descriptor's (possibly i18n) title is the default.
644
+ title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
645
+ ...(seed.path !== undefined ? { path: seed.path } : {}),
646
+ ...(seed.diff !== undefined ? { diff: seed.diff } : {}),
647
+ ...(seed.meta !== undefined ? { meta: seed.meta } : {}),
648
+ }
649
+ next = applyDedupe(state, tab, descriptor)
650
+ }
651
+ // Classify the landing against the INPUT state FIRST: a FOCUS fires
652
+ // onActivate with the tab that is active NOW; a real creation fires
653
+ // onOpen with the minted tab. Both the dedupeKey match AND the id
654
+ // match count as a focus — a descriptor deduping by key (e.g. editor
655
+ // by path) can focus an existing tab for a NEW requested id, and
656
+ // classifying that as a creation would fire onOpen with a phantom
657
+ // tab that never closes.
658
+ const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
659
+ const key = dedupeKey?.(tab)
660
+ const inputTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
661
+ .concat(state.floats.map(f => f.tab))
662
+ const existedByKey = key !== undefined
663
+ && inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
664
+ const existedById = tabOpenIn(state, tab.id)
665
+ const isCreation = !existedByKey && !existedById
666
+ // A URL seed pre-fills a NEWLY CREATED tab's path (the browser tab
667
+ // navigates to it on mount); a FOCUS must never have its path
668
+ // overwritten. An explicit seed.title still wins over a createTab-
669
+ // minted default title (e.g. the sidebar-browser's hostname title).
670
+ let landed: SidebarState = next
671
+ if (seed.url !== undefined && isCreation) {
672
+ landed = patchTab(next, tab.id, {
673
+ path: seed.url,
674
+ ...(seed.title !== undefined ? { title: seed.title } : {}),
675
+ })
676
+ }
677
+ // Lifecycle capture (before the auto-expand block, which early-returns).
678
+ if (isCreation) {
679
+ // Resolve the ACTUAL landed tab — the url patch mints a new object,
680
+ // so the callback must see the tab that was really inserted.
681
+ const landedTabs = allLeaves(landed.splits).flatMap(leaf => leaf.tabs)
682
+ created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
683
+ } else {
684
+ // A focus happened: resolve the tab that is actually active now and
685
+ // report THAT to onActivate (never the caller's un-inserted seed).
686
+ // The pool covers free windows too — a floating instance focuses by
687
+ // raising, and the callback must see the real (patched) tab.
688
+ const candidates = allLeaves(landed.splits).flatMap(leaf => leaf.tabs)
689
+ .concat(landed.floats.map(f => f.tab))
690
+ activated = key !== undefined
691
+ ? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
692
+ : candidates.find(candidate => candidate.id === tab.id)
693
+ activated ??= tab
694
+ }
695
+ // A CONTENT open that focuses an existing FLOATING tab is already in
696
+ // sight (free windows render regardless of panel state): expanding a
697
+ // panel for it would point the user at a pane the content is not in.
698
+ if (
699
+ !isCreation
700
+ && floatWithTab(landed, activated?.id ?? tab.id) !== undefined
701
+ ) {
702
+ return landed
703
+ }
704
+ // A CONTENT open (file / browser) must land in sight: when the panel
705
+ // is collapsed, expand it. Type-only opens (+ menu, agent-terminal
706
+ // auto-tabs) never expand (the panel behavior is their caller's
707
+ // business). The check runs on the post-dedupe state, so a content
708
+ // open that merely FOCUSES an existing tab expands the panel too —
709
+ // the open must never land out of sight. Opens targeted at an
710
+ // INACTIVE session never expand (nothing is in sight for the user).
711
+ if (
712
+ !targetsInactiveSession
713
+ && typeof window !== 'undefined'
714
+ && (seed.path !== undefined || seed.url !== undefined)
715
+ ) {
716
+ if (!landed.panelOpen) return togglePanel(landed)
717
+ }
718
+ return landed
719
+ }
720
+ // A scope targeting ANOTHER session lands the open there without
721
+ // switching the UI; a scope naming the active session (or no scope)
722
+ // takes the regular reduce path so the UI notifies and re-renders.
723
+ if (targetsInactiveSession) {
724
+ store.reduceFor(scope.sessionId, reducer)
725
+ } else {
726
+ store.reduce(reducer)
727
+ }
728
+ if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
729
+ else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
730
+ }
731
+
732
+ const closeTab = (tabId: string, scope?: SessionScope): void => {
733
+ let closed: SidebarTab | undefined
734
+ store.reduce((state) => {
735
+ // Unknown tab ids are a strict no-op: no state churn, no notify, no
736
+ // pointless localStorage rewrite (mirrors updateTab's short-circuit).
737
+ if (!tabOpenIn(state, tabId)) return state
738
+ // A floating tab closes WITH its window — the float is the tab's pane.
739
+ const float = floatWithTab(state, tabId)
740
+ if (float !== undefined) {
741
+ closed = float.tab
742
+ return closeFloatByTab(state, tabId)
743
+ }
744
+ const paneId = findPaneIdOf(state, tabId)
745
+ const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
746
+ closed = leaf?.tabs.find(tab => tab.id === tabId)
747
+ return closeTabReducer(state, paneId, tabId)
748
+ })
749
+ if (closed !== undefined) {
750
+ const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
751
+ if (sessionId !== undefined) {
752
+ const descriptor = tabs.get(closed.type)
753
+ // An explicit scope (with its optional cwd) rides to the callback.
754
+ safeCall(() => descriptor?.onClose?.(closed!, scope ?? { sessionId }))
755
+ }
756
+ }
757
+ }
758
+
759
+ /** The snapshot the store publishes (state/prefs carry the active session). */
760
+ const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
761
+
762
+ /** Store changes: session switch, state mutations, prefs writes. */
763
+ const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
764
+
765
+ /** Patch an open tab's display fields (a missing tab id is a no-op). */
766
+ const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
767
+ store.reduce((state) => patchTab(state, tabId, {
768
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
769
+ ...(patch.path !== undefined ? { path: patch.path } : {}),
770
+ ...(patch.meta !== undefined ? { meta: patch.meta } : {}),
771
+ }))
772
+ }
773
+
774
+ /** Activate an open tab (the tab-bar activation path; fires onActivate). */
775
+ const activateTab = (tabId: string, scope?: SessionScope): void => {
776
+ let activated: SidebarTab | undefined
777
+ store.reduce((state) => {
778
+ // Unknown tab ids are a strict no-op (no state churn / notify).
779
+ if (!tabOpenIn(state, tabId)) return state
780
+ // A floating tab "activates" by raising its window — no pane switch.
781
+ const float = floatWithTab(state, tabId)
782
+ if (float !== undefined) {
783
+ activated = float.tab
784
+ return raiseFloat(state, float.id)
785
+ }
786
+ const paneId = findPaneIdOf(state, tabId)
787
+ const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
788
+ activated = leaf?.tabs.find(tab => tab.id === tabId)
789
+ return activateTabReducer(state, paneId, tabId)
790
+ })
791
+ if (activated !== undefined) {
792
+ const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
793
+ if (sessionId !== undefined) {
794
+ const descriptor = tabs.get(activated.type)
795
+ // An explicit scope (with its optional cwd) rides to the callback.
796
+ safeCall(() => descriptor?.onActivate?.(activated!, scope ?? { sessionId }))
797
+ }
798
+ }
799
+ }
800
+
801
+ /** Open a file in the sidebar editor of `scope`'s session (title defaults
802
+ * to the file name; the tab id is path-derived, like the internal
803
+ * open-path interception, so distinct files open side by side). */
804
+ const openFile = (scope: SessionScope, path: string, title?: string): void => {
805
+ openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
806
+ }
807
+
808
+ return {
809
+ registerTab,
810
+ registerFileViewer,
811
+ getTabs,
812
+ getFileViewers,
813
+ getTab,
814
+ isTabEnabled,
815
+ isViewerEnabled,
816
+ matchFileViewer,
817
+ openTab,
818
+ closeTab,
819
+ subscribe,
820
+ version: SIDEBAR_SERVICE_VERSION,
821
+ features: SIDEBAR_FEATURES,
822
+ getSnapshot,
823
+ subscribeState,
824
+ updateTab,
825
+ activateTab,
826
+ openFile,
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
832
+ * same type exists, focus it; otherwise land the tab through
833
+ * `openTabInActivePane` (the id safety net + active-pane landing are that
834
+ * reducer's job — not re-implemented here).
835
+ * `single: true` resolves to the id-key sugar when no explicit key is given.
836
+ */
837
+ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
838
+ const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
839
+ const key = dedupeKey?.(tab)
840
+ if (key !== undefined) {
841
+ for (const leaf of allLeaves(state.splits)) {
842
+ const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
843
+ if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
844
+ }
845
+ // A floating instance focuses by raising its window (no duplicate tab,
846
+ // no panel expansion — the window is the tab's pane).
847
+ const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
848
+ if (floated !== undefined) return raiseFloat(state, floated.id)
849
+ }
850
+ return openTabInActivePane(state, tab)
851
+ }
852
+
853
+ /** Find which pane hosts a tab id ('' if none). */
854
+ function findPaneIdOf(state: SidebarState, tabId: string): string {
855
+ for (const leaf of allLeaves(state.splits)) {
856
+ if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
857
+ }
858
+ return state.activePane ?? ''
859
+ }