dsh-code-ui 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  | :---: | :---: |
21
21
  | ![会话头 ⌨ code UI 按钮](https://github.com/soGoodayo/dsh-code-ui/raw/main/image/%E4%BC%9A%E8%AF%9D%E5%A4%B4%E6%8C%89%E9%92%AE.png) | ![悬浮模式条](https://github.com/soGoodayo/dsh-code-ui/raw/main/image/%E6%82%AC%E6%B5%AE%E6%8C%89%E9%92%AE.png) |
22
22
 
23
- > 📍 **所有配置都在:设置 → 插件 → 插件列表 → dsh-code-ui 的配置卡片**——编辑器界面(右侧停靠 / 悬浮窗口 / 隐藏)、编辑器主题、切换按钮、启用开关、检查更新,切换即时生效并持久化保存。
23
+ > 📍 **所有配置都在:设置 → 插件 → 插件列表 → dsh-code-ui 的配置卡片**——编辑器界面(右侧停靠 / 悬浮窗口 / 隐藏)、编辑器主题、语法配色、切换按钮、启用开关、检查更新,切换即时生效并持久化保存。
24
24
 
25
25
  ## 功能
26
26
 
@@ -30,7 +30,7 @@
30
30
  - **Cursor 风格工作区**:活动栏 + 文件树侧边栏 + 多标签页编辑器 + 状态栏
31
31
  - **侧栏收起与调宽**:« 按钮 / 📁 三态 / 拖到最窄自动收起,» 把手展开,右缘拖拽调宽
32
32
  - **停靠面板拖宽**:左缘手柄实时调整面板宽度,DSH 界面自动跟随
33
- - **语法高亮**:内联轻量 tokenizer,覆盖 JS/TS/Python/HTML/CSS 等,token 色随主题两套配色
33
+ - **语法高亮**:highlight.js 11(193 种语言)替换自研 tokenizer——宿主路由懒加载不拖累启动,10 款语法配色可选(自动随明暗 / GitHub / Dracula / Monokai…),加载失败或桌面端自动回退内置轻量高亮
34
34
  - **文件浏览与编辑**:展开目录、打开文件、直接编辑、`Ctrl+S` 保存写回磁盘;外部修改自动同步
35
35
  - **选中代码右键**:
36
36
  - 📋 复制
@@ -42,6 +42,8 @@
42
42
  - **内嵌 AI 输入框**(悬浮布局):直接输入、`Ctrl+Enter` 发送;发送时不粘贴引用正文,只附一行引用指针,内容由 AI 调用 `get_referenced_snippets` 工具读取
43
43
  - **主题四态**:跟随 DSH(默认)/ 跟随系统 / 浅色 / 深色,插件卡片切换
44
44
  - **以此文件夹为根**:文件浏览器里可选任意目录作为工作区根
45
+ - **根目录按会话隔离**:切换对话自动换根,各会话的手动根互不污染;新建对话(未发消息)也能立即定位其工作区
46
+ - **标签页右键菜单**:关闭 / 关闭其他 / 关闭右侧标签页 / 关闭已保存 / 关闭全部 / 复制路径;关闭未保存文件前确认(保存并关闭 / 不保存 / 取消,保存失败者保留打开)
45
47
  - **检查更新 / 一键更新**:设置 → 插件 → 插件列表 → dsh-code-ui 的配置卡片,支持在线检查 npm 新版本并一键更新;未发布/开发期可填本地 `.tgz` 路径安装(pnpm 流式进度展示,失败自动归类给出建议,完成后提示重启 DSH 生效)
46
48
 
47
49
  ## 安装
@@ -71,7 +73,7 @@ npm install dsh-code-ui
71
73
 
72
74
  ## 使用
73
75
 
74
- 1. 点会话标题栏 `⌨ code UI` 按钮打开编辑器(默认右侧停靠;悬浮布局则用顶部悬浮模式条)
76
+ 1. 点顶部悬浮模式条的 `code UI` 打开编辑器(默认切换按钮;也可在插件卡片换成会话头 `⌨` 按钮),默认布局为右侧停靠
75
77
  2. 在文件树/浏览器里打开文件
76
78
  3. 选中代码右键 → 引用 / 翻译 / 备注 / 复制
77
79
  4. 悬浮布局底部输入框直接发消息(`Ctrl+Enter` 发送);停靠布局用 DSH 原生输入条,引用只以标签显示,正文由 AI 工具读取
@@ -84,7 +86,10 @@ dsh-code-ui/
84
86
  ├── package.json # 包声明(含 dsh.bundle.patch / dsh.client)
85
87
  ├── lib/
86
88
  │ ├── index.js # Host 端:connection.rpc 文件/引用/备注 RPC + get_referenced_snippets 工具
87
- │ └── client.js # Client 端:__ModuleLoader__ + 编辑器 UI
89
+ │ │ # + webServer exact 路由(下发 lib/vendor 静态资产)
90
+ │ ├── client.js # Client 端:__ModuleLoader__ + 编辑器 UI(highlight.js 懒加载接线)
91
+ │ └── vendor/ # highlight.js 全量包 + 作用域化主题 CSS(脚本生成,勿手改)
92
+ ├── tools/ # vendor 重建脚本:node tools/vendor-hljs.mjs [版本] [--themes-only]
88
93
  └── cordis.patch.yml # bundle 插接层
89
94
  ```
90
95
 
@@ -92,6 +97,7 @@ dsh-code-ui/
92
97
 
93
98
  ## 实现说明
94
99
 
100
+ - **语法高亮**:`lib/vendor/` 下为 highlight.js 全量包(core + 193 语言)与作用域化主题 CSS,由 `tools/vendor-hljs.mjs` 从 CDN 生成;宿主经 `webServer` 注册两个 exact 路由下发,客户端首次需要高亮时以 `<script>`/`<link>` 懒加载(绝不内联进 client.js),失败或 Electron(`file://`)自动回退内置轻量 tokenizer;重高亮带 ~60ms 防抖与 512KB 大文件降级。
95
101
  - **Host↔Client RPC**:Host 通过 `connection.rpc.handle("/dsh-code-ui", handler)` 注册文件系统/引用/备注端点;Client 通过 `connection.rpc.call("/dsh-code-ui", endpoint, payload)` 调用。RPC 返回值遵循 DSH 的 `RpcResult` 协议(`{ ok: true, value }` / `{ ok: false, error }`)。
96
102
  - **模型工具**:注册 `get_referenced_snippets`,让 AI 读取用户标记的代码片段。
97
103
  - **Client UI**:`window.__ModuleLoader__.load({ id, factory })` 注册,React 通过 `require("react")` 获取,slot 通过 `ctx.get("slots")` 注册(`shell.overlay`),样式用 `--dsw-alias-*` 主题变量。
@@ -108,6 +114,7 @@ dsh-code-ui/
108
114
 
109
115
  | 时间 | 版本 | 修复 / 新增 |
110
116
  | --- | --- | --- |
117
+ | 2026-09-08 | v1.4.0 | 里程碑:**语法高亮升级 highlight.js 11**(193 种语言,替换自研 tokenizer)。宿主 `webServer` exact 路由下发 `lib/vendor/` 全量包与主题 CSS,客户端首次高亮时懒加载、不拖累 SPA 启动;Electron(file://)/断网/路由缺失自动回退原 tokenizer 保底可用。插件卡片新增「语法配色」:自动(默认,随编辑器明暗 GitHub ↔ GitHub Dark)+ 10 款固定主题(GitHub / Dracula / Monokai / Nord / VS…),仅染 token 色、编辑器底色仍归「编辑器主题」管理,切换即时生效并持久化。性能:重高亮 ~60ms 防抖 + >512KB 大文件自动降级轻量高亮。`tools/vendor-hljs.mjs` 重建管线入库(支持 `--themes-only`、fetch 超时重试、Dracula 取官方源)。兼容性:适配 dsh 0.1.2+ 新版 settings 服务——不再 import 已删除的 `installSettingsSection`/`settingsNamespace` 具名导出(具名导出缺失会让宿主启动即退出),照 dshmarket 内联 `register`/`watch` 接线(服务 API 新旧一致),旧宿主 0.1.0-rc.7+ 同样可跑。交互优化:文件树选中态改为实心选中色 + 选中文字反白 + 左缘 2px 白色强调条——底色跟随宿主 `--dsw-alias-button-info-fill` 令牌(与 dshUI「对话/轨迹」页签同源,回退 #4176e6),宿主换肤自动同步;原层级灰在浅色主题下与侧栏底色仅差 5/255,选中几乎隐形。文件树根目录**按会话隔离**:宿主以 sessionId→root 映射记忆各会话的手动根(「以此文件夹为根」只影响当前会话,不再全局污染),切换会话即解析新会话的 cwd 或其记忆根——修复「新会话打开仍是旧会话地址」(原手动根为宿主级全局变量,且客户端跟随标志不持久化导致刷新后跟随失效且无提示)。未发消息的新会话也能解析根目录:会话创建时所选工作区路径已写入不可变 `SessionHeader.cwd`,经 `sessions` 服务可查(原仅查 `agents`,agent 要到首条消息后才存在);重启后重开旧对话(agent 尚未恢复)时经 `sessionController.inspect` 读持久化 header 兜底。空新对话(blank 会话)根因:宿主对 blank 会话隐藏会话头 chrome(hideChrome),插件 InputBridge 所在 slot 不渲染、捕获不到新会话 id——新增常驻 SessionWatch(root 作用域 `shell.overlay` + 标准 props `useSessions`)监听当前选中会话,blank 会话(宿主侧 agent 在 session/create 时即建、cwd=所选工作区)也能即时换根。标签页**右键菜单**:关闭 / 关闭其他 / 关闭右侧标签页 / 关闭已保存 / 关闭全部 + 复制路径(无对象项自动置灰);关闭波及未保存文件时弹确认框(保存并关闭 / 不保存 / 取消,多文件列清单、>5 折叠计数,保存失败者保留打开)——原标签 ✕ 按钮静默丢弃未保存改动,一并接入同一确认流程 |
111
118
  | 2026-09-02 | v1.3.2 | 调整:**切换按钮默认改为悬浮模式条**(原会话头 ⌨ 按钮,已保存选择的用户不受影响);**文件树默认根跟随当前会话的工作区目录**——打开编辑器即定位到当前对话的项目根,会话切换自动换根(有未保存文件时暂不跟随并提示,保存后点 ⟳ 即切换);手动「以此文件夹为根」后不再自动跟随 |
112
119
  | 2026-09-02 | v1.3.1 | 调整:安装后编辑器**默认隐藏**(dsh UI,新装不自动占屏,⌨ / 模式条随时唤回),布局默认仍为右侧停靠;配置卡片「编辑器界面」三态说明与 README 措辞同步。新增:一键更新 / 本地包更新成功后按钮文案临时变为「需重启 DSH 生效」(置灰防误点);README 增加「界面一览」(双布局与两种切换按钮截图)并明确配置入口路径(设置 → 插件 → 插件列表 → dsh-code-ui) |
113
120
  | 2026-09-02 | v1.3.0 | 新增:文件树右键操作(真实磁盘)。右键文件:打开 / 引用给 AI(整个文件)/ 复制路径 / 在资源管理器中显示 / 重命名 / 删除;右键文件夹:新建文件 / 新建文件夹 / 以此文件夹为根 / 刷新 / 复制路径 / 定位 / 重命名 / 删除(递归强确认);空白区右键:根目录新建 / 刷新 / 更换根;新建重命名输入弹窗(Enter 确认)、危险操作确认弹窗、工作区根禁删;重命名自动同步标签页/缓冲/watcher,删除自动清理该路径下全部标签。引用体系升级:整文件引用只传绝对路径由 AI 自行读取;停靠布局下 DSH 原生输入条发送也自动附加引用指针(挂钩 submit,两布局统一)。配置卡片:「布局模式 × 界面模式」合并为三态「编辑器界面」(右侧停靠/悬浮窗口/隐藏 dsh UI,隐藏时记住布局)。打开文件弹窗:输入路径回车直接认定(文件夹=设为根 / 文件=打开)。提示条移至顶部居中、可拖拽自定位置(⠿ 把手)。右键菜单视口边界自适应、点击菜单项后自动关闭;⟳ 刷新不再折叠整棵树 |
package/lib/client.js CHANGED
@@ -80,9 +80,15 @@ window.__ModuleLoader__.load({
80
80
  .cdex-sb-expand{width:18px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--dsw-alias-label-secondary,#9d9d9d);background:var(--dsw-specific-sidebar-fill,#252526);border-right:1px solid var(--dsw-alias-border-l1,#333333);font-size:15px;user-select:none;}
81
81
  .cdex-sb-expand:hover{color:var(--dsw-alias-label-primary,#d4d4d4);background:var(--dsw-alias-interactive-bg-hover,#3a3a41);}
82
82
  .cdex-tree{flex:1;overflow:auto;padding:4px;}
83
- .cdex-node{display:flex;align-items:center;gap:5px;padding:2px 6px;border-radius:4px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-primary,#cccccc);font-size:13px;}
83
+ .cdex-node{position:relative;display:flex;align-items:center;gap:5px;padding:2px 6px;border-radius:4px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-primary,#cccccc);font-size:13px;}
84
84
  .cdex-node:hover{background:var(--dsw-alias-bg-layer-1,#2a2d2e);}
85
- .cdex-node.active{background:var(--dsw-alias-bg-layer-2,#37373d);}
85
+ /* 选中态:专用令牌(浅色下层级灰几乎隐形),底色 + 左缘强调条双保险(照活动栏做法)。
86
+ 注意勿用 --cdex-accent-bg 当底色:浅色主题下它是近黑、深色下是白,反着给按钮用的。 */
87
+ /* 选中底色跟随宿主信息蓝令牌(与 dshUI「对话/轨迹」页签同源,宿主换肤自动同步)。
88
+ 本地两套主题变量未定义该令牌,运行时从宿主根继承;#4176e6 为宿主无此令牌时的回退。 */
89
+ .cdex-node.active{background:var(--dsw-alias-button-info-fill,#4176e6);color:#ffffff;}
90
+ .cdex-node.active .cdex-arrow,.cdex-node.active .cdex-file,.cdex-node.active .cdex-folder{color:#ffffff;}
91
+ .cdex-node.active::before{content:'';position:absolute;left:0;top:3px;bottom:3px;width:2px;background:#ffffff;border-radius:1px;}
86
92
  .cdex-arrow{width:14px;flex:0 0 auto;color:var(--dsw-alias-label-secondary,#888888);text-align:center;font-size:11px;}
87
93
  .cdex-folder{color:#dcb67a;}
88
94
  .cdex-file{color:var(--dsw-alias-label-primary,#cccccc);}
@@ -126,6 +132,13 @@ window.__ModuleLoader__.load({
126
132
  .cdex-menu{position:fixed;z-index:10002;background:var(--dsw-alias-bg-overlay,#252526);border:1px solid var(--dsw-alias-border-l2,#454545);border-radius:6px;padding:4px;min-width:180px;box-shadow:0 8px 24px rgba(0,0,0,.5);max-height:calc(100vh - 24px);overflow-y:auto;}
127
133
  .cdex-menu-item{display:flex;gap:8px;align-items:center;padding:7px 10px;border-radius:4px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary,#d4d4d4);}
128
134
  .cdex-menu-item:hover{background:var(--cdex-accent-bg,#0f1115);color:var(--cdex-accent-fg,#ffffff);}
135
+ .cdex-menu-item.disabled{opacity:.45;cursor:default;}
136
+ .cdex-menu-item.disabled:hover{background:transparent;color:var(--dsw-alias-label-primary,#d4d4d4);}
137
+ .cdex-menu-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l2,#454545);}
138
+ .cdex-confirm-list{max-height:160px;overflow-y:auto;margin:8px 0 0;display:flex;flex-direction:column;gap:2px;}
139
+ .cdex-confirm-file{font-size:13px;color:var(--dsw-alias-label-primary,#d4d4d4);padding:2px 0;}
140
+ .cdex-confirm-more{font-size:12px;color:var(--dsw-alias-label-secondary,#9d9d9d);padding:2px 0;}
141
+ .cdex-btn-primary{background:var(--dsw-alias-button-info-fill,#4176e6);color:#ffffff;border-color:transparent;}
129
142
  .cdex-modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:10002;pointer-events:auto;}
130
143
  .cdex-modal-box{background:var(--dsw-alias-bg-overlay,#252526);border:1px solid var(--dsw-alias-border-l2,#454545);border-radius:8px;padding:16px;width:560px;max-width:92vw;}
131
144
  .cdex-modal-title{font-weight:600;margin-bottom:10px;color:var(--dsw-alias-label-primary,#e8e8e8);word-break:break-all;}
@@ -245,6 +258,7 @@ window.__ModuleLoader__.load({
245
258
  nodes: {},
246
259
  tabs: [],
247
260
  activePath: null,
261
+ closeConfirm: null, // 关闭未保存确认框:{ paths 待关列表, dirty 脏文件列表, keepActive 批量关闭后应激活的标签 }
248
262
  buffers: {},
249
263
  savedContent: {},
250
264
  dirty: {},
@@ -265,9 +279,9 @@ window.__ModuleLoader__.load({
265
279
  toast: null,
266
280
  toastKeep: false,
267
281
  toastPos: null,
268
- sessionId: null, // 当前会话 id(InputBridge 从 slot kit 捕获):文件树默认根跟随当前会话
269
- rootIsAuto: true, // 根是否为自动跟随(用户「以此文件夹为根」后置 false,不再跟随)
270
- rootSessionId: null, // 当前自动根来自哪个会话(落后于 sessionId 时触发重新解析)
282
+ sessionId: null, // 当前会话 id(InputBridge 从 slot kit 捕获;blank 会话头隐藏时由 SessionWatch 兜底):文件树根按会话解析/记忆
283
+ rootSessionId: null, // 当前根归属哪个会话(落后于 sessionId 时触发重新解析;手动根由宿主按会话记忆)
284
+ scrollPos: {}, // 文件路径 -> {top,left}:每个标签记住自己的滚动位置(切换后恢复)
271
285
  error: null,
272
286
  }
273
287
  const listeners = new Set()
@@ -598,6 +612,112 @@ window.__ModuleLoader__.load({
598
612
  return out.join('')
599
613
  }
600
614
 
615
+ /* ============ §3.5 highlight.js 接线(vendor 资产经宿主路由懒加载;未就绪/失败回退上方自研 tokenizer) ============ */
616
+ // 主题表:与 tools/vendor-hljs.mjs 的 THEMES、宿主端 SYN_THEME_IDS 一一对应(展示名单点维护)
617
+ const SYN_THEMES = [
618
+ ['github', 'GitHub'], ['github-dark', 'GitHub Dark'], ['atom-one-dark', 'Atom One Dark'], ['atom-one-light', 'Atom One Light'],
619
+ ['monokai', 'Monokai'], ['dracula', 'Dracula'], ['nord', 'Nord'], ['vs', 'VS 浅色'], ['vs2015', 'VS2015 深色'], ['tokyo-night-dark', 'Tokyo Night Dark'],
620
+ ]
621
+ const HLJS_VENDOR_BASE = '/dsh-code-ui/vendor/'
622
+ const HLJS_BIG_CHARS = 512 * 1024 // 超大文件不做 hljs(防卡顿),继续用轻量 tokenizer
623
+ // langOf 的输出大多是 hljs 语言 id/别名;少数需归一,其余交给 hljs.getLanguage 解析,失败回退 plaintext
624
+ const HLJS_LANG_FIX = { text: 'plaintext', jsx: 'javascript', tsx: 'typescript', htm: 'xml' }
625
+ function hljsLangOf(lang) {
626
+ const hljs = window.hljs
627
+ if (!hljs || typeof hljs.getLanguage !== 'function') return null
628
+ const l = HLJS_LANG_FIX[lang] || lang
629
+ try { if (hljs.getLanguage(l)) return l } catch (e) {}
630
+ return 'plaintext'
631
+ }
632
+ // 懒加载单例:经典 <script>/<link> 注入(切勿在 ModuleLoader factory 内 eval——UMD 会检测到
633
+ // 假 module/exports 走 CJS 分支,挂到 module.exports 而不是 window.hljs)。
634
+ // Electron(file://)/断网/路由缺失 → 失败态,永久回退自研 tokenizer(保底可用)。
635
+ let hljsState = 'idle' // idle | loading | ready | failed
636
+ let hljsPromise = null
637
+ let selfVersion = '' // get-self 回填,用作 vendor 资产的 cache-bust query
638
+ function loadHljs() {
639
+ if (hljsState === 'ready') return Promise.resolve(true)
640
+ if (hljsState === 'failed') return Promise.resolve(false)
641
+ if (hljsPromise) return hljsPromise
642
+ hljsState = 'loading'
643
+ hljsPromise = new Promise((resolve) => {
644
+ const settle = (ok) => {
645
+ if (hljsState === 'ready') { resolve(true); return }
646
+ hljsState = ok ? 'ready' : 'failed'
647
+ if (!ok) hljsPromise = null
648
+ resolve(ok)
649
+ emit() // 就绪后重渲染:已打开文件从 tokenizer 输出切到 hljs 输出
650
+ }
651
+ try {
652
+ const v = encodeURIComponent(selfVersion)
653
+ const q = v ? ('?v=' + v) : ''
654
+ const link = document.createElement('link')
655
+ link.rel = 'stylesheet'
656
+ link.href = HLJS_VENDOR_BASE + 'hljs-themes.css' + q
657
+ const script = document.createElement('script')
658
+ script.src = HLJS_VENDOR_BASE + 'highlight.full.min.js' + q
659
+ script.onload = () => { settle(!!(window.hljs && typeof window.hljs.highlight === 'function')) }
660
+ script.onerror = () => { settle(false) }
661
+ // 超时兜底:15s 仍未就绪按失败处理(script 若迟到加载成功,onload 仍会升级为 ready)
662
+ setTimeout(() => { if (hljsState === 'loading') settle(false) }, 15000)
663
+ document.head.appendChild(link)
664
+ document.head.appendChild(script)
665
+ } catch (e) { settle(false) }
666
+ })
667
+ return hljsPromise
668
+ }
669
+ // 语法配色偏好:auto(默认,随编辑器明暗 GitHub/GitHub Dark) 或 SYN_THEMES 之一
670
+ let synThemePref = 'auto'
671
+ function editorPrefersDark() {
672
+ if (themeMode === 'dark') return true
673
+ if (themeMode === 'light') return false
674
+ if (themeMode === 'system') return systemPrefersDark()
675
+ return dshPrefersDark()
676
+ }
677
+ function synThemeId() {
678
+ if (synThemePref !== 'auto') return synThemePref
679
+ return editorPrefersDark() ? 'github-dark' : 'github'
680
+ }
681
+ // hljs 结果缓存 + 防抖重高亮:内容未变直接复用;变了先用自研 tokenizer 顶上(同步、有色、
682
+ // 新敲的字符永远立即可见),停顿 ~60ms 后异步换成 hljs 输出并重渲染。
683
+ const hljsCache = new Map() // path -> { content, html }(超 24 个逐出最旧,防内存无界)
684
+ const hljsPending = new Map() // path -> 取消句柄
685
+ function hljsDelay(fn, ms) {
686
+ if (timer && typeof timer.timeout === 'function') return timer.timeout(fn, ms)
687
+ const t = setTimeout(fn, ms)
688
+ return () => clearTimeout(t)
689
+ }
690
+ function scheduleHljs(p, content, lang) {
691
+ const prev = hljsPending.get(p)
692
+ if (prev) { try { prev() } catch (e) {} }
693
+ const cancel = hljsDelay(() => {
694
+ hljsPending.delete(p)
695
+ let html = ''
696
+ try {
697
+ html = window.hljs.highlight(content, { language: hljsLangOf(lang) || 'plaintext', ignoreIllegals: true }).value
698
+ } catch (e) {
699
+ html = highlight(content, lang) // 单文件异常(不应发生):退回 tokenizer 输出
700
+ }
701
+ // 与 tokenizer 输出保持行数一致:末尾换行补齐(textarea 会渲染最后的空行)
702
+ if (content.endsWith('\n') && !html.endsWith('\n')) html += '\n'
703
+ if (hljsCache.size > 24) { const first = hljsCache.keys().next().value; if (first !== undefined) hljsCache.delete(first) }
704
+ hljsCache.set(p, { content, html })
705
+ if (store.tabs.some((t) => t.path === p)) emit()
706
+ }, 60)
707
+ hljsPending.set(p, cancel)
708
+ }
709
+ // 编辑区高亮统一入口:hljs 就绪且文件不大 → hljs(缓存命中直用,否则防抖调度);
710
+ // 否则自研 tokenizer 同步渲染(加载中/失败/超大文件的回退面)
711
+ function renderHighlight(p, content) {
712
+ const lang = langOf(p)
713
+ if (hljsState === 'idle') loadHljs() // 首次真正需要高亮时才触发懒加载
714
+ if (hljsState !== 'ready' || content.length > HLJS_BIG_CHARS) return highlight(content, lang)
715
+ const cached = hljsCache.get(p)
716
+ if (cached && cached.content === content) return cached.html
717
+ scheduleHljs(p, content, lang)
718
+ return highlight(content, lang) // 间歇帧:tokenizer 顶上,保证字符即时可见有色
719
+ }
720
+
601
721
  /* ============ §4 RPC 操作层(文件树 / 标签页 / 引用备注) ============ */
602
722
  // —— 文件浏览器 ——
603
723
  async function browseDir(path) {
@@ -628,10 +748,10 @@ window.__ModuleLoader__.load({
628
748
  }
629
749
  async function setWorkspaceRoot(path) {
630
750
  if (!path) return
631
- const res = await callHost('set-root', { path })
751
+ // sessionId 一并上送:手动根按会话记忆(宿主 sessionRoots),其它会话不受影响
752
+ const res = await callHost('set-root', { path, sessionId: store.sessionId })
632
753
  if (res && res.error) { showToast('设置失败:' + res.error); return }
633
- store.rootIsAuto = false // 用户显式选择:此后不再跟随会话切换
634
- store.rootSessionId = null
754
+ store.rootSessionId = store.sessionId // 手动根归属当前会话
635
755
  await applyRoot((res && res.root) ? res.root : path)
636
756
  showToast('工作区根目录:' + store.rootPath)
637
757
  }
@@ -656,8 +776,9 @@ window.__ModuleLoader__.load({
656
776
  async function refresh() {
657
777
  store.error = null
658
778
  const sid = store.sessionId
659
- // 自动根模式:首次无根、或会话已切换(rootSessionId 落后)都重新解析当前会话的工作区
660
- if (!store.rootPath || (store.rootIsAuto && sid && sid !== store.rootSessionId)) {
779
+ // 首次无根、或会话已切换(rootSessionId 落后)都重新解析当前会话的根:
780
+ // 自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根
781
+ if (!store.rootPath || (sid && sid !== store.rootSessionId)) {
661
782
  const r = await callHost('get-root', sid ? { sessionId: sid } : {})
662
783
  const root = (r && r.root) || null
663
784
  if (!root) { store.error = '未找到工作区根目录'; emit(); return }
@@ -723,6 +844,37 @@ window.__ModuleLoader__.load({
723
844
  // 标签关闭:释放 host 端 watcher
724
845
  callHost('unwatch-file', { path }).then(() => {})
725
846
  }
847
+ // 统一关闭入口(标签 ✕、右键菜单各关闭项都走这里):波及脏文件时弹确认框
848
+ //(方案 A:保存并关闭 / 不保存 / 取消),干净的直接关。关闭已保存项除外——它只关干净的。
849
+ function requestCloseTabs(paths, keepActive) {
850
+ const list = (paths || []).filter((p) => store.tabs.some((t) => t.path === p))
851
+ if (!list.length) return
852
+ const dirtyList = list.filter((p) => store.dirty[p])
853
+ if (!dirtyList.length) { closeTabsNow(list, keepActive); return }
854
+ store.closeConfirm = { paths: list, dirty: dirtyList, keepActive: keepActive || null }
855
+ emit()
856
+ }
857
+ // 立即关闭一批标签(不做脏检查):顺序复用 closeTab 的邻居激活 + watcher 释放;
858
+ // keepActive=「关闭其他/关闭右侧」场景:结束后激活落到触发菜单的标签上
859
+ function closeTabsNow(list, keepActive) {
860
+ for (let i = 0; i < list.length; i++) closeTab(list[i])
861
+ if (keepActive && store.tabs.some((t) => t.path === keepActive)) {
862
+ store.activePath = keepActive
863
+ loadNotes(keepActive)
864
+ }
865
+ emit()
866
+ }
867
+ // 确认框「保存并关闭」:逐个保存;保存失败(dirty 保持 true)的保留打开,其余关闭
868
+ async function confirmSaveAndClose() {
869
+ const cc = store.closeConfirm
870
+ if (!cc) return
871
+ store.closeConfirm = null
872
+ emit()
873
+ for (let i = 0; i < cc.dirty.length; i++) await saveFile(cc.dirty[i])
874
+ closeTabsNow(cc.paths.filter((p) => !store.dirty[p]), cc.keepActive)
875
+ const failed = cc.dirty.filter((p) => store.dirty[p])
876
+ if (failed.length) showToast('有 ' + failed.length + ' 个文件保存失败,已保留打开')
877
+ }
726
878
  // 外部修改同步:从磁盘重读单文件(脏缓冲不被自动覆盖,只有用户点 ⚡ 或确认后才走这里)
727
879
  async function reloadFromFile(path) {
728
880
  const res = await callHost('read-file', { path })
@@ -794,6 +946,28 @@ window.__ModuleLoader__.load({
794
946
  emit()
795
947
  }
796
948
  function closeMenu() { store.ctxMenu = null; emit() }
949
+ // 标签页右键菜单:关闭 / 关闭其他 / 关闭右侧 / 关闭已保存 / 关闭全部 + 复制路径。
950
+ // 智能禁用:单项动作在无对象时置灰;「关闭已保存」只关干净标签、不触发确认框。
951
+ function onTabContext(e, path) {
952
+ const idx = store.tabs.findIndex((t) => t.path === path)
953
+ if (idx < 0) return
954
+ const savedPaths = store.tabs.filter((t) => !store.dirty[t.path]).map((t) => t.path)
955
+ const rightPaths = store.tabs.slice(idx + 1).map((t) => t.path)
956
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items: [
957
+ { icon: '', label: '关闭', act: () => requestCloseTabs([path]) },
958
+ { icon: '', label: '关闭其他', disabled: store.tabs.length <= 1,
959
+ act: () => requestCloseTabs(store.tabs.filter((t) => t.path !== path).map((t) => t.path), path) },
960
+ { icon: '', label: '关闭右侧标签页', disabled: rightPaths.length === 0,
961
+ act: () => requestCloseTabs(rightPaths, path) },
962
+ { icon: '', label: '关闭已保存', disabled: savedPaths.length === 0,
963
+ act: () => closeTabsNow(savedPaths) },
964
+ { icon: '', label: '关闭全部', disabled: store.tabs.length === 0,
965
+ act: () => requestCloseTabs(store.tabs.map((t) => t.path)) },
966
+ { sep: true },
967
+ { icon: '📋', label: '复制路径', act: () => { copyText(path); showToast('路径已复制') } },
968
+ ] }
969
+ emit()
970
+ }
797
971
  async function doCite(sel) {
798
972
  const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
799
973
  if (ref && ref.reference) { store.references.push(ref.reference); emit() }
@@ -1043,14 +1217,13 @@ window.__ModuleLoader__.load({
1043
1217
  store.inputDraft = inputState.draft
1044
1218
  store.inputPhase = inputState.phase
1045
1219
  }
1046
- // 捕获当前会话 id:文件树默认根跟随当前会话的工作区目录
1220
+ // 捕获当前会话 id:文件树根按会话隔离——切换即解析新会话的根
1221
+ //(自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根)
1047
1222
  if (props.sessionId && props.sessionId !== store.sessionId) {
1048
1223
  store.sessionId = props.sessionId
1049
- if (store.rootIsAuto) {
1050
- const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1051
- if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1052
- else refresh()
1053
- }
1224
+ const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1225
+ if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1226
+ else refresh()
1054
1227
  }
1055
1228
  emit()
1056
1229
  })
@@ -1077,6 +1250,23 @@ window.__ModuleLoader__.load({
1077
1250
  }, [inputActions])
1078
1251
  return null
1079
1252
  }
1253
+ // 常驻会话监视器:空新对话(blank 会话)时宿主隐藏会话头 chrome(hideChrome),
1254
+ // InputBridge 所在的 conversation.session.header.actions slot 不渲染、拿不到会话 id;
1255
+ // 改从 root 作用域标准 props useSessions(ui-session 提供,任意 scope 可用)监听当前
1256
+ // 选中会话——blank 会话也是合法的 current,且宿主侧 agent 在 session/create 时即已
1257
+ // 创建(cwd=所选工作区路径),get-root 上送 blank 会话 id 即可正确解析根目录。
1258
+ function SessionWatch(props) {
1259
+ const useSessions = props.useSessions
1260
+ const sid = typeof useSessions === 'function' ? useSessions((s) => s.current) : undefined
1261
+ React.useEffect(() => {
1262
+ if (!sid || sid === store.sessionId) return
1263
+ store.sessionId = sid
1264
+ const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1265
+ if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1266
+ else refresh()
1267
+ }, [sid])
1268
+ return null
1269
+ }
1080
1270
  function AiInput() {
1081
1271
  const st = useStore()
1082
1272
  let taEl = null
@@ -1287,16 +1477,17 @@ window.__ModuleLoader__.load({
1287
1477
  )
1288
1478
  }
1289
1479
  let gutterEl = null
1480
+ let editorTaEl = null // 编辑器 textarea 当前 DOM(用于识别真挂载,避免内联 ref 每次渲染重复触发)
1290
1481
  function syncGutter(ta) {
1291
1482
  if (gutterEl) gutterEl.scrollTop = ta.scrollTop
1292
1483
  }
1293
1484
  function TabBar() {
1294
1485
  const st = useStore()
1295
1486
  return h('div', { className: 'cdex-tabs' },
1296
- st.tabs.map((t) => h('div', { key: t.path, className: 'cdex-tab' + (st.activePath === t.path ? ' active' : ''), onClick: () => { st.activePath = t.path; loadNotes(t.path); emit() } },
1487
+ st.tabs.map((t) => h('div', { key: t.path, className: 'cdex-tab' + (st.activePath === t.path ? ' active' : ''), onClick: () => { st.activePath = t.path; loadNotes(t.path); emit() }, onContextMenu: (e) => { e.preventDefault(); onTabContext(e, t.path) } },
1297
1488
  h('span', null, (st.externalChanged[t.path] ? '⚡' : '') + t.name + (st.dirty[t.path] ? ' ●' : '')),
1298
1489
  st.externalChanged[t.path] ? h('button', { className: 'cdex-tab-close', title: '文件已在磁盘上被外部修改,点击加载新内容(会覆盖本地未保存的修改)', onClick: (e) => { e.stopPropagation(); reloadFromFile(t.path) } }, '⇩') : null,
1299
- h('button', { className: 'cdex-tab-close', onClick: (e) => { e.stopPropagation(); closeTab(t.path) } }, '✕'),
1490
+ h('button', { className: 'cdex-tab-close', onClick: (e) => { e.stopPropagation(); requestCloseTabs([t.path]) } }, '✕'),
1300
1491
  )),
1301
1492
  h('div', { className: 'cdex-tab-actions' },
1302
1493
  h('button', { className: 'cdex-btn', title: '保存 (Ctrl+S)', onClick: () => saveFile() }, '保存'),
@@ -1320,8 +1511,8 @@ window.__ModuleLoader__.load({
1320
1511
  const lineCount = content.split('\n').length
1321
1512
  const gutter = []
1322
1513
  for (let i = 1; i <= lineCount; i++) gutter.push(String(i))
1323
- // 高亮 HTML(overlay 下层展示)
1324
- const html = highlight(content, langOf(p))
1514
+ // 高亮 HTML(overlay 下层展示):hljs 就绪走 hljs,否则回退自研 tokenizer
1515
+ const html = renderHighlight(p, content)
1325
1516
  // 滚动同步:把 pre 滚动到与 textarea 相同
1326
1517
  const syncOverlayScroll = (taEl) => {
1327
1518
  if (!taEl) return
@@ -1335,7 +1526,7 @@ window.__ModuleLoader__.load({
1335
1526
  h('div', { className: 'cdex-codearea' },
1336
1527
  h('div', { className: 'cdex-gutter', ref: (el) => { gutterEl = el } }, gutter.join('\n')),
1337
1528
  h('div', { className: 'cdex-code-layer' },
1338
- h('pre', { className: 'cdex-code-pre', 'aria-hidden': true, dangerouslySetInnerHTML: { __html: html } }),
1529
+ h('pre', { className: 'cdex-code-pre cdex-syn-' + synThemeId(), 'aria-hidden': true, dangerouslySetInnerHTML: { __html: html } }),
1339
1530
  h('textarea', {
1340
1531
  // key 拼上加载序号:内容异步读回后 key 变化触发重挂载,defaultValue 才会生效(否则编辑区空白、与高亮层脱节)
1341
1532
  key: p + ':' + (st.loadSeq[p] || 0),
@@ -1343,7 +1534,24 @@ window.__ModuleLoader__.load({
1343
1534
  defaultValue: content,
1344
1535
  spellCheck: false,
1345
1536
  wrap: 'off',
1346
- onScroll: (e) => syncOverlayScroll(e.target),
1537
+ ref: (el) => {
1538
+ if (!el) return
1539
+ // 仅在真正挂载时恢复滚动(换标签/内容重挂);不抢焦点,光标由用户自己点击决定
1540
+ if (el !== editorTaEl) {
1541
+ editorTaEl = el
1542
+ const saved = store.scrollPos[p]
1543
+ if (saved) {
1544
+ try { el.scrollTop = saved.top || 0; el.scrollLeft = saved.left || 0 } catch (e) {}
1545
+ }
1546
+ syncOverlayScroll(el)
1547
+ }
1548
+ },
1549
+ onScroll: (e) => {
1550
+ // 记住本文件的滚动位置(切换标签后可恢复);不触发重渲染
1551
+ const el = e.target
1552
+ store.scrollPos[p] = { top: el.scrollTop, left: el.scrollLeft }
1553
+ syncOverlayScroll(el)
1554
+ },
1347
1555
  onContextMenu: onEditorContext,
1348
1556
  onChange: (e) => {
1349
1557
  store.buffers[p] = e.target.value
@@ -1398,9 +1606,11 @@ window.__ModuleLoader__.load({
1398
1606
  } catch (e) {}
1399
1607
  return h('div', { className: 'cdex-menu-backdrop', onClick: closeMenu },
1400
1608
  h('div', { className: 'cdex-menu', style: { left: mx, top: my }, onClick: (e) => e.stopPropagation() },
1401
- items.map((it, i) => h('div', { key: i, className: 'cdex-menu-item', onClick: () => { try { it.act() } finally { closeMenu() } } },
1402
- h('span', null, it.icon), h('span', null, it.label),
1403
- )),
1609
+ items.map((it, i) => it.sep
1610
+ ? h('div', { key: i, className: 'cdex-menu-sep' })
1611
+ : h('div', { key: i, className: 'cdex-menu-item' + (it.disabled ? ' disabled' : ''), onClick: () => { if (it.disabled) return; try { it.act() } finally { closeMenu() } } },
1612
+ h('span', null, it.icon), h('span', null, it.label),
1613
+ )),
1404
1614
  ),
1405
1615
  )
1406
1616
  }
@@ -1468,6 +1678,35 @@ window.__ModuleLoader__.load({
1468
1678
  )
1469
1679
  }
1470
1680
  let noteTa = null
1681
+ // 关闭未保存文件确认(方案 A):保存并关闭 / 不保存 / 取消(点遮罩=取消)。
1682
+ // 单文件精简文案;多文件列清单(>5 折叠计数)。保存失败的文件保留打开并 toast。
1683
+ function CloseConfirm() {
1684
+ const st = useStore()
1685
+ const cc = st.closeConfirm
1686
+ if (!cc) return null
1687
+ const cancel = () => { st.closeConfirm = null; emit() }
1688
+ const discard = () => { const c = st.closeConfirm; st.closeConfirm = null; emit(); closeTabsNow(c.paths, c.keepActive) }
1689
+ const n = cc.dirty.length
1690
+ return h('div', { className: 'cdex-modal', onClick: cancel },
1691
+ h('div', { className: 'cdex-modal-box', onClick: (e) => e.stopPropagation() },
1692
+ h('div', { className: 'cdex-modal-title' }, n === 1 ? '未保存的更改' : '未保存的更改(' + n + ' 个文件)'),
1693
+ n === 1
1694
+ ? h('div', { className: 'cdex-confirm-msg' }, '是否保存对 ', h('b', null, baseName(cc.dirty[0])), ' 的更改?')
1695
+ : h('div', { className: 'cdex-confirm-msg' },
1696
+ '以下文件有未保存的更改,关闭前要保存吗?',
1697
+ h('div', { className: 'cdex-confirm-list' },
1698
+ cc.dirty.slice(0, 5).map((p, i) => h('div', { key: i, className: 'cdex-confirm-file' }, '● ', baseName(p))),
1699
+ n > 5 ? h('div', { className: 'cdex-confirm-more' }, '…等共 ' + n + ' 个文件') : null,
1700
+ ),
1701
+ ),
1702
+ h('div', { className: 'cdex-modal-actions' },
1703
+ h('button', { className: 'cdex-btn-ghost', onClick: cancel }, '取消'),
1704
+ h('button', { className: 'cdex-btn', onClick: discard }, '不保存'),
1705
+ h('button', { className: 'cdex-btn cdex-btn-primary', onClick: () => { confirmSaveAndClose() } }, '保存并关闭'),
1706
+ ),
1707
+ ),
1708
+ )
1709
+ }
1471
1710
  function NoteEditor() {
1472
1711
  const st = useStore()
1473
1712
  const ne = st.noteEditor
@@ -1537,6 +1776,7 @@ window.__ModuleLoader__.load({
1537
1776
  st.ctxMenu ? h(ContextMenu) : null,
1538
1777
  st.noteEditor ? h(NoteEditor) : null,
1539
1778
  st.nameDialog ? h(NameDialog) : null,
1779
+ st.closeConfirm ? h(CloseConfirm) : null,
1540
1780
  st.confirmBox ? h(ConfirmBox) : null,
1541
1781
  st.toast ? h(Toast) : null,
1542
1782
  )
@@ -1651,6 +1891,7 @@ window.__ModuleLoader__.load({
1651
1891
  st.ctxMenu ? h(ContextMenu) : null,
1652
1892
  st.noteEditor ? h(NoteEditor) : null,
1653
1893
  st.nameDialog ? h(NameDialog) : null,
1894
+ st.closeConfirm ? h(CloseConfirm) : null,
1654
1895
  st.confirmBox ? h(ConfirmBox) : null,
1655
1896
  st.toast ? h(Toast) : null,
1656
1897
  )
@@ -1669,6 +1910,12 @@ window.__ModuleLoader__.load({
1669
1910
  { name: 'conversation.session.header.actions', id: 'cdex-input-bridge', order: 9999, label: '' },
1670
1911
  (props) => h(InputBridge, props),
1671
1912
  )))
1913
+ // 常驻会话监视器:root 作用域 shell.overlay,不随会话头 chrome 隐藏而消失
1914
+ //(空新对话 blank 会话时 InputBridge 所在 slot 不渲染,会话 id 由这里兜底捕获)
1915
+ disposers.push(slots.inject('shell.overlay', () => slots.register(
1916
+ { name: 'shell.overlay', id: 'cdex-session-watch', order: 4, label: '' },
1917
+ (props) => h(SessionWatch, props),
1918
+ )))
1672
1919
  // 切换按钮:按用户选择注册(与布局模式解耦,任意组合)
1673
1920
  if (toggleStyle === 'bar') {
1674
1921
  // 悬浮模式条:可拖动横条,两键切换 code UI / dsh UI
@@ -1739,6 +1986,8 @@ window.__ModuleLoader__.load({
1739
1986
  // RPC 失败回退到「可见+会话头按钮」保证可发现性)
1740
1987
  callHost('get-self').then((r) => {
1741
1988
  if (r && !r.error) {
1989
+ selfVersion = String(r.version || '')
1990
+ synThemePref = (typeof r.synTheme === 'string' && SYN_THEMES.some((x) => x[0] === r.synTheme)) ? r.synTheme : 'auto'
1742
1991
  setThemeMode(r.theme)
1743
1992
  layoutMode = (r.layout === 'float') ? 'float' : 'dock'
1744
1993
  toggleStyle = (r.toggle === 'header') ? 'header' : 'bar'
@@ -1764,6 +2013,7 @@ window.__ModuleLoader__.load({
1764
2013
  const [theming, setTheming] = React.useState(false)
1765
2014
  const [uishing, setUishing] = React.useState(false)
1766
2015
  const [toggling, setToggling] = React.useState(false)
2016
+ const [synTheming, setSynTheming] = React.useState(false)
1767
2017
  const lastPayloadRef = React.useRef(null)
1768
2018
  const pollDisposeRef = React.useRef(null)
1769
2019
 
@@ -1783,6 +2033,22 @@ window.__ModuleLoader__.load({
1783
2033
  })
1784
2034
  }
1785
2035
 
2036
+ // 语法配色:写 host settings 持久化;成功后立即换主题类
2037
+ function setSynTheme(m) {
2038
+ if (!self || synTheming) return
2039
+ setSynTheming(true)
2040
+ callHost('set-syn-theme', { synTheme: m }).then((r) => {
2041
+ setSynTheming(false)
2042
+ if (r && !r.error) {
2043
+ setSelf(Object.assign({}, self, { synTheme: m }))
2044
+ synThemePref = m
2045
+ showToast('语法配色已切换:' + (m === 'auto' ? '自动(随明暗)' : ((SYN_THEMES.find((x) => x[0] === m) || [null, m])[1])))
2046
+ } else {
2047
+ showToast('切换失败:' + ((r && r.error) || '未知错误'))
2048
+ }
2049
+ })
2050
+ }
2051
+
1786
2052
  // 编辑器界面(三态,合并原「布局模式 × 界面模式」):
1787
2053
  // dock=右侧停靠 / float=悬浮窗口 / hidden=隐藏(dsh UI,内部记住当前布局,切回原样恢复)
1788
2054
  function setUiShape(v) {
@@ -1961,6 +2227,22 @@ window.__ModuleLoader__.load({
1961
2227
  ),
1962
2228
  h('p', { className: 'cdx-pc-note' }, '「跟随 DSH」随 DSH 整体外观(默认);「跟随系统」随操作系统浅色/深色实时切换;浅色/深色为强制指定。切换立即生效,仅作用于编辑器界面,选择持久化保存。'),
1963
2229
  ),
2230
+ // 字段:语法配色(highlight.js 主题)
2231
+ h('div', { className: 'cdx-pc-field' },
2232
+ h('div', { className: 'cdx-pc-head' },
2233
+ h('span', { className: 'cdx-pc-label' }, '语法配色'),
2234
+ h('span', { className: 'cdx-pc-head' },
2235
+ [{ id: 'auto', name: '自动' }].concat(SYN_THEMES.map((x) => ({ id: x[0], name: x[1] }))).map((t) => h('button', {
2236
+ key: t.id,
2237
+ type: 'button',
2238
+ className: 'cdx-pc-btn ' + (((self && self.synTheme) || 'auto') === t.id ? 'cdx-pc-btn-primary' : 'cdx-pc-btn-outline'),
2239
+ disabled: !self || synTheming,
2240
+ onClick: () => setSynTheme(t.id),
2241
+ }, t.name)),
2242
+ ),
2243
+ ),
2244
+ h('p', { className: 'cdx-pc-note' }, '代码语法高亮的配色(highlight.js,193 种语言)。「自动」(默认)随编辑器明暗在 GitHub / GitHub Dark 间切换;其余为固定主题,可与明暗任意搭配。编辑器底色与界面风格仍由「编辑器主题」管理。切换即时生效,持久化保存。'),
2245
+ ),
1964
2246
  // 字段:编辑器界面(三态,合并原「布局模式 × 界面模式」)
1965
2247
  h('div', { className: 'cdx-pc-field' },
1966
2248
  h('div', { className: 'cdx-pc-head' },