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.
Files changed (121) hide show
  1. package/README.md +167 -0
  2. package/app/api/auth/favorites/route.ts +56 -0
  3. package/app/api/auth/github/callback/route.ts +103 -0
  4. package/app/api/auth/github/route.ts +32 -0
  5. package/app/api/auth/me/route.ts +52 -0
  6. package/app/api/auth/positions/route.ts +50 -0
  7. package/app/api/chat/route.ts +101 -0
  8. package/app/api/connections/route.ts +72 -0
  9. package/app/api/github/repos/route.ts +111 -0
  10. package/app/api/positions/route.ts +80 -0
  11. package/app/api/repo/branch-diff/route.ts +201 -0
  12. package/app/api/repo/branches/route.ts +53 -0
  13. package/app/api/repo/browse/route.ts +55 -0
  14. package/app/api/repo/clone/route.ts +78 -0
  15. package/app/api/repo/clone-stream/route.ts +131 -0
  16. package/app/api/repo/file-content/route.ts +28 -0
  17. package/app/api/repo/file-delete/route.ts +62 -0
  18. package/app/api/repo/file-history/route.ts +45 -0
  19. package/app/api/repo/file-rename/route.ts +83 -0
  20. package/app/api/repo/file-save/route.ts +45 -0
  21. package/app/api/repo/files/route.ts +169 -0
  22. package/app/api/repo/git-blame/route.ts +86 -0
  23. package/app/api/repo/git-commit/route.ts +40 -0
  24. package/app/api/repo/git-heatmap/route.ts +55 -0
  25. package/app/api/repo/imports/route.ts +154 -0
  26. package/app/api/repo/load/route.ts +56 -0
  27. package/app/api/repo/mode/route.ts +14 -0
  28. package/app/api/repo/search/route.ts +127 -0
  29. package/app/api/repo/tree/route.ts +104 -0
  30. package/app/api/repo/upload/route.ts +53 -0
  31. package/app/api/repo/validate-path.ts +53 -0
  32. package/app/canvas_users.db +0 -0
  33. package/app/canvas_users.db-shm +0 -0
  34. package/app/canvas_users.db-wal +0 -0
  35. package/app/globals.css +7899 -0
  36. package/app/layout.tsx +493 -0
  37. package/app/lib/auth.ts +193 -0
  38. package/app/lib/auto-save.ts +137 -0
  39. package/app/lib/branch-compare.ts +443 -0
  40. package/app/lib/breadcrumbs.ts +170 -0
  41. package/app/lib/canvas-export.ts +358 -0
  42. package/app/lib/canvas-text.ts +912 -0
  43. package/app/lib/canvas.ts +564 -0
  44. package/app/lib/card-arrangement.ts +188 -0
  45. package/app/lib/card-context-menu.tsx +453 -0
  46. package/app/lib/card-diff-markers.ts +270 -0
  47. package/app/lib/card-expand.ts +189 -0
  48. package/app/lib/card-groups.ts +246 -0
  49. package/app/lib/cards.tsx +914 -0
  50. package/app/lib/chat.tsx +308 -0
  51. package/app/lib/code-editor.ts +508 -0
  52. package/app/lib/command-palette.ts +262 -0
  53. package/app/lib/connections.tsx +1037 -0
  54. package/app/lib/context.ts +94 -0
  55. package/app/lib/cursor-sharing.ts +281 -0
  56. package/app/lib/dependency-graph.ts +438 -0
  57. package/app/lib/events.tsx +1747 -0
  58. package/app/lib/file-card-plugin.ts +134 -0
  59. package/app/lib/file-modal.tsx +849 -0
  60. package/app/lib/file-preview.ts +400 -0
  61. package/app/lib/file-tabs.ts +318 -0
  62. package/app/lib/galaxydraw-bridge.ts +477 -0
  63. package/app/lib/galaxydraw.test.ts +229 -0
  64. package/app/lib/global-search.ts +264 -0
  65. package/app/lib/goto-definition.ts +224 -0
  66. package/app/lib/heatmap.ts +178 -0
  67. package/app/lib/hidden-files.tsx +222 -0
  68. package/app/lib/layers.ts +0 -0
  69. package/app/lib/layers.tsx +365 -0
  70. package/app/lib/loading.tsx +45 -0
  71. package/app/lib/multi-repo.ts +286 -0
  72. package/app/lib/new-file-dialog.tsx +230 -0
  73. package/app/lib/onboarding.tsx +213 -0
  74. package/app/lib/perf-overlay.ts +360 -0
  75. package/app/lib/positions.ts +176 -0
  76. package/app/lib/pr-review.ts +374 -0
  77. package/app/lib/production-mode.ts +47 -0
  78. package/app/lib/repo.tsx +977 -0
  79. package/app/lib/settings-modal.tsx +374 -0
  80. package/app/lib/settings.ts +97 -0
  81. package/app/lib/shortcuts-panel.ts +141 -0
  82. package/app/lib/status-bar.ts +128 -0
  83. package/app/lib/symbol-outline.ts +212 -0
  84. package/app/lib/syntax.ts +177 -0
  85. package/app/lib/tab-diff.ts +238 -0
  86. package/app/lib/user.tsx +133 -0
  87. package/app/lib/utils.ts +78 -0
  88. package/app/lib/viewport-culling.ts +728 -0
  89. package/app/page.client.tsx +215 -0
  90. package/app/page.tsx +291 -0
  91. package/app/state/machine.js +196 -0
  92. package/app/styles/main.css +2168 -0
  93. package/banner.png +0 -0
  94. package/cli.ts +44 -0
  95. package/package.json +75 -0
  96. package/packages/galaxydraw/README.md +296 -0
  97. package/packages/galaxydraw/banner.png +0 -0
  98. package/packages/galaxydraw/demo/build-static.ts +100 -0
  99. package/packages/galaxydraw/demo/client.ts +154 -0
  100. package/packages/galaxydraw/demo/dist/client.js +8 -0
  101. package/packages/galaxydraw/demo/index.html +256 -0
  102. package/packages/galaxydraw/demo/server.ts +96 -0
  103. package/packages/galaxydraw/dist/index.js +984 -0
  104. package/packages/galaxydraw/dist/index.js.map +16 -0
  105. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  106. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  109. package/packages/galaxydraw/package.json +49 -0
  110. package/packages/galaxydraw/perf.test.ts +284 -0
  111. package/packages/galaxydraw/src/core/cards.ts +435 -0
  112. package/packages/galaxydraw/src/core/engine.ts +339 -0
  113. package/packages/galaxydraw/src/core/events.ts +81 -0
  114. package/packages/galaxydraw/src/core/layout.ts +136 -0
  115. package/packages/galaxydraw/src/core/minimap.ts +216 -0
  116. package/packages/galaxydraw/src/core/state.ts +177 -0
  117. package/packages/galaxydraw/src/core/viewport.ts +106 -0
  118. package/packages/galaxydraw/src/galaxydraw.css +166 -0
  119. package/packages/galaxydraw/src/index.ts +40 -0
  120. package/packages/galaxydraw/tsconfig.json +30 -0
  121. 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
+ }