dsh-vscode-mode 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +32 -2
  2. package/lib/client.js +8854 -4023
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1842 -65
  5. package/lib/index.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/client/index.ts +3 -0
  8. package/src/client/monaco/lsp/providers.ts +28 -6
  9. package/src/client/navHighlight.ts +96 -0
  10. package/src/client/paths.ts +2 -0
  11. package/src/client/rpc.ts +32 -12
  12. package/src/client/sidebar/SidebarView.ts +6 -2
  13. package/src/client/sidebar/menuItems.ts +93 -2
  14. package/src/client/sidebar/panels/FileExplorer.ts +18 -1
  15. package/src/client/sidebar/panels/SvnPanel.ts +303 -0
  16. package/src/client/sidebar/panels/index.ts +44 -3
  17. package/src/client/sidebar/types.ts +20 -0
  18. package/src/client/styles/editor.css +177 -0
  19. package/src/client/svnActions.ts +153 -0
  20. package/src/client/svnLog.ts +356 -0
  21. package/src/client/svnStatus.ts +363 -0
  22. package/src/client/svnStore.ts +150 -0
  23. package/src/client/tabMenu.ts +51 -0
  24. package/src/client/ui/EditorView.ts +583 -24
  25. package/src/client/ui/LogDialog.ts +333 -0
  26. package/src/client/ui/McpSettings.ts +98 -0
  27. package/src/client/ui/ModalShell.ts +79 -0
  28. package/src/client/ui/SideBySideDiff.tsx +139 -0
  29. package/src/client/ui/SnippetsPicker.ts +70 -58
  30. package/src/client/ui/SvnDiffPanel.ts +50 -0
  31. package/src/client/ui/SvnLogDialog.ts +1141 -0
  32. package/src/client/ui/SvnLogStats.ts +243 -0
  33. package/src/client/ui/commandCatalog.ts +72 -0
  34. package/src/debugLog.ts +81 -12
  35. package/src/fileOpenSettings.ts +40 -3
  36. package/src/index.ts +5 -1
  37. package/src/rpc.ts +41 -4
  38. package/src/shared/rpc.ts +53 -1
  39. package/src/shared/svn.ts +377 -0
  40. package/src/shared/svnActions.ts +296 -0
  41. package/src/svn.ts +870 -0
  42. package/src/svnLog.ts +315 -0
  43. package/src/svnText.ts +175 -0
  44. package/src/svnXml.ts +110 -0
