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:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Live refresh for the file tree.
|
||||
*
|
||||
* The tree lists a folder when it is expanded and caches the level, so
|
||||
* anything written outside the plugin — a build, a formatter, the model's own
|
||||
* `bash` — stayed invisible until the reader refreshed. DSH 0.1.7 gave its own
|
||||
* tree per-directory watching; this plugin owns its tree (it took the `files`
|
||||
* kind over), so it carries its own.
|
||||
*
|
||||
* One socket per session, open for as long as the tree is mounted. The client
|
||||
* announces the folders it has expanded and the host watches exactly those;
|
||||
* every notice names a directory whose listing is stale, and the tree drops
|
||||
* that one cached level and re-lists it. Folders the reader collapsed are
|
||||
* un-watched, so a long session does not accumulate handles.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** One server frame: a stale directory, a watch verdict, or a refusal. */
|
||||
interface FsWatchFrame {
|
||||
dir?: unknown
|
||||
ok?: unknown
|
||||
}
|
||||
|
||||
/** What the tree hands the hook. */
|
||||
export interface DirectoryWatchOptions {
|
||||
/** Session whose workspace is being browsed; no socket while undefined. */
|
||||
sessionId: string | undefined
|
||||
/** The workspace root, always watched (the tree always lists it). */
|
||||
root: string | undefined
|
||||
/** Expanded directories below the root, in the host's absolute path form. */
|
||||
dirs: readonly string[]
|
||||
/**
|
||||
* A directory whose listing is stale.
|
||||
* @param dir - absolute directory path, exactly the form the tree keys levels by.
|
||||
*/
|
||||
onStale: (dir: string) => void
|
||||
}
|
||||
|
||||
/** How long to wait before re-opening a dropped socket; failures back off to the cap. */
|
||||
const RETRY_BASE_MS = 1000
|
||||
const RETRY_MAX_MS = 15_000
|
||||
|
||||
/**
|
||||
* Keep the file tree's expanded folders watched for the component's lifetime.
|
||||
* @param options - session, root, expanded directories, and the staleness callback.
|
||||
*/
|
||||
export function useDirectoryWatch(options: DirectoryWatchOptions): void {
|
||||
const { sessionId, root, dirs, onStale } = options
|
||||
// The expanded set is a fresh array on every render, so neither the socket
|
||||
// effect nor the staleness callback may depend on their identity.
|
||||
const wantedRef = useRef<string[]>([])
|
||||
wantedRef.current = root === undefined ? [...dirs] : [root, ...dirs]
|
||||
const staleRef = useRef(onStale)
|
||||
staleRef.current = onStale
|
||||
/** Set by the socket effect; reconciles the host's watch set with the tree's. */
|
||||
const reconcileRef = useRef<() => void>(() => {})
|
||||
// One string per expanded-set change: a stable dependency for the effect below.
|
||||
const wantedKey = wantedRef.current.join('\u0000')
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
/** Directories the host confirmed it is watching (its own resolved paths). */
|
||||
const watching = new Set<string>()
|
||||
|
||||
const send = (op: 'watch' | 'unwatch', path: string): void => {
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ op, path }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the host's watch set in line with the tree's expanded set. The
|
||||
* host answers every `watch` with the directory it resolved, so `watching`
|
||||
* is the host's own answer rather than a client-side guess.
|
||||
*/
|
||||
const reconcile = (): void => {
|
||||
const wanted = new Set(wantedRef.current)
|
||||
for (const path of wanted) send('watch', path)
|
||||
for (const dir of [...watching]) {
|
||||
if (wanted.has(dir)) continue
|
||||
send('unwatch', dir)
|
||||
watching.delete(dir)
|
||||
}
|
||||
}
|
||||
reconcileRef.current = reconcile
|
||||
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/fs-watch', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onopen = () => {
|
||||
failures = 0
|
||||
watching.clear()
|
||||
reconcile()
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
let frame: FsWatchFrame
|
||||
try {
|
||||
frame = JSON.parse(event.data) as FsWatchFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (frame === null || typeof frame !== 'object' || typeof frame.dir !== 'string') return
|
||||
// `ok: true` confirms a watch; `ok: false` refuses one (fenced path,
|
||||
// watcher cap, unreadable directory) and must NOT re-list. A frame
|
||||
// carrying only `dir` is the stale notice.
|
||||
if (frame.ok === true) {
|
||||
watching.add(frame.dir)
|
||||
return
|
||||
}
|
||||
if (frame.ok === undefined) staleRef.current(frame.dir)
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
retry = window.setTimeout(connect, Math.min(RETRY_BASE_MS * 2 ** (failures - 1), RETRY_MAX_MS))
|
||||
}
|
||||
}
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
reconcileRef.current = () => {}
|
||||
if (retry !== undefined) window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Reconciling on its own effect keeps an expand/collapse from tearing the
|
||||
// socket down and re-opening it.
|
||||
useEffect(() => {
|
||||
void wantedKey
|
||||
reconcileRef.current()
|
||||
}, [wantedKey])
|
||||
}
|
||||
Reference in New Issue
Block a user