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