feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).
Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
addressed by the plugin row's Loader entry id and read from the
module's exported Config. The preferences are merged into Config and
marked volatile — which requires DSH's own schemastery, because only
that build wraps a volatile field in the cosmokit reference the
loader's volatile commit walks. With the public package a settings
write reports success and revision 1 while the effective value never
changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
mode is render-time, not load-time. Five call sites that relied on the
old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
boundary injection now writes plugin:dsh-better-sidebar, the same kind
DSH's own v3->v4 migration produces, so historical threads survive.
Tool results moved to role 'tool' with top-level content/isError; all
four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
and the on-demand subagent-catalog observation API. The Tasks page
reads projectionsBySession and a new jobs.list route.
Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
spreadsheet worker, zoom, per-directory refresh); editor.canOpen
refuses those 26 extensions. Markdown, HTML (with its sandbox
switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
host, fixing a regression from the 0.1.6 round: the fallback pointed
at a 'browser' type the plugin no longer registers, so http links in
its own markdown did nothing.
Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
refreshSubagents do not exist on the current baseline, so the
topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
primitives call at module-evaluation time; and the e2e token exchange
needs Playwright's request stack, not Node's fetch.
This commit is contained in:
@@ -5,9 +5,11 @@
|
||||
*
|
||||
* - 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;
|
||||
* band, which outranks the built-in `text` preview (`fallback`), so a file
|
||||
* the product opens lands in the plugin's editor — EXCEPT the formats
|
||||
* {@link HOST_OWNED_EXTS} hands back to DSH's own previews, which are
|
||||
* strictly better for binary documents (host-side Office→PDF conversion,
|
||||
* a worker-backed spreadsheet table, a zoom viewport);
|
||||
* - 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
|
||||
@@ -34,6 +36,42 @@ import {
|
||||
type NativeTabRecords,
|
||||
} from './tab-adapter.tsx'
|
||||
|
||||
/**
|
||||
* Extensions DSH's own previews own, and this plugin therefore refuses.
|
||||
*
|
||||
* DSH 0.1.7 grew a real document-preview package (host-side Office→PDF
|
||||
* conversion, a worker-backed spreadsheet table, image/PDF zoom viewports and
|
||||
* per-directory auto-refresh) for exactly these formats, while the plugin's
|
||||
* equivalents are read-only fallbacks. The plugin keeps what the built-in does
|
||||
* NOT do: Markdown through its own renderer, HTML through its sandboxed route
|
||||
* with the `htmlViewerNoSandbox` safety valve, and — through the `code`
|
||||
* catch-all — a genuinely EDITABLE CodeMirror buffer for every text file.
|
||||
*
|
||||
* Refusing here is what hands the address over: `canOpen` returning false
|
||||
* leaves the built-in `text` type (the `fallback` band) as the only claimant.
|
||||
*/
|
||||
const HOST_OWNED_EXTS: ReadonlySet<string> = new Set([
|
||||
// Spreadsheets: the built-in renders a table in a worker.
|
||||
'xlsx', 'xls', 'xlsb', 'xlt', 'xltx', 'xltm', 'ods', 'ots', 'fods', 'csv', 'tsv',
|
||||
// PDF: the built-in viewer pages and zooms.
|
||||
'pdf',
|
||||
// Images: the built-in viewer adds a zoom viewport.
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif',
|
||||
// Office documents: the built-in converts them to PDF host-side.
|
||||
'doc', 'docx', 'dot', 'dotx', 'ppt', 'pptx',
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether DSH's own preview owns one path.
|
||||
* @param path - the address's decoded path.
|
||||
* @returns true for a {@link HOST_OWNED_EXTS} extension (a dotfile is not one).
|
||||
*/
|
||||
function hostOwnedPath(path: string): boolean {
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot > 0 && HOST_OWNED_EXTS.has(name.slice(dot + 1).toLowerCase())
|
||||
}
|
||||
|
||||
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
|
||||
interface NativeTabRegistry {
|
||||
register(definition: {
|
||||
@@ -190,7 +228,12 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
...(isEditor
|
||||
? {
|
||||
patterns: ['dsh-resource://file/**'],
|
||||
canOpen: (address: string) => parseFileAddress(address) !== undefined,
|
||||
canOpen: (address: string) => {
|
||||
const file = parseFileAddress(address)
|
||||
// A non-file address, or one of the formats the built-in
|
||||
// previews own, leaves the address to DSH's own `text` type.
|
||||
return file !== undefined && !hostOwnedPath(file.path)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// An external implementation outranks the product's own viewers, which
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
*
|
||||
* - 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;
|
||||
* "Which session that is" comes from the controller's `mounted` observation
|
||||
* ({@link mountedSessions}) — never from the session list, which has no
|
||||
* current-session field. 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'
|
||||
@@ -26,11 +29,24 @@ type Pending =
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
|
||||
|
||||
/**
|
||||
* The observation "which session's seat is on screen": DSH 0.1.7 publishes it
|
||||
* as `ISidebarRight.mounted` (`ObservableSnapshot<SessionId | undefined>`, set
|
||||
* only when the mounted seat really changes). `undefined` means NO seat is
|
||||
* drawn — a global panel, or a right column that was never mounted.
|
||||
*/
|
||||
export interface MountedSessions {
|
||||
getSnapshot(): string | undefined
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** The mounted-seat observation (0.1.7 `ISidebarRight.mounted`). */
|
||||
mounted?: MountedSessions
|
||||
/** 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
|
||||
@@ -41,35 +57,82 @@ interface NativeController {
|
||||
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. */
|
||||
/** Stop observing the session list and the mounted-seat feed. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** The active session id, as the client list reports it. */
|
||||
function activeSessionId(ctx: Context): string | undefined {
|
||||
/** The native controller, probed at call time (the service can arrive late). */
|
||||
function controllerOf(ctx: Context): NativeController | undefined {
|
||||
try {
|
||||
return ctx.sessions.list.getSnapshot().current
|
||||
return ctx.get('sidebarRight') as unknown as NativeController | undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-screen-session feed, as anything outside this module should read it.
|
||||
*
|
||||
* The session-list snapshot carries NO current-session field in any DSH
|
||||
* release (0.1.6 and 0.1.7 both publish only `ids` / `byId` / `phase` plus
|
||||
* projections), so a read of one was always `undefined`: `mounted` is the
|
||||
* only sanctioned source, and the plugin's own type invented the field it
|
||||
* used to read.
|
||||
*
|
||||
* The probe tolerates a host without `mounted` (or a controller the runtime
|
||||
* has not provided yet — the seat race this plugin already hit once on
|
||||
* 0.1.5): the session list then doubles as the change pulse, so a late
|
||||
* service is still picked up on the next list publish instead of never.
|
||||
*
|
||||
* @param ctx - the client context.
|
||||
* @returns the observable face of the mounted seat's session id.
|
||||
*/
|
||||
export function mountedSessions(ctx: Context): MountedSessions {
|
||||
return {
|
||||
getSnapshot: () => {
|
||||
try {
|
||||
const mounted = controllerOf(ctx)?.mounted
|
||||
return typeof mounted?.getSnapshot === 'function' ? mounted.getSnapshot() : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
const mounted = controllerOf(ctx)?.mounted
|
||||
return typeof mounted?.subscribe === 'function'
|
||||
? mounted.subscribe(listener)
|
||||
: ctx.sessions.list.subscribe(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session whose seat is on screen, or `undefined` — no seat is mounted
|
||||
* (global panel, column not mounted) or the host has no mounted feed. Callers
|
||||
* treat `undefined` as "not this session": nothing the plugin draws belongs to
|
||||
* that surface, so it must neither write into it nor manage its column.
|
||||
*
|
||||
* @param ctx - the client context.
|
||||
* @returns the on-screen session id, when there is one.
|
||||
*/
|
||||
export function mountedSessionId(ctx: Context): string | undefined {
|
||||
return mountedSessions(ctx).getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns the surface, plus a disposer unbinding its two feed subscriptions.
|
||||
*/
|
||||
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
|
||||
const pending: Pending[] = []
|
||||
const controller = (): NativeController | undefined =>
|
||||
ctx.get('sidebarRight') as unknown as NativeController | undefined
|
||||
const controller = (): NativeController | undefined => controllerOf(ctx)
|
||||
|
||||
const place = (entry: Pending): boolean => {
|
||||
const api = controller()
|
||||
if (api === undefined) return false
|
||||
const active = activeSessionId(ctx)
|
||||
const onScreen = active !== undefined && active === entry.sessionId
|
||||
const onScreen = mountedSessionId(ctx) === entry.sessionId
|
||||
if (entry.kind === 'tab') {
|
||||
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
|
||||
if (onScreen) {
|
||||
@@ -109,7 +172,18 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
|
||||
if (!place(entry)) pending.push(entry)
|
||||
}
|
||||
|
||||
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
|
||||
// Two feeds flush the queue: the mounted seat (a session coming on screen)
|
||||
// and the session list, which also stays the pulse that picks the native
|
||||
// service up when it is provided after this surface was created.
|
||||
let mountedUnsubscribe: (() => void) | undefined
|
||||
const onListChange = (): void => {
|
||||
if (mountedUnsubscribe === undefined) {
|
||||
const mounted = controller()?.mounted
|
||||
if (typeof mounted?.subscribe === 'function') mountedUnsubscribe = mounted.subscribe(flushPending)
|
||||
}
|
||||
flushPending()
|
||||
}
|
||||
const unsubscribeList = ctx.sessions.list.subscribe(onListChange)
|
||||
return {
|
||||
openTab({ sessionId, kind, params, revealIfOpened }) {
|
||||
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
|
||||
@@ -126,7 +200,7 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
|
||||
records.drop(tabId)
|
||||
const api = controller()
|
||||
if (api !== undefined) {
|
||||
if (sessionId === activeSessionId(ctx)) api.close(tabId)
|
||||
if (sessionId === mountedSessionId(ctx)) api.close(tabId)
|
||||
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
|
||||
}
|
||||
return { type: record.tab.type, title: record.tab.title }
|
||||
@@ -144,6 +218,9 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
|
||||
},
|
||||
has: tabId => records.has(tabId),
|
||||
flushPending,
|
||||
dispose: () => { unsubscribe() },
|
||||
dispose: () => {
|
||||
unsubscribeList()
|
||||
mountedUnsubscribe?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user