基于 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(外链分流)
232 lines
10 KiB
TypeScript
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)
|
|
}
|
|
}
|