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
|
@@ -5,20 +5,34 @@ function getPathLeaf(pathValue) {
|
|
|
5
5
|
return String(pathValue ?? '').split('/').pop() || '';
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function getPathDir(pathValue) {
|
|
9
|
+
const parts = String(pathValue ?? '').split('/');
|
|
10
|
+
parts.pop();
|
|
11
|
+
return parts.join('/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createSectionId(pathValue) {
|
|
15
|
+
return `diff-section-${encodeURIComponent(String(pathValue ?? '')).replace(/%/g, '_')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
function badgeClass(status) {
|
|
9
19
|
switch (status) {
|
|
10
20
|
case 'added':
|
|
11
21
|
case 'untracked':
|
|
12
|
-
return '
|
|
22
|
+
return 'ui-status-badge--success';
|
|
13
23
|
case 'deleted':
|
|
14
|
-
return '
|
|
24
|
+
return 'ui-status-badge--danger';
|
|
15
25
|
case 'renamed':
|
|
16
|
-
return '
|
|
26
|
+
return 'ui-status-badge--accent';
|
|
17
27
|
default:
|
|
18
|
-
return '
|
|
28
|
+
return 'ui-status-badge--warning';
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
function chevronSvg(collapsed = false) {
|
|
33
|
+
return `<svg class="diff-section-chevron${collapsed ? ' collapsed' : ''}" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
function commonPrefixLength(left, right) {
|
|
23
37
|
let index = 0;
|
|
24
38
|
while (index < left.length && index < right.length && left[index] === right[index]) {
|
|
@@ -131,12 +145,14 @@ function renderSplitRow(leftLine, rightLine) {
|
|
|
131
145
|
|
|
132
146
|
export class GitDiffViewController {
|
|
133
147
|
constructor({
|
|
148
|
+
onBackToHistory = null,
|
|
134
149
|
onCommitStaged = null,
|
|
135
150
|
onOpenFile = null,
|
|
136
151
|
onStageFile = null,
|
|
137
152
|
onUnstageFile = null,
|
|
138
153
|
toastController = null,
|
|
139
154
|
} = {}) {
|
|
155
|
+
this.onBackToHistory = onBackToHistory;
|
|
140
156
|
this.onCommitStaged = onCommitStaged;
|
|
141
157
|
this.onOpenFile = onOpenFile;
|
|
142
158
|
this.onStageFile = onStageFile;
|
|
@@ -149,16 +165,31 @@ export class GitDiffViewController {
|
|
|
149
165
|
this.openEditorButton = document.getElementById('diffOpenEditorBtn');
|
|
150
166
|
this.primaryActionButton = document.getElementById('diffPrimaryActionBtn');
|
|
151
167
|
this.commitButton = document.getElementById('diffCommitBtn');
|
|
168
|
+
this.backToHistoryButton = document.getElementById('diffBackToHistoryBtn');
|
|
169
|
+
this.gitActionsGroup = document.getElementById('diffGitActionsGroup');
|
|
170
|
+
this.editorActionsGroup = document.getElementById('diffEditorActionsGroup');
|
|
171
|
+
this.actionsDivider = document.getElementById('diffToolbarDivider');
|
|
152
172
|
this.stats = document.getElementById('diffStats');
|
|
153
173
|
this.prevButton = document.getElementById('diffPrevBtn');
|
|
154
174
|
this.nextButton = document.getElementById('diffNextBtn');
|
|
175
|
+
this.layoutToggle = document.getElementById('diffLayoutToggle');
|
|
155
176
|
this.modeButtons = Array.from(document.querySelectorAll('[data-diff-mode]'));
|
|
177
|
+
this.layoutButtons = Array.from(document.querySelectorAll('[data-diff-layout]'));
|
|
156
178
|
this.mode = 'unified';
|
|
179
|
+
this.layoutMode = 'focused';
|
|
180
|
+
this.source = 'workspace';
|
|
157
181
|
this.data = null;
|
|
158
182
|
this.currentIndex = 0;
|
|
183
|
+
this.activeFilePath = null;
|
|
159
184
|
this.fileCache = new Map();
|
|
160
|
-
this.
|
|
185
|
+
this.fileErrors = new Map();
|
|
186
|
+
this.loadingFiles = new Set();
|
|
187
|
+
this.collapsedFiles = new Set();
|
|
188
|
+
this.fileLoadPromises = new Map();
|
|
161
189
|
this.requestScope = 'all';
|
|
190
|
+
this.commitHash = null;
|
|
191
|
+
this.commitMeta = null;
|
|
192
|
+
this.historyFilePath = null;
|
|
162
193
|
this.pendingAction = null;
|
|
163
194
|
this.repoStatus = null;
|
|
164
195
|
}
|
|
@@ -166,7 +197,20 @@ export class GitDiffViewController {
|
|
|
166
197
|
initialize() {
|
|
167
198
|
this.prevButton?.addEventListener('click', () => this.navigateFile(-1));
|
|
168
199
|
this.nextButton?.addEventListener('click', () => this.navigateFile(1));
|
|
200
|
+
this.backToHistoryButton?.addEventListener('click', () => {
|
|
201
|
+
if (!this.commitMeta?.hash) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.onBackToHistory?.({
|
|
205
|
+
hash: this.commitMeta.hash,
|
|
206
|
+
historyFilePath: this.historyFilePath,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
169
209
|
this.openEditorButton?.addEventListener('click', () => {
|
|
210
|
+
if (this.source !== 'workspace') {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
170
214
|
const currentFile = this.getCurrentFile();
|
|
171
215
|
if (!currentFile?.path) {
|
|
172
216
|
return;
|
|
@@ -190,7 +234,32 @@ export class GitDiffViewController {
|
|
|
190
234
|
? event.target.closest('[data-load-full-diff]')
|
|
191
235
|
: null;
|
|
192
236
|
if (loadButton) {
|
|
193
|
-
|
|
237
|
+
const filePath = loadButton.getAttribute('data-diff-file-path') || this.activeFilePath;
|
|
238
|
+
if (filePath) {
|
|
239
|
+
void this.loadFileForPath(filePath, { forceFullPatch: true });
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const toggleButton = event.target instanceof Element
|
|
245
|
+
? event.target.closest('[data-diff-section-toggle]')
|
|
246
|
+
: null;
|
|
247
|
+
if (toggleButton) {
|
|
248
|
+
const filePath = toggleButton.getAttribute('data-diff-section-toggle');
|
|
249
|
+
if (filePath) {
|
|
250
|
+
void this.toggleFileSection(filePath);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const indexButton = event.target instanceof Element
|
|
256
|
+
? event.target.closest('[data-diff-index-path]')
|
|
257
|
+
: null;
|
|
258
|
+
if (indexButton) {
|
|
259
|
+
const filePath = indexButton.getAttribute('data-diff-index-path');
|
|
260
|
+
if (filePath) {
|
|
261
|
+
void this.handleIndexSelection(filePath);
|
|
262
|
+
}
|
|
194
263
|
}
|
|
195
264
|
});
|
|
196
265
|
this.modeButtons.forEach((button) => {
|
|
@@ -203,6 +272,18 @@ export class GitDiffViewController {
|
|
|
203
272
|
this.render();
|
|
204
273
|
});
|
|
205
274
|
});
|
|
275
|
+
this.layoutButtons.forEach((button) => {
|
|
276
|
+
button.addEventListener('click', () => {
|
|
277
|
+
const nextLayout = button.getAttribute('data-diff-layout');
|
|
278
|
+
if (!nextLayout || nextLayout === this.layoutMode) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
void this.setLayoutMode(nextLayout);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
this.scrollContainer?.addEventListener('scroll', () => {
|
|
285
|
+
this.handleScrollSelection();
|
|
286
|
+
});
|
|
206
287
|
}
|
|
207
288
|
|
|
208
289
|
hide() {
|
|
@@ -211,17 +292,36 @@ export class GitDiffViewController {
|
|
|
211
292
|
this.content.innerHTML = '';
|
|
212
293
|
}
|
|
213
294
|
this.data = null;
|
|
295
|
+
this.source = 'workspace';
|
|
296
|
+
this.layoutMode = 'focused';
|
|
214
297
|
this.currentIndex = 0;
|
|
298
|
+
this.activeFilePath = null;
|
|
215
299
|
this.fileCache.clear();
|
|
216
|
-
this.
|
|
300
|
+
this.fileErrors.clear();
|
|
301
|
+
this.loadingFiles.clear();
|
|
302
|
+
this.collapsedFiles.clear();
|
|
303
|
+
this.fileLoadPromises.clear();
|
|
304
|
+
this.requestScope = 'all';
|
|
305
|
+
this.commitHash = null;
|
|
306
|
+
this.commitMeta = null;
|
|
307
|
+
this.historyFilePath = null;
|
|
217
308
|
this.pendingAction = null;
|
|
218
309
|
this.repoStatus = null;
|
|
219
310
|
this.syncToolbar();
|
|
220
311
|
}
|
|
221
312
|
|
|
222
|
-
async
|
|
313
|
+
async openWorkspaceDiff({ filePath = null, scope = 'all' } = {}) {
|
|
314
|
+
this.source = 'workspace';
|
|
315
|
+
this.layoutMode = 'focused';
|
|
316
|
+
this.commitHash = null;
|
|
317
|
+
this.commitMeta = null;
|
|
318
|
+
this.historyFilePath = null;
|
|
319
|
+
this.activeFilePath = filePath;
|
|
223
320
|
this.fileCache.clear();
|
|
224
|
-
this.
|
|
321
|
+
this.fileErrors.clear();
|
|
322
|
+
this.loadingFiles.clear();
|
|
323
|
+
this.collapsedFiles.clear();
|
|
324
|
+
this.fileLoadPromises.clear();
|
|
225
325
|
this.requestScope = scope;
|
|
226
326
|
this.renderLoading('Loading diff summary...');
|
|
227
327
|
|
|
@@ -244,6 +344,7 @@ export class GitDiffViewController {
|
|
|
244
344
|
? Math.max(0, data.files.findIndex((file) => file.path === filePath))
|
|
245
345
|
: 0;
|
|
246
346
|
this.currentIndex = initialIndex;
|
|
347
|
+
this.activeFilePath = data.files?.[initialIndex]?.path ?? null;
|
|
247
348
|
if ((data.files?.length ?? 0) === 0) {
|
|
248
349
|
this.render();
|
|
249
350
|
return data;
|
|
@@ -257,6 +358,7 @@ export class GitDiffViewController {
|
|
|
257
358
|
this.data = {
|
|
258
359
|
files: [],
|
|
259
360
|
metaOnly: false,
|
|
361
|
+
source: 'workspace',
|
|
260
362
|
summary: { additions: 0, deletions: 0, filesChanged: 0 },
|
|
261
363
|
};
|
|
262
364
|
this.renderEmpty('Failed to load git diff');
|
|
@@ -264,8 +366,100 @@ export class GitDiffViewController {
|
|
|
264
366
|
}
|
|
265
367
|
}
|
|
266
368
|
|
|
369
|
+
async openCommitDiff({ hash, path = null, historyFilePath = null } = {}) {
|
|
370
|
+
this.source = 'commit';
|
|
371
|
+
this.layoutMode = 'stacked';
|
|
372
|
+
this.commitHash = String(hash ?? '').trim() || null;
|
|
373
|
+
this.commitMeta = null;
|
|
374
|
+
this.historyFilePath = String(historyFilePath ?? '').trim() || null;
|
|
375
|
+
this.activeFilePath = path || null;
|
|
376
|
+
this.fileCache.clear();
|
|
377
|
+
this.fileErrors.clear();
|
|
378
|
+
this.loadingFiles.clear();
|
|
379
|
+
this.collapsedFiles.clear();
|
|
380
|
+
this.fileLoadPromises.clear();
|
|
381
|
+
this.requestScope = 'all';
|
|
382
|
+
this.renderLoading('Loading commit summary...');
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const query = new URLSearchParams();
|
|
386
|
+
query.set('hash', this.commitHash || '');
|
|
387
|
+
query.set('metaOnly', 'true');
|
|
388
|
+
|
|
389
|
+
const response = await fetch(resolveApiUrl(`/git/commit?${query.toString()}`));
|
|
390
|
+
const data = await response.json();
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
throw new Error(data.error || 'Failed to load git commit');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.data = data;
|
|
396
|
+
this.commitMeta = data.commit ?? null;
|
|
397
|
+
const initialIndex = path
|
|
398
|
+
? Math.max(0, data.files.findIndex((file) => file.path === path))
|
|
399
|
+
: 0;
|
|
400
|
+
this.currentIndex = initialIndex;
|
|
401
|
+
this.activeFilePath = data.files?.[initialIndex]?.path ?? null;
|
|
402
|
+
this.collapsedFiles = new Set(
|
|
403
|
+
(data.files ?? [])
|
|
404
|
+
.map((file) => file.path)
|
|
405
|
+
.filter((filePath) => filePath && filePath !== this.activeFilePath),
|
|
406
|
+
);
|
|
407
|
+
if ((data.files?.length ?? 0) === 0) {
|
|
408
|
+
this.render();
|
|
409
|
+
return data;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (this.activeFilePath) {
|
|
413
|
+
await this.loadFileForPath(this.activeFilePath, { render: false });
|
|
414
|
+
}
|
|
415
|
+
this.render();
|
|
416
|
+
return data;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.error('[git-diff] Failed to load commit:', error);
|
|
419
|
+
this.toastController?.show('Failed to load git commit');
|
|
420
|
+
this.data = {
|
|
421
|
+
commit: null,
|
|
422
|
+
files: [],
|
|
423
|
+
metaOnly: false,
|
|
424
|
+
source: 'commit',
|
|
425
|
+
summary: { additions: 0, deletions: 0, filesChanged: 0 },
|
|
426
|
+
};
|
|
427
|
+
this.commitMeta = null;
|
|
428
|
+
this.renderEmpty('Failed to load git commit');
|
|
429
|
+
return this.data;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async open(payload = {}) {
|
|
434
|
+
return this.openWorkspaceDiff(payload);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async setLayoutMode(layoutMode) {
|
|
438
|
+
const normalizedLayout = layoutMode === 'stacked' ? 'stacked' : 'focused';
|
|
439
|
+
if (this.source !== 'commit') {
|
|
440
|
+
this.layoutMode = 'focused';
|
|
441
|
+
this.render();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.layoutMode = normalizedLayout;
|
|
446
|
+
if (this.activeFilePath) {
|
|
447
|
+
this.setActiveFilePath(this.activeFilePath);
|
|
448
|
+
}
|
|
449
|
+
if (this.layoutMode === 'focused') {
|
|
450
|
+
await this.loadCurrentFile();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (this.activeFilePath) {
|
|
455
|
+
this.collapsedFiles.delete(this.activeFilePath);
|
|
456
|
+
await this.loadFileForPath(this.activeFilePath, { render: false });
|
|
457
|
+
}
|
|
458
|
+
this.render();
|
|
459
|
+
}
|
|
460
|
+
|
|
267
461
|
navigateFile(direction) {
|
|
268
|
-
if (!this.data?.files?.length) {
|
|
462
|
+
if (!this.data?.files?.length || (this.source === 'commit' && this.layoutMode === 'stacked')) {
|
|
269
463
|
return;
|
|
270
464
|
}
|
|
271
465
|
|
|
@@ -275,6 +469,7 @@ export class GitDiffViewController {
|
|
|
275
469
|
}
|
|
276
470
|
|
|
277
471
|
this.currentIndex = nextIndex;
|
|
472
|
+
this.activeFilePath = this.data.files[nextIndex]?.path ?? null;
|
|
278
473
|
this.scrollContainer?.scrollTo({ top: 0, behavior: 'auto' });
|
|
279
474
|
void this.loadCurrentFile();
|
|
280
475
|
}
|
|
@@ -330,21 +525,24 @@ export class GitDiffViewController {
|
|
|
330
525
|
`;
|
|
331
526
|
}
|
|
332
527
|
|
|
333
|
-
|
|
528
|
+
renderFileHeader(file) {
|
|
334
529
|
return `
|
|
335
|
-
<
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
530
|
+
<div class="diff-file-header">
|
|
531
|
+
<span class="diff-file-path">${escapeHtml(file.path)}</span>
|
|
532
|
+
<span class="ui-status-badge ${badgeClass(file.status)}">${escapeHtml(file.status)}</span>
|
|
533
|
+
<span class="diff-file-header-stats"><span class="ui-stat-token ui-stat-token--add diff-stats-add">+${file.stats?.additions ?? 0}</span><span class="ui-stat-token ui-stat-token--del diff-stats-del">-${file.stats?.deletions ?? 0}</span></span>
|
|
534
|
+
</div>
|
|
535
|
+
`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
renderUnifiedFileBody(file) {
|
|
539
|
+
return `
|
|
540
|
+
${file.isBinary ? `<div class="diff-binary-message">${escapeHtml(file.binaryMessage || 'Binary file changed')}</div>` : ''}
|
|
541
|
+
${file.hunks.map((hunk) => this.renderUnifiedHunk(hunk)).join('')}
|
|
344
542
|
`;
|
|
345
543
|
}
|
|
346
544
|
|
|
347
|
-
|
|
545
|
+
renderSplitFileBody(file) {
|
|
348
546
|
const hunks = file.hunks.map((hunk) => {
|
|
349
547
|
const rows = [];
|
|
350
548
|
for (const block of createPairedBlocks(hunk.lines)) {
|
|
@@ -368,22 +566,201 @@ export class GitDiffViewController {
|
|
|
368
566
|
}).join('');
|
|
369
567
|
|
|
370
568
|
return `
|
|
371
|
-
<
|
|
372
|
-
<div class="diff-
|
|
373
|
-
<
|
|
374
|
-
|
|
375
|
-
<span class="diff-file-header-stats"><span class="diff-stats-add">+${file.stats?.additions ?? 0}</span><span class="diff-stats-del">-${file.stats?.deletions ?? 0}</span></span>
|
|
569
|
+
<div class="diff-split">
|
|
570
|
+
<div class="diff-split-pane">
|
|
571
|
+
<div class="diff-split-pane-header">Before</div>
|
|
572
|
+
${hunks}
|
|
376
573
|
</div>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
574
|
+
</div>
|
|
575
|
+
`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
renderDiffDetail(detail, index, { includeHeader = true } = {}) {
|
|
579
|
+
if (!detail) {
|
|
580
|
+
return '<div class="diff-empty-state">Select a file to load its diff.</div>';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (detail.tooLarge) {
|
|
584
|
+
return `
|
|
585
|
+
${includeHeader ? this.renderFileHeader(detail) : ''}
|
|
586
|
+
<div class="diff-limit-card">
|
|
587
|
+
<strong>Large diff withheld</strong>
|
|
588
|
+
<span>This file diff is large enough to impact rendering performance.</span>
|
|
589
|
+
<button class="ui-button btn btn-secondary diff-load-full-btn" type="button" data-load-full-diff data-diff-file-path="${escapeHtml(detail.path)}">Load full diff</button>
|
|
590
|
+
</div>
|
|
591
|
+
`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const contentMarkup = this.mode === 'split'
|
|
595
|
+
? this.renderSplitFileBody(detail, index)
|
|
596
|
+
: this.renderUnifiedFileBody(detail, index);
|
|
597
|
+
|
|
598
|
+
return `${includeHeader ? this.renderFileHeader(detail) : ''}${contentMarkup}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
renderFocusedFileBody() {
|
|
602
|
+
const currentFile = this.getCurrentFile();
|
|
603
|
+
if (!currentFile) {
|
|
604
|
+
return '<div class="diff-empty-state">No changes to display.</div>';
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (this.isFileLoading(currentFile.path)) {
|
|
608
|
+
return `
|
|
609
|
+
<section class="diff-file-block">
|
|
610
|
+
${this.renderFileHeader(currentFile)}
|
|
611
|
+
<div class="diff-empty-state">Loading file diff...</div>
|
|
612
|
+
</section>
|
|
613
|
+
`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const errorMessage = this.fileErrors.get(currentFile.path);
|
|
617
|
+
if (errorMessage) {
|
|
618
|
+
return `
|
|
619
|
+
<section class="diff-file-block">
|
|
620
|
+
${this.renderFileHeader(currentFile)}
|
|
621
|
+
<div class="diff-empty-state">${escapeHtml(errorMessage)}</div>
|
|
622
|
+
</section>
|
|
623
|
+
`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return `
|
|
627
|
+
<section class="diff-file-block" data-diff-file-index="${this.currentIndex}">
|
|
628
|
+
${this.renderDiffDetail(this.getFileDetail(currentFile.path) ?? currentFile, this.currentIndex)}
|
|
629
|
+
</section>
|
|
630
|
+
`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
renderCommitHeader() {
|
|
634
|
+
if (this.source !== 'commit' || !this.commitMeta) {
|
|
635
|
+
return '';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return `
|
|
639
|
+
<section class="diff-commit-header">
|
|
640
|
+
<div class="diff-commit-subject">${escapeHtml(this.commitMeta.subject || this.commitMeta.shortHash || 'Commit')}</div>
|
|
641
|
+
<div class="diff-commit-meta">
|
|
642
|
+
<span>${escapeHtml(this.commitMeta.shortHash || '')}</span>
|
|
643
|
+
<span>${escapeHtml(this.commitMeta.authorName || 'Unknown')}</span>
|
|
644
|
+
<span title="${escapeHtml(this.commitMeta.authoredAt || '')}">${escapeHtml(this.commitMeta.relativeDateLabel || '')}</span>
|
|
645
|
+
${this.commitMeta.isMergeCommit ? '<span>Merge commit</span>' : ''}
|
|
646
|
+
</div>
|
|
647
|
+
<div class="diff-commit-meta diff-commit-meta-secondary">
|
|
648
|
+
<span>${escapeHtml(this.commitMeta.hash || '')}</span>
|
|
649
|
+
<span>${Number(this.data?.summary?.filesChanged || 0)} file${Number(this.data?.summary?.filesChanged || 0) === 1 ? '' : 's'}</span>
|
|
382
650
|
</div>
|
|
383
651
|
</section>
|
|
384
652
|
`;
|
|
385
653
|
}
|
|
386
654
|
|
|
655
|
+
renderCommitIndex() {
|
|
656
|
+
const files = this.data?.files ?? [];
|
|
657
|
+
const items = files.map((file, index) => {
|
|
658
|
+
const isActive = this.activeFilePath === file.path;
|
|
659
|
+
const dirPath = getPathDir(file.path);
|
|
660
|
+
return `
|
|
661
|
+
<button
|
|
662
|
+
class="ui-record-surface diff-index-item${isActive ? ' active' : ''}"
|
|
663
|
+
type="button"
|
|
664
|
+
data-diff-index-path="${escapeHtml(file.path)}"
|
|
665
|
+
aria-current="${isActive ? 'true' : 'false'}"
|
|
666
|
+
>
|
|
667
|
+
<span class="ui-record-header diff-index-item-top">
|
|
668
|
+
<span class="ui-record-title diff-index-item-name">${escapeHtml(getPathLeaf(file.path))}</span>
|
|
669
|
+
<span class="ui-status-badge ${badgeClass(file.status)}">${escapeHtml(file.status)}</span>
|
|
670
|
+
</span>
|
|
671
|
+
${dirPath ? `<span class="ui-record-subtitle diff-index-item-path">${escapeHtml(dirPath)}</span>` : ''}
|
|
672
|
+
<span class="ui-record-meta diff-index-item-meta">
|
|
673
|
+
<span>${index + 1}</span>
|
|
674
|
+
<span class="ui-stat-token ui-stat-token--add diff-stats-add">+${file.stats?.additions ?? 0}</span>
|
|
675
|
+
<span class="ui-stat-token ui-stat-token--del diff-stats-del">-${file.stats?.deletions ?? 0}</span>
|
|
676
|
+
</span>
|
|
677
|
+
</button>
|
|
678
|
+
`;
|
|
679
|
+
}).join('');
|
|
680
|
+
|
|
681
|
+
return `
|
|
682
|
+
<aside class="diff-commit-index" aria-label="Changed files in commit">
|
|
683
|
+
<div class="diff-commit-index-header">Changed Files</div>
|
|
684
|
+
<div class="diff-commit-index-list">
|
|
685
|
+
${items}
|
|
686
|
+
</div>
|
|
687
|
+
</aside>
|
|
688
|
+
`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
renderStackedSection(file, index) {
|
|
692
|
+
const isCollapsed = this.isFileCollapsed(file.path);
|
|
693
|
+
const isActive = this.activeFilePath === file.path;
|
|
694
|
+
const isLoading = this.isFileLoading(file.path);
|
|
695
|
+
const errorMessage = this.fileErrors.get(file.path);
|
|
696
|
+
const detail = this.getFileDetail(file.path) ?? file;
|
|
697
|
+
|
|
698
|
+
let bodyMarkup = '';
|
|
699
|
+
if (isCollapsed) {
|
|
700
|
+
bodyMarkup = '';
|
|
701
|
+
} else if (isLoading) {
|
|
702
|
+
bodyMarkup = '<div class="diff-empty-state">Loading file diff...</div>';
|
|
703
|
+
} else if (errorMessage) {
|
|
704
|
+
bodyMarkup = `<div class="diff-empty-state">${escapeHtml(errorMessage)}</div>`;
|
|
705
|
+
} else {
|
|
706
|
+
bodyMarkup = this.renderDiffDetail(detail, index, { includeHeader: false });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return `
|
|
710
|
+
<section
|
|
711
|
+
class="diff-commit-section${isActive ? ' active' : ''}${isCollapsed ? ' collapsed' : ''}"
|
|
712
|
+
id="${createSectionId(file.path)}"
|
|
713
|
+
data-diff-section-path="${escapeHtml(file.path)}"
|
|
714
|
+
>
|
|
715
|
+
<button
|
|
716
|
+
class="diff-commit-section-header"
|
|
717
|
+
type="button"
|
|
718
|
+
data-diff-section-toggle="${escapeHtml(file.path)}"
|
|
719
|
+
>
|
|
720
|
+
<span class="diff-commit-section-main">
|
|
721
|
+
${chevronSvg(isCollapsed)}
|
|
722
|
+
<span class="diff-commit-section-copy">
|
|
723
|
+
<span class="diff-commit-section-name">${escapeHtml(getPathLeaf(file.path))}</span>
|
|
724
|
+
${getPathDir(file.path) ? `<span class="diff-commit-section-path">${escapeHtml(getPathDir(file.path))}</span>` : ''}
|
|
725
|
+
</span>
|
|
726
|
+
</span>
|
|
727
|
+
<span class="diff-commit-section-meta">
|
|
728
|
+
<span class="ui-status-badge ${badgeClass(file.status)}">${escapeHtml(file.status)}</span>
|
|
729
|
+
<span class="ui-stat-token ui-stat-token--add diff-stats-add">+${file.stats?.additions ?? 0}</span>
|
|
730
|
+
<span class="ui-stat-token ui-stat-token--del diff-stats-del">-${file.stats?.deletions ?? 0}</span>
|
|
731
|
+
</span>
|
|
732
|
+
</button>
|
|
733
|
+
<div class="diff-commit-section-body${isCollapsed ? ' hidden' : ''}">
|
|
734
|
+
${bodyMarkup}
|
|
735
|
+
</div>
|
|
736
|
+
</section>
|
|
737
|
+
`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
renderCommitBody() {
|
|
741
|
+
if (this.layoutMode === 'focused') {
|
|
742
|
+
return `
|
|
743
|
+
<div class="diff-commit-shell diff-commit-shell-focused">
|
|
744
|
+
${this.renderCommitIndex()}
|
|
745
|
+
<div class="diff-commit-main">
|
|
746
|
+
${this.renderFocusedFileBody()}
|
|
747
|
+
</div>
|
|
748
|
+
</div>
|
|
749
|
+
`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return `
|
|
753
|
+
<div class="diff-commit-shell diff-commit-shell-stacked">
|
|
754
|
+
${this.renderCommitIndex()}
|
|
755
|
+
<div class="diff-commit-main">
|
|
756
|
+
<div class="diff-commit-sections">
|
|
757
|
+
${(this.data?.files ?? []).map((file, index) => this.renderStackedSection(file, index)).join('')}
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
`;
|
|
762
|
+
}
|
|
763
|
+
|
|
387
764
|
getCurrentFile() {
|
|
388
765
|
const files = this.data?.files ?? [];
|
|
389
766
|
if (files.length === 0) {
|
|
@@ -393,9 +770,34 @@ export class GitDiffViewController {
|
|
|
393
770
|
return files[this.currentIndex] ?? files[0] ?? null;
|
|
394
771
|
}
|
|
395
772
|
|
|
773
|
+
getFileByPath(path) {
|
|
774
|
+
return (this.data?.files ?? []).find((file) => file.path === path) ?? null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
setActiveFilePath(path, { syncIndex = true } = {}) {
|
|
778
|
+
this.activeFilePath = path;
|
|
779
|
+
if (!syncIndex) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const nextIndex = Math.max(0, (this.data?.files ?? []).findIndex((file) => file.path === path));
|
|
784
|
+
if (nextIndex >= 0) {
|
|
785
|
+
this.currentIndex = nextIndex;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
getCacheKeyForPath(path) {
|
|
790
|
+
if (!path) {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
if (this.source === 'commit') {
|
|
794
|
+
return `commit:${this.commitHash}:${path}`;
|
|
795
|
+
}
|
|
796
|
+
return `workspace:${this.requestScope}:${path}`;
|
|
797
|
+
}
|
|
798
|
+
|
|
396
799
|
getCurrentCacheKey() {
|
|
397
|
-
|
|
398
|
-
return currentFile?.path ? `${this.requestScope}:${currentFile.path}` : null;
|
|
800
|
+
return this.getCacheKeyForPath(this.getCurrentFile()?.path ?? null);
|
|
399
801
|
}
|
|
400
802
|
|
|
401
803
|
getCurrentFileDetail() {
|
|
@@ -403,7 +805,28 @@ export class GitDiffViewController {
|
|
|
403
805
|
return cacheKey ? this.fileCache.get(cacheKey) : null;
|
|
404
806
|
}
|
|
405
807
|
|
|
808
|
+
getFileDetail(path) {
|
|
809
|
+
const cacheKey = this.getCacheKeyForPath(path);
|
|
810
|
+
return cacheKey ? this.fileCache.get(cacheKey) : null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
isFileCollapsed(path) {
|
|
814
|
+
return this.collapsedFiles.has(path);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
isFileLoading(path) {
|
|
818
|
+
return this.loadingFiles.has(path);
|
|
819
|
+
}
|
|
820
|
+
|
|
406
821
|
getCurrentActionState() {
|
|
822
|
+
if (this.source === 'commit') {
|
|
823
|
+
return {
|
|
824
|
+
canCommit: false,
|
|
825
|
+
canStage: false,
|
|
826
|
+
canUnstage: false,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
407
830
|
const currentFile = this.getCurrentFile();
|
|
408
831
|
const detail = this.getCurrentFileDetail() ?? currentFile;
|
|
409
832
|
const stagedCount = Number(this.repoStatus?.summary?.staged || 0);
|
|
@@ -423,6 +846,10 @@ export class GitDiffViewController {
|
|
|
423
846
|
}
|
|
424
847
|
|
|
425
848
|
getPrimaryAction() {
|
|
849
|
+
if (this.source === 'commit') {
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
|
|
426
853
|
const actionState = this.getCurrentActionState();
|
|
427
854
|
if (actionState.canStage && !actionState.canUnstage) {
|
|
428
855
|
return 'stage';
|
|
@@ -443,6 +870,10 @@ export class GitDiffViewController {
|
|
|
443
870
|
}
|
|
444
871
|
|
|
445
872
|
async handleFileAction(action) {
|
|
873
|
+
if (this.source === 'commit') {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
446
877
|
const currentFile = this.getCurrentFile();
|
|
447
878
|
if (!currentFile?.path || this.pendingAction) {
|
|
448
879
|
return;
|
|
@@ -470,135 +901,267 @@ export class GitDiffViewController {
|
|
|
470
901
|
this.syncToolbar();
|
|
471
902
|
}
|
|
472
903
|
|
|
473
|
-
async
|
|
474
|
-
const
|
|
475
|
-
if (!
|
|
904
|
+
async handleIndexSelection(filePath) {
|
|
905
|
+
const file = this.getFileByPath(filePath);
|
|
906
|
+
if (!file) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
this.setActiveFilePath(filePath);
|
|
911
|
+
if (this.source === 'commit' && this.layoutMode === 'stacked') {
|
|
912
|
+
this.collapsedFiles.delete(filePath);
|
|
913
|
+
await this.loadFileForPath(filePath, { render: false });
|
|
476
914
|
this.render();
|
|
477
|
-
|
|
915
|
+
this.scrollToFileSection(filePath);
|
|
916
|
+
return;
|
|
478
917
|
}
|
|
479
918
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
919
|
+
await this.loadFileForPath(filePath);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async toggleFileSection(filePath) {
|
|
923
|
+
if (this.source !== 'commit' || this.layoutMode !== 'stacked') {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
this.setActiveFilePath(filePath);
|
|
928
|
+
if (this.collapsedFiles.has(filePath)) {
|
|
929
|
+
this.collapsedFiles.delete(filePath);
|
|
930
|
+
await this.loadFileForPath(filePath, { render: false });
|
|
484
931
|
this.render();
|
|
485
|
-
|
|
932
|
+
this.scrollToFileSection(filePath);
|
|
933
|
+
return;
|
|
486
934
|
}
|
|
487
935
|
|
|
488
|
-
this.
|
|
936
|
+
this.collapsedFiles.add(filePath);
|
|
489
937
|
this.render();
|
|
938
|
+
}
|
|
490
939
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
940
|
+
scrollToFileSection(filePath) {
|
|
941
|
+
if (!this.content) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const targetId = createSectionId(filePath);
|
|
946
|
+
const scrollToTarget = () => {
|
|
947
|
+
const section = document.getElementById?.(targetId) ?? null;
|
|
948
|
+
if (!section) {
|
|
949
|
+
return;
|
|
497
950
|
}
|
|
498
951
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
952
|
+
if (this.scrollContainer?.scrollTo && this.scrollContainer.getBoundingClientRect && section.getBoundingClientRect) {
|
|
953
|
+
const containerRect = this.scrollContainer.getBoundingClientRect();
|
|
954
|
+
const sectionRect = section.getBoundingClientRect();
|
|
955
|
+
const currentTop = Number(this.scrollContainer.scrollTop) || 0;
|
|
956
|
+
const nextTop = Math.max(0, currentTop + (sectionRect.top - containerRect.top) - 8);
|
|
957
|
+
this.scrollContainer.scrollTo({ top: nextTop, behavior: 'smooth' });
|
|
958
|
+
return;
|
|
503
959
|
}
|
|
504
960
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
961
|
+
section.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
965
|
+
requestAnimationFrame(scrollToTarget);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
setTimeout(scrollToTarget, 0);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
handleScrollSelection() {
|
|
973
|
+
if (this.source !== 'commit' || this.layoutMode !== 'stacked' || !this.scrollContainer || !this.content) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const sections = Array.from(this.content.querySelectorAll?.('[data-diff-section-path]') ?? []);
|
|
978
|
+
if (sections.length === 0) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const containerRect = this.scrollContainer.getBoundingClientRect?.();
|
|
983
|
+
if (!containerRect) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const threshold = containerRect.top + 120;
|
|
988
|
+
let nextPath = null;
|
|
989
|
+
for (const section of sections) {
|
|
990
|
+
const rect = section.getBoundingClientRect?.();
|
|
991
|
+
if (!rect) {
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
const sectionPath = section.getAttribute?.('data-diff-section-path');
|
|
995
|
+
if (!sectionPath) {
|
|
996
|
+
continue;
|
|
511
997
|
}
|
|
512
|
-
|
|
998
|
+
if (rect.top <= threshold) {
|
|
999
|
+
nextPath = sectionPath;
|
|
1000
|
+
} else if (!nextPath) {
|
|
1001
|
+
nextPath = sectionPath;
|
|
1002
|
+
break;
|
|
1003
|
+
} else {
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (nextPath && nextPath !== this.activeFilePath) {
|
|
1009
|
+
this.setActiveFilePath(nextPath);
|
|
513
1010
|
this.render();
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async loadCurrentFile({ forceFullPatch = false } = {}) {
|
|
1015
|
+
const currentFile = this.getCurrentFile();
|
|
1016
|
+
if (!currentFile?.path) {
|
|
519
1017
|
this.render();
|
|
520
1018
|
return null;
|
|
521
1019
|
}
|
|
1020
|
+
|
|
1021
|
+
return this.loadFileForPath(currentFile.path, { forceFullPatch });
|
|
522
1022
|
}
|
|
523
1023
|
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
if (!
|
|
527
|
-
|
|
1024
|
+
async loadFileForPath(filePath, { forceFullPatch = false, render = true } = {}) {
|
|
1025
|
+
const file = this.getFileByPath(filePath);
|
|
1026
|
+
if (!file?.path) {
|
|
1027
|
+
if (render) {
|
|
1028
|
+
this.render();
|
|
1029
|
+
}
|
|
1030
|
+
return null;
|
|
528
1031
|
}
|
|
529
1032
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
<div class="diff-empty-state">Loading file diff...</div>
|
|
539
|
-
</section>
|
|
540
|
-
`;
|
|
1033
|
+
this.setActiveFilePath(filePath);
|
|
1034
|
+
const cacheKey = this.getCacheKeyForPath(filePath);
|
|
1035
|
+
const cachedFile = cacheKey ? this.fileCache.get(cacheKey) : null;
|
|
1036
|
+
if (cachedFile && (!cachedFile.tooLarge || !forceFullPatch)) {
|
|
1037
|
+
if (render) {
|
|
1038
|
+
this.render();
|
|
1039
|
+
}
|
|
1040
|
+
return cachedFile;
|
|
541
1041
|
}
|
|
542
1042
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
<div class="diff-file-header">
|
|
547
|
-
<span class="diff-file-path">${escapeHtml(currentFile.path)}</span>
|
|
548
|
-
<span class="git-status-badge ${badgeClass(currentFile.status)}">${escapeHtml(currentFile.status)}</span>
|
|
549
|
-
</div>
|
|
550
|
-
<div class="diff-empty-state">Select a file to load its diff.</div>
|
|
551
|
-
</section>
|
|
552
|
-
`;
|
|
1043
|
+
const requestKey = `${filePath}:${forceFullPatch ? 'full' : 'partial'}`;
|
|
1044
|
+
if (this.fileLoadPromises.has(requestKey)) {
|
|
1045
|
+
return this.fileLoadPromises.get(requestKey);
|
|
553
1046
|
}
|
|
554
1047
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
<span class="diff-file-path">${escapeHtml(detail.path)}</span>
|
|
560
|
-
<span class="git-status-badge ${badgeClass(detail.status)}">${escapeHtml(detail.status)}</span>
|
|
561
|
-
<span class="diff-file-header-stats"><span class="diff-stats-add">+${detail.stats?.additions ?? 0}</span><span class="diff-stats-del">-${detail.stats?.deletions ?? 0}</span></span>
|
|
562
|
-
</div>
|
|
563
|
-
<div class="diff-limit-card">
|
|
564
|
-
<strong>Large diff withheld</strong>
|
|
565
|
-
<span>This file diff is large enough to impact rendering performance.</span>
|
|
566
|
-
<button class="btn btn-secondary diff-load-full-btn" type="button" data-load-full-diff>Load full diff</button>
|
|
567
|
-
</div>
|
|
568
|
-
</section>
|
|
569
|
-
`;
|
|
1048
|
+
this.loadingFiles.add(filePath);
|
|
1049
|
+
this.fileErrors.delete(filePath);
|
|
1050
|
+
if (render) {
|
|
1051
|
+
this.render();
|
|
570
1052
|
}
|
|
571
1053
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1054
|
+
const requestPromise = (async () => {
|
|
1055
|
+
try {
|
|
1056
|
+
let detail;
|
|
1057
|
+
if (this.source === 'commit') {
|
|
1058
|
+
const query = new URLSearchParams();
|
|
1059
|
+
query.set('hash', this.commitHash || '');
|
|
1060
|
+
query.set('path', filePath);
|
|
1061
|
+
if (forceFullPatch) {
|
|
1062
|
+
query.set('allowLargePatch', 'true');
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const response = await fetch(resolveApiUrl(`/git/commit?${query.toString()}`));
|
|
1066
|
+
const data = await response.json();
|
|
1067
|
+
if (!response.ok) {
|
|
1068
|
+
throw new Error(data.error || 'Failed to load commit file diff');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
detail = {
|
|
1072
|
+
...file,
|
|
1073
|
+
...(data.files?.[0] ?? {}),
|
|
1074
|
+
};
|
|
1075
|
+
} else {
|
|
1076
|
+
const query = new URLSearchParams();
|
|
1077
|
+
query.set('scope', this.requestScope);
|
|
1078
|
+
query.set('path', filePath);
|
|
1079
|
+
if (forceFullPatch) {
|
|
1080
|
+
query.set('allowLargePatch', 'true');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const response = await fetch(resolveApiUrl(`/git/diff?${query.toString()}`));
|
|
1084
|
+
const data = await response.json();
|
|
1085
|
+
if (!response.ok) {
|
|
1086
|
+
throw new Error(data.error || 'Failed to load file diff');
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
detail = {
|
|
1090
|
+
...file,
|
|
1091
|
+
...(data.files?.[0] ?? {}),
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (cacheKey) {
|
|
1096
|
+
this.fileCache.set(cacheKey, detail);
|
|
1097
|
+
}
|
|
1098
|
+
this.fileErrors.delete(filePath);
|
|
1099
|
+
return detail;
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
console.error('[git-diff] Failed to load file diff:', error);
|
|
1102
|
+
const message = this.source === 'commit' ? 'Failed to load commit file diff' : 'Failed to load file diff';
|
|
1103
|
+
this.fileErrors.set(filePath, message);
|
|
1104
|
+
this.toastController?.show(message);
|
|
1105
|
+
return null;
|
|
1106
|
+
} finally {
|
|
1107
|
+
this.loadingFiles.delete(filePath);
|
|
1108
|
+
this.fileLoadPromises.delete(requestKey);
|
|
1109
|
+
this.render();
|
|
1110
|
+
}
|
|
1111
|
+
})();
|
|
1112
|
+
|
|
1113
|
+
this.fileLoadPromises.set(requestKey, requestPromise);
|
|
1114
|
+
return requestPromise;
|
|
575
1115
|
}
|
|
576
1116
|
|
|
577
1117
|
syncToolbar() {
|
|
578
1118
|
const totalFiles = this.data?.files?.length ?? 0;
|
|
579
1119
|
const visibleIndex = totalFiles === 0 ? 0 : this.currentIndex + 1;
|
|
1120
|
+
const isCommitSource = this.source === 'commit';
|
|
1121
|
+
const isStackedCommit = isCommitSource && this.layoutMode === 'stacked';
|
|
1122
|
+
|
|
580
1123
|
if (this.fileIndicator) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1124
|
+
if (isStackedCommit) {
|
|
1125
|
+
this.fileIndicator.textContent = `${totalFiles} file${totalFiles === 1 ? '' : 's'}`;
|
|
1126
|
+
} else {
|
|
1127
|
+
this.fileIndicator.textContent = totalFiles > 0
|
|
1128
|
+
? `${visibleIndex} / ${totalFiles} files`
|
|
1129
|
+
: '0 / 0 files';
|
|
1130
|
+
}
|
|
584
1131
|
}
|
|
585
1132
|
|
|
586
1133
|
if (this.stats) {
|
|
587
1134
|
const additions = this.data?.summary?.additions ?? 0;
|
|
588
1135
|
const deletions = this.data?.summary?.deletions ?? 0;
|
|
589
1136
|
this.stats.innerHTML = `
|
|
590
|
-
<span class="diff-stats-add">+${additions}</span>
|
|
591
|
-
<span class="diff-stats-del">-${deletions}</span>
|
|
1137
|
+
<span class="ui-stat-token ui-stat-token--add diff-stats-add">+${additions}</span>
|
|
1138
|
+
<span class="ui-stat-token ui-stat-token--del diff-stats-del">-${deletions}</span>
|
|
592
1139
|
`;
|
|
593
1140
|
}
|
|
594
1141
|
|
|
595
1142
|
this.modeButtons.forEach((button) => {
|
|
1143
|
+
button.classList.remove('hidden');
|
|
596
1144
|
button.classList.toggle('active', button.getAttribute('data-diff-mode') === this.mode);
|
|
597
1145
|
});
|
|
1146
|
+
this.layoutButtons.forEach((button) => {
|
|
1147
|
+
button.classList.toggle('active', button.getAttribute('data-diff-layout') === this.layoutMode);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
598
1150
|
const hasCurrentFile = Boolean(this.getCurrentFile()?.path);
|
|
599
1151
|
const actionState = this.getCurrentActionState();
|
|
600
1152
|
const primaryAction = this.getPrimaryAction();
|
|
601
|
-
|
|
1153
|
+
|
|
1154
|
+
this.backToHistoryButton?.classList.toggle('hidden', !isCommitSource);
|
|
1155
|
+
this.gitActionsGroup?.classList.toggle('hidden', isCommitSource);
|
|
1156
|
+
this.editorActionsGroup?.classList.toggle('hidden', isCommitSource);
|
|
1157
|
+
this.actionsDivider?.classList.toggle('hidden', isCommitSource);
|
|
1158
|
+
this.layoutToggle?.classList.toggle('hidden', !isCommitSource);
|
|
1159
|
+
this.stats?.classList.remove('hidden');
|
|
1160
|
+
|
|
1161
|
+
if (this.openEditorButton) {
|
|
1162
|
+
this.openEditorButton.textContent = 'Open in Editor';
|
|
1163
|
+
}
|
|
1164
|
+
this.openEditorButton?.toggleAttribute('disabled', !hasCurrentFile || isCommitSource);
|
|
602
1165
|
if (this.primaryActionButton) {
|
|
603
1166
|
this.primaryActionButton.textContent = this.pendingAction === primaryAction
|
|
604
1167
|
? 'Working...'
|
|
@@ -607,7 +1170,7 @@ export class GitDiffViewController {
|
|
|
607
1170
|
: 'Stage';
|
|
608
1171
|
this.primaryActionButton.toggleAttribute(
|
|
609
1172
|
'disabled',
|
|
610
|
-
!hasCurrentFile || !primaryAction || Boolean(this.pendingAction),
|
|
1173
|
+
isCommitSource || !hasCurrentFile || !primaryAction || Boolean(this.pendingAction),
|
|
611
1174
|
);
|
|
612
1175
|
}
|
|
613
1176
|
if (this.commitButton) {
|
|
@@ -615,10 +1178,14 @@ export class GitDiffViewController {
|
|
|
615
1178
|
}
|
|
616
1179
|
this.commitButton?.toggleAttribute(
|
|
617
1180
|
'disabled',
|
|
618
|
-
!actionState.canCommit || Boolean(this.pendingAction),
|
|
1181
|
+
isCommitSource || !actionState.canCommit || Boolean(this.pendingAction),
|
|
619
1182
|
);
|
|
620
|
-
this.prevButton?.
|
|
621
|
-
this.nextButton?.
|
|
1183
|
+
this.prevButton?.classList.remove('hidden');
|
|
1184
|
+
this.nextButton?.classList.remove('hidden');
|
|
1185
|
+
this.prevButton?.classList.toggle('hidden', isStackedCommit);
|
|
1186
|
+
this.nextButton?.classList.toggle('hidden', isStackedCommit);
|
|
1187
|
+
this.prevButton?.toggleAttribute('disabled', isStackedCommit || this.currentIndex <= 0);
|
|
1188
|
+
this.nextButton?.toggleAttribute('disabled', isStackedCommit || totalFiles === 0 || this.currentIndex >= totalFiles - 1);
|
|
622
1189
|
}
|
|
623
1190
|
|
|
624
1191
|
render() {
|
|
@@ -626,7 +1193,7 @@ export class GitDiffViewController {
|
|
|
626
1193
|
|
|
627
1194
|
const files = this.data?.files ?? [];
|
|
628
1195
|
if (files.length === 0) {
|
|
629
|
-
this.renderEmpty('No changes to display.');
|
|
1196
|
+
this.renderEmpty(this.source === 'commit' ? 'No commit changes to display.' : 'No changes to display.');
|
|
630
1197
|
return;
|
|
631
1198
|
}
|
|
632
1199
|
|
|
@@ -634,11 +1201,41 @@ export class GitDiffViewController {
|
|
|
634
1201
|
return;
|
|
635
1202
|
}
|
|
636
1203
|
|
|
637
|
-
this.
|
|
1204
|
+
if (this.source === 'commit') {
|
|
1205
|
+
this.content.innerHTML = `${this.renderCommitHeader()}${this.renderCommitBody()}`;
|
|
1206
|
+
} else {
|
|
1207
|
+
this.content.innerHTML = this.renderFocusedFileBody();
|
|
1208
|
+
}
|
|
638
1209
|
this.syncToolbar();
|
|
639
1210
|
}
|
|
640
1211
|
|
|
641
|
-
getToolbarTitle({ filePath = null, scope = 'all' } = {}) {
|
|
1212
|
+
getToolbarTitle({ commitHash = null, filePath = null, path = null, scope = 'all', source = 'workspace' } = {}) {
|
|
1213
|
+
if (source === 'commit' || this.source === 'commit') {
|
|
1214
|
+
if (this.layoutMode === 'stacked') {
|
|
1215
|
+
if (this.commitMeta?.shortHash) {
|
|
1216
|
+
return `Commit ${this.commitMeta.shortHash}`;
|
|
1217
|
+
}
|
|
1218
|
+
if (commitHash) {
|
|
1219
|
+
return `Commit ${String(commitHash).slice(0, 7)}`;
|
|
1220
|
+
}
|
|
1221
|
+
return 'Commit Diff';
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (path) {
|
|
1225
|
+
return getPathLeaf(path);
|
|
1226
|
+
}
|
|
1227
|
+
if (this.activeFilePath) {
|
|
1228
|
+
return getPathLeaf(this.activeFilePath);
|
|
1229
|
+
}
|
|
1230
|
+
if (this.commitMeta?.shortHash) {
|
|
1231
|
+
return `Commit ${this.commitMeta.shortHash}`;
|
|
1232
|
+
}
|
|
1233
|
+
if (commitHash) {
|
|
1234
|
+
return `Commit ${String(commitHash).slice(0, 7)}`;
|
|
1235
|
+
}
|
|
1236
|
+
return 'Commit Diff';
|
|
1237
|
+
}
|
|
1238
|
+
|
|
642
1239
|
if (filePath) {
|
|
643
1240
|
return getPathLeaf(filePath);
|
|
644
1241
|
}
|