/** * The BetterSidebar client service: a registry that external plugins use * to contribute sidebar tab types and file previewers. The service is * published to the cordis context as `ctx.betterSidebar` (see * {@link ../context-types.ts}); consumers declare it in `inject` and call * `registerTab` / `registerFileViewer`, both returning a disposer that * cordis auto-invokes on fiber disposal (HMR-safe). * * Design notes: * - The registry is synchronous-snapshot (Map + listener set) so React * can read it through `useSyncExternalStore` without tearing. * - `dedupeKey` unifies the three open-tab strategies the builtins used to * hardcode: single-instance (`() => type`), per-path (`tab => tab.path`), * and per-id (`tab => tab.id` for diff tabs whose id is change-derived). * `single: true` is sugar for `dedupeKey: () => id`. * - `createTab` lets a descriptor own tab instantiation (the terminal * builtin uses it to mint `terminal:` ids and bump `nextTerminal`). * - `matchFileViewer` walks descriptors in priority order (desc, stable): * per descriptor it tries `detect` first (when `head` bytes are given), * then `exts`; `exts: []` is a catch-all that matches any path. */ import type { ReactNode } from 'react' import type { Context } from '../context-types.ts' import { activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab, openTabInBottomPane, patchTab, tabOpenIn, type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType, } from './state.ts' import { baseName, extOf } from './paths.ts' import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx' import type { SessionScope } from './api.ts' import type { SidebarPrefs } from '../prefs-shared.ts' /** * Public state vocabulary re-exported for consumers (type-only; the values * stay internal). External plugins name these types in their descriptors — * e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`, * or `badge: (…, state: SidebarState) => …`. */ export type { SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType, } from './state.ts' export type { SessionScope } from './api.ts' export type { SidebarPrefs } from '../prefs-shared.ts' /** The row control a declarative setting renders as in the settings popup. */ export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select' /** One option of a `type: 'select'` setting row. */ export interface SidebarSettingSelectOption { /** The value written to the setting key when this option is picked * (JSON-serializable: string / number / boolean). */ value: string | number | boolean /** Option title (i18n friendly: string or () => string). */ title: string | (() => string) /** Option description (i18n friendly); rendered under the title in the * icon dropdown. */ desc?: string | (() => string) /** Option icon: when ANY option declares one, the dropdown renders * big-icon option cards and the closed control shows the selected * option's icon too; without icons both are a single line of text. */ icon?: ReactNode | ((size: number) => ReactNode) } /** One declarative setting of a tab/viewer, rendered as a nested row in the * Side card settings page (e.g. the Subagent page's "auto-open when a * subagent appears" switch, or the terminal's custom font rows). `type` * selects the control: 'switch' (default) renders the custom switch, * 'text' a free-form input committed on blur/Enter, 'number' a numeric * input clamped to `min`/`max`, 'select' a dropdown over the declared * `options` (single-pick writes the option's value; `multi: true` writes * the array of picked values and defaults to false). */ export interface SidebarSettingToggle { /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */ key: string /** Row title (i18n friendly: string or () => string). */ title: string | (() => string) /** Row description (i18n friendly). */ desc?: string | (() => string) /** Row control type; defaults to 'switch' (backward compatible). */ type?: SidebarSettingToggleType /** Lower bound for `type: 'number'` rows (clamped on commit). */ min?: number /** Upper bound for `type: 'number'` rows (clamped on commit). */ max?: number /** Input placeholder for `type: 'text'` rows. */ placeholder?: string /** Unit suffix rendered after the input (e.g. 'px' for a size row). */ unit?: string /** Options of a `type: 'select'` row. */ options?: readonly SidebarSettingSelectOption[] /** Whether a `type: 'select'` row allows picking several options (the * stored value is then an array of option values); defaults to false. */ multi?: boolean } /** Props of a descriptor's custom settings panel (`settings.render`). */ export interface SidebarSettingsRenderProps { store: SidebarStore service: BetterSidebarService prefs: SidebarPrefs /** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */ pluginSettings: Record /** Persist one plugin-owned setting of this descriptor. */ updatePluginSetting(key: string, value: unknown): void /** Close the settings popup. */ close(): void } /** Declarative settings of one registered tab or file viewer. */ export interface SidebarSettingsDeclaration { /** * Extra settings rows rendered under the feature's own row in the * settings page (only while the feature is enabled). Keys must be fields * of the host's PrefsSchema (built-ins: 'autoOpenSubagent', * 'agentTerminalTools', 'agentOpenTools', 'terminalFontFamily'); unknown * keys are dropped by the settings seam. */ toggles?: readonly SidebarSettingToggle[] /** * Plugin-owned settings rows (v0.12.0+): same row controls as `toggles` * (switch/text/number), but the keys are plugin-local and persisted in * the sidebar's own prefs document under `pluginSettings[]` * — no host PrefsSchema field needed. Values must be JSON-serializable * (the row controls produce strings / numbers / booleans). */ pluginToggles?: readonly SidebarSettingToggle[] /** * Custom settings panel (v0.12.0+): when given, the gear popup renders * this instead of the row lists (`toggles` / `pluginToggles`). Receives * the shared store/service, the live prefs, the descriptor's own * `pluginSettings` blob, and a persistence helper. */ render?: (props: SidebarSettingsRenderProps) => ReactNode } /** Props every tab component receives (builtins and external alike). */ export interface TabComponentProps { ctx: Context store: SidebarStore scope: SessionScope tab: SidebarTab /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */ visible: boolean /** The explorer's expanded directory set (ExplorerView). */ expanded?: string[] /** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */ revealed?: string[] onToggleDir?: (path: string) => void onReferenceFile?: (path: string, isDir: boolean) => void onOpenFile?: (path: string) => void onOpenDiff?: (tab: SidebarTab) => void onSubagentJump?: (childSessionId: string) => void } /** Describes one kind of sidebar tab (builtins register themselves too). */ export interface TabDescriptor { /** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */ id: string title: string | (() => string) /** * One-line description of what this tab shows, rendered under the title in * the host's new-tab list (DSH's native right Sidebar guide page). DSH * 0.1.5-rc.1+ renders descriptions only while the guide lists at most 4 * entries — a longer list drops every description and shows titles alone — * and a descriptor that declares none renders the title by itself (the * host no longer substitutes a generic fallback, so declare the real * purpose of the page). Evaluated at render time, so a function follows * the active locale. */ description?: string | (() => string) icon?: ReactNode | ((size: number) => ReactNode) /** + menu sort order (ascending); default 100. */ order?: number /** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */ hidden?: boolean /** * + menu disabled predicate (e.g. terminal at capacity). Receives the * session scope and the live sidebar state (counts, expansions). */ available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean /** * Single-instance sugar: `true` is shorthand for `dedupeKey: () => id` * (opening the tab focuses an existing one of the same type instead of * creating a duplicate). An explicit `dedupeKey` always wins when both * are given. Builtins: explorer/git/subagent use `single: true`. */ single?: boolean /** * If provided, opening a tab whose `dedupeKey(tab)` matches an existing * tab's key focuses the existing one instead of creating a new one. * Returning `undefined` means "no dedup — always open a new tab". * Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id` * (openDiffTab mints change-derived ids). */ dedupeKey?: (tab: SidebarTab) => string | undefined /** * Custom tab creation (minting the `SidebarTab` and any state patches). * Return `null` to refuse creation. The terminal builtin uses this to * mint `terminal:` ids and bump `nextTerminal`. * When omitted, a default `{ id, type, title }` tab is created. */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null /** * External-link target claim (v0.13.0+): when a GUI external-link click * is taken over (the `browserInterceptLinks` master AND the URL's * protocol flag — `browserInterceptHttp` / `browserInterceptHttps` — * are on), the first registered tab whose `urlTarget(url)` returns true * is opened with `openTab({ type, url, title: hostname })` — the URL is * the whole payload (the tab reads it from `tab.path`). Registration * order wins (first claim first served); a disabled tab type is skipped; * a throwing predicate is swallowed (console.error, the type is skipped). * The built-in browser tab declares NO urlTarget — it stays the implicit * fallback target, so plugins can never be shadowed by it. To host more * than one URL at a time, mint per-URL ids through `createTab` (the * browser builtin's pattern); otherwise the id safety net focuses the * existing tab of the same type and the new URL is not applied. */ urlTarget?: (url: URL) => boolean /** * Declarative settings shown in the Side card settings page: every * registered tab gets an enable/disable switch (icon + title + id), and * `settings.toggles` adds nested switches tied to SidebarPrefs fields * (e.g. the subagent tab's 'autoOpenSubagent'). */ settings?: SidebarSettingsDeclaration /** * Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to * the icon — a number renders as a count (99+ capped), a string renders * as-is, null/undefined hides the badge. Called on every tab-bar render, * so keep it cheap; a throw is swallowed (no badge shown). */ badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined /** * Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only: * `onOpen` when an open actually creates a tab (a dedupe/id-safety-net * focus is NOT an open — it fires `onActivate` instead), `onActivate` * when a tab is focused (dedupe focus, id-safety-net focus, or the * tab-bar activation), `onClose` when a tab is closed through * `closeTab`. Builtin-only flows that mutate state directly (the diff * split placement, agent-terminal reconcile) never touch external tabs * and fire no callbacks. A throwing callback is logged and never breaks * the open/close/activate flow. */ onOpen?: (tab: SidebarTab, scope: SessionScope) => void onActivate?: (tab: SidebarTab, scope: SessionScope) => void onClose?: (tab: SidebarTab, scope: SessionScope) => void component: (props: TabComponentProps) => ReactNode } /** How the host loads a file's bytes for one viewer. */ export type FileFetchStrategy = | 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves) | 'fsRead' // text read through /sidebar/api fs.read | 'mediaUrl' // the viewer gets a media URL string | 'custom' // the viewer's load() fetches its own bytes | 'binary-download' // show a download button (no client-side renderer) /** Props every file viewer component receives. */ export interface FileViewerProps { ctx: Context store: SidebarStore scope: SessionScope path: string title: string /** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */ viewerId: string /** fsRead text content (fetchStrategy='fsRead'). */ content?: string truncated?: boolean /** mediaUrl for the path (fetchStrategy='mediaUrl'). */ mediaUrl?: string /** custom load() return value (fetchStrategy='custom'). */ customData?: unknown /** Internal (built-in text editor): 'host' asks the viewer to skip its own * toolbar row — the editor host's merged-mode header renders it instead, * fed through the two callbacks below. Viewers that ignore these fields * render exactly as before. */ toolbar?: 'self' | 'host' /** Internal: the viewer reports its toolbar state (mode/dirty/save). */ onToolbarState?: (state: EditorToolbarState) => void /** Internal: the viewer registers its toolbar commands on mount (null on * unmount). */ onToolbarControls?: (controls: EditorToolbarControls | null) => void } /** The toolbar state a text editor reports to the host's merged-mode header. */ export interface EditorToolbarState { /** Whether the preview/edit mode toggle applies (markdown/html). */ modes: boolean mode: 'preview' | 'edit' dirty: boolean /** Whether saving applies (text content loaded). */ editable: boolean saveState: 'idle' | 'saving' | 'saved' | 'failed' } /** The commands the host's merged-mode header sends back to the viewer. */ export interface EditorToolbarControls { setMode(mode: 'preview' | 'edit'): void save(): void } /** Describes one file previewer (builtins register themselves too). */ export interface FileViewerDescriptor { /** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */ id: string /** Display name for the settings inventory (falls back to `id` when absent). */ title?: string | (() => string) /** Icon shown in the settings inventory. */ icon?: ReactNode | ((size: number) => ReactNode) /** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */ exts: readonly string[] /** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */ priority?: number fetchStrategy: FileFetchStrategy /** * Content sniff: when `head` bytes are available the descriptor's `detect` * is consulted before its `exts` (per-descriptor, in priority order). */ detect?: (path: string, head: Uint8Array) => boolean /** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer * teardown / re-match; loaders that ignore it keep working. */ load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise /** * Declarative settings shown in the Side card settings page: every * registered viewer gets an enable/disable switch (icon + title + exts). */ settings?: SidebarSettingsDeclaration component: (props: FileViewerProps) => ReactNode } /** * Describes one external file-icon registration (feature `fileIcons`). * Registrations override the built-in per-extension glyph map for their * extensions; unlike the built-ins (monochrome `currentColor` per the skin * contract), a registration's icon may be ANY ReactNode — colored included — * and the registering plugin owns how its colors behave across skins. */ export interface FileIconDescriptor { /** Unique id (`'my-plugin:icons'`). */ id: string /** * Lowercase extensions without leading dot (`['csv','tsv']`). `[]` = the * global default (catch-all): it only claims files the built-in glyph map * does not cover — registered specifics and built-in glyphs always outrank * it. OMITTED = no extension rule at all (a `names`-only registration is * NOT a catch-all). Two values are RESERVED for directory rows (never * matched against real file extensions): `'folder'` (a closed directory) * and `'folder-open'` (an expanded directory) — see `FOLDER_EXT`. */ exts?: readonly string[] /** * Exact FILE names (basename, case-insensitive — `['package.json', * 'Dockerfile']`), the `fileNames` half of an icon theme. Name matches * outrank extension matches, so a theme can color `package.json` apart * from every other `.json`. Omitted/`[]` = no name rule. */ names?: readonly string[] /** * Exact DIRECTORY names (basename, case-insensitive — `['node_modules', * 'src']`), the `folderNames` half of an icon theme. A name match outranks * the reserved `'folder'`/`'folder-open'` exts, and a descriptor with * `folderNames` only claims the directories it names (never every folder — * that is what the reserved exts are for). Omitted/`[]` = no name rule. */ folderNames?: readonly string[] /** Higher wins; default 0. Registered icons always outrank the built-in map. */ priority?: number /** * Size-aware icon factory (the tree and file tabs render at 14 today). * `open` is the directory's expanded state for a DIRECTORY row and * `undefined` for a file row — a folder icon uses it to pick between the * closed and opened glyph. */ icon: (path: string, size: number, open?: boolean) => ReactNode } /** * Reserved `exts` values that claim DIRECTORY rows instead of file * extensions: `'folder'` matches a closed directory, `'folder-open'` an * expanded one (`folderIcon(path, open)` resolves them). They are filtered out of * real-extension matching, so a file literally named `x.folder` is NOT * claimed by a folder registration. */ export const FOLDER_EXT = 'folder' as const export const FOLDER_OPEN_EXT = 'folder-open' as const /** One `openTab` request. */ export interface OpenTabSeed { type: string /** Overrides the descriptor's title when given (the editor tab shows the file name). */ title?: string /** A file path (the editor tab's content seed). */ path?: string /** A diff reference (the diff tab's content seed). */ diff?: SidebarTab['diff'] /** Explicit tab id (defaults to the type). */ id?: string /** A URL the tab navigates to on mount (the browser tab's seed). */ url?: string /** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */ meta?: unknown /** * Where the open lands. `'right'` (the default) is DSH's right Sidebar — * the plugin's content is registered there as native tab types; `'bottom'` * is the plugin's own bottom workbench. Only the plugin's own flows pass * `'bottom'` (the bottom panel's + menu, the auto-terminal). */ target?: 'right' | 'bottom' } /** * The plugin-side seed a native right-Sidebar tab carries in its navigation * params (the native surface passes them back on every navigation). */ export interface NativeTabParams { /** Overrides the descriptor's title for this instance. */ title?: string /** A file path (the editor window's content seed). */ path?: string /** A URL the tab navigates to on mount (the browser tab's seed). */ url?: string /** A diff reference (the diff tab's content seed). */ diff?: SidebarTab['diff'] /** JSON-serializable custom state carried on the synthetic record. */ meta?: unknown } /** * The plugin's write face over DSH's native right Sidebar. * * Installed by the client half ({@link ./native/surface.ts}) so the service — * and therefore every consumer of `ctx.betterSidebar` — keeps speaking the * plugin's own vocabulary while the content lands natively. Without it the * service writes into the plugin's own layout (the pre-0.1.5 behavior, which * the bottom workbench still uses). * @internal Not part of the consumer contract. */ export interface SidebarSurface { /** Open a page type in one session's native surface. */ openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void /** Open a resource address in one session's native surface. */ openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void /** The file address of one path (the native surface owns the grammar). */ fileAddress(sessionId: string, cwd: string | undefined, path: string): string /** Close one native tab; the closed record's type/title, or undefined when the id is not native. */ close(sessionId: string, tabId: string): { type: string; title: string } | undefined /** Patch a native tab's plugin-side record; false when it is not native. */ update(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): boolean /** Focus a native tab; false when it is not native. */ activate(tabId: string): boolean /** Whether a tab id belongs to the native surface. */ has(tabId: string): boolean } /** * The registry service published as `ctx.betterSidebar`. */ export interface BetterSidebarService { registerTab(descriptor: TabDescriptor): () => void registerFileViewer(descriptor: FileViewerDescriptor): () => void registerFileIcon(descriptor: FileIconDescriptor): () => void getTabs(): readonly TabDescriptor[] getFileViewers(): readonly FileViewerDescriptor[] getFileIcons(): readonly FileIconDescriptor[] /** * Find a SPECIFIC registered file icon for a path (priority desc, then * registration order): a `names` match first, then an `exts` match. * Catch-alls (`exts: []`) and folder registrations (`'folder'`/ * `'folder-open'`) are not consulted — this answers "did a registration * claim this exact name or extension". Consumers should prefer * `fileIcon`/`folderIcon`, which run the whole fallback chain. */ matchFileIcon(path: string): FileIconDescriptor | undefined /** * Find the registered icon for DIRECTORY rows (priority desc, then * registration order): a `folderNames` match on `name` first (pass the * directory's basename), then the `'folder'`/`'folder-open'` reserved * exts by `open`. Undefined = fall back to the built-in VSCodicons folder * glyphs. */ matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined /** * The authoritative FILE icon for a path (feature `fileIcons`), running * the whole chain with per-factory crash isolation: * 1. a specific registered name or extension (priority desc, registration * order), * 2. the best registered global default (`exts: []`, priority desc) — an * external plugin that registers a catch-all owns every row the host's * classifier would otherwise draw, * 3. the host's own `FileTypeIcon` artwork (feature `fileIcons`, DSH's * classifier and glyphs — the plugin ships no extension table). * A throwing factory is logged (console.error) and skipped — the caller * always gets a valid ReactNode. */ fileIcon(path: string, size: number): ReactNode /** * The authoritative DIRECTORY icon for a tree row: the registered * `folderNames`/`'folder'`/`'folder-open'` icon (priority desc), else the * built-in `VscFolder`/`VscFolderOpened`. `path` is the directory's own * path (a theme may vary icons per directory); `open` reaches the factory * so one descriptor can render both states. Same crash isolation as * `fileIcon`. */ folderIcon(path: string, open: boolean, size: number): ReactNode /** Find a tab descriptor by id (undefined if not registered). */ getTab(id: string): TabDescriptor | undefined /** * Whether a tab type is enabled in the side card prefs. An absent * `tabsEnabled[id]` entry means enabled — only an explicit `false` * disables the type (hidden from the + menu, `openTab` refuses, and * derived flows gate on it). */ isTabEnabled(id: string): boolean /** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */ isViewerEnabled(id: string): boolean /** * Find a file viewer for a path (priority desc; detect first, then exts). * Disabled viewers are skipped, so files fall through to the next match. */ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined /** * Open a tab (used by external tabs and the + menu). `title` overrides * the descriptor's title when given (the editor tab shows the file name); * when the descriptor provides `createTab` it mints the tab itself and * `title`/`path`/`id` are ignored. `url` lands the tab with its `path` * pre-set to the URL (the browser tab's navigation seed; the caller * usually pairs it with a hostname `title`). A disabled tab type is a * no-op. * * `scope` (v0.12.0+) targets a specific session: when given, the open * lands in THAT session's sidebar state (loading it if it has none yet) * without switching the UI's active session; when absent the open lands * in the currently active session (the pre-0.12 behavior). * * Every open lands in the bottom workbench and expands it (the workbench * is the plugin's only own surface; the right column is DSH's native * Sidebar). An open carrying a `path` or `url` goes through the native * surface instead, which never touches this state. * * Note: `available` gates the + menu's disabled state only — it does NOT * refuse `openTab` (only the settings disable switch does). */ openTab(seed: OpenTabSeed, scope?: SessionScope): void /** * Close a tab by id (fires descriptor.onClose). An unknown tab id is a * strict no-op (no state churn, no callbacks). `scope` (v0.12.0+) rides * to the callback (its optional cwd included); absent, the callback gets * `{ sessionId }` of the active session. */ closeTab(tabId: string, scope?: SessionScope): void /** Subscribe to registry changes (register/dispose). */ subscribe(listener: () => void): () => void /** The plugin version this service instance was built from ('0.12.0'). */ readonly version: string /** * Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' | * 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' | * 'tabMeta' | 'pluginSettings'. Features are never removed — consumers * gate new API usage on membership. */ readonly features: readonly string[] /** * The current sidebar snapshot: the active session id, its state (panel * geometry, open tabs, expansions), and the side card prefs (v0.12.0+). * `state`/`sessionId` are undefined until a session becomes active. */ getSnapshot(): SidebarSnapshot /** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */ subscribeState(listener: () => void): () => void /** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void /** * Activate an open tab (the tab-bar activation path; fires * descriptor.onActivate). An unknown tab id is a strict no-op. `scope` * (v0.12.0+) rides to the callback like `closeTab`'s. */ activateTab(tabId: string, scope?: SessionScope): void /** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */ openFile(scope: SessionScope, path: string, title?: string): void /** * Install (or clear) the native right-Sidebar write face. * @internal Called once by the client half; not part of the consumer API. */ setSurface(surface: SidebarSurface | undefined): void } /** The file name of a path (both separators). */ function baseNameOf(path: string): string { const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) return at === -1 ? path : path.slice(at + 1) } /** * Find the tab type that claims an intercepted external-link URL (v0.13.0+). * Walks the descriptors in REGISTRATION order and returns the first one * that declares `urlTarget` and matches `url`; a throwing predicate is * swallowed (console.error, type skipped) so one broken plugin can never * break the whole link pipeline. The caller passes the ENABLED tab * descriptors (enablement is the caller's prefs domain — filter * `service.getTabs()` through `tabsEnabled` before matching) and falls * back to the built-in browser tab when nothing claims the URL (the * browser never declares `urlTarget` itself, so it can never shadow a * plugin claim). */ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined { for (const tab of tabs) { if (tab.urlTarget === undefined) continue let claimed: boolean try { claimed = tab.urlTarget(url) === true } catch (error) { console.error('[dsh-better-sidebar] urlTarget error:', error) continue } if (claimed) return tab } return undefined } /** * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ export const SIDEBAR_SERVICE_VERSION = '0.19.1' /** * Monotonic capability list consumers use to gate new API usage (features * are never removed). Each string names a v0.12.0+ capability: * - 'badge': TabDescriptor.badge * - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose * - 'updateTab': BetterSidebarService.updateTab * - 'openFile': BetterSidebarService.openFile * - 'targetedOpen': BetterSidebarService.openTab(seed, scope?) * - 'stateSubscription': getSnapshot/subscribeState * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence) * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) * - 'settingSelect': SidebarSettingToggle type 'select' (options/multi) * - 'fileIcons' (v0.19.0): registerFileIcon/getFileIcons/matchFileIcon — * external file-tree icons overriding the built-in glyphs, matched by * extension (`exts`), exact file name (`names`), or directory name * (`folderNames`). * * v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5 * owns the right column, so the plugin keeps only its bottom workbench). * Consumers must not gate on it any more. */ export const SIDEBAR_FEATURES = [ 'badge', 'tabLifecycle', 'updateTab', 'openFile', 'targetedOpen', 'stateSubscription', 'tabMeta', 'pluginSettings', 'urlTarget', 'settingSelect', 'fileIcons', ] as const /** Run one plugin callback; a throw is logged and never breaks the caller. */ function safeCall(fn: () => void): void { try { fn() } catch (error) { console.error('[dsh-better-sidebar] plugin callback error:', error) } } /** * Create one BetterSidebar service bound to a store. The service owns the * tab/viewer registries (Map + listener set) and proxies openTab/closeTab * to the store's reducer. One instance per client plugin activation. */ export function createBetterSidebarService(store: SidebarStore): BetterSidebarService { const tabs = new Map() const viewers = new Map() const fileIcons = new Map() const listeners = new Set<() => void>() /** The native right-Sidebar write face, installed by the client half. */ let surface: SidebarSurface | undefined const notify = (): void => { for (const fn of [...listeners]) fn() } const subscribe = (listener: () => void): (() => void) => { listeners.add(listener) return () => { listeners.delete(listener) } } const registerTab = (descriptor: TabDescriptor): (() => void) => { if (tabs.has(descriptor.id)) { throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`) } tabs.set(descriptor.id, descriptor) notify() return () => { if (tabs.get(descriptor.id) === descriptor) { tabs.delete(descriptor.id) notify() } } } const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => { if (viewers.has(descriptor.id)) { throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`) } viewers.set(descriptor.id, descriptor) notify() return () => { if (viewers.get(descriptor.id) === descriptor) { viewers.delete(descriptor.id) notify() } } } const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values()) const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values()) const getFileIcons = (): readonly FileIconDescriptor[] => Array.from(fileIcons.values()) const getTab = (id: string): TabDescriptor | undefined => tabs.get(id) const registerFileIcon = (descriptor: FileIconDescriptor): (() => void) => { if (fileIcons.has(descriptor.id)) { throw new Error(`[dsh-better-sidebar] file icons "${descriptor.id}" already registered`) } fileIcons.set(descriptor.id, descriptor) notify() return () => { if (fileIcons.get(descriptor.id) === descriptor) { fileIcons.delete(descriptor.id) notify() } } } // Registrations in ranking order: priority desc, stable for equal // priorities (insertion order) — the same ranking `matchFileViewer` uses. const rankedFileIcons = (): FileIconDescriptor[] => Array.from(fileIcons.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // Specific registrations only: catch-alls (`exts: []`) and folder // registrations (`'folder'`/`'folder-open'`) are skipped, and the reserved // folder values never match a real file's extension. Name rules (`names`) // outrank extension rules. The built-in glyph map is not consulted here — // an undefined result IS the "fall through" signal the `fileIcon` resolver // acts on. const matchFileIcon = (path: string): FileIconDescriptor | undefined => { const ext = extOf(path) // Reserved folder values never claim a real file: `x.folder` falls // through to the built-in/catch-all chain like any unknown extension. const reserved = ext === FOLDER_EXT || ext === FOLDER_OPEN_EXT const name = baseName(path).toLowerCase() const ranked = rankedFileIcons() for (const d of ranked) { if (d.names?.some(entry => entry.toLowerCase() === name) === true) return d } if (reserved) return undefined for (const d of ranked) { if (d.exts?.includes(ext) === true) return d } return undefined } // Directory rows: a `folderNames` match on the directory's own basename // first, then the reserved `'folder'`/`'folder-open'` exts (a catch-all // never claims a directory). const matchFolderIcon = (open: boolean, name?: string): FileIconDescriptor | undefined => { const ranked = rankedFileIcons() if (name !== undefined) { const wanted = name.toLowerCase() for (const d of ranked) { if (d.folderNames?.some(entry => entry.toLowerCase() === wanted) === true) return d } } const want = open ? FOLDER_OPEN_EXT : FOLDER_EXT for (const d of ranked) { if (d.exts?.includes(want) === true) return d } return undefined } /** Run one registered factory; a throw is logged and returns undefined. */ const safeIcon = (d: FileIconDescriptor, path: string, size: number, open?: boolean): ReactNode => { try { return d.icon(path, size, open) } catch (error) { console.error(`[dsh-better-sidebar] file icon factory "${d.id}" error:`, error) return undefined } } // The authoritative file-icon chain (see the interface doc): specific name // or extension registration → registered catch-all → the host's own // file-type artwork. The catch-all ranks by priority desc then registration // order (first wins), which is what lets an external plugin own "every // extension I did not name" without also owning the ones DSH draws. const fileIcon = (path: string, size: number): ReactNode => { const specific = matchFileIcon(path) if (specific !== undefined) { const icon = safeIcon(specific, path, size) if (icon !== undefined) return icon } for (const d of rankedFileIcons()) { if (d.exts !== undefined && d.exts.length === 0) { const icon = safeIcon(d, path, size) if (icon !== undefined) return icon } } return builtinFileIcon(path, size) } // Directory rows: registered folderNames/folder/folder-open icon, else the // host's folder glyph. The row's path feeds the factory (a registration may // vary icons per directory) and `open` lets one descriptor render both // states. const folderIcon = (path: string, open: boolean, size: number): ReactNode => { const registered = matchFolderIcon(open, baseName(path)) if (registered !== undefined) { const icon = safeIcon(registered, path, size, open) if (icon !== undefined) return icon } return builtinFolderIcon(open, size) } // The enable switches come from the user's side card prefs (the shared // store the service is bound to): an absent key means enabled. const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => { const ext = extOf(path) // Single pass in priority order (descending; stable for equal // priorities — insertion order). Each descriptor gets first refusal in // its own turn: `detect` (when head bytes are available) beats its own // `exts`, and `exts: []` is a catch-all matching any path — so the // catch-all `code` viewer (-100) only sees paths no higher-priority // descriptor claimed. Disabled viewers are skipped entirely. for (const v of Array.from(viewers.values()).sort( (a, b) => (b.priority ?? 0) - (a.priority ?? 0), )) { if (!isViewerEnabled(v.id)) continue // Content sniff first (only when head bytes are available). if (head !== undefined && v.detect !== undefined) { if (v.detect(path, head)) return v // A catch-all with detect is SNIFF-ONLY: it must not blind-claim // paths it never sniffed (a magic-number viewer must not swallow // every file before the real viewers get their turn). if (v.exts.length === 0) continue } else if (v.exts.length === 0) { // Blind catch-all (no detect) claims anything; a sniff-only // catch-all (detect defined, no head yet) yields this round. if (v.detect === undefined) return v continue } if (v.exts.includes(ext)) return v } return undefined } const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => { // A type the user disabled in settings never opens — neither from the // + menu nor from derived flows (file opens, subagent auto-open, // external plugins). Already-open tabs keep rendering. if (!isTabEnabled(seed.type)) { console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`) return } const descriptor = tabs.get(seed.type) if (descriptor === undefined) return // A scope targets another session: the open lands in THAT session's // state (loaded on demand) without switching the UI's active session. const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (targetSessionId === undefined) return const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId } // ── Native right Sidebar ────────────────────────────────────────────── // With the native surface installed, every open except an explicit // bottom-panel one lands there: a file path becomes a resource address // (the native registry routes it to the plugin's file type), a path-less // editor open becomes the `files` page kind, and everything else becomes // a page open carrying the seed as navigation params. if (surface !== undefined && seed.target !== 'bottom') { const state = store.getSnapshot().state // The descriptor's own factory mints what a view needs beyond the seed: // the side chat's thread bootstrap / reattach meta, the terminal's // per-instance title. A `null` return refuses the open (terminal cap). const minted = descriptor.createTab === undefined || state === undefined ? undefined : descriptor.createTab(state) if (minted === null) return const title = seed.title ?? minted?.tab.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title) // Multi-instance kinds (terminal / browser / side chat / diff) mint a // fresh tab per open; single-instance kinds focus the existing one. const revealIfOpened = descriptor.createTab === undefined const synthetic: SidebarTab = { id: seed.id ?? minted?.tab.id ?? seed.type, type: seed.type, title, ...(seed.path === undefined ? {} : { path: seed.path }), ...(seed.diff === undefined ? {} : { diff: seed.diff }), ...(seed.meta === undefined && minted?.tab.meta === undefined ? {} : { meta: seed.meta ?? minted?.tab.meta }), } if (seed.path !== undefined) { surface.openResource({ sessionId: targetSessionId, address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path), revealIfOpened: true, }) } else if (seed.type === 'editor') { // The path-less editor window IS the file explorer. surface.openTab({ sessionId: targetSessionId, kind: 'files', params: {}, revealIfOpened: true }) } else { surface.openTab({ sessionId: targetSessionId, kind: seed.type, params: { title, ...(seed.url === undefined ? {} : { url: seed.url }), ...(seed.diff === undefined ? {} : { diff: seed.diff }), ...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }), }, revealIfOpened, }) } // The native surface reports one open event, not create-vs-focus, so a // lifecycle consumer hears onOpen (documented in the guide). safeCall(() => descriptor.onOpen?.(synthetic, callbackScope)) return } // Whether this open targets a session that is NOT the one on screen: a // targeted open must not auto-expand panels the user cannot see (the // expansion is about landing "in sight" for the CURRENT viewer). const activeSessionId = store.getSnapshot().sessionId const targetsInactiveSession = scope !== undefined && scope.sessionId !== activeSessionId // Lifecycle capture: `created` when the open minted a NEW tab (a // dedupe/id-safety-net focus is an ACTIVATION, not an open). let created: SidebarTab | undefined let activated: SidebarTab | undefined // A bottom-targeted open lands in the bottom workbench's own pane; the // right tree it would otherwise follow is no longer rendered (DSH's // native sidebar owns the right column). const land = openTabInBottomPane const reducer = (state: SidebarState): SidebarState => { // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.). let tab: SidebarTab let next: SidebarState if (descriptor.createTab !== undefined) { const result = descriptor.createTab(state) if (result === null) return state tab = result.tab next = applyDedupe(state, result.tab, descriptor, land) if (result.patch !== undefined) next = { ...next, ...result.patch } } else { tab = { id: seed.id ?? seed.type, type: seed.type, // A caller-provided title wins (the editor shows the file name); // otherwise the descriptor's (possibly i18n) title is the default. title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title), ...(seed.path !== undefined ? { path: seed.path } : {}), ...(seed.diff !== undefined ? { diff: seed.diff } : {}), ...(seed.meta !== undefined ? { meta: seed.meta } : {}), } next = applyDedupe(state, tab, descriptor, land) } // Classify the landing against the INPUT state FIRST: a FOCUS fires // onActivate with the tab that is active NOW; a real creation fires // onOpen with the minted tab. Both the dedupeKey match AND the id // match count as a focus — a descriptor deduping by key (e.g. editor // by path) can focus an existing tab for a NEW requested id, and // classifying that as a creation would fire onOpen with a phantom // tab that never closes. const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) const key = dedupeKey?.(tab) const inputTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) const existedByKey = key !== undefined && inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key) const existedById = tabOpenIn(state, tab.id) const isCreation = !existedByKey && !existedById // A URL seed pre-fills a NEWLY CREATED tab's path (the browser tab // navigates to it on mount); a FOCUS must never have its path // overwritten. An explicit seed.title still wins over a createTab- // minted default title (e.g. the sidebar-browser's hostname title). let landed: SidebarState = next if (seed.url !== undefined && isCreation) { landed = patchTab(next, tab.id, { path: seed.url, ...(seed.title !== undefined ? { title: seed.title } : {}), }) } // Lifecycle capture (before the auto-expand block, which early-returns). if (isCreation) { // Resolve the ACTUAL landed tab — the url patch mints a new object, // so the callback must see the tab that was really inserted. const landedTabs = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs) created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab } else { // A focus happened: resolve the tab that is actually active now and // report THAT to onActivate (never the caller's un-inserted seed). const candidates = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs) activated = key !== undefined ? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key) : candidates.find(candidate => candidate.id === tab.id) activated ??= tab } // Every open that lands in the workbench is visible: a creation // expands it (openTabInBottomPane) and so does a FOCUS (the dedupe/id // reducers only activate the tab). An open targeted at an INACTIVE // session expands THAT session's workbench — it is waiting for the // user when they switch to it. return landed.bottomOpen ? landed : { ...landed, bottomOpen: true } } // A scope targeting ANOTHER session lands the open there without // switching the UI; a scope naming the active session (or no scope) // takes the regular reduce path so the UI notifies and re-renders. if (targetsInactiveSession) { store.reduceFor(scope.sessionId, reducer) } else { store.reduce(reducer) } if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope)) else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope)) } const closeTab = (tabId: string, scope?: SessionScope): void => { const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (surface !== undefined && sessionId !== undefined) { const closedNative = surface.close(sessionId, tabId) if (closedNative !== undefined) { const descriptor = tabs.get(closedNative.type) if (descriptor !== undefined) { safeCall(() => descriptor.onClose?.( { id: tabId, type: closedNative.type as TabType, title: closedNative.title }, scope ?? { sessionId }, )) } return } } let closed: SidebarTab | undefined store.reduce((state) => { // Unknown tab ids are a strict no-op: no state churn, no notify, no // pointless localStorage rewrite (mirrors updateTab's short-circuit). if (!tabOpenIn(state, tabId)) return state const paneId = findPaneIdOf(state, tabId) const leaf = leafWithTab(state.bottomSplits, tabId) closed = leaf?.tabs.find(tab => tab.id === tabId) return closeTabReducer(state, paneId, tabId) }) if (closed !== undefined) { const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (sessionId !== undefined) { const descriptor = tabs.get(closed.type) // An explicit scope (with its optional cwd) rides to the callback. safeCall(() => descriptor?.onClose?.(closed!, scope ?? { sessionId })) } } } /** The snapshot the store publishes (state/prefs carry the active session). */ const getSnapshot = (): SidebarSnapshot => store.getSnapshot() /** Store changes: session switch, state mutations, prefs writes. */ const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener) /** Patch an open tab's display fields (a missing tab id is a no-op). */ const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => { if (surface?.update(tabId, patch) === true) return store.reduce((state) => patchTab(state, tabId, { ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.path !== undefined ? { path: patch.path } : {}), ...(patch.meta !== undefined ? { meta: patch.meta } : {}), })) } /** Activate an open tab (the tab-bar activation path; fires onActivate). */ const activateTab = (tabId: string, scope?: SessionScope): void => { if (surface?.activate(tabId) === true) return let activated: SidebarTab | undefined store.reduce((state) => { // Unknown tab ids are a strict no-op (no state churn / notify). if (!tabOpenIn(state, tabId)) return state const paneId = findPaneIdOf(state, tabId) const leaf = leafWithTab(state.bottomSplits, tabId) activated = leaf?.tabs.find(tab => tab.id === tabId) return activateTabReducer(state, paneId, tabId) }) if (activated !== undefined) { const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (sessionId !== undefined) { const descriptor = tabs.get(activated.type) // An explicit scope (with its optional cwd) rides to the callback. safeCall(() => descriptor?.onActivate?.(activated!, scope ?? { sessionId })) } } } /** Open a file in the sidebar editor of `scope`'s session (title defaults * to the file name; the tab id is path-derived, like the internal * open-path interception, so distinct files open side by side). */ const openFile = (scope: SessionScope, path: string, title?: string): void => { openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope) } return { registerTab, registerFileViewer, registerFileIcon, getTabs, getFileViewers, getFileIcons, matchFileIcon, matchFolderIcon, fileIcon, folderIcon, getTab, isTabEnabled, isViewerEnabled, matchFileViewer, openTab, closeTab, subscribe, version: SIDEBAR_SERVICE_VERSION, features: SIDEBAR_FEATURES, getSnapshot, subscribeState, updateTab, activateTab, openFile, setSurface: (next: SidebarSurface | undefined) => { surface = next }, } } /** * Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the * same type exists, focus it; otherwise land the tab through `land` (the id * safety net + landing are that reducer's job — not re-implemented here). * `single: true` resolves to the id-key sugar when no explicit key is given. */ function applyDedupe( state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor, land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInBottomPane, ): SidebarState { const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) const key = dedupeKey?.(tab) if (key !== undefined) { for (const leaf of allLeaves(state.bottomSplits)) { const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key) if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id) } } return land(state, tab) } /** Find which pane hosts a tab id ('' if none). */ function findPaneIdOf(state: SidebarState, tabId: string): string { for (const leaf of allLeaves(state.bottomSplits)) { if (leaf.tabs.some(t => t.id === tabId)) return leaf.id } return state.activePane ?? '' }