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,243 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — SVN 日志统计窗(P1-10,对应 TortoiseSVN Statistics 三页 D23)。
4
+ *
5
+ * 纯前端聚合当前显示的条目(不发额外 RPC);统计口径 = 传入条目集合,
6
+ * 即官方「统计覆盖期间 = Log 对话框显示的期间」。内容对齐官方可达子集:
7
+ * 概要(期间与修订数)· Commits by author(横条 + 饼图 + Others 阈值)·
8
+ * Commits by date(按日柱状,normal/stacked)+ Authors case insensitive 开关(默认关,与官方一致)。
9
+ * Esc 交由底层日志弹窗处理(嵌套 ModalShell 同键会连关两层,故统计窗只走 ✕/遮罩关闭)。
10
+ * 作者 ddj 2026年09月17号
11
+ */
12
+ import React from 'react'
13
+ import { ModalShell } from './ModalShell.js'
14
+
15
+ /** stacked 时间线展开的作者上限(其余并入「其他」)。 */
16
+ const STACK_TOP = 5
17
+ /** 作者配色(按占比序循环;饼图与 stacked 分段共用)。 */
18
+ const TONES = ['#4e9a06', '#3465a4', '#c17d11', '#75507b', '#cc0000', '#5c3566', '#ce5c00', '#888a85']
19
+
20
+ /**
21
+ * 日期 → 本地日(YYYY-MM-DD;解析失败返回空串,该条不参与期间与按日统计)。
22
+ * @author ddj 2026年09月17号
23
+ * @param date ISO8601 字符串
24
+ * @returns 日文本
25
+ */
26
+ function dayTextOf(date) {
27
+ const at = new Date(String(date ?? ''))
28
+ if (Number.isNaN(at.getTime())) return ''
29
+ const pad = (n) => String(n).padStart(2, '0')
30
+ return at.getFullYear() + '-' + pad(at.getMonth() + 1) + '-' + pad(at.getDate())
31
+ }
32
+
33
+ /**
34
+ * 聚合统计(P1-10 纯函数,供组件与单测共用)。
35
+ * @author ddj 2026年09月17号
36
+ * @param entries 日志条目
37
+ * @param opts caseInsensitive 作者归并开关(默认 false = 区分大小写,与官方一致)
38
+ * @returns total 总数 / minDate·maxDate 期间 / authors 作者分布(降序)/ days 按日分布(升序)
39
+ */
40
+ export function svnLogStatsOf(entries, opts = {}) {
41
+ const ci = opts.caseInsensitive === true
42
+ const list = Array.isArray(entries) ? entries : []
43
+ const authors = new Map()
44
+ const days = new Map()
45
+ let min = ''
46
+ let max = ''
47
+ for (const entry of list) {
48
+ const name = entry.author || '(无作者)'
49
+ const key = ci ? name.toLowerCase() : name
50
+ const a = authors.get(key) || { name, count: 0 }
51
+ a.count++
52
+ authors.set(key, a)
53
+ const day = dayTextOf(entry.date)
54
+ if (!day) continue
55
+ if (!min || day < min) min = day
56
+ if (!max || day > max) max = day
57
+ const d = days.get(day) || { count: 0, byKey: new Map() }
58
+ d.count++
59
+ d.byKey.set(key, (d.byKey.get(key) || 0) + 1)
60
+ days.set(day, d)
61
+ }
62
+ const authorList = [...authors.entries()]
63
+ .map(([key, a]) => ({ key, name: a.name, count: a.count }))
64
+ .sort((x, y) => y.count - x.count || x.key.localeCompare(y.key))
65
+ const dayList = [...days.entries()]
66
+ .map(([day, d]) => ({ day, count: d.count, byKey: d.byKey }))
67
+ .sort((x, y) => x.day.localeCompare(y.day))
68
+ return { total: list.length, minDate: min, maxDate: max, authors: authorList, days: dayList }
69
+ }
70
+
71
+ /**
72
+ * 作者分布按 Others 阈值拆分:占比 ≥ 阈值% 的保留,其余并入「其他」。
73
+ * @author ddj 2026年09月17号
74
+ * @param authorList 作者分布(降序)
75
+ * @param othersPct 阈值(占总数百分比,0 = 不合并)
76
+ * @param total 总修订数
77
+ * @returns main 保留项(附 tone 下标)/ otherCount 其他合计
78
+ */
79
+ function splitAuthors(authorList, othersPct, total) {
80
+ const main = []
81
+ let otherCount = 0
82
+ for (const a of authorList) {
83
+ if (total > 0 && (a.count / total) * 100 < othersPct) { otherCount += a.count; continue }
84
+ main.push(a)
85
+ }
86
+ return { main, otherCount }
87
+ }
88
+
89
+ /**
90
+ * 概要行(总数 / 期间 / 作者数)。
91
+ * @author ddj 2026年09月17号
92
+ * @param props.stats 聚合结果
93
+ * @returns 元素
94
+ */
95
+ function StatsHead(props) {
96
+ const { stats } = props
97
+ const period = stats.minDate ? stats.minDate + ' ~ ' + stats.maxDate : '—'
98
+ return React.createElement('div', { className: 'edrv-svnlog-stat-line' },
99
+ React.createElement('span', null, '修订总数:' + stats.total),
100
+ React.createElement('span', null, '期间:' + period),
101
+ React.createElement('span', null, '作者数:' + stats.authors.length))
102
+ }
103
+
104
+ /**
105
+ * 作者横条(占比条形,Other 合并项垫底)。
106
+ * @author ddj 2026年09月17号
107
+ * @param props.stats 聚合结果
108
+ * @param props.othersPct Others 阈值百分比
109
+ * @returns 元素
110
+ */
111
+ function AuthorBars(props) {
112
+ const { stats, othersPct } = props
113
+ if (!stats.total) return null
114
+ const { main, otherCount } = splitAuthors(stats.authors, othersPct, stats.total)
115
+ const row = (a, tone, name) => React.createElement('div', { key: a.key || name, className: 'edrv-svnlog-stat-row' },
116
+ React.createElement('span', { className: 'edrv-svnlog-stat-name', title: name }, name),
117
+ React.createElement('span', { className: 'edrv-svnlog-stat-barwrap' },
118
+ React.createElement('span', {
119
+ className: 'edrv-svnlog-stat-bar', style: { width: ((a.count / stats.total) * 100) + '%', background: tone },
120
+ })),
121
+ React.createElement('span', { className: 'edrv-svnlog-stat-count' }, String(a.count)))
122
+ const rows = main.map((a, i) => row(a, TONES[i % TONES.length], a.name))
123
+ if (otherCount > 0) rows.push(row({ key: '__others__', count: otherCount }, '#888a85', '其他'))
124
+ return React.createElement('div', { className: 'edrv-svnlog-stat-hist' }, rows)
125
+ }
126
+
127
+ /**
128
+ * 作者饼图(conic-gradient;份额低于阈值的并入「其他」灰块)。
129
+ * @author ddj 2026年09月17号
130
+ * @param props.stats 聚合结果
131
+ * @param props.othersPct Others 阈值百分比
132
+ * @returns 元素
133
+ */
134
+ function AuthorPie(props) {
135
+ const { stats, othersPct } = props
136
+ if (!stats.total) return null
137
+ const { main, otherCount } = splitAuthors(stats.authors, othersPct, stats.total)
138
+ const stops = []
139
+ let at = 0
140
+ const seg = (share, tone) => {
141
+ const from = (at / stats.total) * 360
142
+ at += share
143
+ const to = (at / stats.total) * 360
144
+ stops.push(tone + ' ' + from.toFixed(2) + 'deg ' + to.toFixed(2) + 'deg')
145
+ }
146
+ for (const [i, a] of main.entries()) seg(a.count, TONES[i % TONES.length])
147
+ if (otherCount > 0) seg(otherCount, '#888a85')
148
+ return React.createElement('div', {
149
+ className: 'edrv-svnlog-stat-pie',
150
+ style: { background: 'conic-gradient(' + stops.join(',') + ')' },
151
+ title: main.map((a) => a.name + ' ' + a.count).join(',') + (otherCount ? ',其他 ' + otherCount : ''),
152
+ })
153
+ }
154
+
155
+ /**
156
+ * 按日柱状(normal = 每日总量;stacked = 按作者分段,超出上限并入「其他」)。
157
+ * @author ddj 2026年09月17号
158
+ * @param props.stats 聚合结果
159
+ * @param props.stacked 是否按作者分段
160
+ * @returns 元素
161
+ */
162
+ function DayBars(props) {
163
+ const { stats, stacked } = props
164
+ if (!stats.days.length) return null
165
+ const max = Math.max(1, ...stats.days.map((d) => d.count))
166
+ const topKeys = stats.authors.slice(0, STACK_TOP).map((a) => a.key)
167
+ const toneOf = (key) => {
168
+ const at = topKeys.indexOf(key)
169
+ return at >= 0 ? TONES[at % TONES.length] : '#888a85'
170
+ }
171
+ const column = (d) => {
172
+ const segs = []
173
+ if (stacked) {
174
+ const rest = { count: 0 }
175
+ for (const [key, count] of d.byKey) {
176
+ if (!topKeys.includes(key)) { rest.count += count; continue }
177
+ segs.push({ key, count })
178
+ }
179
+ if (rest.count > 0) segs.push({ key: '__others__', count: rest.count })
180
+ } else {
181
+ segs.push({ key: '__all__', count: d.count })
182
+ }
183
+ return React.createElement('div', {
184
+ key: d.day, className: 'edrv-svnlog-stat-day',
185
+ title: d.day + ':' + d.count + ' 条',
186
+ },
187
+ segs.map((s) => React.createElement('span', {
188
+ key: s.key, className: 'edrv-svnlog-stat-seg',
189
+ style: { height: ((s.count / max) * 100) + '%', background: stacked ? toneOf(s.key) : '#3465a4' },
190
+ })))
191
+ }
192
+ return React.createElement('div', { className: 'edrv-svnlog-stat-days' }, stats.days.map(column))
193
+ }
194
+
195
+ /**
196
+ * 统计窗(嵌套在日志弹窗之上;z-index 192 落在既有阶梯内)。
197
+ * @author ddj 2026年09月17号
198
+ * @param props.entries 参与统计的条目(当前显示列表)
199
+ * @param props.onClose 关闭回调
200
+ * @returns 弹窗元素
201
+ */
202
+ export function SvnLogStats(props) {
203
+ const { entries, onClose } = props
204
+ const [ci, setCi] = React.useState(false)
205
+ const [othersPct, setOthersPct] = React.useState(0)
206
+ const [stacked, setStacked] = React.useState(false)
207
+ const stats = React.useMemo(() => svnLogStatsOf(entries, { caseInsensitive: ci }), [entries, ci])
208
+ return React.createElement(ModalShell, {
209
+ dialogClass: 'edrv-svnlog-stats',
210
+ width: 'min(680px, calc(100vw - 40px))',
211
+ zIndex: 192,
212
+ closeOnEsc: false,
213
+ onClose,
214
+ },
215
+ React.createElement('div', { className: 'edrv-svnlog-stats-head' },
216
+ React.createElement('h3', { className: 'edrv-svnlog-title' }, 'Statistics'),
217
+ React.createElement('span', { style: { flex: 1 } }),
218
+ React.createElement('button', { className: 'edrv-svn-act', title: '关闭', onClick: onClose }, '✕')),
219
+ (stats.total === 0
220
+ ? React.createElement('div', { className: 'edrv-svnlog-stats-body' },
221
+ React.createElement('div', { className: 'edrv-tree-loading' }, '暂无可统计的条目'))
222
+ : React.createElement('div', { className: 'edrv-svnlog-stats-body' },
223
+ React.createElement(StatsHead, { stats }),
224
+ React.createElement('div', { className: 'edrv-svnlog-stat-block' },
225
+ React.createElement('div', { className: 'edrv-svnlog-stat-label' },
226
+ 'Commits by author',
227
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '作者名不区分大小写归并统计' },
228
+ React.createElement('input', { type: 'checkbox', checked: ci, onChange: (e) => setCi(e.target.checked) }),
229
+ 'Authors case insensitive'),
230
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '占比低于阈值的作者并入「其他」' },
231
+ '其他阈值 ' + othersPct + '%',
232
+ React.createElement('input', { type: 'range', min: 0, max: 50, step: 5, value: othersPct, onChange: (e) => setOthersPct(Number(e.target.value)) }))),
233
+ React.createElement('div', { className: 'edrv-svnlog-stat-duo' },
234
+ React.createElement(AuthorBars, { stats, othersPct }),
235
+ React.createElement(AuthorPie, { stats, othersPct }))),
236
+ React.createElement('div', { className: 'edrv-svnlog-stat-block' },
237
+ React.createElement('div', { className: 'edrv-svnlog-stat-label' },
238
+ 'Commits by date',
239
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '按作者分段堆叠(前 ' + STACK_TOP + ' 位作者,其余并入「其他」)' },
240
+ React.createElement('input', { type: 'checkbox', checked: stacked, onChange: (e) => setStacked(e.target.checked) }),
241
+ 'stacked')),
242
+ React.createElement(DayBars, { stats, stacked })))))
243
+ }
@@ -7,6 +7,9 @@
7
7
  * 作者 ddj 2026年09月10号
