dsh-session-navigator

桥与主题

面板跑在 DSH 的 document 里,独立窗口是另一个进程里的另一个 document。两者渲染的是同一份 lib/client.js,但它们彼此看不见:没有共享的 DOM、没有共享的 CSS、没有共享的事件总线。 这两件事——导航命令和配色——就靠下面这套很窄的通道传过去。

1. 命令总线

lib/host/bridge.js:40 的 createBridge() 是一个进程内的环形缓冲,不是消息队列:

参数 值 理由
capacity 64 条 面板关着时攒下的命令,重连时要能补发
MAX_AGE_MS 30 000 半分钟前的”打开这个会话”已经不是用户的意图了

每条命令是 { seq, kind, payload, createdAt },seq 单调递增,永不复用。 since(cursor) 返回 seq > cursor 且 未过期的命令:游标太旧时返回的只是还留着的那些, 补发一部分总比什么都不补发好。

总线故意是哑的(lib/host/bridge.js:60-66):它不校验 payload 的语义,只搬运。 认识某个 kind 的监听者自己处理,不认识的忽略。这样新面板可以对着旧窗口说话,不需要升 schema。

2. 允许的 kind,以及谁执行

WINDOW_COMMANDS(lib/host/bridge.js:133)是窗口唯一被允许发的五种命令,其余一律 bad-request。 另外两种由 host 自己发,用来通知页面窗口的生命周期。

kind 谁发 谁执行 作用
open-session 窗口 DSH 页面 在 DSH 里打开某个会话
new-session 窗口 DSH 页面 在某个 workspace(或默认位置)新建会话
open-settings 窗口 DSH 页面 打开 DSH 的设置(窗口没有设置外壳)
reveal DSH 页面 窗口 在资源管理器里定位会话文件
refresh 窗口 DSH 页面 刷新面板数据
window-open host DSH 页面 窗口已打开(用于同步”默认打开方式”的状态)
window-closed host DSH 页面 窗口已关闭

payload 只接受三个字符串字段,各自截到 200 字符:

{ "kind": "open-session", "payload": { "sessionId": "01J…" } }

sessionId / workspaceId / scope 之外的键直接丢掉。桥是一根窄管子,不是插件的遥控器。

为什么方向是分开的(lib/client.js:14226-14281):

3. SSE 协议

GET /bridge/events?since=<cursor>(lib/index.js:1693)。帧序列固定:

event: ready
data: {"cursor":42,"kinds":["open-session","new-session","reveal","refresh","open-settings"]}

id: 43
event: command
data: {"seq":43,"kind":"open-session","payload":{…},"createdAt":1730000000000}

: keep-alive

重连不重放:EventSource 断线后会自己重连同一个 URL,并带上 Last-Event-ID。服务端优先 读这个头,读不到才回落到查询参数 since(lib/index.js:1716-1717)。没有这一步,重连会从 首次连接的游标开始补发,new-session 就开两个窗口。

背压与上限:res.write() 返回 false 表示套接字缓冲已满,此时直接结束这条流——面板会带 Last-Event-ID 重连,从它真正收到的最后一帧继续,而不是让缓冲无限增长。同时订阅者上限 32 (MAX_LISTENERS,lib/host/bridge.js:35),满了回 503:重连风暴不能把卡住的响应再叠一层。

SSE 而不是轮询:命令是事件,而唯一会变的数字(下载百分比)走的是轮询——两者的性质不同。

4. 主题

问题

面板在 DSH 的 document 里,getComputedStyle 能解出每个 --dsw-alias-* 的当前值。 独立窗口是另一个 document,DSH 的样式表不在那里,那些 token 会退回面板自己的默认值—— 于是用户装了主题插件、或者切到浅色,窗口不跟着变。

解法

面板把它看到的报给 host,窗口来问 host 要。lib/host/theme.js 是个极小的共享 store。

只接受样式表真正用到的 14 个 token(THEME_TOKENS,lib/host/theme.js:31):

--dsw-alias-bg-layer-1 / -2 / -3
--dsw-alias-label-primary / -secondary / -tertiary
--dsw-alias-border-l1 / -l2
--dsw-alias-state-business-primary / -error-primary / -success-primary / -warning-primary
--dsw-alias-brand-primary
--dsw-alias-interactive-bg-hover

校验(isSafeTokenValue,lib/host/theme.js:57):值最终会进 CSS 自定义属性,所以无界的 map 就是一条样式注入通道。规则是:

不匹配的单个 token 被丢掉,不影响其它 token。

