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