The fork was pinned at upstream v0.18.0. That is the entire reason the window
ran TWO sidebars and TWO near-identical panel toggles: DSH 2.0.10 ships its own
right sidebar (@deepseek-ai/dsh-client-ui-sidebar-right), and upstream only
adopted it in v0.19.0-alpha.1 — which arrived after our fork point. v0.19.1 is
also the one upstream line whose support range actually matches our host
(engines.dsh >=0.1.5-rc.1, 14 DSH peers ^0.1.5-rc.1, host = 0.1.5-rc.2), so it
is the only tag the host's startup precheck accepts.
What upstream v0.18.0 → v0.19.1 brings
- src/client/native/{index,surface,tab-adapter}.tsx: every tab the plugin can
draw becomes an OFFICIAL native tab type + body, and an external plugin's
descriptor registered later gets its native type too. This is what retires
the fork's own right panel — and with it the duplicate toggle cluster
(`toggleCluster` is gone from v0.19.1).
- native tab-chip icons via the `sidebar.right.pane.tab.title` seat, coloured
built-in tab icons, file/folder glyphs from DSH's own `FileTypeIcon`.
- guide descriptions (DSH renders them while the guide lists <= 4 entries),
tab bodies that fill the panel, and the 0.1.5-rc.2 adaptation work we were
carrying by hand (peer/devDep pinning, primitives' transitive devDeps).
How the sync was done
Rebuilt the tree from the v0.19.1 tag rather than resolving 53 conflicted
files: our v0.18.0 delta was measured first and is only (a) one mechanical
rename `dsh-better-sidebar` -> `dsh-apes-sidebar` and (b) ten real edits. So:
tag tree + rename (scoped: code/config/scripts/tests/README, while the 46
historical docs/plans records and switch.sh's upstream package name stay
untouched) + re-applied deltas + our apes module.
Fork deltas re-applied on the new base
- src/client/apes/ (icons / locales / tabs): the three apes:* information-layer
tabs (agents / apps / profile), registered through the same BetterSidebarService
as the builtins — which is exactly why they now ride upstream's native adapter
into the official sidebar for free.
- identity: package.json (name/version 1.2.0/description/forkedFrom), dsh.plugin.json
(id/version), SIDEBAR_SERVICE_VERSION 1.2.0, README fork notice,
docs/external-plugin-guide.md.
- scripts/switch.sh + the pnpm switch* scripts (apes <-> upstream two-line switch).
- tests/apes-tabs.spec.ts (unescaped: it starts from the v0.19.1 base).
Dropped
- the Phase A native-registration spike (`apes/native-tabs.tsx` + its spec): it
re-derived by hand exactly what upstream's native layer already does — the
hand-written bridge is dead weight now, and v0.19.1's own e2e/jsdom lanes
cover the same seat contract far better.
- the unused apesProfileGuideDesc key and the two package.json.bak.* backups.
Verified on the new base: typecheck clean; build clean; 1351 passed / 9 skipped
(128 files), up from 1255 on the old base.
283 lines
12 KiB
TypeScript
283 lines
12 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; 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-apes-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 fields carrying the descriptor's own description line, evaluated
|
|
* fresh for the current locale. DSH 0.1.5-rc.1+ renders `description` only
|
|
* while the guide lists at most 4 entries, and a descriptor that declares
|
|
* none must reach the host with NO `description` field at all — the host has
|
|
* no fallback of its own, so an empty string would render as a blank second
|
|
* line rather than a clean title-only capsule.
|
|
* @param descriptor - the tab descriptor owning the guide entry.
|
|
* @returns the `description` field, or an empty object.
|
|
*/
|
|
function guideDescriptionOf(descriptor: TabDescriptor | undefined): { description?: () => string } {
|
|
const description = descriptor?.description
|
|
return description === undefined ? {} : { description: () => (typeof description === 'function' ? description() : description) }
|
|
}
|
|
|
|
/**
|
|
* 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, service, descriptorId: injected.descriptorId }),
|
|
}, 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),
|
|
...guideDescriptionOf(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-apes-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 AND guide line: without them the "Files" row is
|
|
// the only guide entry with a blank icon slot and no description.
|
|
...guideDescriptionOf(editor),
|
|
...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 }
|