8
8
  */
9
9
  import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
10
+ import { svnCurrentStatus } from '../svnStatus.js'
11
+ import { svnActionOn, svnActionsFor } from '../../shared/svnActions.js'
12
+ import type { SvnActionDef } from '../../shared/svnActions.js'
10
13
 
11
14
  /** 一条编辑器指令(展示 + 执行 + 可用性)。 */
12
15
  export interface CommandDef {
@@ -105,6 +108,68 @@ function closeTabDef(): CommandDef {
105
108
  }
106
109
  }
107
110
 
111
+ /**
112
+ * 命令栏专用 SVN 动作(从 shared 动作目录生成;F1 收敛后命令栏不再手写动作清单)。
113
+ *
114
+ * 为什么这样:P3/P4 每加一个 SVN 动作,命令栏/树菜单/页签菜单三处都要改,
115
+ * 且显隐条件会在三处各自漂移。现在动作元数据只有一份(shared/svnActions.ts),
116
+ * 这里只负责「映射成命令定义」,新增动作无需改本文件。
117
+ *
118
+ * @author ddj 2026年09月16号
119
+ * @returns 命令定义数组
120
+ */
121
+ function svnPaletteDefs(): CommandDef[] {
122
+ return svnActionsFor('palette').map((action) => ({
123
+ id: action.commandId ?? ('edrv.svn' + action.id),
124
+ label: 'SVN ' + action.label,
125
+ category: 'SVN',
126
+ order: action.order,
127
+ available: () => {
128
+ const status = svnCurrentStatus()
129
+ const ready = Boolean(status?.managed)
130
+ const cli = Boolean(status?.svnCli)
131
+ const tortoise = Boolean(status?.tortoise)
132
+ // 命令栏作用于「活动文件」:有编辑器模型时按 file 求值(与 svnEditorActions 同口径)。
133
+ // 若恒用 'editor',file/directory 类动作(查看日志/加入/还原)会因 targets 不含 editor
134
+ // 被整体过滤——svnStatus.ts 注释警告过的同款坑(2026-09-18 实测命令栏缺「查看日志」)。
135
+ const hasModel = hasEditorModel()
136
+ if (!svnActionOn(action, {
137
+ managed: ready, svnCli: cli, tortoise, target: hasModel ? 'file' : 'editor',
138
+ versioned: true, diffable: true,
139
+ })) return false
140
+ // 需要活动文件的动作(命令栏语义)额外要求编辑器模型
141
+ if (action.needsEditorModel && !hasModel) return false
142
+ return true
143
+ },
144
+ run: () => emit(svnEventOf(action)),
145
+ }))
146
+ }
147
+
148
+ /**
149
+ * 动作 → 命令事件后缀(`edrv.command.<后缀>`)。
150
+ * 与既有事件名保持兼容:update/diff-base/add/revert 沿用历史后缀,新动作按驼峰拼接。
151
+ * @author ddj 2026年09月16号
152
+ * @param action 动作定义
153
+ * @returns 事件后缀
154
+ */
155
+ function svnEventOf(action: SvnActionDef): string {
156
+ const legacy: Record<string, string> = {
157
+ update: 'svnUpdate',
158
+ 'refresh-changes': 'svnRefreshChanges',
159
+ 'diff-base': 'svnDiffBase',
160
+ add: 'svnAdd',
161
+ revert: 'svnRevertCli',
162
+ log: 'svnLog',
163
+ cleanup: 'svnCleanup',
164
+ 'tortoise-commit': 'svnTortoiseCommit',
165
+ 'tortoise-log': 'svnTortoiseLog',
166
+ 'tortoise-diff': 'svnTortoiseDiff',
167
+ 'tortoise-blame': 'svnTortoiseBlame',
168
+ 'tortoise-revert': 'svnTortoiseRevert',
169
+ }
170
+ return legacy[action.id] ?? ('svn' + action.id)
171
+ }
172
+
108
173
  /**
109
174
  * 编辑器内置指令目录(顺序 = 快捷键设置页展示顺序)。
110
175
  * 前置 8 条的键位由 EditorView / QuickOpen 自行 capture 监听(历史实现),
@@ -132,6 +197,11 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
132
197
  keybinding: 'Ctrl+Shift+F', available: alwaysAvailable,
133
198
  run: () => emit('searchInFiles'),
134
199
  },
200
+ {
201
+ id: 'edrv.showLogs', label: '查看诊断日志', category: '视图', order: 30,
202
+ available: alwaysAvailable,
203
+ run: () => emit('showLogs'),
204
+ },
135
205
  {
136
206
  id: 'edrv.navigateBack', label: '后退(导航历史)', category: '导航', order: 10,
137
207
  keybinding: 'Alt+ArrowLeft|Ctrl+Alt+-', available: needsModel,
@@ -182,6 +252,8 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
182
252
  available: needsModel,
183
253
  run: () => emit('insertSnippet'),
184
254
  },
255
+ // SVN(动作清单来自 shared/svnActions.ts:三入口共用一份显隐规则)
256
+ ...svnPaletteDefs(),
185
257
  ]
186
258
 
187
259
  /**
package/src/debugLog.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
- * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)。
2
+ * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
3
3
  * 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
4
4
  * `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
5
5
  * 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
6
- * 迁移自 src/rpc.ts(enqueueDebug/flushDebug),逻辑一字不改,仅终端回显改走统一日志器。
7
- * 作者 ddj 2026-09-08
6
+ * 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
7
+ * isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
8
+ * 作者 ddj 2026-09-08 / 2026-09-17
8
9
  */
