feat(native): render the plugin's tabs in DSH's native right Sidebar
Phase 2 of the 0.1.5 adaptation: the plugin stops drawing its own right
panel and contributes tab TYPES to DSH's native right Sidebar, which owns
the right column. The bottom workbench (terminal dock) stays plugin-owned.
Registrations (src/client/native/):
- every TabDescriptor becomes a native tab type (kind = descriptor.id, the
default `extension` band) plus a keyed body in `sidebar.right.pane.tab`
and a live chip title in `sidebar.right.pane.tab.title`
- the `editor` type also claims `dsh-resource://file/**`, so it outranks
the built-in `text` preview (fallback) and every file the product opens
lands in the plugin's editor
- the built-in `files` page kind is TAKEN OVER by an extension
registration of the same kind: `openTab('files')` draws the plugin's
explorer, and the built-in resumes when the plugin unregisters
- registrations follow the plugin registry + the side-card settings: a
type registered later (external plugin) gets its native type too, a
disabled type is unregistered (absent from the guide, refuses opens)
Adapter (src/client/native/tab-adapter.tsx):
- one synthetic plugin `SidebarTab` per native tab id, minted from the
native record + navigation params (or the descriptor's own createTab,
which a native guide open bypasses), kept live across navigations and
updateTab writes
- the explorer's per-tab expansion/reveal sets and the per-kind instance
counter, which the plugin's own layout used to hold
Service routing (src/client/service.ts):
- `setSurface` installs the native write face; openTab/openFile land
natively (a path becomes a resource address, a path-less editor open
becomes the `files` page, the rest carry the seed as navigation params),
closeTab/updateTab/activateTab recognise native tab ids
- new optional `OpenTabSeed.target` ('right' default, 'bottom' keeps the
bottom workbench); the bottom panel's own + menu and auto-terminal pass
'bottom'
- src/client/native/surface.ts probes the controller's per-session writes
(`openTabIn`/`openResourceIn`/`closeIn`, not in `ISidebarRight`) and
queues opens for a session whose surface is not mounted yet
Right panel retired behaviourally (Phase 3 deletes the code): Sidebar
renders its bottom workbench only, keeps the panel collapsed, and hides
the right-panel toggle. The panel-specific e2e specs are removed; the
mount lane now drives the native guide (tab sweep), the plugin explorer
inside the native pane (editor/mermaid/README chunks) and the sidechat
host routes.
Verified: pnpm typecheck / lint / build / test (126 files, 1349 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (7/7).
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* The plugin's content, registered into DSH's native right Sidebar.
|
||||
*
|
||||
* Every tab the plugin can draw becomes a native tab TYPE plus a BODY:
|
||||
*
|
||||
* - the `editor` type is both a page (the files window) and a resource
|
||||
* viewer — it claims `dsh-resource://file/**` at the default `extension`
|
||||
* band, which outranks the built-in `text` preview (`fallback`) and the
|
||||
* built-in `files` tree (`builtin`), so every file the product opens lands
|
||||
* in the plugin's editor;
|
||||
* - the built-in `files` page kind is TAKEN OVER by an `extension`
|
||||
* registration of the same kind, so `openTab('files')` draws the plugin's
|
||||
* explorer instead of the built-in tree; the built-in resumes when this
|
||||
* plugin unregisters;
|
||||
* - every other descriptor (changes / subagent / side chat / terminal /
|
||||
* browser / diff) becomes a page type of its own.
|
||||
*
|
||||
* The registrations follow the plugin's registry lifecycle: a descriptor
|
||||
* registered later (an external plugin) gets its native type too, and a type
|
||||
* the user disabled in the side-card settings is unregistered — so it is
|
||||
* absent from the native guide and `openTab` refuses it.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { parseFileAddress } from '../resource-address.ts'
|
||||
import type { BetterSidebarService, TabDescriptor } from '../service.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import {
|
||||
NativeTabBody,
|
||||
NativeTabTitle,
|
||||
type NativeBodyInjected,
|
||||
type NativeTabInfo,
|
||||
type NativeTabParams,
|
||||
type NativeTabRecords,
|
||||
} from './tab-adapter.tsx'
|
||||
|
||||
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
|
||||
interface NativeTabRegistry {
|
||||
register(definition: {
|
||||
id: string
|
||||
kind: string
|
||||
patterns?: readonly string[]
|
||||
priority?: 'extension' | 'builtin' | 'fallback'
|
||||
canOpen?: (address: string) => boolean
|
||||
title: (address: string) => string
|
||||
guide?: readonly { order: number; title: () => string; description: () => string; icon?: unknown }[]
|
||||
}): () => void
|
||||
}
|
||||
|
||||
/** The kind the plugin's files window owns (page + file viewer). */
|
||||
const EDITOR_KIND = 'editor'
|
||||
|
||||
/** The built-in page kind this plugin takes over. */
|
||||
const FILES_KIND = 'files'
|
||||
|
||||
/** The plugin's implementation id for a descriptor (unique across kinds). */
|
||||
function nativeId(descriptorId: string): string {
|
||||
return `dsh-better-sidebar:${descriptorId}`
|
||||
}
|
||||
|
||||
/** The descriptor's title text, evaluated fresh for the current locale. */
|
||||
function titleOf(descriptor: TabDescriptor): string {
|
||||
return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
||||
}
|
||||
|
||||
/** One descriptor's live native registrations. */
|
||||
interface Registration {
|
||||
readonly dispose: () => void
|
||||
}
|
||||
|
||||
/** Everything the registrations need. */
|
||||
export interface NativeSurfaceDeps {
|
||||
readonly ctx: Context
|
||||
readonly store: SidebarStore
|
||||
readonly service: BetterSidebarService
|
||||
/** The shared native tab record registry (the surface writes it too). */
|
||||
readonly records: NativeTabRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the plugin's tabs into the native right Sidebar and keep them in
|
||||
* step with the plugin's own registry and settings.
|
||||
* @param deps - client context, the plugin store/service, and the records.
|
||||
* @returns a disposer unregistering everything.
|
||||
*/
|
||||
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
const { ctx, store, service, records } = deps
|
||||
const disposers: Array<() => void> = []
|
||||
// The native seat declares `sidebar.right.pane.tab` when its client half
|
||||
// activates, which may be after this plugin; the slot registry's `inject`
|
||||
// re-runs on every declaration lifetime, and the tab-type registry
|
||||
// (`ctx.sidebarRightTabs`) is provided by that same package, so one wait
|
||||
// covers both.
|
||||
const disposeSeat = ctx.slots.inject('sidebar.right.pane.tab', () => {
|
||||
const tabs = ctx.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
|
||||
if (tabs === undefined) return () => {}
|
||||
const live = new Map<string, Registration>()
|
||||
|
||||
const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => {
|
||||
const address = parseFileAddress(info.tab.contentId)
|
||||
return address === undefined ? undefined : { path: address.path }
|
||||
}
|
||||
const fileSessionIdOf = (info: NativeTabInfo): string | undefined => {
|
||||
const address = parseFileAddress(info.tab.contentId)
|
||||
return address !== undefined && address.scope === 'session' ? address.sessionId : undefined
|
||||
}
|
||||
|
||||
/** Register the body + chip-title slots for one native implementation id. */
|
||||
const registerSlots = (
|
||||
id: string,
|
||||
injected: Omit<NativeBodyInjected, 'sessionId'>,
|
||||
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
|
||||
): Array<() => void> => [
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: id,
|
||||
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
|
||||
}, NativeTabBody)),
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: id,
|
||||
inject: () => ({ records }),
|
||||
}, NativeTabTitle)),
|
||||
]
|
||||
|
||||
/** One descriptor's native type + body + title, as one disposable. */
|
||||
const registerDescriptor = (descriptor: TabDescriptor): (() => void) => {
|
||||
const id = nativeId(descriptor.id)
|
||||
const isEditor = descriptor.id === EDITOR_KIND
|
||||
const icon = descriptor.icon
|
||||
const disposeType = tabs.register({
|
||||
id,
|
||||
kind: descriptor.id,
|
||||
...(isEditor
|
||||
? {
|
||||
patterns: ['dsh-resource://file/**'],
|
||||
canOpen: (address: string) => parseFileAddress(address) !== undefined,
|
||||
}
|
||||
: {}),
|
||||
// An external implementation outranks the product's own viewers, which
|
||||
// is what lets the plugin's editor take over file addresses.
|
||||
priority: 'extension',
|
||||
title: () => titleOf(descriptor),
|
||||
...(descriptor.hidden === true
|
||||
? {}
|
||||
: {
|
||||
guide: [{
|
||||
order: descriptor.order ?? 100,
|
||||
title: () => titleOf(descriptor),
|
||||
description: () => t('nativeGuideDesc'),
|
||||
...(typeof icon === 'function'
|
||||
? { icon: (props: { size?: number }) => icon(props.size ?? 16) }
|
||||
: {}),
|
||||
}],
|
||||
}),
|
||||
})
|
||||
const slots = registerSlots(
|
||||
id,
|
||||
{ ctx, store, service, records, descriptorId: descriptor.id },
|
||||
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
|
||||
)
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
}
|
||||
}
|
||||
|
||||
/** One `files`-kind takeover: the plugin's explorer under the built-in kind. */
|
||||
const registerFilesKind = (): (() => void) => {
|
||||
const id = 'dsh-better-sidebar:files'
|
||||
const disposeType = tabs.register({
|
||||
id,
|
||||
kind: FILES_KIND,
|
||||
priority: 'extension',
|
||||
title: () => t('files'),
|
||||
guide: [{
|
||||
order: 10,
|
||||
title: () => t('files'),
|
||||
description: () => t('nativeGuideDesc'),
|
||||
}],
|
||||
})
|
||||
const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bring the live registrations in line with the registry + the settings. */
|
||||
const sync = (): void => {
|
||||
const wanted = new Map<string, () => () => void>()
|
||||
for (const descriptor of service.getTabs()) {
|
||||
if (!service.isTabEnabled(descriptor.id)) continue
|
||||
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
|
||||
}
|
||||
for (const [descriptorId, registration] of live) {
|
||||
if (wanted.has(descriptorId)) continue
|
||||
registration.dispose()
|
||||
live.delete(descriptorId)
|
||||
}
|
||||
for (const [descriptorId, create] of wanted) {
|
||||
if (live.has(descriptorId)) continue
|
||||
live.set(descriptorId, { dispose: create() })
|
||||
}
|
||||
// The built-in files kind follows the editor type's switch: with the
|
||||
// editor disabled the plugin has no explorer to put there.
|
||||
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
|
||||
const hasFiles = live.has(FILES_KIND)
|
||||
if (wantsFiles && !hasFiles) live.set(FILES_KIND, { dispose: registerFilesKind() })
|
||||
if (!wantsFiles && hasFiles) {
|
||||
live.get(FILES_KIND)?.dispose()
|
||||
live.delete(FILES_KIND)
|
||||
}
|
||||
}
|
||||
|
||||
disposers.push(service.subscribe(sync), store.subscribe(sync))
|
||||
sync()
|
||||
return () => {
|
||||
for (const registration of live.values()) registration.dispose()
|
||||
live.clear()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
disposeSeat()
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export type { NativeTabRecords }
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The plugin's write face over DSH's native right Sidebar (`ctx.sidebarRight`).
|
||||
*
|
||||
* The service speaks in the plugin's own vocabulary (tab type, seed, session
|
||||
* scope); this module turns those into the native surface's vocabulary
|
||||
* (kind + navigation params, or a `dsh-resource://` address) and forwards
|
||||
* tab-record operations to the plugin's native record registry.
|
||||
*
|
||||
* Two native limits shape the implementation:
|
||||
*
|
||||
* - the surface exists only while a session's panel is mounted, and the
|
||||
* service's public face (`ISidebarRight`) writes only into THAT session.
|
||||
* The controller also carries `openTabIn` / `openResourceIn` /
|
||||
* `closeIn`, which act on any session whose store the runtime has minted;
|
||||
* both are probed at call time, and an open for a session that has no
|
||||
* store yet is QUEUED and replayed when that session comes on screen;
|
||||
* - layout state is memory-only, so a queued open is not durable either.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { fileAddressFor } from '../resource-address.ts'
|
||||
import type { NativeTabParams, SidebarSurface } from '../service.ts'
|
||||
import type { NativeTabRecords } from './tab-adapter.tsx'
|
||||
|
||||
/** One open the surface could not place yet. */
|
||||
type Pending =
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
|
||||
|
||||
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
|
||||
interface NativeController {
|
||||
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
close(tabId: string): void
|
||||
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
|
||||
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
closeIn?(sessionId: string, tabId: string): void
|
||||
}
|
||||
|
||||
/** The plugin's write face over the native surface. */
|
||||
export interface NativeSurface extends SidebarSurface {
|
||||
/** Replay opens that were queued for a session that had no mounted surface. */
|
||||
flushPending(): void
|
||||
/** Stop observing the session list. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** The active session id, as the client list reports it. */
|
||||
function activeSessionId(ctx: Context): string | undefined {
|
||||
try {
|
||||
return ctx.sessions.list.getSnapshot().current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the plugin's write face to the native controller.
|
||||
* @param ctx - the client context (session list + `ctx.sidebarRight`).
|
||||
* @param records - the plugin's native tab record registry.
|
||||
* @returns the surface, plus a disposer unbinding its session subscription.
|
||||
*/
|
||||
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
|
||||
const pending: Pending[] = []
|
||||
const controller = (): NativeController | undefined =>
|
||||
ctx.get('sidebarRight') as unknown as NativeController | undefined
|
||||
|
||||
const place = (entry: Pending): boolean => {
|
||||
const api = controller()
|
||||
if (api === undefined) return false
|
||||
const active = activeSessionId(ctx)
|
||||
const onScreen = active !== undefined && active === entry.sessionId
|
||||
if (entry.kind === 'tab') {
|
||||
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
|
||||
if (onScreen) {
|
||||
api.openTab(entry.tabKind, options)
|
||||
return true
|
||||
}
|
||||
if (api.openTabIn !== undefined) {
|
||||
api.openTabIn(entry.sessionId, entry.tabKind, options)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const options = {
|
||||
...(entry.line === undefined ? {} : { params: { line: entry.line } }),
|
||||
revealIfOpened: entry.revealIfOpened,
|
||||
}
|
||||
if (onScreen) {
|
||||
api.openResource(entry.address, options)
|
||||
return true
|
||||
}
|
||||
if (api.openResourceIn !== undefined) {
|
||||
api.openResourceIn(entry.sessionId, entry.address, options)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const flushPending = (): void => {
|
||||
if (pending.length === 0) return
|
||||
for (let index = pending.length - 1; index >= 0; index--) {
|
||||
const entry = pending[index]
|
||||
if (entry !== undefined && place(entry)) pending.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const enqueue = (entry: Pending): void => {
|
||||
if (!place(entry)) pending.push(entry)
|
||||
}
|
||||
|
||||
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
|
||||
return {
|
||||
openTab({ sessionId, kind, params, revealIfOpened }) {
|
||||
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
|
||||
},
|
||||
openResource({ sessionId, address, line, revealIfOpened }) {
|
||||
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened })
|
||||
},
|
||||
fileAddress(sessionId, cwd, path) {
|
||||
return fileAddressFor(sessionId, cwd, path)
|
||||
},
|
||||
close(sessionId, tabId) {
|
||||
const record = records.get(tabId)
|
||||
if (record === undefined) return undefined
|
||||
records.drop(tabId)
|
||||
const api = controller()
|
||||
if (api !== undefined) {
|
||||
if (sessionId === activeSessionId(ctx)) api.close(tabId)
|
||||
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
|
||||
}
|
||||
return { type: record.tab.type, title: record.tab.title }
|
||||
},
|
||||
update(tabId, patch) {
|
||||
if (!records.has(tabId)) return false
|
||||
records.update(tabId, patch)
|
||||
return true
|
||||
},
|
||||
activate(tabId) {
|
||||
// The native surface has no cross-pane activation face the plugin needs:
|
||||
// a tab is focused by opening its (kind, address) again, which the
|
||||
// native open already de-duplicates.
|
||||
return records.has(tabId)
|
||||
},
|
||||
has: tabId => records.has(tabId),
|
||||
flushPending,
|
||||
dispose: () => { unsubscribe() },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* The plugin's side of a native right-Sidebar tab.
|
||||
*
|
||||
* A plugin tab descriptor is one registration in the plugin's own registry
|
||||
* (`ctx.betterSidebar`); this module adapts the native tab record to that
|
||||
* descriptor's component contract. The native surface carries the tab's
|
||||
* record (id, kind, title, content identity, navigation params, lifetime
|
||||
* signal); the plugin's components expect a `SidebarTab` plus the explorer
|
||||
* state the plugin's own layout used to hold — both live here.
|
||||
*
|
||||
* The adapter owns three things the native layout does not carry:
|
||||
*
|
||||
* - a synthetic plugin `SidebarTab` per native tab id, minted from the native
|
||||
* record + `navigation.params` and kept live across navigations, so a
|
||||
* component that retitles itself (`updateTab`) or rewrites its path (the
|
||||
* editor's in-place file switch) keeps working;
|
||||
* - the explorer's per-tab expansion/reveal sets;
|
||||
* - the per-kind instance counter behind titles like "Terminal 2".
|
||||
*
|
||||
* Nothing here is a singleton: the registry is created once per client
|
||||
* activation and handed to every registration.
|
||||
*/
|
||||
import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import { RenderBoundary } from '../RenderBoundary.tsx'
|
||||
import { OrphanedTab } from '../OrphanedTab.tsx'
|
||||
import { referenceInChat } from '../reference-in-chat.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import type { SidebarStore, SidebarTab, TabType } from '../state.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* The plugin-side seed a native open carries in `navigation.params`.
|
||||
* JSON-shaped by convention (the native surface does not validate it).
|
||||
*/
|
||||
export interface NativeTabParams {
|
||||
/** Overrides the descriptor's title for this instance. */
|
||||
readonly title?: string
|
||||
/** A file path (the editor window's content seed). */
|
||||
readonly path?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
readonly url?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
readonly diff?: SidebarTab['diff']
|
||||
/** JSON-serializable custom state carried on the synthetic record. */
|
||||
readonly meta?: unknown
|
||||
/** A line to land on (file addresses carry it as a navigation parameter). */
|
||||
readonly line?: number
|
||||
}
|
||||
|
||||
/** The native tab information this adapter reads (structural mirror of `useTabInfo`). */
|
||||
export interface NativeTabInfo {
|
||||
readonly tab: {
|
||||
readonly id: string
|
||||
readonly kind: string
|
||||
readonly title: string
|
||||
readonly contentId: string
|
||||
readonly visible: boolean
|
||||
readonly navigation: {
|
||||
readonly address: string
|
||||
readonly params: NativeTabParams | undefined
|
||||
readonly revision: number
|
||||
}
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
}
|
||||
|
||||
/** One native tab's plugin-side view state. */
|
||||
interface View {
|
||||
tab: SidebarTab
|
||||
scope: SessionScope
|
||||
expanded: string[]
|
||||
revealed: string[]
|
||||
/** Bumped on every mutation; the components subscribe to it. */
|
||||
version: number
|
||||
}
|
||||
|
||||
/** The plugin-side record registry for native tabs. */
|
||||
export interface NativeTabRecords {
|
||||
/**
|
||||
* The synthetic record for a native tab, minted on first sight and kept
|
||||
* across navigations (a navigation refreshes the seed fields, never the
|
||||
* identity or a plugin-side title/meta mutation).
|
||||
* @param input - the native record and the session it lives in.
|
||||
* @returns the current view state.
|
||||
*/
|
||||
ensure(input: {
|
||||
id: string
|
||||
kind: string
|
||||
title: string
|
||||
params: NativeTabParams | undefined
|
||||
scope: SessionScope
|
||||
/**
|
||||
* The descriptor's own factory, called ONCE for a record that arrives
|
||||
* without seed fields (a native guide open, which knows nothing about the
|
||||
* plugin's per-instance minting): it supplies the title and the meta a
|
||||
* view needs — the side chat's thread bootstrap, the terminal's name.
|
||||
*/
|
||||
mint?: () => { title?: string; meta?: unknown } | undefined
|
||||
}): View
|
||||
/** One record by native tab id. */
|
||||
get(id: string): View | undefined
|
||||
/** Whether this id belongs to a native tab (vs the plugin's own layout). */
|
||||
has(id: string): boolean
|
||||
/** Merge a patch into the synthetic record (the `updateTab` path). */
|
||||
update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** Forget a record (the native tab closed). */
|
||||
drop(id: string): void
|
||||
/** Toggle one directory in a record's expansion set. */
|
||||
toggleExpanded(id: string, path: string): void
|
||||
/** Mint the next instance number of a kind (titles like "Terminal 2"). */
|
||||
nextInstance(kind: string): number
|
||||
/** A per-record version for `useSyncExternalStore`. */
|
||||
versionOf(id: string): number
|
||||
/** Subscribe to record changes (title/path/meta/expanded). */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Create the record registry for one client activation. */
|
||||
export function createNativeTabRecords(): NativeTabRecords {
|
||||
const views = new Map<string, View>()
|
||||
const instances = new Map<string, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
const notify = (): void => { for (const listener of listeners) listener() }
|
||||
const put = (id: string, view: View): void => {
|
||||
views.set(id, { ...view, version: view.version + 1 })
|
||||
notify()
|
||||
}
|
||||
return {
|
||||
ensure({ id, kind, title, params, scope, mint }) {
|
||||
const existing = views.get(id)
|
||||
if (existing === undefined) {
|
||||
const seeded = params?.title === undefined && params?.meta === undefined ? mint?.() : undefined
|
||||
const meta = params?.meta ?? seeded?.meta
|
||||
const minted: View = {
|
||||
tab: {
|
||||
id,
|
||||
type: kind as TabType,
|
||||
title: params?.title ?? seeded?.title ?? title,
|
||||
...(params?.path === undefined ? {} : { path: params.path }),
|
||||
...(params?.diff === undefined ? {} : { diff: params.diff }),
|
||||
...(meta === undefined ? {} : { meta }),
|
||||
},
|
||||
scope,
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
version: 0,
|
||||
}
|
||||
views.set(id, minted)
|
||||
return minted
|
||||
}
|
||||
// A navigation may carry new seed fields (the editor's in-place switch,
|
||||
// a browser tab pointed at another URL); the record's identity and any
|
||||
// plugin-side mutation (title/meta from updateTab) stay.
|
||||
const patch: Partial<SidebarTab> = {}
|
||||
if (params?.path !== undefined && params.path !== existing.tab.path) patch.path = params.path
|
||||
if (params?.diff !== undefined) patch.diff = params.diff
|
||||
if (params?.url !== undefined) {
|
||||
const meta = typeof existing.tab.meta === 'object' && existing.tab.meta !== null
|
||||
? existing.tab.meta as Record<string, unknown>
|
||||
: {}
|
||||
patch.meta = { ...meta, url: params.url }
|
||||
}
|
||||
if (existing.scope.cwd !== scope.cwd) {
|
||||
views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } })
|
||||
return views.get(id)!
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return existing
|
||||
const next: View = { ...existing, tab: { ...existing.tab, ...patch } }
|
||||
views.set(id, next)
|
||||
return next
|
||||
},
|
||||
get: id => views.get(id),
|
||||
has: id => views.has(id),
|
||||
update(id, patch) {
|
||||
const entry = views.get(id)
|
||||
if (entry === undefined) return
|
||||
put(id, { ...entry, tab: { ...entry.tab, ...patch } })
|
||||
},
|
||||
drop(id) {
|
||||
if (views.delete(id)) notify()
|
||||
},
|
||||
toggleExpanded(id, path) {
|
||||
const entry = views.get(id)
|
||||
if (entry === undefined) return
|
||||
const expanded = entry.expanded.includes(path)
|
||||
? entry.expanded.filter(candidate => candidate !== path)
|
||||
: [...entry.expanded, path]
|
||||
put(id, { ...entry, expanded })
|
||||
},
|
||||
nextInstance(kind) {
|
||||
const next = (instances.get(kind) ?? 0) + 1
|
||||
instances.set(kind, next)
|
||||
return next
|
||||
},
|
||||
versionOf: id => views.get(id)?.version ?? 0,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** What a body registration injects (the plugin's business face). */
|
||||
export interface NativeBodyInjected {
|
||||
readonly sessionId: string
|
||||
readonly ctx: Context
|
||||
readonly store: SidebarStore
|
||||
readonly service: BetterSidebarService
|
||||
readonly records: NativeTabRecords
|
||||
/** The descriptor id this body draws (one registration per descriptor). */
|
||||
readonly descriptorId: string
|
||||
/**
|
||||
* Extra seed fields derived from the native record — a file address carries
|
||||
* its path there, not in `navigation.params`.
|
||||
*/
|
||||
readonly paramsOf?: (info: NativeTabInfo) => NativeTabParams | undefined
|
||||
/**
|
||||
* The session the body acts in, when the record names one (a
|
||||
* `session`-scoped file address names its own session); absent falls back to
|
||||
* the session the slot is scoped to.
|
||||
*/
|
||||
readonly sessionIdOf?: (info: NativeTabInfo) => string | undefined
|
||||
}
|
||||
|
||||
/** The props the slot framework adds to every tab body and title. */
|
||||
export interface NativeBodyFrameworkProps {
|
||||
readonly useTabInfo: () => NativeTabInfo
|
||||
}
|
||||
|
||||
/** Subscribe a component to its own record's mutations. */
|
||||
function useRecordVersion(records: NativeTabRecords, id: string): number {
|
||||
return useSyncExternalStore(
|
||||
listener => records.subscribe(listener),
|
||||
() => records.versionOf(id),
|
||||
)
|
||||
}
|
||||
|
||||
/** The current session's workspace root, live from the client session list. */
|
||||
function useSessionCwd(ctx: Context, sessionId: string): string | undefined {
|
||||
return useSyncExternalStore(
|
||||
useMemo(() => (listener: () => void) => ctx.sessions.list.subscribe(listener), [ctx]),
|
||||
() => ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin tab rendered inside the native right Sidebar: the descriptor's
|
||||
* own component with the plugin's props, over a synthetic record minted from
|
||||
* the native tab and dropped when the record ends.
|
||||
*/
|
||||
export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkProps): ReactNode {
|
||||
const { ctx, store, service, records, descriptorId, useTabInfo } = props
|
||||
const info = useTabInfo()
|
||||
const nativeTab = info.tab
|
||||
const version = useRecordVersion(records, nativeTab.id)
|
||||
const sessionId = props.sessionIdOf?.(info) ?? props.sessionId
|
||||
const cwd = useSessionCwd(ctx, sessionId)
|
||||
const scope = useMemo((): SessionScope => ({ sessionId, cwd }), [sessionId, cwd])
|
||||
// `version` is not read: it only forces this render when the record changed.
|
||||
void version
|
||||
const derived = props.paramsOf?.(info)
|
||||
const params = derived === undefined && nativeTab.navigation.params === undefined
|
||||
? undefined
|
||||
: { ...derived, ...nativeTab.navigation.params }
|
||||
const descriptor = service.getTab(descriptorId)
|
||||
const view = records.ensure({
|
||||
id: nativeTab.id,
|
||||
kind: nativeTab.kind,
|
||||
title: nativeTab.title,
|
||||
params,
|
||||
scope,
|
||||
mint: () => {
|
||||
const state = store.getSnapshot().state
|
||||
if (descriptor?.createTab === undefined || state === undefined) return undefined
|
||||
const minted = descriptor.createTab(state)
|
||||
return minted === null ? undefined : { title: minted.tab.title, meta: minted.tab.meta }
|
||||
},
|
||||
})
|
||||
useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id])
|
||||
if (descriptor === undefined) {
|
||||
return createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible })
|
||||
}
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
createElement(descriptor.component, {
|
||||
ctx,
|
||||
store,
|
||||
scope,
|
||||
tab: view.tab,
|
||||
visible: nativeTab.visible,
|
||||
expanded: view.expanded,
|
||||
revealed: view.revealed,
|
||||
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
|
||||
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
|
||||
onOpenDiff: (tab: SidebarTab) => {
|
||||
service.openTab({
|
||||
type: 'diff',
|
||||
title: tab.title,
|
||||
id: tab.id,
|
||||
...(tab.diff === undefined ? {} : { diff: tab.diff }),
|
||||
}, scope)
|
||||
},
|
||||
onSubagentJump: (childSessionId: string) => {
|
||||
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** What a title registration injects. */
|
||||
export interface NativeTitleInjected {
|
||||
readonly records: NativeTabRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* A live tab chip: the synthetic record's title (the editor rewrites it on an
|
||||
* in-place file switch, the side chat on the thread's first prompt). Without
|
||||
* this registration the chip would keep the title captured when the tab
|
||||
* opened.
|
||||
*/
|
||||
export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkProps): ReactNode {
|
||||
const { records, useTabInfo } = props
|
||||
const nativeTab = useTabInfo().tab
|
||||
return useSyncExternalStore(
|
||||
listener => records.subscribe(listener),
|
||||
() => records.get(nativeTab.id)?.tab.title ?? nativeTab.title,
|
||||
)
|
||||
}
|
||||
|
||||
/** The component pair one descriptor contributes to the native surface. */
|
||||
export interface NativeTabComponents {
|
||||
readonly body: ComponentType<NativeBodyInjected & NativeBodyFrameworkProps>
|
||||
readonly title: ComponentType<NativeTitleInjected & NativeBodyFrameworkProps>
|
||||
}
|
||||
Reference in New Issue
Block a user