dsh-vscode-mode 0.5.1 → 0.5.3

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.
@@ -12,6 +12,8 @@ import { dbg, rpc } from '../rpc.js'
12
12
  import { emitFileChanged, emitRefresh } from '../events.js'
13
13
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
14
14
  import { dataUrlOf, isImagePath, isSvgPath } from '../imagePreview.js'
15
+ import { isMarkdownPath } from '../markdownPreview.js'
16
+ import { MarkdownPanel } from '../md/mdPanel.js'
15
17
  import { base64ToBytes, isPdfPath } from '../pdfPreview.js'
16
18
  import {
17
19
  clearBaseline,
@@ -30,6 +32,7 @@ import { createDiffRenderer } from '../monaco/diffRender.js'
30
32
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
31
33
  import { diffRegions } from '../state/regions.js'
32
34
  import { QuickOpen } from './QuickOpen.js'
35
+ import { attachHorizontalWheel } from './horizontalWheel.js'
33
36
  import { CommandPalette } from './CommandPalette.js'
34
37
  import { closeCommandPalette } from '../commandPaletteStore.js'
35
38
  import { DiffLauncher } from './DiffLauncher.js'
@@ -47,6 +50,7 @@ import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybi
47
50
  import { getSidebarMinWidth } from '../sidebarMin.js'
48
51
  import { navHistoryFor } from '../navHistory.js'
49
52
  import { statusOfAdd } from '../addToConversation.js'
53
+ import { setSearchSeed } from '../searchSeed.js'
50
54
  import { CACHE_KEY } from '../paths.js'
51
55
  import { runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
52
56
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
@@ -56,8 +60,9 @@ import { SnippetsPicker } from './SnippetsPicker.js'
56
60
  import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
57
61
  import {
58
62
  absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
59
- insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
63
+ evictPlan, insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
60
64
  } from '../tabActions.js'
65
+ import { getMaxOpenEditors } from '../editorLimit.js'
61
66
  import { buildTabMenu } from '../tabMenu.js'
62
67
  import { ensureSvnChanges, ensureSvnStatus, getSvnChanges, getSvnStatus, refreshSvnChanges, svnAdd, svnChangeMapOf, svnDiffBase, svnEditorActions, svnRevert, svnTortoise, svnUpdate } from '../svnStatus.js'
63
68
  import { ensureSvnLog, getSvnLog, refreshSvnLog, svnDiffPair, svnDiffRev, svnDiffWorking, svnLogKeyOf, svnLogLoadedLimit, svnLogTruncated, svnWcRev, SVN_LOG_SHOW_ALL_LIMIT } from '../svnLog.js'
@@ -165,6 +170,7 @@ export function EditorView(props) {
165
170
  const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
166
171
  const [imgBroken, setImgBroken] = React.useState(false) // 图片解码失败(onError),显示占位与重试
167
172
  const svgTextRef = React.useRef(new Set()) // 强制以文本打开的 SVG 路径集合(toggleSvgText 维护)
173
+ const mdPreviewRef = React.useRef(new Set()) // 处于预览态的 Markdown 路径集合(toggleMdPreview 维护;不持久化)
168
174
  const [pdfBytes, setPdfBytes] = React.useState(null) // 当前 PDF tab 的原始字节(null=加载中/非 PDF)
169
175
  const pdfB64CacheRef = React.useRef(new Map()) // path → base64(tab 切回免重读;FIFO 上限防内存膨胀)
170
176
  const pdfCtlRef = React.useRef(new Map()) // path → PDF 面板控制器(mount 产出,关闭/卸载销毁)
@@ -229,6 +235,7 @@ export function EditorView(props) {
229
235
  const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
230
236
  const navForwardRef = React.useRef(null) // 前进动作最新闭包(窗口级键盘监听读取)
231
237
  const cycleTabRef = React.useRef(null) // 页签循环动作最新闭包(Ctrl+Alt+←/→、Ctrl+PgUp/PgDn)
238
+ const toggleMdPreviewRef = React.useRef(null) // Markdown 预览切换动作最新闭包(窗口键位监听读取,防陈旧闭包)
232
239
  const rowNavColRef = React.useRef(null) // 整行上下移动的期望列(连续移动保持列位)
233
240
  const rowNavMoveRef = React.useRef(false) // 本次光标变化是否由整行移动触发(否则清空期望列)
234
241
  const activeRef = React.useRef(null) // 当前活动文件的最新值(空依赖闭包/指令回调读取)
@@ -240,6 +247,9 @@ export function EditorView(props) {
240
247
  const dirtyRef = React.useRef({}) // 脏标记最新值(菜单禁用判定与关闭时落盘读)
241
248
  dirtyRef.current = dirtyMap
242
249
  const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
250
+ // 页签上限与 LRU 使用序(超限淘汰最久未用者;见下方 tabTouchEffect / tabEvictEffect)
251
+ const [tabLimit, setTabLimit] = React.useState(() => getMaxOpenEditors())
252
+ const tabUseRef = React.useRef({ seq: 0, used: {} })
243
253
  const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
244
254
  const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
245
255
  const lineRegionMapRef = React.useRef(new Map()) // 行 → 区域 映射(hover 命中)
@@ -282,6 +292,9 @@ export function EditorView(props) {
282
292
  const isImageActive = !!active && isImagePath(active) && !svgTextRef.current.has(active)
283
293
  // 当前 tab 是否为 PDF 面板(与 loadContent 分派条件同源)
284
294
  const isPdfActive = !!active && isPdfPath(active)
295
+ // 当前 tab 是否为可预览的 Markdown,以及是否正处于预览态(预览态替换 Monaco host,只读)
296
+ const isMdActive = !!active && isMarkdownPath(active)
297
+ const mdPreviewing = isMdActive && mdPreviewRef.current.has(active)
285
298
  const regions = React.useMemo(() => diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded), [currentRecords, content, contentPath, active])
286
299
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
287
300
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
@@ -972,10 +985,18 @@ export function EditorView(props) {
972
985
  // G9:迁移历史持久化里的绝对路径页签(旧版差异入口写入),并顺带按新形态去重
973
986
  const restored = normalizeTabs(saved?.tabs, cwd)
974
987
  if (restored.length) {
975
- setTabs(restored)
976
988
  // 活动路径同形态归一,否则恢复后匹配不到任何页签(pickActive 回退首个)
977
989
  const wanted = typeof saved?.active === 'string' ? tabPathOf(saved.active, cwd) : saved?.active
978
- setActive(pickActive(restored, wanted))
990
+ const activePath = pickActive(restored, wanted)
991
+ // 上限已下调过(或历史存档超限):恢复即收敛。此时页签均未编辑、无脏数据,
992
+ // 故只做纯计算裁剪,不走 closeTabs(避免无谓的落盘探测与状态栏提示)。
993
+ // evictPlan 已保护活动页签,无需额外 keep。
994
+ const limit = getMaxOpenEditors()
995
+ const plan = evictPlan(restored, limit, { active: activePath, used: {} })
996
+ const dropped = new Set(plan)
997
+ const kept = plan.length ? restored.filter((tab) => !dropped.has(tab.path)) : restored
998
+ setTabs(kept)
999
+ setActive(kept.some((tab) => tab.path === activePath) ? activePath : (kept[0]?.path ?? null))
979
1000
  }
980
1001
  }
981
1002
  } catch (e) { /* 损坏忽略 */ }
@@ -999,6 +1020,45 @@ export function EditorView(props) {
999
1020
  catch (e) { /* 忽略 */ }
1000
1021
  }, [tabs, active, sessionId, scope])
1001
1022
 
1023
+ /**
1024
+ * 标记页签为「最近使用」(LRU 序号单调递增;编辑动作也算使用,见 onEdit)。
1025
+ * @author ddj 2026年09月18号
1026
+ * @param path 页签路径
1027
+ */
1028
+ const touchTab = (path) => {
1029
+ if (!path) return
1030
+ const state = tabUseRef.current
1031
+ state.seq += 1
1032
+ state.used[path] = state.seq
1033
+ }
1034
+
1035
+ // 页签上限设置变更(edrv:max-open-editors)→ 更新上限态并触发下方淘汰 effect 复算
1036
+ React.useEffect(() => {
1037
+ const onLimit = () => setTabLimit(getMaxOpenEditors())
1038
+ window.addEventListener('edrv:max-open-editors', onLimit)
1039
+ return () => window.removeEventListener('edrv:max-open-editors', onLimit)
1040
+ }, [])
1041
+
1042
+ // LRU 使用序维护:活动页签变化即标记;顺带把 used 表裁剪到当前页签集合,
1043
+ // 否则长会话中已关闭页签的条目会永久堆积(内存无界增长)。
1044
+ // ⚠️ 本 effect 必须声明在淘汰 effect 之前:先标记再淘汰,才能保证「刚打开/刚切到的
1045
+ // 页签」不会在同一次 commit 里被当作最久未用者淘汰。
1046
+ React.useEffect(() => {
1047
+ if (active) touchTab(active)
1048
+ const alive = new Set(tabs.map((t) => t.path))
1049
+ const used = tabUseRef.current.used
1050
+ for (const path of Object.keys(used)) if (!alive.has(path)) delete used[path]
1051
+ }, [tabs, active])
1052
+
1053
+ // 页签超限淘汰:关闭最久未使用的页签(固定页签与活动页签受保护;见 tabActions.evictPlan)。
1054
+ // 复用 closeTabs:它已实现 flushSave + persistDirty(脏页签先静默落盘)+ releaseTab + commitClose,
1055
+ // 故「超限淘汰脏页签」不会丢用户编辑。淘汰后 tabs 变化 → 本 effect 复算 evictPlan 返回空 → 自然收敛。
1056
+ React.useEffect(() => {
1057
+ const plan = evictPlan(tabsRef.current, tabLimit, { active: activeRef.current, used: tabUseRef.current.used })
1058
+ if (!plan.length) return
1059
+ closeTabs(applyClose(tabsRef.current, new Set(plan), activeRef.current), '已关闭最久未使用的页签')
1060
+ }, [tabs, tabLimit, active])
1061
+
1002
1062
  // 侧边栏状态:恢复(显隐/宽度/激活面板);侧栏形态独立键(默认收起,不共享页签形态偏好)
1003
1063
  // 宽度下限来自通用设置 sidebarMinWidth(默认 300),恢复值按其重夹
1004
1064
  const sidebarKey = CACHE_KEY.sidebar + (layout === 'side' ? 'side.' : '') + String(scope)
@@ -1056,14 +1116,51 @@ export function EditorView(props) {
1056
1116
  return () => window.removeEventListener('keydown', onKey, true)
1057
1117
  }, [])
1058
1118
 
1059
- // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框
1119
+ /**
1120
+ * 打开搜索面板(Ctrl+Shift+F 与命令栏「在工作区中搜索」的唯一动作)。
1121
+ *
1122
+ * 若编辑器有选区,把选区文本作为一次性种子交给搜索面板自动填入(需求 1);
1123
+ * 无选区/无编辑器时种子为空,行为与改动前一致(仅展开并聚焦)。
1124
+ * 种子经 searchSeed 模块交棒而非事件 detail:侧栏原本收起时搜索面板尚未挂载,
1125
+ * 事件 detail 会丢;面板挂载后自行取走槽内种子。
1126
+ * @author ddj 2026年09月18号
1127
+ */
1128
+ const openSearchPanel = () => {
1129
+ const ed = editorRef.current
1130
+ const model = ed?.getModel?.()
1131
+ const sel = ed?.getSelection?.()
1132
+ let selected = ''
1133
+ if (model && sel && (sel.startLineNumber !== sel.endLineNumber || sel.startColumn !== sel.endColumn)) {
1134
+ // 选中区间文本(多行时 seedQueryOf 只取首行)
1135
+ selected = model.getValueInRange?.(sel) ?? ''
1136
+ }
1137
+ setSearchSeed(selected)
1138
+ setSidebarOn(true)
1139
+ setActivePanel('search')
1140
+ setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1141
+ }
1142
+
1143
+ // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框(有选中则填入)
1060
1144
  React.useEffect(() => {
1061
1145
  const onKey = (e) => {
1062
1146
  if (!matchEvent(e, bindingsOf('edrv.searchInFiles'))) return
1063
1147
  e.preventDefault(); e.stopPropagation()
1064
- setSidebarOn(true)
1065
- setActivePanel('search')
1066
- setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1148
+ openSearchPanel()
1149
+ }
1150
+ window.addEventListener('keydown', onKey, true)
1151
+ return () => window.removeEventListener('keydown', onKey, true)
1152
+ }, [])
1153
+
1154
+ // Ctrl+Shift+V 切换 Markdown 预览:仅活动文件是 Markdown 时吞键;
1155
+ // 其余情况直接放行(不 preventDefault),保留浏览器/输入框原生的「无格式粘贴」语义
1156
+ // ——与 closeTab / addSelectionRef「不可用时放行按键」同款约定。
1157
+ React.useEffect(() => {
1158
+ const onKey = (e) => {
1159
+ if (!matchEvent(e, bindingsOf('edrv.toggleMarkdownPreview'))) return
1160
+ const path = activeRef.current
1161
+ if (!isMarkdownPath(path)) return
1162
+ e.preventDefault(); e.stopPropagation()
1163
+ toggleMdPreviewRef.current?.(path)
1067
1164
  }
1068
1165
  window.addEventListener('keydown', onKey, true)
1069
1166
  return () => window.removeEventListener('keydown', onKey, true)
@@ -1181,10 +1278,11 @@ export function EditorView(props) {
1181
1278
  ['edrv.command.save', () => { if (editorRef.current?.getModel?.()) { flushSave(); doSaveRef.current?.(false) } }],
1182
1279
  // 快速打开由 QuickOpen 自己接该事件(它持有搜索框 ref),此处不重复实现
1183
1280
  ['edrv.command.toggleSidebar', () => setSidebarOn((v) => !v)],
1184
- ['edrv.command.searchInFiles', () => {
1185
- setSidebarOn(true)
1186
- setActivePanel('search')
1187
- setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1281
+ ['edrv.command.searchInFiles', () => openSearchPanel()],
1282
+ ['edrv.command.toggleMarkdownPreview', () => {
1283
+ const path = activeRef.current
1284
+ if (!isMarkdownPath(path)) { setStatus('当前文件不是 Markdown'); return }
1285
+ toggleMdPreviewRef.current?.(path)
1188
1286
  }],
1189
1287
  ['edrv.command.navigateBack', () => navBackRef.current?.()],
1190
1288
  ['edrv.command.navigateForward', () => navForwardRef.current?.()],
@@ -1265,6 +1363,10 @@ export function EditorView(props) {
1265
1363
  else if (right > host.scrollLeft + host.clientWidth) host.scrollLeft = right - host.clientWidth
1266
1364
  }, [active, tabs.length])
1267
1365
 
1366
+ // 页签栏滚轮横向滚动(需求 3):页签栏溢出时,鼠标滚轮(含触控板纵向手势)横滚页签栏,
1367
+ // 不溢出时不接管(保持页面垂直滚动);Shift+滚轮交由浏览器原生横滚。详见 horizontalWheel.ts。
1368
+ React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [])
1369
+
1268
1370
  // 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
1269
1371
  // pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
1270
1372
  // mousedown 兜底仅取消浏览器历史导航默认动作(MDN:preventDefault mousedown/pointerdown 可抑制)。
@@ -1550,6 +1652,9 @@ export function EditorView(props) {
1550
1652
  const ed = editorRef.current
1551
1653
  if (!ed || !active) return
1552
1654
  setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
1655
+ // 编辑即「使用」:需求 2 的淘汰口径是「最近没有改动」,故有改动时刷新 LRU 序号,
1656
+ // 使长时间只在某文件里编辑的页签不会被其它文件的打开动作挤出。
1657
+ touchTab(active)
1553
1658
  setStatus('编辑中…')
1554
1659
  // 外部改动尚未处理(冲突/文件被删):抑制自动保存,避免用陈旧缓冲反复覆盖磁盘;
1555
1660
  // 手工 Ctrl+S 不受抑制(doSave 会走版本守卫并给冲突提示)。
@@ -1564,6 +1669,9 @@ export function EditorView(props) {
1564
1669
  // ⚠️ 必须以 contentReady(contentPath === active)为门,不能用 `content !== null`:
1565
1670
  // 切页签的那一帧 content 仍是**上一个文件**的内容,据此建 model 会先塞入陈旧文本,
1566
1671
  // 待真实内容到达再由 getModel→setValue 覆盖,光标随之被重置(见下方跳转 effect 注释)。
1672
+ // ⚠️ 依赖必须含 mdPreviewing:Markdown 预览态不渲染 Monaco host(见 body 分派的 mdPreviewing 分支),
1673
+ // 切回源码时 ensureEditor 会**新建**编辑器实例,而 active/content 都没变;缺此依赖则本 effect
1674
+ // 不重跑 → 空 model、行内差异标记消失。同款原因见下方差异自绘 effect。
1567
1675
  React.useEffect(() => {
1568
1676
  if (!monaco || !editorRef.current || !active || !contentReady) return
1569
1677
  const ed = editorRef.current
@@ -1571,7 +1679,7 @@ export function EditorView(props) {
1571
1679
  if (ed.getModel() !== model) ed.setModel(model)
1572
1680
  restoreViewState(active)
1573
1681
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
1574
- }, [monaco, active, content, contentReady])
1682
+ }, [monaco, active, content, contentReady, mdPreviewing])
1575
1683
 
1576
1684
  // PDF 面板外壳 ref 回调(div 仅在 PDF 分支渲染,refs 先于 effect 就绪)
1577
1685
  const ensurePdfHost = (node) => { pdfHostRef.current = node }
@@ -1598,11 +1706,13 @@ export function EditorView(props) {
1598
1706
  }, [isPdfActive, pdfBytes, active])
1599
1707
 
1600
1708
  // 行内差异自绘(decorations / view zones / minus overlay)→ diffRenderer
1709
+ // ⚠️ 依赖含 mdPreviewing:切回源码时编辑器实例被重建(见上方 model 同步 effect 的说明),
1710
+ // 不重跑则新实例上没有任何差异装饰,表现为「差异标记全部消失」。
1601
1711
  React.useEffect(() => {
1602
1712
  if (!monaco || !editorRef.current || !active || content === null) return
1603
1713
  diffRendererRef.current.render(monaco, editorRef.current, pendingRegions, sessionId)
1604
1714
  setLoadStage({ progress: 100, message: '编辑器已就绪' })
1605
- }, [monaco, active, content, pendingRegions])
1715
+ }, [monaco, active, content, pendingRegions, mdPreviewing])
1606
1716
 
1607
1717
  React.useEffect(() => () => {
1608
1718
  flushSave()
@@ -1982,6 +2092,32 @@ export function EditorView(props) {
1982
2092
  loadContent(path, sessionId, true)
1983
2093
  }
1984
2094
 
2095
+ /**
2096
+ * Markdown 在源码编辑与预览之间切换(按路径记忆,不持久化)。
2097
+ *
2098
+ * ⚠️ 进入预览前必须 flushSave():预览态会替换 Monaco host,切回时编辑器实例是新建的;
2099
+ * 若此刻还有未到期的 700ms 防抖保存,定时器到点时 editorRef.current 已被清空
2100
+ * (见 EditorView 卸载清理),doSave 会静默 return —— 用户改动永不落盘。
2101
+ * 同时保存视图状态,保证切回源码后光标/滚动位置不丢。
2102
+ * @author ddj 2026年09月18号
2103
+ * @param path Markdown 文件路径
2104
+ */
2105
+ const toggleMdPreview = (path) => {
2106
+ if (!path) return
2107
+ const next = new Set(mdPreviewRef.current)
2108
+ const entering = !next.has(path)
2109
+ if (entering) {
2110
+ flushSave()
2111
+ saveViewState(path)
2112
+ next.add(path)
2113
+ } else {
2114
+ next.delete(path)
2115
+ }
2116
+ mdPreviewRef.current = next
2117
+ setStatus(entering ? '已切换为 Markdown 预览' : '已切换为源码编辑')
2118
+ }
2119
+ toggleMdPreviewRef.current = toggleMdPreview
2120
+
1985
2121
  /**
1986
2122
  * 图片预览面板:工具条(尺寸 meta / SVG 文本切换 / 刷新)+ 棋盘底自适应图片。
1987
2123
  * @author ddj 2026年09月08号
@@ -2598,6 +2734,13 @@ export function EditorView(props) {
2598
2734
  const pathBar = React.createElement('div', { className: 'edrv-pathbar', title: active || '' },
2599
2735
  React.createElement('span', { className: 'edrv-pb-name' }, active ? String(active).split(/[\\/]/).pop() : '未打开文件'),
2600
2736
  React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开文件'),
2737
+ // Markdown 预览/源码切换(需求 4):仅 .md 文件出现;命令栏与 Ctrl+Shift+V 为同一动作
2738
+ (isMdActive ? React.createElement('button', {
2739
+ className: 'edrv-pill edrv-pill-ghost edrv-pb-mdbtn',
2740
+ title: (mdPreviewing ? '切回源码编辑' : '预览 Markdown') + '(' + (chordOf('edrv.toggleMarkdownPreview') ?? '未绑定') + ')',
2741
+ onClick: () => toggleMdPreview(active),
2742
+ }, mdPreviewing ? '源码' : '预览') : null),
2743
+ (mdPreviewing ? React.createElement('span', { className: 'edrv-pb-meta' }, '预览') : null),
2601
2744
  (active && !isImageActive && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
2602
2745
  (isImageActive && imgSize ? React.createElement('span', { className: 'edrv-pb-meta' }, imgSize.w + '×' + imgSize.h) : null),
2603
2746
  (cursor ? React.createElement('span', { className: 'edrv-pb-meta' }, cursor) : null),
@@ -2786,6 +2929,17 @@ export function EditorView(props) {
2786
2929
  body = React.createElement('div', { className: 'edrv-pdf-host', ref: ensurePdfHost, key: active })
2787
2930
  } else if (isPdfActive) {
2788
2931
  body = loadingBody(loadStage.message || '读取 PDF…', loadStage.progress)
2932
+ } else if (mdPreviewing && contentReady) {
2933
+ // Markdown 预览(需求 4):复用已加载的文本内容渲染,不额外请求;编辑仍在源码态完成
2934
+ body = React.createElement(MarkdownPanel, {
2935
+ key: 'edrv-md-' + active,
2936
+ text: content,
2937
+ onToggleSource: () => toggleMdPreview(active),
2938
+ onReload: () => reloadFile(),
2939
+ onOpenFile: (path, line) => openFileAt(path, line ?? 1),
2940
+ })
2941
+ } else if (mdPreviewing) {
2942
+ body = loadingBody(loadStage.message || '读取 Markdown…', loadStage.progress)
2789
2943
  } else if (content === null) {
2790
2944
  body = loadingBody(loadStage.message || '读取文件内容…', loadStage.progress)
2791
2945
  } else {
@@ -11,6 +11,7 @@ import '../styles/mcp.css'
11
11
  import { availableOpeners, AUTO_OPEN_TOOL } from '../fileOpeners.js'
12
12
  import { SettingsContext } from '../settingsContext.js'
13
13
  import { normalizeSidebarMinWidth, SIDEBAR_MIN_DEFAULT } from '../sidebarMin.js'
14
+ import { EDITOR_LIMIT_CEIL, EDITOR_LIMIT_DEFAULT, normalizeMaxOpenEditors } from '../../shared/editorLimit.js'
14
15
  import { TORTOISE_DIR_DEFAULT } from '../../shared/svn.js'
15
16
  import { KeybindingsPanel } from './KeybindingsPanel.js'
16
17
  import { LspSettings } from './LspSettings.js'
@@ -136,6 +137,8 @@ function GeneralSettings({ registry }) {
136
137
  const [devMessage, setDevMessage] = React.useState('')
137
138
  const [minW, setMinW] = React.useState(SIDEBAR_MIN_DEFAULT)
138
139
  const [minDraft, setMinDraft] = React.useState(null) // 输入草稿(null=跟随已保存值;blur/Enter 提交)
140
+ const [limit, setLimit] = React.useState(EDITOR_LIMIT_DEFAULT)
141
+ const [limitDraft, setLimitDraft] = React.useState(null) // 页签上限草稿(同上提交语义)
139
142
  const settings = React.useContext(SettingsContext)
140
143
  const snapshot = settings?.getSnapshot?.()
141
144
  const loading = !snapshot || snapshot.status === 'loading'
@@ -147,6 +150,7 @@ function GeneralSettings({ registry }) {
147
150
  const next = snap?.value?.fileOpenTool
148
151
  if (typeof next === 'string') setTool(next)
149
152
  setMinW(normalizeSidebarMinWidth(snap?.value?.sidebarMinWidth))
153
+ setLimit(normalizeMaxOpenEditors(snap?.value?.maxOpenEditors))
150
154
  }
151
155
  onChange()
152
156
  return settings?.subscribe?.(onChange)
@@ -174,6 +178,19 @@ function GeneralSettings({ registry }) {
174
178
  setMinDraft(null)
175
179
  saveMinW(minDraft)
176
180
  }
181
+ /** 提交页签数量上限(归一化夹取 0–50 后持久化;0 = 不限制)。 */
182
+ const saveLimit = (raw) => {
183
+ const next = normalizeMaxOpenEditors(raw)
184
+ setLimit(next); setBusy(true); setError('')
185
+ if (!settings?.set) { setError('设置服务不可用'); setBusy(false); return }
186
+ settings.set('maxOpenEditors', next).catch((e) => setError(String(e))).finally(() => setBusy(false))
187
+ }
188
+ /** 结束输入(blur/Enter)时提交页签上限草稿。 */
189
+ const commitLimit = () => {
190
+ if (limitDraft === null) return
191
+ setLimitDraft(null)
192
+ saveLimit(limitDraft)
193
+ }
177
194
  const closeDevForm = () => {
178
195
  if (!window.confirm('关闭开发形态:插件将切换为正式版安装(版本依赖 + 删除工作区链接),pnpm 装配后需重启 DSH 生效。确认关闭?')) return
179
196
  setDevBusy(true)
@@ -220,6 +237,18 @@ function GeneralSettings({ registry }) {
220
237
  onKeyDown: (event) => { if (event.key === 'Enter') commitMinW() },
221
238
  }),
222
239
  React.createElement('small', null, '180–560 px;拖拽低于该宽度自动隐藏')),
240
+ React.createElement('label', { className: 'vsm-general-row' },
241
+ React.createElement('span', null, '页签数量上限'),
242
+ React.createElement('input', {
243
+ type: 'number', min: 0, max: EDITOR_LIMIT_CEIL, step: 1,
244
+ value: limitDraft ?? limit,
245
+ disabled: loading || unavailable || notReady || busy || snapshot?.writable === false,
246
+ title: '0 = 不限制;超出上限时自动关闭最久未使用的页签(固定页签不受影响)',
247
+ onChange: (event) => setLimitDraft(event.target.value),
248
+ onBlur: commitLimit,
249
+ onKeyDown: (event) => { if (event.key === 'Enter') commitLimit() },
250
+ }),
251
+ React.createElement('small', null, '0 = 不限制;超限时关闭最久未使用的页签(固定页签除外)')),
223
252
  ),
224
253
  ),
225
254
  React.createElement(SvnSettingsSection, null),
@@ -197,6 +197,11 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
197
197
  keybinding: 'Ctrl+Shift+F', available: alwaysAvailable,
198
198
  run: () => emit('searchInFiles'),
199
199
  },
200
+ {
201
+ id: 'edrv.toggleMarkdownPreview', label: '切换 Markdown 预览', category: '视图', order: 25,
202
+ keybinding: 'Ctrl+Shift+V', available: alwaysAvailable,
203
+ run: () => emit('toggleMarkdownPreview'),
204
+ },
200
205
  {
201
206
  id: 'edrv.showLogs', label: '查看诊断日志', category: '视图', order: 30,
202
207
  available: alwaysAvailable,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * dsh-vscode-mode client — 溢出容器的滚轮横向滚动(页签栏用)。
3
+ *
4
+ * 需求 3:页签栏出现水平滚动条时,用鼠标滚轮(垂直滚动轮)也能横向滚动。
5
+ * 浏览器对 `overflow-x: auto` 的容器**不会**把垂直滚轮折算成横向滚动(只有原生横向
6
+ * 滚轮/触控板横滑或 Shift+滚轮才横滚),故需要显式接管。
7
+ *
8
+ * 设计取舍:
9
+ * - **不溢出就不接管**:内容放得下时滚轮必须保持页面垂直滚动语义,绝不能 preventDefault。
10
+ * - **Shift+滚轮不接管**:Chrome/Edge 对横向溢出容器原生支持 Shift+滚轮横滚,
11
+ * 我们再次处理会导致位移翻倍。
12
+ * - 触控板的横向手势(deltaX)与纵向手势(deltaY)都按主分量取用。
13
+ *
14
+ * `wheelScrollStep` 为纯函数(node 可测);`attachHorizontalWheel` 只做 DOM 装配。
15
+ * 作者 ddj 2026年09月18号
16
+ */
17
+
18
+ /** 行模式(deltaMode === 1)下一「行」折算的像素数(主流浏览器约 16px 行高)。 */
19
+ const LINE_HEIGHT_PX = 16
20
+
21
+ /** 滚轮输入(DOM WheelEvent 的相关字段子集,便于单测构造)。 */
22
+ export interface WheelInput {
23
+ deltaX: number
24
+ deltaY: number
25
+ /** 0 = 像素 / 1 = 行 / 2 = 页。 */
26
+ deltaMode: number
27
+ shiftKey: boolean
28
+ }
29
+
30
+ /** 容器几何(只需可滚宽度与可视宽度)。 */
31
+ export interface WheelGeometry {
32
+ scrollWidth: number
33
+ clientWidth: number
34
+ }
35
+
36
+ /**
37
+ * 计算一次滚轮事件应产生的横向位移。
38
+ * @author ddj 2026年09月18号
39
+ * @param input 滚轮事件字段
40
+ * @param geometry 容器几何(未溢出时返回 0,即不接管)
41
+ * @returns 横向位移(px,带符号);0 表示本事件不处理(调用方不得 preventDefault)
42
+ */
43
+ export function wheelScrollStep(input: WheelInput, geometry: WheelGeometry): number {
44
+ const scrollable = Number(geometry?.scrollWidth) - Number(geometry?.clientWidth)
45
+ if (!Number.isFinite(scrollable) || scrollable <= 0) return 0
46
+ // Shift+滚轮交给浏览器原生横滚(处理两次会翻倍)
47
+ if (input?.shiftKey === true) return 0
48
+ const dx = Number(input?.deltaX) || 0
49
+ const dy = Number(input?.deltaY) || 0
50
+ const raw = Math.abs(dx) >= Math.abs(dy) ? dx : dy
51
+ if (!raw) return 0
52
+ const mode = input?.deltaMode | 0
53
+ if (mode === 1) return Math.round(raw * LINE_HEIGHT_PX)
54
+ if (mode === 2) return Math.round(raw * Number(geometry.clientWidth || 0))
55
+ return Math.round(raw)
56
+ }
57
+
58
+ /** 可挂载滚轮的节点最小形状(浏览器元素或测试替身)。 */
59
+ export interface WheelTarget {
60
+ addEventListener: (type: string, listener: (event: unknown) => void, options?: unknown) => void
61
+ removeEventListener: (type: string, listener: (event: unknown) => void, options?: unknown) => void
62
+ }
63
+
64
+ /**
65
+ * 给横向溢出容器挂上「垂直滚轮驱动横向滚动」。
66
+ * 监听器为 `passive: false`(需要 preventDefault 阻止页面滚动),仅在需要位移时阻止默认行为。
67
+ * @author ddj 2026年09月18号
68
+ * @param node 目标元素(空则返回空注销器,不做任何事)
69
+ * @returns 注销函数(幂等调用安全)
70
+ */
71
+ export function attachHorizontalWheel(node: WheelTarget | null | undefined): () => void {
72
+ if (!node || typeof node.addEventListener !== 'function') return () => {}
73
+ const onWheel = (event: unknown): void => {
74
+ const e = event as WheelInput & { preventDefault?: () => void }
75
+ const step = wheelScrollStep(
76
+ { deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode, shiftKey: e.shiftKey },
77
+ { scrollWidth: (node as unknown as { scrollWidth: number }).scrollWidth, clientWidth: (node as unknown as { clientWidth: number }).clientWidth },
78
+ )
79
+ if (!step) return
80
+ e.preventDefault?.()
81
+ ;(node as unknown as { scrollLeft: number }).scrollLeft += step
82
+ }
83
+ node.addEventListener('wheel', onWheel, { passive: false })
84
+ return () => node.removeEventListener('wheel', onWheel, { passive: false })
85
+ }
@@ -34,4 +34,38 @@ declare module '@deepseek-ai/dsh-client-ui-primitives' {
34
34
  export const IconListPenOutline16: (props: EdrvIconProps) => unknown
35
35
  /** 代码图标(活动栏「大纲」)。 */
36
36
  export const IconCodeOutline16: (props: EdrvIconProps) => unknown
37
+
38
+ /** 代码块复制按钮文案(MarkdownText 的 labels.code)。 */
39
+ export interface MarkdownCodeLabels {
40
+ /** 复制按钮空闲态文案。 */
41
+ copyLabel: string
42
+ /** 复制后确认窗口期文案。 */
43
+ copiedLabel: string
44
+ }
45
+
46
+ /** Markdown 渲染的本地化 chrome(labels 引用须稳定,否则流式渲染缓存失效)。 */
47
+ export interface MarkdownLabels {
48
+ code: MarkdownCodeLabels
49
+ /** 脚注段落标题。 */
50
+ footnotes: string
51
+ }
52
+
53
+ /**
54
+ * Markdown 渲染原语(GFM + KaTeX;raw HTML 与不安全协议被禁用)。
55
+ * `streaming: false` 走一次性完整解析;`variant: 'body'` 为文档级排版。
56
+ * 旧版 DSH 可能无此导出,调用方须做 typeof 守卫并降级。
57
+ */
58
+ export const MarkdownText: (props: {
59
+ text: string
60
+ streaming?: boolean
61
+ labels: MarkdownLabels
62
+ variant?: 'body' | 'compact'
63
+ }) => unknown
64
+
65
+ /** Markdown 导航作用域:普通外链与本地文件链接的打开能力(不提供则保持原生/纯文本)。 */
66
+ export function MarkdownDelegateProvider(props: {
67
+ children?: unknown
68
+ openExternalLink?: (href: string) => void
69
+ openFile?: (path: string, options?: { line?: number }) => void
70
+ }): unknown
37
71
  }
package/src/compat.ts CHANGED
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs'
10
10
  import { dirname, join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { entriesOf } from './mcp.js'
13
- import { loadSettingsDeps, settingsInstallNote, settingsInstallStrategy } from './fileOpenSettings.js'
13
+ import { loadSettingsDeps, schemaLibName, settingsInstallNote, settingsInstallStrategy } from './fileOpenSettings.js'
14
14
  import { readDevForm } from './devForm.js'
15
15
  import { compareDshVersions, detectDshVersion, familyLabel, parseDshVersion } from './dshVersion.js'
16
16
  import { SKILL_PREFIXES, skillGroupState } from './skills.js'
@@ -59,7 +59,7 @@ export function detectExternal(ctx: Ctx, depsAvailable: boolean): CompatAdapter[
59
59
  const skills = skillGroupState()
60
60
  return [
61
61
  { name: MCP_PACKAGE, active: mcpCount > 0, note: mcpCount > 0 ? mcpCount + ' 个 MCP 服务条目' : '未检测到 MCP 条目(MCP 管理页显示为空)' },
62
- { name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装' : '未安装:fileOpenTool 持久化降级为配置值' },
62
+ { name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装(schema: ' + (schemaLibName() || '未知') + ')' : '未安装:fileOpenTool 持久化降级为配置值' },
63
63
  { name: 'settings 服务', active: hasSettings, note: hasSettings ? '可读写设置' : '不可用(设置读写走配置回退)' },
64
64
  { name: '文件浏览器打开(subprocess 服务)', active: typeof sub?.spawn === 'function', note: typeof sub?.spawn === 'function' ? '可定位/打开 OS 文件浏览器' : '不可用(右键「在文件浏览器中打开」将提示失败)' },
65
65
  // 新增项一律追加在末尾:既有下标被 tests/compat.test.ts 断言,不得前插。