9
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
10
+ import { mkdir, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises'
10
11
  import { join } from 'node:path'
11
- import { DEBUG_LOG, debugLogFile, pluginLogRoot } from './paths.js'
12
+ import { DEBUG_LOG, dshHome, debugLogFile, pluginLogRoot } from './paths.js'
12
13
  import { log } from './log.js'
14
+ import type { LoggerLevel } from './shared/logger.js'
13
15
  import type { Ctx } from './store.js'
14
16
 
15
17
  /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
@@ -24,9 +26,22 @@ const DEBUG_BUF_CAP = 32 * 1024
24
26
  const DEBUG_FLUSH_IDLE_MS = 1000
25
27
  /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
26
28
  const debugLegacyCleaned = new Set<string>()
27
- /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀)。 */
29
+ /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
28
30
  const debugLog = log.child('debug')
29
31
 
32
+ /** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
33
+ const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/
34
+
35
+ /**
36
+ * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
37
+ * @author ddj 2026年09月17号
38
+ * @param name 请求回传的文件名(可为空)
39
+ * @returns 是否合法
40
+ */
41
+ export function isDebugLogName(name: string | null | undefined): name is string {
42
+ return typeof name === 'string' && DEBUG_LOG_NAME.test(name)
43
+ }
44
+
30
45
  /**
31
46
  * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
32
47
  * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
@@ -56,16 +71,19 @@ function flushDebug(cwd: string, batch: string[]): Promise<void> {
56
71
  }
57
72
 
58
73
  /**
59
- * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(`[dsh-vscode-mode:debug]` 前缀)。
60
- * @author ddj 2026年09月08号
74
+ * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
75
+ * 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
76
+ * @author ddj 2026年09月08号 / 2026年09月17号
61
77
  * @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
62
78
  * @param cwd 工作区(日志文件按 cwd hash 隔离)
63
79
  * @param text 单条日志文本(不含换行)
80
+ * @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
64
81
  */
