dsh-better-sidebar 0.10.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,434 @@
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, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
26
+ type SidebarState, type SidebarStore, type SidebarTab,
27
+ } from './state.ts'
28
+ import { isNarrowWidth } from './breakpoints.ts'
29
+ import type { SessionScope } from './api.ts'
30
+
31
+ /** One declarative boolean setting of a tab/viewer, rendered as a nested
32
+ * switch row in the Side card settings page (e.g. the Subagent page's
33
+ * "auto-open when a subagent appears"). */
34
+ export interface SidebarSettingToggle {
35
+ /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
36
+ key: string
37
+ /** Row title (i18n friendly: string or () => string). */
38
+ title: string | (() => string)
39
+ /** Row description (i18n friendly). */
40
+ desc?: string | (() => string)
41
+ }
42
+
43
+ /** Declarative settings of one registered tab or file viewer. */
44
+ export interface SidebarSettingsDeclaration {
45
+ /**
46
+ * Extra boolean toggles rendered under the feature's own row in the
47
+ * settings page (only while the feature is enabled). Keys must be fields
48
+ * of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
49
+ * 'agentTerminalTools'); unknown keys are dropped by the settings seam.
50
+ */
51
+ toggles?: readonly SidebarSettingToggle[]
52
+ }
53
+
54
+ /** Props every tab component receives (builtins and external alike). */
55
+ export interface TabComponentProps {
56
+ ctx: Context
57
+ store: SidebarStore
58
+ scope: SessionScope
59
+ tab: SidebarTab
60
+ /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
61
+ visible: boolean
62
+ /** The explorer's expanded directory set (ExplorerView). */
63
+ expanded?: string[]
64
+ onToggleDir?: (path: string) => void
65
+ onReferenceFile?: (path: string) => void
66
+ onOpenFile?: (path: string) => void
67
+ onOpenDiff?: (tab: SidebarTab) => void
68
+ onSubagentJump?: (childSessionId: string) => void
69
+ }
70
+
71
+ /** Describes one kind of sidebar tab (builtins register themselves too). */
72
+ export interface TabDescriptor {
73
+ /** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
74
+ id: string
75
+ title: string | (() => string)
76
+ icon?: ReactNode | ((size: number) => ReactNode)
77
+ /** + menu sort order (ascending); default 100. */
78
+ order?: number
79
+ /** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
80
+ hidden?: boolean
81
+ /**
82
+ * + menu disabled predicate (e.g. terminal at capacity). Receives the
83
+ * session scope and the live sidebar state (counts, expansions).
84
+ */
85
+ available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
86
+ /**
87
+ * Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
88
+ * (opening the tab focuses an existing one of the same type instead of
89
+ * creating a duplicate). An explicit `dedupeKey` always wins when both
90
+ * are given. Builtins: explorer/git/subagent use `single: true`.
91
+ */
92
+ single?: boolean
93
+ /**
94
+ * If provided, opening a tab whose `dedupeKey(tab)` matches an existing
95
+ * tab's key focuses the existing one instead of creating a new one.
96
+ * Returning `undefined` means "no dedup — always open a new tab".
97
+ * Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
98
+ * (openDiffTab mints change-derived ids).
99
+ */
100
+ dedupeKey?: (tab: SidebarTab) => string | undefined
101
+ /**
102
+ * Custom tab creation (minting the `SidebarTab` and any state patches).
103
+ * Return `null` to refuse creation. The terminal builtin uses this to
104
+ * mint `terminal:<n>` ids and bump `nextTerminal`.
105
+ * When omitted, a default `{ id, type, title }` tab is created.
106
+ */
107
+ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
108
+ /**
109
+ * Declarative settings shown in the Side card settings page: every
110
+ * registered tab gets an enable/disable switch (icon + title + id), and
111
+ * `settings.toggles` adds nested switches tied to SidebarPrefs fields
112
+ * (e.g. the subagent tab's 'autoOpenSubagent').
113
+ */
114
+ settings?: SidebarSettingsDeclaration
115
+ component: (props: TabComponentProps) => ReactNode
116
+ }
117
+
118
+ /** How the host loads a file's bytes for one viewer. */
119
+ export type FileFetchStrategy =
120
+ | 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
121
+ | 'fsRead' // text read through /sidebar/api fs.read
122
+ | 'mediaUrl' // the viewer gets a media URL string
123
+ | 'custom' // the viewer's load() fetches its own bytes
124
+ | 'binary-download' // show a download button (no client-side renderer)
125
+
126
+ /** Props every file viewer component receives. */
127
+ export interface FileViewerProps {
128
+ ctx: Context
129
+ store: SidebarStore
130
+ scope: SessionScope
131
+ path: string
132
+ title: string
133
+ /** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
134
+ viewerId: string
135
+ /** fsRead text content (fetchStrategy='fsRead'). */
136
+ content?: string
137
+ truncated?: boolean
138
+ /** mediaUrl for the path (fetchStrategy='mediaUrl'). */
139
+ mediaUrl?: string
140
+ /** custom load() return value (fetchStrategy='custom'). */
141
+ customData?: unknown
142
+ }
143
+
144
+ /** Describes one file previewer (builtins register themselves too). */
145
+ export interface FileViewerDescriptor {
146
+ /** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
147
+ id: string
148
+ /** Display name for the settings inventory (falls back to `id` when absent). */
149
+ title?: string | (() => string)
150
+ /** Icon shown in the settings inventory. */
151
+ icon?: ReactNode | ((size: number) => ReactNode)
152
+ /** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
153
+ exts: readonly string[]
154
+ /** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
155
+ priority?: number
156
+ fetchStrategy: FileFetchStrategy
157
+ /**
158
+ * Content sniff: when `head` bytes are available the descriptor's `detect`
159
+ * is consulted before its `exts` (per-descriptor, in priority order).
160
+ */
161
+ detect?: (path: string, head: Uint8Array) => boolean
162
+ /** fetchStrategy='custom' loader. */
163
+ load?: (path: string, scope: SessionScope) => Promise<unknown>
164
+ /**
165
+ * Declarative settings shown in the Side card settings page: every
166
+ * registered viewer gets an enable/disable switch (icon + title + exts).
167
+ */
168
+ settings?: SidebarSettingsDeclaration
169
+ component: (props: FileViewerProps) => ReactNode
170
+ }
171
+
172
+ /** The registry service published as `ctx.betterSidebar`. */
173
+ export interface BetterSidebarService {
174
+ registerTab(descriptor: TabDescriptor): () => void
175
+ registerFileViewer(descriptor: FileViewerDescriptor): () => void
176
+ getTabs(): readonly TabDescriptor[]
177
+ getFileViewers(): readonly FileViewerDescriptor[]
178
+ /** Find a tab descriptor by id (undefined if not registered). */
179
+ getTab(id: string): TabDescriptor | undefined
180
+ /**
181
+ * Whether a tab type is enabled in the side card prefs. An absent
182
+ * `tabsEnabled[id]` entry means enabled — only an explicit `false`
183
+ * disables the type (hidden from the + menu, `openTab` refuses, and
184
+ * derived flows gate on it).
185
+ */
186
+ isTabEnabled(id: string): boolean
187
+ /** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
188
+ isViewerEnabled(id: string): boolean
189
+ /**
190
+ * Find a file viewer for a path (priority desc; detect first, then exts).
191
+ * Disabled viewers are skipped, so files fall through to the next match.
192
+ */
193
+ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
194
+ /**
195
+ * Open a tab (used by external tabs and the + menu). `title` overrides
196
+ * the descriptor's title when given (the editor tab shows the file name);
197
+ * when the descriptor provides `createTab` it mints the tab itself and
198
+ * `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
199
+ * pre-set to the URL (the browser tab's navigation seed; the caller
200
+ * usually pairs it with a hostname `title`). A disabled tab type is a
201
+ * no-op.
202
+ *
203
+ * A CONTENT open (a `path` or `url` seed) must land in sight: when the
204
+ * panel hosting the landing pane is collapsed, it is expanded
205
+ * automatically (the right panel by default, the bottom panel when the
206
+ * active pane lives there; on narrow viewports the merged drawer opens).
207
+ * Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
208
+ * the panel behavior is their caller's business.
209
+ */
210
+ openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
211
+ /** Close a tab by id. */
212
+ closeTab(tabId: string): void
213
+ /** Subscribe to registry changes (register/dispose). */
214
+ subscribe(listener: () => void): () => void
215
+ }
216
+
217
+ /** Extract the lowercase extension without leading dot from a path. */
218
+ function extOfPath(path: string): string {
219
+ const at = path.lastIndexOf('.')
220
+ if (at === -1) return ''
221
+ const base = path.slice(at + 1).toLowerCase()
222
+ return base.includes('/') || base.includes('\\') ? '' : base
223
+ }
224
+
225
+ /**
226
+ * Create one BetterSidebar service bound to a store. The service owns the
227
+ * tab/viewer registries (Map + listener set) and proxies openTab/closeTab
228
+ * to the store's reducer. One instance per client plugin activation.
229
+ */
230
+ export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
231
+ const tabs = new Map<string, TabDescriptor>()
232
+ const viewers = new Map<string, FileViewerDescriptor>()
233
+ const listeners = new Set<() => void>()
234
+
235
+ const notify = (): void => {
236
+ for (const fn of [...listeners]) fn()
237
+ }
238
+
239
+ const subscribe = (listener: () => void): (() => void) => {
240
+ listeners.add(listener)
241
+ return () => { listeners.delete(listener) }
242
+ }
243
+
244
+ const registerTab = (descriptor: TabDescriptor): (() => void) => {
245
+ if (tabs.has(descriptor.id)) {
246
+ throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`)
247
+ }
248
+ tabs.set(descriptor.id, descriptor)
249
+ notify()
250
+ return () => {
251
+ if (tabs.get(descriptor.id) === descriptor) {
252
+ tabs.delete(descriptor.id)
253
+ notify()
254
+ }
255
+ }
256
+ }
257
+
258
+ const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
259
+ if (viewers.has(descriptor.id)) {
260
+ throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`)
261
+ }
262
+ viewers.set(descriptor.id, descriptor)
263
+ notify()
264
+ return () => {
265
+ if (viewers.get(descriptor.id) === descriptor) {
266
+ viewers.delete(descriptor.id)
267
+ notify()
268
+ }
269
+ }
270
+ }
271
+
272
+ const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
273
+ const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
274
+ const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
275
+
276
+ // The enable switches come from the user's side card prefs (the shared
277
+ // store the service is bound to): an absent key means enabled.
278
+ const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
279
+ const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
280
+
281
+ const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
282
+ const ext = extOfPath(path)
283
+ // Single pass in priority order (descending; stable for equal
284
+ // priorities — insertion order). Each descriptor gets first refusal in
285
+ // its own turn: `detect` (when head bytes are available) beats its own
286
+ // `exts`, and `exts: []` is a catch-all matching any path — so the
287
+ // catch-all `code` viewer (-100) only sees paths no higher-priority
288
+ // descriptor claimed. Disabled viewers are skipped entirely.
289
+ for (const v of Array.from(viewers.values()).sort(
290
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
291
+ )) {
292
+ if (!isViewerEnabled(v.id)) continue
293
+ // Content sniff first (only when head bytes are available).
294
+ if (head !== undefined && v.detect !== undefined) {
295
+ if (v.detect(path, head)) return v
296
+ // A catch-all with detect is SNIFF-ONLY: it must not blind-claim
297
+ // paths it never sniffed (a magic-number viewer must not swallow
298
+ // every file before the real viewers get their turn).
299
+ if (v.exts.length === 0) continue
300
+ } else if (v.exts.length === 0) {
301
+ // Blind catch-all (no detect) claims anything; a sniff-only
302
+ // catch-all (detect defined, no head yet) yields this round.
303
+ if (v.detect === undefined) return v
304
+ continue
305
+ }
306
+ if (v.exts.includes(ext)) return v
307
+ }
308
+ return undefined
309
+ }
310
+
311
+ const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
312
+ // A type the user disabled in settings never opens — neither from the
313
+ // + menu nor from derived flows (file opens, subagent auto-open,
314
+ // external plugins). Already-open tabs keep rendering.
315
+ if (!isTabEnabled(seed.type)) {
316
+ console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
317
+ return
318
+ }
319
+ store.reduce((state) => {
320
+ const descriptor = tabs.get(seed.type)
321
+ if (descriptor === undefined) return state
322
+ // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
323
+ let tab: SidebarTab
324
+ let next: SidebarState
325
+ if (descriptor.createTab !== undefined) {
326
+ const result = descriptor.createTab(state)
327
+ if (result === null) return state
328
+ tab = result.tab
329
+ next = applyDedupe(state, result.tab, descriptor)
330
+ if (result.patch !== undefined) next = { ...next, ...result.patch }
331
+ } else {
332
+ tab = {
333
+ id: seed.id ?? seed.type,
334
+ type: seed.type,
335
+ // A caller-provided title wins (the editor shows the file name);
336
+ // otherwise the descriptor's (possibly i18n) title is the default.
337
+ title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
338
+ ...(seed.path !== undefined ? { path: seed.path } : {}),
339
+ ...(seed.diff !== undefined ? { diff: seed.diff } : {}),
340
+ }
341
+ next = applyDedupe(state, tab, descriptor)
342
+ }
343
+ // A URL seed pre-fills the tab's path (the browser tab navigates to it
344
+ // on mount). An explicit seed.title also wins over a createTab-minted
345
+ // default title (e.g. the sidebar-browser's hostname title).
346
+ let landed: SidebarState
347
+ if (seed.url !== undefined) {
348
+ landed = patchTab(next, tab.id, {
349
+ path: seed.url,
350
+ ...(seed.title !== undefined ? { title: seed.title } : {}),
351
+ })
352
+ } else {
353
+ landed = next
354
+ }
355
+ // A CONTENT open (file / browser) must land in sight: when the panel
356
+ // hosting the landing pane is collapsed, expand it. On narrow
357
+ // viewports the two workbenches merge into one drawer, so the drawer
358
+ // (panelOpen) is the only lever; on wide viewports the landing pane's
359
+ // own panel opens — the bottom panel when the active pane lives in the
360
+ // bottom tree, else the right panel. Type-only opens (+ menu,
361
+ // agent-terminal auto-tabs) never expand (the panel behavior is their
362
+ // caller's business). The check runs on the post-dedupe state, so a
363
+ // content open that merely FOCUSES an existing tab expands the panel
364
+ // too — the open must never land out of sight.
365
+ if (
366
+ typeof window !== 'undefined'
367
+ && (seed.path !== undefined || seed.url !== undefined)
368
+ ) {
369
+ if (isNarrowWidth(window.innerWidth)) {
370
+ if (!landed.panelOpen) return togglePanel(landed)
371
+ } else {
372
+ const hostKey = treeOf(landed, landed.activePane ?? '')
373
+ if (hostKey === 'bottomSplits') {
374
+ if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
375
+ } else if (!landed.panelOpen) {
376
+ return togglePanel(landed)
377
+ }
378
+ }
379
+ }
380
+ return landed
381
+ })
382
+ }
383
+
384
+ const closeTab = (tabId: string): void => {
385
+ store.reduce((state) => {
386
+ const paneId = findPaneIdOf(state, tabId)
387
+ if (paneId === '') return state
388
+ return closeTabReducer(state, paneId, tabId)
389
+ })
390
+ }
391
+
392
+ return {
393
+ registerTab,
394
+ registerFileViewer,
395
+ getTabs,
396
+ getFileViewers,
397
+ getTab,
398
+ isTabEnabled,
399
+ isViewerEnabled,
400
+ matchFileViewer,
401
+ openTab,
402
+ closeTab,
403
+ subscribe,
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
409
+ * same type exists, focus it; otherwise land the tab through
410
+ * `openTabInActivePane` (the id safety net + active-pane landing are that
411
+ * reducer's job — not re-implemented here).
412
+ * `single: true` resolves to the id-key sugar when no explicit key is given.
413
+ */
414
+ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
415
+ const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
416
+ const key = dedupeKey?.(tab)
417
+ if (key !== undefined) {
418
+ // The scan covers BOTH trees: opening a single-instance tab from the
419
+ // bottom panel focuses an existing instance wherever it lives.
420
+ for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
421
+ const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
422
+ if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
423
+ }
424
+ }
425
+ return openTabInActivePane(state, tab)
426
+ }
427
+
428
+ /** Find which pane hosts a tab id ('' if none). Either tree is searched. */
429
+ function findPaneIdOf(state: SidebarState, tabId: string): string {
430
+ for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
431
+ if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
432
+ }
433
+ return state.activePane ?? ''
434
+ }