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.
- package/README.md +32 -2
- package/lib/client.js +8854 -4023
- package/lib/client.js.map +1 -1
- package/lib/index.js +1842 -65
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/index.ts +3 -0
- package/src/client/monaco/lsp/providers.ts +28 -6
- package/src/client/navHighlight.ts +96 -0
- package/src/client/paths.ts +2 -0
- package/src/client/rpc.ts +32 -12
- package/src/client/sidebar/SidebarView.ts +6 -2
- package/src/client/sidebar/menuItems.ts +93 -2
- package/src/client/sidebar/panels/FileExplorer.ts +18 -1
- package/src/client/sidebar/panels/SvnPanel.ts +303 -0
- package/src/client/sidebar/panels/index.ts +44 -3
- package/src/client/sidebar/types.ts +20 -0
- package/src/client/styles/editor.css +177 -0
- package/src/client/svnActions.ts +153 -0
- package/src/client/svnLog.ts +356 -0
- package/src/client/svnStatus.ts +363 -0
- package/src/client/svnStore.ts +150 -0
- package/src/client/tabMenu.ts +51 -0
- package/src/client/ui/EditorView.ts +583 -24
- package/src/client/ui/LogDialog.ts +333 -0
- package/src/client/ui/McpSettings.ts +98 -0
- package/src/client/ui/ModalShell.ts +79 -0
- package/src/client/ui/SideBySideDiff.tsx +139 -0
- package/src/client/ui/SnippetsPicker.ts +70 -58
- package/src/client/ui/SvnDiffPanel.ts +50 -0
- package/src/client/ui/SvnLogDialog.ts +1141 -0
- package/src/client/ui/SvnLogStats.ts +243 -0
- package/src/client/ui/commandCatalog.ts +72 -0
- package/src/debugLog.ts +81 -12
- package/src/fileOpenSettings.ts +40 -3
- package/src/index.ts +5 -1
- package/src/rpc.ts +41 -4
- package/src/shared/rpc.ts +53 -1
- package/src/shared/svn.ts +377 -0
- package/src/shared/svnActions.ts +296 -0
- package/src/svn.ts +870 -0
- package/src/svnLog.ts +315 -0
- package/src/svnText.ts +175 -0
- package/src/svnXml.ts +110 -0
package/src/svnLog.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — SVN 日志解析(`svn log --xml -v`)。
|
|
3
|
+
*
|
|
4
|
+
* 实测要点(svn 1.14.5):
|
|
5
|
+
* - **默认范围是 `BASE:1`**:工作副本落后时几乎拿不到日志,调用方必须显式 `-r HEAD:1`。
|
|
6
|
+
* - path 节点给的是**仓库绝对路径**(`/trunk/sub/x.txt`),不是工作副本相对路径;
|
|
7
|
+
* 要落成工作区相对路径必须结合 `svn info --xml` 的 `<relative-url>`(如 `^/trunk`)剥前缀,
|
|
8
|
+
* 不在前缀内的(分支/外部路径)标为 null,由 UI 显示为「仓库外路径」而不是硬拼。
|
|
9
|
+
* - `<path>` 带 `action` / `kind` / `copyfrom-path` / `copyfrom-rev` / `prop-mods` / `text-mods`。
|
|
10
|
+
* - `<msg>` 是多行文本节点(不是属性),需保留换行。
|
|
11
|
+
* 作者 ddj 2026年09月16号
|
|
12
|
+
*/
|
|
13
|
+
import type { SvnLogEntry, SvnLogPath } from './shared/svn.js'
|
|
14
|
+
import { attrOf, numAttrOf, textOf, unescapeXml } from './svnXml.js'
|
|
15
|
+
|
|
16
|
+
/** 日志条目上限(防超大仓库撑爆载荷)。 */
|
|
17
|
+
export const LOG_CAP = 500
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Show All(P1-7)专用条目上限:比默认 `LOG_CAP` 高一个量级,仍留护栏。
|
|
21
|
+
*
|
|
22
|
+
* 取舍依据(蓝图 §5 P1-7):真全量(省略 `-l`)在超大仓库会拉取上百 MB XML,
|
|
23
|
+
* 且 DSH 输出收集器超限**保留尾部**(见 `svn.ts` `LOG_OUTPUT_CAP` 教训)→ 宁可有界。
|
|
24
|
+
* 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 5000` → 5000 条 /
|
|
25
|
+
* 18.86MB / 30.1s;根路径 `-g` 300 条全历史 > 600s 未返回(真全量 + `-g` 不可行)。
|
|
26
|
+
*/
|
|
27
|
+
export const SVN_LOG_SHOW_ALL_CAP = 5000
|
|
28
|
+
|
|
29
|
+
/** 默认拉取条数(对齐 TortoiseSVN 默认 100 条)。 */
|
|
30
|
+
export const LOG_DEFAULT_LIMIT = 100
|
|
31
|
+
|
|
32
|
+
/** 合法日志动作字母。 */
|
|
33
|
+
const LOG_ACTIONS: ReadonlySet<string> = new Set(['A', 'D', 'R', 'M'])
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* merged 条目的最大解析深度(P1-5):实测最深 1 层,更深递归未观察到,
|
|
37
|
+
* 故按「可能更深」实现递归但有界,防病态输入无限下钻。
|
|
38
|
+
*/
|
|
39
|
+
const LOG_MERGE_DEPTH = 8
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 把仓库绝对路径映射为工作区相对路径。
|
|
43
|
+
*
|
|
44
|
+
* `relativeUrl` 形如 `^/trunk`(来自 `svn info --xml`);仓库路径 `/trunk/sub/x.txt`
|
|
45
|
+
* 剥前缀后得 `sub/x.txt`。注意 relative-url 是 **URL 编码形态**(实测:空格 = `%20`、
|
|
46
|
+
* 中文 = `%E9..`),而 log --xml 的 path 是明文,比对前必须先解码。
|
|
47
|
+
* 前缀不完全匹配(跨分支/外部定义)返回 null——宁可在 UI 标注「仓库外」也不要硬拼出
|
|
48
|
+
* 错误路径导致打开失败。
|
|
49
|
+
*
|
|
50
|
+
* @author ddj 2026年09月16号 / 2026年09月17号
|
|
51
|
+
* @param repoPath 仓库绝对路径(`/trunk/...`)
|
|
52
|
+
* @param relativeUrl 工作副本根的 relative-url(`^/trunk`,URL 编码形态)
|
|
53
|
+
* @returns 工作区相对路径;不在根下返回 null
|
|
54
|
+
*/
|
|
55
|
+
export function mapRepoPath(repoPath: string, relativeUrl: string): string | null {
|
|
56
|
+
const repo = String(repoPath ?? '').replace(/\/+$/, '')
|
|
57
|
+
const encoded = String(relativeUrl ?? '').replace(/^\^/, '').replace(/\/+$/, '')
|
|
58
|
+
let prefix = encoded
|
|
59
|
+
try {
|
|
60
|
+
prefix = decodeURIComponent(encoded)
|
|
61
|
+
} catch (e) { /* 含非法 % 序列:回落编码原值比对 */ }
|
|
62
|
+
if (!repo) return null
|
|
63
|
+
// 无 relative-url(未探测到)时不做映射,交由调用方标注
|
|
64
|
+
if (!prefix) return null
|
|
65
|
+
if (repo === prefix) return ''
|
|
66
|
+
if (!repo.startsWith(prefix + '/')) return null
|
|
67
|
+
return repo.slice(prefix.length + 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 解析 `svn log --xml -v` 输出为日志条目列表。
|
|
72
|
+
*
|
|
73
|
+
* 实现:顺序扫描(非按块切分)——`parseLogEntry` 会连**嵌套内容**一起消费,
|
|
74
|
+
* 并返回本条结束位置,故顶层循环天然只收顶层条目,嵌套归各自的父条目。
|
|
75
|
+
* 每条内取 author/date/msg 与 `<paths>` 段;`<path attrs>文本</path>` 的属性在标签上、
|
|
76
|
+
* 路径在标签之间。闭标签不得当作新条目(P2 在 status 解析上踩过同一个坑)。
|
|
77
|
+
*
|
|
78
|
+
* P1-5(`-g`):被合并的修订是**嵌套 `<logentry>`**(位于父条目 `</msg>` 之后,可并列多个),
|
|
79
|
+
* 收进父条目的 `merged`;`cap` 只约束顶层条目(实测 `-l` 亦只约束顶层,总条数可超 `-l`)。
|
|
80
|
+
*
|
|
81
|
+
* @author ddj 2026年09月16号 / 2026年09月18号
|
|
82
|
+
* @param xml log --xml 的 stdout
|
|
83
|
+
* @param relativeUrl 工作副本根的 relative-url(用于路径映射;缺省则不映射)
|
|
84
|
+
* @param cap 顶层条目上限
|
|
85
|
+
* @returns 日志条目与是否截断
|
|
86
|
+
*/
|
|
87
|
+
export function parseLogXml(
|
|
88
|
+
xml: string,
|
|
89
|
+
relativeUrl = '',
|
|
90
|
+
cap: number = LOG_CAP,
|
|
91
|
+
): { entries: SvnLogEntry[]; truncated: boolean } {
|
|
92
|
+
const entries: SvnLogEntry[] = []
|
|
93
|
+
let truncated = false
|
|
94
|
+
if (typeof xml !== 'string' || !xml) return { entries, truncated }
|
|
95
|
+
let from = xml.indexOf('<logentry')
|
|
96
|
+
while (from >= 0) {
|
|
97
|
+
if (entries.length >= cap) { truncated = true; break }
|
|
98
|
+
const scanned = parseLogEntry(xml, from, relativeUrl, 0)
|
|
99
|
+
if (!scanned) break
|
|
100
|
+
entries.push(scanned.entry)
|
|
101
|
+
from = xml.indexOf('<logentry', scanned.end)
|
|
102
|
+
}
|
|
103
|
+
return { entries, truncated }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 解析 `at` 处的一条 logentry(连嵌套内容一起消费;P1-5 递归 merged)。
|
|
108
|
+
*
|
|
109
|
+
* @author ddj 2026年09月18号
|
|
110
|
+
* @param xml 日志 XML 全文
|
|
111
|
+
* @param at `<logentry` 的起始下标
|
|
112
|
+
* @param relativeUrl 工作副本根的 relative-url
|
|
113
|
+
* @param depth 当前嵌套深度(顶层 0)
|
|
114
|
+
* @returns 条目与「本条结束下标」;起始处不是 logentry 时返回 null
|
|
115
|
+
*/
|
|
116
|
+
function parseLogEntry(xml: string, at: number, relativeUrl: string, depth: number): { entry: SvnLogEntry; end: number } | null {
|
|
117
|
+
const attrs = tagTailOf(xml, at, 'logentry')
|
|
118
|
+
if (attrs === null) return null
|
|
119
|
+
const revision = numAttrOf(attrs, 'revision')
|
|
120
|
+
if (revision === undefined) return null
|
|
121
|
+
const bodyFrom = at + '<logentry'.length + attrs.length + 1
|
|
122
|
+
let end = entryEndOf(xml, bodyFrom, depth)
|
|
123
|
+
const body = xml.slice(bodyFrom, end)
|
|
124
|
+
const entry: SvnLogEntry = {
|
|
125
|
+
revision,
|
|
126
|
+
author: textOf(body, 'author'),
|
|
127
|
+
date: textOf(body, 'date'),
|
|
128
|
+
message: textOf(body, 'msg'),
|
|
129
|
+
paths: parseLogPaths(body, relativeUrl),
|
|
130
|
+
}
|
|
131
|
+
// 顶层从不带 reverse-merge;嵌套条目按可能存在 true 处理(实测只采到 false)
|
|
132
|
+
const reverse = attrOf(attrs, 'reverse-merge')
|
|
133
|
+
if (reverse) entry.reverseMerge = reverse === 'true'
|
|
134
|
+
if (depth < LOG_MERGE_DEPTH) {
|
|
135
|
+
const nested = parseMergedOf(xml, bodyFrom, end, relativeUrl, depth)
|
|
136
|
+
if (nested.list.length) entry.merged = nested.list
|
|
137
|
+
end = nested.end
|
|
138
|
+
}
|
|
139
|
+
return { entry, end }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 取 `at` 处标签的属性串(`<name` 之后到首个 `>` 之间)。
|
|
144
|
+
* @author ddj 2026年09月18号
|
|
145
|
+
* @param xml XML 全文
|
|
146
|
+
* @param at `<name` 的起始下标
|
|
147
|
+
* @param name 标签名
|
|
148
|
+
* @returns 属性串(已 trim);该处不是该标签的开标签时返回 null
|
|
149
|
+
*/
|
|
150
|
+
function tagTailOf(xml: string, at: number, name: string): string | null {
|
|
151
|
+
if (!xml.startsWith('<' + name, at)) return null
|
|
152
|
+
const gt = xml.indexOf('>', at)
|
|
153
|
+
if (gt < 0) return null
|
|
154
|
+
const tag = xml.slice(at + 1 + name.length, gt)
|
|
155
|
+
return tag.trim().endsWith('/') ? null : tag
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 求条目结束下标:从正文起点扫描,跳过全部嵌套条目所用的闭标签。
|
|
160
|
+
*
|
|
161
|
+
* 实现按「先看下一个 `<logentry` 起点、再看当前深度的 `</logentry>`」取舍,
|
|
162
|
+
* 因而支持更深层级(深层闭标签由深层那次递归消费,不会提前终止本层)。
|
|
163
|
+
* @author ddj 2026年09月18号
|
|
164
|
+
* @param xml XML 全文
|
|
165
|
+
* @param from 正文起点(开标签 `>` 之后)
|
|
166
|
+
* @param depth 本条目深度
|
|
167
|
+
* @returns 本条 `</logentry>` 之后的绝对下标
|
|
168
|
+
*/
|
|
169
|
+
function entryEndOf(xml: string, from: number, depth: number): number {
|
|
170
|
+
let scan = from
|
|
171
|
+
let level = depth
|
|
172
|
+
while (scan < xml.length) {
|
|
173
|
+
const openAt = xml.indexOf('<logentry', scan)
|
|
174
|
+
const closeAt = xml.indexOf('</logentry>', scan)
|
|
175
|
+
if (closeAt < 0) return xml.length
|
|
176
|
+
if (openAt >= 0 && openAt < closeAt) {
|
|
177
|
+
const attrs = tagTailOf(xml, openAt, 'logentry')
|
|
178
|
+
// 深层条目的结束下标:递归求得更深层的闭标签位置
|
|
179
|
+
if (attrs === null) { scan = openAt + '<logentry'.length; continue }
|
|
180
|
+
scan = entryEndOf(xml, openAt + '<logentry'.length + attrs.length + 1, level + 1)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (level <= depth) return closeAt + '</logentry>'.length
|
|
184
|
+
level -= 1
|
|
185
|
+
scan = closeAt + '</logentry>'.length
|
|
186
|
+
}
|
|
187
|
+
return xml.length
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 解析块内全部并列嵌套 logentry(P1-5);`end` 随之推进到本条目结束处。
|
|
192
|
+
*
|
|
193
|
+
* @author ddj 2026年09月18号
|
|
194
|
+
* @param xml XML 全文
|
|
195
|
+
* @param from 正文起点(开标签 `>` 之后)
|
|
196
|
+
* @param end 本条目结束下标(深度扫描的结果)
|
|
197
|
+
* @param relativeUrl 工作副本根的 relative-url
|
|
198
|
+
* @param depth 父条目深度
|
|
199
|
+
* @returns 嵌套条目列表与推进后的结束下标
|
|
200
|
+
*/
|
|
201
|
+
function parseMergedOf(xml: string, from: number, end: number, relativeUrl: string, depth: number): { list: SvnLogEntry[]; end: number } {
|
|
202
|
+
const list: SvnLogEntry[] = []
|
|
203
|
+
let scan = from
|
|
204
|
+
while (scan < end) {
|
|
205
|
+
const openAt = xml.indexOf('<logentry', scan)
|
|
206
|
+
if (openAt < 0 || openAt >= end) break
|
|
207
|
+
const nested = parseLogEntry(xml, openAt, relativeUrl, depth + 1)
|
|
208
|
+
if (!nested) { scan = openAt + '<logentry'.length; continue }
|
|
209
|
+
list.push(nested.entry)
|
|
210
|
+
scan = nested.end
|
|
211
|
+
end = Math.max(end, nested.end)
|
|
212
|
+
}
|
|
213
|
+
return { list, end }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 解析单条日志的 `<paths>` 段为变更路径列表。
|
|
218
|
+
* @author ddj 2026年09月16号
|
|
219
|
+
* @param block 单条 logentry 原文
|
|
220
|
+
* @param relativeUrl 工作副本根的 relative-url
|
|
221
|
+
* @returns 变更路径列表
|
|
222
|
+
*/
|
|
223
|
+
function parseLogPaths(block: string, relativeUrl: string): SvnLogPath[] {
|
|
224
|
+
const section = /<paths>([\s\S]*?)<\/paths>/.exec(block)
|
|
225
|
+
if (!section) return []
|
|
226
|
+
const out: SvnLogPath[] = []
|
|
227
|
+
// 一次正则拿到「属性串 + 文本内容」两段(path 为文本节点而非属性)
|
|
228
|
+
const re = /<path\b([^>]*?)\/?>([\s\S]*?)<\/path>/g
|
|
229
|
+
let m: RegExpExecArray | null
|
|
230
|
+
while ((m = re.exec(section[1])) !== null) {
|
|
231
|
+
const attrs = m[1]
|
|
232
|
+
const rawPath = unescapeXml(m[2]).trim()
|
|
233
|
+
if (!rawPath) continue
|
|
234
|
+
const action = attrOf(attrs, 'action')
|
|
235
|
+
out.push({
|
|
236
|
+
path: rawPath,
|
|
237
|
+
relPath: mapRepoPath(rawPath, relativeUrl),
|
|
238
|
+
action: (LOG_ACTIONS.has(action) ? action : 'M') as SvnLogPath['action'],
|
|
239
|
+
kind: attrOf(attrs, 'kind') || undefined,
|
|
240
|
+
copyFrom: attrOf(attrs, 'copyfrom-path') || undefined,
|
|
241
|
+
copyFromRev: numAttrOf(attrs, 'copyfrom-rev'),
|
|
242
|
+
propMods: attrOf(attrs, 'prop-mods') === 'true',
|
|
243
|
+
textMods: attrOf(attrs, 'text-mods') === 'true',
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 可排序键(P0-12):版本号 / 作者 / 日期 / 信息首行。
|
|
251
|
+
*/
|
|
252
|
+
export type LogSortKey = 'revision' | 'author' | 'date' | 'message'
|
|
253
|
+
|
|
254
|
+
/** 排序方向。 */
|
|
255
|
+
export type LogSortDir = 'asc' | 'desc'
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 日志条目排序(P0-12/D31,纯函数;默认 revision desc = 后端原序语义)。
|
|
259
|
+
* - author/message 用 localeCompare(中英混排稳定);message 按首行排序,与显示一致
|
|
260
|
+
* - date 按解析时间戳;解析失败的行恒定沉底(不随方向翻转,避免 NaN 污染)
|
|
261
|
+
* @author ddj 2026年09月17号
|
|
262
|
+
* @param entries 条目(只读)
|
|
263
|
+
* @param key 排序键
|
|
264
|
+
* @param dir 方向
|
|
265
|
+
* @returns 新数组(不改动入参)
|
|
266
|
+
*/
|
|
267
|
+
export function orderLogEntries(entries: readonly SvnLogEntry[], key: LogSortKey = 'revision', dir: LogSortDir = 'desc'): SvnLogEntry[] {
|
|
268
|
+
const out = entries.slice()
|
|
269
|
+
if (key === 'revision') {
|
|
270
|
+
out.sort((a, b) => (dir === 'asc' ? a.revision - b.revision : b.revision - a.revision))
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
const sign = dir === 'asc' ? 1 : -1
|
|
274
|
+
out.sort((a, b) => {
|
|
275
|
+
if (key === 'date') {
|
|
276
|
+
const at = new Date(String(a.date ?? '')).getTime()
|
|
277
|
+
const bt = new Date(String(b.date ?? '')).getTime()
|
|
278
|
+
if (Number.isNaN(at) && Number.isNaN(bt)) return 0
|
|
279
|
+
if (Number.isNaN(at)) return 1 // 解析失败恒沉底
|
|
280
|
+
if (Number.isNaN(bt)) return -1
|
|
281
|
+
return sign * (at - bt)
|
|
282
|
+
}
|
|
283
|
+
const av = sortTextOf(a, key)
|
|
284
|
+
const bv = sortTextOf(b, key)
|
|
285
|
+
return sign * av.localeCompare(bv)
|
|
286
|
+
})
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 排序用的文本值(message 取首行,与列表显示一致)。
|
|
292
|
+
* @author ddj 2026年09月17号
|
|
293
|
+
* @param entry 条目
|
|
294
|
+
* @param key 排序键(author/message)
|
|
295
|
+
* @returns 文本
|
|
296
|
+
*/
|
|
297
|
+
function sortTextOf(entry: SvnLogEntry, key: LogSortKey): string {
|
|
298
|
+
if (key === 'message') return String(entry.message ?? '').split(/\r?\n/)[0] ?? ''
|
|
299
|
+
return String(entry.author ?? '')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 从 `svn info --xml` 输出解析工作副本版号(P1-9;目录目标返回 null——版号加粗仅对文件目标)。
|
|
304
|
+
* @author ddj 2026年09月17号
|
|
305
|
+
* @param xml svn info --xml 输出
|
|
306
|
+
* @returns 工作副本版号(无 commit 节点或目录 = null)
|
|
307
|
+
*/
|
|
308
|
+
export function parseSvnInfoRevision(xml: string): number | null {
|
|
309
|
+
const text = String(xml ?? '')
|
|
310
|
+
if (!text.includes('<entry')) return null
|
|
311
|
+
const kind = /<entry[^>]*\bkind="([^"]*)"/.exec(text)?.[1] ?? ''
|
|
312
|
+
if (kind === 'dir') return null
|
|
313
|
+
const at = /<commit[^>]*\brevision="(\d+)"/.exec(text)
|
|
314
|
+
return at ? Number(at[1]) : null
|
|
315
|
+
}
|
package/src/svnText.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — SVN **文本输出**解析(svn update/revert/add/commit 均无 `--xml`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:这些子命令只给人类可读文本,解析必须「宽松 + 原文兜底」,
|
|
5
|
+
* 且共用同一批基元(退出码判定、输出合并/截断、结果行字母表)。与 svnXml.ts 的区别:
|
|
6
|
+
* 这里没有结构化格式可用,只能按行首字母与摘要行解析,故全部可注入/可单测。
|
|
7
|
+
*
|
|
8
|
+
* 本地化注意:实测中英文输出都要容忍(「Updated to revision N」/「已更新到版本 N」),
|
|
9
|
+
* 无法识别时一律保留原文而不猜。
|
|
10
|
+
* 作者 ddj 2026年09月16号
|
|
11
|
+
*/
|
|
12
|
+
import type { SvnActionResult, SvnUpdateEntry, SvnUpdateResult } from './shared/svn.js'
|
|
13
|
+
|
|
14
|
+
/** 输出载荷上限(1MB,与 runSvn stdio 限额一致)。 */
|
|
15
|
+
export const TEXT_OUTPUT_CAP = 1 << 20
|
|
16
|
+
/** 冲突清单上限(防异常输出撑爆载荷)。 */
|
|
17
|
+
export const TEXT_CONFLICT_CAP = 64
|
|
18
|
+
/** 条目级结果上限(防超大工作副本 update 撑爆载荷)。 */
|
|
19
|
+
export const TEXT_ENTRY_CAP = 4000
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 命令输出是否成功。
|
|
23
|
+
* @author ddj 2026年09月16号
|
|
24
|
+
* @param code 退出码
|
|
25
|
+
* @returns 是否成功
|
|
26
|
+
*/
|
|
27
|
+
export function isOkCode(code: number | null): boolean {
|
|
28
|
+
return code === 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 合并 stdout/stderr 为单一诊断文本(去空、截断到上限)。
|
|
33
|
+
* @author ddj 2026年09月16号
|
|
34
|
+
* @param stdout 标准输出
|
|
35
|
+
* @param stderr 标准错误
|
|
36
|
+
* @param cap 上限
|
|
37
|
+
* @returns 合并文本
|
|
38
|
+
*/
|
|
39
|
+
export function mergeOutput(stdout: string, stderr: string, cap: number = TEXT_OUTPUT_CAP): string {
|
|
40
|
+
const output = [stdout, stderr].filter((item) => item && item.trim()).join('\n').trim()
|
|
41
|
+
return output.slice(0, cap)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 取文本尾部(错误提示只带末尾关键信息)。
|
|
46
|
+
* @author ddj 2026年09月16号
|
|
47
|
+
* @param text 原始文本
|
|
48
|
+
* @param max 保留长度
|
|
49
|
+
* @returns 末尾片段
|
|
50
|
+
*/
|
|
51
|
+
export function tailOfText(text: string, max = 400): string {
|
|
52
|
+
const trim = String(text ?? '').trim()
|
|
53
|
+
if (trim.length <= max) return trim || '无输出'
|
|
54
|
+
return '…' + trim.slice(-max)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* svn update 的逐条结果行状态字母表(`svn help update` 的「A Added / U Updated …」)。
|
|
59
|
+
* 保留中文字面量以覆盖本地化输出;未识别字母不生成条目(宁缺勿错)。
|
|
60
|
+
*/
|
|
61
|
+
const UPDATE_ACTION_LABEL: Record<string, string> = {
|
|
62
|
+
A: '已添加',
|
|
63
|
+
U: '已更新',
|
|
64
|
+
D: '已删除',
|
|
65
|
+
G: '已合并',
|
|
66
|
+
C: '冲突',
|
|
67
|
+
E: '已存在',
|
|
68
|
+
R: '已替换',
|
|
69
|
+
' ': '无变化',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** update 结果行:行首若干空白 + 单字母 + 空白 + 路径(路径可含空格,取整段)。 */
|
|
73
|
+
const UPDATE_LINE_RE = /^([ADGCEUR]|\s{3})\s{2,}(\S.*)$/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 解析 `svn update` 文本输出为**条目级**结构化结果(附冲突清单与一行摘要)。
|
|
77
|
+
*
|
|
78
|
+
* 为什么不能只给摘要:update 是「逐文件状态」语义,用户需要看到哪些文件被更新、
|
|
79
|
+
* 哪些冲突(实测冲突行 `C conf.txt` + 末尾 `Summary of conflicts:` 段)。
|
|
80
|
+
* update 不接受 `--xml`(实测:`Subcommand 'update' doesn't accept option '--xml'`),
|
|
81
|
+
* 故只能文本解析;解析不出条目时保留 revision/冲突摘要等既有字段,保证不退化。
|
|
82
|
+
*
|
|
83
|
+
* @author ddj 2026年09月16号
|
|
84
|
+
* @param stdout 标准输出
|
|
85
|
+
* @param stderr 标准错误
|
|
86
|
+
* @param code 退出码
|
|
87
|
+
* @returns 结构化结果(entries 为条目级明细;conflicts 为冲突路径清单)
|
|
88
|
+
*/
|
|
89
|
+
export function updateResultOf(stdout: string, stderr: string, code: number | null): SvnUpdateResult {
|
|
90
|
+
const output = mergeOutput(stdout, stderr)
|
|
91
|
+
// 修订版号:英文 `Updated to revision N` / 中文「已更新到版本 N」;取最后一处(汇总行)
|
|
92
|
+
const matches = [...output.matchAll(/(?:revision|版本)\s*\.?\s*(\d+)/gi)]
|
|
93
|
+
const revision = matches.length ? Number(matches[matches.length - 1][1]) : undefined
|
|
94
|
+
const entries: SvnUpdateEntry[] = []
|
|
95
|
+
const conflicts: string[] = []
|
|
96
|
+
for (const line of output.split(/\r?\n/)) {
|
|
97
|
+
// 条目行(`U a.txt` / `C conf.txt` / `A new.txt`)
|
|
98
|
+
const hit = UPDATE_LINE_RE.exec(line)
|
|
99
|
+
if (hit) {
|
|
100
|
+
const action = hit[1].trim()
|
|
101
|
+
const path = hit[2].trim()
|
|
102
|
+
if (path && entries.length < TEXT_ENTRY_CAP) {
|
|
103
|
+
entries.push({
|
|
104
|
+
action: action as SvnUpdateEntry['action'],
|
|
105
|
+
path,
|
|
106
|
+
label: UPDATE_ACTION_LABEL[action] ?? action,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
if (action === 'C' && conflicts.length < TEXT_CONFLICT_CAP) conflicts.push(path)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
// 兼容 P1 的宽松口径:属性列冲突(` M C f` 等)也算冲突
|
|
113
|
+
const loose = /^(?:\s?[A-Z_ ]?C)\s+(\S.*)$/.exec(line)
|
|
114
|
+
if (loose && conflicts.length < TEXT_CONFLICT_CAP) {
|
|
115
|
+
const path = loose[1].trim()
|
|
116
|
+
if (!conflicts.includes(path)) conflicts.push(path)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 冲突摘要段(`Summary of conflicts:` / 中文本地化)作为兜底:段落里的数字不当作路径
|
|
120
|
+
const conflictText = conflicts.length ? ',冲突 ' + conflicts.length + ' 项' : ''
|
|
121
|
+
const summary = revision !== undefined
|
|
122
|
+
? '已更新到修订版 r' + revision + conflictText
|
|
123
|
+
: '更新完成' + conflictText
|
|
124
|
+
void code
|
|
125
|
+
return { summary, revision, conflicts, entries, output }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析 revert/add/commit 等批量命令输出为「计数 + 摘要」。
|
|
130
|
+
* @author ddj 2026年09月16号
|
|
131
|
+
* @param stdout 标准输出
|
|
132
|
+
* @param stderr 标准错误
|
|
133
|
+
* @param code 退出码
|
|
134
|
+
* @param verb 动作动词(中文摘要用)
|
|
135
|
+
* @returns 动作结果载荷
|
|
136
|
+
*/
|
|
137
|
+
export function batchResultOf(
|
|
138
|
+
stdout: string,
|
|
139
|
+
stderr: string,
|
|
140
|
+
code: number | null,
|
|
141
|
+
verb: string,
|
|
142
|
+
): SvnActionResult {
|
|
143
|
+
const output = mergeOutput(stdout, stderr)
|
|
144
|
+
const count = (stdout.match(/^(?:Reverted|A|Adding|Skipped|Deleting|D)\b/gm) ?? []).length
|
|
145
|
+
const failed = !isOkCode(code)
|
|
146
|
+
const summary = failed
|
|
147
|
+
? verb + '失败' + (output ? ':' + tailOfText(output) : '')
|
|
148
|
+
: verb + '完成' + (count ? '(' + count + ' 项)' : '')
|
|
149
|
+
return { count, summary, output }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 解析 `svn commit` 输出(取新版本号;宽松中英文)。
|
|
154
|
+
* @author ddj 2026年09月16号
|
|
155
|
+
* @param stdout 标准输出
|
|
156
|
+
* @param stderr 标准错误
|
|
157
|
+
* @param code 退出码
|
|
158
|
+
* @param verb 动作动词
|
|
159
|
+
* @returns 动作结果 + 新修订版号(成功且可解析时)
|
|
160
|
+
*/
|
|
161
|
+
export function commitResultOf(
|
|
162
|
+
stdout: string,
|
|
163
|
+
stderr: string,
|
|
164
|
+
code: number | null,
|
|
165
|
+
verb = '提交',
|
|
166
|
+
): SvnActionResult & { revision?: number } {
|
|
167
|
+
const base = batchResultOf(stdout, stderr, code, verb)
|
|
168
|
+
const output = base.output
|
|
169
|
+
const matches = [...output.matchAll(/(?:Committed revision|提交后的版本为|提交.*?版本)\s*\.?\s*(\d+)/gi)]
|
|
170
|
+
const revision = matches.length ? Number(matches[matches.length - 1][1]) : undefined
|
|
171
|
+
const summary = isOkCode(code) && revision !== undefined
|
|
172
|
+
? verb + '成功:r' + revision
|
|
173
|
+
: base.summary
|
|
174
|
+
return { ...base, revision, summary }
|
|
175
|
+
}
|
package/src/svnXml.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — SVN XML 解析基元(零第三方依赖,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:svn 的 `--xml` 输出结构简单且高度同形(`<entry attr="...">` 族 +
|
|
5
|
+
* 文本节点),status/log/info 三处解析共用同一套「实体反转义 + 取属性 + 单趟标签扫描」,
|
|
6
|
+
* 各自实现会重复踩同一批坑(实测两次:闭标签被当成新条目、路径含 `&` 需反转义)。
|
|
7
|
+
*
|
|
8
|
+
* 设计约束:不引 XML 库——输出有上限保护,且只需识别少数字段,正则单趟扫描足够且可确定测试。
|
|
9
|
+
* 作者 ddj 2026年09月16号
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** XML 实体表(`&` 等命名实体)。 */
|
|
13
|
+
const NAMED_ENTITY: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* XML 实体反转义(命名实体 + 十进制/十六进制数字引用)。
|
|
17
|
+
* 实测场景:工作副本路径含 `&` 时 svn 输出 `a&b.txt`,不反转义会把实体写进 UI。
|
|
18
|
+
* 无法识别的引用原样保留(不猜)。
|
|
19
|
+
* @author ddj 2026年09月16号
|
|
20
|
+
* @param text 原始文本
|
|
21
|
+
* @returns 反转义后的文本
|
|
22
|
+
*/
|
|
23
|
+
export function unescapeXml(text: string): string {
|
|
24
|
+
if (typeof text !== 'string' || !text.includes('&')) return text ?? ''
|
|
25
|
+
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body: string) => {
|
|
26
|
+
if (body.startsWith('#x') || body.startsWith('#X')) {
|
|
27
|
+
const code = Number.parseInt(body.slice(2), 16)
|
|
28
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole
|
|
29
|
+
}
|
|
30
|
+
if (body.startsWith('#')) {
|
|
31
|
+
const code = Number.parseInt(body.slice(1), 10)
|
|
32
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole
|
|
33
|
+
}
|
|
34
|
+
return NAMED_ENTITY[body] ?? whole
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 取标签属性值(`path="..."`;未命中返回空串)。属性值自动反转义。
|
|
40
|
+
* @author ddj 2026年09月16号
|
|
41
|
+
* @param tag 标签原文(含属性部分)
|
|
42
|
+
* @param name 属性名
|
|
43
|
+
* @returns 属性值;缺失返回 ''
|
|
44
|
+
*/
|
|
45
|
+
export function attrOf(tag: string, name: string): string {
|
|
46
|
+
const hit = new RegExp(name + '\\s*=\\s*"([^"]*)"').exec(tag)
|
|
47
|
+
return hit ? unescapeXml(hit[1]) : ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 取标签的文本内容(`<msg>...</msg>` 形式,支持换行)。
|
|
52
|
+
* 与 attrOf 互补:SVN 的提交信息/作者/日期是文本节点而非属性。
|
|
53
|
+
* @author ddj 2026年09月16号
|
|
54
|
+
* @param xml 源文本
|
|
55
|
+
* @param tag 标签名
|
|
56
|
+
* @returns 反转义后的内容;缺失返回 ''
|
|
57
|
+
*/
|
|
58
|
+
export function textOf(xml: string, tag: string): string {
|
|
59
|
+
const hit = new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>').exec(xml)
|
|
60
|
+
return hit ? unescapeXml(hit[1]) : ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 一条标签扫描命中。 */
|
|
64
|
+
export interface XmlTagHit {
|
|
65
|
+
/** 标签名。 */
|
|
66
|
+
name: string
|
|
67
|
+
/** 是否闭合标签(`</x>`)。 */
|
|
68
|
+
closing: boolean
|
|
69
|
+
/** 是否自闭合(`<x/>`)。 */
|
|
70
|
+
selfClosing: boolean
|
|
71
|
+
/** 属性原文(未反转义)。 */
|
|
72
|
+
attrs: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 单趟扫描指定标签族(只识别 name 集合内的标签,其余忽略)。
|
|
77
|
+
*
|
|
78
|
+
* 调用方须自行处理「闭标签不等于新条目」——`<wc-status>`/`<logentry>` 的闭标签无属性,
|
|
79
|
+
* 若当成新条目会产出多余的空记录(P2 实测踩过)。
|
|
80
|
+
*
|
|
81
|
+
* @author ddj 2026年09月16号
|
|
82
|
+
* @param xml 源文本
|
|
83
|
+
* @param names 关注的标签名集合
|
|
84
|
+
* @returns 命中序列
|
|
85
|
+
*/
|
|
86
|
+
export function scanXmlTags(xml: string, names: readonly string[]): XmlTagHit[] {
|
|
87
|
+
if (typeof xml !== 'string' || !xml || !names.length) return []
|
|
88
|
+
const alternation = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
89
|
+
const re = new RegExp('<(/?)(' + alternation + ')\\b([^>]*?)(/?)>', 'g')
|
|
90
|
+
const hits: XmlTagHit[] = []
|
|
91
|
+
let match: RegExpExecArray | null
|
|
92
|
+
while ((match = re.exec(xml)) !== null) {
|
|
93
|
+
hits.push({ closing: match[1] === '/', name: match[2], attrs: match[3], selfClosing: match[4] === '/' })
|
|
94
|
+
}
|
|
95
|
+
return hits
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 解析数字属性(`revision="123"`);非法/缺失返回 undefined。
|
|
100
|
+
* @author ddj 2026年09月16号
|
|
101
|
+
* @param tag 标签原文
|
|
102
|
+
* @param name 属性名
|
|
103
|
+
* @returns 数字或 undefined
|
|
104
|
+
*/
|
|
105
|
+
export function numAttrOf(tag: string, name: string): number | undefined {
|
|
106
|
+
const raw = attrOf(tag, name)
|
|
107
|
+
if (raw === '') return undefined
|
|
108
|
+
const value = Number.parseInt(raw, 10)
|
|
109
|
+
return Number.isFinite(value) ? value : undefined
|
|
110
|
+
}
|