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,1141 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — SVN 日志弹窗(P3,居中弹窗形态,TortoiseSVN Revision Log Dialog 对标)。
4
+ *
5
+ * 蓝图:plans/svn-integration/06-log-ui-reference.md(§5 P0 全量,2026-09-17 实施)。
6
+ * 布局(P0-14 起为 TortoiseSVN 同款上中下三段全宽):
7
+ * 头部(标题 + ✕)→ 过滤栏(字段下拉 + 搜索框 + 正则开关 + From/To 日期)→
8
+ * 上段:修订列表(列头行 + 单行五列,全宽,高度可拖)→
9
+ * 中段:完整提交信息(meta + 正文,全宽,高度可拖)→
10
+ * 下段:变更路径(含 Copy from 列与受影响灰显,占余,独立滚动)→
11
+ * 底部(状态行 + 勾选项 + 按钮组)。
12
+ * 弹窗外框整体可拖拽调尺寸(右缘调宽 / 底缘调高 / 右下角双向,双击复位)。
13
+ * 已实现 P0:过滤语法(-/+/!/引号/空格 AND/正则回落,G2 前端过滤)· Actions 聚合徽标 ·
14
+ * 日期列与区间 · 蓝行(copy 进来)· 受影响路径灰显/隐藏 · Copy from 列 ·
15
+ * 右键菜单(复用 ContextMenu)· 复制修订 · F5/↑↓ 键盘 · 按钮组 · 状态行 · 列头排序(G10)。
16
+ * 已实现 P1 批 A:多选(Ctrl/Shift + anchor 回落)· 比较两个修订(host svn.diffPair)·
17
+ * Statistics 统计窗(SvnLogStats 纯前端聚合)· 分页改追加(缓存键去 limit 维度)。
18
+ * 已实现 P1 批 B:与工作副本比较 · Show Range(区间模式徽标 + 回到最新)· Stop on copy ·
19
+ * WC 版号行加粗(仅文件目标)。
20
+ * 已实现 P1 批 C:Include merged revs(P1-5,嵌套 logentry → 灰字缩进行)· Show All 上限
21
+ * 提到 SVN_LOG_SHOW_ALL_LIMIT(P1-7,仍是有界护栏,真全量按蓝图结论不做)。
22
+ * 作者 ddj 2026年09月16号 / 2026年09月17号 / 2026年09月18号
23
+ */
24
+ import React from 'react'
25
+ import { SVN_LOG_ACTION_LABEL } from '../../shared/svn.js'
26
+ import { orderLogEntries } from '../../svnLog.js'
27
+ import { SVN_LOG_SHOW_ALL_LIMIT } from '../svnLog.js'
28
+ import { ModalShell } from './ModalShell.js'
29
+ import { ContextMenu } from './ContextMenu.js'
30
+ import type { ContextMenuEntry } from './ContextMenu.js'
31
+ import { SvnLogStats } from './SvnLogStats.js'
32
+
33
+ /** 弹窗默认宽(P0-14 可拖拽调整;窄屏按视口 clamp)。 */
34
+ const DIALOG_W_DEFAULT = 920
35
+ /** 弹窗默认高(P0-14 可拖拽调整)。 */
36
+ const DIALOG_H_DEFAULT = 720
37
+ /** 弹窗宽/高 clamp 下限(P0-14)。 */
38
+ const DIALOG_W_MIN = 680
39
+ const DIALOG_H_MIN = 480
40
+ /** 修订列表默认高(上段;P0-14 双击复位值)。 */
41
+ const LIST_H_DEFAULT = 280
42
+ /** 修订列表高 clamp 下限。 */
43
+ const LIST_H_MIN = 200
44
+ /** 提交信息区默认高(中段;P0-14 双击复位值)。 */
45
+ const MSG_H_DEFAULT = 140
46
+ /** 提交信息区高 clamp 下限。 */
47
+ const MSG_H_MIN = 60
48
+ /** 分段拖拽时下段保留的最小空间。 */
49
+ const PANE_KEEP_MIN = 170
50
+ /** 日志动作字母的配色分级(新增=绿、删除=红、替换=紫、修改=蓝)。 */
51
+ const ACTION_TONE = { A: 'added', D: 'deleted', R: 'added', M: 'modified' }
52
+ /** 排序键 → 列头名(P0-12;顺序 = 列头展示顺序)。 */
53
+ const SORT_COLUMNS = [
54
+ { key: 'revision', label: '版本' },
55
+ { key: 'actions', label: '动作' },
56
+ { key: 'author', label: '作者' },
57
+ { key: 'date', label: '日期' },
58
+ { key: 'message', label: '信息' },
59
+ ]
60
+ /** 可排序键集合(actions 列仅展示徽标,不参与排序)。 */
61
+ const SORT_KEYS = ['revision', 'author', 'date', 'message']
62
+ /** 过滤字段下拉(官方 findtype 的本插件可达子集)。 */
63
+ const FILTER_FIELDS = [
64
+ { key: 'all', label: '全部' },
65
+ { key: 'message', label: '信息' },
66
+ { key: 'paths', label: '路径' },
67
+ { key: 'author', label: '作者' },
68
+ { key: 'revision', label: '版本' },
69
+ ]
70
+
71
+ /**
72
+ * 取提交信息首行(列表展示用)。
73
+ * @author ddj 2026年09月16号
74
+ * @param message 提交信息全文
75
+ * @returns 首行文本
76
+ */
77
+ function firstLineOf(message) {
78
+ const text = String(message ?? '').trim()
79
+ if (!text) return '(无提交信息)'
80
+ return text.split(/\r?\n/)[0]
81
+ }
82
+
83
+ /**
84
+ * 格式化提交时间(ISO8601 → 本地可读;解析失败原样返回)。
85
+ * @author ddj 2026年09月16号
86
+ * @param date ISO8601 字符串
87
+ * @returns 展示文本
88
+ */
89
+ function dateTextOf(date) {
90
+ const text = String(date ?? '')
91
+ if (!text) return ''
92
+ const at = new Date(text)
93
+ return Number.isNaN(at.getTime()) ? text : at.toLocaleString()
94
+ }
95
+
96
+ /**
97
+ * 列内短时间(MM/DD HH:mm;解析失败原样返回,完整时间进 tooltip)。
98
+ * @author ddj 2026年09月17号
99
+ * @param date ISO8601 字符串
100
+ * @returns 短时间文本
101
+ */
102
+ function dateShortOf(date) {
103
+ const at = new Date(String(date ?? ''))
104
+ if (Number.isNaN(at.getTime())) return String(date ?? '')
105
+ const pad = (n) => String(n).padStart(2, '0')
106
+ return pad(at.getMonth() + 1) + '/' + pad(at.getDate()) + ' ' + pad(at.getHours()) + ':' + pad(at.getMinutes())
107
+ }
108
+
109
+ /**
110
+ * 该版本的变更动作聚合(A/D/R/M 去重保序;P0-2)。
111
+ * @author ddj 2026年09月17号
112
+ * @param entry 日志条目
113
+ * @returns 动作字母数组
114
+ */
115
+ function actionsOf(entry) {
116
+ const seen = []
117
+ for (const p of entry.paths || []) {
118
+ if (p?.action && !seen.includes(p.action)) seen.push(p.action)
119
+ }
120
+ return seen
121
+ }
122
+
123
+ /**
124
+ * 动作字母元素(按 action 着色,带官方语义 tooltip)。
125
+ * @author ddj 2026年09月16号
126
+ * @param action 动作字母
127
+ * @returns 字母元素
128
+ */
129
+ function actionLetterEl(action) {
130
+ const key = action || 'M'
131
+ return React.createElement('span', {
132
+ className: 'edrv-svn-ch edrv-svn-ch-' + (ACTION_TONE[key] ?? 'plain'),
133
+ title: SVN_LOG_ACTION_LABEL[key] ?? key,
134
+ }, key)
135
+ }
136
+
137
+ /**
138
+ * 条目在显示列表中的层级(0 = 顶层;≥1 = 某父条目的 merged 子项;未出现 = null)。
139
+ *
140
+ * 只识别到一级嵌套:解析侧最深实测 1 层(更深按 1 级展示,不递归铺开)。
141
+ * @author ddj 2026年09月18号
142
+ * @param list 显示中的顶层条目列表
143
+ * @param revision 目标修订号
144
+ * @returns 层级或 null
145
+ */
146
+ function levelInList(list, revision) {
147
+ if (!Array.isArray(list)) return null
148
+ for (const item of list) {
149
+ if (item.revision === revision) return 0
150
+ if ((item.merged || []).some((sub) => sub.revision === revision)) return 1
151
+ }
152
+ return null
153
+ }
154
+
155
+ /**
156
+ * 上窗格可渲染行:顶层条目 + 其 merged 子项(P1-5,紧贴父条目之后、缩进一级)。
157
+ * 只展平不排序:merged 的顺序由 svn 输出决定(父条目内嵌套顺序即官方交错顺序)。
158
+ * @author ddj 2026年09月18号
159
+ * @param list 排序后的顶层条目(null = 未加载)
160
+ * @returns [{ item, level }]
161
+ */
162
+ function logRowsOf(list) {
163
+ if (!Array.isArray(list)) return []
164
+ const rows = []
165
+ for (const item of list) {
166
+ rows.push({ item, level: 0 })
167
+ for (const sub of item.merged || []) rows.push({ item: sub, level: 1 })
168
+ }
169
+ return rows
170
+ }
171
+
172
+ /**
173
+ * 单个目标之内 merged 子项总数(状态行口径:顶层修订之外还显示了多少条)。
174
+ * @author ddj 2026年09月18号
175
+ * @param list 排序后的顶层条目(null = 未加载)
176
+ * @returns merged 子项数
177
+ */
178
+ function mergedCountOf(list) {
179
+ if (!Array.isArray(list)) return 0
180
+ let total = 0
181
+ for (const item of list) total += (item.merged || []).length
182
+ return total
183
+ }
184
+
185
+ /**
186
+ * 写剪贴板(优先异步 API,非安全上下文回落 execCommand;失败静默忽略)。
187
+ * @author ddj 2026年09月18号
188
+ * @param text 文本
189
+ */
190
+ function copyTextOf(text) {
191
+ try {
192
+ void navigator.clipboard?.writeText(text)
193
+ } catch (e) {
194
+ try {
195
+ const input = document.createElement('textarea')
196
+ input.value = text
197
+ document.body.appendChild(input)
198
+ input.select()
199
+ document.execCommand('copy')
200
+ document.body.removeChild(input)
201
+ } catch (e2) { /* 非安全上下文等:忽略 */ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 变更路径是否「受影响」(P0-5:目标路径自身或其子孙;仓库外恒非受影响,G9)。
207
+ * @author ddj 2026年09月17号
208
+ * @param path 变更路径条目
209
+ * @param target 打开日志时的目标('' = 工作区根)
210
+ * @returns 是否受影响
211
+ */
212
+ function isAffectedPath(path, target) {
213
+ if (!target) return true
214
+ const rel = path?.relPath
215
+ if (rel === null || rel === undefined) return false
216
+ return rel === target || rel.startsWith(target + '/')
217
+ }
218
+
219
+ /**
220
+ * 把过滤表达式按官方语法切成词元(引号内空格不切;"" 为字面引号;无反斜杠转义)。
221
+ * @author ddj 2026年09月17号
222
+ * @param query 原始过滤串
223
+ * @returns 词元数组
224
+ */
225
+ export function tokenizeLogQuery(query) {
226
+ const text = String(query ?? '')
227
+ const tokens = []
228
+ let current = ''
229
+ let inQuote = false
230
+ const push = () => { if (current) { tokens.push(current); current = '' } }
231
+ for (let i = 0; i < text.length; i++) {
232
+ const ch = text[i]
233
+ if (inQuote) {
234
+ if (ch === '"' && text[i + 1] === '"') { current += '"'; i++ } // "" 自转义
235
+ else if (ch === '"') { inQuote = false; push() } // 闭合引号结束词元
236
+ else current += ch
237
+ continue
238
+ }
239
+ if (ch === '"') {
240
+ // 非引号态的 "" = 字面引号(官方 `""` 语义);单个 " 开启短语模式(引号内空格不切分)
241
+ if (text[i + 1] === '"') { current += '"'; i++ }
242
+ else inQuote = true
243
+ continue
244
+ }
245
+ if (/\s/.test(ch)) { push(); continue }
246
+ current += ch
247
+ }
248
+ if (inQuote) push() // 未闭合引号:已有内容作为词元收尾
249
+ else push()
250
+ return tokens
251
+ }
252
+
253
+ /**
254
+ * 单词元在字段文本上是否命中(regex 模式编译失败回落子串,不抛错)。
255
+ * @author ddj 2026年09月17号
256
+ * @param term 词元文本
257
+ * @param hay 小写字段文本
258
+ * @param rawText 原始字段文本(regex 用)
259
+ * @param useRegex 是否正则模式
260
+ * @returns 是否命中
261
+ */
262
+ function termHits(term, hay, rawText, useRegex) {
263
+ if (!useRegex) return hay.includes(term.toLowerCase())
264
+ try { return new RegExp(term, 'i').test(rawText) } catch (e) { return hay.includes(term.toLowerCase()) }
265
+ }
266
+
267
+ /**
268
+ * 条目在指定字段上的原始匹配文本(all = 四段拼接)。
269
+ * @author ddj 2026年09月17号
270
+ * @param entry 日志条目
271
+ * @param field 字段键
272
+ * @returns 文本
273
+ */
274
+ function rawTextOf(entry, field) {
275
+ const paths = (entry.paths || []).map((p) => String(p?.relPath ?? p?.path ?? '')).join(' ')
276
+ if (field === 'message') return String(entry.message ?? '')
277
+ if (field === 'paths') return paths
278
+ if (field === 'author') return String(entry.author ?? '')
279
+ if (field === 'revision') return String(entry.revision ?? '')
280
+ return String(entry.author ?? '') + ' ' + String(entry.revision ?? '') + ' ' + paths + ' ' + String(entry.message ?? '')
281
+ }
282
+
283
+ /**
284
+ * 条目在指定字段上的小写匹配文本。
285
+ * @author ddj 2026年09月17号
286
+ * @param entry 日志条目
287
+ * @param field 字段键
288
+ * @returns 小写文本
289
+ */
290
+ function filterTextOf(entry, field) {
291
+ return rawTextOf(entry, field).toLowerCase()
292
+ }
293
+
294
+ /**
295
+ * 官方过滤语法判定(P0-1,G2:纯前端):
296
+ * - 空格分隔多项全部必须匹配(AND);`"短语"` 含空格;`""` 为字面引号
297
+ * - `-项` 排除、`+项` 重新纳入(顺序敏感,对同一命中后写的生效)
298
+ * - 置首 `!` 整表达式取反;regex 模式逐词元编译、失败回落子串
299
+ * 无反斜杠转义(官方语义中它不是转义符)。
300
+ * @author ddj 2026年09月17号
301
+ * @param query 原始过滤串
302
+ * @param field 字段(all/message/paths/author/revision)
303
+ * @param useRegex 是否正则模式
304
+ * @returns 判定函数
305
+ */
306
+ export function parseLogFilter(query, field = 'all', useRegex = false) {
307
+ const tokens = tokenizeLogQuery(query)
308
+ const negate = tokens.length > 0 && tokens[0].startsWith('!')
309
+ const head = negate ? tokens[0].slice(1) : tokens[0]
310
+ const ordered = (head ? [head, ...tokens.slice(1)] : tokens.slice(1))
311
+ .map((t) => {
312
+ if (t.startsWith('-') && t.length > 1) return { kind: 'ex', term: t.slice(1) }
313
+ if (t.startsWith('+') && t.length > 1) return { kind: 'in', term: t.slice(1) }
314
+ return { kind: 'and', term: t }
315
+ })
316
+ return (entry) => {
317
+ const hay = filterTextOf(entry, field)
318
+ const raw = rawTextOf(entry, field)
319
+ let allPlain = true
320
+ let verdict = null // null = 无 -/+ 命中;'ex' = 排除;'in' = 纳入(后写覆盖)
321
+ for (const t of ordered) {
322
+ // 不提前退出:后面的 + 项仍可挽救 plain 失败(官方顺序敏感语义)
323
+ if (t.kind === 'and') { if (!termHits(t.term, hay, raw, useRegex)) allPlain = false }
324
+ else if (termHits(t.term, hay, raw, useRegex)) verdict = t.kind
325
+ }
326
+ let pass = verdict ? verdict === 'in' : allPlain
327
+ if (negate) pass = !pass
328
+ return pass
329
+ }
330
+ }
331
+
332
+ /**
333
+ * 复制修订的官方五字段文本(P0-9:版本号/作者/日期/信息/变更清单)。
334
+ * @author ddj 2026年09月17号
335
+ * @param entry 日志条目
336
+ * @returns 多行文本
337
+ */
338
+ export function revisionClipboardText(entry) {
339
+ const lines = [
340
+ 'r' + entry.revision + ' ' + (entry.author || '(无作者)') + ' ' + dateTextOf(entry.date),
341
+ String(entry.message ?? '(无提交信息)'),
342
+ ]
343
+ const paths = entry.paths || []
344
+ if (paths.length) {
345
+ lines.push('变更文件(' + paths.length + '):')
346
+ for (const p of paths) lines.push(' ' + (p.action || 'M') + ' ' + String(p.relPath ?? p.path))
347
+ }
348
+ return lines.join('\n')
349
+ }
350
+
351
+ /**
352
+ * 动作徽标配色类(与 .edrv-svn-ch-* 同语义配色,独立小尺寸)。
353
+ * @author ddj 2026年09月17号
354
+ * @param action 动作字母
355
+ * @returns 类名
356
+ */
357
+ function actionLetterClass(action) {
358
+ return 'edrv-svnlog-badge-' + (ACTION_TONE[action] ?? 'plain')
359
+ }
360
+
361
+ /**
362
+ * 上窗格列头单格(P0-12:可排序列带方向指示;actions 列仅标签)。
363
+ * @author ddj 2026年09月17号
364
+ * @param props.col 列定义
365
+ * @param props.sortKey 当前排序键(null = 默认倒序)
366
+ * @param props.sortDir 当前方向
367
+ * @param props.onSort 点列头回调
368
+ * @returns 列头格元素
369
+ */
370
+ function SortCol(props) {
371
+ const { col, sortKey, sortDir, onSort } = props
372
+ const sortable = SORT_KEYS.includes(col.key)
373
+ const active = sortKey === col.key
374
+ const arrow = active ? (sortDir === 'asc' ? '↑' : '↓') : ''
375
+ return React.createElement('span', {
376
+ className: 'edrv-svnlog-col' + (sortable ? ' edrv-svnlog-col-sortable' : '') + (active ? ' edrv-svnlog-col-on' : ''),
377
+ title: sortable ? '按' + col.label + '排序;再点切换升/降序' : undefined,
378
+ onClick: sortable ? () => onSort(col.key) : undefined,
379
+ }, col.label + (arrow ? ' ' + arrow : ''))
380
+ }
381
+
382
+ /**
383
+ * 上窗格列头行(与数据行共用同一套列宽 class;P0-12/D30;排序中显示复位入口)。
384
+ * @author ddj 2026年09月17号
385
+ * @param props.sortKey/sortDir/onSort/onSortReset 排序状态与回调
386
+ * @returns 列头行元素
387
+ */
388
+ function LogCols(props) {
389
+ const { sortKey, sortDir, onSort, onSortReset } = props
390
+ return React.createElement('div', { className: 'edrv-svnlog-cols' },
391
+ SORT_COLUMNS.map((col) => React.createElement(SortCol, { key: col.key, col, sortKey, sortDir, onSort })),
392
+ (sortKey
393
+ ? React.createElement('button', {
394
+ className: 'edrv-svn-act edrv-svnlog-sort-reset', title: '恢复默认(版本号降序,最新在上)', onClick: onSortReset,
395
+ }, '默认')
396
+ : null))
397
+ }
398
+
399
+ /**
400
+ * 上窗格单行(单行五列:版本/动作徽标/作者/日期/信息首行;文件数与完整时间进 tooltip;
401
+ * 蓝行 = 本线被 copy 进来的版本,P0-4)。
402
+ * P1-5:merged 子项灰字 + 逐级缩进,前置「↳」标记与反向合并 tooltip。
403
+ * @author ddj 2026年09月16号 / 2026年09月17号 / 2026年09月18号
404
+ * @param props.item 日志条目(顶层或 merged 子项)
405
+ * @param props.on 是否选中
406
+ * @param props.wc 是否为工作副本当前版号(P1-9 加粗)
407
+ * @param props.level 嵌套层级(0 = 顶层;P1-5)
408
+ * @param props.onPick 选中回调(event, item;Ctrl/Shift 修饰由父级解释,P1-3)
409
+ * @param props.onMenu 右键回调(event, item)
410
+ * @returns 行元素
411
+ */
412
+ function LogItem(props) {
413
+ const { item, on, wc, level, onPick, onMenu } = props
414
+ const actions = actionsOf(item)
415
+ const copied = (item.paths || []).some((p) => p?.copyFrom)
416
+ const merged = level > 0
417
+ const title = (merged ? '合并进来的修订 · ' : '')
418
+ + firstLineOf(item.message)
419
+ + ' · r' + item.revision + ' · ' + dateTextOf(item.date)
420
+ + ' · ' + (item.paths?.length || 0) + ' 文件'
421
+ + (item.reverseMerge ? ' · 反向合并(reverse-merge)' : '')
422
+ return React.createElement('div', {
423
+ className: 'edrv-svnlog-item'
424
+ + (on ? ' edrv-svnlog-item-on' : '')
425
+ + (copied ? ' edrv-svnlog-item-copied' : '')
426
+ + (merged ? ' edrv-svnlog-item-merged' : '')
427
+ + (wc ? ' edrv-svnlog-item-wc' : ''),
428
+ title,
429
+ style: merged ? { paddingLeft: 6 + level * 18 } : undefined,
430
+ onClick: (event) => onPick(event, item),
431
+ onContextMenu: (event) => onMenu?.(event, item),
432
+ },
433
+ (merged ? React.createElement('span', { className: 'edrv-svnlog-cell edrv-svnlog-merge-mark' }, '↳') : null),
434
+ React.createElement('span', { className: 'edrv-svnlog-cell edrv-svnlog-cell-rev edrv-svnlog-rev' }, 'r' + item.revision),
435
+ React.createElement('span', { className: 'edrv-svnlog-cell edrv-svnlog-cell-act' },
436
+ actions.map((a) => React.createElement('span', { key: a, className: 'edrv-svnlog-badge ' + actionLetterClass(a) }, a))),
437
+ React.createElement('span', { className: 'edrv-svnlog-cell edrv-svnlog-cell-author' }, item.author || '(无作者)'),
438
+ React.createElement('span', { className: 'edrv-svnlog-cell edrv-svnlog-cell-date', title: dateTextOf(item.date) }, dateShortOf(item.date)),
439
+ React.createElement('span', { className: 'edrv-svnlog-cell edrv-svnlog-cell-msg' }, firstLineOf(item.message)))
440
+ }
441
+
442
+ /**
443
+ * 拖拽分隔条(P0-13:竖条调左右分栏宽,横条调右侧上下占比;双击复位)。
444
+ * @author ddj 2026年09月17号
445
+ * @param props.vertical 是否竖向(col-resize)
446
+ * @param props.title tooltip 主题
447
+ * @param props.onDragStart pointerdown 回调(弹窗侧启动 window 级 move/up 跟踪)
448
+ * @param props.onReset 双击复位
449
+ * @returns 分隔条元素
450
+ */
451
+ function Splitter(props) {
452
+ const { vertical, title, onDragStart, onReset } = props
453
+ return React.createElement('div', {
454
+ className: vertical ? 'edrv-svnlog-vsplit' : 'edrv-svnlog-hsplit',
455
+ title: title + '(双击复位)',
456
+ onPointerDown: onDragStart,
457
+ onDoubleClick: (event) => { event.stopPropagation(); onReset() },
458
+ })
459
+ }
460
+
461
+ /**
462
+ * 吞掉拖拽结束后浏览器派发的下一次合成 click(capture 一次性)。
463
+ * 拖拽常把指针移出卡片,松手后 click 落在遮罩上会误触「点遮罩关闭」。
464
+ * @author ddj 2026年09月17号
465
+ */
466
+ function swallowNextClick() {
467
+ const swallow = (event) => {
468
+ event.stopPropagation()
469
+ event.preventDefault()
470
+ window.removeEventListener('click', swallow, true)
471
+ }
472
+ window.addEventListener('click', swallow, true)
473
+ window.setTimeout(() => window.removeEventListener('click', swallow, true), 0)
474
+ }
475
+
476
+ /**
477
+ * 外框尺寸手柄(P0-14):mode 'r'=右缘调宽 / 'b'=底缘调高 / 'c'=右下角双向;双击复位。
478
+ * @author ddj 2026年09月17号
479
+ * @param props.mode 手柄形态
480
+ * @param props.onDragStart pointerdown 回调(弹窗侧启动 window 级跟踪)
481
+ * @param props.onReset 双击复位
482
+ * @returns 手柄元素
483
+ */
484
+ function Grip(props) {
485
+ const { mode, onDragStart, onReset } = props
486
+ return React.createElement('div', {
487
+ className: 'edrv-svnlog-grip edrv-svnlog-grip-' + mode,
488
+ onPointerDown: (event) => onDragStart(event, mode),
489
+ onDoubleClick: (event) => { event.stopPropagation(); onReset() },
490
+ })
491
+ }
492
+
493
+ /**
494
+ * 下窗格变更路径单行(双击看差异;目录打开;Copy from 成列 P0-7;非受影响灰显 P0-5)。
495
+ * relPath 三态:null = 仓库外(显示仓库原路径);'' = 工作副本根自身变更(如合并属性,
496
+ * 显示占位文案);其余 = 工作区相对路径。根条目不提供打开/差异动作。
497
+ * @author ddj 2026年09月16号 / 2026年09月17号
498
+ * @param props.item 日志路径条目
499
+ * @param props.revision 所属版本
500
+ * @param props.dim 非受影响(灰显)
501
+ * @param props.onOpenFile 打开工作区文件
502
+ * @param props.onOpenDiff 打开版本差异
503
+ * @param props.onMenu 右键回调(event, item, revision)
504
+ * @returns 行元素
505
+ */
506
+ function PathRow(props) {
507
+ const { item, revision, dim, onOpenFile, onOpenDiff, onMenu } = props
508
+ const outside = item.relPath === null
509
+ const isRoot = item.relPath === ''
510
+ const actionable = !outside && !isRoot
511
+ const open = () => { if (actionable) onOpenFile?.(item.relPath) }
512
+ const diff = () => { if (actionable && item.kind !== 'dir') onOpenDiff?.(item.relPath, revision) }
513
+ const relText = outside ? item.path + '(仓库外)' : (isRoot ? '(工作副本根)' : item.relPath)
514
+ const buttons = actionable ? React.createElement('span', { className: 'edrv-svnlog-acts' },
515
+ React.createElement('button', {
516
+ className: 'edrv-svn-act', title: '在工作区打开该文件',
517
+ onClick: (event) => { event.stopPropagation(); open() },
518
+ }, '打开'),
519
+ (item.kind === 'dir'
520
+ ? null
521
+ : React.createElement('button', {
522
+ className: 'edrv-svn-act', title: '查看该版本与上一版的差异',
523
+ onClick: (event) => { event.stopPropagation(); diff() },
524
+ }, '差异')))
525
+ : null
526
+ return React.createElement('div', {
527
+ className: 'edrv-svnlog-path' + (dim ? ' edrv-svnlog-path-dim' : ''),
528
+ title: item.path + (item.copyFrom ? '(复制自 ' + item.copyFrom + '@' + (item.copyFromRev ?? '?') + ')' : '')
529
+ + (outside ? '(仓库外路径)' : (isRoot ? '(工作副本根自身变更,常见于合并属性)' : ''))
530
+ + (item.kind === 'dir' ? '' : '(双击查看该版本差异)'),
531
+ onDoubleClick: () => { if (item.kind === 'dir') open(); else diff() },
532
+ onContextMenu: (event) => onMenu?.(event, item, revision),
533
+ },
534
+ actionLetterEl(item.action),
535
+ React.createElement('span', { className: 'edrv-svnlog-pathtext' }, relText),
536
+ (item.kind === 'dir' ? React.createElement('span', { className: 'edrv-svnlog-kind' }, '目录') : null),
537
+ (item.copyFrom ? React.createElement('span', { className: 'edrv-svnlog-kind' }, '复制') : null),
538
+ React.createElement('span', { className: 'edrv-svnlog-copyfrom', title: item.copyFrom ? item.copyFrom + '@' + (item.copyFromRev ?? '?') : '' },
539
+ item.copyFrom ? item.copyFrom + '@' + (item.copyFromRev ?? '?') : ''),
540
+ buttons)
541
+ }
542
+
543
+ /**
544
+ * 上窗格列表(列头行 + 数据行 + 「加载更多」;P0-13 宽度受拖拽控制)。
545
+ * @author ddj 2026年09月16号 / 2026年09月17号
546
+ * @param props.list 过滤排序后的条目(null = 未加载)
547
+ * @param props.selected 选中条目数组(P1-3 多选;至少一条,由父级保证)
548
+ * @param props.keyword 过滤词(决定空态文案)
549
+ * @param props.busy 加载中
550
+ * @param props.truncated 是否可能还有更多
551
+ * @param props.sortKey/sortDir/onSort/onSortReset 列头排序
552
+ * @param props.onPick 选中回调(event, item;P1-3 修饰键由父级解释)
553
+ * @param props.onLoadMore 加载更多
554
+ * @param props.onItemMenu 行右键回调
555
+ * @param props.height 列表高度像素(P0-14 上段,拖拽受控;宽度随弹窗全宽)
556
+ * @param props.wcRev 文件目标的工作副本版号(P1-9;null = 不加粗)
557
+ * @returns 列表元素
558
+ */
559
+ function LogList(props) {
560
+ const { list, selected, keyword, busy, truncated, sortKey, sortDir, onSort, onSortReset, onPick, onLoadMore, onItemMenu, height, wcRev } = props
561
+ let inner
562
+ if (list === null) {
563
+ inner = React.createElement('div', { className: 'edrv-tree-loading' }, busy ? '读取日志中…' : '尚未加载')
564
+ } else if (!list.length) {
565
+ inner = React.createElement('div', { className: 'edrv-tree-loading' }, keyword ? '无匹配条目' : '无提交历史')
566
+ } else {
567
+ inner = logRowsOf(list).map((row) => React.createElement(LogItem, {
568
+ key: row.item.revision + ':' + row.level,
569
+ item: row.item,
570
+ on: selected.some((s) => s.revision === row.item.revision),
571
+ wc: wcRev != null && row.item.revision === wcRev,
572
+ level: row.level,
573
+ onPick,
574
+ onMenu: onItemMenu,
575
+ }))
576
+ }
577
+ const more = truncated
578
+ ? React.createElement('div', { className: 'edrv-svnlog-more' },
579
+ React.createElement('button', {
580
+ className: 'edrv-svn-act', disabled: busy, onClick: onLoadMore,
581
+ }, busy ? '加载中…' : '加载更多(+100)'))
582
+ : null
583
+ return React.createElement('div', { className: 'edrv-svnlog-list', style: height ? { height } : undefined },
584
+ React.createElement(LogCols, { sortKey, sortDir, onSort, onSortReset }),
585
+ inner,
586
+ more)
587
+ }
588
+
589
+ /**
590
+ * 中段内容:提交信息(共用一个文本区域:单选 = 原样 meta+正文;多选 = 一条 meta 概要 + 单一 pre 内合并显示,
591
+ * 每条带 r/作者/日期头行、横线分隔,对齐官方多选消息合并形态)。
592
+ * @author ddj 2026年09月16号 / 2026年09月18号
593
+ * @param props.selected 选中条目数组(≥1,由父级保证)
594
+ * @returns meta 与正文元素
595
+ */
596
+ function LogMsgInner(props) {
597
+ const { selected } = props
598
+ if (!selected || !selected.length) {
599
+ return React.createElement('div', { className: 'edrv-svndiff-empty' }, '选择修订查看提交信息')
600
+ }
601
+ if (selected.length === 1) {
602
+ const one = selected[0]
603
+ return React.createElement(React.Fragment, null,
604
+ React.createElement('div', { className: 'edrv-svnlog-meta' },
605
+ React.createElement('span', { className: 'edrv-svnlog-rev' }, 'r' + one.revision),
606
+ React.createElement('span', null, one.author || '(无作者)'),
607
+ React.createElement('span', { className: 'edrv-svnlog-date' }, dateTextOf(one.date))),
608
+ React.createElement('pre', { className: 'edrv-svnlog-body' }, one.message || '(无提交信息)'))
609
+ }
610
+ const head = React.createElement('div', { className: 'edrv-svnlog-meta' },
611
+ React.createElement('span', { className: 'edrv-svnlog-rev' }, 'r' + selected[0].revision + ' ~ r' + selected[selected.length - 1].revision),
612
+ React.createElement('span', null, '已选 ' + selected.length + ' 条修订'),
613
+ React.createElement('span', { className: 'edrv-svnlog-date' }, '信息合并显示'))
614
+ const text = selected.map((entry) => 'r' + entry.revision + ' · ' + (entry.author || '(无作者)') + ' · ' + dateTextOf(entry.date)
615
+ + '\n' + String(entry.message || '(无提交信息)')).join('\n----------------\n')
616
+ return React.createElement(React.Fragment, null, head,
617
+ React.createElement('pre', { className: 'edrv-svnlog-body' }, text))
618
+ }
619
+
620
+ /**
621
+ * 多选合并的变更路径(按 path 去重,保留首次出现的动作与所属修订,供打开/差异动作定位)。
622
+ * @author ddj 2026年09月18号
623
+ * @param selected 选中条目数组(≥1)
624
+ * @returns 合并后的路径条目(附所属修订 revision)
625
+ */
626
+ function mergedPathsOf(selected) {
627
+ const seen = new Map()
628
+ for (const entry of selected) {
629
+ for (const p of entry.paths || []) {
630
+ if (!p?.path || seen.has(p.path)) continue
631
+ seen.set(p.path, { ...p, revision: entry.revision })
632
+ }
633
+ }
634
+ return [...seen.values()]
635
+ }
636
+
637
+ /**
638
+ * 下段内容:变更路径(P1-3 合并视图——多选时按 path 去重合并;受影响灰显/隐藏 P0-5,G9 仓库外恒非受影响)。
639
+ * @author ddj 2026年09月16号 / 2026年09月18号
640
+ * @param props.selected 选中条目数组(≥1)
641
+ * @param props.target 日志目标(受影响判定用)
642
+ * @param props.onlyAffected 是否仅显示受影响路径
643
+ * @param props.onOpenFile 打开工作区文件
644
+ * @param props.onOpenDiff 打开版本差异
645
+ * @param props.onPathMenu 路径行右键回调
646
+ * @returns 路径头与行元素
647
+ */
648
+ function LogPathsInner(props) {
649
+ const { selected, target, onlyAffected, onOpenFile, onOpenDiff, onPathMenu } = props
650
+ if (!selected || !selected.length) {
651
+ return React.createElement('div', { className: 'edrv-svndiff-empty' }, '选择修订查看变更文件')
652
+ }
653
+ const allPaths = mergedPathsOf(selected)
654
+ const shownPaths = onlyAffected ? allPaths.filter((p) => isAffectedPath(p, target)) : allPaths
655
+ const head = React.createElement('div', { className: 'edrv-svnlog-paths-head' },
656
+ '变更文件(' + shownPaths.length + (onlyAffected && shownPaths.length !== allPaths.length ? '/' + allPaths.length : '') + ')',
657
+ React.createElement('span', { style: { flex: 1 } }),
658
+ React.createElement('span', { className: 'edrv-svnlog-hint' }, '双击行查看所属修订差异;灰显 = 目标路径之外'))
659
+ const rows = shownPaths.length
660
+ ? shownPaths.map((item, idx) => React.createElement(PathRow, {
661
+ key: String(item.path) + ':' + idx,
662
+ item,
663
+ revision: item.revision,
664
+ dim: !onlyAffected && !isAffectedPath(item, target),
665
+ onOpenFile,
666
+ onOpenDiff,
667
+ onMenu: onPathMenu,
668
+ }))
669
+ : React.createElement('div', { className: 'edrv-tree-loading' },
670
+ onlyAffected && allPaths.length ? '所选修订在目标路径下无变更(其余 ' + allPaths.length + ' 项已隐藏)' : '所选修订无变更路径(仅属性改动或未取 -v)')
671
+ return React.createElement(React.Fragment, null, head, React.createElement('div', { className: 'edrv-svnlog-paths' }, rows))
672
+ }
673
+
674
+ /**
675
+ * 头部(标题 + target + 关闭;Refresh 移到底部按钮组 P0-6)。
676
+ * @author ddj 2026年09月16号 / 2026年09月17号
677
+ * @param props.target 目标路径
678
+ * @param props.onClose 关闭
679
+ * @returns 头部元素
680
+ */
681
+ function LogHead(props) {
682
+ const { target, onClose } = props
683
+ return React.createElement('div', { className: 'edrv-svnlog-head' },
684
+ React.createElement('h3', { className: 'edrv-svnlog-title' }, 'SVN 日志'),
685
+ React.createElement('span', { className: 'edrv-svnlog-target', title: target || '(工作区根)' }, target || '(工作区根)'),
686
+ React.createElement('span', { style: { flex: 1 } }),
687
+ React.createElement('button', { className: 'edrv-svn-act', title: '关闭', onClick: onClose }, '✕'))
688
+ }
689
+
690
+ /**
691
+ * 过滤栏(P0-1 字段下拉 + 搜索框 + 正则开关;P0-3 From/To 日期区间)。
692
+ * @author ddj 2026年09月17号
693
+ * @param props.field/regexOn/query/from/to 及其回调
694
+ * @returns 过滤栏元素
695
+ */
696
+ function LogFilterBar(props) {
697
+ const { field, regexOn, query, from, to, onField, onRegex, onQuery, onFrom, onTo } = props
698
+ return React.createElement('div', { className: 'edrv-svnlog-filter' },
699
+ React.createElement('select', {
700
+ className: 'edrv-svnlog-field', value: field, title: '搜索字段',
701
+ onChange: (event) => onField(event.target.value),
702
+ }, FILTER_FIELDS.map((f) => React.createElement('option', { key: f.key, value: f.key }, f.label))),
703
+ React.createElement('input', {
704
+ className: 'edrv-svnlog-search', placeholder: '过滤:空格=AND,-排除,+纳入,!取反,"短语"',
705
+ value: query, onChange: (event) => onQuery(event.target.value),
706
+ }),
707
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '把每个词元当正则表达式(语法错误回落子串匹配)' },
708
+ React.createElement('input', { type: 'checkbox', checked: regexOn, onChange: (event) => onRegex(event.target.checked) }),
709
+ '正则'),
710
+ React.createElement('label', { className: 'edrv-svnlog-check' },
711
+ '从',
712
+ React.createElement('input', { type: 'date', className: 'edrv-svnlog-date', value: from, onChange: (event) => onFrom(event.target.value) })),
713
+ React.createElement('label', { className: 'edrv-svnlog-check' },
714
+ '到',
715
+ React.createElement('input', { type: 'date', className: 'edrv-svnlog-date', value: to, onChange: (event) => onTo(event.target.value) })))
716
+ }
717
+
718
+ /**
719
+ * 状态行文案(P0-11:条数 + 范围 + 选中数 + 变更路径数;信息序对齐官方)。
720
+ * @author ddj 2026年09月17号 / 2026年09月18号
721
+ * @param params list 已加载全量 / filtered 过滤后 / shownCount / keyword / truncated / current / selectedCount 选中数(P1-3)
722
+ * @returns 状态文本
723
+ */
724
+ function statusLineOf(list, filtered, shownCount, keyword, truncated, current, selectedCount) {
725
+ if (!list) return '未加载'
726
+ const maxRev = list.length ? 'r' + list[0].revision : '—'
727
+ const minRev = list.length ? 'r' + list[list.length - 1].revision : '—'
728
+ const changedPaths = current ? (current.paths || []).length : 0
729
+ // P1-5:merged 的条数按顶层列表统计(-g 时总条数可超过 -l,如实标出以免误判条数)
730
+ const mergedCount = mergedCountOf(list)
731
+ return '显示 ' + filtered.length + ' 条修订(' + minRev + ' ~ ' + maxRev + ')'
732
+ + (mergedCount ? ' + 合并 ' + mergedCount + ' 条' : '')
733
+ + ' · 选中 ' + (selectedCount || 1) + ' 条 · ' + changedPaths + ' 个变更路径'
734
+ + (keyword ? '(过滤后 ' + shownCount + ' 条)' : '')
735
+ + (truncated ? ' · 可能还有更多' : ' · 已全部加载')
736
+ }
737
+
738
+ /**
739
+ * 底部(状态行 P0-11 + 勾选项 P0-5/P1-4/P1-5 + 按钮组 P0-6:Next 100 / Show All / Refresh)。
740
+ * @author ddj 2026年09月17号 / 2026年09月18号
741
+ * @param props 见内部解构
742
+ * @returns 底部元素
743
+ */
744
+ function LogFoot(props) {
745
+ const { list, filtered, shownCount, keyword, truncated, current, selectedCount, onlyAffected, onOnlyAffected, busy, onLoadMore, onShowAll, onRefresh, onStats, stopOnCopy, onStopCopy, includeMerged, onIncludeMerged, rangeActive, onOpenRange, onRangeReset } = props
746
+ const status = statusLineOf(list, filtered, shownCount, keyword, truncated, current, selectedCount)
747
+ return React.createElement('div', { className: 'edrv-svnlog-footwrap' },
748
+ React.createElement('div', { className: 'edrv-svnlog-foot' },
749
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '隐藏不落在目标路径下的变更项(未勾时灰显)' },
750
+ React.createElement('input', { type: 'checkbox', checked: onlyAffected, onChange: (event) => onOnlyAffected(event.target.checked) }),
751
+ '仅显示受影响路径'),
752
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '官方 Stop on copy/rename:遇到从别处复制/重命名而来的路径即停止回溯(默认不勾)' },
753
+ React.createElement('input', { type: 'checkbox', checked: stopOnCopy === true, onChange: (event) => onStopCopy?.(event.target.checked) }),
754
+ 'Stop on copy'),
755
+ React.createElement('label', { className: 'edrv-svnlog-check', title: '官方 Include merged revisions(svn -g):把合并进本路径的修订交错插入,灰字缩进显示(默认不勾;输出量更大)' },
756
+ React.createElement('input', { type: 'checkbox', checked: includeMerged === true, onChange: (event) => onIncludeMerged?.(event.target.checked) }),
757
+ 'Include merged revs'),
758
+ (rangeActive ? React.createElement('span', { className: 'edrv-svnlog-check', title: '区间模式:仅显示指定版本区间(svn -r START:END)' }, '区间模式') : null),
759
+ React.createElement('span', { style: { flex: 1 } }),
760
+ status),
761
+ React.createElement('div', { className: 'edrv-svnlog-btns' },
762
+ React.createElement('button', {
763
+ className: 'edrv-svn-act', disabled: busy || !truncated || rangeActive,
764
+ title: rangeActive ? '区间模式下不可加载更多(回到最新后可用)' : (truncated ? '再取 100 条' : '已到最早提交:该目标的全部历史已显示(svn 日志只含该路径发生过变更的版本)'),
765
+ onClick: onLoadMore,
766
+ }, 'Next 100'),
767
+ React.createElement('button', {
768
+ className: 'edrv-svn-act', disabled: busy || rangeActive,
769
+ title: rangeActive
770
+ ? '区间模式下不可用(回到最新后可用)'
771
+ : '取到本插件上限 ' + SVN_LOG_SHOW_ALL_LIMIT + ' 条(超大仓库可能较慢;再往前的历史可用 Show Range… 指定区间)',
772
+ onClick: onShowAll,
773
+ }, 'Show All'),
774
+ React.createElement('button', {
775
+ className: 'edrv-svn-act', disabled: busy, title: '按版本区间拉取(P1-6;起始=较早、结束=较晚)',
776
+ onClick: onOpenRange,
777
+ }, 'Show Range…'),
778
+ (rangeActive
779
+ ? React.createElement('button', { className: 'edrv-svn-act', disabled: busy, title: '清除区间,回到默认 HEAD 往前的窗口', onClick: onRangeReset }, '回到最新')
780
+ : null),
781
+ React.createElement('span', { style: { flex: 1 } }),
782
+ React.createElement('button', { className: 'edrv-svn-act', disabled: busy, title: '重新读取日志(F5)', onClick: onRefresh }, 'Refresh'),
783
+ React.createElement('button', {
784
+ className: 'edrv-svn-act', disabled: !list || !list.length,
785
+ title: '统计当前显示的修订(期间 / 按作者 / 按日期,P1-10)', onClick: onStats,
786
+ }, 'Statistics')))
787
+ }
788
+
789
+ /**
790
+ * 日志弹窗主体(P0 蓝图实施:布局/过滤/排序/菜单/键盘/按钮组)。
791
+ * @author ddj 2026年09月16号 / 2026年09月17号
792
+ * @param props.target 目标路径('' = 工作区根)
793
+ * @param props.entries 日志条目(null = 未加载)
794
+ * @param props.truncated 是否可能还有更多
795
+ * @param props.error 错误文案
796
+ * @param props.busy 加载中
797
+ * @param props.onRefresh 刷新回调
798
+ * @param props.onLoadMore 加载更多回调
799
+ * @param props.onShowAll 取到上限回调(P0-6)
800
+ * @param props.onOpenDiff 打开版本差异(path, revision)
801
+ * @param props.onOpenFile 打开工作区文件(relPath)
802
+ * @param props.onComparePair 比较两个选中修订(revA, revB,按选中顺序;P1-2,缺省时菜单隐藏该项)
803
+ * @param props.onCompareWorking 某版本与工作副本比较(revision;P1-1,缺省时菜单隐藏该项)
804
+ * @param props.stopOnCopy 是否勾选 Stop on copy(P1-4)
805
+ * @param props.onStopCopy 勾选回调(on)
806
+ * @param props.includeMerged 是否勾选 Include merged revs(P1-5)
807
+ * @param props.onIncludeMerged 勾选回调(on)
808
+ * @param props.rangeActive 是否处于 Show Range 区间模式(P1-6)
809
+ * @param props.onShowRange 应用区间(start 较早,end 较晚;P1-6)
810
+ * @param props.onRangeReset 清除区间回到最新(P1-6)
811
+ * @param props.wcRev 文件目标的工作副本版号(P1-9;null = 不加粗,目录/根不传)
812
+ * @param props.onClose 关闭回调
813
+ * @returns 弹窗元素
814
+ */
815
+ export function SvnLogDialog(props) {
816
+ const {
817
+ target, entries, truncated, error, busy, onRefresh, onLoadMore, onShowAll, onOpenDiff, onOpenFile, onComparePair, onCompareWorking, stopOnCopy, onStopCopy, includeMerged, onIncludeMerged, rangeActive, onShowRange, onRangeReset, wcRev, onClose,
818
+ } = props
819
+ const [selected, setSelected] = React.useState([]) // P1-3:多选数组(保点击顺序)
820
+ const [anchorRev, setAnchorRev] = React.useState(null) // P1-3:Shift 范围基准 revision
821
+ const [query, setQuery] = React.useState('')
822
+ const [field, setField] = React.useState('all')
823
+ const [regexOn, setRegexOn] = React.useState(false)
824
+ const [from, setFrom] = React.useState('')
825
+ const [to, setTo] = React.useState('')
826
+ const [onlyAffected, setOnlyAffected] = React.useState(false)
827
+ const [sortKey, setSortKey] = React.useState(null)
828
+ const [sortDir, setSortDir] = React.useState('desc')
829
+ const [menu, setMenu] = React.useState(null)
830
+ const [dlgW, setDlgW] = React.useState(() => Math.min(DIALOG_W_DEFAULT, (typeof window !== 'undefined' ? window.innerWidth : 1280) - 24)) // P0-14:外框宽(拖拽受控)
831
+ const [dlgH, setDlgH] = React.useState(() => Math.min(DIALOG_H_DEFAULT, (typeof window !== 'undefined' ? window.innerHeight : 800) - 24)) // P0-14:外框高(拖拽受控)
832
+ const [listH, setListH] = React.useState(LIST_H_DEFAULT) // P0-14:上段修订列表高(拖拽受控)
833
+ const [msgH, setMsgH] = React.useState(MSG_H_DEFAULT) // P0-14:中段提交信息高(拖拽受控)
834
+ const [statsOpen, setStatsOpen] = React.useState(false) // P1-10:统计窗开关
835
+ const [rangeOpen, setRangeOpen] = React.useState(false) // P1-6:区间输入弹窗开关
836
+ const [rangeFrom, setRangeFrom] = React.useState('') // P1-6:起始修订(较早)
837
+ const [rangeTo, setRangeTo] = React.useState('') // P1-6:结束修订(较晚)
838
+ const bodyRef = React.useRef(null)
839
+
840
+ const list = Array.isArray(entries) ? entries : null
841
+ // 选中项:默认首条;数据刷新/过滤后仅保留仍存在的选中(G10:按 revision 维护),全部消失回落首条;
842
+ // anchor 同步维护(P1-3:消失即置空,下次 Shift 回落单选)
843
+ React.useEffect(() => {
844
+ if (!list || !list.length) { setSelected([]); setAnchorRev(null); return }
845
+ setSelected((prev) => {
846
+ const kept = prev.filter((item) => list.some((x) => x.revision === item.revision))
847
+ return kept.length ? kept : [list[0]]
848
+ })
849
+ setAnchorRev((prev) => (prev !== null && list.some((x) => x.revision === prev) ? prev : null))
850
+ }, [list])
851
+
852
+ // 过滤(P0-1 语法 + P0-3 日期区间)→ 排序(P0-12:先过滤后排序)
853
+ const filtered = React.useMemo(() => {
854
+ if (list === null) return null
855
+ const predicate = parseLogFilter(query, field, regexOn)
856
+ const range = dateRangePredicateOf(from, to)
857
+ return list.filter((item) => predicate(item) && range(item))
858
+ }, [list, query, field, regexOn, from, to])
859
+ const sorted = React.useMemo(
860
+ () => (filtered === null ? null : orderLogEntries(filtered, sortKey ?? 'revision', sortKey ? sortDir : 'desc')),
861
+ [filtered, sortKey, sortDir],
862
+ )
863
+ // P1-3:可见选中 = 选中数组 ∩ 当前显示列表;current = 可见选中首条(详情/菜单主语),空则回落首条
864
+ const visibleSelected = sorted
865
+ ? selected.filter((item) => sorted.some((x) => x.revision === item.revision))
866
+ : selected
867
+ const current = visibleSelected.length
868
+ ? visibleSelected[0]
869
+ : (sorted && sorted.length ? sorted[0] : null)
870
+
871
+ /** 点列头:同键切换升降,异键回落升序;复位入口在列头行(G10)。 */
872
+ const onSort = (key) => {
873
+ if (sortKey !== key) { setSortKey(key); setSortDir('asc'); return }
874
+ setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
875
+ }
876
+ const onSortReset = () => { setSortKey(null); setSortDir('desc') }
877
+
878
+ /**
879
+ * 多选点击解释(P1-3):单击=单选;Ctrl=切换;Shift=以 anchor 为基准在显示列表内范围选。
880
+ * anchor 缺失或不在显示列表时 Shift 回落单选(与选中项消失回落首条同策略)。
881
+ * @author ddj 2026年09月17号
882
+ * @param event 点击事件(读 ctrlKey/metaKey/shiftKey)
883
+ * @param item 被点条目
884
+ */
885
+ const pickWithMods = (event, item) => {
886
+ if (event.shiftKey && anchorRev !== null && sorted) {
887
+ const a = sorted.findIndex((x) => x.revision === anchorRev)
888
+ const b = sorted.findIndex((x) => x.revision === item.revision)
889
+ if (a >= 0 && b >= 0) {
890
+ setSelected(sorted.slice(Math.min(a, b), Math.max(a, b) + 1))
891
+ return // Shift 不移动 anchor(官方同款)
892
+ }
893
+ }
894
+ if (event.ctrlKey || event.metaKey) {
895
+ setSelected((prev) => {
896
+ const has = prev.some((x) => x.revision === item.revision)
897
+ return has ? prev.filter((x) => x.revision !== item.revision) : [...prev, item]
898
+ })
899
+ setAnchorRev(item.revision)
900
+ return
901
+ }
902
+ setSelected([item])
903
+ setAnchorRev(item.revision)
904
+ }
905
+
906
+ // P0-13 分栏拖拽:pointerdown 记起点,window 级 move/up 跟踪,apply 内按容器实时 clamp
907
+ const startDrag = (event, axis, current, apply) => {
908
+ event.preventDefault()
909
+ event.stopPropagation()
910
+ const startX = event.clientX
911
+ const startY = event.clientY
912
+ const move = (e) => {
913
+ const delta = axis === 'x' ? e.clientX - startX : e.clientY - startY
914
+ apply(current + delta)
915
+ }
916
+ const up = () => {
917
+ window.removeEventListener('pointermove', move)
918
+ window.removeEventListener('pointerup', up)
919
+ swallowNextClick()
920
+ }
921
+ window.addEventListener('pointermove', move)
922
+ window.addEventListener('pointerup', up)
923
+ }
924
+
925
+ // P0-14 外框调尺寸:mode 'r'=右缘调宽 / 'b'=底缘调高 / 'c'=角手柄双向;clamp 视口内
926
+ const startResize = (event, mode) => {
927
+ event.preventDefault()
928
+ event.stopPropagation()
929
+ const startX = event.clientX
930
+ const startY = event.clientY
931
+ const startW = dlgW
932
+ const startH = dlgH
933
+ const move = (e) => {
934
+ if (mode !== 'b') setDlgW(Math.min(window.innerWidth - 24, Math.max(DIALOG_W_MIN, startW + (e.clientX - startX))))
935
+ if (mode !== 'r') setDlgH(Math.min(window.innerHeight - 24, Math.max(DIALOG_H_MIN, startH + (e.clientY - startY))))
936
+ }
937
+ const up = () => {
938
+ window.removeEventListener('pointermove', move)
939
+ window.removeEventListener('pointerup', up)
940
+ swallowNextClick()
941
+ }
942
+ window.addEventListener('pointermove', move)
943
+ window.addEventListener('pointerup', up)
944
+ }
945
+
946
+ /** 上段修订列表高度拖拽(clamp:下限 200,上限 = body 高 - 信息区/路径区保留空间)。 */
947
+ const onListHDrag = (event) => startDrag(event, 'y', listH, (next) => {
948
+ const total = bodyRef.current?.clientHeight ?? 0
949
+ const max = Math.max(LIST_H_MIN + 80, total - msgH - PANE_KEEP_MIN)
950
+ setListH(Math.min(max, Math.max(LIST_H_MIN, next)))
951
+ })
952
+
953
+ /** 中段提交信息高度拖拽(clamp:下限 60,上限 = body 高 - 列表/路径区保留空间)。 */
954
+ const onMsgHDrag = (event) => startDrag(event, 'y', msgH, (next) => {
955
+ const total = bodyRef.current?.clientHeight ?? 0
956
+ const max = Math.max(MSG_H_MIN + 80, total - listH - PANE_KEEP_MIN)
957
+ setMsgH(Math.min(max, Math.max(MSG_H_MIN, next)))
958
+ })
959
+
960
+ // 键盘:F5 刷新(防浏览器整页刷新);↑↓ 在显示列表内移动选中(输入框内不劫持)
961
+ React.useEffect(() => {
962
+ const onKey = (event) => {
963
+ if (event.key === 'F5') { event.preventDefault(); onRefresh(); return }
964
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
965
+ const tag = String(event.target?.tagName ?? '').toLowerCase()
966
+ if (tag === 'input' || tag === 'select' || tag === 'textarea') return
967
+ if (!sorted || !sorted.length) return
968
+ event.preventDefault()
969
+ const at = sorted.findIndex((item) => item.revision === selected[0]?.revision)
970
+ const next = at < 0 ? 0 : Math.min(sorted.length - 1, Math.max(0, at + (event.key === 'ArrowDown' ? 1 : -1)))
971
+ const hit = sorted[next]
972
+ setSelected([hit]) // ↑↓ 折叠为单选(官方同款),并重置 Shift 基准
973
+ setAnchorRev(hit.revision)
974
+ }
975
+ window.addEventListener('keydown', onKey, true)
976
+ return () => window.removeEventListener('keydown', onKey, true)
977
+ }, [sorted, onRefresh, selected])
978
+
979
+ /** 复制当前选中修订(P0-9;剪贴板不可用静默忽略)。 */
980
+ const copyRevision = (entry) => {
981
+ if (!entry) return
982
+ copyTextOf(revisionClipboardText(entry))
983
+ }
984
+
985
+ /**
986
+ * 提交区间(P1-6):两版号均为正整数才生效;起始=较早、结束=较晚(svn -r START:END 语义)。
987
+ * @author ddj 2026年09月17号
988
+ */
989
+ const submitRange = () => {
990
+ const start = Math.floor(Number(rangeFrom))
991
+ const end = Math.floor(Number(rangeTo))
992
+ if (!Number.isFinite(start) || start < 1 || !Number.isFinite(end) || end < 1) return
993
+ setRangeOpen(false)
994
+ onShowRange?.(start, end)
995
+ }
996
+
997
+ /**
998
+ * 上窗格行右键菜单(P0-8 + P1-3 多选两项时插入「比较两个修订」,P1-2 由 onComparePair 承接)。
999
+ *
1000
+ * P1-5:merged 行的工作副本动作不可达(其 paths 属**源分支**、本地不存在),
1001
+ * 故「与工作副本比较」禁用并给出原因;「与上一版本比较」仍按该修订仓库路径可用。
1002
+ */
1003
+ const onItemMenu = (event, entry) => {
1004
+ event.preventDefault()
1005
+ event.stopPropagation()
1006
+ const pair = visibleSelected.length === 2 ? visibleSelected : null
1007
+ // 是否 merged 行:看该修订在显示列表中的层级(0 = 顶层)
1008
+ const mergedRow = levelInList(sorted, entry.revision) === 1
1009
+ setMenu({
1010
+ x: event.clientX, y: event.clientY,
1011
+ entries: [
1012
+ (pair && onComparePair
1013
+ ? { id: 'cmp-pair', label: '比较两个修订(r' + pair[0].revision + ' ↔ r' + pair[1].revision + ')', disabled: !target, hint: target ? undefined : '仅文件目标', onClick: () => onComparePair(pair[0].revision, pair[1].revision) }
1014
+ : null),
1015
+ { id: 'cmp-prev', label: '与上一版本比较', disabled: !target, hint: target ? undefined : '仅文件目标', onClick: () => onOpenDiff?.(target, entry.revision) },
1016
+ {
1017
+ id: 'cmp-work',
1018
+ label: '与工作副本比较',
1019
+ disabled: !target || mergedRow,
1020
+ hint: !target ? '仅文件目标' : (mergedRow ? '合并进来的修订属源分支,工作副本无对应文件' : undefined),
1021
+ onClick: () => onCompareWorking?.(entry.revision),
1022
+ },
1023
+ { id: 'copy-rev', label: '复制修订信息', onClick: () => copyRevision(entry) },
1024
+ ].filter(Boolean),
1025
+ })
1026
+ }
1027
+
1028
+ /** 下窗格路径行右键菜单(P0-8;G8 目录不可开差异,G9 仓库外不可打开)。 */
1029
+ const onPathMenu = (event, item, revision) => {
1030
+ event.preventDefault()
1031
+ event.stopPropagation()
1032
+ const outside = item.relPath === null
1033
+ const isDir = item.kind === 'dir'
1034
+ setMenu({
1035
+ x: event.clientX, y: event.clientY,
1036
+ entries: [
1037
+ { id: 'show-changes', label: '查看该版改动', disabled: outside || isDir, hint: isDir ? '目录无文本差异' : undefined, onClick: () => onOpenDiff?.(item.relPath, revision) },
1038
+ { id: 'open-local', label: '打开工作区文件', disabled: outside, onClick: () => onOpenFile?.(item.relPath) },
1039
+ { id: 'copy-path', label: '复制路径', onClick: () => copyTextOf(String(item.relPath ?? item.path)) },
1040
+ ],
1041
+ })
1042
+ }
1043
+
1044
+ const errLine = error
1045
+ ? React.createElement('div', { className: 'edrv-svnlog-error' }, String(error))
1046
+ : null
1047
+ // P0-14 上中下三段全宽:上=修订列表(高可拖)/ 中=提交信息(高可拖)/ 下=变更路径(占余)
1048
+ const body = React.createElement('div', { className: 'edrv-svnlog-body', ref: bodyRef },
1049
+ React.createElement(LogList, {
1050
+ list: sorted, selected: visibleSelected, keyword: query.trim(), busy, truncated,
1051
+ sortKey, sortDir, onSort, onSortReset,
1052
+ onPick: pickWithMods, onLoadMore, onItemMenu,
1053
+ height: listH, wcRev,
1054
+ }),
1055
+ React.createElement(Splitter, {
1056
+ title: '调整修订列表高度', onDragStart: onListHDrag, onReset: () => setListH(LIST_H_DEFAULT),
1057
+ }),
1058
+ React.createElement('div', { className: 'edrv-svnlog-pane edrv-svnlog-msgpane', style: { height: msgH } },
1059
+ React.createElement(LogMsgInner, { selected: visibleSelected })),
1060
+ React.createElement(Splitter, {
1061
+ title: '调整提交信息区高度', onDragStart: onMsgHDrag, onReset: () => setMsgH(MSG_H_DEFAULT),
1062
+ }),
1063
+ React.createElement('div', { className: 'edrv-svnlog-pane edrv-svnlog-pathspane' },
1064
+ React.createElement(LogPathsInner, { selected: visibleSelected, target, onlyAffected, onOpenFile, onOpenDiff, onPathMenu })))
1065
+
1066
+ return React.createElement(ModalShell, {
1067
+ dialogClass: 'edrv-svnlog-dialog',
1068
+ width: Math.round(dlgW) + 'px',
1069
+ cardStyle: { height: Math.round(dlgH) + 'px' },
1070
+ onClose,
1071
+ },
1072
+ React.createElement(LogHead, { target, onClose }),
1073
+ React.createElement(LogFilterBar, {
1074
+ field, regexOn, query, from, to,
1075
+ onField: setField, onRegex: setRegexOn, onQuery: setQuery, onFrom: setFrom, onTo: setTo,
1076
+ }),
1077
+ errLine,
1078
+ body,
1079
+ React.createElement(LogFoot, {
1080
+ list, filtered: sorted, shownCount: sorted ? sorted.length : 0, keyword: query.trim(), truncated, current,
1081
+ selectedCount: visibleSelected.length, onlyAffected, onOnlyAffected: setOnlyAffected, busy, onLoadMore, onShowAll, onRefresh,
1082
+ onStats: () => setStatsOpen(true), stopOnCopy, onStopCopy, includeMerged, onIncludeMerged,
1083
+ rangeActive: rangeActive === true, onOpenRange: () => setRangeOpen(true), onRangeReset: () => onRangeReset?.(),
1084
+ }),
1085
+ React.createElement(Grip, { mode: 'r', onDragStart: startResize, onReset: () => { setDlgW(DIALOG_W_DEFAULT); setDlgH(DIALOG_H_DEFAULT) } }),
1086
+ React.createElement(Grip, { mode: 'b', onDragStart: startResize, onReset: () => { setDlgW(DIALOG_W_DEFAULT); setDlgH(DIALOG_H_DEFAULT) } }),
1087
+ React.createElement(Grip, { mode: 'c', onDragStart: startResize, onReset: () => { setDlgW(DIALOG_W_DEFAULT); setDlgH(DIALOG_H_DEFAULT) } }),
1088
+ (menu ? React.createElement(ContextMenu, { x: menu.x, y: menu.y, entries: menu.entries, onClose: () => setMenu(null) }) : null),
1089
+ (statsOpen ? React.createElement(SvnLogStats, { key: 'edrv-svnlog-stats', entries: sorted, onClose: () => setStatsOpen(false) }) : null),
1090
+ (rangeOpen ? React.createElement(ModalShell, {
1091
+ key: 'edrv-svnlog-range',
1092
+ dialogClass: 'edrv-svnlog-range',
1093
+ width: 'min(430px, calc(100vw - 40px))',
1094
+ zIndex: 192,
1095
+ onClose: () => setRangeOpen(false),
1096
+ },
1097
+ React.createElement('div', { className: 'edrv-svnlog-range-body' },
1098
+ React.createElement('h3', { className: 'edrv-svnlog-title' }, '显示区间(Show Range)'),
1099
+ React.createElement('div', { className: 'edrv-svnlog-range-row' },
1100
+ React.createElement('label', { className: 'edrv-svnlog-check' },
1101
+ '起始修订(较早)',
1102
+ React.createElement('input', {
1103
+ className: 'edrv-svnlog-range-input', type: 'number', min: 1, value: rangeFrom,
1104
+ onChange: (event) => setRangeFrom(event.target.value),
1105
+ })),
1106
+ React.createElement('label', { className: 'edrv-svnlog-check' },
1107
+ '结束修订(较晚)',
1108
+ React.createElement('input', {
1109
+ className: 'edrv-svnlog-range-input', type: 'number', min: 1, value: rangeTo,
1110
+ onChange: (event) => setRangeTo(event.target.value),
1111
+ }))),
1112
+ React.createElement('div', { className: 'edrv-svnlog-hint' }, 'svn 语义 -r 起始:结束;列表仍按版本号倒序显示,统计与状态行覆盖区间内条目'),
1113
+ React.createElement('div', { className: 'edrv-svnlog-range-btns' },
1114
+ React.createElement('button', { className: 'edrv-svn-act', onClick: () => setRangeOpen(false) }, '取消'),
1115
+ React.createElement('button', {
1116
+ className: 'edrv-svn-act',
1117
+ disabled: !(Number(rangeFrom) >= 1 && Number(rangeTo) >= 1),
1118
+ onClick: submitRange,
1119
+ }, '确定'))))
1120
+ : null))
1121
+ }
1122
+
1123
+ /**
1124
+ * 日期区间判定(P0-3:From/To 含当日全天;解析失败的条目不因区间被过滤)。
1125
+ * @author ddj 2026年09月17号
1126
+ * @param from 起始日(YYYY-MM-DD 或空)
1127
+ * @param to 结束日(YYYY-MM-DD 或空)
1128
+ * @returns 判定函数
1129
+ */
1130
+ function dateRangePredicateOf(from, to) {
1131
+ if (!from && !to) return () => true
1132
+ const min = from ? new Date(from + 'T00:00:00').getTime() : null
1133
+ const max = to ? new Date(to + 'T23:59:59.999').getTime() : null
1134
+ return (entry) => {
1135
+ const at = new Date(String(entry.date ?? '')).getTime()
1136
+ if (Number.isNaN(at)) return true
1137
+ if (min !== null && at < min) return false
1138
+ if (max !== null && at > max) return false
1139
+ return true
1140
+ }
1141
+ }