65
- export function debugRecord(_ctx: Ctx, cwd: string, text: string): void {
82
+ export function debugRecord(_ctx: Ctx, cwd: string, text: string, level: LoggerLevel = 'debug'): void {
83
+ const tagged = '[' + level + '] ' + text
66
84
  const st = debugBuffers.get(cwd) ?? { lines: [], len: 0, timer: null }
67
- st.lines.push(text)
68
- st.len += text.length
85
+ st.lines.push(tagged)
86
+ st.len += tagged.length
69
87
  const flush = () => {
70
88
  st.timer = null
71
89
  const batch = st.lines
@@ -80,5 +98,56 @@ export function debugRecord(_ctx: Ctx, cwd: string, text: string): void {
80
98
  st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS)
81
99
  }
82
100
  debugBuffers.set(cwd, st)
83
- debugLog.error(text)
101
+ debugLog[level](text)
102
+ }
103
+
104
+ /**
105
+ * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
106
+ * @author ddj 2026年09月17号
107
+ * @param home DSH home(测试可注入;缺省按环境解析)
108
+ * @returns 清单项数组
109
+ */
110
+ export async function listDebugLogs(home = dshHome()): Promise<{ name: string; bytes: number; mtimeMs: number }[]> {
111
+ const names = await readdir(pluginLogRoot(home)).catch(() => [] as string[])
112
+ const items: { name: string; bytes: number; mtimeMs: number }[] = []
113
+ for (const name of names) {
114
+ if (!isDebugLogName(name)) continue
115
+ const info = await stat(join(pluginLogRoot(home), name)).catch(() => null)
116
+ if (!info?.isFile()) continue
117
+ items.push({ name, bytes: info.size, mtimeMs: info.mtimeMs })
118
+ }
119
+ items.sort((a, b) => b.mtimeMs - a.mtimeMs)
120
+ return items
121
+ }
122
+
123
+ /**
124
+ * 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
125
+ * 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
126
+ * @author ddj 2026年09月17号
127
+ * @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
128
+ * @param maxBytes 尾部保留字节数(缺省 256KB)
129
+ * @param home DSH home(测试可注入)
130
+ * @returns 读取结果;文件不存在或名字非法返回 null
131
+ */
132
+ export async function readDebugLog(name: string | null | undefined, maxBytes = 256 * 1024, home = dshHome()): Promise<{ name: string; path: string; content: string; size: number; truncated: boolean } | null> {
133
+ if (!isDebugLogName(name)) return null
134
+ const target = join(pluginLogRoot(home), name)
135
+ const info = await stat(target).catch(() => null)
136
+ if (!info?.isFile()) return null
137
+ const text = await readFile(target, 'utf8').catch(() => null)
138
+ if (text === null) return null
139
+ const cut = Math.max(0, text.length - maxBytes)
140
+ return { name, path: target, content: cut > 0 ? text.slice(cut) : text, size: info.size, truncated: cut > 0 }
141
+ }
142
+
143
+ /**
144
+ * 清空一份诊断日志(truncate 到 0,保留文件本身)。
145
+ * @author ddj 2026年09月17号
146
+ * @param name 日志文件名(isDebugLogName 白名单校验)
147
+ * @param home DSH home(测试可注入)
148
+ * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
149
+ */
150
+ export async function clearDebugLog(name: string | null | undefined, home = dshHome()): Promise<boolean> {
151
+ if (!isDebugLogName(name)) return false
152
+ return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false)
84
153
  }
