dsh-code-ui 1.3.2 → 1.4.1

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,9 +279,10 @@ 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
+ sessionEditor: {}, // 会话 id -> { tabs, activePath, nodes, rootPath }:切换会话时快照/恢复编辑器标签与文件树展开状态(滚动位按文件路径记忆于 scrollPos)
285
+ scrollPos: {}, // 文件路径 -> {top,left}:每个标签记住自己的滚动位置(切换后恢复)
271
286
  error: null,
272
287
  }
273
288
  const listeners = new Set()
@@ -598,6 +613,129 @@ window.__ModuleLoader__.load({
598
613
  return out.join('')
599
614
  }
600
615
 
616
+ /* ============ §3.5 highlight.js 接线(vendor 资产经宿主路由懒加载;未就绪/失败回退上方自研 tokenizer) ============ */
617
+ // 主题表:与 tools/vendor-hljs.mjs 的 THEMES、宿主端 SYN_THEME_IDS 一一对应(展示名单点维护)
618
+ const SYN_THEMES = [
619
+ ['github', 'GitHub'], ['github-dark', 'GitHub Dark'], ['atom-one-dark', 'Atom One Dark'], ['atom-one-light', 'Atom One Light'],
620
+ ['monokai', 'Monokai'], ['dracula', 'Dracula'], ['nord', 'Nord'], ['vs', 'VS 浅色'], ['vs2015', 'VS2015 深色'], ['tokyo-night-dark', 'Tokyo Night Dark'],
621
+ ]
622
+ const HLJS_VENDOR_BASE = '/dsh-code-ui/vendor/'
623
+ const HLJS_BIG_CHARS = 512 * 1024 // 超大文件不做 hljs(防卡顿),继续用轻量 tokenizer
624
+ const HLJS_SYNC_MAX = 32 * 1024 // 该阈值内同步走 hljs:单帧渲染,杜绝 tokenizer↔hljs 交换帧(交换帧会重写 innerHTML 丢滚动位 → 每敲一个字符文本抖动)
625
+ // langOf 的输出大多是 hljs 语言 id/别名;少数需归一,其余交给 hljs.getLanguage 解析,失败回退 plaintext
626
+ const HLJS_LANG_FIX = { text: 'plaintext', jsx: 'javascript', tsx: 'typescript', htm: 'xml' }
627
+ function hljsLangOf(lang) {
628
+ const hljs = window.hljs
629
+ if (!hljs || typeof hljs.getLanguage !== 'function') return null
630
+ const l = HLJS_LANG_FIX[lang] || lang
631
+ try { if (hljs.getLanguage(l)) return l } catch (e) {}
632
+ return 'plaintext'
633
+ }
634
+ // 懒加载单例:经典 <script>/<link> 注入(切勿在 ModuleLoader factory 内 eval——UMD 会检测到
635
+ // 假 module/exports 走 CJS 分支,挂到 module.exports 而不是 window.hljs)。
636
+ // Electron(file://)/断网/路由缺失 → 失败态,永久回退自研 tokenizer(保底可用)。
637
+ let hljsState = 'idle' // idle | loading | ready | failed
638
+ let hljsPromise = null
639
+ let selfVersion = '' // get-self 回填,用作 vendor 资产的 cache-bust query
640
+ function loadHljs() {
641
+ if (hljsState === 'ready') return Promise.resolve(true)
642
+ if (hljsState === 'failed') return Promise.resolve(false)
643
+ if (hljsPromise) return hljsPromise
644
+ hljsState = 'loading'
645
+ hljsPromise = new Promise((resolve) => {
646
+ const settle = (ok) => {
647
+ if (hljsState === 'ready') { resolve(true); return }
648
+ hljsState = ok ? 'ready' : 'failed'
649
+ if (!ok) hljsPromise = null
650
+ resolve(ok)
651
+ emit() // 就绪后重渲染:已打开文件从 tokenizer 输出切到 hljs 输出
652
+ }
653
+ try {
654
+ const v = encodeURIComponent(selfVersion)
655
+ const q = v ? ('?v=' + v) : ''
656
+ const link = document.createElement('link')
657
+ link.rel = 'stylesheet'
658
+ link.href = HLJS_VENDOR_BASE + 'hljs-themes.css' + q
659
+ const script = document.createElement('script')
660
+ script.src = HLJS_VENDOR_BASE + 'highlight.full.min.js' + q
661
+ script.onload = () => { settle(!!(window.hljs && typeof window.hljs.highlight === 'function')) }
662
+ script.onerror = () => { settle(false) }
663
+ // 超时兜底:15s 仍未就绪按失败处理(script 若迟到加载成功,onload 仍会升级为 ready)
664
+ setTimeout(() => { if (hljsState === 'loading') settle(false) }, 15000)
665
+ document.head.appendChild(link)
666
+ document.head.appendChild(script)
667
+ } catch (e) { settle(false) }
668
+ })
669
+ return hljsPromise
670
+ }
671
+ // 语法配色偏好:auto(默认,随编辑器明暗 GitHub/GitHub Dark) 或 SYN_THEMES 之一
672
+ let synThemePref = 'auto'
673
+ function editorPrefersDark() {
674
+ if (themeMode === 'dark') return true
675
+ if (themeMode === 'light') return false
676
+ if (themeMode === 'system') return systemPrefersDark()
677
+ return dshPrefersDark()
678
+ }
679
+ function synThemeId() {
680
+ if (synThemePref !== 'auto') return synThemePref
681
+ return editorPrefersDark() ? 'github-dark' : 'github'
682
+ }
683
+ // hljs 结果缓存 + 防抖重高亮:内容未变直接复用;变了先用自研 tokenizer 顶上(同步、有色、
684
+ // 新敲的字符永远立即可见),停顿 ~60ms 后异步换成 hljs 输出并重渲染。
685
+ const hljsCache = new Map() // path -> { content, html }(超 24 个逐出最旧,防内存无界)
686
+ const hljsPending = new Map() // path -> 取消句柄
687
+ function hljsDelay(fn, ms) {
688
+ if (timer && typeof timer.timeout === 'function') return timer.timeout(fn, ms)
689
+ const t = setTimeout(fn, ms)
690
+ return () => clearTimeout(t)
691
+ }
692
+ function scheduleHljs(p, content, lang) {
693
+ const prev = hljsPending.get(p)
694
+ if (prev) { try { prev() } catch (e) {} }
695
+ const cancel = hljsDelay(() => {
696
+ hljsPending.delete(p)
697
+ let html = ''
698
+ try {
699
+ html = window.hljs.highlight(content, { language: hljsLangOf(lang) || 'plaintext', ignoreIllegals: true }).value
700
+ } catch (e) {
701
+ html = highlight(content, lang) // 单文件异常(不应发生):退回 tokenizer 输出
702
+ }
703
+ // 与 tokenizer 输出保持行数一致:末尾换行补齐(textarea 会渲染最后的空行)
704
+ if (content.endsWith('\n') && !html.endsWith('\n')) html += '\n'
705
+ if (hljsCache.size > 24) { const first = hljsCache.keys().next().value; if (first !== undefined) hljsCache.delete(first) }
706
+ hljsCache.set(p, { content, html })
707
+ if (store.tabs.some((t) => t.path === p)) emit()
708
+ }, 60)
709
+ hljsPending.set(p, cancel)
710
+ }
711
+ // 编辑区高亮统一入口:hljs 就绪且文件不大 → hljs(缓存命中直用,否则防抖调度);
712
+ // 否则自研 tokenizer 同步渲染(加载中/失败/超大文件的回退面)
713
+ function renderHighlight(p, content) {
714
+ const lang = langOf(p)
715
+ if (hljsState === 'idle') loadHljs() // 首次真正需要高亮时才触发懒加载
716
+ if (hljsState !== 'ready' || content.length > HLJS_BIG_CHARS) return highlight(content, lang)
717
+ const cached = hljsCache.get(p)
718
+ if (cached && cached.content === content) return cached.html
719
+ // 小文件同步走 hljs:一次渲染直接出最终结果。原「tokenizer 顶上 + 60ms 后换 hljs」
720
+ // 的交换帧会让高亮层每击键重写两次 innerHTML(浏览器重写时 scrollTop/scrollLeft 被
721
+ // 重置为 0),表现为滚动过的文件每敲一个字符文本抖动一下。
722
+ if (content.length <= HLJS_SYNC_MAX) {
723
+ let html
724
+ try {
725
+ html = window.hljs.highlight(content, { language: hljsLangOf(lang) || 'plaintext', ignoreIllegals: true }).value
726
+ } catch (e) {
727
+ html = highlight(content, lang) // 单文件异常(不应发生):退回 tokenizer 输出
728
+ }
729
+ // 与 tokenizer 输出保持行数一致:末尾换行补齐(textarea 会渲染最后的空行)
730
+ if (content.endsWith('\n') && !html.endsWith('\n')) html += '\n'
731
+ if (hljsCache.size > 24) { const first = hljsCache.keys().next().value; if (first !== undefined) hljsCache.delete(first) }
732
+ hljsCache.set(p, { content, html })
733
+ return html
734
+ }
735
+ scheduleHljs(p, content, lang)
736
+ return highlight(content, lang) // 大文件:tokenizer 顶上(同步、即时),60ms 防抖后换 hljs
737
+ }
738
+
601
739
  /* ============ §4 RPC 操作层(文件树 / 标签页 / 引用备注) ============ */
602
740
  // —— 文件浏览器 ——
603
741
  async function browseDir(path) {
@@ -628,36 +766,66 @@ window.__ModuleLoader__.load({
628
766
  }
629
767
  async function setWorkspaceRoot(path) {
630
768
  if (!path) return
631
- const res = await callHost('set-root', { path })
769
+ // sessionId 一并上送:手动根按会话记忆(宿主 sessionRoots),其它会话不受影响
770
+ const res = await callHost('set-root', { path, sessionId: store.sessionId })
632
771
  if (res && res.error) { showToast('设置失败:' + res.error); return }
633
- store.rootIsAuto = false // 用户显式选择:此后不再跟随会话切换
634
- store.rootSessionId = null
772
+ store.rootSessionId = store.sessionId // 手动根归属当前会话
635
773
  await applyRoot((res && res.root) ? res.root : path)
636
774
  showToast('工作区根目录:' + store.rootPath)
637
775
  }
638
- // 换根并整体重置文件树/标签页/缓冲(自动跟随与手动设置共用)
776
+ // 换根:标签页/缓冲/脏标记按会话记忆(handleSessionSwitch 快照恢复,均以绝对路径为键、
777
+ // 跨根安全)。树状态(目录展开/已加载子目录)同样按会话恢复——nodes 里已有该根节点时
778
+ // 跳过重置(同根换会话、或恢复了目标会话的树快照),只在新根没有树状态时重建。
779
+ // 注意不能用 toggleExpand 展开:它是切换语义,会把已展开的根折叠回去。
639
780
  async function applyRoot(newRoot) {
640
781
  store.rootPath = newRoot
641
- store.nodes = {}
642
- store.tabs = []
643
- store.activePath = null
644
- store.buffers = {}
645
- store.savedContent = {}
646
- store.dirty = {}
782
+ if (!store.nodes[newRoot]) {
783
+ store.nodes = {}
784
+ store.browsePath = null
785
+ store.browseEntries = []
786
+ store.nodes[newRoot] = { name: baseName(newRoot), path: newRoot, isDir: true, children: [], expanded: true, loaded: false }
787
+ store.filePickerOpen = false
788
+ emit()
789
+ await toggleExpand(newRoot)
790
+ return
791
+ }
792
+ emit()
793
+ }
794
+ // —— 会话切换:编辑器状态按会话记忆 ——
795
+ // 切换时快照当前会话的标签页/激活项/文件树(展开+已加载子目录)/根路径,恢复目标会话的;
796
+ // 滚动位按文件路径记忆于 store.scrollPos(标签恢复挂载时自动回滚),焦点不恢复、由用户
797
+ // 自己点击决定。缓冲/脏标记以绝对路径为键全局保留:切走再切回,未保存内容与 ● 状态
798
+ // 原样还在,因此不再需要「有脏文件暂不跟随」守卫——根始终跟随新会话。
799
+ function handleSessionSwitch(nextSid) {
800
+ if (!nextSid || nextSid === store.sessionId) return
801
+ const prevSid = store.sessionId
802
+ const keyOf = (s) => s || '__no-session__'
803
+ if (prevSid && store.rootPath) {
804
+ store.sessionEditor[keyOf(prevSid)] = {
805
+ tabs: store.tabs,
806
+ activePath: store.activePath,
807
+ nodes: store.nodes,
808
+ rootPath: store.rootPath,
809
+ }
810
+ }
811
+ const saved = store.sessionEditor[keyOf(nextSid)]
812
+ store.tabs = saved ? saved.tabs : []
813
+ store.activePath = saved ? saved.activePath : null
814
+ store.nodes = saved && saved.nodes ? saved.nodes : {}
815
+ store.rootPath = saved && saved.rootPath ? saved.rootPath : null
647
816
  store.activeNotes = []
648
- store.browsePath = null
649
- store.browseEntries = []
650
- store.nodes[newRoot] = { name: baseName(newRoot), path: newRoot, isDir: true, children: [], expanded: true, loaded: false }
651
- store.filePickerOpen = false
817
+ store.sessionId = nextSid
652
818
  emit()
653
- await toggleExpand(newRoot)
819
+ if (store.activePath) loadNotes(store.activePath)
820
+ refresh()
654
821
  }
655
822
  // —— 文件树 ——
656
823
  async function refresh() {
657
824
  store.error = null
658
825
  const sid = store.sessionId
659
- // 自动根模式:首次无根、或会话已切换(rootSessionId 落后)都重新解析当前会话的工作区
660
- if (!store.rootPath || (store.rootIsAuto && sid && sid !== store.rootSessionId)) {
826
+ // 首次无根、或会话已切换(rootSessionId 落后)都重新解析当前会话的根:
827
+ // 自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根
828
+ if (!store.rootPath || (sid && sid !== store.rootSessionId)) {
661
829
  const r = await callHost('get-root', sid ? { sessionId: sid } : {})
662
830
  const root = (r && r.root) || null
663
831
  if (!root) { store.error = '未找到工作区根目录'; emit(); return }
@@ -723,6 +891,37 @@ window.__ModuleLoader__.load({
723
891
  // 标签关闭:释放 host 端 watcher
724
892
  callHost('unwatch-file', { path }).then(() => {})
725
893
  }
894
+ // 统一关闭入口(标签 ✕、右键菜单各关闭项都走这里):波及脏文件时弹确认框
895
+ //(方案 A:保存并关闭 / 不保存 / 取消),干净的直接关。关闭已保存项除外——它只关干净的。
896
+ function requestCloseTabs(paths, keepActive) {
897
+ const list = (paths || []).filter((p) => store.tabs.some((t) => t.path === p))
898
+ if (!list.length) return
899
+ const dirtyList = list.filter((p) => store.dirty[p])
900
+ if (!dirtyList.length) { closeTabsNow(list, keepActive); return }
901
+ store.closeConfirm = { paths: list, dirty: dirtyList, keepActive: keepActive || null }
902
+ emit()
903
+ }
904
+ // 立即关闭一批标签(不做脏检查):顺序复用 closeTab 的邻居激活 + watcher 释放;
905
+ // keepActive=「关闭其他/关闭右侧」场景:结束后激活落到触发菜单的标签上
906
+ function closeTabsNow(list, keepActive) {
907
+ for (let i = 0; i < list.length; i++) closeTab(list[i])
908
+ if (keepActive && store.tabs.some((t) => t.path === keepActive)) {
909
+ store.activePath = keepActive
910
+ loadNotes(keepActive)
911
+ }
912
+ emit()
913
+ }
914
+ // 确认框「保存并关闭」:逐个保存;保存失败(dirty 保持 true)的保留打开,其余关闭
915
+ async function confirmSaveAndClose() {
916
+ const cc = store.closeConfirm
917
+ if (!cc) return
918
+ store.closeConfirm = null
919
+ emit()
920
+ for (let i = 0; i < cc.dirty.length; i++) await saveFile(cc.dirty[i])
921
+ closeTabsNow(cc.paths.filter((p) => !store.dirty[p]), cc.keepActive)
922
+ const failed = cc.dirty.filter((p) => store.dirty[p])
923
+ if (failed.length) showToast('有 ' + failed.length + ' 个文件保存失败,已保留打开')
924
+ }
726
925
  // 外部修改同步:从磁盘重读单文件(脏缓冲不被自动覆盖,只有用户点 ⚡ 或确认后才走这里)
727
926
  async function reloadFromFile(path) {
728
927
  const res = await callHost('read-file', { path })
@@ -794,6 +993,28 @@ window.__ModuleLoader__.load({
794
993
  emit()
795
994
  }
796
995
  function closeMenu() { store.ctxMenu = null; emit() }
996
+ // 标签页右键菜单:关闭 / 关闭其他 / 关闭右侧 / 关闭已保存 / 关闭全部 + 复制路径。
997
+ // 智能禁用:单项动作在无对象时置灰;「关闭已保存」只关干净标签、不触发确认框。
998
+ function onTabContext(e, path) {
999
+ const idx = store.tabs.findIndex((t) => t.path === path)
1000
+ if (idx < 0) return
1001
+ const savedPaths = store.tabs.filter((t) => !store.dirty[t.path]).map((t) => t.path)
1002
+ const rightPaths = store.tabs.slice(idx + 1).map((t) => t.path)
1003
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items: [
1004
+ { icon: '', label: '关闭', act: () => requestCloseTabs([path]) },
1005
+ { icon: '', label: '关闭其他', disabled: store.tabs.length <= 1,
1006
+ act: () => requestCloseTabs(store.tabs.filter((t) => t.path !== path).map((t) => t.path), path) },
1007
+ { icon: '', label: '关闭右侧标签页', disabled: rightPaths.length === 0,
1008
+ act: () => requestCloseTabs(rightPaths, path) },
1009
+ { icon: '', label: '关闭已保存', disabled: savedPaths.length === 0,
1010
+ act: () => closeTabsNow(savedPaths) },
1011
+ { icon: '', label: '关闭全部', disabled: store.tabs.length === 0,
1012
+ act: () => requestCloseTabs(store.tabs.map((t) => t.path)) },
1013
+ { sep: true },
1014
+ { icon: '📋', label: '复制路径', act: () => { copyText(path); showToast('路径已复制') } },
1015
+ ] }
1016
+ emit()
1017
+ }
797
1018
  async function doCite(sel) {
798
1019
  const ref = await callHost('add-reference', { path: sel.path, language: sel.language, lineStart: sel.lineStart, lineEnd: sel.lineEnd, text: sel.text, kind: 'cite' })
799
1020
  if (ref && ref.reference) { store.references.push(ref.reference); emit() }
@@ -1043,15 +1264,8 @@ window.__ModuleLoader__.load({
1043
1264
  store.inputDraft = inputState.draft
1044
1265
  store.inputPhase = inputState.phase
1045
1266
  }
1046
- // 捕获当前会话 id:文件树默认根跟随当前会话的工作区目录
1047
- if (props.sessionId && props.sessionId !== store.sessionId) {
1048
- 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
- }
1054
- }
1267
+ // 捕获当前会话 id:编辑器状态按会话记忆(标签快照/恢复 + 根解析),见 handleSessionSwitch
1268
+ if (props.sessionId) handleSessionSwitch(props.sessionId)
1055
1269
  emit()
1056
1270
  })
1057
1271
  // 挂钩 DSH 原生输入条的发送:存在引用时把指针附进草稿再发(整文件=绝对路径)。
@@ -1077,6 +1291,19 @@ window.__ModuleLoader__.load({
1077
1291
  }, [inputActions])
1078
1292
  return null
1079
1293
  }
1294
+ // 常驻会话监视器:空新对话(blank 会话)时宿主隐藏会话头 chrome(hideChrome),
1295
+ // InputBridge 所在的 conversation.session.header.actions slot 不渲染、拿不到会话 id;
1296
+ // 改从 root 作用域标准 props useSessions(ui-session 提供,任意 scope 可用)监听当前
1297
+ // 选中会话——blank 会话也是合法的 current,且宿主侧 agent 在 session/create 时即已
1298
+ // 创建(cwd=所选工作区路径),get-root 上送 blank 会话 id 即可正确解析根目录。
1299
+ function SessionWatch(props) {
1300
+ const useSessions = props.useSessions
1301
+ const sid = typeof useSessions === 'function' ? useSessions((s) => s.current) : undefined
1302
+ React.useEffect(() => {
1303
+ if (sid) handleSessionSwitch(sid)
1304
+ }, [sid])
1305
+ return null
1306
+ }
1080
1307
  function AiInput() {
1081
1308
  const st = useStore()
1082
1309
  let taEl = null
@@ -1287,16 +1514,17 @@ window.__ModuleLoader__.load({
1287
1514
  )
1288
1515
  }
1289
1516
  let gutterEl = null
1517
+ let editorTaEl = null // 编辑器 textarea 当前 DOM(用于识别真挂载,避免内联 ref 每次渲染重复触发)
1290
1518
  function syncGutter(ta) {
1291
1519
  if (gutterEl) gutterEl.scrollTop = ta.scrollTop
1292
1520
  }
1293
1521
  function TabBar() {
1294
1522
  const st = useStore()
1295
1523
  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() } },
1524
+ 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
1525
  h('span', null, (st.externalChanged[t.path] ? '⚡' : '') + t.name + (st.dirty[t.path] ? ' ●' : '')),
1298
1526
  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) } }, '✕'),
1527
+ h('button', { className: 'cdex-tab-close', onClick: (e) => { e.stopPropagation(); requestCloseTabs([t.path]) } }, '✕'),
1300
1528
  )),
