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
|
@@ -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
|
+
}
|