@@ -12,12 +12,13 @@ import { pathToFileURL } from 'node:url'
12
12
  import type { Ctx } from './store.js'
13
13
  import { KEYBINDING_DEFAULTS } from './shared/keybindings.js'
14
14
  import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
15
+ import { TORTOISE_DIR_DEFAULT } from './shared/svn.js'
15
16
  import type { AiConfigPatch, AiConfigView } from './shared/ai.js'
16
17
  import { log } from './log.js'
17
18
 
18
19
  export const FILE_OPEN_SETTINGS_NS = 'dsh-vscode-mode'
19
20
  export const FILE_OPEN_DEFAULT = 'auto'
20
- export interface FileOpenSettings { fileOpenTool: string; integrationBaseUrl: string }
21
+ export interface FileOpenSettings { fileOpenTool: string; integrationBaseUrl: string; svnPath: string; tortoisePath: string }
21
22
  export interface FileOpenSettingsState { value: string; revision?: number; update: (value: string, expectedRevision?: number) => Promise<void> }
22
23
 
23
24
  /** AI 内联补全配置默认值(默认关闭;路由空 = 自动;档位空 = 跟随模型默认)。 */
@@ -223,6 +224,18 @@ function baseValueOf(config: unknown): string {
223
224
  return typeof raw === 'string' && raw.trim() ? raw.trim() : INTEGRATION_BASE_DEFAULT
224
225
  }
