zen-gitsync 2.15.11 → 2.15.12

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 (69) hide show
  1. package/package.json +3 -3
  2. package/src/gitCommit.js +475 -475
  3. package/src/ui/public/assets/AppVersionBadge-BaXEI9Aq.js +2 -0
  4. package/src/ui/public/assets/BranchSelector-DJhEF0y9.js +1 -0
  5. package/src/ui/public/assets/CommitForm-BfpcK-bz.js +6 -0
  6. package/src/ui/public/assets/ConsoleView-D838O0O6.js +17 -0
  7. package/src/ui/public/assets/CustomCommandManager-BcCK46df.js +1 -0
  8. package/src/ui/public/assets/EditorView-DnnqYNGF.js +0 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-CJgoxffE.js → FlowExecutionViewer-CSlMmcDS.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-UgHpZ3FR.js +21 -0
  11. package/src/ui/public/assets/LogList-uUgskwwp.js +7 -0
  12. package/src/ui/public/assets/MindmapView-CCmFMiHR.js +1 -0
  13. package/src/ui/public/assets/MonitorView-BxoqeX70.js +1 -0
  14. package/src/ui/public/assets/ProjectStartupButton-C3AwivIB.js +1 -0
  15. package/src/ui/public/assets/{RemoteRepoCard-CKQguRWi.js → RemoteRepoCard-BbxeVpje.js} +1 -1
  16. package/src/ui/public/assets/SourceMapView-DKv-Y6t_.js +3 -0
  17. package/src/ui/public/assets/{SvgIcon-DOkE0Qie.js → SvgIcon-sMM8XFgm.js} +1 -1
  18. package/src/ui/public/assets/TemplateManager-Dzj8rXVw.js +1 -0
  19. package/src/ui/public/assets/UserInputNode-BU7Trzq-.js +1 -0
  20. package/src/ui/public/assets/WorkbenchView-DTgRoOkv.js +12 -0
  21. package/src/ui/public/assets/{_plugin-vue_export-helper-DvV-QrsM.js → _plugin-vue_export-helper-8ryIYVvs.js} +1 -1
  22. package/src/ui/public/assets/{configStore-BRR4SloR.js → configStore-D77uNk8X.js} +1 -1
  23. package/src/ui/public/assets/element-plus-D8SQiesr.js +17 -0
  24. package/src/ui/public/assets/element-plus-DB_sOyH_.css +1 -0
  25. package/src/ui/public/assets/{flow-mindmap-BMvnvRl8.js → flow-mindmap-BLa-kSA7.js} +1 -1
  26. package/src/ui/public/assets/index-CpFreGqX.js +23 -0
  27. package/src/ui/public/assets/{socket-io-C2w3MxRu.js → socket-io-B1foIbJS.js} +1 -1
  28. package/src/ui/public/assets/{vendor--LaaJk9m.js → vendor-B9orL8iU.js} +37 -37
  29. package/src/ui/public/assets/vendor-Dm_yd-By.css +1 -0
  30. package/src/ui/public/assets/{vue-flow-MtbKS8yD.js → vue-flow-BY7LODit.js} +1 -1
  31. package/src/ui/public/favicon.svg +75 -75
  32. package/src/ui/public/index.html +27 -27
  33. package/src/ui/public/logo.svg +74 -74
  34. package/src/ui/server/middleware/requestLogger.js +53 -53
  35. package/src/ui/server/routes/branchStatus.js +105 -105
  36. package/src/ui/server/routes/fileOpen.js +309 -309
  37. package/src/ui/server/routes/fs.js +791 -791
  38. package/src/ui/server/routes/git/diff.js +426 -426
  39. package/src/ui/server/routes/git/diffUtils.js +149 -149
  40. package/src/ui/server/routes/git/stash.js +550 -550
  41. package/src/ui/server/routes/git/tags.js +176 -176
  42. package/src/ui/server/routes/git.js +181 -181
  43. package/src/ui/server/routes/gitOps.js +1211 -1211
  44. package/src/ui/server/routes/process.js +86 -86
  45. package/src/ui/server/routes/status.js +67 -67
  46. package/src/ui/server/utils/createSavePortToFile.js +48 -48
  47. package/src/ui/server/utils/instanceRegistry.js +282 -282
  48. package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
  49. package/src/utils/index.js +1168 -1168
  50. package/src/ui/public/assets/AppVersionBadge-CEhrATQo.js +0 -2
  51. package/src/ui/public/assets/BranchSelector-xLl3O-4I.js +0 -1
  52. package/src/ui/public/assets/CommitForm--izGtr_x.js +0 -6
  53. package/src/ui/public/assets/ConsoleView-BLYfnXAy.js +0 -17
  54. package/src/ui/public/assets/CustomCommandManager-D8Qd3b9m.js +0 -1
  55. package/src/ui/public/assets/EditorView-DG8tqp3L.js +0 -0
  56. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAIqeASp.js +0 -21
  57. package/src/ui/public/assets/LogList-DjuL8nWB.js +0 -7
  58. package/src/ui/public/assets/MindmapView-DPomtQsu.js +0 -1
  59. package/src/ui/public/assets/MonitorView-gZhz528u.js +0 -1
  60. package/src/ui/public/assets/ProjectStartupButton-C8nDEd8N.js +0 -1
  61. package/src/ui/public/assets/SourceMapView-DfSRy6lc.js +0 -3
  62. package/src/ui/public/assets/TemplateManager-BJZloJIO.js +0 -1
  63. package/src/ui/public/assets/UserInputNode-DpRNQzrz.js +0 -1
  64. package/src/ui/public/assets/WorkbenchView-vaZsec5Z.js +0 -12
  65. package/src/ui/public/assets/element-plus-CL7fBdM_.js +0 -24
  66. package/src/ui/public/assets/element-plus-Cu9Y2raX.css +0 -1
  67. package/src/ui/public/assets/index-BAFCDhj2.js +0 -23
  68. package/src/ui/public/assets/vendor-CCbwTgrE.css +0 -1
  69. package/src/ui/server/.claude/codediff.txt +0 -8
