Baseline moves to DSH 0.1.5-alpha.2 only (peer floor ^0.1.5-alpha.2, CI pin, manifest engines); alpha.1 users stay on v0.19.0-alpha.0. Host-contract changes handled: - the native guide entry lost its `description` field (entries are icon+title capsules now): `TabDescriptor.description` and the six `guideDesc*` keys plus the `nativeGuideDesc` fallback are removed. - the file address grammar changed: `fileAddressFor` always returns a session-scoped address (absolute paths keep their leading `/`), `parseFileAddress` is prefix-based and strips `?`/`#`. The plugin's purity-gated mirror follows. - global main panels replaced the `conversation` root slot with the keyed `main` slot (`main.conversation`): the bottom workbench's center-column locator now accepts both keys and skips `display: contents` slot hosts. Fixes a pre-existing fill bug the alpha.2 probe made obvious: the native tab body host is a block scroller with a definite height, so tab roots that only declared `flex: 1` collapsed to content height and the side chat composer drifted off the pane bottom. The native adapter now wraps every tab body in a `height: 100%` column flex host. Also promotes the dependencies `dsh-client-ui-primitives@0.1.5-alpha.2` dropped from its manifest (anser / shiki / mdast-util-* / micromark-* / katex / @shikijs/langs) and the newly unmet `dsh-session-persistence` peer into devDependencies.
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
/**
|
|
* 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; 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
|
|
}
|
|
|
|
/**
|
|
* The guide row's glyph for a descriptor icon (nothing when it has none).
|
|
* The native guide renders `entry.icon`, so a takeover registered without one
|
|
* is the only row in the list with a blank leading slot.
|
|
* @param icon - the descriptor's icon value.
|
|
* @returns the guide-entry icon fields, or an empty object.
|
|
*/
|
|
function guideIconOf(icon: TabDescriptor['icon']): { icon?: (props: { size?: number }) => unknown } {
|
|
return typeof icon === 'function' ? { icon: (props: { size?: number }) => icon(props.size ?? 16) } : {}
|
|
}
|
|
|
|
/**
|
|
* The chip title of a file address: its own file name. A resource tab is
|
|
* identified by its address, so the strip must name the FILE — the
|
|
* descriptor's title ("Files") would make every open file look identical.
|
|
* @param address - the native tab's content address.
|
|
* @returns the file name, or undefined when the address is not a file.
|
|
*/
|
|
function fileTitleOf(address: string): string | undefined {
|
|
const parsed = parseFileAddress(address)
|
|
if (parsed === undefined) return undefined
|
|
const segments = parsed.path.split('/').filter(segment => segment !== '')
|
|
return segments.length === 0 ? undefined : segments[segments.length - 1]
|
|
}
|
|
|
|
/** 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
|
|
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
|
|
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
|
|
// `sidebarRightTabs`, so a declaration-triggered registration reads the
|
|
// service as missing and — because the declaration never collapses —
|
|
// registers nothing, permanently. Observed on a real profile: the
|
|
// declaration callback fired with both `sidebarRight` and
|
|
// `sidebarRightTabs` undefined, and the registry appeared a moment later.
|
|
// `ctx.inject` re-runs this body whenever the service appears/reappears,
|
|
// which is the lifecycle the registrations need.
|
|
const seat = ctx.inject(['sidebarRightTabs'], (injected) => {
|
|
const tabs = injected.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',
|
|
// A resource tab is titled by the file it shows; a page tab keeps the
|
|
// descriptor's own title.
|
|
title: (address: string) => (isEditor ? fileTitleOf(address) ?? titleOf(descriptor) : titleOf(descriptor)),
|
|
// No guide entry for the editor: its page identity is the `files`
|
|
// kind takeover below (same view, same title), so listing both would
|
|
// offer the reader two identical "Files" rows. The type itself stays
|
|
// registered as the file RESOURCE viewer.
|
|
...(descriptor.hidden === true || isEditor
|
|
? {}
|
|
: {
|
|
guide: [{
|
|
order: descriptor.order ?? 100,
|
|
title: () => titleOf(descriptor),
|
|
...guideIconOf(icon),
|
|
}],
|
|
}),
|
|
})
|
|
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 = (editor: TabDescriptor | undefined): (() => 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'),
|
|
// The takeover IS the editor descriptor's page, so it carries the
|
|
// editor's glyph: without it the "Files" row is the only guide
|
|
// entry with an empty icon slot.
|
|
...guideIconOf(editor?.icon),
|
|
}],
|
|
})
|
|
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(service.getTab(EDITOR_KIND)) })
|
|
}
|
|
if (!wantsFiles && hasFiles) {
|
|
live.get(FILES_KIND)?.dispose()
|
|
live.delete(FILES_KIND)
|
|
}
|
|
}
|
|
|
|
const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)]
|
|
sync()
|
|
return () => {
|
|
for (const registration of live.values()) registration.dispose()
|
|
live.clear()
|
|
for (const dispose of disposeSubscriptions.reverse()) dispose()
|
|
}
|
|
})
|
|
return () => {
|
|
void seat.dispose()
|
|
}
|
|
}
|
|
|
|
export type { NativeTabRecords }
|