225
226
 
227
+ /** 配置/设置里的 svn CLI 覆盖(空 = 从 PATH 解析 'svn')。 */
228
+ function svnPathValueOf(config: unknown): string {
229
+ const raw = (config as { svnPath?: unknown } | undefined)?.svnPath
230
+ return typeof raw === 'string' ? raw.trim() : ''
231
+ }
232
+
233
+ /** 配置/设置里的 TortoiseSVN 目录(空 = 默认安装目录)。 */
234
+ function tortoiseDirValueOf(config: unknown): string {
235
+ const raw = (config as { tortoisePath?: unknown } | undefined)?.tortoisePath
236
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : TORTOISE_DIR_DEFAULT
237
+ }
238
+
226
239
  /**
227
240
  * 安装设置 section(版本自适应;任一策略不可用返回 false 不抛错)。
228
241
  * @author ddj 2026年08月24号 / 2026年09月02号
@@ -257,6 +270,9 @@ export async function installOpenSettingsSection(
257
270
  aiProvider: deps.z.string().default(AI_CONFIG_DEFAULT.provider),
258
271
  aiModel: deps.z.string().default(AI_CONFIG_DEFAULT.model),
259
272
  aiEffort: deps.z.string().default(AI_CONFIG_DEFAULT.effort),
273
+ // SVN 能力:svn CLI 覆盖(空 = PATH)与 TortoiseSVN 目录(Windows 过渡增强)
274
+ svnPath: deps.z.string().default(''),
275
+ tortoisePath: deps.z.string().default(TORTOISE_DIR_DEFAULT),
260
276
  })
261
277
  const strategy = await runSettingsInstall(ctx, ns, schema, entry, {
262
278
  setSource: (source) => hooks.setSource(source as () => FileOpenSettings),
@@ -284,7 +300,7 @@ function aiValueOf(stored: unknown): { enabled: boolean; provider: string; model
284
300
  * @param onChange 设置变化回调
285
301
  * @returns 设置状态(fileOpenTool 值 + AI 补全配置读写)
286
302
  */
