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.
@@ -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
- const saveTimerRef = React.useRef(null)
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
- const closeTab = (path) => {
342
- flushSave()
343
- if (path === active) { saveViewState(path); recordNav() }
344
- // PDF tab 关闭:销毁面板控制器并清缓存(未保存注释随之丢弃,脏点已提示)
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
- setTabs((prev) => {
349
- const idx = prev.findIndex((t) => t.path === path)
350
- if (idx < 0) return prev
351
- const next = prev.filter((t) => t.path !== path)
352
- if (active === path) setActive(next[idx] ? next[idx].path : (next[idx - 1] ? next[idx - 1].path : null))
353
- return next
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 v2 恢复页签(按工作区作用域;cwd 晚到 sid→ws 切换时重恢复一次)
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
- if (Array.isArray(saved.tabs) && saved.tabs.length) {
715
- setTabs(saved.tabs.map((p) => ({ path: p })))
716
- if (typeof saved.active === 'string') setActive(saved.active)
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 { localStorage.setItem(CACHE_KEY.editor + String(scope), JSON.stringify({ tabs: tabs.map((t) => t.path), active })) }
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
- // Tab 右键菜单打开时 Esc 关闭
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
- if (saveTimerRef.current) { saveTimerRef.current(); saveTimerRef.current = null }
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
- if (saveTimerRef.current) saveTimerRef.current()
1086
- saveTimerRef.current = schedule(() => doSave(true), 700)
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
- // Tab 右键菜单浮层:全屏遮罩(点击/右键关闭)+ 菜单(固定定位,clamp 防越界)。
1902
- // 编辑区右键走 Monaco 原生 context menu(editor.addAction),不在此渲染浮层。
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('div', { className: 'edrv-ctxmenu', style: menuPos(tabMenu.x, tabMenu.y) },
1920
- React.createElement('button', { className: 'edrv-ctxmenu-item', onClick: () => { addRefToChat(tabMenu.path); dismissMenus() } }, '添加文件到对话'),
1921
- React.createElement('div', { className: 'edrv-ctxmenu-sep' }),
1922
- React.createElement('button', { className: 'edrv-ctxmenu-item edrv-ctxmenu-danger', onClick: () => { closeTab(tabMenu.path); dismissMenus() } }, '关闭标签页'))
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
  /**