1301
1529
  h('div', { className: 'cdex-tab-actions' },
1302
1530
  h('button', { className: 'cdex-btn', title: '保存 (Ctrl+S)', onClick: () => saveFile() }, '保存'),
@@ -1305,6 +1533,19 @@ window.__ModuleLoader__.load({
1305
1533
  }
1306
1534
  function Editor() {
1307
1535
  const st = useStore()
1536
+ // 高亮层 innerHTML 被替换时浏览器会把 scrollTop/scrollLeft 重置为 0;每次渲染提交后
1537
+ // 从 textarea 回同步(也覆盖大文件 60ms 防抖换帧路径),杜绝输入时文本抖动。
1538
+ // 必须放在 early return 之前(Hooks 顺序约束)。
1539
+ React.useEffect(() => {
1540
+ const ta = editorTaEl
1541
+ if (!ta) return
1542
+ const layer = ta.parentNode
1543
+ if (!layer) return
1544
+ const preEl = layer.querySelector('.cdex-code-pre')
1545
+ if (preEl) { preEl.scrollTop = ta.scrollTop; preEl.scrollLeft = ta.scrollLeft }
1546
+ const gutEl = layer.parentNode ? layer.parentNode.querySelector('.cdex-gutter') : null
1547
+ if (gutEl) gutEl.scrollTop = ta.scrollTop
1548
+ })
1308
1549
  if (!st.activePath) {
1309
1550
  // 空态时显式展示当前工作区根,避免「以此文件夹为根」后误认为未切换
1310
1551
  return h('div', { className: 'cdex-editor' },
@@ -1320,8 +1561,8 @@ window.__ModuleLoader__.load({
1320
1561
  const lineCount = content.split('\n').length
1321
1562
  const gutter = []
1322
1563
  for (let i = 1; i <= lineCount; i++) gutter.push(String(i))
1323
- // 高亮 HTML(overlay 下层展示)
1324
- const html = highlight(content, langOf(p))
1564
+ // 高亮 HTML(overlay 下层展示):hljs 就绪走 hljs,否则回退自研 tokenizer
1565
+ const html = renderHighlight(p, content)
1325
1566
  // 滚动同步:把 pre 滚动到与 textarea 相同
1326
1567
  const syncOverlayScroll = (taEl) => {
1327
1568
  if (!taEl) return
@@ -1335,7 +1576,7 @@ window.__ModuleLoader__.load({
1335
1576
  h('div', { className: 'cdex-codearea' },
1336
1577
  h('div', { className: 'cdex-gutter', ref: (el) => { gutterEl = el } }, gutter.join('\n')),
1337
1578
  h('div', { className: 'cdex-code-layer' },
1338
- h('pre', { className: 'cdex-code-pre', 'aria-hidden': true, dangerouslySetInnerHTML: { __html: html } }),
1579
+ h('pre', { className: 'cdex-code-pre cdex-syn-' + synThemeId(), 'aria-hidden': true, dangerouslySetInnerHTML: { __html: html } }),
1339
1580
  h('textarea', {
1340
1581
  // key 拼上加载序号:内容异步读回后 key 变化触发重挂载,defaultValue 才会生效(否则编辑区空白、与高亮层脱节)
1341
1582
  key: p + ':' + (st.loadSeq[p] || 0),
@@ -1343,7 +1584,24 @@ window.__ModuleLoader__.load({
1343
1584
  defaultValue: content,
1344
1585
  spellCheck: false,
1345
1586
  wrap: 'off',
1346
- onScroll: (e) => syncOverlayScroll(e.target),
1587
+ ref: (el) => {
1588
+ if (!el) return
1589
+ // 仅在真正挂载时恢复滚动(换标签/内容重挂);不抢焦点,光标由用户自己点击决定
1590
+ if (el !== editorTaEl) {
1591
+ editorTaEl = el
1592
+ const saved = store.scrollPos[p]
1593
+ if (saved) {
1594
+ try { el.scrollTop = saved.top || 0; el.scrollLeft = saved.left || 0 } catch (e) {}
1595
+ }
1596
+ syncOverlayScroll(el)
1597
+ }
1598
+ },
1599
+ onScroll: (e) => {
1600
+ // 记住本文件的滚动位置(切换标签后可恢复);不触发重渲染
1601
+ const el = e.target
1602
+ store.scrollPos[p] = { top: el.scrollTop, left: el.scrollLeft }
1603
+ syncOverlayScroll(el)
1604
+ },
1347
1605
  onContextMenu: onEditorContext,
1348
1606
  onChange: (e) => {
1349
1607
  store.buffers[p] = e.target.value
@@ -1398,9 +1656,11 @@ window.__ModuleLoader__.load({
1398
1656
  } catch (e) {}
1399
1657
  return h('div', { className: 'cdex-menu-backdrop', onClick: closeMenu },
1400
1658
  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
- )),
1659
+ items.map((it, i) => it.sep
1660
+ ? h('div', { key: i, className: 'cdex-menu-sep' })
1661
+ : h('div', { key: i, className: 'cdex-menu-item' + (it.disabled ? ' disabled' : ''), onClick: () => { if (it.disabled) return; try { it.act() } finally { closeMenu() } } },
1662
+ h('span', null, it.icon), h('span', null, it.label),
1663
+ )),
1404
1664
  ),
1405
1665
  )
1406
1666
  }
@@ -1468,6 +1728,35 @@ window.__ModuleLoader__.load({
1468
1728
  )
1469
1729
  }
1470
1730
  let noteTa = null
1731
+ // 关闭未保存文件确认(方案 A):保存并关闭 / 不保存 / 取消(点遮罩=取消)。
1732
+ // 单文件精简文案;多文件列清单(>5 折叠计数)。保存失败的文件保留打开并 toast。
1733
+ function CloseConfirm() {
1734
+ const st = useStore()
1735
+ const cc = st.closeConfirm
1736
+ if (!cc) return null
1737
+ const cancel = () => { st.closeConfirm = null; emit() }
1738
+ const discard = () => { const c = st.closeConfirm; st.closeConfirm = null; emit(); closeTabsNow(c.paths, c.keepActive) }
1739
+ const n = cc.dirty.length
1740
+ return h('div', { className: 'cdex-modal', onClick: cancel },
1741
+ h('div', { className: 'cdex-modal-box', onClick: (e) => e.stopPropagation() },
1742
+ h('div', { className: 'cdex-modal-title' }, n === 1 ? '未保存的更改' : '未保存的更改(' + n + ' 个文件)'),
1743
+ n === 1
1744
+ ? h('div', { className: 'cdex-confirm-msg' }, '是否保存对 ', h('b', null, baseName(cc.dirty[0])), ' 的更改?')
1745
+ : h('div', { className: 'cdex-confirm-msg' },
1746
+ '以下文件有未保存的更改,关闭前要保存吗?',
1747
+ h('div', { className: 'cdex-confirm-list' },
1748
+ cc.dirty.slice(0, 5).map((p, i) => h('div', { key: i, className: 'cdex-confirm-file' }, '● ', baseName(p))),
1749
+ n > 5 ? h('div', { className: 'cdex-confirm-more' }, '…等共 ' + n + ' 个文件') : null,
1750
+ ),
1751
+ ),
1752
+ h('div', { className: 'cdex-modal-actions' },
1753
+ h('button', { className: 'cdex-btn-ghost', onClick: cancel }, '取消'),
1754
+ h('button', { className: 'cdex-btn', onClick: discard }, '不保存'),
1755
+ h('button', { className: 'cdex-btn cdex-btn-primary', onClick: () => { confirmSaveAndClose() } }, '保存并关闭'),
1756
+ ),
1757
+ ),
1758
+ )
1759
+ }
1471
1760
  function NoteEditor() {
1472
1761
  const st = useStore()
1473
1762
  const ne = st.noteEditor
@@ -1537,6 +1826,7 @@ window.__ModuleLoader__.load({
1537
1826
  st.ctxMenu ? h(ContextMenu) : null,
1538
1827
  st.noteEditor ? h(NoteEditor) : null,
1539
1828
  st.nameDialog ? h(NameDialog) : null,
1829
+ st.closeConfirm ? h(CloseConfirm) : null,
1540
1830
  st.confirmBox ? h(ConfirmBox) : null,
1541
1831
  st.toast ? h(Toast) : null,
1542
1832
  )
@@ -1651,6 +1941,7 @@ window.__ModuleLoader__.load({
1651
1941
  st.ctxMenu ? h(ContextMenu) : null,
1652
1942
  st.noteEditor ? h(NoteEditor) : null,
1653
1943
  st.nameDialog ? h(NameDialog) : null,
1944
+ st.closeConfirm ? h(CloseConfirm) : null,
1654
1945
  st.confirmBox ? h(ConfirmBox) : null,
1655
1946
  st.toast ? h(Toast) : null,
1656
1947
  )
@@ -1669,6 +1960,12 @@ window.__ModuleLoader__.load({
1669
1960
  { name: 'conversation.session.header.actions', id: 'cdex-input-bridge', order: 9999, label: '' },
1670
1961
  (props) => h(InputBridge, props),
1671
1962
  )))
1963
+ // 常驻会话监视器:root 作用域 shell.overlay,不随会话头 chrome 隐藏而消失
1964
+ //(空新对话 blank 会话时 InputBridge 所在 slot 不渲染,会话 id 由这里兜底捕获)
1965
+ disposers.push(slots.inject('shell.overlay', () => slots.register(
1966
+ { name: 'shell.overlay', id: 'cdex-session-watch', order: 4, label: '' },
1967
+ (props) => h(SessionWatch, props),
1968
+ )))
1672
1969
  // 切换按钮:按用户选择注册(与布局模式解耦,任意组合)
1673
1970
  if (toggleStyle === 'bar') {
1674
1971
  // 悬浮模式条:可拖动横条,两键切换 code UI / dsh UI
@@ -1739,6 +2036,8 @@ window.__ModuleLoader__.load({
1739
2036
  // RPC 失败回退到「可见+会话头按钮」保证可发现性)
1740
2037
  callHost('get-self').then((r) => {
1741
2038
  if (r && !r.error) {
2039
+ selfVersion = String(r.version || '')
2040
+ synThemePref = (typeof r.synTheme === 'string' && SYN_THEMES.some((x) => x[0] === r.synTheme)) ? r.synTheme : 'auto'
1742
2041
  setThemeMode(r.theme)
1743
2042
  layoutMode = (r.layout === 'float') ? 'float' : 'dock'
1744
2043
  toggleStyle = (r.toggle === 'header') ? 'header' : 'bar'
@@ -1764,6 +2063,7 @@ window.__ModuleLoader__.load({
1764
2063
  const [theming, setTheming] = React.useState(false)
1765
2064
  const [uishing, setUishing] = React.useState(false)
1766
2065
  const [toggling, setToggling] = React.useState(false)
2066
+ const [synTheming, setSynTheming] = React.useState(false)
1767
2067
  const lastPayloadRef = React.useRef(null)
1768
2068
  const pollDisposeRef = React.useRef(null)
1769
2069
 
@@ -1783,6 +2083,22 @@ window.__ModuleLoader__.load({
1783
2083
  })
1784
2084
  }
1785
2085
 
2086
+ // 语法配色:写 host settings 持久化;成功后立即换主题类
2087
+ function setSynTheme(m) {
2088
+ if (!self || synTheming) return
2089
+ setSynTheming(true)
2090
+ callHost('set-syn-theme', { synTheme: m }).then((r) => {
2091
+ setSynTheming(false)
2092
+ if (r && !r.error) {
2093
+ setSelf(Object.assign({}, self, { synTheme: m }))
2094
+ synThemePref = m
2095
+ showToast('语法配色已切换:' + (m === 'auto' ? '自动(随明暗)' : ((SYN_THEMES.find((x) => x[0] === m) || [null, m])[1])))
2096
+ } else {
2097
+ showToast('切换失败:' + ((r && r.error) || '未知错误'))
2098
+ }
2099
+ })
2100
+ }
2101
+
1786
2102
  // 编辑器界面(三态,合并原「布局模式 × 界面模式」):
1787
2103
  // dock=右侧停靠 / float=悬浮窗口 / hidden=隐藏(dsh UI,内部记住当前布局,切回原样恢复)
1788
2104
  function setUiShape(v) {
@@ -1961,6 +2277,22 @@ window.__ModuleLoader__.load({
1961
2277
  ),
1962
2278
  h('p', { className: 'cdx-pc-note' }, '「跟随 DSH」随 DSH 整体外观(默认);「跟随系统」随操作系统浅色/深色实时切换;浅色/深色为强制指定。切换立即生效,仅作用于编辑器界面,选择持久化保存。'),
1963
2279
  ),
2280
+ // 字段:语法配色(highlight.js 主题)
2281
+ h('div', { className: 'cdx-pc-field' },
2282
+ h('div', { className: 'cdx-pc-head' },
2283
+ h('span', { className: 'cdx-pc-label' }, '语法配色'),
2284
+ h('span', { className: 'cdx-pc-head' },
2285
+ [{ id: 'auto', name: '自动' }].concat(SYN_THEMES.map((x) => ({ id: x[0], name: x[1] }))).map((t) => h('button', {
2286
+ key: t.id,
2287
+ type: 'button',
2288
+ className: 'cdx-pc-btn ' + (((self && self.synTheme) || 'auto') === t.id ? 'cdx-pc-btn-primary' : 'cdx-pc-btn-outline'),
2289
+ disabled: !self || synTheming,
2290
+ onClick: () => setSynTheme(t.id),
2291
+ }, t.name)),
2292
+ ),
2293
+ ),
2294
+ h('p', { className: 'cdx-pc-note' }, '代码语法高亮的配色(highlight.js,193 种语言)。「自动」(默认)随编辑器明暗在 GitHub / GitHub Dark 间切换;其余为固定主题,可与明暗任意搭配。编辑器底色与界面风格仍由「编辑器主题」管理。切换即时生效,持久化保存。'),
2295
+ ),
1964
2296
  // 字段:编辑器界面(三态,合并原「布局模式 × 界面模式」)
1965
2297
  h('div', { className: 'cdx-pc-field' },
1966
2298
  h('div', { className: 'cdx-pc-head' },