Files
apes-sidebar/src/client/native/index.ts
T
Menghuan1918 0b6ac5d267 feat(dsh): adapt to DSH 0.1.5-alpha.2
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.
2026-09-09 23:09:48 +08:00

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 }