gitmaps 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/app/api/auth/favorites/route.ts +56 -0
- package/app/api/auth/github/callback/route.ts +103 -0
- package/app/api/auth/github/route.ts +32 -0
- package/app/api/auth/me/route.ts +52 -0
- package/app/api/auth/positions/route.ts +50 -0
- package/app/api/chat/route.ts +101 -0
- package/app/api/connections/route.ts +72 -0
- package/app/api/github/repos/route.ts +111 -0
- package/app/api/positions/route.ts +80 -0
- package/app/api/repo/branch-diff/route.ts +201 -0
- package/app/api/repo/branches/route.ts +53 -0
- package/app/api/repo/browse/route.ts +55 -0
- package/app/api/repo/clone/route.ts +78 -0
- package/app/api/repo/clone-stream/route.ts +131 -0
- package/app/api/repo/file-content/route.ts +28 -0
- package/app/api/repo/file-delete/route.ts +62 -0
- package/app/api/repo/file-history/route.ts +45 -0
- package/app/api/repo/file-rename/route.ts +83 -0
- package/app/api/repo/file-save/route.ts +45 -0
- package/app/api/repo/files/route.ts +169 -0
- package/app/api/repo/git-blame/route.ts +86 -0
- package/app/api/repo/git-commit/route.ts +40 -0
- package/app/api/repo/git-heatmap/route.ts +55 -0
- package/app/api/repo/imports/route.ts +154 -0
- package/app/api/repo/load/route.ts +56 -0
- package/app/api/repo/mode/route.ts +14 -0
- package/app/api/repo/search/route.ts +127 -0
- package/app/api/repo/tree/route.ts +104 -0
- package/app/api/repo/upload/route.ts +53 -0
- package/app/api/repo/validate-path.ts +53 -0
- package/app/canvas_users.db +0 -0
- package/app/canvas_users.db-shm +0 -0
- package/app/canvas_users.db-wal +0 -0
- package/app/globals.css +7899 -0
- package/app/layout.tsx +493 -0
- package/app/lib/auth.ts +193 -0
- package/app/lib/auto-save.ts +137 -0
- package/app/lib/branch-compare.ts +443 -0
- package/app/lib/breadcrumbs.ts +170 -0
- package/app/lib/canvas-export.ts +358 -0
- package/app/lib/canvas-text.ts +912 -0
- package/app/lib/canvas.ts +564 -0
- package/app/lib/card-arrangement.ts +188 -0
- package/app/lib/card-context-menu.tsx +453 -0
- package/app/lib/card-diff-markers.ts +270 -0
- package/app/lib/card-expand.ts +189 -0
- package/app/lib/card-groups.ts +246 -0
- package/app/lib/cards.tsx +914 -0
- package/app/lib/chat.tsx +308 -0
- package/app/lib/code-editor.ts +508 -0
- package/app/lib/command-palette.ts +262 -0
- package/app/lib/connections.tsx +1037 -0
- package/app/lib/context.ts +94 -0
- package/app/lib/cursor-sharing.ts +281 -0
- package/app/lib/dependency-graph.ts +438 -0
- package/app/lib/events.tsx +1747 -0
- package/app/lib/file-card-plugin.ts +134 -0
- package/app/lib/file-modal.tsx +849 -0
- package/app/lib/file-preview.ts +400 -0
- package/app/lib/file-tabs.ts +318 -0
- package/app/lib/galaxydraw-bridge.ts +477 -0
- package/app/lib/galaxydraw.test.ts +229 -0
- package/app/lib/global-search.ts +264 -0
- package/app/lib/goto-definition.ts +224 -0
- package/app/lib/heatmap.ts +178 -0
- package/app/lib/hidden-files.tsx +222 -0
- package/app/lib/layers.ts +0 -0
- package/app/lib/layers.tsx +365 -0
- package/app/lib/loading.tsx +45 -0
- package/app/lib/multi-repo.ts +286 -0
- package/app/lib/new-file-dialog.tsx +230 -0
- package/app/lib/onboarding.tsx +213 -0
- package/app/lib/perf-overlay.ts +360 -0
- package/app/lib/positions.ts +176 -0
- package/app/lib/pr-review.ts +374 -0
- package/app/lib/production-mode.ts +47 -0
- package/app/lib/repo.tsx +977 -0
- package/app/lib/settings-modal.tsx +374 -0
- package/app/lib/settings.ts +97 -0
- package/app/lib/shortcuts-panel.ts +141 -0
- package/app/lib/status-bar.ts +128 -0
- package/app/lib/symbol-outline.ts +212 -0
- package/app/lib/syntax.ts +177 -0
- package/app/lib/tab-diff.ts +238 -0
- package/app/lib/user.tsx +133 -0
- package/app/lib/utils.ts +78 -0
- package/app/lib/viewport-culling.ts +728 -0
- package/app/page.client.tsx +215 -0
- package/app/page.tsx +291 -0
- package/app/state/machine.js +196 -0
- package/app/styles/main.css +2168 -0
- package/banner.png +0 -0
- package/cli.ts +44 -0
- package/package.json +75 -0
- package/packages/galaxydraw/README.md +296 -0
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +100 -0
- package/packages/galaxydraw/demo/client.ts +154 -0
- package/packages/galaxydraw/demo/dist/client.js +8 -0
- package/packages/galaxydraw/demo/index.html +256 -0
- package/packages/galaxydraw/demo/server.ts +96 -0
- package/packages/galaxydraw/dist/index.js +984 -0
- package/packages/galaxydraw/dist/index.js.map +16 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +49 -0
- package/packages/galaxydraw/perf.test.ts +284 -0
- package/packages/galaxydraw/src/core/cards.ts +435 -0
- package/packages/galaxydraw/src/core/engine.ts +339 -0
- package/packages/galaxydraw/src/core/events.ts +81 -0
- package/packages/galaxydraw/src/core/layout.ts +136 -0
- package/packages/galaxydraw/src/core/minimap.ts +216 -0
- package/packages/galaxydraw/src/core/state.ts +177 -0
- package/packages/galaxydraw/src/core/viewport.ts +106 -0
- package/packages/galaxydraw/src/galaxydraw.css +166 -0
- package/packages/galaxydraw/src/index.ts +40 -0
- package/packages/galaxydraw/tsconfig.json +30 -0
- package/server.ts +62 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* File expand modal — fullscreen file preview with diff/full/chat views.
|
|
4
|
+
* Extracted from cards.tsx for modularity.
|
|
5
|
+
*/
|
|
6
|
+
import { measure } from 'measure-fn';
|
|
7
|
+
import type { CanvasContext } from './context';
|
|
8
|
+
import { escapeHtml } from './utils';
|
|
9
|
+
import { highlightSyntax, buildModalDiffHTML } from './syntax';
|
|
10
|
+
import { openFileChatInModal } from './chat';
|
|
11
|
+
import { addClickableImports } from './goto-definition';
|
|
12
|
+
import { addTab, getOpenTabs, getActiveTab, initTabBar, clearTabs, nextTab, prevTab, onTabChange, onTabCloseRequest, setActiveTab, getSavedTabPaths, type FileTab } from './file-tabs';
|
|
13
|
+
import { renderBreadcrumbs } from './breadcrumbs';
|
|
14
|
+
import { renderSymbolOutline } from './symbol-outline';
|
|
15
|
+
import { loadDraft, clearDraft, startAutoSave, stopAutoSave } from './auto-save';
|
|
16
|
+
import { isEditingAllowed, getProductionEditorNotice } from './production-mode';
|
|
17
|
+
|
|
18
|
+
// ─── File expand modal ──────────────────────────────────
|
|
19
|
+
export function openFileModal(ctx: CanvasContext, file: any, initialView?: string, initialLine?: number) {
|
|
20
|
+
const modal = document.getElementById('filePreviewModal');
|
|
21
|
+
const pathEl = document.getElementById('previewFilePath');
|
|
22
|
+
const contentEl = document.getElementById('previewContent');
|
|
23
|
+
const lineCountEl = document.getElementById('previewLineCount');
|
|
24
|
+
const statusEl = document.getElementById('previewFileStatus');
|
|
25
|
+
const tabsEl = document.getElementById('modalViewTabs');
|
|
26
|
+
if (!modal || !pathEl || !contentEl) return;
|
|
27
|
+
|
|
28
|
+
renderBreadcrumbs(ctx, pathEl, file.path);
|
|
29
|
+
contentEl.innerHTML = '<span style="color: var(--text-muted); font-style: italic;">Loading...</span>';
|
|
30
|
+
modal.classList.add('active');
|
|
31
|
+
|
|
32
|
+
// Initialize tab bar and add file as tab
|
|
33
|
+
initTabBar();
|
|
34
|
+
const tabIndex = addTab(file);
|
|
35
|
+
|
|
36
|
+
// Restore previously saved tabs (from last session)
|
|
37
|
+
const saved = getSavedTabPaths();
|
|
38
|
+
if (saved.paths.length > 0) {
|
|
39
|
+
const state = ctx.snap().context;
|
|
40
|
+
for (const savedPath of saved.paths) {
|
|
41
|
+
if (savedPath === file.path) continue; // Already opened
|
|
42
|
+
// Create a minimal file stub — content will be loaded on tab switch
|
|
43
|
+
const stubFile = {
|
|
44
|
+
path: savedPath,
|
|
45
|
+
name: savedPath.split('/').pop() || savedPath,
|
|
46
|
+
content: '',
|
|
47
|
+
lines: 0,
|
|
48
|
+
};
|
|
49
|
+
addTab(stubFile);
|
|
50
|
+
}
|
|
51
|
+
// Re-activate the current file's tab
|
|
52
|
+
setActiveTab(tabIndex);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (statusEl) {
|
|
56
|
+
const statusColors = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444' };
|
|
57
|
+
const statusLabels = { added: 'ADDED', modified: 'MODIFIED', deleted: 'DELETED' };
|
|
58
|
+
if (file.status && statusColors[file.status]) {
|
|
59
|
+
statusEl.textContent = statusLabels[file.status];
|
|
60
|
+
statusEl.style.display = '';
|
|
61
|
+
statusEl.style.background = statusColors[file.status] + '20';
|
|
62
|
+
statusEl.style.color = statusColors[file.status];
|
|
63
|
+
} else {
|
|
64
|
+
statusEl.style.display = 'none';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (lineCountEl) {
|
|
69
|
+
lineCountEl.textContent = file.lines ? `${file.lines.toLocaleString()} lines` : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hasDiff = !!(file.status && (file.hunks?.length > 0 || file.content));
|
|
73
|
+
const rendered = { full: '', diff: '', full_raw: '' };
|
|
74
|
+
// Default to edit view — editor is the primary experience
|
|
75
|
+
let currentView = initialView || 'edit';
|
|
76
|
+
let onNavKey: ((e: KeyboardEvent) => void) | null = null;
|
|
77
|
+
let originalContent = file.content || '';
|
|
78
|
+
|
|
79
|
+
function hasUnsavedChanges(): boolean {
|
|
80
|
+
if (currentView !== 'edit') return false;
|
|
81
|
+
const editContainer = document.getElementById('modalEditContainer');
|
|
82
|
+
const editor = (editContainer as any)?._cmEditor;
|
|
83
|
+
if (editor) return editor.getContent() !== originalContent;
|
|
84
|
+
const textarea = document.getElementById('modalEditTextarea') as HTMLTextAreaElement;
|
|
85
|
+
return textarea ? textarea.value !== originalContent : false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function closeModal(force = false) {
|
|
89
|
+
if (!modal) return;
|
|
90
|
+
|
|
91
|
+
// Warn about unsaved changes
|
|
92
|
+
if (!force && hasUnsavedChanges()) {
|
|
93
|
+
if (!confirm('You have unsaved changes. Discard them?')) return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
modal.classList.remove('active');
|
|
97
|
+
document.removeEventListener('keydown', onEsc);
|
|
98
|
+
if (onNavKey) document.removeEventListener('keydown', onNavKey);
|
|
99
|
+
|
|
100
|
+
// Stop auto-save timer
|
|
101
|
+
stopAutoSave();
|
|
102
|
+
|
|
103
|
+
// Clear all tabs
|
|
104
|
+
clearTabs();
|
|
105
|
+
|
|
106
|
+
// Reset edit state
|
|
107
|
+
const editContainer = document.getElementById('modalEditContainer');
|
|
108
|
+
const saveStatus = document.getElementById('modalSaveStatus');
|
|
109
|
+
const commitSection = document.getElementById('editCommitSection');
|
|
110
|
+
|
|
111
|
+
// Destroy CodeMirror editor
|
|
112
|
+
const editor = (editContainer as any)?._cmEditor;
|
|
113
|
+
if (editor) { editor.destroy(); (editContainer as any)._cmEditor = null; }
|
|
114
|
+
const cmMount = document.getElementById('cmEditorMount');
|
|
115
|
+
if (cmMount) cmMount.innerHTML = '';
|
|
116
|
+
|
|
117
|
+
if (editContainer) editContainer.style.display = 'none';
|
|
118
|
+
if (saveStatus) { saveStatus.style.display = 'none'; saveStatus.className = 'modal-save-status'; }
|
|
119
|
+
if (commitSection) commitSection.style.display = 'none';
|
|
120
|
+
|
|
121
|
+
if (tabsEl) {
|
|
122
|
+
tabsEl.querySelectorAll('.modal-tab').forEach(t => {
|
|
123
|
+
t.replaceWith(t.cloneNode(true));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onEsc(e: KeyboardEvent) {
|
|
129
|
+
if (e.key === 'Escape') closeModal();
|
|
130
|
+
// Ctrl+Tab / Ctrl+Shift+Tab to cycle tabs
|
|
131
|
+
if (e.key === 'Tab' && (e.ctrlKey || e.metaKey)) {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
const newIdx = e.shiftKey ? prevTab() : nextTab();
|
|
134
|
+
if (newIdx >= 0) {
|
|
135
|
+
const tab = getOpenTabs()[newIdx];
|
|
136
|
+
if (tab) switchToTab(ctx, tab);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Ctrl+Shift+O to toggle outline
|
|
140
|
+
if (e.key === 'o' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
toggleOutline();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toggleOutline() {
|
|
147
|
+
const panel = document.getElementById('modalOutlinePanel');
|
|
148
|
+
if (!panel) return;
|
|
149
|
+
const isVisible = panel.style.display !== 'none';
|
|
150
|
+
panel.style.display = isVisible ? 'none' : '';
|
|
151
|
+
if (!isVisible) {
|
|
152
|
+
// Render outline with current content
|
|
153
|
+
updateOutline();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function updateOutline() {
|
|
158
|
+
const panel = document.getElementById('modalOutlinePanel');
|
|
159
|
+
if (!panel || panel.style.display === 'none') return;
|
|
160
|
+
const raw = rendered.full_raw || file.content || '';
|
|
161
|
+
if (!raw) return;
|
|
162
|
+
renderSymbolOutline(panel, raw, file.name || file.path, (line: number) => {
|
|
163
|
+
scrollToLine(line);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scrollToLine(line: number) {
|
|
168
|
+
const modalPre = document.getElementById('modalBodyPre');
|
|
169
|
+
if (!modalPre) return;
|
|
170
|
+
// Find the line element or estimate scroll position
|
|
171
|
+
const codeEl = document.getElementById('previewContent');
|
|
172
|
+
if (!codeEl) return;
|
|
173
|
+
const lineHeight = 24; // approximate
|
|
174
|
+
const targetScroll = (line - 1) * lineHeight;
|
|
175
|
+
modalPre.scrollTo({ top: targetScroll, behavior: 'smooth' });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Outline toggle button
|
|
179
|
+
const outlineToggle = document.getElementById('outlineToggle');
|
|
180
|
+
if (outlineToggle) {
|
|
181
|
+
const newToggle = outlineToggle.cloneNode(true) as HTMLElement;
|
|
182
|
+
outlineToggle.replaceWith(newToggle);
|
|
183
|
+
newToggle.addEventListener('click', toggleOutline);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
document.addEventListener('keydown', onEsc);
|
|
187
|
+
document.getElementById('closePreview')?.addEventListener('click', closeModal, { once: true });
|
|
188
|
+
modal.querySelector('.modal-backdrop')?.addEventListener('click', closeModal, { once: true });
|
|
189
|
+
|
|
190
|
+
// ─── Tab switching helper ─────────────────────────
|
|
191
|
+
function switchToTab(ctx: CanvasContext, tab: FileTab) {
|
|
192
|
+
// Update modal header
|
|
193
|
+
if (pathEl) renderBreadcrumbs(ctx, pathEl, tab.path);
|
|
194
|
+
if (lineCountEl) {
|
|
195
|
+
const lines = tab.rendered.full_raw?.split('\n').length || tab.file.lines || 0;
|
|
196
|
+
lineCountEl.textContent = lines ? `${lines.toLocaleString()} lines` : '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Update status badge
|
|
200
|
+
if (statusEl) {
|
|
201
|
+
const statusColors = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444' };
|
|
202
|
+
const statusLabels = { added: 'ADDED', modified: 'MODIFIED', deleted: 'DELETED' };
|
|
203
|
+
if (tab.file.status && statusColors[tab.file.status]) {
|
|
204
|
+
statusEl.textContent = statusLabels[tab.file.status];
|
|
205
|
+
statusEl.style.display = '';
|
|
206
|
+
statusEl.style.background = statusColors[tab.file.status] + '20';
|
|
207
|
+
statusEl.style.color = statusColors[tab.file.status];
|
|
208
|
+
} else {
|
|
209
|
+
statusEl.style.display = 'none';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Destroy existing CodeMirror
|
|
214
|
+
const editContainer = document.getElementById('modalEditContainer');
|
|
215
|
+
const editor = (editContainer as any)?._cmEditor;
|
|
216
|
+
if (editor) { editor.destroy(); (editContainer as any)._cmEditor = null; }
|
|
217
|
+
const cmMount = document.getElementById('cmEditorMount');
|
|
218
|
+
if (cmMount) cmMount.innerHTML = '';
|
|
219
|
+
|
|
220
|
+
// Update content
|
|
221
|
+
file = tab.file;
|
|
222
|
+
rendered.full = tab.rendered.full;
|
|
223
|
+
rendered.diff = tab.rendered.diff;
|
|
224
|
+
rendered.full_raw = tab.rendered.full_raw;
|
|
225
|
+
originalContent = tab.originalContent;
|
|
226
|
+
currentView = tab.currentView || 'full';
|
|
227
|
+
|
|
228
|
+
// Switch view
|
|
229
|
+
const modalPre = document.getElementById('modalBodyPre');
|
|
230
|
+
const chatContainer = document.getElementById('modalChatContainer');
|
|
231
|
+
const blameContainer = document.getElementById('modalBlameContainer');
|
|
232
|
+
if (editContainer) editContainer.style.display = 'none';
|
|
233
|
+
if (chatContainer) chatContainer.style.display = 'none';
|
|
234
|
+
if (blameContainer) blameContainer.style.display = 'none';
|
|
235
|
+
|
|
236
|
+
if (currentView === 'full' && rendered.full) {
|
|
237
|
+
if (modalPre) modalPre.style.display = '';
|
|
238
|
+
contentEl.innerHTML = rendered.full;
|
|
239
|
+
addClickableImports(ctx, contentEl, file.path, rendered.full_raw);
|
|
240
|
+
} else if (currentView === 'diff' && rendered.diff) {
|
|
241
|
+
if (modalPre) modalPre.style.display = '';
|
|
242
|
+
contentEl.innerHTML = rendered.diff;
|
|
243
|
+
} else {
|
|
244
|
+
if (modalPre) modalPre.style.display = '';
|
|
245
|
+
contentEl.innerHTML = rendered.full || '<span style="color: var(--text-muted);">Loading...</span>';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Restore scroll
|
|
249
|
+
if (modalPre) {
|
|
250
|
+
requestAnimationFrame(() => {
|
|
251
|
+
modalPre.scrollTop = tab.scrollTop || 0;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Update view tabs
|
|
256
|
+
if (tabsEl) {
|
|
257
|
+
tabsEl.querySelectorAll('.modal-tab').forEach(t => {
|
|
258
|
+
t.classList.toggle('active', t.dataset.view === currentView);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// If content not loaded yet, fetch it
|
|
263
|
+
if (!rendered.full && !rendered.diff) {
|
|
264
|
+
loadTabContent(ctx, tab);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Wire up tab change callback
|
|
269
|
+
onTabChange((tab, index) => switchToTab(ctx, tab));
|
|
270
|
+
onTabCloseRequest((index) => {
|
|
271
|
+
// Return false to prevent close if unsaved changes
|
|
272
|
+
return true; // For now, allow close
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Diff navigation setup
|
|
276
|
+
const changedFiles = (ctx.allFilesData || []).filter(f => f.status);
|
|
277
|
+
const navEl = document.getElementById('modalDiffNav');
|
|
278
|
+
|
|
279
|
+
if (navEl && changedFiles.length > 1) {
|
|
280
|
+
navEl.style.display = 'flex';
|
|
281
|
+
const currentIndex = changedFiles.findIndex(f => f.path === file.path);
|
|
282
|
+
|
|
283
|
+
const prevBtn = document.getElementById('diffNavPrev');
|
|
284
|
+
const nextBtn = document.getElementById('diffNavNext');
|
|
285
|
+
|
|
286
|
+
if (prevBtn && nextBtn) {
|
|
287
|
+
const newPrev = prevBtn.cloneNode(true) as HTMLElement;
|
|
288
|
+
const newNext = nextBtn.cloneNode(true) as HTMLElement;
|
|
289
|
+
prevBtn.replaceWith(newPrev);
|
|
290
|
+
nextBtn.replaceWith(newNext);
|
|
291
|
+
|
|
292
|
+
const handlePrev = () => {
|
|
293
|
+
const targetIdx = currentIndex > 0 ? currentIndex - 1 : changedFiles.length - 1;
|
|
294
|
+
closeModal();
|
|
295
|
+
setTimeout(() => openFileModal(ctx, changedFiles[targetIdx]), 50);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const handleNext = () => {
|
|
299
|
+
const targetIdx = currentIndex < changedFiles.length - 1 ? currentIndex + 1 : 0;
|
|
300
|
+
closeModal();
|
|
301
|
+
setTimeout(() => openFileModal(ctx, changedFiles[targetIdx]), 50);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
newPrev.addEventListener('click', handlePrev);
|
|
305
|
+
newNext.addEventListener('click', handleNext);
|
|
306
|
+
|
|
307
|
+
onNavKey = (e: KeyboardEvent) => {
|
|
308
|
+
if (modal!.classList.contains('active') && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
|
|
309
|
+
if (e.key === 'j') handleNext();
|
|
310
|
+
if (e.key === 'k') handlePrev();
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
document.addEventListener('keydown', onNavKey);
|
|
314
|
+
}
|
|
315
|
+
} else if (navEl) {
|
|
316
|
+
navEl.style.display = 'none';
|
|
317
|
+
const prevBtn = document.getElementById('diffNavPrev');
|
|
318
|
+
const nextBtn = document.getElementById('diffNavNext');
|
|
319
|
+
if (prevBtn) prevBtn.replaceWith(prevBtn.cloneNode(true));
|
|
320
|
+
if (nextBtn) nextBtn.replaceWith(nextBtn.cloneNode(true));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (tabsEl) {
|
|
324
|
+
const tabs = tabsEl.querySelectorAll('.modal-tab');
|
|
325
|
+
tabs.forEach(tab => {
|
|
326
|
+
if (tab.dataset.view === 'diff') {
|
|
327
|
+
tab.style.display = hasDiff ? '' : 'none';
|
|
328
|
+
}
|
|
329
|
+
tab.classList.toggle('active', tab.dataset.view === currentView);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
tabs.forEach(tab => {
|
|
333
|
+
tab.addEventListener('click', () => {
|
|
334
|
+
const view = tab.dataset.view;
|
|
335
|
+
if (view === currentView) return;
|
|
336
|
+
|
|
337
|
+
// Warn when leaving edit mode with unsaved changes
|
|
338
|
+
if (currentView === 'edit' && hasUnsavedChanges()) {
|
|
339
|
+
if (!confirm('You have unsaved changes. Discard them?')) return;
|
|
340
|
+
}
|
|
341
|
+
currentView = view;
|
|
342
|
+
tabs.forEach(t => t.classList.toggle('active', t.dataset.view === view));
|
|
343
|
+
|
|
344
|
+
const modalPre = document.getElementById('modalBodyPre');
|
|
345
|
+
const editContainer = document.getElementById('modalEditContainer');
|
|
346
|
+
const saveStatus = document.getElementById('modalSaveStatus');
|
|
347
|
+
|
|
348
|
+
if (view === 'edit') {
|
|
349
|
+
activateEditView();
|
|
350
|
+
} else if (view === 'diff') {
|
|
351
|
+
// Show diff, hide edit
|
|
352
|
+
if (modalPre) modalPre.style.display = '';
|
|
353
|
+
if (editContainer) editContainer.style.display = 'none';
|
|
354
|
+
if (saveStatus) saveStatus.style.display = 'none';
|
|
355
|
+
if (rendered.diff) {
|
|
356
|
+
contentEl.innerHTML = rendered.diff;
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
// Fallback: show code, hide edit
|
|
360
|
+
if (modalPre) modalPre.style.display = '';
|
|
361
|
+
if (editContainer) editContainer.style.display = 'none';
|
|
362
|
+
if (saveStatus) saveStatus.style.display = 'none';
|
|
363
|
+
if (rendered.full) {
|
|
364
|
+
contentEl.innerHTML = rendered.full;
|
|
365
|
+
addClickableImports(ctx, contentEl, file.path, rendered.full_raw);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Edit view activation (shared by initial open + tab click) ────
|
|
373
|
+
function activateEditView() {
|
|
374
|
+
const modalPre = document.getElementById('modalBodyPre');
|
|
375
|
+
const editContainer = document.getElementById('modalEditContainer');
|
|
376
|
+
const saveStatus = document.getElementById('modalSaveStatus');
|
|
377
|
+
|
|
378
|
+
// Production mode: show read-only notice instead of editor
|
|
379
|
+
if (!isEditingAllowed()) {
|
|
380
|
+
if (modalPre) modalPre.style.display = 'none';
|
|
381
|
+
if (editContainer) {
|
|
382
|
+
editContainer.style.display = 'flex';
|
|
383
|
+
editContainer.innerHTML = getProductionEditorNotice();
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (modalPre) modalPre.style.display = 'none';
|
|
389
|
+
if (editContainer) editContainer.style.display = 'flex';
|
|
390
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = ''; saveStatus.className = 'modal-save-status'; }
|
|
391
|
+
|
|
392
|
+
const textarea = document.getElementById('modalEditTextarea') as HTMLTextAreaElement;
|
|
393
|
+
const lineInfo = document.getElementById('editLineInfo');
|
|
394
|
+
const saveBtn = document.getElementById('editSaveBtn');
|
|
395
|
+
|
|
396
|
+
// Load current content
|
|
397
|
+
let editContent = rendered.full_raw || file.content || '';
|
|
398
|
+
originalContent = editContent;
|
|
399
|
+
|
|
400
|
+
// Check for auto-saved draft
|
|
401
|
+
const repoPath = ctx.snap().context.repoPath;
|
|
402
|
+
let restoredDraft = false;
|
|
403
|
+
if (repoPath && file.path) {
|
|
404
|
+
const draft = loadDraft(repoPath, file.path);
|
|
405
|
+
if (draft && draft.content !== editContent && draft.originalContent === editContent) {
|
|
406
|
+
editContent = draft.content;
|
|
407
|
+
restoredDraft = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Hide textarea (CodeMirror replaces it)
|
|
412
|
+
if (textarea) textarea.style.display = 'none';
|
|
413
|
+
|
|
414
|
+
// Mount CodeMirror into the edit container
|
|
415
|
+
let editorMountEl = document.getElementById('cmEditorMount');
|
|
416
|
+
if (!editorMountEl) {
|
|
417
|
+
editorMountEl = document.createElement('div');
|
|
418
|
+
editorMountEl.id = 'cmEditorMount';
|
|
419
|
+
editorMountEl.className = 'cm-editor-mount';
|
|
420
|
+
// Insert before the toolbar
|
|
421
|
+
const toolbar = document.getElementById('modalEditToolbar');
|
|
422
|
+
if (toolbar && editContainer) {
|
|
423
|
+
editContainer.insertBefore(editorMountEl, toolbar);
|
|
424
|
+
} else if (editContainer) {
|
|
425
|
+
editContainer.appendChild(editorMountEl);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
editorMountEl.innerHTML = '';
|
|
429
|
+
|
|
430
|
+
// Create CodeMirror editor
|
|
431
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || '';
|
|
432
|
+
import('./code-editor').then(({ createCodeEditor }) => {
|
|
433
|
+
const editor = createCodeEditor(editorMountEl!, editContent, ext, {
|
|
434
|
+
onSave: () => saveFile(),
|
|
435
|
+
onChange: (content) => {
|
|
436
|
+
// Show modified indicator
|
|
437
|
+
if (saveStatus && content !== originalContent) {
|
|
438
|
+
saveStatus.style.display = '';
|
|
439
|
+
saveStatus.textContent = '● Modified';
|
|
440
|
+
saveStatus.className = 'modal-save-status modified';
|
|
441
|
+
} else if (saveStatus && content === originalContent) {
|
|
442
|
+
saveStatus.style.display = 'none';
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
onCursorMove: (line, col) => {
|
|
446
|
+
if (lineInfo) lineInfo.textContent = `Line ${line}, Col ${col}`;
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Store editor reference for content access
|
|
451
|
+
(editContainer as any)._cmEditor = editor;
|
|
452
|
+
editor.focus();
|
|
453
|
+
|
|
454
|
+
// Scroll to initial line if provided (preserves canvas view position)
|
|
455
|
+
if (initialLine && initialLine > 1) {
|
|
456
|
+
requestAnimationFrame(() => {
|
|
457
|
+
editor.scrollToLine?.(initialLine);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Show draft restored notification
|
|
462
|
+
if (restoredDraft && saveStatus) {
|
|
463
|
+
saveStatus.style.display = '';
|
|
464
|
+
saveStatus.innerHTML = '⟳ Draft restored <button id="discardDraft" style="margin-left:8px;background:none;border:1px solid rgba(239,68,68,0.4);color:#ef4444;border-radius:4px;padding:1px 8px;cursor:pointer;font-size:11px">Discard</button>';
|
|
465
|
+
saveStatus.className = 'modal-save-status modified';
|
|
466
|
+
const discardBtn = document.getElementById('discardDraft');
|
|
467
|
+
discardBtn?.addEventListener('click', () => {
|
|
468
|
+
if (repoPath && file.path) clearDraft(repoPath, file.path);
|
|
469
|
+
editor.setContent(originalContent);
|
|
470
|
+
saveStatus.style.display = 'none';
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Start auto-save for this editor session
|
|
475
|
+
if (repoPath && file.path) {
|
|
476
|
+
startAutoSave(repoPath, file.path, () => editor.getContent(), originalContent);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Save handler
|
|
481
|
+
const saveFile = async () => {
|
|
482
|
+
const editor = (editContainer as any)?._cmEditor;
|
|
483
|
+
const content = editor ? editor.getContent() : textarea?.value || '';
|
|
484
|
+
const state = ctx.snap().context;
|
|
485
|
+
const repoPath = state.repoPath;
|
|
486
|
+
if (!repoPath) {
|
|
487
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = 'No repo path'; saveStatus.className = 'modal-save-status error'; }
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = 'Saving...'; saveStatus.className = 'modal-save-status saving'; }
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetch('/api/repo/file-save', {
|
|
495
|
+
method: 'POST',
|
|
496
|
+
headers: { 'Content-Type': 'application/json' },
|
|
497
|
+
body: JSON.stringify({
|
|
498
|
+
path: repoPath,
|
|
499
|
+
filePath: file.path,
|
|
500
|
+
content,
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
if (res.ok) {
|
|
504
|
+
const data = await res.json();
|
|
505
|
+
originalContent = content;
|
|
506
|
+
// Update the in-memory file data
|
|
507
|
+
file.content = content;
|
|
508
|
+
file.lines = data.lines;
|
|
509
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = `✓ Saved (${data.lines} lines)`; saveStatus.className = 'modal-save-status saved'; }
|
|
510
|
+
// Also update the rendered full view for when they switch back
|
|
511
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || '';
|
|
512
|
+
rendered.full = highlightSyntax(content, ext);
|
|
513
|
+
rendered.full_raw = content;
|
|
514
|
+
|
|
515
|
+
// Clear auto-save draft since we saved to disk
|
|
516
|
+
if (repoPath && file.path) clearDraft(repoPath, file.path);
|
|
517
|
+
|
|
518
|
+
// Show commit section after save
|
|
519
|
+
const commitSection = document.getElementById('editCommitSection');
|
|
520
|
+
const commitInput = document.getElementById('editCommitMsg') as HTMLInputElement;
|
|
521
|
+
const commitBtn = document.getElementById('editCommitBtn');
|
|
522
|
+
const commitCancel = document.getElementById('editCommitCancel');
|
|
523
|
+
|
|
524
|
+
if (commitSection && commitInput) {
|
|
525
|
+
commitSection.style.display = 'flex';
|
|
526
|
+
const fileName = file.name || file.path?.split('/').pop() || 'file';
|
|
527
|
+
commitInput.value = `edit: ${fileName}`;
|
|
528
|
+
commitInput.focus();
|
|
529
|
+
commitInput.select();
|
|
530
|
+
|
|
531
|
+
const doCommit = async () => {
|
|
532
|
+
const msg = commitInput.value.trim();
|
|
533
|
+
if (!msg) { commitInput.focus(); return; }
|
|
534
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = 'Committing...'; saveStatus.className = 'modal-save-status saving'; }
|
|
535
|
+
try {
|
|
536
|
+
const cRes = await fetch('/api/repo/git-commit', {
|
|
537
|
+
method: 'POST',
|
|
538
|
+
headers: { 'Content-Type': 'application/json' },
|
|
539
|
+
body: JSON.stringify({
|
|
540
|
+
path: repoPath,
|
|
541
|
+
filePath: file.path,
|
|
542
|
+
message: msg,
|
|
543
|
+
}),
|
|
544
|
+
});
|
|
545
|
+
if (cRes.ok) {
|
|
546
|
+
const cData = await cRes.json();
|
|
547
|
+
const shortHash = cData.hash ? cData.hash.substring(0, 7) : '';
|
|
548
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = `✓ Committed ${shortHash}`; saveStatus.className = 'modal-save-status saved'; }
|
|
549
|
+
commitSection.style.display = 'none';
|
|
550
|
+
setTimeout(() => { if (saveStatus?.textContent?.startsWith('✓')) { saveStatus.style.display = 'none'; } }, 4000);
|
|
551
|
+
} else {
|
|
552
|
+
const err = await cRes.text();
|
|
553
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = `Commit err: ${err}`; saveStatus.className = 'modal-save-status error'; }
|
|
554
|
+
}
|
|
555
|
+
} catch (err: any) {
|
|
556
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = `Commit err: ${err.message}`; saveStatus.className = 'modal-save-status error'; }
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// Wire commit button
|
|
561
|
+
if (commitBtn) {
|
|
562
|
+
const newCommitBtn = commitBtn.cloneNode(true) as HTMLElement;
|
|
563
|
+
commitBtn.replaceWith(newCommitBtn);
|
|
564
|
+
newCommitBtn.addEventListener('click', doCommit);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Enter key in input triggers commit
|
|
568
|
+
commitInput.addEventListener('keydown', (e) => {
|
|
569
|
+
if (e.key === 'Enter') { e.preventDefault(); doCommit(); }
|
|
570
|
+
if (e.key === 'Escape') { commitSection.style.display = 'none'; editor?.focus(); }
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Cancel button
|
|
574
|
+
if (commitCancel) {
|
|
575
|
+
const newCancelBtn = commitCancel.cloneNode(true) as HTMLElement;
|
|
576
|
+
commitCancel.replaceWith(newCancelBtn);
|
|
577
|
+
newCancelBtn.addEventListener('click', () => { commitSection.style.display = 'none'; editor?.focus(); });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Fade out save status after 3s (only if no commit action is pending)
|
|
582
|
+
setTimeout(() => { if (saveStatus?.textContent?.startsWith('✓ Saved')) { saveStatus.style.display = 'none'; } }, 3000);
|
|
583
|
+
} else {
|
|
584
|
+
const err = await res.text();
|
|
585
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = `Error: ${err}`; saveStatus.className = 'modal-save-status error'; }
|
|
586
|
+
}
|
|
587
|
+
} catch (err: any) {
|
|
588
|
+
if (saveStatus) { saveStatus.style.display = ''; saveStatus.textContent = `Error: ${err.message}`; saveStatus.className = 'modal-save-status error'; }
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
if (saveBtn) {
|
|
593
|
+
const newSaveBtn = saveBtn.cloneNode(true) as HTMLElement;
|
|
594
|
+
saveBtn.replaceWith(newSaveBtn);
|
|
595
|
+
newSaveBtn.addEventListener('click', saveFile);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
// Activate the initial edit view immediately
|
|
601
|
+
requestAnimationFrame(() => activateEditView());
|
|
602
|
+
|
|
603
|
+
if (hasDiff) {
|
|
604
|
+
rendered.diff = buildModalDiffHTML(file);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
measure('modal:fetchContent', async () => {
|
|
608
|
+
try {
|
|
609
|
+
const state = ctx.snap().context;
|
|
610
|
+
let content = '';
|
|
611
|
+
|
|
612
|
+
if (state.currentCommitHash && file.path) {
|
|
613
|
+
const response = await fetch('/api/repo/file-content', {
|
|
614
|
+
method: 'POST',
|
|
615
|
+
headers: { 'Content-Type': 'application/json' },
|
|
616
|
+
body: JSON.stringify({
|
|
617
|
+
path: state.repoPath,
|
|
618
|
+
commit: state.currentCommitHash,
|
|
619
|
+
filePath: file.path
|
|
620
|
+
})
|
|
621
|
+
});
|
|
622
|
+
if (response.ok) {
|
|
623
|
+
const data = await response.json();
|
|
624
|
+
content = data.content || '';
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!content && file.content) {
|
|
629
|
+
content = file.content;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!content) {
|
|
633
|
+
contentEl.innerHTML = '<span style="color: var(--text-muted); font-style: italic;">No content available</span>';
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const lineCount = content.split('\n').length;
|
|
638
|
+
if (lineCountEl) {
|
|
639
|
+
lineCountEl.textContent = `${lineCount.toLocaleString()} lines`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || '';
|
|
643
|
+
rendered.full = highlightSyntax(content, ext);
|
|
644
|
+
rendered.full_raw = content;
|
|
645
|
+
originalContent = content;
|
|
646
|
+
|
|
647
|
+
// If currently in edit mode, update the CodeMirror editor with fetched content
|
|
648
|
+
if (currentView === 'edit') {
|
|
649
|
+
const editContainer = document.getElementById('modalEditContainer');
|
|
650
|
+
const editor = (editContainer as any)?._cmEditor;
|
|
651
|
+
if (editor && !editor.getContent()) {
|
|
652
|
+
editor.setContent(content);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Sync rendered data to the active tab
|
|
657
|
+
const activeTab = getActiveTab();
|
|
658
|
+
if (activeTab && activeTab.path === file.path) {
|
|
659
|
+
activeTab.rendered = { ...rendered };
|
|
660
|
+
activeTab.originalContent = originalContent;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Update outline if visible
|
|
664
|
+
updateOutline();
|
|
665
|
+
|
|
666
|
+
} catch (err) {
|
|
667
|
+
measure('modal:fetchError', () => err);
|
|
668
|
+
if (file.content) {
|
|
669
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || '';
|
|
670
|
+
rendered.full = highlightSyntax(file.content, ext);
|
|
671
|
+
rendered.full_raw = file.content;
|
|
672
|
+
} else {
|
|
673
|
+
contentEl.innerHTML = `<span style="color: var(--error);">Failed to load: ${escapeHtml(err.message)}</span>`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
// ─── Load content for a tab (used when switching to an unloaded tab) ──
|
|
681
|
+
async function loadTabContent(ctx: CanvasContext, tab: FileTab) {
|
|
682
|
+
const contentEl = document.getElementById('previewContent');
|
|
683
|
+
const lineCountEl = document.getElementById('previewLineCount');
|
|
684
|
+
if (!contentEl) return;
|
|
685
|
+
|
|
686
|
+
contentEl.innerHTML = '<span style="color: var(--text-muted); font-style: italic;">Loading...</span>';
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
const state = ctx.snap().context;
|
|
690
|
+
let content = '';
|
|
691
|
+
|
|
692
|
+
if (state.currentCommitHash && tab.path) {
|
|
693
|
+
const response = await fetch('/api/repo/file-content', {
|
|
694
|
+
method: 'POST',
|
|
695
|
+
headers: { 'Content-Type': 'application/json' },
|
|
696
|
+
body: JSON.stringify({
|
|
697
|
+
path: state.repoPath,
|
|
698
|
+
commit: state.currentCommitHash,
|
|
699
|
+
filePath: tab.path
|
|
700
|
+
})
|
|
701
|
+
});
|
|
702
|
+
if (response.ok) {
|
|
703
|
+
const data = await response.json();
|
|
704
|
+
content = data.content || '';
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!content && tab.file.content) content = tab.file.content;
|
|
709
|
+
if (!content) {
|
|
710
|
+
contentEl.innerHTML = '<span style="color: var(--text-muted);">No content available</span>';
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const ext = tab.name?.split('.').pop()?.toLowerCase() || '';
|
|
715
|
+
tab.rendered.full = highlightSyntax(content, ext);
|
|
716
|
+
tab.rendered.full_raw = content;
|
|
717
|
+
tab.originalContent = content;
|
|
718
|
+
|
|
719
|
+
if (lineCountEl) {
|
|
720
|
+
lineCountEl.textContent = `${content.split('\n').length.toLocaleString()} lines`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Check if this tab is still active
|
|
724
|
+
const activeTab = getActiveTab();
|
|
725
|
+
if (activeTab && activeTab.path === tab.path) {
|
|
726
|
+
contentEl.innerHTML = tab.rendered.full;
|
|
727
|
+
addClickableImports(ctx, contentEl, tab.path, tab.rendered.full_raw);
|
|
728
|
+
}
|
|
729
|
+
} catch (err: any) {
|
|
730
|
+
contentEl.innerHTML = `<span style="color: var(--error);">Failed to load: ${err.message}</span>`;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ─── Blame view ─────────────────────────────────────
|
|
735
|
+
const blameCache = new Map<string, any[]>();
|
|
736
|
+
|
|
737
|
+
const BLAME_COLORS = [
|
|
738
|
+
'#c4b5fd', '#93c5fd', '#86efac', '#fde68a', '#fca5a5',
|
|
739
|
+
'#f9a8d4', '#a5b4fc', '#67e8f9', '#d9f99d', '#fdba74',
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
function getAuthorColor(author: string, authorMap: Map<string, string>): string {
|
|
743
|
+
if (authorMap.has(author)) return authorMap.get(author)!;
|
|
744
|
+
const color = BLAME_COLORS[authorMap.size % BLAME_COLORS.length];
|
|
745
|
+
authorMap.set(author, color);
|
|
746
|
+
return color;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function timeAgo(ts: number): string {
|
|
750
|
+
const diff = Date.now() / 1000 - ts;
|
|
751
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
|
752
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
|
753
|
+
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
|
754
|
+
if (diff < 2592000) return `${Math.floor(diff / 604800)}w`;
|
|
755
|
+
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo`;
|
|
756
|
+
return `${Math.floor(diff / 31536000)}y`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function loadBlameView(ctx: CanvasContext, file: any, container: HTMLElement) {
|
|
760
|
+
const state = ctx.snap().context;
|
|
761
|
+
if (!state.repoPath) return;
|
|
762
|
+
|
|
763
|
+
const cacheKey = `${state.repoPath}:${file.path}:${state.currentCommitHash || 'HEAD'}`;
|
|
764
|
+
|
|
765
|
+
// Check cache
|
|
766
|
+
if (blameCache.has(cacheKey)) {
|
|
767
|
+
renderBlame(blameCache.get(cacheKey)!, container, file);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
container.innerHTML = '<div class="blame-loading">Loading blame data...</div>';
|
|
772
|
+
|
|
773
|
+
try {
|
|
774
|
+
const res = await fetch('/api/repo/git-blame', {
|
|
775
|
+
method: 'POST',
|
|
776
|
+
headers: { 'Content-Type': 'application/json' },
|
|
777
|
+
body: JSON.stringify({
|
|
778
|
+
path: state.repoPath,
|
|
779
|
+
filePath: file.path,
|
|
780
|
+
commit: state.currentCommitHash || undefined,
|
|
781
|
+
}),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (!res.ok) {
|
|
785
|
+
const err = await res.text();
|
|
786
|
+
container.innerHTML = `<div class="blame-error">Blame failed: ${escapeHtml(err)}</div>`;
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const data = await res.json();
|
|
791
|
+
blameCache.set(cacheKey, data.entries);
|
|
792
|
+
renderBlame(data.entries, container, file);
|
|
793
|
+
} catch (err: any) {
|
|
794
|
+
container.innerHTML = `<div class="blame-error">Error: ${escapeHtml(err.message)}</div>`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function renderBlame(entries: any[], container: HTMLElement, file: any) {
|
|
799
|
+
const authorMap = new Map<string, string>();
|
|
800
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || '';
|
|
801
|
+
|
|
802
|
+
let html = '<div class="blame-scroll"><table class="blame-table"><tbody>';
|
|
803
|
+
|
|
804
|
+
let prevHash = '';
|
|
805
|
+
for (let i = 0; i < entries.length; i++) {
|
|
806
|
+
const e = entries[i];
|
|
807
|
+
const isNewGroup = e.hash !== prevHash;
|
|
808
|
+
prevHash = e.hash;
|
|
809
|
+
|
|
810
|
+
const color = getAuthorColor(e.author, authorMap);
|
|
811
|
+
const authorName = e.author.length > 12 ? e.author.slice(0, 11) + '…' : e.author;
|
|
812
|
+
const age = timeAgo(e.authorTime);
|
|
813
|
+
const escapedContent = escapeHtml(e.content);
|
|
814
|
+
const groupClass = isNewGroup ? ' blame-group-start' : '';
|
|
815
|
+
|
|
816
|
+
html += `<tr class="blame-row${groupClass}">`;
|
|
817
|
+
|
|
818
|
+
// Blame gutter
|
|
819
|
+
if (isNewGroup) {
|
|
820
|
+
html += `<td class="blame-gutter" style="border-left: 3px solid ${color}">`;
|
|
821
|
+
html += `<span class="blame-hash" title="${escapeHtml(e.summary)}">${e.shortHash}</span>`;
|
|
822
|
+
html += `<span class="blame-author" style="color: ${color}" title="${escapeHtml(e.author)}">${escapeHtml(authorName)}</span>`;
|
|
823
|
+
html += `<span class="blame-age">${age}</span>`;
|
|
824
|
+
html += '</td>';
|
|
825
|
+
} else {
|
|
826
|
+
html += `<td class="blame-gutter blame-gutter-empty" style="border-left: 3px solid ${color}"></td>`;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Line number
|
|
830
|
+
html += `<td class="blame-lineno">${e.line}</td>`;
|
|
831
|
+
|
|
832
|
+
// Code
|
|
833
|
+
html += `<td class="blame-code"><code>${escapedContent || ' '}</code></td>`;
|
|
834
|
+
html += '</tr>';
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
html += '</tbody></table></div>';
|
|
838
|
+
|
|
839
|
+
// Author legend
|
|
840
|
+
if (authorMap.size > 1) {
|
|
841
|
+
html += '<div class="blame-legend">';
|
|
842
|
+
for (const [author, color] of authorMap) {
|
|
843
|
+
html += `<span class="blame-legend-item"><span class="blame-legend-dot" style="background:${color}"></span>${escapeHtml(author)}</span>`;
|
|
844
|
+
}
|
|
845
|
+
html += '</div>';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
container.innerHTML = html;
|
|
849
|
+
}
|