Files
apes-sidebar/src/client/native/surface.ts
T
Menghuan1918 51546f7433 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).
2026-09-09 11:47:31 +08:00

150 lines
5.8 KiB
TypeScript

/**
* 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() },
}
}