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.
142 lines
6.3 KiB
TypeScript
142 lines
6.3 KiB
TypeScript
/**
|
|
* External-link interception: clicking an http(s) link that a REGISTERED tab
|
|
* type CLAIMS through `urlTarget` opens that type in the sidebar. Everything
|
|
* else is left alone — the plugin never `preventDefault`s a link it cannot
|
|
* serve, because the destination of a chat link is not the plugin's call:
|
|
* since DSH 0.1.7 the host's `ui-chat` `openExternalLink` applies the user's
|
|
* `linkOpening` setting and the host's own browser tab (a kind the Web
|
|
* profile disables), and inside plugin-drawn markdown (sidechat
|
|
* transcripts, editor previews, HTML previews, diff panes) the anchor's own
|
|
* default (`window.open`) applies.
|
|
*
|
|
* A Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
|
|
* user can still force a real browser tab.
|
|
*
|
|
* Only the GUI's OWN document is watched — links inside a sandboxed iframe
|
|
* live in another document and never bubble here (and their clicks must keep
|
|
* working inside the sidebar).
|
|
*/
|
|
|
|
/** `new URL(value)`, or null for an unparsable href. */
|
|
function parseUrl(value: string): URL | null {
|
|
try {
|
|
return new URL(value)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** The pure decision: the URL a click may hand to the sidebar, or null to let
|
|
* the click fall through. Extracted so the policy is unit-testable without a
|
|
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
|
|
* The protocol/same-origin policy lives HERE; whether some enabled tab type
|
|
* actually claims the URL is the caller's `takeoverEnabled` callback. */
|
|
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
|
|
const url = parseUrl(anchorHref)
|
|
if (url === null) return null
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
// Same-origin links are GUI-internal navigation (settings pages, tool
|
|
// docs) — never routed into the sidebar.
|
|
try {
|
|
if (url.origin === new URL(selfOrigin).origin) return null
|
|
} catch {
|
|
// Unparsable selfOrigin (never in practice): intercept defensively.
|
|
}
|
|
return url.href
|
|
}
|
|
|
|
/** Whether a left-click may be taken over (unmodified left click only). */
|
|
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
|
|
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
|
|
}
|
|
|
|
/**
|
|
* The caller-side gate: take a link over only when the sidebar is not
|
|
* suspended AND some enabled tab type claims the URL. An unclaimed link is
|
|
* the host's to route (its `openExternalLink` / the anchor's own default).
|
|
*/
|
|
export function shouldTakeOverLink(url: URL, opts: {
|
|
/** Another panel provider owns the right column — the sidebar must not act. */
|
|
suspended: boolean
|
|
/** The tab type claiming this URL through `urlTarget` (enabled tabs only). */
|
|
resolveTarget: (url: URL) => string | undefined
|
|
}): boolean {
|
|
if (opts.suspended) return false
|
|
return opts.resolveTarget(url) !== undefined
|
|
}
|
|
|
|
/**
|
|
* Whether a claimed type is still on offer at OPEN time: registered in the
|
|
* tab registry AND not switched off in the settings. The claim (gate) and the
|
|
* open are two separate turns of the event loop, so the type may disappear in
|
|
* between — a plugin unloaded, the user disabled it, the registry reset.
|
|
*/
|
|
export function isTargetAvailable(
|
|
type: string,
|
|
tabs: readonly { id: string }[],
|
|
tabsEnabled: Record<string, boolean>,
|
|
): boolean {
|
|
return tabs.some(tab => tab.id === type && tabsEnabled[tab.id] !== false)
|
|
}
|
|
|
|
/**
|
|
* Open an intercepted link: the sidebar tab when the claimed type is still
|
|
* available, a real browser tab otherwise. The fallback is not decoration —
|
|
* the plugin's own `openTab` silently ignores an unknown / disabled type
|
|
* (service.ts returns early), so without it a taken-over click would do
|
|
* nothing at all. Returns the path taken (tests assert the fallback).
|
|
*/
|
|
export function openInterceptedLink(url: string, deps: {
|
|
/** The tab type claiming this URL through `urlTarget` (enabled tabs only). */
|
|
resolveTarget: (url: URL) => string | undefined
|
|
/** Whether that type is still registered and enabled ({@link isTargetAvailable}). */
|
|
isAvailable: (type: string) => boolean
|
|
/** The plugin's own tab service (`ctx.get('betterSidebar')`), when attached. */
|
|
sidebar: { openTab: (seed: { type: string; url: string; title?: string }) => void } | undefined
|
|
}): 'sidebar' | 'window' {
|
|
const parsed = parseUrl(url)
|
|
const type = parsed === null ? undefined : deps.resolveTarget(parsed)
|
|
if (parsed !== null && type !== undefined && deps.sidebar !== undefined && deps.isAvailable(type)) {
|
|
deps.sidebar.openTab({ type, url, title: parsed.hostname })
|
|
return 'sidebar'
|
|
}
|
|
// Nothing in the sidebar can serve this URL (unclaimed, the type is gone or
|
|
// switched off, or the plugin's service is detached): hand it to the real
|
|
// browser so the click is never a silent no-op. A click IS the user
|
|
// gesture, so the popup blocker lets this through.
|
|
window.open(url, '_blank', 'noopener,noreferrer')
|
|
return 'window'
|
|
}
|
|
|
|
/**
|
|
* Register the document-level click capture that funnels CLAIMED external
|
|
* links into the sidebar. Returns the disposer (HMR-safe).
|
|
*/
|
|
export function registerLinkInterception(opts: {
|
|
/** Whether the takeover may happen for THIS url (the caller's gate —
|
|
* {@link shouldTakeOverLink}: not suspended and claimed by an enabled
|
|
* tab type). */
|
|
takeoverEnabled: (url: URL) => boolean
|
|
/** Open the URL — the caller resolves the claimed type and falls back to a
|
|
* real browser tab when it is gone ({@link openInterceptedLink}). */
|
|
openInSidebar: (url: string) => void
|
|
/** The GUI's own origin (window.location.origin at registration). */
|
|
selfOrigin: string
|
|
}): () => void {
|
|
const onClick = (event: MouseEvent): void => {
|
|
if (!isPlainLeftClick(event)) return
|
|
if (event.defaultPrevented) return
|
|
const target = event.target
|
|
if (target === null || typeof (target as Element).closest !== 'function') return
|
|
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
|
|
if (anchor === null) return
|
|
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
|
|
if (url === null) return
|
|
if (!opts.takeoverEnabled(new URL(url))) return
|
|
event.preventDefault()
|
|
opts.openInSidebar(url)
|
|
}
|
|
document.addEventListener('click', onClick, true)
|
|
return () => { document.removeEventListener('click', onClick, true) }
|
|
}
|