dsh-vscode-mode 0.3.1 → 0.3.2
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 +11 -3
- package/lib/client.js +1062 -197
- package/lib/client.js.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +192 -20
- package/src/client/editorModelState.ts +18 -0
- package/src/client/openFlow.ts +2 -1
- package/src/client/paths.ts +4 -2
- package/src/client/saveDebounce.ts +69 -0
- package/src/client/sidebar/panels/FileExplorer.ts +83 -2
- package/src/client/state/scopeStore.ts +2 -1
- package/src/client/styles/editor.css +15 -2
- package/src/client/tabActions.ts +326 -0
- package/src/client/tabMenu.ts +107 -0
- package/src/client/ui/ContextMenu.ts +52 -8
- package/src/client/ui/EditorView.ts +221 -57
- package/src/client/ui/commandCatalog.ts +17 -1
- package/src/shared/keybindings.ts +3 -0
|
@@ -42,6 +42,13 @@ import { onLspProgress, refreshStatus } from '../monaco/lsp/index.js'
|
|
|
42
42
|
import { setupAiInline, trackAiEditor, aiInlineEnabled } from '../ai/inlineProvider.js'
|
|
43
43
|
import { SnippetsPicker } from './SnippetsPicker.js'
|
|
44
44
|
import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
|
|
45
|
+
import {
|
|
46
|
+
absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
|
|
47
|
+
insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, togglePin,
|
|
48
|
+
} from '../tabActions.js'
|
|
49
|
+
import { buildTabMenu } from '../tabMenu.js'
|
|
50
|
+
import { createSaveTimer } from '../saveDebounce.js'
|
|
51
|
+
import { ContextMenu } from './ContextMenu.js'
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* 从 Monaco 语言目录取可选语言 id 列表(代码片段新建时的语言下拉来源)。
|
|
@@ -141,7 +148,9 @@ export function EditorView(props) {
|
|
|
141
148
|
if (!modelsRef.current) modelsRef.current = modelsForScope(scope)
|
|
142
149
|
// 视图状态缓存(path → Monaco viewState;重启后恢复光标/滚动/折叠位置)
|
|
143
150
|
const viewStatesRef = React.useRef({})
|
|
144
|
-
|
|
151
|
+
// 防抖保存槽(arm/flush/cancel;见 saveDebounce.ts 的缺陷说明,勿退回单一取消句柄)
|
|
152
|
+
const saveTimerRef = React.useRef(createSaveTimer())
|
|
153
|
+
const savingRef = React.useRef(new Set()) // 在途保存的路径(关闭前落盘去重,防同内容双发)
|
|
145
154
|
const loadSeqRef = React.useRef(0)
|
|
146
155
|
const programmaticRef = React.useRef(false)
|
|
147
156
|
const restoredScopeRef = React.useRef(null) // 已恢复状态的作用域(cwd 晚到 sid→ws 时允许重恢复)
|
|
@@ -158,6 +167,10 @@ export function EditorView(props) {
|
|
|
158
167
|
const rowNavMoveRef = React.useRef(false) // 本次光标变化是否由整行移动触发(否则清空期望列)
|
|
159
168
|
const activeRef = React.useRef(null) // 当前活动文件的最新值(空依赖闭包/指令回调读取)
|
|
160
169
|
activeRef.current = active
|
|
170
|
+
const tabsRef = React.useRef([]) // 当前页签表的最新值(菜单动作按 id 分派时读,防陈旧闭包)
|
|
171
|
+
tabsRef.current = tabs
|
|
172
|
+
const dirtyRef = React.useRef({}) // 脏标记最新值(菜单禁用判定与关闭时落盘读)
|
|
173
|
+
dirtyRef.current = dirtyMap
|
|
161
174
|
const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
|
|
162
175
|
const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
|
|
163
176
|
const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
|
|
@@ -209,10 +222,7 @@ export function EditorView(props) {
|
|
|
209
222
|
const sum = React.useMemo(() => summarize(Object.values(records)), [records])
|
|
210
223
|
|
|
211
224
|
const addTab = (path, select) => {
|
|
212
|
-
setTabs((prev) =>
|
|
213
|
-
if (prev.some((t) => t.path === path)) return prev
|
|
214
|
-
return prev.concat([{ path }])
|
|
215
|
-
})
|
|
225
|
+
setTabs((prev) => insertTab(prev, path))
|
|
216
226
|
if (select) setActive(path)
|
|
217
227
|
}
|
|
218
228
|
|
|
@@ -338,20 +348,57 @@ export function EditorView(props) {
|
|
|
338
348
|
navBackRef.current = navBack
|
|
339
349
|
navForwardRef.current = navForward
|
|
340
350
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
351
|
+
/**
|
|
352
|
+
* 关闭页签的收尾(不动作状态):活动页签先存视图状态与导航历史;
|
|
353
|
+
* PDF 页签销毁面板控制器并清 base64 缓存(未保存注释随之丢弃,脏点已提示)。
|
|
354
|
+
* @author ddj 2026年09月11号
|
|
355
|
+
* @param path 待关闭页签路径
|
|
356
|
+
*/
|
|
357
|
+
const releaseTab = (path) => {
|
|
358
|
+
if (path === activeRef.current) { saveViewState(path); recordNav() }
|
|
345
359
|
const pdfCtl = pdfCtlRef.current.get(path)
|
|
346
360
|
if (pdfCtl) { pdfCtl.destroy(); pdfCtlRef.current.delete(path) }
|
|
347
361
|
pdfB64CacheRef.current.delete(path)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 提交关闭结果:一次性更新页签与活动页签(补位规则见 tabActions.applyClose)。
|
|
366
|
+
* @author ddj 2026年09月11号
|
|
367
|
+
* @param result tabActions 产出的关闭结果
|
|
368
|
+
*/
|
|
369
|
+
const commitClose = (result) => {
|
|
370
|
+
setTabs((prev) => (prev === result.tabs ? prev : result.tabs))
|
|
371
|
+
if (result.active !== activeRef.current) setActive(result.active)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 立即关闭页签(页签 × 按钮与「关闭」菜单项共用;批量关闭走 closeTabs)。
|
|
376
|
+
* @author ddj 2026年08月25号 / 2026年09月11号
|
|
377
|
+
* @param path 待关闭页签路径
|
|
378
|
+
*/
|
|
379
|
+
const closeTab = (path) => {
|
|
380
|
+
// 先静默落盘再关闭:flushSave 立即提交待执行的防抖保存(不是取消),
|
|
381
|
+
// persistDirty 再对仍标脏者(保存失败/在途)用 model 当前文本补一次
|
|
382
|
+
flushSave()
|
|
383
|
+
persistDirty([path])
|
|
384
|
+
releaseTab(path)
|
|
385
|
+
commitClose(applyClose(tabsRef.current, new Set([path]), activeRef.current))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 批量关闭:先落盘全部待关闭的脏页签,再逐个收尾并一次性提交。
|
|
390
|
+
* @author ddj 2026年09月11号
|
|
391
|
+
* @param result 关闭结果(来自 tabActions 的 closeOthers/closeRight/closeSaved/closeAll)
|
|
392
|
+
* @param label 状态栏文案前缀(如「已关闭其他」)
|
|
393
|
+
*/
|
|
394
|
+
const closeTabs = (result, label) => {
|
|
395
|
+
flushSave()
|
|
396
|
+
const closing = tabsRef.current.filter((t) => !result.tabs.some((r) => r.path === t.path))
|
|
397
|
+
if (!closing.length) { setStatus('无可关闭的页签'); return }
|
|
398
|
+
persistDirty(closing.map((t) => t.path))
|
|
399
|
+
for (const tab of closing) releaseTab(tab.path)
|
|
400
|
+
commitClose(result)
|
|
401
|
+
setStatus(label + '(' + closing.length + ' 个)')
|
|
355
402
|
}
|
|
356
403
|
|
|
357
404
|
/**
|
|
@@ -700,7 +747,7 @@ export function EditorView(props) {
|
|
|
700
747
|
}
|
|
701
748
|
}, [sessionId, layout])
|
|
702
749
|
|
|
703
|
-
// localStorage
|
|
750
|
+
// localStorage 恢复页签(v3:页签带 pinned;按工作区作用域;cwd 晚到 sid→ws 切换时重恢复一次)
|
|
704
751
|
React.useEffect(() => {
|
|
705
752
|
if (!sessionId || restoredScopeRef.current === scope) return
|
|
706
753
|
restoredScopeRef.current = scope
|
|
@@ -708,12 +755,15 @@ export function EditorView(props) {
|
|
|
708
755
|
modelsRef.current = modelsForScope(scope)
|
|
709
756
|
viewStatesRef.current = viewStatesLoad(scope)
|
|
710
757
|
try {
|
|
758
|
+
// v3 优先;缺失时回读 v2(旧版纯路径数组)——normalizeTabs 兼容两种形状
|
|
711
759
|
const raw = localStorage.getItem(CACHE_KEY.editor + String(scope))
|
|
760
|
+
?? localStorage.getItem(CACHE_KEY.editorLegacy + String(scope))
|
|
712
761
|
if (raw) {
|
|
713
762
|
const saved = JSON.parse(raw)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
763
|
+
const restored = normalizeTabs(saved?.tabs)
|
|
764
|
+
if (restored.length) {
|
|
765
|
+
setTabs(restored)
|
|
766
|
+
setActive(pickActive(restored, saved?.active))
|
|
717
767
|
}
|
|
718
768
|
}
|
|
719
769
|
} catch (e) { /* 损坏忽略 */ }
|
|
@@ -729,7 +779,11 @@ export function EditorView(props) {
|
|
|
729
779
|
|
|
730
780
|
React.useEffect(() => {
|
|
731
781
|
if (!sessionId) return
|
|
732
|
-
try {
|
|
782
|
+
try {
|
|
783
|
+
// v3 写入:页签带 pinned(只写有值字段,减小体积);固定态随工作区作用域持久化
|
|
784
|
+
const payload = { tabs: tabs.map((t) => (t.pinned ? { path: t.path, pinned: true } : { path: t.path })), active }
|
|
785
|
+
localStorage.setItem(CACHE_KEY.editor + String(scope), JSON.stringify(payload))
|
|
786
|
+
}
|
|
733
787
|
catch (e) { /* 忽略 */ }
|
|
734
788
|
}, [tabs, active, sessionId, scope])
|
|
735
789
|
|
|
@@ -874,6 +928,7 @@ export function EditorView(props) {
|
|
|
874
928
|
['edrv.command.navigateForward', () => navForwardRef.current?.()],
|
|
875
929
|
['edrv.command.nextTab', () => cycleTabRef.current?.(1)],
|
|
876
930
|
['edrv.command.prevTab', () => cycleTabRef.current?.(-1)],
|
|
931
|
+
['edrv.command.closeTab', () => closeActiveTab()],
|
|
877
932
|
['edrv.command.nextEditorRow', () => moveRow(1)],
|
|
878
933
|
['edrv.command.prevEditorRow', () => moveRow(-1)],
|
|
879
934
|
['edrv.command.goToDefinition', () => { const ed = editorRef.current; if (ed) void runGoToDefinition(ed) }],
|
|
@@ -972,13 +1027,7 @@ export function EditorView(props) {
|
|
|
972
1027
|
loadContent(active, sessionId)
|
|
973
1028
|
}, [active, sessionId])
|
|
974
1029
|
|
|
975
|
-
//
|
|
976
|
-
React.useEffect(() => {
|
|
977
|
-
if (!tabMenu) return
|
|
978
|
-
const onKey = (e) => { if (e.key === 'Escape') dismissMenus() }
|
|
979
|
-
window.addEventListener('keydown', onKey, true)
|
|
980
|
-
return () => window.removeEventListener('keydown', onKey, true)
|
|
981
|
-
}, [tabMenu])
|
|
1030
|
+
// 页签右键菜单的 Esc 关闭由 ContextMenu 组件自己处理(capture 监听),此处不再重复挂监听
|
|
982
1031
|
|
|
983
1032
|
React.useEffect(() => {
|
|
984
1033
|
if (monaco || monacoErr) return
|
|
@@ -1043,8 +1092,14 @@ export function EditorView(props) {
|
|
|
1043
1092
|
return model
|
|
1044
1093
|
}
|
|
1045
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* 提交待执行的防抖保存(**真正执行保存**,不是取消)。
|
|
1097
|
+
* 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
|
|
1098
|
+
* 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
|
|
1099
|
+
* @author ddj 2026年09月11号
|
|
1100
|
+
*/
|
|
1046
1101
|
const flushSave = () => {
|
|
1047
|
-
|
|
1102
|
+
saveTimerRef.current?.flush()
|
|
1048
1103
|
}
|
|
1049
1104
|
|
|
1050
1105
|
const doSave = (silent) => {
|
|
@@ -1062,6 +1117,8 @@ export function EditorView(props) {
|
|
|
1062
1117
|
if (isImageActive) { setStatus('图片只读预览'); return }
|
|
1063
1118
|
const text = ed.getValue()
|
|
1064
1119
|
if (!silent) setStatus('保存中…')
|
|
1120
|
+
// 在途登记:关闭路径的 persistDirty 据此跳过重复提交(内容在同一 tick 内取,必然相同)
|
|
1121
|
+
savingRef.current.add(active)
|
|
1065
1122
|
rpc('edrv.save', { sessionId, path: active, content: text }).then((res) => {
|
|
1066
1123
|
if (res && res.ok) {
|
|
1067
1124
|
setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
|
|
@@ -1074,16 +1131,45 @@ export function EditorView(props) {
|
|
|
1074
1131
|
if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
|
|
1075
1132
|
} else { setStatus('保存失败'); setError(res?.error ? String(res.error) : '保存失败') }
|
|
1076
1133
|
}).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
|
|
1134
|
+
.finally(() => { savingRef.current.delete(active) })
|
|
1077
1135
|
}
|
|
1078
1136
|
doSaveRef.current = doSave
|
|
1079
1137
|
|
|
1138
|
+
/**
|
|
1139
|
+
* 关闭前落盘:把「待关闭且仍标脏」的页签静默保存。
|
|
1140
|
+
*
|
|
1141
|
+
* 调用方须先 `flushSave()`:它已把活动页签的待执行保存真正提交(见 saveDebounce 的缺陷说明)。
|
|
1142
|
+
* 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
|
|
1143
|
+
* 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
|
|
1144
|
+
* 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
|
|
1145
|
+
* @author ddj 2026年09月11号
|
|
1146
|
+
* @param paths 即将关闭的页签路径
|
|
1147
|
+
*/
|
|
1148
|
+
const persistDirty = (paths) => {
|
|
1149
|
+
for (const path of paths) {
|
|
1150
|
+
if (!dirtyRef.current[path]) continue
|
|
1151
|
+
// 已有在途保存(flushSave 刚提交的防抖保存):同一 tick 内容必然一致,跳过重复提交
|
|
1152
|
+
if (savingRef.current.has(path)) continue
|
|
1153
|
+
const pdfCtl = pdfCtlRef.current.get(path)
|
|
1154
|
+
if (pdfCtl) { if (pdfCtl.isDirty()) void pdfCtl.savePdf(); continue }
|
|
1155
|
+
const model = modelsRef.current.get(path)
|
|
1156
|
+
if (!model) { setStatus('未保存的修改无法落盘:' + baseNameOf(path)); continue }
|
|
1157
|
+
const content = model.getValue()
|
|
1158
|
+
savingRef.current.add(path)
|
|
1159
|
+
rpc('edrv.save', { sessionId, path, content })
|
|
1160
|
+
.then((res) => { if (res && res.ok) setDirtyMap((d) => Object.assign({}, d, { [path]: false })) })
|
|
1161
|
+
.catch((e) => dbg(sessionId, '关闭前落盘失败:' + path + ' · ' + String(e)))
|
|
1162
|
+
.finally(() => { savingRef.current.delete(path) })
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1080
1166
|
const onEdit = () => {
|
|
1081
1167
|
const ed = editorRef.current
|
|
1082
1168
|
if (!ed || !active) return
|
|
1083
1169
|
setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
|
|
1084
1170
|
setStatus('编辑中…')
|
|
1085
|
-
|
|
1086
|
-
saveTimerRef.current
|
|
1171
|
+
// 重新计时(arm 内部先取消上一轮);到点自动保存,切页签/关闭前由 flushSave 立即提交
|
|
1172
|
+
saveTimerRef.current?.arm(schedule, 700, () => doSave(true))
|
|
1087
1173
|
}
|
|
1088
1174
|
// @author ddj 2026年09月09号 空依赖监听只持有首帧 onEdit(active 恒 null→提前返回,星号与自动保存从未生效):每次渲染同步最新闭包
|
|
1089
1175
|
onEditRef.current = onEdit
|
|
@@ -1618,6 +1704,94 @@ export function EditorView(props) {
|
|
|
1618
1704
|
// 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
|
|
1619
1705
|
menuHandlersRef.current = { addRefToChat, openInExplorer }
|
|
1620
1706
|
|
|
1707
|
+
/**
|
|
1708
|
+
* 复制文本到剪贴板(状态栏反馈;浏览器拒绝时提示,不抛异常)。
|
|
1709
|
+
* @author ddj 2026年09月11号
|
|
1710
|
+
* @param text 待复制文本
|
|
1711
|
+
* @param okText 成功文案
|
|
1712
|
+
*/
|
|
1713
|
+
const copyText = (text, okText) => {
|
|
1714
|
+
const value = String(text ?? '')
|
|
1715
|
+
if (!value) { setStatus('无可复制内容'); return }
|
|
1716
|
+
if (!navigator.clipboard?.writeText) { setStatus('剪贴板不可用'); return }
|
|
1717
|
+
navigator.clipboard.writeText(value)
|
|
1718
|
+
.then(() => setStatus(okText + ':' + value))
|
|
1719
|
+
.catch(() => setStatus('复制失败(浏览器拒绝剪贴板写入)'))
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* 在资源管理器视图中显示:展开侧栏文件树到目标文件并高亮。
|
|
1724
|
+
* 工作区外文件(片段/全局规则)无树节点,判定为不可定位并给出提示。
|
|
1725
|
+
* @author ddj 2026年09月11号
|
|
1726
|
+
* @param path 工作区相对路径
|
|
1727
|
+
*/
|
|
1728
|
+
const revealTabInView = (path) => {
|
|
1729
|
+
if (!isTreeRevealable(path)) { setStatus('该文件不在工作区内,无法在资源管理器中定位'); return }
|
|
1730
|
+
setSidebarOn(true)
|
|
1731
|
+
setActivePanel('explorer')
|
|
1732
|
+
// 面板可能刚展开(FileExplorer 尚未挂载),故用窗口事件而非直接调用
|
|
1733
|
+
window.dispatchEvent(new CustomEvent('edrv:reveal-path', { detail: { path } }))
|
|
1734
|
+
setStatus('已在资源管理器中定位:' + baseNameOf(path))
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* 切换页签固定态(固定页签整体前移;固定后隐藏 ×,仅可经菜单取消固定)。
|
|
1739
|
+
* @author ddj 2026年09月11号
|
|
1740
|
+
* @param path 目标页签路径
|
|
1741
|
+
*/
|
|
1742
|
+
const toggleTabPin = (path) => {
|
|
1743
|
+
const target = tabsRef.current.find((t) => t.path === path)
|
|
1744
|
+
if (!target) return
|
|
1745
|
+
setTabs((prev) => togglePin(prev, path))
|
|
1746
|
+
setStatus(target.pinned ? '已取消固定:' + baseNameOf(path) : '已固定:' + baseNameOf(path))
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** 关闭当前活动页签(Ctrl+F4 与菜单动作共用;无页签时提示)。 */
|
|
1750
|
+
const closeActiveTab = () => {
|
|
1751
|
+
if (!activeRef.current) { setStatus('无打开的文件'); return }
|
|
1752
|
+
closeTab(activeRef.current)
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* 页签右键菜单动作表(按菜单条目 id 分派;全部读 ref 取最新状态,防陈旧闭包)。
|
|
1757
|
+
* @author ddj 2026年09月11号
|
|
1758
|
+
* @param path 右键目标页签路径
|
|
1759
|
+
* @returns id → 动作
|
|
1760
|
+
*/
|
|
1761
|
+
const tabMenuActions = (path) => ({
|
|
1762
|
+
'add-to-conversation': () => addRefToChat(path),
|
|
1763
|
+
close: () => closeTab(path),
|
|
1764
|
+
'close-others': () => closeTabs(closeOthers(tabsRef.current, path, activeRef.current), '已关闭其他页签'),
|
|
1765
|
+
'close-right': () => closeTabs(closeRight(tabsRef.current, path, activeRef.current), '已关闭右侧页签'),
|
|
1766
|
+
'close-saved': () => closeTabs(closeSaved(tabsRef.current, dirtyRef.current, activeRef.current), '已关闭已保存页签'),
|
|
1767
|
+
'close-all': () => closeTabs(closeAll(tabsRef.current, activeRef.current), '已关闭全部页签'),
|
|
1768
|
+
'copy-path': () => copyText(absoluteOf(path, cwd), '已复制路径'),
|
|
1769
|
+
'copy-relative-path': () => copyText(relativeOf(path, cwd), '已复制相对路径'),
|
|
1770
|
+
'reveal-in-os': () => openInExplorer(path),
|
|
1771
|
+
'reveal-in-view': () => revealTabInView(path),
|
|
1772
|
+
'toggle-pinned': () => toggleTabPin(path),
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* 构建页签右键菜单条目(数据来自 tabMenu 纯函数;此处只注入键位与可用性快照)。
|
|
1777
|
+
* @author ddj 2026年09月11号
|
|
1778
|
+
* @returns ContextMenu 的 entries
|
|
1779
|
+
*/
|
|
1780
|
+
const tabMenuEntries = () => {
|
|
1781
|
+
if (!tabMenu) return []
|
|
1782
|
+
const actions = tabMenuActions(tabMenu.path)
|
|
1783
|
+
return buildTabMenu({
|
|
1784
|
+
path: tabMenu.path,
|
|
1785
|
+
tabs: tabsRef.current,
|
|
1786
|
+
active: activeRef.current,
|
|
1787
|
+
dirty: dirtyRef.current,
|
|
1788
|
+
cwd,
|
|
1789
|
+
hasSession: Boolean(sessionId),
|
|
1790
|
+
canAddToConversation: Boolean(addToConversation),
|
|
1791
|
+
closeChord: chordOf('edrv.closeTab'),
|
|
1792
|
+
}).map((entry) => Object.assign({}, entry, { onClick: actions[entry.id] }))
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1621
1795
|
/**
|
|
1622
1796
|
* 在光标处按片段语法展开插入(Monaco 原生 snippet 控制器解析 ${1:占位} 与 $TM_* 变量)。
|
|
1623
1797
|
* @author ddj 2026年09月10号
|
|
@@ -1681,8 +1855,8 @@ export function EditorView(props) {
|
|
|
1681
1855
|
const tabsEl = React.createElement('div', { className: 'edrv-tabs', ref: tabsHostRef, style: { flex: '1 1 auto', minWidth: 0 } },
|
|
1682
1856
|
tabs.map((t) => React.createElement('div', {
|
|
1683
1857
|
key: t.path,
|
|
1684
|
-
className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
|
|
1685
|
-
title: t.path,
|
|
1858
|
+
className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : '') + (t.pinned ? ' edrv-tab-pinned' : ''),
|
|
1859
|
+
title: t.pinned ? t.path + '(已固定)' : t.path,
|
|
1686
1860
|
onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); recordNav(); setActive(t.path) } },
|
|
1687
1861
|
onContextMenu: (e) => {
|
|
1688
1862
|
e.preventDefault()
|
|
@@ -1690,11 +1864,14 @@ export function EditorView(props) {
|
|
|
1690
1864
|
setTabMenu({ x: e.clientX, y: e.clientY, path: t.path })
|
|
1691
1865
|
},
|
|
1692
1866
|
},
|
|
1867
|
+
// 固定页签在文件名前显示 📌 标记,且不渲染 × (与参考图一致:固定页签不显示关闭按钮)
|
|
1868
|
+
// @author ddj 2026年09月11号
|
|
1869
|
+
(t.pinned ? React.createElement('span', { className: 'edrv-tab-pin', title: '已固定', 'aria-label': '已固定' }, '📌') : null),
|
|
1693
1870
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, t.path.split(/[\\/]/).pop() || t.path),
|
|
1694
1871
|
// 未保存修改页签显示 * 星号(保存成功消失、失败持续);替换原圆点脏标记
|
|
1695
1872
|
// @author ddj 2026年09月09号
|
|
1696
1873
|
(dirtyMap[t.path] ? React.createElement('span', { className: 'edrv-tab-star', title: '未保存修改' }, '*') : null),
|
|
1697
|
-
React.createElement('span', { className: 'edrv-tab-x', onClick: (e) => { e.stopPropagation(); closeTab(t.path) } }, '×'))),
|
|
1874
|
+
(t.pinned ? null : React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (e) => { e.stopPropagation(); closeTab(t.path) } }, '×')))),
|
|
1698
1875
|
(openInput
|
|
1699
1876
|
? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
|
|
1700
1877
|
: React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')))
|
|
@@ -1898,28 +2075,17 @@ export function EditorView(props) {
|
|
|
1898
2075
|
React.createElement(DiffLauncher, { sessionId, sum, tab: launcherTab, onClose: () => setLauncherOpen(false), onOpenFile: (p) => { openFile(p, true); setLauncherOpen(false) } }))
|
|
1899
2076
|
: null
|
|
1900
2077
|
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
|
|
1904
|
-
* 计算右键菜单固定定位(clamp 防越出视口)。
|
|
1905
|
-
* @author ddj 2026年08月25号
|
|
1906
|
-
*/
|
|
1907
|
-
const menuPos = (x, y) => ({
|
|
1908
|
-
left: Math.max(4, Math.min(x, (window.innerWidth || 800) - 224)),
|
|
1909
|
-
top: Math.max(4, Math.min(y, (window.innerHeight || 600) - 176)),
|
|
1910
|
-
})
|
|
1911
|
-
const menuBackdrop = tabMenu
|
|
1912
|
-
? React.createElement('div', {
|
|
1913
|
-
style: { position: 'fixed', inset: 0, zIndex: 70 },
|
|
1914
|
-
onClick: dismissMenus,
|
|
1915
|
-
onContextMenu: (e) => { e.preventDefault(); dismissMenus() },
|
|
1916
|
-
})
|
|
1917
|
-
: null
|
|
2078
|
+
// 页签右键菜单:改用通用 ContextMenu 浮层(portal + data-edrv-view + 实测尺寸 clamp +
|
|
2079
|
+
// Esc/外部点击关闭),与侧栏文件树同一套原语。编辑区右键仍走 Monaco 原生菜单。
|
|
2080
|
+
// @author ddj 2026年09月11号
|
|
1918
2081
|
const tabMenuEl = tabMenu
|
|
1919
|
-
? React.createElement(
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2082
|
+
? React.createElement(ContextMenu, {
|
|
2083
|
+
key: 'edrv-tab-menu',
|
|
2084
|
+
x: tabMenu.x,
|
|
2085
|
+
y: tabMenu.y,
|
|
2086
|
+
entries: tabMenuEntries(),
|
|
2087
|
+
onClose: dismissMenus,
|
|
2088
|
+
})
|
|
1923
2089
|
: null
|
|
1924
2090
|
|
|
1925
2091
|
// 代码片段浮层(命令栏「代码片段:配置代码片段」/「插入代码片段」):portal 到 body,随会话注入 cwd/语言
|
|
@@ -2011,13 +2177,11 @@ export function EditorView(props) {
|
|
|
2011
2177
|
const rootEl = layout === 'side'
|
|
2012
2178
|
? React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', 'data-edrv-layout': 'side', className: 'edrv-view-side', style: Object.assign({}, baseStyle, { height: '100%' }) },
|
|
2013
2179
|
editorRow,
|
|
2014
|
-
menuBackdrop,
|
|
2015
2180
|
tabMenuEl,
|
|
2016
2181
|
paletteEl,
|
|
2017
2182
|
snippetPickerEl)
|
|
2018
2183
|
: React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: Object.assign({}, baseStyle, { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)' }) },
|
|
2019
2184
|
editorRow,
|
|
2020
|
-
menuBackdrop,
|
|
2021
2185
|
tabMenuEl,
|
|
2022
2186
|
paletteEl,
|
|
2023
2187
|
snippetPickerEl)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 与既有 `edrv:` 刷新/主题事件分属不同命名空间,互不干扰)。
|
|
7
7
|
* 作者 ddj 2026年09月10号
|
|
8
8
|
*/
|
|
9
|
-
import { hasEditorModel } from '../editorModelState.js'
|
|
9
|
+
import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
|
|
10
10
|
|
|
11
11
|
/** 一条编辑器指令(展示 + 执行 + 可用性)。 */
|
|
12
12
|
export interface CommandDef {
|
|
@@ -90,6 +90,21 @@ function addSelectionRefDef(): CommandDef {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* 关闭当前页签(参考图 Ctrl+F4)。
|
|
95
|
+
* 可用性按「是否已打开页签」判定而非编辑器模型:图片/PDF 页签无 Monaco 实例但可关闭;
|
|
96
|
+
* 无页签时判定不可用 → 指令桥放行按键,不吞掉浏览器/系统对 Ctrl+F4 的默认行为。
|
|
97
|
+
* @author ddj 2026年09月11号
|
|
98
|
+
* @returns 命令定义
|
|
99
|
+
*/
|
|
100
|
+
function closeTabDef(): CommandDef {
|
|
101
|
+
return {
|
|
102
|
+
id: 'edrv.closeTab', label: '关闭当前页签', category: '文件', order: 40,
|
|
103
|
+
keybinding: 'Ctrl+F4', available: hasOpenTabs,
|
|
104
|
+
run: () => emit('closeTab'),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
93
108
|
/**
|
|
94
109
|
* 编辑器内置指令目录(顺序 = 快捷键设置页展示顺序)。
|
|
95
110
|
* 前置 8 条的键位由 EditorView / QuickOpen 自行 capture 监听(历史实现),
|
|
@@ -178,6 +193,7 @@ export const BRIDGE_COMMANDS: readonly CommandDef[] = [
|
|
|
178
193
|
nextEditorRowDef(),
|
|
179
194
|
prevEditorRowDef(),
|
|
180
195
|
addSelectionRefDef(),
|
|
196
|
+
closeTabDef(),
|
|
181
197
|
]
|
|
182
198
|
|
|
183
199
|
/**
|
|
@@ -25,6 +25,9 @@ export const KEYBINDING_DEFAULTS: Record<string, string> = {
|
|
|
25
25
|
'edrv.prevEditorRow': 'Ctrl+Alt+ArrowUp',
|
|
26
26
|
// 添加选中内容为引用:把当前选区追加进对话输入框(Ctrl+U,VS Code 同款)
|
|
27
27
|
'edrv.addSelectionRef': 'Ctrl+U',
|
|
28
|
+
// 关闭当前页签:VS Code 同款为 Ctrl+W,但浏览器会截获该键(脚本无法 preventDefault),
|
|
29
|
+
// 故取参考图里的第二候选 Ctrl+F4(Chrome/Edge 默认无行为,可安全拦截)。
|
|
30
|
+
'edrv.closeTab': 'Ctrl+F4',
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/**
|