@@ -0,0 +1,333 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 诊断日志弹窗(居中弹窗形态,ModalShell 外壳)。
4
+ *
5
+ * 形态对齐 SvnLogDialog:单列日志区 + 头部工具栏 + 底部状态行。
6
+ * 数据流:组件自取数据(edrv.dlog.list/read/clear/reveal),EditorView 只负责开与关;
7
+ * 装载/动作集中在 useDlogData(单一数据资源),组件只留视图状态与渲染。
8
+ * 级别过滤与文本搜索为**前端过滤**(日志文件 ≤512KB,整读后过滤,不回传服务端)。
9
+ * 自动刷新:2s 轮询尾部读取(静默,不闪 busy);开关「启用诊断日志」即改即生效
10
+ * (client/rpc.ts 的 setDebugEnabled 写内存缓存 + localStorage,免刷新)。
11
+ * 行格式(host debugLog 落盘):`<ISO时间> [level] 文本`;旧格式无级别标记时 level 为空。
12
+ * 作者 ddj 2026年09月17号
13
+ */
14
+ import React from 'react'
15
+ import { ModalShell } from './ModalShell.js'
16
+ import { isDebug, rpc, setDebugEnabled } from '../rpc.js'
17
+
18
+ /** 弹窗宽度(日志行长可观,横向铺开;窄屏回落)。 */
19
+ const DIALOG_WIDTH = 'min(880px, calc(100vw - 40px))'
20
+ /** 自动刷新轮询间隔(毫秒)。 */
21
+ const AUTO_REFRESH_MS = 2000
22
+ /** 级别过滤档位(全部 + 四级别,与 LoggerLevel 对齐)。 */
23
+ const LEVEL_OPTIONS = ['all', 'debug', 'info', 'warn', 'error']
24
+ /** 级别 → 展示名(筛选下拉与行标签共用)。 */
25
+ const LEVEL_LABEL = { all: '全部', debug: '调试', info: '信息', warn: '警告', error: '错误' }
26
+
27
+ /**
28
+ * 解析一行日志:`<ISO> [level] text`(旧格式无级别段 → level 为空串)。
29
+ * @author ddj 2026年09月17号
30
+ * @param line 原始行
31
+ * @returns { at, level, text }(at 无法识别时为空串)
32
+ */
33
+ export function parseLogLine(line) {
34
+ const text = String(line ?? '')
35
+ const matched = /^(\S+) (?:\[(debug|info|warn|error)\] )?([\s\S]*)$/.exec(text)
36
+ if (!matched) return { at: '', level: '', text }
37
+ const [, at, level, body] = matched
38
+ return { at, level: level || '', text: body }
39
+ }
40
+
41
+ /**
42
+ * 过滤日志行(级别 + 关键字;无级别旧行只在「全部」档显示)。
43
+ * @author ddj 2026年09月17号
44
+ * @param rows 已解析行数组
45
+ * @param level 档位('all' 或具体级别)
46
+ * @param keyword 关键字(大小写不敏感;空串不过滤)
47
+ * @returns 过滤后行
48
+ */
49
+ export function filterRows(rows, level, keyword) {
50
+ const key = String(keyword ?? '').trim().toLowerCase()
51
+ return rows.filter((row) => {
52
+ if (level !== 'all' && row.level !== level) return false
53
+ if (!key) return true
54
+ return (row.at + ' ' + row.text).toLowerCase().includes(key)
55
+ })
56
+ }
57
+
58
+ /**
59
+ * 字节数格式化(B / KB / MB,一位小数)。
60
+ * @author ddj 2026年09月17号
61
+ * @param bytes 字节数
62
+ * @returns 展示文本
63
+ */
64
+ export function formatBytes(bytes) {
65
+ const n = Number(bytes ?? 0)
66
+ if (n < 1024) return n + ' B'
67
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
68
+ return (n / (1024 * 1024)).toFixed(1) + ' MB'
69
+ }
70
+
71
+ /**
72
+ * RPC 调用简包装:失败/网络异常统一回 null,错误文案取 error 字段。
73
+ * @author ddj 2026年09月17号
74
+ * @param sessionId 会话 id
75
+ * @param method 方法名
76
+ * @param args 参数
77
+ * @returns 成功载荷或 null
78
+ */
79
+ async function dlogRpc(sessionId, method, args) {
80
+ try {
81
+ const res = await rpc(method, { sessionId, ...args })
82
+ return res.ok ? res : { ok: false, error: res.error }
83
+ } catch (e) {
84
+ return { ok: false, error: String(e) }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 诊断日志数据资源 hook:清单/尾部读取/清空/打开目录 + 自动刷新轮询。
90
+ * 只做数据与装载动作,不含视图状态(级别/关键字/开关在组件侧)。
91
+ * @author ddj 2026年09月17号
92
+ * @param sessionId 会话 id
93
+ * @param auto 是否自动刷新(2s 静默轮询尾部)
94
+ * @returns 数据状态与动作(pick 切换文件;refresh 手动全量刷新)
95
+ */
96
+ function useDlogData(sessionId, auto) {
97
+ const [files, setFiles] = React.useState(null)
98
+ const [current, setCurrent] = React.useState(null)
99
+ const [picked, setPicked] = React.useState(null)
100
+ const [read, setRead] = React.useState(null)
101
+ const [busy, setBusy] = React.useState(false)
102
+ const [error, setError] = React.useState('')
103
+ const busyRef = React.useRef(false)
104
+ busyRef.current = busy
105
+
106
+ /** 拉清单:首次取回后默认选中「本工作区」那份(不存在则选最近一份)。 */
107
+ const loadList = async () => {
108
+ const res = await dlogRpc(sessionId, 'edrv.dlog.list', {})
109
+ if (!res.ok) { setError(res.error); return }
110
+ setFiles(res.files)
111
+ setCurrent(res.current)
112
+ setPicked((prev) => prev ?? res.current ?? res.files[0]?.name ?? null)
113
+ }
114
+
115
+ /** 尾部读取指定文件(silent = 自动刷新静默模式,不闪 busy、不清旧内容)。 */
116
+ const loadRead = async (name, silent) => {
117
+ if (!name) { setRead(null); return }
118
+ if (!silent) setBusy(true)
119
+ const res = await dlogRpc(sessionId, 'edrv.dlog.read', { file: name })
120
+ if (!res.ok) { setError(res.error); if (!silent) setRead(null) }
121
+ else { setError(''); setRead(res) }
122
+ if (!silent) setBusy(false)
123
+ }
124
+
125
+ // 首次进入:清单 + 默认文件;切换文件:重读
126
+ React.useEffect(() => { void loadList() }, [sessionId])
127
+ React.useEffect(() => { void loadRead(picked) }, [picked, sessionId])
128
+
129
+ // 自动刷新:busy 中跳过本轮,避免与手动刷新打架
130
+ React.useEffect(() => {
131
+ if (!auto || !picked) return undefined
132
+ const timer = setInterval(() => { if (!busyRef.current) void loadRead(picked, true) }, AUTO_REFRESH_MS)
133
+ return () => clearInterval(timer)
134
+ }, [auto, picked, sessionId])
135
+
136
+ /** 手动刷新:清单 + 当前文件全量重拉。 */
137
+ const refresh = async () => {
138
+ await loadList()
139
+ await loadRead(picked)
140
+ }
141
+
142
+ /** 清空当前日志文件后重读。 */
143
+ const clear = async () => {
144
+ if (!picked) return
145
+ setBusy(true)
146
+ const res = await dlogRpc(sessionId, 'edrv.dlog.clear', { file: picked })
147
+ if (!res.ok) setError(res.error)
148
+ else { setError(''); await loadRead(picked) }
149
+ setBusy(false)
150
+ }
151
+
152
+ /** 打开日志根目录(OS 文件管理器)。 */
153
+ const reveal = async () => {
154
+ const res = await dlogRpc(sessionId, 'edrv.dlog.reveal', {})
155
+ if (!res.ok) setError(res.error)
156
+ }
157
+
158
+ /** 切换查看的日志文件(清旧内容防闪烁)。 */
159
+ const pick = (name) => { setPicked(name || null); setRead(null) }
160
+
161
+ return { files, current, picked, read, busy, error, pick, refresh, clear, reveal }
162
+ }
163
+
164
+ /**
165
+ * 级别 → 配色类名(error 红 / warn 黄 / info 蓝 / debug 灰 / 未知默认)。
166
+ * @author ddj 2026年09月17号
167
+ * @param level 行级别(可空)
168
+ * @returns 类名后缀
169
+ */
170
+ function toneClassOf(level) {
171
+ return LEVEL_OPTIONS.includes(level) ? level : 'plain'
172
+ }
173
+
174
+ /**
175
+ * 单行日志(时间 + 级别标签 + 文本;完整时间入 title)。
176
+ * @author ddj 2026年09月17号
177
+ * @param props.row 已解析行
178
+ * @returns 行元素
179
+ */
180
+ function LogRow(props) {
181
+ const { row } = props
182
+ return React.createElement('div', { className: 'edrv-dlog-row edrv-dlog-row-' + toneClassOf(row.level) },
183
+ React.createElement('span', { className: 'edrv-dlog-at', title: row.at }, row.at ? row.at.slice(11, 19) : '--:--:--'),
184
+ React.createElement('span', { className: 'edrv-dlog-lvl' }, row.level ? LEVEL_LABEL[row.level] : '·'),
185
+ React.createElement('span', { className: 'edrv-dlog-text' }, row.text))
186
+ }
187
+
188
+ /**
189
+ * 日志主体(滚动区;空态分「未加载 / 暂无日志 / 无匹配」三种)。
190
+ * @author ddj 2026年09月17号
191
+ * @param props.rows 过滤后行(null = 未加载)
192
+ * @param props.busy 读取中
193
+ * @param props.keyword 当前关键字
194
+ * @param props.bodyRef 主体滚动容器 ref(自动滚到底)
195
+ * @returns 主体元素
196
+ */
197
+ function LogBody(props) {
198
+ const { rows, busy, keyword, bodyRef } = props
199
+ let inner
200
+ if (rows === null) {
201
+ inner = React.createElement('div', { className: 'edrv-tree-loading' }, busy ? '读取日志中…' : '尚未加载')
202
+ } else if (!rows.length) {
203
+ inner = React.createElement('div', { className: 'edrv-tree-loading' },
204
+ keyword ? '无匹配行' : '暂无日志:开启下方「启用诊断日志」并触发一次差异渲染等操作后生成')
205
+ } else {
206
+ inner = rows.map((row, idx) => React.createElement(LogRow, { key: idx, row }))
207
+ }
208
+ return React.createElement('div', { className: 'edrv-dlog-scroll', ref: bodyRef }, inner)
209
+ }
210
+
211
+ /**
212
+ * 头部工具栏(标题 + 文件选择 + 级别 + 搜索 + 动作按钮)。
213
+ * @author ddj 2026年09月17号
214
+ * @param props 见内部解构(files/picked/current/level/query/busy/auto 及回调)
215
+ * @returns 头部元素
216
+ */
217
+ function LogHead(props) {
218
+ const { files, picked, current, level, query, busy, auto, onPick, onLevel, onQuery, onAuto, onRefresh, onCopy, onClear, onReveal, onClose } = props
219
+ const options = (files || []).map((f) => React.createElement('option', {
220
+ key: f.name, value: f.name,
221
+ }, f.name + (f.name === current ? '(本工作区)' : '') + ' · ' + formatBytes(f.bytes)))
222
+ return React.createElement('div', { className: 'edrv-dlog-head' },
223
+ React.createElement('h3', { className: 'edrv-dlog-title' }, '诊断日志'),
224
+ React.createElement('select', {
225
+ className: 'edrv-dlog-file', value: picked ?? '', disabled: !(files && files.length),
226
+ onChange: (event) => onPick(event.target.value),
227
+ }, options.length ? options : React.createElement('option', { value: '' }, '(无日志文件)')),
228
+ React.createElement('select', {
229
+ className: 'edrv-dlog-level', value: level, onChange: (event) => onLevel(event.target.value),
230
+ }, LEVEL_OPTIONS.map((lv) => React.createElement('option', { key: lv, value: lv }, LEVEL_LABEL[lv]))),
231
+ React.createElement('input', {
232
+ className: 'edrv-dlog-search', placeholder: '搜索日志文本', value: query,
233
+ onChange: (event) => onQuery(event.target.value),
234
+ }),
235
+ React.createElement('label', { className: 'edrv-dlog-auto', title: '每 2 秒静默拉取一次尾部日志' },
236
+ React.createElement('input', { type: 'checkbox', checked: auto, onChange: (event) => onAuto(event.target.checked) }),
237
+ '自动刷新'),
238
+ React.createElement('button', { className: 'edrv-svn-act', disabled: busy, title: '重新读取清单与日志', onClick: onRefresh }, busy ? '…' : '⟳'),
239
+ React.createElement('button', { className: 'edrv-svn-act', title: '复制当前过滤后的日志内容', onClick: onCopy }, '复制'),
240
+ React.createElement('button', { className: 'edrv-svn-act edrv-svn-act-danger', title: '清空当前日志文件', onClick: onClear }, '清空'),
241
+ React.createElement('button', { className: 'edrv-svn-act', title: '在文件管理器中打开日志目录', onClick: onReveal }, '目录'),
242
+ React.createElement('button', { className: 'edrv-svn-act', title: '关闭', onClick: onClose }, '✕'))
243
+ }
244
+
245
+ /**
246
+ * 底部状态行(路径 + 统计 + 截头提示 + 诊断开关)。
247
+ * @author ddj 2026年09月17号
248
+ * @param props.read 读取结果(可 null)
249
+ * @param props.total 过滤前总行数
250
+ * @param props.shown 过滤后行数
251
+ * @param props.enabled 诊断开关
252
+ * @param props.copied 复制提示在途
253
+ * @param props.onToggle 开关回调
254
+ * @returns 底部元素
255
+ */
256
+ function LogFoot(props) {
257
+ const { read, total, shown, enabled, copied, onToggle } = props
258
+ const stat = read
259
+ ? '共 ' + total + ' 行' + (shown !== total ? '(过滤后 ' + shown + ' 行)' : '') + ' · ' + formatBytes(read.size) + (read.truncated ? ' · 已截头(仅保留尾部)' : '')
260
+ : '未加载'
261
+ return React.createElement('div', { className: 'edrv-dlog-foot' },
262
+ React.createElement('span', { className: 'edrv-dlog-path', title: read?.path || '' }, read?.path || '—'),
263
+ React.createElement('span', { className: 'edrv-dlog-stat' }, stat + (copied ? ' · 已复制到剪贴板' : '')),
264
+ React.createElement('span', { style: { flex: 1 } }),
265
+ React.createElement('label', { className: 'edrv-dlog-switch', title: '开启后 client 诊断日志上报 host 落盘(立即生效,免刷新)' },
266
+ React.createElement('input', { type: 'checkbox', checked: enabled, onChange: (event) => onToggle(event.target.checked) }),
267
+ '启用诊断日志'))
268
+ }
269
+
270
+ /**
271
+ * 诊断日志弹窗主体:数据经 useDlogData,组件只持视图状态与渲染。
272
+ * @author ddj 2026年09月17号
273
+ * @param props.sessionId 会话 id
274
+ * @param props.onClose 关闭回调
275
+ * @returns 弹窗元素
276
+ */
277
+ export function LogDialog(props) {
278
+ const { sessionId, onClose } = props
279
+ const [level, setLevel] = React.useState('all')
280
+ const [query, setQuery] = React.useState('')
281
+ const [auto, setAuto] = React.useState(false)
282
+ const [enabled, setEnabledState] = React.useState(isDebug())
283
+ const [copied, setCopied] = React.useState(false)
284
+ const bodyRef = React.useRef(null)
285
+ const data = useDlogData(sessionId, auto)
286
+
287
+ // 新内容到底:日志语义「最新在底部」,每次 read 更新后滚到底
288
+ React.useEffect(() => {
289
+ const host = bodyRef.current
290
+ if (host) host.scrollTop = host.scrollHeight
291
+ }, [data.read])
292
+
293
+ // 复制提示自动消退
294
+ React.useEffect(() => {
295
+ if (!copied) return undefined
296
+ const timer = setTimeout(() => setCopied(false), 1500)
297
+ return () => clearTimeout(timer)
298
+ }, [copied])
299
+
300
+ const allRows = React.useMemo(() => {
301
+ if (!data.read?.content) return []
302
+ return data.read.content.split('\n').filter((line) => line.trim()).map(parseLogLine)
303
+ }, [data.read])
304
+ const rows = React.useMemo(() => filterRows(allRows, level, query), [allRows, level, query])
305
+
306
+ /** 复制当前过滤后的日志行(还原级别标记,便于贴给他人排查)。 */
307
+ const copy = async () => {
308
+ if (!rows.length) return
309
+ try {
310
+ await navigator.clipboard.writeText(rows.map((r) => (r.at ? r.at + ' ' : '') + (r.level ? '[' + r.level + '] ' : '') + r.text).join('\n'))
311
+ setCopied(true)
312
+ } catch (e) { /* 剪贴板不可用:状态行已提示复制在途,此处静默 */ }
313
+ }
314
+ const toggleEnabled = (next) => {
315
+ setDebugEnabled(next)
316
+ setEnabledState(next)
317
+ }
318
+
319
+ const errLine = data.error ? React.createElement('div', { className: 'edrv-dlog-error' }, String(data.error)) : null
320
+ return React.createElement(ModalShell, {
321
+ dialogClass: 'edrv-dlog-dialog',
322
+ width: DIALOG_WIDTH,
323
+ onClose,
324
+ },
325
+ React.createElement(LogHead, {
326
+ files: data.files, picked: data.picked, current: data.current, level, query, busy: data.busy, auto,
327
+ onPick: data.pick, onLevel: setLevel, onQuery: setQuery, onAuto: setAuto,
328
+ onRefresh: data.refresh, onCopy: copy, onClear: data.clear, onReveal: data.reveal, onClose,
329
+ }),
330
+ errLine,
331
+ React.createElement(LogBody, { rows, busy: data.busy, keyword: query.trim(), bodyRef }),
332
+ React.createElement(LogFoot, { read: data.read, total: allRows.length, shown: rows.length, enabled, copied, onToggle: toggleEnabled }))
333
+ }
@@ -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 { TORTOISE_DIR_DEFAULT } from '../../shared/svn.js'
14
15
  import { KeybindingsPanel } from './KeybindingsPanel.js'
15
16
  import { LspSettings } from './LspSettings.js'
16
17
  import { PerfSettings } from './PerfSettings.js'
@@ -221,10 +222,107 @@ function GeneralSettings({ registry }) {
221
222
  React.createElement('small', null, '180–560 px;拖拽低于该宽度自动隐藏')),
222
223
  ),
223
224
  ),
