Files
apes-sidebar/src/client/index.tsx
T
Menghuan1918 29f162dac0 feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关
基于 PR #74 的颗粒度讨论向前兼容拓展服务化 API:
- TabDescriptor 新增可选 urlTarget(url) 谓词:聊天/界面外链被拦截时按注册
  顺序路由到第一个命中的插件 tab 类型(openTab({ type, url, title })),
  内置 browser 退居隐式兜底;SIDEBAR_FEATURES 追加 'urlTarget'
- 浏览器链接设置改为二级菜单(齿轮弹窗):新增 browserInterceptHttp
  (默认开)+ browserInterceptHttps(默认关);browserInterceptLinks
  保留为总闸(默认开,旧文档零迁移)
- 拦截管线:registerLinkInterception 的 takeoverEnabled 改收解析后的
  URL,协议/同源纯判定(shouldInterceptLink)签名不变
- 测试:matchUrlTarget(注册顺序/抛错吞掉/browser 永不命中)、jsdom
  点击接管门控、parsePrefs 新字段默认值;全默认字面量同步
- 文档:设计文档 + AGENTS.md(字段/示例/设置键)+ README(外链分流)
2026-08-15 15:05:06 +08:00

232 lines
10 KiB
TypeScript

/**
* Client half of dsh-better-sidebar: resolves the user's "Side card"
* preferences through the plugin's own fenced settings route, mounts the
* right sidebar portal (inside an error boundary so a rendering failure
* shows an error strip instead of a blank panel), registers the turn-tail
* interception, and contributes the Side card settings section to the DSH
* Settings shell. Requires the runtime's slots and sessions services; the
* bundle itself is a module-table consumer only (react + ui-primitives +
* xterm, all provided or inlined).
*/
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
* sidebar SHELL itself must never blank the page silently — the shared
* RenderBoundary shows a dismissible error strip and logs the stack. The
* per-tab scope (Sidebar.tsx) catches viewer/editor crashes first; this root
* boundary stays as the last resort for Workbench/shell errors.
*/
/**
* Client plugin body.
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
// The sidebar follows the DSH i18n system: attach the locale service so
// the module-level t()/isZh() resolve the Host-backed language preference
// (and switch live — the Sidebar root subscribes to it), and register the
// plugin's dictionaries into the shared locale registry. The disposers
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
attachLocale(ctx.locale)
ctx.effect(() => {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh(); offEn() }
}, 'dsh-better-sidebar: dictionaries')
// One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no
// module-level singleton).
const sidebarStore = createSidebarStore()
// The sidebar registry service: external plugins register tab types and
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
// Published before the panel mounts so consumers injecting 'betterSidebar'
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}
try {
// Fresh chunk state for this activation: invalidate any chunk factories
// registered by a previous fiber (HMR) and drop the in-memory load cache
// so the next lazy open re-fetches the current chunk scripts.
resetChunks()
ctx.effect(() => {
let disposed = false
let root: Root | undefined
let host: HTMLDivElement | undefined
void (async () => {
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
// schema defaults; the sidebar still mounts (a stalled wire gives up
// after the timeout and mounts on the defaults).
const prefs = await Promise.race([
loadPrefs(api),
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
} catch (error) {
fail('mount', error)
}
})()
return () => {
disposed = true
root?.unmount()
host?.remove()
}
}, 'dsh-better-sidebar: sidebar mount')
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerOpenPathInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: open-path interception',
)
ctx.effect(
() => {
try {
// External http(s) links in the chat/GUI open the sidebar instead
// of a new window. Gated on the browserInterceptLinks MASTER pref,
// the URL's protocol flag (browserInterceptHttp / Https — https
// defaults OFF: most https sites refuse iframe embedding), and the
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
// target is the first registered tab whose `urlTarget` claims the
// URL (enabled tabs only), else the built-in browser tab.
const urlTargetOf = (url: URL): string | undefined => {
const prefs = sidebarStore.getPrefs()
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
return matchUrlTarget(enabled, url)?.id
}
return registerLinkInterception({
takeoverEnabled: (url) => {
const prefs = sidebarStore.getPrefs()
if (prefs.browserInterceptLinks === false) return false
const protocolOn = url.protocol === 'https:'
? prefs.browserInterceptHttps !== false
: prefs.browserInterceptHttp !== false
if (!protocolOn) return false
// A plugin claim is the target (already enabled-filtered);
// otherwise the built-in browser must be enabled.
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
},
openInSidebar: (url) => {
let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ }
const type = urlTargetOf(new URL(url)) ?? 'browser'
ctx.betterSidebar?.openTab({ type, url, title })
},
selfOrigin: window.location.origin,
})
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: link interception',
)
// The IME guard: composition keys (candidate arrows, confirm, cancel)
// belong to the input method, never to page JS. Inlined third-party UI
// (formerly Univer's office controls, #562 regression) has shipped
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
// Chinese input; the document-capture guard neutralizes the whole class
// before React or any native listener sees the event. Registered as
// early as possible so no other capture-phase listener can win the
// ordering race.
ctx.effect(
() => {
try {
return registerImeGuard()
} catch (error) {
fail('ime guard', error)
return undefined
}
},
'dsh-better-sidebar: IME composition guard',
)
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
// fenced settings route, keeps the shared store in sync, and renders the
// declarative enable/disable inventory from the tab/viewer registry.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'better-sidebar',
order: 100,
label: () => t('settingsNav'),
inject: () => ({ store: sidebarStore, service }),
}, SideCardSection))
} catch (error) {
fail('load', error)
}
}