collabmd 0.1.21 → 0.1.23
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 +94 -7
- package/bin/collabmd.js +3 -2
- package/docker-compose.yml +13 -1
- package/package.json +2 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
- package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
- package/public/assets/js/{excalidraw-editor-TCIH6GJL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +503 -182
- package/public/index.html +104 -84
- package/src/client/application/app-shell/git-feature.js +247 -9
- package/src/client/application/app-shell/ui-feature.js +65 -7
- package/src/client/application/app-shell/workspace-feature.js +46 -0
- package/src/client/application/app-shell-elements.js +2 -0
- package/src/client/application/mermaid-preview-hydrator.js +1 -1
- package/src/client/application/plantuml-preview-hydrator.js +1 -1
- package/src/client/application/workspace-coordinator.js +147 -18
- package/src/client/application/workspace-preview-controller.js +34 -0
- package/src/client/application/workspace-route-controller.js +104 -0
- package/src/client/bootstrap/collabmd-app-shell.js +95 -0
- package/src/client/domain/runtime-paths.js +5 -0
- package/src/client/excalidraw-editor.js +50 -2
- package/src/client/infrastructure/auth-client.js +219 -52
- package/src/client/infrastructure/browser-navigation-port.js +20 -0
- package/src/client/infrastructure/editor-collaboration-client.js +4 -0
- package/src/client/infrastructure/editor-session.js +47 -7
- package/src/client/infrastructure/editor-view-adapter.js +122 -58
- package/src/client/infrastructure/runtime-config.js +66 -0
- package/src/client/presentation/comment-ui-controller.js +82 -26
- package/src/client/presentation/comments-panel.js +2 -2
- package/src/client/presentation/excalidraw-embed-controller.js +60 -2
- package/src/client/presentation/file-history-view-controller.js +806 -0
- package/src/client/presentation/git-diff-view-controller.js +718 -121
- package/src/client/presentation/git-panel-controller.js +458 -66
- package/src/client/presentation/layout-controller.js +1 -1
- package/src/client/styles/components/actions.css +89 -0
- package/src/client/styles/components/feedback.css +43 -0
- package/src/client/styles/components/navigation.css +68 -0
- package/src/client/styles/components/scrollbars.css +104 -0
- package/src/client/styles/components/states.css +289 -0
- package/src/client/styles/components/surfaces.css +155 -0
- package/src/client/styles/features/backlinks.css +213 -0
- package/src/client/styles/features/collaboration-chat.css +228 -0
- package/src/client/styles/features/collaboration-dialogs.css +154 -0
- package/src/client/styles/features/collaboration-presence.css +103 -0
- package/src/client/styles/features/collaboration-ui.css +3 -0
- package/src/client/styles/features/comment-card.css +236 -0
- package/src/client/styles/features/comment-markdown.css +127 -0
- package/src/client/styles/features/comment-overlays.css +60 -0
- package/src/client/styles/features/comments-drawer.css +120 -0
- package/src/client/styles/features/comments.css +4 -0
- package/src/client/styles/features/diagram-preview.css +227 -0
- package/src/client/styles/features/diff-commit.css +268 -0
- package/src/client/styles/features/diff-lines.css +160 -0
- package/src/client/styles/features/diff-toolbar.css +98 -0
- package/src/client/styles/features/diff.css +3 -0
- package/src/client/styles/features/embedded-preview.css +218 -0
- package/src/client/styles/features/git-branch.css +155 -0
- package/src/client/styles/features/git-files.css +105 -0
- package/src/client/styles/features/git-history.css +339 -0
- package/src/client/styles/features/git.css +3 -0
- package/src/client/styles/features/media-lightbox.css +157 -0
- package/src/client/styles/features/outline.css +115 -0
- package/src/client/styles/features/preview-content.css +3 -0
- package/src/client/styles/features/preview-markdown.css +201 -0
- package/src/client/styles/features/preview-sidebars.css +2 -0
- package/src/client/styles/features/quick-switcher.css +167 -0
- package/src/client/styles/foundation/themes.css +347 -0
- package/src/client/styles/foundation/tokens.css +66 -0
- package/src/client/styles/foundation/utilities.css +36 -0
- package/src/client/styles/layout/editor-page.css +203 -0
- package/src/client/styles/layout/overlays.css +132 -0
- package/src/client/styles/layout/responsive.css +251 -0
- package/src/client/styles/layout/shell.css +314 -0
- package/src/client/styles/layout/sidebar.css +176 -0
- package/src/client/styles/layout/view-modes.css +92 -0
- package/src/client/styles/overrides/codemirror.css +53 -0
- package/src/client/styles/overrides/highlightjs.css +7 -0
- package/src/client/styles/overrides/vendor.css +2 -0
- package/src/client/styles/primitives/controls.css +276 -0
- package/src/client/styles/style.css +25 -4945
- package/src/server/auth/create-auth-service.js +373 -15
- package/src/server/auth/session-cookie.js +30 -4
- package/src/server/config/env.js +121 -5
- package/src/server/create-app-server.js +8 -0
- package/src/server/domain/collaboration/collaboration-room.js +90 -47
- package/src/server/index.js +3 -1
- package/src/server/infrastructure/git/command-runner.js +2 -1
- package/src/server/infrastructure/git/git-service.js +48 -2
- package/src/server/infrastructure/git/history-service.js +683 -0
- package/src/server/infrastructure/git/parsers.js +30 -0
- package/src/server/infrastructure/git/responses.js +73 -0
- package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
- package/src/server/infrastructure/http/create-git-api-command-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
- package/src/server/infrastructure/http/create-request-handler.js +15 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +0 -22
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { createGitRequestError } from './errors.js';
|
|
2
|
+
import { getVaultFileKind, isImageAttachmentFilePath } from '../../../domain/file-kind.js';
|
|
3
|
+
import { normalizeRelativeGitPath } from './path-utils.js';
|
|
4
|
+
import {
|
|
5
|
+
countPatchLines,
|
|
6
|
+
parseNumstatEntries,
|
|
7
|
+
parseNameStatusEntries,
|
|
8
|
+
parseUnifiedDiff,
|
|
9
|
+
} from './parsers.js';
|
|
10
|
+
import {
|
|
11
|
+
createCommitDiffResponse,
|
|
12
|
+
createEmptyStats,
|
|
13
|
+
createFileHistoryResponse,
|
|
14
|
+
createFileSnapshotResponse,
|
|
15
|
+
createHistoryResponse,
|
|
16
|
+
} from './responses.js';
|
|
17
|
+
|
|
18
|
+
const EMPTY_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
19
|
+
const DEFAULT_HISTORY_LIMIT = 30;
|
|
20
|
+
const HISTORY_LIMIT_CAP = 50;
|
|
21
|
+
|
|
22
|
+
function clampHistoryLimit(limit) {
|
|
23
|
+
const parsedLimit = Number.parseInt(String(limit ?? DEFAULT_HISTORY_LIMIT), 10);
|
|
24
|
+
if (!Number.isFinite(parsedLimit) || parsedLimit <= 0) {
|
|
25
|
+
return DEFAULT_HISTORY_LIMIT;
|
|
26
|
+
}
|
|
27
|
+
return Math.min(HISTORY_LIMIT_CAP, parsedLimit);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeHistoryOffset(offset) {
|
|
31
|
+
const parsedOffset = Number.parseInt(String(offset ?? 0), 10);
|
|
32
|
+
if (!Number.isFinite(parsedOffset) || parsedOffset < 0) {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
return parsedOffset;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatRelativeDate(value) {
|
|
39
|
+
const timestamp = Date.parse(String(value ?? ''));
|
|
40
|
+
if (!Number.isFinite(timestamp)) {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const diffMs = Date.now() - timestamp;
|
|
45
|
+
const absDiffMs = Math.abs(diffMs);
|
|
46
|
+
if (absDiffMs < 60_000) {
|
|
47
|
+
return 'just now';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const units = [
|
|
51
|
+
{ divisor: 365 * 24 * 60 * 60 * 1000, label: 'y' },
|
|
52
|
+
{ divisor: 30 * 24 * 60 * 60 * 1000, label: 'mo' },
|
|
53
|
+
{ divisor: 7 * 24 * 60 * 60 * 1000, label: 'w' },
|
|
54
|
+
{ divisor: 24 * 60 * 60 * 1000, label: 'd' },
|
|
55
|
+
{ divisor: 60 * 60 * 1000, label: 'h' },
|
|
56
|
+
{ divisor: 60 * 1000, label: 'm' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const unit of units) {
|
|
60
|
+
if (absDiffMs >= unit.divisor) {
|
|
61
|
+
const amount = Math.round(absDiffMs / unit.divisor);
|
|
62
|
+
return diffMs >= 0 ? `${amount}${unit.label} ago` : `in ${amount}${unit.label}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return 'just now';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getCachedValue(cache, key) {
|
|
70
|
+
const cached = cache.get(key);
|
|
71
|
+
if (!cached) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (Date.now() >= cached.expiresAt) {
|
|
75
|
+
cache.delete(key);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return cached.value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function setCachedValue(cache, key, value, ttlMs) {
|
|
82
|
+
cache.set(key, {
|
|
83
|
+
expiresAt: Date.now() + ttlMs,
|
|
84
|
+
value,
|
|
85
|
+
});
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createSummaryFromFiles(files = []) {
|
|
90
|
+
return files.reduce((summary, file) => ({
|
|
91
|
+
additions: summary.additions + Number(file.stats?.additions || 0),
|
|
92
|
+
deletions: summary.deletions + Number(file.stats?.deletions || 0),
|
|
93
|
+
filesChanged: summary.filesChanged + 1,
|
|
94
|
+
}), {
|
|
95
|
+
additions: 0,
|
|
96
|
+
deletions: 0,
|
|
97
|
+
filesChanged: 0,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class GitHistoryService {
|
|
102
|
+
constructor({
|
|
103
|
+
commandRunner,
|
|
104
|
+
maxInitialPatchBytes = 250_000,
|
|
105
|
+
maxInitialPatchLines = 1_500,
|
|
106
|
+
responseCacheTtlMs = 5_000,
|
|
107
|
+
}) {
|
|
108
|
+
this.commandRunner = commandRunner;
|
|
109
|
+
this.maxInitialPatchBytes = maxInitialPatchBytes;
|
|
110
|
+
this.maxInitialPatchLines = maxInitialPatchLines;
|
|
111
|
+
this.responseCacheTtlMs = responseCacheTtlMs;
|
|
112
|
+
this.pendingRequests = new Map();
|
|
113
|
+
this.historyCache = new Map();
|
|
114
|
+
this.commitCache = new Map();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate() {
|
|
118
|
+
this.pendingRequests.clear();
|
|
119
|
+
this.historyCache.clear();
|
|
120
|
+
this.commitCache.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async listHistory({ limit = DEFAULT_HISTORY_LIMIT, offset = 0 } = {}) {
|
|
124
|
+
const isGitRepo = await this.commandRunner.isGitRepo();
|
|
125
|
+
const normalizedLimit = clampHistoryLimit(limit);
|
|
126
|
+
const normalizedOffset = normalizeHistoryOffset(offset);
|
|
127
|
+
|
|
128
|
+
if (!isGitRepo) {
|
|
129
|
+
return createHistoryResponse({
|
|
130
|
+
commits: [],
|
|
131
|
+
hasMore: false,
|
|
132
|
+
isGitRepo: false,
|
|
133
|
+
limit: normalizedLimit,
|
|
134
|
+
offset: normalizedOffset,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const cacheKey = JSON.stringify({
|
|
139
|
+
limit: normalizedLimit,
|
|
140
|
+
offset: normalizedOffset,
|
|
141
|
+
type: 'history',
|
|
142
|
+
});
|
|
143
|
+
const cached = getCachedValue(this.historyCache, cacheKey);
|
|
144
|
+
if (cached) {
|
|
145
|
+
return cached;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return this.runRequest(cacheKey, async () => {
|
|
149
|
+
const hasHeadCommit = await this.hasHeadCommit();
|
|
150
|
+
if (!hasHeadCommit) {
|
|
151
|
+
return setCachedValue(this.historyCache, cacheKey, createHistoryResponse({
|
|
152
|
+
commits: [],
|
|
153
|
+
hasMore: false,
|
|
154
|
+
limit: normalizedLimit,
|
|
155
|
+
offset: normalizedOffset,
|
|
156
|
+
}), this.responseCacheTtlMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const output = await this.commandRunner.execGit([
|
|
160
|
+
'log',
|
|
161
|
+
'HEAD',
|
|
162
|
+
`--skip=${normalizedOffset}`,
|
|
163
|
+
`-n`,
|
|
164
|
+
String(normalizedLimit + 1),
|
|
165
|
+
'--date=iso-strict',
|
|
166
|
+
'--format=%x1e%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P',
|
|
167
|
+
'--numstat',
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const commits = String(output ?? '')
|
|
171
|
+
.split('\x1e')
|
|
172
|
+
.map((chunk) => chunk.trim())
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.map((chunk) => this.parseHistoryChunk(chunk));
|
|
175
|
+
|
|
176
|
+
const hasMore = commits.length > normalizedLimit;
|
|
177
|
+
const response = createHistoryResponse({
|
|
178
|
+
commits: commits.slice(0, normalizedLimit),
|
|
179
|
+
hasMore,
|
|
180
|
+
limit: normalizedLimit,
|
|
181
|
+
offset: normalizedOffset,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return setCachedValue(this.historyCache, cacheKey, response, this.responseCacheTtlMs);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async listFileHistory({ limit = DEFAULT_HISTORY_LIMIT, offset = 0, path } = {}) {
|
|
189
|
+
const isGitRepo = await this.commandRunner.isGitRepo();
|
|
190
|
+
const normalizedLimit = clampHistoryLimit(limit);
|
|
191
|
+
const normalizedOffset = normalizeHistoryOffset(offset);
|
|
192
|
+
const normalizedPath = normalizeRelativeGitPath(path);
|
|
193
|
+
|
|
194
|
+
if (!isGitRepo) {
|
|
195
|
+
return createFileHistoryResponse({
|
|
196
|
+
commits: [],
|
|
197
|
+
hasMore: false,
|
|
198
|
+
isGitRepo: false,
|
|
199
|
+
limit: normalizedLimit,
|
|
200
|
+
offset: normalizedOffset,
|
|
201
|
+
path: normalizedPath,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const cacheKey = JSON.stringify({
|
|
206
|
+
limit: normalizedLimit,
|
|
207
|
+
offset: normalizedOffset,
|
|
208
|
+
path: normalizedPath,
|
|
209
|
+
type: 'file-history',
|
|
210
|
+
});
|
|
211
|
+
const cached = getCachedValue(this.historyCache, cacheKey);
|
|
212
|
+
if (cached) {
|
|
213
|
+
return cached;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return this.runRequest(cacheKey, async () => {
|
|
217
|
+
const hasHeadCommit = await this.hasHeadCommit();
|
|
218
|
+
if (!hasHeadCommit) {
|
|
219
|
+
return setCachedValue(this.historyCache, cacheKey, createFileHistoryResponse({
|
|
220
|
+
commits: [],
|
|
221
|
+
hasMore: false,
|
|
222
|
+
limit: normalizedLimit,
|
|
223
|
+
offset: normalizedOffset,
|
|
224
|
+
path: normalizedPath,
|
|
225
|
+
}), this.responseCacheTtlMs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const logArgs = [
|
|
229
|
+
'log',
|
|
230
|
+
'HEAD',
|
|
231
|
+
`--skip=${normalizedOffset}`,
|
|
232
|
+
'-n',
|
|
233
|
+
String(normalizedLimit + 1),
|
|
234
|
+
'--follow',
|
|
235
|
+
'--find-renames=20%',
|
|
236
|
+
'--date=iso-strict',
|
|
237
|
+
'--format=%x1e%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P',
|
|
238
|
+
'--',
|
|
239
|
+
normalizedPath,
|
|
240
|
+
];
|
|
241
|
+
const [nameStatusOutput, numstatOutput] = await Promise.all([
|
|
242
|
+
this.commandRunner.execGit([
|
|
243
|
+
...logArgs.slice(0, -2),
|
|
244
|
+
'--name-status',
|
|
245
|
+
...logArgs.slice(-2),
|
|
246
|
+
]),
|
|
247
|
+
this.commandRunner.execGit([
|
|
248
|
+
...logArgs.slice(0, -2),
|
|
249
|
+
'--numstat',
|
|
250
|
+
...logArgs.slice(-2),
|
|
251
|
+
]),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const commits = this.mergeFileHistoryChunks(nameStatusOutput, numstatOutput);
|
|
255
|
+
const hasMore = commits.length > normalizedLimit;
|
|
256
|
+
const response = createFileHistoryResponse({
|
|
257
|
+
commits: commits.slice(0, normalizedLimit),
|
|
258
|
+
hasMore,
|
|
259
|
+
limit: normalizedLimit,
|
|
260
|
+
offset: normalizedOffset,
|
|
261
|
+
path: normalizedPath,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return setCachedValue(this.historyCache, cacheKey, response, this.responseCacheTtlMs);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async getCommit({ allowLargePatch = false, hash, metaOnly = false, path = null } = {}) {
|
|
269
|
+
const isGitRepo = await this.commandRunner.isGitRepo();
|
|
270
|
+
if (!isGitRepo) {
|
|
271
|
+
return createCommitDiffResponse({
|
|
272
|
+
commit: null,
|
|
273
|
+
files: [],
|
|
274
|
+
isGitRepo: false,
|
|
275
|
+
metaOnly,
|
|
276
|
+
path: null,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const normalizedHash = this.normalizeCommitHash(hash);
|
|
281
|
+
const normalizedPath = path ? normalizeRelativeGitPath(path) : null;
|
|
282
|
+
if (!metaOnly && !normalizedPath) {
|
|
283
|
+
throw createGitRequestError(400, 'Missing path parameter');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const cacheKey = JSON.stringify({
|
|
287
|
+
allowLargePatch: Boolean(allowLargePatch),
|
|
288
|
+
hash: normalizedHash,
|
|
289
|
+
metaOnly: Boolean(metaOnly),
|
|
290
|
+
path: normalizedPath,
|
|
291
|
+
type: 'commit',
|
|
292
|
+
});
|
|
293
|
+
const cached = getCachedValue(this.commitCache, cacheKey);
|
|
294
|
+
if (cached) {
|
|
295
|
+
return cached;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return this.runRequest(cacheKey, async () => {
|
|
299
|
+
const meta = await this.loadCommitMetadata(normalizedHash);
|
|
300
|
+
const summary = createSummaryFromFiles(meta.files);
|
|
301
|
+
|
|
302
|
+
if (metaOnly) {
|
|
303
|
+
const metaResponse = createCommitDiffResponse({
|
|
304
|
+
commit: meta.commit,
|
|
305
|
+
files: meta.files,
|
|
306
|
+
metaOnly: true,
|
|
307
|
+
path: normalizedPath,
|
|
308
|
+
summary,
|
|
309
|
+
});
|
|
310
|
+
return setCachedValue(this.commitCache, cacheKey, metaResponse, this.responseCacheTtlMs);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const baseFile = meta.files.find((file) => file.path === normalizedPath);
|
|
314
|
+
if (!baseFile) {
|
|
315
|
+
throw createGitRequestError(404, 'Commit file not found');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const fileSummary = {
|
|
319
|
+
additions: Number(baseFile.stats?.additions || 0),
|
|
320
|
+
deletions: Number(baseFile.stats?.deletions || 0),
|
|
321
|
+
filesChanged: 1,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
!allowLargePatch
|
|
326
|
+
&& (fileSummary.additions + fileSummary.deletions) > this.maxInitialPatchLines
|
|
327
|
+
) {
|
|
328
|
+
const guardedResponse = createCommitDiffResponse({
|
|
329
|
+
commit: meta.commit,
|
|
330
|
+
files: [{
|
|
331
|
+
...baseFile,
|
|
332
|
+
canLoadFullPatch: true,
|
|
333
|
+
hunks: [],
|
|
334
|
+
patchLineCount: fileSummary.additions + fileSummary.deletions,
|
|
335
|
+
tooLarge: true,
|
|
336
|
+
}],
|
|
337
|
+
metaOnly: false,
|
|
338
|
+
path: normalizedPath,
|
|
339
|
+
summary,
|
|
340
|
+
});
|
|
341
|
+
return setCachedValue(this.commitCache, cacheKey, guardedResponse, this.responseCacheTtlMs);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const diffText = await this.commandRunner.execGit([
|
|
345
|
+
'diff',
|
|
346
|
+
'--no-color',
|
|
347
|
+
'--no-ext-diff',
|
|
348
|
+
'--find-renames',
|
|
349
|
+
meta.baseRef,
|
|
350
|
+
meta.commit.hash,
|
|
351
|
+
'--',
|
|
352
|
+
normalizedPath,
|
|
353
|
+
]);
|
|
354
|
+
const parsedFiles = parseUnifiedDiff(diffText);
|
|
355
|
+
const detail = parsedFiles.find((file) => file.path === normalizedPath) ?? {
|
|
356
|
+
...baseFile,
|
|
357
|
+
hunks: [],
|
|
358
|
+
isBinary: false,
|
|
359
|
+
stats: baseFile.stats ?? createEmptyStats(),
|
|
360
|
+
};
|
|
361
|
+
const patchLineCount = countPatchLines(detail);
|
|
362
|
+
|
|
363
|
+
const detailedFile = (
|
|
364
|
+
!allowLargePatch
|
|
365
|
+
&& (
|
|
366
|
+
patchLineCount > this.maxInitialPatchLines
|
|
367
|
+
|| diffText.length > this.maxInitialPatchBytes
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
? {
|
|
371
|
+
...baseFile,
|
|
372
|
+
byteLength: diffText.length,
|
|
373
|
+
canLoadFullPatch: true,
|
|
374
|
+
hunks: [],
|
|
375
|
+
patchLineCount,
|
|
376
|
+
stats: detail.stats,
|
|
377
|
+
tooLarge: true,
|
|
378
|
+
}
|
|
379
|
+
: {
|
|
380
|
+
...baseFile,
|
|
381
|
+
...detail,
|
|
382
|
+
canLoadFullPatch: false,
|
|
383
|
+
patchLineCount,
|
|
384
|
+
tooLarge: false,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const response = createCommitDiffResponse({
|
|
388
|
+
commit: meta.commit,
|
|
389
|
+
files: [detailedFile],
|
|
390
|
+
metaOnly: false,
|
|
391
|
+
path: normalizedPath,
|
|
392
|
+
summary,
|
|
393
|
+
});
|
|
394
|
+
return setCachedValue(this.commitCache, cacheKey, response, this.responseCacheTtlMs);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getFileSnapshot({ hash, path } = {}) {
|
|
399
|
+
const isGitRepo = await this.commandRunner.isGitRepo();
|
|
400
|
+
const normalizedHash = this.normalizeCommitHash(hash);
|
|
401
|
+
const normalizedPath = normalizeRelativeGitPath(path);
|
|
402
|
+
|
|
403
|
+
if (!isGitRepo) {
|
|
404
|
+
return createFileSnapshotResponse({
|
|
405
|
+
content: '',
|
|
406
|
+
fileKind: getVaultFileKind(normalizedPath),
|
|
407
|
+
hash: normalizedHash,
|
|
408
|
+
isGitRepo: false,
|
|
409
|
+
path: normalizedPath,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (isImageAttachmentFilePath(normalizedPath)) {
|
|
414
|
+
throw createGitRequestError(400, 'Binary file snapshots are not supported');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const fileKind = getVaultFileKind(normalizedPath);
|
|
418
|
+
if (!fileKind) {
|
|
419
|
+
throw createGitRequestError(400, 'Unsupported file snapshot type');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const cacheKey = JSON.stringify({
|
|
423
|
+
hash: normalizedHash,
|
|
424
|
+
path: normalizedPath,
|
|
425
|
+
type: 'file-snapshot',
|
|
426
|
+
});
|
|
427
|
+
const cached = getCachedValue(this.commitCache, cacheKey);
|
|
428
|
+
if (cached) {
|
|
429
|
+
return cached;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return this.runRequest(cacheKey, async () => {
|
|
433
|
+
let content = '';
|
|
434
|
+
try {
|
|
435
|
+
content = await this.commandRunner.execGit(['show', `${normalizedHash}:${normalizedPath}`]);
|
|
436
|
+
} catch {
|
|
437
|
+
throw createGitRequestError(404, 'Commit file not found');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return setCachedValue(this.commitCache, cacheKey, createFileSnapshotResponse({
|
|
441
|
+
content,
|
|
442
|
+
fileKind,
|
|
443
|
+
hash: normalizedHash,
|
|
444
|
+
path: normalizedPath,
|
|
445
|
+
}), this.responseCacheTtlMs);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async runRequest(key, callback) {
|
|
450
|
+
if (this.pendingRequests.has(key)) {
|
|
451
|
+
return this.pendingRequests.get(key);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const requestPromise = callback();
|
|
455
|
+
this.pendingRequests.set(key, requestPromise);
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
return await requestPromise;
|
|
459
|
+
} finally {
|
|
460
|
+
if (this.pendingRequests.get(key) === requestPromise) {
|
|
461
|
+
this.pendingRequests.delete(key);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async hasHeadCommit() {
|
|
467
|
+
try {
|
|
468
|
+
await this.commandRunner.execGit(['rev-parse', '--verify', 'HEAD']);
|
|
469
|
+
return true;
|
|
470
|
+
} catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
normalizeCommitHash(hash) {
|
|
476
|
+
const normalizedHash = String(hash ?? '').trim();
|
|
477
|
+
if (!/^[0-9a-f]{4,64}$/iu.test(normalizedHash)) {
|
|
478
|
+
throw createGitRequestError(400, 'Invalid commit hash');
|
|
479
|
+
}
|
|
480
|
+
return normalizedHash;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
parseHistoryChunk(chunk) {
|
|
484
|
+
const [headerLine = '', ...statLines] = String(chunk ?? '')
|
|
485
|
+
.split(/\r?\n/u)
|
|
486
|
+
.filter((line) => line.length > 0);
|
|
487
|
+
const [
|
|
488
|
+
hash = '',
|
|
489
|
+
shortHash = '',
|
|
490
|
+
subject = '',
|
|
491
|
+
authorName = '',
|
|
492
|
+
authorEmail = '',
|
|
493
|
+
authoredAt = '',
|
|
494
|
+
rawParents = '',
|
|
495
|
+
] = headerLine.split('\x1f');
|
|
496
|
+
const parentHashes = rawParents.split(' ').map((value) => value.trim()).filter(Boolean);
|
|
497
|
+
|
|
498
|
+
const summary = statLines.reduce((result, line) => {
|
|
499
|
+
const [rawAdditions = '0', rawDeletions = '0'] = line.split('\t');
|
|
500
|
+
return {
|
|
501
|
+
additions: result.additions + (rawAdditions === '-' ? 0 : Number.parseInt(rawAdditions, 10) || 0),
|
|
502
|
+
deletions: result.deletions + (rawDeletions === '-' ? 0 : Number.parseInt(rawDeletions, 10) || 0),
|
|
503
|
+
filesChanged: result.filesChanged + 1,
|
|
504
|
+
};
|
|
505
|
+
}, {
|
|
506
|
+
additions: 0,
|
|
507
|
+
deletions: 0,
|
|
508
|
+
filesChanged: 0,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
additions: summary.additions,
|
|
513
|
+
authorEmail,
|
|
514
|
+
authorName,
|
|
515
|
+
authoredAt,
|
|
516
|
+
deletions: summary.deletions,
|
|
517
|
+
filesChanged: summary.filesChanged,
|
|
518
|
+
hash,
|
|
519
|
+
isMergeCommit: parentHashes.length > 1,
|
|
520
|
+
parentCount: parentHashes.length,
|
|
521
|
+
relativeDateLabel: formatRelativeDate(authoredAt),
|
|
522
|
+
shortHash,
|
|
523
|
+
subject,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
parseFileHistoryChunk(chunk) {
|
|
528
|
+
const [headerLine = '', ...detailLines] = String(chunk ?? '')
|
|
529
|
+
.split(/\r?\n/u)
|
|
530
|
+
.filter((line) => line.length > 0);
|
|
531
|
+
const [
|
|
532
|
+
hash = '',
|
|
533
|
+
shortHash = '',
|
|
534
|
+
subject = '',
|
|
535
|
+
authorName = '',
|
|
536
|
+
authorEmail = '',
|
|
537
|
+
authoredAt = '',
|
|
538
|
+
rawParents = '',
|
|
539
|
+
] = headerLine.split('\x1f');
|
|
540
|
+
const parentHashes = rawParents.split(' ').map((value) => value.trim()).filter(Boolean);
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
authorEmail,
|
|
544
|
+
authorName,
|
|
545
|
+
authoredAt,
|
|
546
|
+
detailLines,
|
|
547
|
+
hash,
|
|
548
|
+
isMergeCommit: parentHashes.length > 1,
|
|
549
|
+
parentCount: parentHashes.length,
|
|
550
|
+
relativeDateLabel: formatRelativeDate(authoredAt),
|
|
551
|
+
shortHash,
|
|
552
|
+
subject,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
mergeFileHistoryChunks(nameStatusOutput, numstatOutput) {
|
|
557
|
+
const nameStatusChunks = String(nameStatusOutput ?? '')
|
|
558
|
+
.split('\x1e')
|
|
559
|
+
.map((chunk) => chunk.trim())
|
|
560
|
+
.filter(Boolean)
|
|
561
|
+
.map((chunk) => this.parseFileHistoryChunk(chunk));
|
|
562
|
+
const numstatChunks = String(numstatOutput ?? '')
|
|
563
|
+
.split('\x1e')
|
|
564
|
+
.map((chunk) => chunk.trim())
|
|
565
|
+
.filter(Boolean)
|
|
566
|
+
.map((chunk) => this.parseFileHistoryChunk(chunk));
|
|
567
|
+
|
|
568
|
+
return nameStatusChunks.map((chunk, index) => {
|
|
569
|
+
const nameStatusEntry = parseNameStatusEntries(chunk.detailLines.join('\n'))[0] ?? null;
|
|
570
|
+
const numstatEntry = parseNumstatEntries(numstatChunks[index]?.detailLines?.join('\n') ?? '')[0] ?? null;
|
|
571
|
+
return {
|
|
572
|
+
additions: Number(numstatEntry?.additions || 0),
|
|
573
|
+
authorEmail: chunk.authorEmail,
|
|
574
|
+
authorName: chunk.authorName,
|
|
575
|
+
authoredAt: chunk.authoredAt,
|
|
576
|
+
deletions: Number(numstatEntry?.deletions || 0),
|
|
577
|
+
filesChanged: 1,
|
|
578
|
+
hash: chunk.hash,
|
|
579
|
+
isMergeCommit: chunk.isMergeCommit,
|
|
580
|
+
oldPath: nameStatusEntry?.oldPath ?? null,
|
|
581
|
+
parentCount: chunk.parentCount,
|
|
582
|
+
pathAtCommit: nameStatusEntry?.path ?? null,
|
|
583
|
+
relativeDateLabel: chunk.relativeDateLabel,
|
|
584
|
+
shortHash: chunk.shortHash,
|
|
585
|
+
status: nameStatusEntry?.status ?? 'modified',
|
|
586
|
+
subject: chunk.subject,
|
|
587
|
+
};
|
|
588
|
+
}).filter((entry) => entry.hash && entry.pathAtCommit);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async loadCommitMetadata(hash) {
|
|
592
|
+
const cacheKey = JSON.stringify({ hash, metaOnly: true, type: 'commit-metadata' });
|
|
593
|
+
const cached = getCachedValue(this.commitCache, cacheKey);
|
|
594
|
+
if (cached) {
|
|
595
|
+
return cached;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const headerOutput = await this.commandRunner.execGit([
|
|
599
|
+
'show',
|
|
600
|
+
'--no-patch',
|
|
601
|
+
'--date=iso-strict',
|
|
602
|
+
'--format=%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P',
|
|
603
|
+
hash,
|
|
604
|
+
]).catch(() => {
|
|
605
|
+
throw createGitRequestError(404, 'Commit not found');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const [
|
|
609
|
+
commitHash = '',
|
|
610
|
+
shortHash = '',
|
|
611
|
+
subject = '',
|
|
612
|
+
authorName = '',
|
|
613
|
+
authorEmail = '',
|
|
614
|
+
authoredAt = '',
|
|
615
|
+
rawParents = '',
|
|
616
|
+
] = String(headerOutput ?? '').trim().split('\x1f');
|
|
617
|
+
|
|
618
|
+
if (!commitHash) {
|
|
619
|
+
throw createGitRequestError(404, 'Commit not found');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const parentHashes = rawParents.split(' ').map((value) => value.trim()).filter(Boolean);
|
|
623
|
+
const baseRef = parentHashes[0] || EMPTY_TREE_HASH;
|
|
624
|
+
const nameStatusOutput = await this.commandRunner.execGit([
|
|
625
|
+
'diff',
|
|
626
|
+
'--find-renames',
|
|
627
|
+
'--name-status',
|
|
628
|
+
baseRef,
|
|
629
|
+
commitHash,
|
|
630
|
+
]);
|
|
631
|
+
const numstatOutput = await this.commandRunner.execGit([
|
|
632
|
+
'diff',
|
|
633
|
+
'--find-renames',
|
|
634
|
+
'--numstat',
|
|
635
|
+
baseRef,
|
|
636
|
+
commitHash,
|
|
637
|
+
]);
|
|
638
|
+
|
|
639
|
+
const files = this.mergeCommitMetadataFiles(
|
|
640
|
+
parseNameStatusEntries(nameStatusOutput),
|
|
641
|
+
numstatOutput,
|
|
642
|
+
);
|
|
643
|
+
const meta = {
|
|
644
|
+
baseRef,
|
|
645
|
+
commit: {
|
|
646
|
+
authorEmail,
|
|
647
|
+
authorName,
|
|
648
|
+
authoredAt,
|
|
649
|
+
filesChanged: files.length,
|
|
650
|
+
hash: commitHash,
|
|
651
|
+
isMergeCommit: parentHashes.length > 1,
|
|
652
|
+
parentCount: parentHashes.length,
|
|
653
|
+
relativeDateLabel: formatRelativeDate(authoredAt),
|
|
654
|
+
shortHash,
|
|
655
|
+
subject,
|
|
656
|
+
},
|
|
657
|
+
files,
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
return setCachedValue(this.commitCache, cacheKey, meta, this.responseCacheTtlMs);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
mergeCommitMetadataFiles(nameStatusEntries, numstatOutput) {
|
|
664
|
+
const statsByPath = new Map();
|
|
665
|
+
const numstatLines = String(numstatOutput ?? '')
|
|
666
|
+
.split(/\r?\n/u)
|
|
667
|
+
.filter(Boolean);
|
|
668
|
+
|
|
669
|
+
for (let index = 0; index < nameStatusEntries.length; index += 1) {
|
|
670
|
+
const line = numstatLines[index] ?? '';
|
|
671
|
+
const [rawAdditions = '0', rawDeletions = '0'] = line.split('\t');
|
|
672
|
+
statsByPath.set(nameStatusEntries[index].path, {
|
|
673
|
+
additions: rawAdditions === '-' ? 0 : Number.parseInt(rawAdditions, 10) || 0,
|
|
674
|
+
deletions: rawDeletions === '-' ? 0 : Number.parseInt(rawDeletions, 10) || 0,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return nameStatusEntries.map((entry) => ({
|
|
679
|
+
...entry,
|
|
680
|
+
stats: statsByPath.get(entry.path) ?? createEmptyStats(),
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
@@ -236,6 +236,36 @@ export function parseNameStatusOutput(output) {
|
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
export function parseNameStatusEntries(output) {
|
|
240
|
+
return String(output ?? '')
|
|
241
|
+
.split(/\r?\n/u)
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.map((line) => {
|
|
244
|
+
const [rawStatus = '', ...parts] = line.split('\t');
|
|
245
|
+
const statusToken = rawStatus.trim().toUpperCase();
|
|
246
|
+
if (!statusToken) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (statusToken.startsWith('R') || statusToken.startsWith('C')) {
|
|
251
|
+
const [oldPath = '', path = ''] = parts.map((value) => decodeQuotedPath(value));
|
|
252
|
+
return {
|
|
253
|
+
...createStatusInfo(statusToken[0]),
|
|
254
|
+
oldPath: oldPath || null,
|
|
255
|
+
path: path || null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const [path = ''] = parts.map((value) => decodeQuotedPath(value));
|
|
260
|
+
return {
|
|
261
|
+
...createStatusInfo(statusToken[0]),
|
|
262
|
+
oldPath: null,
|
|
263
|
+
path: path || null,
|
|
264
|
+
};
|
|
265
|
+
})
|
|
266
|
+
.filter((entry) => entry?.path);
|
|
267
|
+
}
|
|
268
|
+
|
|
239
269
|
export function countPatchLines(file = null) {
|
|
240
270
|
return (file?.hunks ?? []).reduce((total, hunk) => total + (hunk.lines?.length ?? 0), 0);
|
|
241
271
|
}
|