225
+ React.createElement(SvnSettingsSection, null),
224
226
  React.createElement(IntegrationSettings, null),
225
227
  )
226
228
  }
227
229
 
230
+ /** 检测快照 → 一行状态文案(未检测/未检出/能力可用性)。 */
231
+ function svnStatusText(info) {
232
+ if (!info) return '未检测(打开编辑器后自动检测)'
233
+ if (!info.managed) return '当前工作区不受 SVN 管理(SVN 入口隐藏)'
234
+ const parts = [info.svnCli ? 'svn CLI 可用' : 'svn CLI 不可用(更新不可用)']
235
+ parts.push(info.tortoise ? 'TortoiseSVN 可用' : 'TortoiseSVN 不可用(仅 Windows)')
236
+ return '受 SVN 管理 · ' + parts.join(' · ')
237
+ }
238
+
239
+ /**
240
+ * SVN 设置节:路径覆盖 + 检测状态行。
241
+ * 写入经 SettingsContext(namespace dsh-vscode-mode);状态经 svn.status 直测(force),
242
+ * 并监听 edrv:svn-status 事件跟随编辑器侧重测结果刷新。
243
+ * @author ddj 2026年09月16号
244
+ */
245
+ function SvnSettingsSection() {
246
+ const settings = React.useContext(SettingsContext)
247
+ const snapshot = settings?.getSnapshot?.()
248
+ const loading = !snapshot || snapshot.status === 'loading'
249
+ const unavailable = snapshot?.status === 'unavailable' || !settings
250
+ const notReady = snapshot?.status !== 'ready'
251
+ const [svnPath, setSvnPath] = React.useState('')
252
+ const [svnDraft, setSvnDraft] = React.useState(null)
253
+ const [tortoisePath, setTortoisePath] = React.useState('')
254
+ const [tortoiseDraft, setTortoiseDraft] = React.useState(null)
255
+ const [svnInfo, setSvnInfo] = React.useState(null)
256
+ const [busy, setBusy] = React.useState(false)
257
+ const [error, setError] = React.useState('')
258
+ React.useEffect(() => {
259
+ const onChange = () => {
260
+ const snap = settings?.getSnapshot?.()
261
+ setSvnPath(typeof snap?.value?.svnPath === 'string' ? snap.value.svnPath : '')
262
+ setTortoisePath(typeof snap?.value?.tortoisePath === 'string' ? snap.value.tortoisePath : '')
263
+ }
264
+ onChange()
265
+ return settings?.subscribe?.(onChange)
266
+ }, [settings])
267
+ const probe = React.useCallback(() => {
268
+ rpc('svn.status', { force: true }).then((res) => {
269
+ if (res.ok) { setSvnInfo(res); setError('') }
270
+ else setSvnInfo(null)
271
+ }).catch(() => setSvnInfo(null))
272
+ }, [])
273
+ React.useEffect(() => { probe() }, [probe])
274
+ React.useEffect(() => {
275
+ const onSvnStatus = () => probe()
276
+ window.addEventListener('edrv:svn-status', onSvnStatus)
277
+ return () => window.removeEventListener('edrv:svn-status', onSvnStatus)
278
+ }, [probe])
279
+ /** 提交路径字段(blur/Enter 共用;成功后强制重测反映新路径)。 */
280
+ const savePath = (key, value, apply) => {
281
+ setBusy(true); setError('')
282
+ if (!settings?.set) { setError('设置服务不可用'); setBusy(false); return }
283
+ settings.set(key, value).then(() => { apply(); probe() }).catch((e) => setError(String(e))).finally(() => setBusy(false))
284
+ }
285
+ const commitSvn = () => {
286
+ if (svnDraft === null) return
287
+ const next = svnDraft.trim()
288
+ setSvnDraft(null)
289
+ savePath('svnPath', next, () => setSvnPath(next))
290
+ }
291
+ const commitTortoise = () => {
292
+ if (tortoiseDraft === null) return
293
+ const next = tortoiseDraft.trim()
294
+ setTortoiseDraft(null)
295
+ savePath('tortoisePath', next, () => setTortoisePath(next))
296
+ }
297
+ const disabled = loading || unavailable || notReady || busy || snapshot?.writable === false
298
+ return React.createElement('section', { className: 'vsm-panel' },
299
+ React.createElement('h3', { className: 'vsm-panel-title' }, 'SVN'),
300
+ React.createElement('div', { className: 'vsm-panel-body' },
301
+ React.createElement('div', { className: 'vsm-general-row' },
302
+ React.createElement('span', null, '检测状态'),
303
+ React.createElement('small', null, svnStatusText(svnInfo)),
304
+ React.createElement('button', { className: 'vsm-primary vsm-small', disabled: busy, onClick: probe, title: '重新检测' }, '重测')),
305
+ error && React.createElement('small', { className: 'vsm-mcp-error' }, error),
306
+ React.createElement('label', { className: 'vsm-general-row' },
307
+ React.createElement('span', null, 'svn 可执行路径'),
308
+ React.createElement('input', {
309
+ value: svnDraft ?? svnPath, placeholder: '空 = 使用 PATH 中的 svn', disabled,
310
+ onChange: (event) => setSvnDraft(event.target.value),
311
+ onBlur: commitSvn,
312
+ onKeyDown: (event) => { if (event.key === 'Enter') commitSvn() },
313
+ })),
314
+ React.createElement('label', { className: 'vsm-general-row' },
315
+ React.createElement('span', null, 'TortoiseSVN 目录'),
316
+ React.createElement('input', {
317
+ value: tortoiseDraft ?? tortoisePath, placeholder: TORTOISE_DIR_DEFAULT, disabled,
318
+ onChange: (event) => setTortoiseDraft(event.target.value),
319
+ onBlur: commitTortoise,
320
+ onKeyDown: (event) => { if (event.key === 'Enter') commitTortoise() },
321
+ })),
322
+ ),
323
+ )
324
+ }
325
+
228
326
  /** 兼容性小节:host 报告(RPC compat)+ 客户端本地适配项。 */
