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,806 @@
|
|
|
1
|
+
import { escapeHtml } from '../domain/vault-utils.js';
|
|
2
|
+
import { resolveApiUrl } from '../domain/runtime-paths.js';
|
|
3
|
+
|
|
4
|
+
const HISTORY_PAGE_SIZE = 30;
|
|
5
|
+
|
|
6
|
+
function getPathLeaf(pathValue) {
|
|
7
|
+
return String(pathValue ?? '').split('/').pop() || '';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getPathDir(pathValue) {
|
|
11
|
+
const parts = String(pathValue ?? '').split('/');
|
|
12
|
+
parts.pop();
|
|
13
|
+
return parts.join('/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderHistoryTitle(filePath = '') {
|
|
17
|
+
const fileName = getPathLeaf(filePath);
|
|
18
|
+
const dirPath = getPathDir(filePath);
|
|
19
|
+
if (!dirPath) {
|
|
20
|
+
return `<span class="file-history-path">${escapeHtml(fileName)}</span>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `
|
|
24
|
+
<span class="file-history-path">${escapeHtml(fileName)}</span>
|
|
25
|
+
<span class="file-history-dir">${escapeHtml(dirPath)}</span>
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderCommitSubtitle(commit = {}) {
|
|
30
|
+
const parts = [
|
|
31
|
+
String(commit.authorName || 'Unknown'),
|
|
32
|
+
String(commit.relativeDateLabel || ''),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
if (commit.status === 'renamed' && commit.oldPath) {
|
|
36
|
+
parts.push(`Renamed from ${commit.oldPath}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return parts.filter(Boolean).map((part) => escapeHtml(part)).join(' · ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function entryKey(entry = {}) {
|
|
43
|
+
if (entry.type === 'local') {
|
|
44
|
+
return 'local';
|
|
45
|
+
}
|
|
46
|
+
return `commit:${String(entry.hash || '').trim()}:${String(entry.pathAtCommit || '').trim()}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createEmptyHistoryState() {
|
|
50
|
+
return {
|
|
51
|
+
commits: [],
|
|
52
|
+
error: '',
|
|
53
|
+
hasMore: false,
|
|
54
|
+
loaded: false,
|
|
55
|
+
loading: false,
|
|
56
|
+
loadingMore: false,
|
|
57
|
+
offset: 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class FileHistoryViewController {
|
|
62
|
+
constructor({
|
|
63
|
+
diffRenderer = null,
|
|
64
|
+
onOpenCommitDiff = () => {},
|
|
65
|
+
onOpenFile = () => {},
|
|
66
|
+
onOpenPreview = () => {},
|
|
67
|
+
onOpenWorkspaceDiff = () => {},
|
|
68
|
+
toastController = null,
|
|
69
|
+
} = {}) {
|
|
70
|
+
this.diffRenderer = diffRenderer;
|
|
71
|
+
this.onOpenCommitDiff = onOpenCommitDiff;
|
|
72
|
+
this.onOpenFile = onOpenFile;
|
|
73
|
+
this.onOpenPreview = onOpenPreview;
|
|
74
|
+
this.onOpenWorkspaceDiff = onOpenWorkspaceDiff;
|
|
75
|
+
this.toastController = toastController;
|
|
76
|
+
this.page = document.getElementById('diff-page');
|
|
77
|
+
this.content = document.getElementById('diffContent');
|
|
78
|
+
this.fileIndicator = document.getElementById('diffFileIndicator');
|
|
79
|
+
this.openEditorButton = document.getElementById('diffOpenEditorBtn');
|
|
80
|
+
this.primaryActionButton = document.getElementById('diffPrimaryActionBtn');
|
|
81
|
+
this.commitButton = document.getElementById('diffCommitBtn');
|
|
82
|
+
this.backToHistoryButton = document.getElementById('diffBackToHistoryBtn');
|
|
83
|
+
this.gitActionsGroup = document.getElementById('diffGitActionsGroup');
|
|
84
|
+
this.editorActionsGroup = document.getElementById('diffEditorActionsGroup');
|
|
85
|
+
this.actionsDivider = document.getElementById('diffToolbarDivider');
|
|
86
|
+
this.stats = document.getElementById('diffStats');
|
|
87
|
+
this.prevButton = document.getElementById('diffPrevBtn');
|
|
88
|
+
this.nextButton = document.getElementById('diffNextBtn');
|
|
89
|
+
this.layoutToggle = document.getElementById('diffLayoutToggle');
|
|
90
|
+
this.modeButtons = Array.from(document.querySelectorAll('[data-diff-mode]'));
|
|
91
|
+
this.currentFilePath = null;
|
|
92
|
+
this.localChanges = null;
|
|
93
|
+
this.history = createEmptyHistoryState();
|
|
94
|
+
this.diffMode = 'unified';
|
|
95
|
+
this.selectedEntryKey = null;
|
|
96
|
+
this.detailCache = new Map();
|
|
97
|
+
this.detailErrors = new Map();
|
|
98
|
+
this.historyListElement = null;
|
|
99
|
+
this.historyListScrollTop = 0;
|
|
100
|
+
this.loadingEntryKey = null;
|
|
101
|
+
this.selectionRequestToken = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
initialize() {
|
|
105
|
+
this.openEditorButton?.addEventListener('click', () => {
|
|
106
|
+
if (!this.currentFilePath) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.onOpenFile?.(this.currentFilePath);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.modeButtons.forEach((button) => {
|
|
114
|
+
button.addEventListener('click', () => {
|
|
115
|
+
const nextMode = button.getAttribute('data-diff-mode');
|
|
116
|
+
if (!nextMode || nextMode === this.diffMode) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.diffMode = nextMode;
|
|
120
|
+
this.render();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
this.content?.addEventListener('click', (event) => {
|
|
125
|
+
const selectionButton = event.target instanceof Element
|
|
126
|
+
? event.target.closest('[data-file-history-select]')
|
|
127
|
+
: null;
|
|
128
|
+
if (selectionButton) {
|
|
129
|
+
const nextEntry = this.findEntryByKey(selectionButton.getAttribute('data-file-history-select'));
|
|
130
|
+
if (nextEntry) {
|
|
131
|
+
void this.selectEntry(nextEntry);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const openDiffButton = event.target instanceof Element
|
|
137
|
+
? event.target.closest('[data-file-history-open-selected-diff]')
|
|
138
|
+
: null;
|
|
139
|
+
if (openDiffButton) {
|
|
140
|
+
this.openSelectedDiff();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const previewButton = event.target instanceof Element
|
|
145
|
+
? event.target.closest('[data-file-history-open-selected-preview]')
|
|
146
|
+
: null;
|
|
147
|
+
if (previewButton) {
|
|
148
|
+
this.openSelectedPreview();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const loadMoreButton = event.target instanceof Element
|
|
153
|
+
? event.target.closest('[data-file-history-load-more]')
|
|
154
|
+
: null;
|
|
155
|
+
if (loadMoreButton) {
|
|
156
|
+
void this.loadMore();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
hide() {
|
|
162
|
+
this.page?.classList.add('hidden');
|
|
163
|
+
this.content?.classList.remove('diff-content--history');
|
|
164
|
+
if (this.content) {
|
|
165
|
+
this.content.innerHTML = '';
|
|
166
|
+
}
|
|
167
|
+
this.currentFilePath = null;
|
|
168
|
+
this.localChanges = null;
|
|
169
|
+
this.history = createEmptyHistoryState();
|
|
170
|
+
this.selectedEntryKey = null;
|
|
171
|
+
this.loadingEntryKey = null;
|
|
172
|
+
this.detailCache.clear();
|
|
173
|
+
this.detailErrors.clear();
|
|
174
|
+
this.historyListElement = null;
|
|
175
|
+
this.historyListScrollTop = 0;
|
|
176
|
+
this.selectionRequestToken = 0;
|
|
177
|
+
this.syncToolbar();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getEntries() {
|
|
181
|
+
const entries = [];
|
|
182
|
+
if (this.localChanges) {
|
|
183
|
+
entries.push({
|
|
184
|
+
...this.localChanges,
|
|
185
|
+
key: 'local',
|
|
186
|
+
label: 'Local changes',
|
|
187
|
+
pathAtCommit: this.currentFilePath,
|
|
188
|
+
type: 'local',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const commit of this.history.commits ?? []) {
|
|
193
|
+
entries.push({
|
|
194
|
+
...commit,
|
|
195
|
+
key: entryKey({ ...commit, type: 'commit' }),
|
|
196
|
+
type: 'commit',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return entries;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
findEntryByKey(key) {
|
|
204
|
+
return this.getEntries().find((entry) => entry.key === key) ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
getSelectedEntry() {
|
|
208
|
+
return this.findEntryByKey(this.selectedEntryKey);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getDefaultEntry() {
|
|
212
|
+
const entries = this.getEntries();
|
|
213
|
+
return entries[0] ?? null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getSelectedDetailState() {
|
|
217
|
+
const entry = this.getSelectedEntry();
|
|
218
|
+
if (!entry) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
detail: this.detailCache.get(entry.key) ?? null,
|
|
224
|
+
entry,
|
|
225
|
+
error: this.detailErrors.get(entry.key) ?? '',
|
|
226
|
+
loading: this.loadingEntryKey === entry.key,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async openFileHistory({ filePath }) {
|
|
231
|
+
this.currentFilePath = String(filePath ?? '').trim() || null;
|
|
232
|
+
this.localChanges = null;
|
|
233
|
+
this.history = {
|
|
234
|
+
...createEmptyHistoryState(),
|
|
235
|
+
loading: true,
|
|
236
|
+
};
|
|
237
|
+
this.selectedEntryKey = null;
|
|
238
|
+
this.loadingEntryKey = null;
|
|
239
|
+
this.detailCache.clear();
|
|
240
|
+
this.detailErrors.clear();
|
|
241
|
+
this.historyListElement = null;
|
|
242
|
+
this.historyListScrollTop = 0;
|
|
243
|
+
this.selectionRequestToken = 0;
|
|
244
|
+
this.renderLoading();
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const [localChanges, history] = await Promise.all([
|
|
248
|
+
this.loadLocalChanges().catch(() => null),
|
|
249
|
+
this.fetchHistoryPage({ offset: 0 }),
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
this.localChanges = localChanges;
|
|
253
|
+
this.history = history;
|
|
254
|
+
|
|
255
|
+
const initialEntry = this.getDefaultEntry();
|
|
256
|
+
if (!initialEntry) {
|
|
257
|
+
this.render();
|
|
258
|
+
return {
|
|
259
|
+
history,
|
|
260
|
+
localChanges,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await this.selectEntry(initialEntry);
|
|
265
|
+
return {
|
|
266
|
+
history,
|
|
267
|
+
localChanges,
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error('[file-history] Failed to load file history:', error);
|
|
271
|
+
this.toastController?.show('Failed to load file history');
|
|
272
|
+
this.localChanges = null;
|
|
273
|
+
this.history = {
|
|
274
|
+
...createEmptyHistoryState(),
|
|
275
|
+
error: 'Failed to load file history',
|
|
276
|
+
loaded: true,
|
|
277
|
+
};
|
|
278
|
+
this.render();
|
|
279
|
+
return {
|
|
280
|
+
history: this.history,
|
|
281
|
+
localChanges: null,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async loadLocalChanges() {
|
|
287
|
+
if (!this.currentFilePath) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const query = new URLSearchParams();
|
|
292
|
+
query.set('metaOnly', 'true');
|
|
293
|
+
query.set('path', this.currentFilePath);
|
|
294
|
+
query.set('scope', 'all');
|
|
295
|
+
const response = await fetch(resolveApiUrl(`/git/diff?${query.toString()}`));
|
|
296
|
+
const data = await response.json();
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
throw new Error(data.error || 'Failed to load local file changes');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const file = data.files?.[0];
|
|
302
|
+
if (!file) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
...file,
|
|
308
|
+
summary: data.summary ?? {
|
|
309
|
+
additions: Number(file.stats?.additions || 0),
|
|
310
|
+
deletions: Number(file.stats?.deletions || 0),
|
|
311
|
+
filesChanged: 1,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async fetchHistoryPage({ offset = 0 } = {}) {
|
|
317
|
+
if (!this.currentFilePath) {
|
|
318
|
+
return {
|
|
319
|
+
...createEmptyHistoryState(),
|
|
320
|
+
loaded: true,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const query = new URLSearchParams();
|
|
325
|
+
query.set('path', this.currentFilePath);
|
|
326
|
+
query.set('limit', String(HISTORY_PAGE_SIZE));
|
|
327
|
+
query.set('offset', String(offset));
|
|
328
|
+
const response = await fetch(resolveApiUrl(`/git/file-history?${query.toString()}`));
|
|
329
|
+
const data = await response.json();
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
throw new Error(data.error || 'Failed to load file history');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const commits = Array.isArray(data.commits) ? data.commits : [];
|
|
335
|
+
return {
|
|
336
|
+
commits,
|
|
337
|
+
error: '',
|
|
338
|
+
hasMore: Boolean(data.hasMore),
|
|
339
|
+
loaded: true,
|
|
340
|
+
loading: false,
|
|
341
|
+
loadingMore: false,
|
|
342
|
+
offset: offset + commits.length,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async loadSelectedDetail(entry) {
|
|
347
|
+
if (!entry) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (entry.type === 'local') {
|
|
352
|
+
const query = new URLSearchParams();
|
|
353
|
+
query.set('path', this.currentFilePath || '');
|
|
354
|
+
query.set('scope', 'all');
|
|
355
|
+
const response = await fetch(resolveApiUrl(`/git/diff?${query.toString()}`));
|
|
356
|
+
const data = await response.json();
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
throw new Error(data.error || 'Failed to load local diff');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
detail: data.files?.[0] ?? null,
|
|
363
|
+
summary: data.summary ?? entry.summary ?? null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const query = new URLSearchParams();
|
|
368
|
+
query.set('hash', entry.hash || '');
|
|
369
|
+
query.set('path', entry.pathAtCommit || '');
|
|
370
|
+
const response = await fetch(resolveApiUrl(`/git/commit?${query.toString()}`));
|
|
371
|
+
const data = await response.json();
|
|
372
|
+
if (!response.ok) {
|
|
373
|
+
throw new Error(data.error || 'Failed to load commit diff');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
commit: data.commit ?? null,
|
|
378
|
+
detail: data.files?.[0] ?? null,
|
|
379
|
+
summary: data.summary ?? null,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async selectEntry(entry) {
|
|
384
|
+
if (!entry) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const key = entry.key || entryKey(entry);
|
|
389
|
+
this.selectedEntryKey = key;
|
|
390
|
+
this.detailErrors.delete(key);
|
|
391
|
+
|
|
392
|
+
const cached = this.detailCache.get(key);
|
|
393
|
+
if (cached) {
|
|
394
|
+
this.loadingEntryKey = null;
|
|
395
|
+
this.render();
|
|
396
|
+
return cached;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.loadingEntryKey = key;
|
|
400
|
+
this.render();
|
|
401
|
+
|
|
402
|
+
const requestToken = ++this.selectionRequestToken;
|
|
403
|
+
try {
|
|
404
|
+
const detail = await this.loadSelectedDetail(entry);
|
|
405
|
+
this.detailCache.set(key, detail);
|
|
406
|
+
this.detailErrors.delete(key);
|
|
407
|
+
if (requestToken === this.selectionRequestToken && this.selectedEntryKey === key) {
|
|
408
|
+
this.loadingEntryKey = null;
|
|
409
|
+
this.render();
|
|
410
|
+
}
|
|
411
|
+
return detail;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
console.error('[file-history] Failed to load selected diff:', error);
|
|
414
|
+
this.toastController?.show(entry.type === 'local' ? 'Failed to load local diff' : 'Failed to load commit diff');
|
|
415
|
+
this.detailErrors.set(key, entry.type === 'local' ? 'Failed to load local diff' : 'Failed to load commit diff');
|
|
416
|
+
if (requestToken === this.selectionRequestToken && this.selectedEntryKey === key) {
|
|
417
|
+
this.loadingEntryKey = null;
|
|
418
|
+
this.render();
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async loadMore() {
|
|
425
|
+
if (!this.currentFilePath || this.history.loading || this.history.loadingMore || !this.history.hasMore) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.history = {
|
|
430
|
+
...this.history,
|
|
431
|
+
error: '',
|
|
432
|
+
loadingMore: true,
|
|
433
|
+
};
|
|
434
|
+
this.render();
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const nextPage = await this.fetchHistoryPage({ offset: this.history.offset });
|
|
438
|
+
this.history = {
|
|
439
|
+
commits: [...this.history.commits, ...nextPage.commits],
|
|
440
|
+
error: '',
|
|
441
|
+
hasMore: nextPage.hasMore,
|
|
442
|
+
loaded: true,
|
|
443
|
+
loading: false,
|
|
444
|
+
loadingMore: false,
|
|
445
|
+
offset: nextPage.offset,
|
|
446
|
+
};
|
|
447
|
+
this.render();
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error('[file-history] Failed to load more history:', error);
|
|
450
|
+
this.toastController?.show('Failed to load file history');
|
|
451
|
+
this.history = {
|
|
452
|
+
...this.history,
|
|
453
|
+
error: 'Failed to load file history',
|
|
454
|
+
loadingMore: false,
|
|
455
|
+
};
|
|
456
|
+
this.render();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
openSelectedDiff() {
|
|
461
|
+
const entry = this.getSelectedEntry();
|
|
462
|
+
if (!entry) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (entry.type === 'local') {
|
|
467
|
+
this.onOpenWorkspaceDiff?.(this.currentFilePath);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.onOpenCommitDiff?.(entry.hash, {
|
|
472
|
+
historyFilePath: this.currentFilePath,
|
|
473
|
+
path: entry.pathAtCommit,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
openSelectedPreview() {
|
|
478
|
+
const entry = this.getSelectedEntry();
|
|
479
|
+
if (!entry || entry.type !== 'commit') {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
this.onOpenPreview?.({
|
|
484
|
+
currentFilePath: this.currentFilePath,
|
|
485
|
+
hash: entry.hash,
|
|
486
|
+
path: entry.pathAtCommit,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
renderDiffFileHeader(file = {}) {
|
|
491
|
+
if (typeof this.diffRenderer?.renderFileHeader === 'function') {
|
|
492
|
+
return this.diffRenderer.renderFileHeader(file);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return `
|
|
496
|
+
<div class="diff-file-header">
|
|
497
|
+
<span class="diff-file-path">${escapeHtml(file.path || this.currentFilePath || '')}</span>
|
|
498
|
+
</div>
|
|
499
|
+
`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
renderDiffMarkup(file = {}) {
|
|
503
|
+
if (typeof this.diffRenderer?.renderDiffDetail === 'function') {
|
|
504
|
+
const previousMode = this.diffRenderer.mode;
|
|
505
|
+
this.diffRenderer.mode = this.diffMode;
|
|
506
|
+
const markup = this.diffRenderer.renderDiffDetail(file, 0, { includeHeader: true });
|
|
507
|
+
this.diffRenderer.mode = previousMode;
|
|
508
|
+
return markup;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return `
|
|
512
|
+
${this.renderDiffFileHeader(file)}
|
|
513
|
+
<div class="diff-empty-state">Diff renderer unavailable.</div>
|
|
514
|
+
`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
syncToolbar() {
|
|
518
|
+
const selection = this.getSelectedDetailState();
|
|
519
|
+
const selectedEntry = selection?.entry ?? null;
|
|
520
|
+
const selectedSummary = selection?.detail?.summary
|
|
521
|
+
?? selectedEntry?.summary
|
|
522
|
+
?? (selectedEntry ? {
|
|
523
|
+
additions: Number(selectedEntry.additions || 0),
|
|
524
|
+
deletions: Number(selectedEntry.deletions || 0),
|
|
525
|
+
} : null);
|
|
526
|
+
const additions = Number(selectedSummary?.additions || 0);
|
|
527
|
+
const deletions = Number(selectedSummary?.deletions || 0);
|
|
528
|
+
const hasSelection = Boolean(selectedEntry);
|
|
529
|
+
|
|
530
|
+
if (this.fileIndicator) {
|
|
531
|
+
this.fileIndicator.textContent = selectedEntry?.type === 'commit'
|
|
532
|
+
? (selectedEntry.shortHash || 'Commit')
|
|
533
|
+
: selectedEntry?.type === 'local'
|
|
534
|
+
? 'Local changes'
|
|
535
|
+
: 'History';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (this.stats) {
|
|
539
|
+
this.stats.innerHTML = `
|
|
540
|
+
<span class="ui-stat-token ui-stat-token--add diff-stats-add">+${additions}</span>
|
|
541
|
+
<span class="ui-stat-token ui-stat-token--del diff-stats-del">-${deletions}</span>
|
|
542
|
+
`;
|
|
543
|
+
this.stats.classList.toggle('hidden', !hasSelection);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
this.backToHistoryButton?.classList.add('hidden');
|
|
547
|
+
this.gitActionsGroup?.classList.add('hidden');
|
|
548
|
+
this.layoutToggle?.classList.add('hidden');
|
|
549
|
+
this.prevButton?.classList.add('hidden');
|
|
550
|
+
this.nextButton?.classList.add('hidden');
|
|
551
|
+
this.actionsDivider?.classList.add('hidden');
|
|
552
|
+
this.editorActionsGroup?.classList.remove('hidden');
|
|
553
|
+
this.modeButtons.forEach((button) => {
|
|
554
|
+
button.classList.toggle('hidden', !hasSelection);
|
|
555
|
+
button.classList.toggle('active', button.getAttribute('data-diff-mode') === this.diffMode);
|
|
556
|
+
});
|
|
557
|
+
this.openEditorButton?.toggleAttribute('disabled', !this.currentFilePath);
|
|
558
|
+
if (this.openEditorButton) {
|
|
559
|
+
this.openEditorButton.textContent = 'Open File';
|
|
560
|
+
}
|
|
561
|
+
this.primaryActionButton?.toggleAttribute('disabled', true);
|
|
562
|
+
this.commitButton?.toggleAttribute('disabled', true);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
renderLoading() {
|
|
566
|
+
this.page?.classList.remove('hidden');
|
|
567
|
+
if (this.content) {
|
|
568
|
+
this.content.classList.add('diff-content--history');
|
|
569
|
+
this.content.innerHTML = '<div class="git-panel-empty">Loading file history...</div>';
|
|
570
|
+
}
|
|
571
|
+
this.historyListElement = null;
|
|
572
|
+
this.syncToolbar();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
renderEmpty(message) {
|
|
576
|
+
this.page?.classList.remove('hidden');
|
|
577
|
+
if (this.content) {
|
|
578
|
+
this.content.classList.add('diff-content--history');
|
|
579
|
+
this.content.innerHTML = `<div class="git-panel-empty">${escapeHtml(message)}</div>`;
|
|
580
|
+
}
|
|
581
|
+
this.historyListElement = null;
|
|
582
|
+
this.syncToolbar();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
captureListScroll() {
|
|
586
|
+
if (this.historyListElement) {
|
|
587
|
+
this.historyListScrollTop = Number(this.historyListElement.scrollTop || 0);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
restoreListScroll() {
|
|
592
|
+
if (!this.content?.querySelector) {
|
|
593
|
+
this.historyListElement = null;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const listElement = this.content.querySelector('[data-file-history-list]');
|
|
598
|
+
this.historyListElement = listElement ?? null;
|
|
599
|
+
if (!listElement) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
listElement.scrollTop = this.historyListScrollTop;
|
|
604
|
+
listElement.addEventListener('scroll', () => {
|
|
605
|
+
this.historyListScrollTop = Number(listElement.scrollTop || 0);
|
|
606
|
+
}, { passive: true });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
renderScopeLabels(entry = {}) {
|
|
610
|
+
const labels = [];
|
|
611
|
+
if (entry.hasStagedChanges) {
|
|
612
|
+
labels.push('staged');
|
|
613
|
+
}
|
|
614
|
+
if (entry.hasWorkingTreeChanges) {
|
|
615
|
+
labels.push('working tree');
|
|
616
|
+
}
|
|
617
|
+
if (entry.hasUntrackedChanges) {
|
|
618
|
+
labels.push('untracked');
|
|
619
|
+
}
|
|
620
|
+
return labels.length > 0 ? labels.join(' · ') : 'working tree';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
renderHistoryItem(entry = {}) {
|
|
624
|
+
const isActive = entry.key === this.selectedEntryKey;
|
|
625
|
+
const additions = Number(entry.additions ?? entry.summary?.additions ?? 0);
|
|
626
|
+
const deletions = Number(entry.deletions ?? entry.summary?.deletions ?? 0);
|
|
627
|
+
const kicker = entry.type === 'local' ? 'Live workspace' : renderCommitSubtitle(entry);
|
|
628
|
+
const title = entry.type === 'local'
|
|
629
|
+
? 'Local changes'
|
|
630
|
+
: (entry.subject || entry.shortHash || 'Commit');
|
|
631
|
+
const badge = entry.type === 'local'
|
|
632
|
+
? '<span class="ui-pill-badge ui-pill-badge--accent file-history-item-badge">Live</span>'
|
|
633
|
+
: `<span class="ui-pill-badge ui-pill-badge--code file-history-item-badge">${escapeHtml(entry.shortHash || '')}</span>`;
|
|
634
|
+
|
|
635
|
+
return `
|
|
636
|
+
<button
|
|
637
|
+
class="ui-record-surface file-history-item${isActive ? ' active' : ''}${entry.type === 'local' ? ' file-history-item-local' : ''}"
|
|
638
|
+
type="button"
|
|
639
|
+
data-file-history-select="${escapeHtml(entry.key)}"
|
|
640
|
+
aria-current="${isActive ? 'true' : 'false'}"
|
|
641
|
+
>
|
|
642
|
+
<span class="file-history-item-top">
|
|
643
|
+
<span class="file-history-item-copy">
|
|
644
|
+
<span class="file-history-item-title">${escapeHtml(title)}</span>
|
|
645
|
+
<span class="file-history-item-meta">${entry.type === 'local' ? escapeHtml(this.renderScopeLabels(entry)) : kicker}</span>
|
|
646
|
+
</span>
|
|
647
|
+
${badge}
|
|
648
|
+
</span>
|
|
649
|
+
<span class="file-history-item-stats">
|
|
650
|
+
<span class="git-change-add">+${additions}</span>
|
|
651
|
+
<span class="git-change-del">-${deletions}</span>
|
|
652
|
+
</span>
|
|
653
|
+
</button>
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
renderSelectedHeader(selection) {
|
|
658
|
+
const entry = selection?.entry;
|
|
659
|
+
if (!entry) {
|
|
660
|
+
return `
|
|
661
|
+
<div class="file-history-detail-header">
|
|
662
|
+
<div class="file-history-detail-copy">
|
|
663
|
+
<span class="file-history-detail-kicker">History</span>
|
|
664
|
+
<h2 class="file-history-detail-title">No selection</h2>
|
|
665
|
+
<p class="file-history-detail-meta">Choose a history item to inspect the file diff.</p>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const kicker = entry.type === 'local'
|
|
672
|
+
? 'Live workspace'
|
|
673
|
+
: `Commit ${escapeHtml(entry.shortHash || '')}`;
|
|
674
|
+
const title = entry.type === 'local'
|
|
675
|
+
? 'Local changes'
|
|
676
|
+
: escapeHtml(entry.subject || entry.shortHash || 'Commit');
|
|
677
|
+
const meta = entry.type === 'local'
|
|
678
|
+
? escapeHtml(this.renderScopeLabels(entry))
|
|
679
|
+
: renderCommitSubtitle(entry);
|
|
680
|
+
|
|
681
|
+
return `
|
|
682
|
+
<div class="file-history-detail-header">
|
|
683
|
+
<div class="file-history-detail-copy">
|
|
684
|
+
<span class="file-history-detail-kicker">${kicker}</span>
|
|
685
|
+
<h2 class="file-history-detail-title">${title}</h2>
|
|
686
|
+
<p class="file-history-detail-meta">${meta}</p>
|
|
687
|
+
</div>
|
|
688
|
+
<div class="file-history-detail-actions">
|
|
689
|
+
<button
|
|
690
|
+
class="ui-button ui-action-button ui-action-button--surface btn btn-secondary"
|
|
691
|
+
type="button"
|
|
692
|
+
data-file-history-open-selected-diff
|
|
693
|
+
>
|
|
694
|
+
Open Standalone Diff
|
|
695
|
+
</button>
|
|
696
|
+
${entry.type === 'commit' ? `
|
|
697
|
+
<button
|
|
698
|
+
class="ui-button ui-action-button btn btn-primary"
|
|
699
|
+
type="button"
|
|
700
|
+
data-file-history-open-selected-preview
|
|
701
|
+
>
|
|
702
|
+
Preview File
|
|
703
|
+
</button>
|
|
704
|
+
` : ''}
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
renderSelectedBody(selection) {
|
|
711
|
+
const entry = selection?.entry;
|
|
712
|
+
if (!entry) {
|
|
713
|
+
return '<div class="diff-empty-state">Select a history entry to inspect this file.</div>';
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (selection.loading) {
|
|
717
|
+
return `
|
|
718
|
+
<section class="diff-file-block file-history-diff-block">
|
|
719
|
+
${this.renderDiffFileHeader({ path: entry.pathAtCommit || this.currentFilePath || '' })}
|
|
720
|
+
<div class="diff-empty-state">Loading diff...</div>
|
|
721
|
+
</section>
|
|
722
|
+
`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (selection.error) {
|
|
726
|
+
return `
|
|
727
|
+
<section class="diff-file-block file-history-diff-block">
|
|
728
|
+
${this.renderDiffFileHeader({ path: entry.pathAtCommit || this.currentFilePath || '' })}
|
|
729
|
+
<div class="diff-empty-state">${escapeHtml(selection.error)}</div>
|
|
730
|
+
</section>
|
|
731
|
+
`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (!selection.detail?.detail) {
|
|
735
|
+
return '<div class="diff-empty-state">No diff available for this history item.</div>';
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return `
|
|
739
|
+
<section class="diff-file-block file-history-diff-block">
|
|
740
|
+
${this.renderDiffMarkup(selection.detail.detail)}
|
|
741
|
+
</section>
|
|
742
|
+
`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
render() {
|
|
746
|
+
this.page?.classList.remove('hidden');
|
|
747
|
+
this.captureListScroll();
|
|
748
|
+
this.syncToolbar();
|
|
749
|
+
|
|
750
|
+
if (!this.currentFilePath) {
|
|
751
|
+
this.renderEmpty('No file selected.');
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (this.history.loading && !this.history.loaded) {
|
|
756
|
+
this.renderLoading();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const hasRows = Boolean(this.localChanges) || (this.history.commits?.length ?? 0) > 0;
|
|
761
|
+
if (!hasRows) {
|
|
762
|
+
this.renderEmpty(this.history.error || 'No history found for this file.');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!this.content) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
this.content.classList.add('diff-content--history');
|
|
771
|
+
const selection = this.getSelectedDetailState();
|
|
772
|
+
this.content.innerHTML = `
|
|
773
|
+
<section class="file-history-shell">
|
|
774
|
+
<aside class="file-history-sidebar" aria-label="File history entries">
|
|
775
|
+
<header class="file-history-sidebar-header">
|
|
776
|
+
<span class="file-history-kicker">File history</span>
|
|
777
|
+
<div class="file-history-title">${renderHistoryTitle(this.currentFilePath)}</div>
|
|
778
|
+
<p class="file-history-sidebar-copy">Select an entry to inspect the exact changes for this file.</p>
|
|
779
|
+
</header>
|
|
780
|
+
<div class="file-history-list" data-file-history-list>
|
|
781
|
+
${this.getEntries().map((entry) => this.renderHistoryItem(entry)).join('')}
|
|
782
|
+
</div>
|
|
783
|
+
${this.history.hasMore ? `
|
|
784
|
+
<div class="git-history-footer file-history-footer">
|
|
785
|
+
<button
|
|
786
|
+
class="ui-button ui-button--compact ui-action-button ui-action-button--surface btn btn-secondary git-history-load-more"
|
|
787
|
+
type="button"
|
|
788
|
+
data-file-history-load-more
|
|
789
|
+
${this.history.loadingMore ? 'disabled' : ''}
|
|
790
|
+
>
|
|
791
|
+
${this.history.loadingMore ? 'Loading...' : 'Load More'}
|
|
792
|
+
</button>
|
|
793
|
+
</div>
|
|
794
|
+
` : ''}
|
|
795
|
+
</aside>
|
|
796
|
+
<section class="file-history-detail">
|
|
797
|
+
${this.renderSelectedHeader(selection)}
|
|
798
|
+
<div class="file-history-detail-body">
|
|
799
|
+
${this.renderSelectedBody(selection)}
|
|
800
|
+
</div>
|
|
801
|
+
</section>
|
|
802
|
+
</section>
|
|
803
|
+
`;
|
|
804
|
+
this.restoreListScroll();
|
|
805
|
+
}
|
|
806
|
+
}
|