上报时机

reportTheme()(lib/client.js:11130):读 .se-shell(没挂载就读 body,最后才退到 documentElement)的 computed style,取这 14 个 token 的值,POST /theme 带 { mode, tokens }。 mode 来自 documentElement 的 data-theme 是否为 light。

读 body 而不是 documentElement 是有原因的:DSH 把调色板声明在 body 上 (--dsw-alias-* 定义在 body / body[data-ds-dark-theme]),自定义属性只向下继承, 所以从 <html> 读拿到的是空调色板。上一版正是在 apply() 里从 documentElement 读一次——那时面板还没挂载、<html> 又没有 token,于是 host 存下一份空的调色板, 窗口只好用样式表里硬编码的兜底色(工作区名字/图标因此是深蓝 #4176e6,而 DSH 里是主题色)。

值必须解析成具体颜色(resolveThemeValue(),lib/client.js:11092):DSH 的 alias token 几乎都是指向自己调色板的引用,例如 --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50)。 窗口没有 --dsw-static-* 这一层,原样搬过去会得到一个”引用了不存在变量”的值——这在 CSS 里是 无效而不是未设置,消费方 var(--token, fallback) 的 fallback 根本轮不到,文字会掉成 unset。所以在这里就地展开引用(深度上限 8 层,支持 var(--x, #fallback)),解不出来的 token 直接丢给 host 丢弃,让窗口保留自己的兜底。

触发点有三个:apply() 时一次;Panel 挂载后一次(lib/client.js:6386,此时 .se-shell 才存在,读到的就是面板真正看到的调色板);以及一个 MutationObserver——监视 documentElement 的 class / data-theme / style 三个属性。这是 DSH 里所有主题插件切换 明暗的方式,所以监听它就等于监听”主题变了”。观察器挂在 ctx.effect 里,卸载时断开。

窗口侧

assets/detached/shim.js 在窗口启动时 GET /theme,把每个 token setProperty 到 documentElement,并同步 data-theme。

shim 会拦掉窗口发出的 POST /theme(assets/detached/shim.js:93)。因为窗口里的 client.js 也在跑,它也会上报——但窗口的配色本来就是从 host 拿来的,回声会把一份空调色板 盖在真的上面(首帧还没拿到回复时尤其明显)。所以这条通道只允许单向:面板 → host → 窗口。

5. 独立窗口

POST /detached/open / POST /detached/close(lib/index.js:1776-1786)。窗口本体是 DSH 的 Electron 窗口,加载 /session-navigator/api/ui,需要浏览器组件就绪,否则 404 component-not-installed。

窗口打开/关闭时 host 往总线发 window-open / window-closed(lib/index.js:179-180), 页面据此更新”默认打开方式”的状态和按钮文案。

窗口的几何(shell.window.bounds / alwaysOnTop)存在设置里:bounds 为 null 表示”还没放过”, 客户端先居中一次再把落点记下来。

6. shim 到底做了什么

assets/detached/shim.js:1-25 写得很清楚:窗口和面板是同一个 UI,窗口原样加载 lib/client.js,shim 只是在那份模块 mount 之前假装自己是 DSH。client.js 向平台要四样东西, shim 四样全给:

平台接口 shim 给的
window.__ModuleLoader__.load({ id, factory }) 直接调用 factory
require("react") 仓库里自带的 UMD 构建
ctx.slots / ctx.locale / ctx.effect / ctx.get 一个”刚好够用”的 ctx
document.body + createPortal 窗口自己的 document

因为面板是通过 portal 渲染进 document.body 的(getOverlayRoot),这在任何 document 里都能工作, 所以窗口只需要渲染一次 footer-action 槽位、把 panel store 打开就行。

shim 故意不做两件事:不上报主题(见上),不订阅命令桥(窗口只向 host 请求导航, 执行由 DSH 页面里的面板完成)。

7. 相关文件

文件 职责
lib/host/bridge.js 总线、WINDOW_COMMANDS、normalizeCommand
lib/host/theme.js THEME_TOKENS、isSafeTokenValue、theme store
lib/index.js:1680 POST /bridge/commands
lib/index.js:1693 GET /bridge/events(SSE)
lib/client.js:11130 reportTheme() + resolveThemeValue()(lib/client.js:11092)+ MutationObserver
lib/client.js:828 subscribeBridge()
lib/client.js:14210 apply() 里装配桥与主题监听
assets/detached/shim.js 窗口的假 DSH 外壳、主题拉取、回声守卫