Files
apes-sidebar/src/client/link-intercept.ts
T
Menghuan1918 6586e3e2d3 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.
2026-09-22 17:09:08 +08:00

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) }
}