@@ -1,426 +1,426 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- import fs from 'fs/promises';
16
- import path from 'path';
17
- import logger from '../../utils/logger.js'
18
- import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
19
-
20
- import { createDiffHelpers } from './diffUtils.js';
21
-
22
- import { ensureWithinCwd } from '../../utils/pathGuard.js';
23
-
24
- /**
25
- * SEC-PATH-1: resolve user path to be inside process.cwd(), returns null on escape
26
- * @param {string} userPath
27
- * @returns {Promise<string|null>} 绝对路径(已校验在 cwd 内),或 null(越界)
28
- */
29
- async function safePathInProject(userPath) {
30
- if (typeof userPath !== 'string' || !userPath) return null;
31
- if (/[\x00-\x1f]/.test(userPath)) return null;
32
- const cwd = process.cwd();
33
- const result = await ensureWithinCwd(userPath, cwd);
34
- return result ? result.safePath : null;
35
- }
36
-
37
- /**
38
- * 把 safePathInProject 返回的绝对路径转成相对于仓库根的路径(正斜杠),
39
- * 用于拼接 git rev:path 语法(git show/cat-file 不接受绝对路径)。
40
- * @param {string} safeFilePath - 已校验在 cwd 内的绝对路径
41
- * @returns {string} 仓库根的相对路径(正斜杠分隔)
42
- */
43
- function toGitRepoRelativePath(safeFilePath) {
44
- const rel = path.relative(process.cwd(), safeFilePath);
45
- // git 内部统一用正斜杠,Windows 反斜杠会被 git show 当成转义字符导致 notFound
46
- return rel.replace(/\\/g, '/');
47
- }
48
-
49
- export function registerGitDiffRoutes({
50
- app,
51
- execGitCommand
52
- }) {
53
- const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });
54
-
55
- const skipExtensions = /\.(min\.js|umd\.cjs|bundle\.js|dist\.js|prod\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;
56
- const maxBytes = 1024 * 1024;
57
-
58
- // 获取文件差异
59
- app.get('/api/diff', asyncRoute(async (req, res) => {
60
- try {
61
- const filePath = req.query.file;
62
-
63
- if (!filePath) {
64
- return res.status(400).json({ error: '缺少文件路径参数' });
65
- }
66
-
67
- const diffArgs = ['diff', '--', filePath];
68
-
69
- // 使用优化的检查函数(diffCommand 用于内部 numstat 检测的字符串转换,
70
- // 实际走 git 时用 argv 数组,避免 Windows cmd.exe 拼引号问题)
71
- const skipCheck = await checkShouldSkipDiff(filePath, `git diff -- "${filePath}"`);
72
- if (skipCheck.shouldSkip) {
73
- return res.json({
74
- diff: skipCheck.reason,
75
- isLargeFile: true,
76
- stats: skipCheck.stats
77
- });
78
- }
79
-
80
- // 执行git diff命令获取文件差异
81
- const { stdout } = await execGitCommand(diffArgs);
82
-
83
- // 检查实际diff大小
84
- const sizeCheck = checkDiffSize(stdout, 500);
85
- if (sizeCheck) {
86
- return res.json(sizeCheck);
87
- }
88
-
89
- // 统计增加和删除行数
90
- const stats = getDiffStats(stdout);
91
-
92
- res.json({ diff: stdout, stats });
93
- } catch (error) {
94
- res.status(500).json({ error: error.message });
95
- }
96
- }));
97
- // 获取已暂存文件差异
98
- app.get('/api/diff-cached', asyncRoute(async (req, res) => {
99
- try {
100
- const filePath = req.query.file;
101
-
102
- if (!filePath) {
103
- return res.status(400).json({ error: '缺少文件路径参数' });
104
- }
105
-
106
- const diffArgs = ['diff', '--cached', '--', filePath];
107
-
108
- // 使用优化的检查函数
109
- const skipCheck = await checkShouldSkipDiff(filePath, `git diff --cached -- "${filePath}"`);
110
- if (skipCheck.shouldSkip) {
111
- return res.json({
112
- diff: skipCheck.reason,
113
- isLargeFile: true,
114
- stats: skipCheck.stats
115
- });
116
- }
117
-
118
- // 执行git diff --cached命令获取已暂存文件差异
119
- const { stdout } = await execGitCommand(diffArgs);
120
-
121
- // 检查实际diff大小
122
- const sizeCheck = checkDiffSize(stdout, 500);
123
- if (sizeCheck) {
124
- return res.json(sizeCheck);
125
- }
126
-
127
- // 统计增加和删除行数
128
- const stats = getDiffStats(stdout);
129
-
130
- res.json({ diff: stdout, stats });
131
- } catch (error) {
132
- res.status(500).json({ error: error.message });
133
- }
134
- }));
135
-
136
- // 获取全量 diff(git diff HEAD,含已暂存与未暂存的所有变更)
137
- app.get('/api/diff-head', asyncRoute(async (req, res) => {
138
- try {
139
- const { stdout } = await execGitCommand(['diff', 'HEAD']);
140
- const MAX = 500 * 1024;
141
- const content = stdout.length > MAX
142
- ? stdout.slice(0, MAX) + '\n\n[内容过大,已截断]'
143
- : stdout;
144
- res.json({ success: true, diff: content });
145
- } catch (error) {
146
- res.status(500).json({ success: false, error: error.message });
147
- }
148
- }));
149
-
150
- // 获取文件内容 (用于未跟踪文件)
151
- app.get('/api/file-content', asyncRoute(async (req, res) => {
152
- try {
153
- const filePath = req.query.file;
154
-
155
- if (!filePath) {
156
- return res.status(400).json({ error: '缺少文件路径参数' });
157
- }
158
-
159
- // SEC-PATH-1:resolve 到 cwd 内,越界返回 403
160
- const safeFilePath = await safePathInProject(String(filePath));
161
- if (!safeFilePath) {
162
- return res.status(403).json({ error: '禁止访问工作目录以外的文件' });
163
- }
164
-
165
- try {
166
- // 二进制/产物文件:直接告知前端 isBinary,不读取内容
167
- if (skipExtensions.test(safeFilePath)) {
168
- const isImage = /\.(png|jpg|jpeg|gif|webp|bmp|ico|svg)$/i.test(safeFilePath);
169
- return res.json({
170
- success: true,
171
- isBinary: true,
172
- isImage,
173
- content: isImage
174
- ? '⚠️ 该文件是图片,建议在预览中查看。'
175
- : '⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。'
176
- });
177
- }
178
-
179
- // 读取文件内容(走 safePath,不是 user input)
180
- const content = await fs.readFile(safeFilePath, 'utf8');
181
- res.json({ success: true, content });
182
- } catch (readError) {
183
- res.status(500).json({ success: false, error: `无法读取文件: ${readError.message}` });
184
- }
185
- } catch (error) {
186
- res.status(500).json({ error: error.message });
187
- }
188
- }));
189
-
190
- app.get('/api/git-file-content', asyncRoute(async (req, res) => {
191
- try {
192
- const filePath = req.query.file;
193
- const rev = req.query.rev;
194
-
195
- if (!filePath || !rev) {
196
- throw new HttpError(400, '缺少必要参数');
197
- }
198
-
199
- // SEC-PATH-1:路径必须在 cwd 内,越界返回 403
200
- const safeFilePath = await safePathInProject(String(filePath));
201
- if (!safeFilePath) {
202
- return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
203
- }
204
-
205
- if (skipExtensions.test(safeFilePath)) {
206
- return res.json({
207
- success: true,
208
- isBinary: true,
209
- content: '⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。'
210
- });
211
- }
212
-
213
- const r = String(rev);
214
- // git show/cat-file 的 rev:path 语法要求 path 必须是相对于仓库根的路径,
215
- // 不接受绝对路径(safeFilePath 是绝对路径,直接拼 spec 会导致 git show 报
216
- // "path exists on disk, but not in 'HEAD'" → 前端拿到 notFound:true)。
217
- // 这里用 toGitRepoRelativePath 转成仓库根相对路径,并把 Windows 反斜杠
218
- // 替换为正斜杠(git 内部统一用 /)。
219
- const relPath = toGitRepoRelativePath(safeFilePath);
220
- const spec = r === ':' ? `:${relPath}` : `${r}:${relPath}`;
221
-
222
- let sizeBytes = 0;
223
- try {
224
- const { stdout: sizeOut } = await execGitCommand(['cat-file', '-s', spec], { log: false });
225
- sizeBytes = parseInt(String(sizeOut).trim(), 10) || 0;
226
- } catch (e) {
227
- return res.json({ success: true, notFound: true, content: '' });
228
- }
229
-
230
- if (sizeBytes > maxBytes) {
231
- return res.json({
232
- success: true,
233
- isLargeFile: true,
234
- size: sizeBytes,
235
- content: `⚠️ 文件内容过大 (${(sizeBytes / 1024).toFixed(1)} KB),已跳过显示以避免浏览器卡顿。`
236
- });
237
- }
238
-
239
- const { stdout } = await execGitCommand(['show', spec], { log: false });
240
- return res.json({ success: true, content: stdout ?? '' });
241
- } catch (error) {
242
- res.status(500).json({ success: false, error: error.message });
243
- }
244
- }));
245
-
246
- // 解决冲突:保存解决后的文件内容
247
- app.post('/api/resolve-conflict', asyncRoute(async (req, res) => {
248
- try {
249
- const { filePath, content } = req.body;
250
-
251
- if (!filePath) {
252
- return res.status(400).json({
253
- success: false,
254
- error: '缺少文件路径参数'
255
- });
256
- }
257
-
258
- // SEC-PATH-1:resolve 到 cwd 内,越界 403
259
- const safeFilePath = await safePathInProject(String(filePath));
260
- if (!safeFilePath) {
261
- return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
262
- }
263
-
264
- if (content === undefined) {
265
- return res.status(400).json({
266
- success: false,
267
- error: '缺少文件内容参数'
268
- });
269
- }
270
-
271
- try {
272
- // 写入解决后的内容到文件(走 safePath)
273
- await fs.writeFile(safeFilePath, content, 'utf8');
274
-
275
- res.json({
276
- success: true,
277
- message: '冲突已解决,文件已更新'
278
- });
279
- } catch (writeError) {
280
- res.status(500).json({
281
- success: false,
282
- error: `保存文件失败: ${writeError.message}`
283
- });
284
- }
285
- } catch (error) {
286
- logger.error('解决冲突失败:', error);
287
- res.status(500).json({
288
- success: false,
289
- error: `解决冲突失败: ${error.message}`
290
- });
291
- }
292
- }));
293
-
294
- // 撤回文件修改
295
- app.post('/api/revert_file', asyncRoute(async (req, res) => {
296
- try {
297
- const { filePath } = req.body;
298
-
299
- if (!filePath) {
300
- return res.status(400).json({
301
- success: false,
302
- error: '缺少文件路径参数'
303
- });
304
- }
305
-
306
- // SEC-PATH-1:resolve 到 cwd 内
307
- const safeFilePath = await safePathInProject(String(filePath));
308
- if (!safeFilePath) {
309
- return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
310
- }
311
-
312
- // 检查文件状态:未跟踪文件需要删除,修改文件需要恢复
313
- // pathspec 用相对路径(避免 Windows 绝对路径在某些 git 版本下的边界问题)
314
- const relFilePath = toGitRepoRelativePath(safeFilePath);
315
- const { stdout: statusOutput } = await execGitCommand(['status', '--porcelain', '--', relFilePath]);
316
-
317
- // 未跟踪的文件 (??), 需要删除它
318
- if (statusOutput.startsWith('??')) {
319
- try {
320
- await fs.unlink(safeFilePath);
321
- return res.json({ success: true, message: '未跟踪的文件已删除' });
322
- } catch (error) {
323
- return res.status(500).json({
324
- success: false,
325
- error: `删除文件失败: ${error.message}`
326
- });
327
- }
328
- }
329
- // 已暂存的文件,先取消暂存
330
- else if (statusOutput.startsWith('A ') || statusOutput.startsWith('M ') || statusOutput.startsWith('D ')) {
331
- // 先取消暂存
332
- await execGitCommand(['reset', 'HEAD', '--', relFilePath]);
333
- }
334
-
335
- // 已修改文件,取消所有本地修改
336
- if (statusOutput) {
337
- await execGitCommand(['checkout', '--', relFilePath]);
338
- return res.json({ success: true, message: '文件修改已撤回' });
339
- } else {
340
- return res.status(400).json({
341
- success: false,
342
- error: '文件没有修改或不存在'
343
- });
344
- }
345
- } catch (error) {
346
- logger.error('撤回文件修改失败:', error);
347
- res.status(500).json({
348
- success: false,
349
- error: `撤回文件修改失败: ${error.message}`
350
- });
351
- }
352
- }));
353
-
354
- // 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)
355
- // body: { filePaths: string[] }
356
- // 返回: { success, count, results: [{ path, success, error? }] }
357
- app.post('/api/revert_files', asyncRoute(async (req, res) => {
358
- const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []
359
- if (filePaths.length === 0) {
360
- return res.status(400).json({ success: false, error: '缺少文件路径参数' })
361
- }
362
-
363
- // 批量大小限制,防止 DoS
364
- const MAX_BATCH = 200;
365
- if (filePaths.length > MAX_BATCH) {
366
- return res.status(400).json({ success: false, error: `批量大小不能超过 ${MAX_BATCH}` });
367
- }
368
-
369
- const results = []
370
- let successCount = 0
371
-
372
- for (const filePath of filePaths) {
373
- try {
374
- // SEC-PATH-1:每个 path 都校验在 cwd 内,越界直接报错
375
- const safeFilePath = await safePathInProject(String(filePath));
376
- if (!safeFilePath) {
377
- results.push({ path: filePath, success: false, error: '禁止访问工作目录以外的文件' });
378
- continue;
379
- }
380
-
381
- // 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)
382
- // pathspec 用相对路径,避免 Windows 绝对路径的潜在问题
383
- const relFilePath = toGitRepoRelativePath(safeFilePath);
384
- const { stdout: statusOutput } = await execGitCommand(['status', '--porcelain', '--', relFilePath])
385
-
386
- // 未跟踪的文件 (??) → 直接删除
387
- if (statusOutput.startsWith('??')) {
388
- try {
389
- await fs.unlink(safeFilePath)
390
- results.push({ path: filePath, success: true, message: '未跟踪的文件已删除' })
391
- successCount++
392
- continue
393
- } catch (err) {
394
- results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })
395
- continue
396
- }
397
- }
398
-
399
- // 已暂存的文件,先取消暂存(不影响工作区)
400
- if (statusOutput.startsWith('A ') || statusOutput.startsWith('M ') || statusOutput.startsWith('D ')) {
401
- await execGitCommand(['reset', 'HEAD', '--', relFilePath])
402
- }
403
-
404
- // 已修改文件:丢弃工作区修改
405
- if (statusOutput) {
406
- await execGitCommand(['checkout', '--', relFilePath])
407
- results.push({ path: filePath, success: true, message: '文件修改已撤回' })
408
- successCount++
409
- } else {
410
- // 文件已无修改(可能在并发中被处理掉了)
411
- results.push({ path: filePath, success: true, message: '文件无修改' })
412
- successCount++
413
- }
414
- } catch (err) {
415
- results.push({ path: filePath, success: false, error: err?.message || String(err) })
416
- }
417
- }
418
-
419
- res.json({
420
- success: true,
421
- count: filePaths.length,
422
- successCount,
423
- results
424
- })
425
- }));
426
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import fs from 'fs/promises';
16
+ import path from 'path';
17
+ import logger from '../../utils/logger.js'
18
+ import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
19
+
20
+ import { createDiffHelpers } from './diffUtils.js';
21
+
22
+ import { ensureWithinCwd } from '../../utils/pathGuard.js';
23
+
24
+ /**
25
+ * SEC-PATH-1: resolve user path to be inside process.cwd(), returns null on escape
26
+ * @param {string} userPath
27
+ * @returns {Promise<string|null>} 绝对路径(已校验在 cwd 内),或 null(越界)
28
+ */
29
+ async function safePathInProject(userPath) {
30
+ if (typeof userPath !== 'string' || !userPath) return null;
31
+ if (/[\x00-\x1f]/.test(userPath)) return null;
32
+ const cwd = process.cwd();
33
+ const result = await ensureWithinCwd(userPath, cwd);
34
+ return result ? result.safePath : null;
35
+ }
36
+
37
+ /**
38
+ * 把 safePathInProject 返回的绝对路径转成相对于仓库根的路径(正斜杠),
39
+ * 用于拼接 git rev:path 语法(git show/cat-file 不接受绝对路径)。
40
+ * @param {string} safeFilePath - 已校验在 cwd 内的绝对路径
41
+ * @returns {string} 仓库根的相对路径(正斜杠分隔)
42
+ */
43
+ function toGitRepoRelativePath(safeFilePath) {
44
+ const rel = path.relative(process.cwd(), safeFilePath);
45
+ // git 内部统一用正斜杠,Windows 反斜杠会被 git show 当成转义字符导致 notFound
46
+ return rel.replace(/\\/g, '/');
47
+ }
48
+
49
+ export function registerGitDiffRoutes({
50
+ app,
51
+ execGitCommand
52
+ }) {
53
+ const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });
54
+
55
+ const skipExtensions = /\.(min\.js|umd\.cjs|bundle\.js|dist\.js|prod\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;
56
+ const maxBytes = 1024 * 1024;
57
+
58
+ // 获取文件差异
59
+ app.get('/api/diff', asyncRoute(async (req, res) => {
60
+ try {
61
+ const filePath = req.query.file;
62
+
63
+ if (!filePath) {
64
+ return res.status(400).json({ error: '缺少文件路径参数' });
65
+ }
66
+
67
+ const diffArgs = ['diff', '--', filePath];
68
+
69
+ // 使用优化的检查函数(diffCommand 用于内部 numstat 检测的字符串转换,
70
+ // 实际走 git 时用 argv 数组,避免 Windows cmd.exe 拼引号问题)
71
+ const skipCheck = await checkShouldSkipDiff(filePath, `git diff -- "${filePath}"`);
72
+ if (skipCheck.shouldSkip) {
73
+ return res.json({
74
+ diff: skipCheck.reason,
75
+ isLargeFile: true,
76
+ stats: skipCheck.stats
77
+ });
78
+ }
79
+
80
+ // 执行git diff命令获取文件差异
81
+ const { stdout } = await execGitCommand(diffArgs);
82
+
83
+ // 检查实际diff大小
84
+ const sizeCheck = checkDiffSize(stdout, 500);
85
+ if (sizeCheck) {
86
+ return res.json(sizeCheck);
87
+ }
88
+
89
+ // 统计增加和删除行数
90
+ const stats = getDiffStats(stdout);
91
+
92
+ res.json({ diff: stdout, stats });
93
+ } catch (error) {
94
+ res.status(500).json({ error: error.message });
95
+ }
96
+ }));
97
+ // 获取已暂存文件差异
98
+ app.get('/api/diff-cached', asyncRoute(async (req, res) => {
99
+ try {
100
+ const filePath = req.query.file;
101
+
102
+ if (!filePath) {
103
+ return res.status(400).json({ error: '缺少文件路径参数' });
104
+ }
105
+
106
+ const diffArgs = ['diff', '--cached', '--', filePath];
107
+
108
+ // 使用优化的检查函数
109
+ const skipCheck = await checkShouldSkipDiff(filePath, `git diff --cached -- "${filePath}"`);
110
+ if (skipCheck.shouldSkip) {
111
+ return res.json({
112
+ diff: skipCheck.reason,
113
+ isLargeFile: true,
114
+ stats: skipCheck.stats
115
+ });
116
+ }
117
+
118
+ // 执行git diff --cached命令获取已暂存文件差异
119
+ const { stdout } = await execGitCommand(diffArgs);
120
+
121
+ // 检查实际diff大小
122
+ const sizeCheck = checkDiffSize(stdout, 500);
123
+ if (sizeCheck) {
124
+ return res.json(sizeCheck);
125
+ }
126
+
127
+ // 统计增加和删除行数
128
+ const stats = getDiffStats(stdout);
129
+
130
+ res.json({ diff: stdout, stats });
131
+ } catch (error) {
132
+ res.status(500).json({ error: error.message });
133
+ }
134
+ }));
135
+
136
+ // 获取全量 diff(git diff HEAD,含已暂存与未暂存的所有变更)
137
+ app.get('/api/diff-head', asyncRoute(async (req, res) => {
138
+ try {
139
+ const { stdout } = await execGitCommand(['diff', 'HEAD']);
140
+ const MAX = 500 * 1024;
141
+ const content = stdout.length > MAX
142
+ ? stdout.slice(0, MAX) + '\n\n[内容过大,已截断]'
143
+ : stdout;
144
+ res.json({ success: true, diff: content });
145
+ } catch (error) {
146
+ res.status(500).json({ success: false, error: error.message });
147
+ }
148
+ }));
149
+
150
+ // 获取文件内容 (用于未跟踪文件)
151
+ app.get('/api/file-content', asyncRoute(async (req, res) => {
152
+ try {
153
+ const filePath = req.query.file;
154
+
155
+ if (!filePath) {
156
+ return res.status(400).json({ error: '缺少文件路径参数' });
157
+ }
158
+
159
+ // SEC-PATH-1:resolve 到 cwd 内,越界返回 403
160
+ const safeFilePath = await safePathInProject(String(filePath));
161
+ if (!safeFilePath) {
162
+ return res.status(403).json({ error: '禁止访问工作目录以外的文件' });
163
+ }
164
+
165
+ try {
166
+ // 二进制/产物文件:直接告知前端 isBinary,不读取内容
167
+ if (skipExtensions.test(safeFilePath)) {
168
+ const isImage = /\.(png|jpg|jpeg|gif|webp|bmp|ico|svg)$/i.test(safeFilePath);
169
+ return res.json({
170
+ success: true,
171
+ isBinary: true,
172
+ isImage,
173
+ content: isImage
174
+ ? '⚠️ 该文件是图片,建议在预览中查看。'
175
+ : '⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。'
176
+ });
177
+ }
178
+
179
+ // 读取文件内容(走 safePath,不是 user input)
180
+ const content = await fs.readFile(safeFilePath, 'utf8');
181
+ res.json({ success: true, content });
182
+ } catch (readError) {
183
+ res.status(500).json({ success: false, error: `无法读取文件: ${readError.message}` });
184
+ }
185
+ } catch (error) {
186
+ res.status(500).json({ error: error.message });
187
+ }
188
+ }));
189
+
190
+ app.get('/api/git-file-content', asyncRoute(async (req, res) => {
191
+ try {
192
+ const filePath = req.query.file;
193
+ const rev = req.query.rev;
194
+
195
+ if (!filePath || !rev) {
196
+ throw new HttpError(400, '缺少必要参数');
197
+ }
198
+
199
+ // SEC-PATH-1:路径必须在 cwd 内,越界返回 403
200
+ const safeFilePath = await safePathInProject(String(filePath));
201
+ if (!safeFilePath) {
202
+ return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
203
+ }
204
+
205
+ if (skipExtensions.test(safeFilePath)) {
206
+ return res.json({
207
+ success: true,
208
+ isBinary: true,
209
+ content: '⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。'
210
+ });
211
+ }
212
+
213
+ const r = String(rev);
214
+ // git show/cat-file 的 rev:path 语法要求 path 必须是相对于仓库根的路径,
215
+ // 不接受绝对路径(safeFilePath 是绝对路径,直接拼 spec 会导致 git show 报
216
+ // "path exists on disk, but not in 'HEAD'" → 前端拿到 notFound:true)。
217
+ // 这里用 toGitRepoRelativePath 转成仓库根相对路径,并把 Windows 反斜杠
218
+ // 替换为正斜杠(git 内部统一用 /)。
219
+ const relPath = toGitRepoRelativePath(safeFilePath);
220
+ const spec = r === ':' ? `:${relPath}` : `${r}:${relPath}`;
221
+
222
+ let sizeBytes = 0;
223
+ try {
224
+ const { stdout: sizeOut } = await execGitCommand(['cat-file', '-s', spec], { log: false });
225
+ sizeBytes = parseInt(String(sizeOut).trim(), 10) || 0;
226
+ } catch (e) {
227
+ return res.json({ success: true, notFound: true, content: '' });
228
+ }
229
+
230
+ if (sizeBytes > maxBytes) {
231
+ return res.json({
232
+ success: true,
233
+ isLargeFile: true,
234
+ size: sizeBytes,
235
+ content: `⚠️ 文件内容过大 (${(sizeBytes / 1024).toFixed(1)} KB),已跳过显示以避免浏览器卡顿。`
236
+ });
237
+ }
238
+
239
+ const { stdout } = await execGitCommand(['show', spec], { log: false });
240
+ return res.json({ success: true, content: stdout ?? '' });
241
+ } catch (error) {
242
+ res.status(500).json({ success: false, error: error.message });
243
+ }
244
+ }));
245
+
246
+ // 解决冲突:保存解决后的文件内容
247
+ app.post('/api/resolve-conflict', asyncRoute(async (req, res) => {
248
+ try {
249
+ const { filePath, content } = req.body;
250
+
251
+ if (!filePath) {
252
+ return res.status(400).json({
253
+ success: false,
254
+ error: '缺少文件路径参数'
255
+ });
256
+ }
257
+
258
+ // SEC-PATH-1:resolve 到 cwd 内,越界 403
259
+ const safeFilePath = await safePathInProject(String(filePath));
260
+ if (!safeFilePath) {
261
+ return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
262
+ }
263
+
264
+ if (content === undefined) {
265
+ return res.status(400).json({
266
+ success: false,
267
+ error: '缺少文件内容参数'
268
+ });
269
+ }
270
+
271
+ try {
272
+ // 写入解决后的内容到文件(走 safePath)
273
+ await fs.writeFile(safeFilePath, content, 'utf8');
274
+
275
+ res.json({
276
+ success: true,
277
+ message: '冲突已解决,文件已更新'
278
+ });
279
+ } catch (writeError) {
280
+ res.status(500).json({
281
+ success: false,
282
+ error: `保存文件失败: ${writeError.message}`
283
+ });
284
+ }
285
+ } catch (error) {
286
+ logger.error('解决冲突失败:', error);
287
+ res.status(500).json({
288
+ success: false,
289
+ error: `解决冲突失败: ${error.message}`
290
+ });
291
+ }
292
+ }));
293
+
294
+ // 撤回文件修改
295
+ app.post('/api/revert_file', asyncRoute(async (req, res) => {
296
+ try {
297
+ const { filePath } = req.body;
298
+
299
+ if (!filePath) {
300
+ return res.status(400).json({
301
+ success: false,
302
+ error: '缺少文件路径参数'
303
+ });
304
+ }
305
+
306
+ // SEC-PATH-1:resolve 到 cwd 内
307
+ const safeFilePath = await safePathInProject(String(filePath));
308
+ if (!safeFilePath) {
309
+ return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
310
+ }
311
+
312
+ // 检查文件状态:未跟踪文件需要删除,修改文件需要恢复
313
+ // pathspec 用相对路径(避免 Windows 绝对路径在某些 git 版本下的边界问题)
314
+ const relFilePath = toGitRepoRelativePath(safeFilePath);
315
+ const { stdout: statusOutput } = await execGitCommand(['status', '--porcelain', '--', relFilePath]);
316
+
317
+ // 未跟踪的文件 (??), 需要删除它
318
+ if (statusOutput.startsWith('??')) {
319
+ try {
320
+ await fs.unlink(safeFilePath);
321
+ return res.json({ success: true, message: '未跟踪的文件已删除' });
322
+ } catch (error) {
323
+ return res.status(500).json({
324
+ success: false,
325
+ error: `删除文件失败: ${error.message}`
326
+ });
327
+ }
328
+ }
329
+ // 已暂存的文件,先取消暂存
330
+ else if (statusOutput.startsWith('A ') || statusOutput.startsWith('M ') || statusOutput.startsWith('D ')) {
331
+ // 先取消暂存
332
+ await execGitCommand(['reset', 'HEAD', '--', relFilePath]);
333
+ }
334
+
335
+ // 已修改文件,取消所有本地修改
336
+ if (statusOutput) {
337
+ await execGitCommand(['checkout', '--', relFilePath]);
338
+ return res.json({ success: true, message: '文件修改已撤回' });
339
+ } else {
340
+ return res.status(400).json({
341
+ success: false,
342
+ error: '文件没有修改或不存在'
343
+ });
344
+ }
345
+ } catch (error) {
346
+ logger.error('撤回文件修改失败:', error);
347
+ res.status(500).json({
348
+ success: false,
349
+ error: `撤回文件修改失败: ${error.message}`
350
+ });
351
+ }
352
+ }));
353
+
354
+ // 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)
355
+ // body: { filePaths: string[] }
356
+ // 返回: { success, count, results: [{ path, success, error? }] }
357
+ app.post('/api/revert_files', asyncRoute(async (req, res) => {
358
+ const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []
359
+ if (filePaths.length === 0) {
360
+ return res.status(400).json({ success: false, error: '缺少文件路径参数' })
361
+ }
362
+
363
+ // 批量大小限制,防止 DoS
364
+ const MAX_BATCH = 200;
365
+ if (filePaths.length > MAX_BATCH) {
366
+ return res.status(400).json({ success: false, error: `批量大小不能超过 ${MAX_BATCH}` });
367
+ }
368
+
369
+ const results = []
370
+ let successCount = 0
371
+
372
+ for (const filePath of filePaths) {
373
+ try {
374
+ // SEC-PATH-1:每个 path 都校验在 cwd 内,越界直接报错
375
+ const safeFilePath = await safePathInProject(String(filePath));
376
+ if (!safeFilePath) {
377
+ results.push({ path: filePath, success: false, error: '禁止访问工作目录以外的文件' });
378
+ continue;
379
+ }
380
+
381
+ // 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)
382
+ // pathspec 用相对路径,避免 Windows 绝对路径的潜在问题
383
+ const relFilePath = toGitRepoRelativePath(safeFilePath);
384
+ const { stdout: statusOutput } = await execGitCommand(['status', '--porcelain', '--', relFilePath])
385
+
386
+ // 未跟踪的文件 (??) → 直接删除
387
+ if (statusOutput.startsWith('??')) {
388
+ try {
389
+ await fs.unlink(safeFilePath)
390
+ results.push({ path: filePath, success: true, message: '未跟踪的文件已删除' })
391
+ successCount++
392
+ continue
393
+ } catch (err) {
394
+ results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })
395
+ continue
396
+ }
397
+ }
398
+
399
+ // 已暂存的文件,先取消暂存(不影响工作区)
400
+ if (statusOutput.startsWith('A ') || statusOutput.startsWith('M ') || statusOutput.startsWith('D ')) {
401
+ await execGitCommand(['reset', 'HEAD', '--', relFilePath])
402
+ }
403
+
404
+ // 已修改文件:丢弃工作区修改
405
+ if (statusOutput) {
406
+ await execGitCommand(['checkout', '--', relFilePath])
407
+ results.push({ path: filePath, success: true, message: '文件修改已撤回' })
408
+ successCount++
409
+ } else {
410
+ // 文件已无修改(可能在并发中被处理掉了)
411
+ results.push({ path: filePath, success: true, message: '文件无修改' })
412
+ successCount++
413
+ }
414
+ } catch (err) {
415
+ results.push({ path: filePath, success: false, error: err?.message || String(err) })
416
+ }
417
+ }
418
+
419
+ res.json({
420
+ success: true,
421
+ count: filePaths.length,
422
+ successCount,
423
+ results
424
+ })
425
+ }));
426
+ }