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/README.md +15 -7
- package/lib/client.js +329 -29
- package/lib/index.js +184 -42
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
emit()
|
|
657
|
-
await
|
|
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();
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
1385
|
-
h('
|
|
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 = '
|
|
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
|
-
//
|
|
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 === '
|
|
1727
|
-
applyUiMode(r.uiMode === '
|
|
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) || '
|
|
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
|
|
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' },
|