229
327
  function CompatSection({ getSummary }) {
230
328
  const [report, setReport] = React.useState(null)
@@ -0,0 +1,79 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 通用弹窗外壳(居中卡片 + 遮罩 + Esc/遮罩关闭)。
4
+ *
5
+ * 为什么抽:仓库里已有三处「自建遮罩 + createPortal + Esc 监听」的近似实现
6
+ * (代码片段选择器、工作区选择、以及 P3 日志与 P4 提交弹窗要新增的两处)。
7
+ * 除样板重复外,还有三条硬约定必须处处遵守,靠各写一遍必然漏:
8
+ * - 浮层经 createPortal 挂 body,根节点必须带 `data-edrv-view`(editor.css 的浮层样式
9
+ * 全部以 `[data-edrv-view]` 作用域限定;见 tests/overlayScope.test.ts)
10
+ * - 卡片复用 `.vsm-mcp-dialog`(与「添加 MCP」同一视觉语言)
11
+ * - z-index 必须落在既有阶梯内(见 tests/overlayZOrder.test.ts):命令栏 200 >
12
+ * 片段二级 197 > 片段一级 195 > 命令栏遮罩 190。本外壳默认 190(不遮挡命令栏),
13
+ * 需要更高的调用方显式传 zIndex。
14
+ *
15
+ * 作者 ddj 2026年09月16号
16
+ */
17
+ import React from 'react'
18
+ import { createPortal } from 'react-dom'
19
+
20
+ /** 弹窗外壳 props。 */
21
+ export interface ModalShellProps {
22
+ /** 卡片自定义类名(附加在 vsm-mcp-dialog 之后)。 */
23
+ dialogClass?: string
24
+ /** 遮罩类名(默认 edrv-modal-mask)。 */
25
+ maskClass?: string
26
+ /** 卡片宽度 CSS(默认 min(560px, calc(100vw - 40px)))。 */
27
+ width?: string
28
+ /** 卡片附加样式(P0-14:可调尺寸弹窗传入受控 height;与 width 合并,缺省不影响既有弹窗)。 */
29
+ cardStyle?: Record<string, string>
30
+ /** 遮罩 z-index(默认 190,落在既有阶梯内)。 */
31
+ zIndex?: number
32
+ /** 点击遮罩是否关闭(默认 true)。 */
33
+ closeOnMask?: boolean
34
+ /** Esc 是否关闭(默认 true)。 */
35
+ closeOnEsc?: boolean
36
+ /** 关闭回调。 */
37
+ onClose?: () => void
38
+ /** 卡片内容。 */
39
+ children?: unknown
40
+ }
41
+
42
+ /**
43
+ * 通用弹窗外壳(居中 + 遮罩 + Esc/遮罩关闭 + portal 到 body)。
44
+ * @author ddj 2026年09月16号
45
+ * @param props 外壳配置
46
+ * @returns portal 元素
47
+ */
48
+ export function ModalShell(props: ModalShellProps) {
49
+ const {
50
+ dialogClass, maskClass, width, cardStyle, zIndex = 190, closeOnMask = true, closeOnEsc = true, onClose, children,
51
+ } = props || {}
52
+
53
+ React.useEffect(() => {
54
+ if (!closeOnEsc) return undefined
55
+ const onKey = (event) => {
56
+ if (event.key !== 'Escape') return
57
+ // 阻止冒泡:避免编辑器/对话输入框同时响应 Esc
58
+ event.stopPropagation()
59
+ onClose?.()
60
+ }
61
+ window.addEventListener('keydown', onKey, true)
62
+ return () => window.removeEventListener('keydown', onKey, true)
63
+ }, [closeOnEsc, onClose])
64
+
65
+ const mask = React.createElement('div', {
66
+ className: maskClass || 'edrv-modal-mask',
67
+ style: zIndex ? { zIndex } : undefined,
68
+ onClick: () => { if (closeOnMask) onClose?.() },
69
+ },
70
+ React.createElement('div', {
71
+ className: 'vsm-mcp-dialog' + (dialogClass ? ' ' + dialogClass : ''),
72
+ style: width || cardStyle ? Object.assign({}, width ? { width } : null, cardStyle || null) : undefined,
73
+ // 卡片内点击不冒泡到遮罩(否则点内容就关闭)
74
+ onClick: (event) => event.stopPropagation(),
75
+ }, children))
76
+
77
+ // 根节点带 data-edrv-view:editor.css 的浮层样式均以此为作用域前缀
78
+ return createPortal(React.createElement('div', { 'data-edrv-view': '1' }, mask), document.body)
79
+ }
@@ -0,0 +1,139 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 只读并排差异视图(Monaco createDiffEditor 的通用封装)。
4
+ *
5
+ * 三处共用同一实现,避免各自处理 Monaco 的 model 生命周期与已知坑:
6
+ * - P2 基线差异(BASE ↔ 工作区)
7
+ * - P3 版本差异(REV-1 ↔ REV)
8
+ * - P4 提交前预览(工作区 ↔ 即将提交内容,届时按需扩展)
9
+ *
10
+ * 关键实现要点(来自 P2 实测):
11
+ * - 复用既有 Monaco 单例(与编辑器同实例,主题跟随官方令牌),不占编辑器自身 model
12
+ * - URI 必须先去前导 `/`:`'scheme:///' + '/abs'` 会拼出四斜杠 → Uri.parse 抛错 → 白屏
13
+ * - 只销毁本视图创建的 model,复用到的留给下次打开(避免闪烁/重建)
14
+ * - 左侧为 null 时不建编辑器,只显示说明(新增文件的正常语义,不是错误)
15
+ *
16
+ * 作者 ddj 2026年09月16号
17
+ */
18
+ import React from 'react'
19
+ import { langOf } from '../monaco/loader.js'
20
+ import { themeNameOf } from '../monaco/theme.js'
21
+
22
+ /**
23
+ * 构造 model URI(对齐 EditorView.getModel 的 edrv URI 约定;路径剥前导斜杠防四斜杠)。
24
+ * @author ddj 2026年09月16号
25
+ * @param scheme URI scheme
26
+ * @param path 文件路径
27
+ * @param side 侧别(同文件不同侧需不同 scheme,避免 model 冲突)
28
+ * @returns Monaco Uri
29
+ */
30
+ function uriOf(scheme, path, side) {
31
+ return window.monaco.Uri.parse(scheme + '-' + side + ':///' + encodeURI(String(path ?? '').replace(/^\/+/, '')))
32
+ }
33
+
34
+ /**
35
+ * 取(或建)指定 URI 的只读 model:命中则更新内容复用,未命中则创建。
36
+ * @author ddj 2026年09月16号
37
+ * @param monaco Monaco 实例
38
+ * @param uri 目标 URI
39
+ * @param text 内容
40
+ * @param language Monaco language id
41
+ * @param created 本视图创建的 model 收集器(卸载时只销毁自己建的)
42
+ * @returns model
43
+ */
44
+ function modelFor(monaco, uri, text, language, created) {
45
+ const existing = monaco.editor.getModel(uri)
46
+ if (existing) {
47
+ existing.setValue(text)
48
+ return existing
49
+ }
50
+ const model = monaco.editor.createModel(text, language, uri)
51
+ created.push(model)
52
+ return model
53
+ }
54
+
55
+ /**
56
+ * 挂载只读并排差异编辑器;返回卸载函数。
57
+ * @author ddj 2026年09月16号
58
+ * @param props.host 宿主 DOM 节点
59
+ * @param props.monaco Monaco 实例
60
+ * @param props.path 文件路径(决定语法高亮与 model URI)
61
+ * @param props.left 左侧内容(null 表示该侧无内容)
62
+ * @param props.right 右侧内容
63
+ * @param props.scheme model URI scheme 前缀(区分不同来源,避免跨视图复用串内容)
64
+ * @returns 卸载函数
65
+ */
66
+ export function mountSideBySide(props) {
67
+ const { host, monaco, path, left, right, scheme = 'edrv-diff' } = props
68
+ const created = []
69
+ const language = langOf(path)
70
+ const leftModel = modelFor(monaco, uriOf(scheme, path, 'left'), left ?? '', language, created)
71
+ const rightModel = modelFor(monaco, uriOf(scheme, path, 'right'), right ?? '', language, created)
72
+ const diff = monaco.editor.createDiffEditor(host, {
73
+ theme: themeNameOf(),
74
+ readOnly: true,
75
+ originalEditable: false,
76
+ renderSideBySide: true,
77
+ // 只读审阅:不要 minimap/字形栏等编辑态附属物
78
+ minimap: { enabled: false },
79
+ glyphMargin: false,
80
+ lineDecorationsWidth: 8,
81
+ scrollBeyondLastLine: false,
82
+ automaticLayout: true,
83
+ renderOverviewRuler: false,
84
+ wordWrap: 'off',
85
+ fontSize: 13,
86
+ lineHeight: 20,
87
+ padding: { top: 6 },
88
+ })
89
+ diff.setModel({ original: leftModel, modified: rightModel })
90
+ return () => {
91
+ try { diff.dispose() } catch (error) { /* 已随宿主卸载 */ }
92
+ for (const model of created) {
93
+ try { model.dispose() } catch (error) { /* 已被其他持有者销毁 */ }
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * 只读并排差异视图(编辑器主体内的一个分支,非弹窗)。
100
+ * @author ddj 2026年09月16号
101
+ * @param props.monaco Monaco 实例(未就绪为 null,显示加载态)
102
+ * @param props.path 文件路径
103
+ * @param props.left 左侧内容(null = 无内容)
104
+ * @param props.right 右侧内容
105
+ * @param props.title 标题(通常为路径)
106
+ * @param props.leftLabel 左侧标签(如「BASE」「r12」)
107
+ * @param props.rightLabel 右侧标签(如「工作区」「r13」)
108
+ * @param props.emptyNote 左侧无内容时的说明
109
+ * @param props.message 补充说明
110
+ * @param props.scheme model URI scheme 前缀
111
+ * @param props.onClose 关闭回调
112
+ * @returns 差异视图元素
113
+ */
114
+ export function SideBySideDiff(props) {
115
+ const {
116
+ monaco, path, left, right, title, leftLabel, rightLabel, emptyNote, message, scheme, onClose,
117
+ } = props
118
+ const hostRef = React.useRef(null)
119
+
120
+ React.useEffect(() => {
121
+ const host = hostRef.current
122
+ if (!monaco || !host) return undefined
123
+ return mountSideBySide({ host, monaco, path, left, right, scheme })
124
+ }, [monaco, path, left, right, scheme])
125
+
126
+ const noLeft = left === null || left === undefined
127
+ const noRight = right === null || right === undefined
128
+
129
+ return React.createElement('div', { className: 'edrv-svndiff' },
130
+ React.createElement('div', { className: 'edrv-svndiff-bar' },
131
+ React.createElement('span', { className: 'edrv-svndiff-title', title: title ?? path }, String(title ?? path ?? '')),
132
+ React.createElement('span', { className: 'edrv-svndiff-tag' }, (leftLabel || '左') + ' ↔ ' + (rightLabel || '右')),
133
+ (message ? React.createElement('span', { className: 'edrv-svndiff-tag' }, String(message)) : null),
134
+ React.createElement('span', { style: { flex: 1 } }),
135
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '关闭差异视图', onClick: onClose }, '✕ 关闭')),
136
+ ((noLeft && noRight) || !monaco
137
+ ? React.createElement('div', { className: 'edrv-svndiff-empty' }, emptyNote || '无可比较内容')
138
+ : React.createElement('div', { ref: hostRef, className: 'edrv-svndiff-host' })))
139
+ }