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).
150 lines
5.8 KiB
TypeScript
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() },
|
|
}
|
|
}
|