* feat(injection): 统一面板宿主——固定含块层 + 挂载自检降级 + body 锚点守卫 - Sidebar 渲染根包 [data-dsh-panel-host](fixed inset-0 pointer-events:none) - 面板/开关簇由 fixed 改 absolute(层内视口坐标等价),免疫套壳中间层 transform 劫持 - 挂载后一次性几何自检:页面级 transform 时降级 absolute + rAF 视口同步 - MutationObserver(body childList)守卫锚点被宿主清空时重挂 * perf(injection): 几何写路径统一为 writeGeometry + 面板 layout 隔离 - 拖拽帧与打开态共用单一推挤写入器(--dsh-sidebar-width/height) - .panel/.bottomPanel contain: layout style——内部重排不外泄 - 拖拽期(body[data-dsh-sidebar-dragging])才挂 will-change: transform * perf(chunks): HMR 激活保留未变 chunk 的已解析 exports + ETag HEAD 重验证 - 新增 revalidateChunksOnReactivate():激活时 HEAD 每个已加载 chunk 对比 ETag, 未变保留内存缓存(跳过重注入/重执行),变更或不可达则 fail-open 重取 - 测试夹具与孤儿缓存条目不跨激活存活 - index.tsx 激活路径由 resetChunks() 改为 revalidateChunksOnReactivate() * fix(layout): 推挤锚点改 data 属性 + #root calc 宽度防桌面壳溢出 - nth-child(2) → #root [data-dsh-frame] > [data-pane="conversation"](实证同元素) - #root 宽度 calc(100% - var) 与 margin 同步过渡:web 视觉不变(实测 1140+300=1440 无溢出), Desktop 壳(#208)width:100% 的加性溢出根因消除 * feat(desktop): 桌面信号自动探测 + win32 顶栏 overlay 自动避让 + 键盘/safe-area 适配 - 新增 desktop-env.ts:解析 dsh-desktop-mode/platform URL 信号与 preload 标记 - win32 advanced 壳自动启用 titleBarCompat(32px overlay),手动 pref 仍可覆盖 - visualViewport 键盘 inset:底部面板/窄屏抽屉键盘弹起时上移 - 面板与开关簇 safe-area-inset 适配(env() 桌面端为 0) - 单测 7 例(含 URLSearchParams 前导 ? 剥除修复) * docs+e2e: 注入方案实施记录(路线B关闭、偏差记录、基线数据)+ 契约同步 + e2e 新断言 - 设计文档:路线 B 标记关闭、§8.1 改单 PR 步骤(含 commit 对照)、拖拽基线数据、实施偏差记录 - AGENTS.md §8:data-dsh-panel-host 含块层、推挤锚点契约(禁 nth-child)、桌面信号 - mount.e2e:data-dsh-panel-host 断言 + win32 advanced 桌面信号场景 * fix(cr): 响应 #232 CR 三条 findings - P1 降级同步:按未修正几何判定(跟踪自身补偿量),祖先 transform 消失才退出循环—— 修正后矩形不再被误判为无需修正(自清除缺陷) - P1 chunk 重验证成为 loadChunk 屏障:重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports; 测试夹具加载不再被误记为生产加载 - P2 键盘 inset:innerHeight - (vv.height + vv.offsetTop),并监听 scroll(offsetTop 变化) - 新增屏障单测 1 例;设计文档偏差记录更新 * fix(chunks): 屏障 HEAD 加超时兜底 + resetChunks 清 revalidation - revalidateChunksOnReactivate 的 HEAD 与 recordEtag 均带 AbortSignal.timeout(5s):路由挂起时 fail-open(drop + 重取), 屏障不再可能无限期阻塞所有懒加载 chunk(CR #232 P1 修复的健壮性缺口) - resetChunks 同步清 revalidation:重验证挂起期间重置后, loadChunk 不再等待已丢弃状态的孤儿任务(finally 身份判断 no-op) - 单测 +2:HEAD 挂起超时 fail-open(fake AbortSignal.timeout 触发中止)、 resetChunks 清屏障后 loadChunk 立即放行 * fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.8 - peer/devDependencies: 13 个 @deepseek-ai/* 从 ^0.1.0-rc.7 升至 ^0.1.0-rc.8;删除随 rc.8 消失的 dsh-client-web-react / dsh-client-schema-form (无 rc.8 发布、rc.8 依赖树与壳 bundle 零引用、插件 src 零引用); cordis peer/devDep 同步升 ^4.0.0-rc.8 - 版本 0.13.1 -> 0.14.0(package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION 三处同步) - CHUNK_EXTERNALS / CLIENT_EXTERNALS / CLIENT_REQUIRE_ALLOWED 三处镜像 同步删除两个死包(primitives/slots 仍是 rc.8 壳静态模块表种子,保留) - CI 挂载冒烟钉版 @deepseek-ai/dsh@0.1.0-rc.7 -> rc.8 - pnpm clean --lockfile 全量重解析:lockfile 内 @deepseek-ai/* rc.7 残留 清零(传递链同升 rc.8,杜绝 #206 式混用树);pnpm-workspace.yaml minimumReleaseAgeExclude 按 #207 模式整体换为 rc.8 列表 - 文档同步:README 安装说明、AGENTS.md CI 钉版、外部插件指南与设计文档 cordis 示例、issue 模板版本示例 * feat(prefs): editorExplorer 默认独立(关)——文件各自新开 tab - SIDEBAR_PREFS_DEFAULTS 与 host PrefsSchema 的 editorExplorer 默认 true -> false:新会话树点击/打开文件按 path 新开 tab,无路径窗口即 纯资源管理器;合并模式保留为可选手动开启(持久化过的用户设置不受影响) - 测试同步:prefs.spec 默认断言翻转、plugin-shape.spec 与 smoke.spec 默认对象翻转;editor-host 合并模式用例显式开启 editorExplorer(split 用例本已显式);side-card-section-rows 的 select 行用例固定选中 Merged - e2e 重写:mount.e2e 文件打开流程从 in-place 断言改为独立模式 (新开 hello.txt tab、Files home tab 保留),文件头注释同步 - 契约文档同步:AGENTS.md §3.4 editor 行默认描述翻转 + CI 冒烟描述、 README/README_EN v0.13.0 特性描述 + v0.14.0 changelog * fix(client): rc.8 模块系统经 ctx.modules 注入 + chunk 副本全局共享 rc.8 不再暴露 window.__DSH_MODULES__ 页面全局(改由 ctx.modules 服务 提供),chunk 外部依赖解析全面失效(terminal/editor/mermaid 全部 "client module system unavailable")。修复: - src/client/index.tsx: inject 增加 'modules'(Cordis 服务访问守卫, 此前直接读 ctx.modules 抛 "cannot get property modules without inject");apply() 激活时 setChunkModuleSystem(ctx.modules) - package.json dsh.client.inject 增加 @deepseek-ai/dsh-client-modules - chunk-loader: 注入写插件自有页面全局 __dshSidebarModuleSystem__—— 懒加载 chunk bundle 各自内联一份 loader(rc.7 靠官方全局回退工作, rc.8 全局消失后副本注入变量恒空);moduleSystem() 按 注入变量 → 插件全局 → rc.7 全局回退解析 - context-types: Context 镜像补 modules 服务声明(client half) - e2e: assertNoCrash 失败时输出错误条文本;独立模式断言修正 (Files 数量按存在性、mermaid 流程先切回 Files、md 预览 locator 去掉 重复的 [data-dsh-better-sidebar] 嵌套) 验证:typecheck / 639 测试全绿;本地 rc.8 CLI + Chromium 挂载冒烟 3/3 通过(drag-layout、mount 深扫含 editor/mermaid chunk、桌面信号) * feat(settings): 终端 shell/shellArgs 可在设置页配置(bash 管理) - SidebarPrefs 新增 terminalShell / terminalShellArgs(默认空 = 保持 yaml config.shell → $SHELL / 登录 shell / powershell.exe 的既有解析) - 终端卡齿轮二级页面新增两行 text 配置(与字体行同列) - host 侧生效链路:UI 终端 WS 打开与 agent terminal_create 均读取 设置文档覆盖(ptyManager.open / AgentPtyRegistry.create 增加可选 shell 覆盖参数;shellOverridesOf 解析设置文档);设置页写入即时 影响之后打开的终端(既有 pty 保持原 shell) - locales zh/en 文案;parsePrefs 校验新字段;测试同步 * feat(settings): 侧边卡片设置页加 DSH-better-sidebar 版本徽标 - 设置页 intro 下新增身份徽标:名称 + v{service.version}(服务实例 版本,与 package.json 由 tests/service.spec.ts 保持同步) - token 驱动样式(--dsw-alias-*),名称/版本 pill 布局 * fix(drag): 快速拖动提交最后位置 + HMR 后中心列重定位兜底 快速释放(浏览器合并/丢失 pointermove 突发,超快速 flick 甚至可能 完全无 move)时的回退问题: - pointerup 优先采用最后一次已知拖动值(rAF pending),而非 up 位置 (事件合并时可能等于按下位置) - pointercancel / lostpointercapture 中断时同样提交最后已知位置; 无 move 时用中断事件自身的坐标计算(clamp 后提交)——只有真正 未产生位移的拖动才回滚 - commitDrag 统一 flush + 同值提交 + 立即重测中心列(消除提交后 React 用旧 centerRect 覆盖底栏 right 的中间帧) HMR 重激活后底栏空白(输入框被推挤上移、强制刷新恢复): - 根因:新组件实例重跑 locate 时壳的 centerCol 尚未就绪,而壳重建 复用 #root 子节点(childList 观察器不触发)→ centerRect 卡 {0,0} → 底栏 visibility:hidden 永久空白 - 修复:额外观察 <html> 的 style 属性(layout push 写回 --dsh-sidebar-* 时重定位重测);底栏打开时 effect 重跑 locate/measure 兜底 - 底栏未测量时由「不渲染」改为 visibility:hidden(渲染链不被几何 门控,jsdom 测试不回归) e2e 新增:极快拖动(单 move 立即 up)提交拖动位置、稳定后不回退 * feat(plugins): 添加插件目录支持搜索/分组/独立滚动(多插件场景) - PluginListBody 顶部加实时搜索框(按名称/id/描述过滤,空结果提示) - PluginEntry 增加可选 category 分组字段(i18n 友好),目录条目按组 渲染(未分类条目保持平铺);数量徽标跟随过滤结果 - 条目列表 max-height + overflow-y 独立滚动,插件多时弹窗不再无限 增长;渲染数量由搜索天然收敛 - locales zh/en(搜索占位、无匹配);测试文件固定英文环境 (vitest 4.1.11+ jsdom 跟随系统 locale,本机 zh-CN 导致英文文案 断言漂移) * chore(deps): 适配 pnpm 11.8 supply-chain 校验(exclude 通配) pnpm 11.8.0 起对 lockfile 既有条目强制 minimumReleaseAge 校验, 且精确 exclude(@scope/pkg@version)对带 peer 依赖后缀的解析条目 匹配失效(44 条误报违规,CI 全红)。@deepseek-ai/* scope 为官方 可信 scope,minimumReleaseAgeExclude 改为 '@deepseek-ai/*' 通配 (保留 rc.1 历史与 stable 条目);通配经实测通过全部 504 条校验。 * docs: v0.14.0 changelog 补全(自 v0.13.1 全部更改 + 特别声明 rc.8 适配) 新增条目:终端 shell/shellArgs 设置页配置、设置页版本徽标、 添加插件目录搜索/分组/滚动、rc.8 模块系统迁移、拖拽健壮性修复; 版本说明块醒目声明本版适配 DSH 0.1.0-rc.8(rc.7 及更早无法解析依赖)
370 lines
17 KiB
TypeScript
370 lines
17 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 { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
|
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
|
import { revalidateChunksOnReactivate, setChunkModuleSystem } 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 { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
import { loadExternalDisable, 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). `modules`
|
|
* (rc.8+) is the client module system the chunk loader resolves its
|
|
* externals through — Cordis guards service access without inject. */
|
|
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
|
|
|
|
/**
|
|
* 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)
|
|
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
|
|
// instead of "Terminal 1". Start with a safe fallback and replace it as
|
|
// soon as the host shell info resolves. Tabs created before the response
|
|
// arrives keep the fallback title, so also retitle any already-open UI
|
|
// terminal tabs that still carry it.
|
|
const fallbackTitle = t('terminal')
|
|
let terminalTitle = fallbackTitle
|
|
void api.shellGet().then(({ name }) => {
|
|
terminalTitle = name
|
|
const snapshot = service.getSnapshot()
|
|
if (snapshot.state === undefined) return
|
|
const tabs = allLeaves(snapshot.state.splits)
|
|
.concat(allLeaves(snapshot.state.bottomSplits))
|
|
.flatMap(leaf => leaf.tabs)
|
|
for (const tab of tabs) {
|
|
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
|
|
service.updateTab(tab.id, { title: name })
|
|
}
|
|
}
|
|
}).catch(() => { /* keep fallback */ })
|
|
// 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, { terminalTitle: () => terminalTitle }),
|
|
'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 {
|
|
// rc.8+ exposes the client module system as the `ctx.modules` service
|
|
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
|
// it to resolve its externals, so inject it before anything can load a
|
|
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
|
setChunkModuleSystem(ctx.modules)
|
|
// Fresh chunk state for this activation: drop per-test fixtures and
|
|
// revalidate loaded chunk scripts against the bundle route's ETags —
|
|
// unchanged chunks keep their resolved exports (no re-inject /
|
|
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
|
|
void revalidateChunksOnReactivate()
|
|
ctx.effect(() => {
|
|
let disposed = false
|
|
let root: Root | undefined
|
|
let host: HTMLDivElement | undefined
|
|
let mounted = false
|
|
let bodyObserver: MutationObserver | undefined
|
|
let hostCheckFrame: number | null = null
|
|
const unmount = (): void => {
|
|
if (!mounted) return
|
|
mounted = false
|
|
bodyObserver?.disconnect()
|
|
bodyObserver = undefined
|
|
if (hostCheckFrame !== null) {
|
|
cancelAnimationFrame(hostCheckFrame)
|
|
hostCheckFrame = null
|
|
}
|
|
root?.unmount()
|
|
root = undefined
|
|
host?.remove()
|
|
host = undefined
|
|
}
|
|
/** Re-attach the host if the page (a desktop shell wrapper, SPA
|
|
* navigation, …) ever removes it from <body>. Cheap: childList only,
|
|
* no subtree, no attribute filtering. */
|
|
const guardAnchor = (): void => {
|
|
if (bodyObserver !== undefined) return
|
|
bodyObserver = new MutationObserver(() => {
|
|
if (host !== undefined && !document.body.contains(host)) {
|
|
document.body.appendChild(host)
|
|
}
|
|
})
|
|
bodyObserver.observe(document.body, { childList: true })
|
|
}
|
|
/** One-shot geometry self-check: if the host page transforms
|
|
* <html>/<body> itself (exotic shells), a fixed panel host would
|
|
* track the transformed box instead of the viewport. Flip the
|
|
* degraded mode and pin the host to the viewport every frame until
|
|
* the ancestor transform is actually gone. The normal path (no
|
|
* page-level transform) never runs the sync loop. */
|
|
const scheduleHostCheck = (): void => {
|
|
hostCheckFrame ??= requestAnimationFrame(() => {
|
|
hostCheckFrame = null
|
|
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
|
|
if (layer === null || layer === undefined) return
|
|
const rect = layer.getBoundingClientRect()
|
|
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|
|
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
|
|
if (!mismatched) {
|
|
layer.removeAttribute('data-dsh-panel-host-degraded')
|
|
layer.style.transform = ''
|
|
return
|
|
}
|
|
layer.setAttribute('data-dsh-panel-host-degraded', '')
|
|
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
|
|
// Track our own compensating translation so the loop judges the
|
|
// UNCORRECTED geometry: clearing degraded mode must wait for the
|
|
// ancestor transform to actually disappear — the frame right after
|
|
// our correction applies would otherwise look "fixed" and the
|
|
// offset would return immediately (CR #232 P1).
|
|
let applied = { x: 0, y: 0 }
|
|
const sync = (): void => {
|
|
const r = layer.getBoundingClientRect()
|
|
const rawLeft = r.left - applied.x
|
|
const rawTop = r.top - applied.y
|
|
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
|
|
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
|
|
layer.removeAttribute('data-dsh-panel-host-degraded')
|
|
layer.style.transform = ''
|
|
return
|
|
}
|
|
const next = { x: -rawLeft, y: -rawTop }
|
|
if (next.x !== applied.x || next.y !== applied.y) {
|
|
applied = next
|
|
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
|
|
}
|
|
hostCheckFrame = requestAnimationFrame(sync)
|
|
}
|
|
hostCheckFrame = requestAnimationFrame(sync)
|
|
})
|
|
}
|
|
const mount = (): void => {
|
|
if (mounted || 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 })))
|
|
mounted = true
|
|
guardAnchor()
|
|
scheduleHostCheck()
|
|
} catch (error) {
|
|
fail('mount', error)
|
|
}
|
|
}
|
|
const sync = async (): Promise<void> => {
|
|
if (disposed) return
|
|
// 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
|
|
// Mutual exclusion with the dsh-web-ui family right panel: while the
|
|
// aionui-panel provider is selected, the sidebar must not mount at
|
|
// all. Re-evaluated on every settings-document update (live switch).
|
|
const suspended = await loadExternalDisable(api)
|
|
if (disposed) return
|
|
sidebarStore.setSuspended(suspended)
|
|
if (suspended) unmount()
|
|
else mount()
|
|
}
|
|
void sync()
|
|
// Live re-evaluation: the runtime broadcasts settings-document updates
|
|
// (the aionui card saves through the same document). Best effort —
|
|
// deployments without the 'remote' service fall back to boot-time
|
|
// evaluation only.
|
|
const remote = ctx.get('remote') as { $on?: (event: string, listener: () => void) => () => void } | undefined
|
|
const offRemote = remote?.$on?.('settings/document-updated', () => { void sync() })
|
|
return () => {
|
|
disposed = true
|
|
offRemote?.()
|
|
unmount()
|
|
}
|
|
}, '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) => {
|
|
if (sidebarStore.getSuspended()) return false
|
|
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',
|
|
)
|
|
|
|
// DSH 0.1.x does not yet carry an icon through the settings.section
|
|
// registration contract: its shell renders a generic gear for every
|
|
// external section. Mark only this plugin's localized nav row so
|
|
// layout.css can paint the requested Side card SVG; the disposer clears
|
|
// the marker for HMR / plugin disable.
|
|
ctx.effect(
|
|
() => registerSettingsNavIcon(() => t('settingsNav')),
|
|
'dsh-better-sidebar: settings navigation icon',
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|