dsh-code-ui 1.3.1 → 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/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,6 +279,9 @@ window.__ModuleLoader__.load({
265
279
  toast: null,
266
280
  toastKeep: false,
267
281
  toastPos: null,
282
+ sessionId: null, // 当前会话 id(InputBridge 从 slot kit 捕获;blank 会话头隐藏时由 SessionWatch 兜底):文件树根按会话解析/记忆
283
+ rootSessionId: null, // 当前根归属哪个会话(落后于 sessionId 时触发重新解析;手动根由宿主按会话记忆)
284
+ scrollPos: {}, // 文件路径 -> {top,left}:每个标签记住自己的滚动位置(切换后恢复)
268
285
  error: null,
269
286
  }
270
287
  const listeners = new Set()
@@ -595,6 +612,112 @@ window.__ModuleLoader__.load({
595
612
  return out.join('')
596
613
  }
597
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
+
598
721
  /* ============ §4 RPC 操作层(文件树 / 标签页 / 引用备注) ============ */
599
722
  // —— 文件浏览器 ——
600
723
  async function browseDir(path) {
@@ -613,7 +736,7 @@ window.__ModuleLoader__.load({
613
736
  emit()
614
737
  const start = store.browsePath || store.rootPath || null
615
738
  if (start) { browseDir(start); return }
616
- callHost('get-root').then((r) => {
739
+ callHost('get-root', store.sessionId ? { sessionId: store.sessionId } : {}).then((r) => {
617
740
  if (r && r.root) { browseDir(r.root) }
618
741
  else { store.error = '未定位到工作区根,请在输入框粘贴目录或文件路径后回车'; emit() }
619
742
  })
@@ -625,9 +748,15 @@ window.__ModuleLoader__.load({
625
748
  }
626
749
  async function setWorkspaceRoot(path) {
627
750
  if (!path) return
628
- const res = await callHost('set-root', { path })
751
+ // sessionId 一并上送:手动根按会话记忆(宿主 sessionRoots),其它会话不受影响
752
+ const res = await callHost('set-root', { path, sessionId: store.sessionId })
629
753
  if (res && res.error) { showToast('设置失败:' + res.error); return }
630
- const newRoot = (res && res.root) ? res.root : path
754
+ store.rootSessionId = store.sessionId // 手动根归属当前会话
755
+ await applyRoot((res && res.root) ? res.root : path)
756
+ showToast('工作区根目录:' + store.rootPath)
757
+ }
758
+ // 换根并整体重置文件树/标签页/缓冲(自动跟随与手动设置共用)
759
+ async function applyRoot(newRoot) {
631
760
  store.rootPath = newRoot
632
761
  store.nodes = {}
633
762
  store.tabs = []
@@ -642,19 +771,19 @@ window.__ModuleLoader__.load({
642
771
  store.filePickerOpen = false
643
772
  emit()
644
773
  await toggleExpand(newRoot)
645
- showToast('工作区根目录:' + newRoot)
646
774
  }
647
775
  // —— 文件树 ——
648
776
  async function refresh() {
649
777
  store.error = null
650
- if (!store.rootPath) {
651
- const r = await callHost('get-root')
652
- store.rootPath = (r && r.root) || null
653
- if (!store.rootPath) { store.error = '未找到工作区根目录'; emit(); return }
654
- store.nodes = {}
655
- store.nodes[store.rootPath] = { name: baseName(store.rootPath), path: store.rootPath, isDir: true, children: [], expanded: true, loaded: false }
656
- emit()
657
- await toggleExpand(store.rootPath)
778
+ const sid = store.sessionId
779
+ // 首次无根、或会话已切换(rootSessionId 落后)都重新解析当前会话的根:
780
+ // 自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根
781
+ if (!store.rootPath || (sid && sid !== store.rootSessionId)) {
782
+ const r = await callHost('get-root', sid ? { sessionId: sid } : {})
783
+ const root = (r && r.root) || null
784
+ if (!root) { store.error = '未找到工作区根目录'; emit(); return }
785
+ await applyRoot(root)
786
+ store.rootSessionId = sid
658
787
  } else {
659
788
  // 修复:刷新不再折叠整棵树,只重载根目录(子目录展开状态保留在 store.nodes)
660
789
  await reloadDir(store.rootPath)
@@ -715,6 +844,37 @@ window.__ModuleLoader__.load({
715
844
  // 标签关闭:释放 host 端 watcher
716
845
  callHost('unwatch-file', { path }).then(() => {})
717
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
+ }
718
878
  // 外部修改同步:从磁盘重读单文件(脏缓冲不被自动覆盖,只有用户点 ⚡ 或确认后才走这里)
719
879
  async function reloadFromFile(path) {
720
880
  const res = await callHost('read-file', { path })
@@ -786,6 +946,28 @@ window.__ModuleLoader__.load({
786
946
  emit()
787
947
  }
788
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
+ }
789
971
  async function doCite(sel) {
790
972
  const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
791
973
  if (ref && ref.reference) { store.references.push(ref.reference); emit() }
@@ -1035,6 +1217,14 @@ window.__ModuleLoader__.load({
1035
1217
  store.inputDraft = inputState.draft
1036
1218
  store.inputPhase = inputState.phase
1037
1219
  }
1220
+ // 捕获当前会话 id:文件树根按会话隔离——切换即解析新会话的根
1221
+ //(自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根)
1222
+ if (props.sessionId && props.sessionId !== store.sessionId) {
1223
+ store.sessionId = props.sessionId
1224
+ const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1225
+ if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1226
+ else refresh()
1227
+ }
1038
1228
  emit()
1039
1229
  })
1040
1230
  // 挂钩 DSH 原生输入条的发送:存在引用时把指针附进草稿再发(整文件=绝对路径)。
@@ -1060,6 +1250,23 @@ window.__ModuleLoader__.load({
1060
1250
  }, [inputActions])
1061
1251
  return null
1062
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
+ }
1063
1270
  function AiInput() {
1064
1271
  const st = useStore()
1065
1272
  let taEl = null
@@ -1270,16 +1477,17 @@ window.__ModuleLoader__.load({
1270
1477
  )
1271
1478
  }
1272
1479
  let gutterEl = null
1480
+ let editorTaEl = null // 编辑器 textarea 当前 DOM(用于识别真挂载,避免内联 ref 每次渲染重复触发)
1273
1481
  function syncGutter(ta) {
1274
1482
  if (gutterEl) gutterEl.scrollTop = ta.scrollTop
1275
1483
  }
1276
1484
  function TabBar() {
1277
1485
  const st = useStore()
1278
1486
  return h('div', { className: 'cdex-tabs' },
1279
- 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) } },
1280
1488
  h('span', null, (st.externalChanged[t.path] ? '⚡' : '') + t.name + (st.dirty[t.path] ? ' ●' : '')),
1281
1489
  st.externalChanged[t.path] ? h('button', { className: 'cdex-tab-close', title: '文件已在磁盘上被外部修改,点击加载新内容(会覆盖本地未保存的修改)', onClick: (e) => { e.stopPropagation(); reloadFromFile(t.path) } }, '⇩') : null,
1282
- 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]) } }, '✕'),
1283
1491
  )),
1284
1492
  h('div', { className: 'cdex-tab-actions' },
1285
1493
  h('button', { className: 'cdex-btn', title: '保存 (Ctrl+S)', onClick: () => saveFile() }, '保存'),
@@ -1303,8 +1511,8 @@ window.__ModuleLoader__.load({
1303
1511
  const lineCount = content.split('\n').length
1304
1512
  const gutter = []
1305
1513
  for (let i = 1; i <= lineCount; i++) gutter.push(String(i))
1306
- // 高亮 HTML(overlay 下层展示)
1307
- const html = highlight(content, langOf(p))
1514
+ // 高亮 HTML(overlay 下层展示):hljs 就绪走 hljs,否则回退自研 tokenizer
1515
+ const html = renderHighlight(p, content)
1308
1516
  // 滚动同步:把 pre 滚动到与 textarea 相同
1309
1517
  const syncOverlayScroll = (taEl) => {
1310
1518
  if (!taEl) return
@@ -1318,7 +1526,7 @@ window.__ModuleLoader__.load({
1318
1526
  h('div', { className: 'cdex-codearea' },
1319
1527
  h('div', { className: 'cdex-gutter', ref: (el) => { gutterEl = el } }, gutter.join('\n')),
1320
1528
  h('div', { className: 'cdex-code-layer' },
1321
- 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 } }),
1322
1530
  h('textarea', {
1323
1531
  // key 拼上加载序号:内容异步读回后 key 变化触发重挂载,defaultValue 才会生效(否则编辑区空白、与高亮层脱节)
1324
1532
  key: p + ':' + (st.loadSeq[p] || 0),
@@ -1326,7 +1534,24 @@ window.__ModuleLoader__.load({
1326
1534
  defaultValue: content,
1327
1535
  spellCheck: false,
1328
1536
  wrap: 'off',
1329
- 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
+ },
1330
1555
  onContextMenu: onEditorContext,
1331
1556
  onChange: (e) => {
1332
1557
  store.buffers[p] = e.target.value
@@ -1381,9 +1606,11 @@ window.__ModuleLoader__.load({
1381
1606
  } catch (e) {}
1382
1607
  return h('div', { className: 'cdex-menu-backdrop', onClick: closeMenu },
1383
1608
  h('div', { className: 'cdex-menu', style: { left: mx, top: my }, onClick: (e) => e.stopPropagation() },
1384
- items.map((it, i) => h('div', { key: i, className: 'cdex-menu-item', onClick: () => { try { it.act() } finally { closeMenu() } } },
1385
- h('span', null, it.icon), h('span', null, it.label),
1386
- )),
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
+ )),
1387
1614
  ),
1388
1615
  )
1389
1616
  }
@@ -1451,6 +1678,35 @@ window.__ModuleLoader__.load({
1451
1678
  )
1452
1679
  }
1453
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
+ }
1454
1710
  function NoteEditor() {
1455
1711
  const st = useStore()
1456
1712
  const ne = st.noteEditor
@@ -1520,6 +1776,7 @@ window.__ModuleLoader__.load({
1520
1776
  st.ctxMenu ? h(ContextMenu) : null,
1521
1777
  st.noteEditor ? h(NoteEditor) : null,
1522
1778
  st.nameDialog ? h(NameDialog) : null,
1779
+ st.closeConfirm ? h(CloseConfirm) : null,
1523
1780
  st.confirmBox ? h(ConfirmBox) : null,
1524
1781
  st.toast ? h(Toast) : null,
1525
1782
  )
@@ -1634,6 +1891,7 @@ window.__ModuleLoader__.load({
1634
1891
  st.ctxMenu ? h(ContextMenu) : null,
1635
1892
  st.noteEditor ? h(NoteEditor) : null,
1636
1893
  st.nameDialog ? h(NameDialog) : null,
1894
+ st.closeConfirm ? h(CloseConfirm) : null,
1637
1895
  st.confirmBox ? h(ConfirmBox) : null,
1638
1896
  st.toast ? h(Toast) : null,
1639
1897
  )
@@ -1644,7 +1902,7 @@ window.__ModuleLoader__.load({
1644
1902
  // get-self 失败 / 字段缺失(旧 host)时绝不隐藏编辑器,只有明确 enabled===false 才不挂载。
1645
1903
  let unmountEditorFns = null
1646
1904
  let layoutMode = 'dock' // dock(右侧停靠,默认) / float(悬浮窗口):编辑器以哪种布局呈现
1647
- let toggleStyle = 'header' // header(会话头 ⌨ 按钮,默认) / bar(悬浮模式条):用哪种按钮切换界面
1905
+ let toggleStyle = 'bar' // bar(悬浮模式条,默认) / header(会话头 ⌨ 按钮):用哪种按钮切换界面
1648
1906
  function mountEditor() {
1649
1907
  if (unmountEditorFns) return
1650
1908
  const disposers = []
@@ -1652,6 +1910,12 @@ window.__ModuleLoader__.load({
1652
1910
  { name: 'conversation.session.header.actions', id: 'cdex-input-bridge', order: 9999, label: '' },
1653
1911
  (props) => h(InputBridge, props),
1654
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
+ )))
1655
1919
  // 切换按钮:按用户选择注册(与布局模式解耦,任意组合)
1656
1920
  if (toggleStyle === 'bar') {
1657
1921
  // 悬浮模式条:可拖动横条,两键切换 code UI / dsh UI
@@ -1718,13 +1982,16 @@ window.__ModuleLoader__.load({
1718
1982
  if (v) mountEditor()
1719
1983
  else unmountEditor()
1720
1984
  }
1721
- // 启动时先查开关/主题/布局/界面模式/切换按钮再挂载(RPC 失败 = 全默认:启用+跟随DSH+停靠+显示+会话头按钮)
1985
+ // 启动时先查开关/主题/布局/界面模式/切换按钮再挂载(新装默认:启用+跟随DSH+停靠+隐藏+悬浮模式条;
1986
+ // RPC 失败回退到「可见+会话头按钮」保证可发现性)
1722
1987
  callHost('get-self').then((r) => {
1723
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'
1724
1991
  setThemeMode(r.theme)
1725
1992
  layoutMode = (r.layout === 'float') ? 'float' : 'dock'
1726
- toggleStyle = (r.toggle === 'bar') ? 'bar' : 'header'
1727
- applyUiMode(r.uiMode === 'native' ? 'native' : 'codeui')
1993
+ toggleStyle = (r.toggle === 'header') ? 'header' : 'bar'
1994
+ applyUiMode(r.uiMode === 'codeui' ? 'codeui' : 'native')
1728
1995
  applyEnabled(!(r.enabled === false))
1729
1996
  } else {
1730
1997
  setThemeMode('dsh')
@@ -1746,6 +2013,7 @@ window.__ModuleLoader__.load({
1746
2013
  const [theming, setTheming] = React.useState(false)
1747
2014
  const [uishing, setUishing] = React.useState(false)
1748
2015
  const [toggling, setToggling] = React.useState(false)
2016
+ const [synTheming, setSynTheming] = React.useState(false)
1749
2017
  const lastPayloadRef = React.useRef(null)
1750
2018
  const pollDisposeRef = React.useRef(null)
1751
2019
 
@@ -1765,6 +2033,22 @@ window.__ModuleLoader__.load({
1765
2033
  })
1766
2034
  }
1767
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
+
1768
2052
  // 编辑器界面(三态,合并原「布局模式 × 界面模式」):
1769
2053
  // dock=右侧停靠 / float=悬浮窗口 / hidden=隐藏(dsh UI,内部记住当前布局,切回原样恢复)
1770
2054
  function setUiShape(v) {
@@ -1943,6 +2227,22 @@ window.__ModuleLoader__.load({
1943
2227
  ),
1944
2228
  h('p', { className: 'cdx-pc-note' }, '「跟随 DSH」随 DSH 整体外观(默认);「跟随系统」随操作系统浅色/深色实时切换;浅色/深色为强制指定。切换立即生效,仅作用于编辑器界面,选择持久化保存。'),
1945
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
+ ),
1946
2246
  // 字段:编辑器界面(三态,合并原「布局模式 × 界面模式」)
1947
2247
  h('div', { className: 'cdx-pc-field' },
1948
2248
  h('div', { className: 'cdx-pc-head' },
@@ -1971,13 +2271,13 @@ window.__ModuleLoader__.load({
1971
2271
  ['header', 'bar'].map((m) => h('button', {
1972
2272
  key: m,
1973
2273
  type: 'button',
1974
- className: 'cdx-pc-btn ' + (((self && self.toggle) || 'header') === m ? 'cdx-pc-btn-primary' : 'cdx-pc-btn-outline'),
2274
+ className: 'cdx-pc-btn ' + (((self && self.toggle) || 'bar') === m ? 'cdx-pc-btn-primary' : 'cdx-pc-btn-outline'),
1975
2275
  disabled: !self || toggling,
1976
2276
  onClick: () => setToggle(m),
1977
2277
  }, m === 'header' ? '会话头按钮' : '悬浮模式条')),
1978
2278
  ),
1979
2279
  ),
1980
- h('p', { className: 'cdx-pc-note' }, '选择用哪种按钮在 code UI 与dsh UI 之间切换:「会话头按钮」在会话标题栏显示 ⌨ code UI(默认);「悬浮模式条」为屏幕上方可拖动的横条(code UI / dsh UI 两键)。与布局模式无关,可任意组合,选择持久化保存。'),
2280
+ h('p', { className: 'cdx-pc-note' }, '选择用哪种按钮在 code UI 与 dsh UI 之间切换:「悬浮模式条」为屏幕上方可拖动的横条(code UI / dsh UI 两键,默认);「会话头按钮」在会话标题栏显示 ⌨ code UI。与布局模式无关,可任意组合,选择持久化保存。'),
1981
2281
  ),
1982
2282
  // 字段一:在线检查更新
1983
2283
  h('div', { className: 'cdx-pc-field' },