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/README.md +12 -4
- package/lib/client.js +372 -40
- package/lib/index.js +170 -40
- package/lib/vendor/highlight.full.min.js +5306 -0
- package/lib/vendor/hljs-themes.css +711 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
660
|
-
|
|
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
|
|
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();
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
1402
|
-
h('
|
|
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' },
|