287
- export function setupOpenSettings(ctx: Ctx, config: unknown, onChange: (value: string) => void): FileOpenSettingsState & { ai: () => AiConfigView; aiUpdate: (patch: AiConfigPatch, expectedRevision?: number) => Promise<AiConfigView> } {
303
+ export function setupOpenSettings(ctx: Ctx, config: unknown, onChange: (value: string) => void): FileOpenSettingsState & { ai: () => AiConfigView; aiUpdate: (patch: AiConfigPatch, expectedRevision?: number) => Promise<AiConfigView>; svn: () => { svnPath: string; tortoisePath: string } } {
288
304
  let current = configValue(config)
289
305
  let revision: number | undefined
290
306
  let provider: SettingsProvider | undefined
@@ -304,10 +320,29 @@ export function setupOpenSettings(ctx: Ctx, config: unknown, onChange: (value: s
304
320
  aiCurrent = stored !== undefined ? aiValueOf(stored) : aiCurrent
305
321
  }
306
322
 
307
- void installOpenSettingsSection(ctx, FILE_OPEN_SETTINGS_NS, { fileOpenTool: current, integrationBaseUrl: baseValueOf(config) }, {
323
+ /** SVN 路径当前值(settings 未就绪回退配置值)。 */
324
+ let svnCurrent: { svnPath: string; tortoisePath: string } = {
325
+ svnPath: svnPathValueOf(config),
326
+ tortoisePath: tortoiseDirValueOf(config),
327
+ }
328
+ const svnSync = (stored: unknown): void => {
329
+ svnCurrent = { svnPath: svnPathValueOf(stored), tortoisePath: tortoiseDirValueOf(stored) }
330
+ }
331
+ /** 读取 settings 存储值(describe 未就绪返回 undefined)。 */
332
+ const storedValue = (): unknown => {
333
+ return provider?.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)?.value
334
+ }
335
+
336
+ void installOpenSettingsSection(ctx, FILE_OPEN_SETTINGS_NS, {
337
+ fileOpenTool: current,
338
+ integrationBaseUrl: baseValueOf(config),
339
+ svnPath: svnCurrent.svnPath,
340
+ tortoisePath: svnCurrent.tortoisePath,
341
+ }, {
308
342
  setSource: (source) => {
309
343
  notify(source().fileOpenTool)
310
344
  aiCurrent = aiValueOf(source())
345
+ svnSync(source())
311
346
  syncRevision()
312
347
  },
313
348
  onChange: settingsChange,
@@ -315,6 +350,7 @@ export function setupOpenSettings(ctx: Ctx, config: unknown, onChange: (value: s
315
350
  ctx.inject?.(['settings'], (settingsCtx: Ctx) => {
316
351
  provider = settingsCtx.get('settings')
317
352
  aiSync()
353
+ svnSync(storedValue())
318
354
  syncRevision()
319
355
  })
320
356
 
@@ -331,6 +367,7 @@ export function setupOpenSettings(ctx: Ctx, config: unknown, onChange: (value: s
331
367
  syncRevision()
332
368
  },
333
369
  ai: () => aiCurrent,
370
+ svn: () => svnCurrent,
334
371
  aiUpdate: async (patch: AiConfigPatch, expectedRevision?: number): Promise<AiConfigView> => {
335
372
  if (!provider?.update) {
336
373
  // settings 不可用:内存态生效(重启回落默认),保持与 fileOpenTool 的降级语义一致
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { createLspRpc } from './lsp/rpc.js'
26
26
  import { disposeAllServers, hookExitReclaim } from './lsp/transport.js'
27
27
  import { createAiRpc } from './ai/rpc.js'
28
28
  import { createFileVersions } from './fileVersions.js'
29
+ import { createSvnRpc } from './svn.js'
29
30
  import { installRulesSection } from './rules.js'
30
31
  import { installSkillGroup } from './skills.js'
31
32
  import type { RpcHandlerMap } from './shared/rpc.js'
@@ -66,6 +67,9 @@ export function apply(ctx: Ctx, config?: unknown): void {
66
67
  /** AI 内联补全 RPC(settings 状态桥接 + llm 惰性获取)。 */
67
68
  const aiRpc = createAiRpc({ ctx, settings: openSettings })
68
69
  const aiHandlers: Partial<RpcHandlerMap> = aiRpc.handlers
70
+ /** SVN RPC(检测/更新/Tortoise 发射;settings 提供 svnPath/tortoisePath)。 */
71
+ const svnRpc = createSvnRpc({ ctx, settings: openSettings })
72
+ const svnHandlers: Partial<RpcHandlerMap> = svnRpc.handlers as Partial<RpcHandlerMap>
69
73
  /** 文件磁盘新鲜度观察器(客户端轮询 edrv.versions;变化时顺带失效目录树缓存)。 */
70
74
  const fileVersions = createFileVersions(ctx)
71
75
 
@@ -86,7 +90,7 @@ export function apply(ctx: Ctx, config?: unknown): void {
86
90
  if (typeof sid === 'string') lspRpc.disposeSession(sid)
87
91
  })
88
92
 
89
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions), (warning) => warnings.push(warning))
93
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers), (warning) => warnings.push(warning))
90
94
  installIsolation(ctx)
91
95
  // 系统集成生命周期:启动自动恢复右键菜单注册(marker 存在时);插件卸载/reload 清理注册痕迹
92
96
  ctx.effect(() => shellMenuLifecycle(ctx))