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.
- package/README.md +32 -5
- package/lib/client.js +556 -31
- package/lib/client.js.map +1 -1
- package/lib/index.js +185 -40
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/client/compat.ts +1 -1
- package/src/client/editorLimit.ts +31 -0
- package/src/client/index.ts +4 -0
- package/src/client/markdownPreview.ts +24 -0
- package/src/client/md/componentType.ts +28 -0
- package/src/client/md/mdPanel.ts +98 -0
- package/src/client/searchSeed.ts +64 -0
- package/src/client/sidebar/panels/SearchPanel.ts +70 -15
- package/src/client/styles/editor.css +10 -1
- package/src/client/tabActions.ts +54 -0
- package/src/client/ui/EditorView.ts +167 -13
- package/src/client/ui/McpSettings.ts +29 -0
- package/src/client/ui/commandCatalog.ts +5 -0
- package/src/client/ui/horizontalWheel.ts +85 -0
- package/src/client-primitives.d.ts +34 -0
- package/src/compat.ts +2 -2
- package/src/fileOpenSettings.ts +89 -10
- package/src/lsp/extmgr.ts +56 -3
- package/src/lsp/zip.ts +25 -9
- package/src/revert.ts +20 -8
- package/src/shared/editorLimit.ts +46 -0
- package/src/shared/keybindings.ts +3 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
|
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 断言,不得前插。
|