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,264 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Global search panel — Ctrl+Shift+F to search across all repo files.
4
+ * Uses git grep via the /api/repo/search endpoint.
5
+ * Results grouped by file, clickable to open in editor modal.
6
+ */
7
+ import type { CanvasContext } from './context';
8
+ import { escapeHtml } from './utils';
9
+
10
+ let _panel: HTMLElement | null = null;
11
+ let _searchTimeout: any = null;
12
+ let _abortController: AbortController | null = null;
13
+ let _ctx: CanvasContext | null = null;
14
+
15
+ /** Toggle the search panel */
16
+ export function toggleGlobalSearch(ctx: CanvasContext) {
17
+ _ctx = ctx;
18
+ if (_panel) {
19
+ closeSearch();
20
+ } else {
21
+ openSearch();
22
+ }
23
+ }
24
+
25
+ function openSearch() {
26
+ if (_panel) return;
27
+
28
+ _panel = document.createElement('div');
29
+ _panel.id = 'globalSearchPanel';
30
+ _panel.className = 'global-search-panel';
31
+ // Inline positioning for reliability (same approach as settings modal)
32
+ Object.assign(_panel.style, {
33
+ position: 'fixed',
34
+ top: '0',
35
+ right: '0',
36
+ width: '420px',
37
+ height: '100vh',
38
+ zIndex: '9000',
39
+ display: 'flex',
40
+ flexDirection: 'column',
41
+ });
42
+ _panel.innerHTML = `
43
+ <div class="gs-header">
44
+ <div class="gs-search-row">
45
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="gs-search-icon">
46
+ <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
47
+ </svg>
48
+ <input type="text" id="gsSearchInput" class="gs-input" placeholder="Search across all files..." spellcheck="false" autocomplete="off" />
49
+ <button id="gsClose" class="gs-close" title="Close (Esc)">✕</button>
50
+ </div>
51
+ <div class="gs-options">
52
+ <label class="gs-option" title="Match case">
53
+ <input type="checkbox" id="gsCaseSensitive" />
54
+ <span>Aa</span>
55
+ </label>
56
+ <span class="gs-info" id="gsInfo"></span>
57
+ </div>
58
+ </div>
59
+ <div class="gs-results" id="gsResults">
60
+ <div class="gs-empty">Type at least 2 characters to search</div>
61
+ </div>
62
+ `;
63
+
64
+ document.body.appendChild(_panel);
65
+
66
+ // Focus input
67
+ const input = _panel.querySelector('#gsSearchInput') as HTMLInputElement;
68
+ input?.focus();
69
+
70
+ // Wire events
71
+ input?.addEventListener('input', () => onSearchInput(input.value));
72
+ _panel.querySelector('#gsClose')?.addEventListener('click', closeSearch);
73
+
74
+ // Escape to close
75
+ document.addEventListener('keydown', _onEsc);
76
+
77
+ // Slide-in animation
78
+ requestAnimationFrame(() => _panel?.classList.add('visible'));
79
+ }
80
+
81
+ export function closeSearch() {
82
+ if (!_panel) return;
83
+ document.removeEventListener('keydown', _onEsc);
84
+ _panel.classList.remove('visible');
85
+ // Wait for slide-out animation
86
+ setTimeout(() => {
87
+ _panel?.remove();
88
+ _panel = null;
89
+ }, 200);
90
+ if (_abortController) { _abortController.abort(); _abortController = null; }
91
+ if (_searchTimeout) { clearTimeout(_searchTimeout); _searchTimeout = null; }
92
+ }
93
+
94
+ function _onEsc(e: KeyboardEvent) {
95
+ if (e.key === 'Escape' && _panel) {
96
+ e.preventDefault();
97
+ e.stopPropagation();
98
+ closeSearch();
99
+ }
100
+ }
101
+
102
+ function onSearchInput(query: string) {
103
+ if (_searchTimeout) clearTimeout(_searchTimeout);
104
+ if (_abortController) { _abortController.abort(); _abortController = null; }
105
+
106
+ const info = document.getElementById('gsInfo');
107
+ const results = document.getElementById('gsResults');
108
+
109
+ if (query.length < 2) {
110
+ if (info) info.textContent = '';
111
+ if (results) results.innerHTML = '<div class="gs-empty">Type at least 2 characters to search</div>';
112
+ return;
113
+ }
114
+
115
+ if (info) info.textContent = 'Searching...';
116
+ if (results) results.innerHTML = '<div class="gs-loading"><div class="gs-spinner"></div></div>';
117
+
118
+ // Debounce 300ms
119
+ _searchTimeout = setTimeout(() => performSearch(query), 300);
120
+ }
121
+
122
+ async function performSearch(query: string) {
123
+ if (!_ctx) return;
124
+
125
+ const state = _ctx.snap().context;
126
+ const repoPath = state.repoPath;
127
+ if (!repoPath) return;
128
+
129
+ const caseSensitive = (document.getElementById('gsCaseSensitive') as HTMLInputElement)?.checked || false;
130
+ const info = document.getElementById('gsInfo');
131
+ const resultsEl = document.getElementById('gsResults');
132
+
133
+ _abortController = new AbortController();
134
+
135
+ try {
136
+ const res = await fetch('/api/repo/search', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({
140
+ path: repoPath,
141
+ query,
142
+ commit: state.currentCommitHash || undefined,
143
+ caseSensitive,
144
+ }),
145
+ signal: _abortController.signal,
146
+ });
147
+
148
+ if (!res.ok) {
149
+ if (info) info.textContent = 'Search failed';
150
+ if (resultsEl) resultsEl.innerHTML = '<div class="gs-empty gs-error">Search failed</div>';
151
+ return;
152
+ }
153
+
154
+ const data = await res.json();
155
+ const { results, totalMatches } = data;
156
+
157
+ if (info) {
158
+ const fileCount = results.length;
159
+ info.textContent = totalMatches === 0
160
+ ? 'No results'
161
+ : `${totalMatches} match${totalMatches > 1 ? 'es' : ''} in ${fileCount} file${fileCount > 1 ? 's' : ''}`;
162
+ }
163
+
164
+ if (!resultsEl) return;
165
+
166
+ if (results.length === 0) {
167
+ resultsEl.innerHTML = `<div class="gs-empty">No results for "${escapeHtml(query)}"</div>`;
168
+ return;
169
+ }
170
+
171
+ // Render grouped results
172
+ resultsEl.innerHTML = results.map((group: any) => `
173
+ <div class="gs-file-group">
174
+ <div class="gs-file-header" data-path="${escapeHtml(group.file)}">
175
+ <span class="gs-file-icon">${getFileIcon(group.file)}</span>
176
+ <span class="gs-file-name">${escapeHtml(group.file.split('/').pop() || group.file)}</span>
177
+ <span class="gs-file-path">${escapeHtml(group.file)}</span>
178
+ <span class="gs-match-count">${group.matches.length}</span>
179
+ </div>
180
+ ${group.matches.map((m: any) => `
181
+ <div class="gs-match-line" data-path="${escapeHtml(group.file)}" data-line="${m.line}">
182
+ <span class="gs-line-num">${m.line}</span>
183
+ <span class="gs-line-content">${highlightMatch(escapeHtml(m.content), query, caseSensitive)}</span>
184
+ </div>
185
+ `).join('')}
186
+ </div>
187
+ `).join('');
188
+
189
+ // Wire click handlers
190
+ resultsEl.querySelectorAll('.gs-match-line').forEach(el => {
191
+ el.addEventListener('click', () => {
192
+ const path = el.getAttribute('data-path');
193
+ const line = parseInt(el.getAttribute('data-line') || '1', 10);
194
+ if (path) openFileFromSearch(path, line);
195
+ });
196
+ });
197
+
198
+ resultsEl.querySelectorAll('.gs-file-header').forEach(el => {
199
+ el.addEventListener('click', () => {
200
+ const path = el.getAttribute('data-path');
201
+ if (path) openFileFromSearch(path, 1);
202
+ });
203
+ });
204
+
205
+ } catch (err: any) {
206
+ if (err.name === 'AbortError') return; // Cancelled
207
+ if (info) info.textContent = 'Search error';
208
+ if (resultsEl) resultsEl.innerHTML = `<div class="gs-empty gs-error">${escapeHtml(err.message)}</div>`;
209
+ }
210
+ }
211
+
212
+ /** Highlight search matches in result text */
213
+ function highlightMatch(html: string, query: string, caseSensitive: boolean): string {
214
+ const flags = caseSensitive ? 'g' : 'gi';
215
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
216
+ return html.replace(new RegExp(escaped, flags), '<mark class="gs-highlight">$&</mark>');
217
+ }
218
+
219
+ function getFileIcon(name: string): string {
220
+ const ext = name.split('.').pop()?.toLowerCase() || '';
221
+ const icons: Record<string, string> = {
222
+ ts: '🔷', tsx: '⚛️', js: '🟡', jsx: '⚛️',
223
+ css: '🎨', html: '🌐', json: '📋', md: '📝',
224
+ py: '🐍', go: '🔵', rs: '🦀', yml: '⚙️', yaml: '⚙️',
225
+ sh: '💻', sql: '🗃️', svg: '🖼️', png: '🖼️', jpg: '🖼️',
226
+ toml: '⚙️', lock: '🔒', gitignore: '🚫',
227
+ };
228
+ return icons[ext] || '📄';
229
+ }
230
+
231
+ /** Open a file from search results in the editor modal */
232
+ function openFileFromSearch(filePath: string, line: number) {
233
+ if (!_ctx) return;
234
+
235
+ // Create a minimal file stub
236
+ const file = {
237
+ path: filePath,
238
+ name: filePath.split('/').pop() || filePath,
239
+ content: '',
240
+ lines: 0,
241
+ };
242
+
243
+ // Import and open the file modal
244
+ import('./file-modal').then(({ openFileModal }) => {
245
+ openFileModal(_ctx!, file, 'edit');
246
+ // Scroll to line after editor loads
247
+ if (line > 1) {
248
+ setTimeout(() => {
249
+ const editContainer = document.getElementById('modalEditContainer');
250
+ const editor = (editContainer as any)?._cmEditor;
251
+ if (editor?.view) {
252
+ const lineInfo = editor.view.state.doc.line(Math.min(line, editor.view.state.doc.lines));
253
+ editor.view.dispatch({
254
+ selection: { anchor: lineInfo.from },
255
+ scrollIntoView: true,
256
+ });
257
+ }
258
+ }, 500);
259
+ }
260
+ });
261
+
262
+ // Close search panel
263
+ closeSearch();
264
+ }
@@ -0,0 +1,224 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Go-to-definition — makes import paths clickable in the file modal.
4
+ * Ctrl+Click or click on highlighted import paths navigates to the
5
+ * target file's card on the canvas.
6
+ */
7
+ import type { CanvasContext } from './context';
8
+ import { showToast } from './utils';
9
+
10
+ // ─── Import path resolution ────────────────────────
11
+ const IMPORT_PATTERNS = [
12
+ // JS/TS: import X from './path'
13
+ /(?:import|export)\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g,
14
+ // JS/TS: require('./path')
15
+ /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
16
+ // JS/TS: import('./path')
17
+ /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
18
+ // Python: from X import Y / import X
19
+ /(?:from|import)\s+([\w.]+)/g,
20
+ // CSS: @import './path'
21
+ /@import\s+['"]([^'"]+)['"]/g,
22
+ ];
23
+
24
+ const JS_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
25
+ const INDEX_FILES = ['index.ts', 'index.tsx', 'index.js', 'index.jsx'];
26
+
27
+ /**
28
+ * Resolve an import specifier to a file path in the repo.
29
+ */
30
+ function resolveImportPath(importPath: string, currentFilePath: string, allFiles: string[]): string | null {
31
+ // Skip external packages (no ./ or ../ prefix, not Python dots)
32
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
33
+ // Could be a Python dotted import or a bare specifier
34
+ // Try converting Python dots to slashes
35
+ const pythonPath = importPath.replace(/\./g, '/');
36
+ const pyMatch = allFiles.find(f => f === pythonPath + '.py' || f === pythonPath + '/__init__.py');
37
+ if (pyMatch) return pyMatch;
38
+ return null;
39
+ }
40
+
41
+ // Resolve relative path
42
+ const currentDir = currentFilePath.split('/').slice(0, -1).join('/');
43
+ const parts = [...(currentDir ? currentDir.split('/') : []), ...importPath.split('/')];
44
+
45
+ // Normalize: resolve . and ..
46
+ const resolved: string[] = [];
47
+ for (const part of parts) {
48
+ if (part === '.') continue;
49
+ if (part === '..') { resolved.pop(); continue; }
50
+ resolved.push(part);
51
+ }
52
+
53
+ const basePath = resolved.join('/');
54
+
55
+ // Try exact match first
56
+ if (allFiles.includes(basePath)) return basePath;
57
+
58
+ // Try with extensions
59
+ for (const ext of JS_EXTENSIONS) {
60
+ const candidate = basePath + ext;
61
+ if (allFiles.includes(candidate)) return candidate;
62
+ }
63
+
64
+ // Try as directory with index file
65
+ for (const indexFile of INDEX_FILES) {
66
+ const candidate = basePath + '/' + indexFile;
67
+ if (allFiles.includes(candidate)) return candidate;
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Extract all import paths from file content with their line positions.
75
+ */
76
+ function extractImports(content: string): Array<{ path: string; line: number; start: number; end: number }> {
77
+ const lines = content.split('\n');
78
+ const imports: Array<{ path: string; line: number; start: number; end: number }> = [];
79
+
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const line = lines[i];
82
+ for (const pattern of IMPORT_PATTERNS) {
83
+ pattern.lastIndex = 0;
84
+ let match;
85
+ while ((match = pattern.exec(line)) !== null) {
86
+ const importPath = match[1];
87
+ if (!importPath) continue;
88
+ // Find the position of the import path string in the line
89
+ const pathStart = line.indexOf(importPath, match.index);
90
+ imports.push({
91
+ path: importPath,
92
+ line: i + 1,
93
+ start: pathStart,
94
+ end: pathStart + importPath.length,
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ return imports;
101
+ }
102
+
103
+ /**
104
+ * Post-process rendered HTML in the modal to make import paths clickable.
105
+ * Call this after setting contentEl.innerHTML.
106
+ */
107
+ export function addClickableImports(ctx: CanvasContext, contentEl: HTMLElement, filePath: string, rawContent: string) {
108
+ if (!rawContent || !filePath) return;
109
+
110
+ const imports = extractImports(rawContent);
111
+ if (imports.length === 0) return;
112
+
113
+ // Get all file paths from the canvas
114
+ const allFiles: string[] = [];
115
+ if (ctx.allFilesData) {
116
+ for (const f of ctx.allFilesData) {
117
+ if (f.path) allFiles.push(f.path);
118
+ }
119
+ }
120
+ // Also gather from fileCards map
121
+ for (const [p] of ctx.fileCards) {
122
+ if (!allFiles.includes(p)) allFiles.push(p);
123
+ }
124
+
125
+ // Resolve imports and mark which are navigable
126
+ const navigable = new Map<string, string>(); // importPath -> resolvedPath
127
+ for (const imp of imports) {
128
+ if (navigable.has(imp.path)) continue;
129
+ const resolved = resolveImportPath(imp.path, filePath, allFiles);
130
+ if (resolved) navigable.set(imp.path, resolved);
131
+ }
132
+
133
+ if (navigable.size === 0) return;
134
+
135
+ // Add a delegated click handler on the content element
136
+ contentEl.addEventListener('click', (e: MouseEvent) => {
137
+ // Only activate on Ctrl+Click or if clicking a link-import element
138
+ const target = e.target as HTMLElement;
139
+ const importLink = target.closest('.goto-import-link');
140
+
141
+ if (importLink) {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ const resolvedPath = importLink.getAttribute('data-resolved');
145
+ if (resolvedPath) navigateToFile(ctx, resolvedPath);
146
+ return;
147
+ }
148
+
149
+ // Ctrl+Click on any text containing an import path
150
+ if (!e.ctrlKey && !e.metaKey) return;
151
+ const text = target.textContent || '';
152
+ for (const [importPath, resolvedPath] of navigable) {
153
+ if (text.includes(importPath)) {
154
+ e.preventDefault();
155
+ e.stopPropagation();
156
+ navigateToFile(ctx, resolvedPath);
157
+ return;
158
+ }
159
+ }
160
+ });
161
+
162
+ // Post-process HTML to wrap import paths with clickable spans
163
+ // We search for string literals containing navigable import paths
164
+ const codeEl = contentEl.querySelector('code') || contentEl;
165
+ const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);
166
+ const textNodes: Text[] = [];
167
+ let node: Text | null;
168
+ while ((node = walker.nextNode() as Text)) {
169
+ for (const [importPath] of navigable) {
170
+ if (node.textContent?.includes(importPath)) {
171
+ textNodes.push(node);
172
+ break;
173
+ }
174
+ }
175
+ }
176
+
177
+ for (const textNode of textNodes) {
178
+ const text = textNode.textContent || '';
179
+ for (const [importPath, resolvedPath] of navigable) {
180
+ if (text.includes(importPath)) {
181
+ const parts = text.split(importPath);
182
+ if (parts.length < 2) continue;
183
+
184
+ const fragment = document.createDocumentFragment();
185
+ for (let i = 0; i < parts.length; i++) {
186
+ if (i > 0) {
187
+ // Insert the clickable import link
188
+ const link = document.createElement('span');
189
+ link.className = 'goto-import-link';
190
+ link.setAttribute('data-resolved', resolvedPath);
191
+ link.textContent = importPath;
192
+ link.title = `Go to: ${resolvedPath} (Ctrl+Click)`;
193
+ fragment.appendChild(link);
194
+ }
195
+ if (parts[i]) {
196
+ fragment.appendChild(document.createTextNode(parts[i]));
197
+ }
198
+ }
199
+ textNode.replaceWith(fragment);
200
+ break; // Only process first match per text node
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Navigate to a file — opens it as a new tab in the modal.
208
+ */
209
+ function navigateToFile(ctx: CanvasContext, filePath: string) {
210
+ // Find the file data
211
+ const fileData = ctx.allFilesData?.find(f => f.path === filePath);
212
+ if (!fileData) {
213
+ showToast(`File not found: ${filePath}`, 'error');
214
+ return;
215
+ }
216
+
217
+ showToast(`→ ${filePath.split('/').pop()}`, 'info');
218
+
219
+ // Open the file as a new tab in the current modal
220
+ import('./file-modal').then(({ openFileModal }) => {
221
+ openFileModal(ctx, fileData);
222
+ });
223
+ }
224
+
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Git Heatmap — color-codes file cards by commit frequency.
3
+ * Hot files (frequently changed) glow red/orange.
4
+ * Cold files (rarely changed) stay blue/gray.
5
+ * Toggle with 'H' hotkey or toolbar button.
6
+ */
7
+ import { getSetting } from './settings';
8
+
9
+ // ─── Types ──────────────────────────────────────────
10
+
11
+ interface HeatmapEntry {
12
+ file: string;
13
+ commits: number;
14
+ heat: number; // 0-1 normalized
15
+ }
16
+
17
+ interface HeatmapState {
18
+ active: boolean;
19
+ data: HeatmapEntry[];
20
+ maxCommits: number;
21
+ days: number;
22
+ }
23
+
24
+ const state: HeatmapState = {
25
+ active: false,
26
+ data: [],
27
+ maxCommits: 0,
28
+ days: 90,
29
+ };
30
+
31
+ // ─── Color Scale ────────────────────────────────────
32
+
33
+ /** HSL heat colors: cold (220° blue) → warm (30° orange) → hot (0° red) */
34
+ function heatToColor(heat: number): string {
35
+ // 0 = cold blue, 0.5 = orange, 1.0 = hot red
36
+ const hue = 220 - heat * 220; // 220 → 0
37
+ const saturation = 40 + heat * 50; // 40% → 90%
38
+ const lightness = 25 + heat * 20; // 25% → 45%
39
+ return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
40
+ }
41
+
42
+ function heatToGlow(heat: number): string {
43
+ if (heat < 0.3) return 'none';
44
+ const intensity = Math.round(heat * 20);
45
+ const hue = 220 - heat * 220;
46
+ return `0 0 ${intensity}px hsla(${hue}, 80%, 50%, ${heat * 0.6})`;
47
+ }
48
+
49
+ // ─── Data Fetching ──────────────────────────────────
50
+
51
+ export async function fetchHeatmap(repoPath: string, days = 90): Promise<void> {
52
+ try {
53
+ const res = await fetch('/api/repo/git-heatmap', {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ path: repoPath, days }),
57
+ });
58
+ if (!res.ok) throw new Error(`Heatmap API error: ${res.status}`);
59
+ const json = await res.json();
60
+ state.data = json.files || [];
61
+ state.maxCommits = json.maxCommits || 0;
62
+ state.days = days;
63
+ } catch (err) {
64
+ console.error('[heatmap] fetch failed:', err);
65
+ state.data = [];
66
+ }
67
+ }
68
+
69
+ // ─── Apply / Remove Overlay ─────────────────────────
70
+
71
+ function applyOverlay() {
72
+ const heatMap = new Map(state.data.map(e => [e.file, e]));
73
+ const cards = document.querySelectorAll<HTMLElement>('.file-card');
74
+
75
+ for (const card of cards) {
76
+ const path = card.dataset.path;
77
+ if (!path) continue;
78
+
79
+ const entry = heatMap.get(path);
80
+ const heat = entry?.heat ?? 0;
81
+
82
+ // Apply heat background + glow
83
+ card.style.setProperty('--heat-bg', heatToColor(heat));
84
+ card.style.setProperty('--heat-glow', heatToGlow(heat));
85
+ card.classList.add('heatmap-active');
86
+
87
+ // Add commit count badge
88
+ if (entry && entry.commits > 0) {
89
+ let badge = card.querySelector('.heatmap-badge') as HTMLElement;
90
+ if (!badge) {
91
+ badge = document.createElement('div');
92
+ badge.className = 'heatmap-badge';
93
+ card.appendChild(badge);
94
+ }
95
+ badge.textContent = `🔥 ${entry.commits}`;
96
+ badge.title = `${entry.commits} commits in last ${state.days} days`;
97
+ }
98
+ }
99
+ }
100
+
101
+ function removeOverlay() {
102
+ const cards = document.querySelectorAll<HTMLElement>('.file-card');
103
+ for (const card of cards) {
104
+ card.style.removeProperty('--heat-bg');
105
+ card.style.removeProperty('--heat-glow');
106
+ card.classList.remove('heatmap-active');
107
+ card.querySelector('.heatmap-badge')?.remove();
108
+ }
109
+ }
110
+
111
+ // ─── Toggle ─────────────────────────────────────────
112
+
113
+ export async function toggleHeatmap(repoPath: string): Promise<boolean> {
114
+ state.active = !state.active;
115
+
116
+ if (state.active) {
117
+ if (state.data.length === 0) {
118
+ const days = getSetting('heatmapDays');
119
+ await fetchHeatmap(repoPath, days);
120
+ }
121
+ applyOverlay();
122
+ } else {
123
+ removeOverlay();
124
+ }
125
+
126
+ return state.active;
127
+ }
128
+
129
+ export function isHeatmapActive(): boolean {
130
+ return state.active;
131
+ }
132
+
133
+ /** Refresh heatmap data and re-apply if active */
134
+ export async function refreshHeatmap(repoPath: string): Promise<void> {
135
+ const days = getSetting('heatmapDays');
136
+ await fetchHeatmap(repoPath, days);
137
+ if (state.active) {
138
+ removeOverlay();
139
+ applyOverlay();
140
+ }
141
+ }
142
+
143
+ // ─── CSS (injected once) ────────────────────────────
144
+
145
+ let cssInjected = false;
146
+ export function injectHeatmapCSS() {
147
+ if (cssInjected) return;
148
+ cssInjected = true;
149
+
150
+ const style = document.createElement('style');
151
+ style.textContent = `
152
+ .file-card.heatmap-active {
153
+ background: var(--heat-bg, #1a1a2e) !important;
154
+ box-shadow: var(--heat-glow, none) !important;
155
+ transition: background 0.4s ease, box-shadow 0.4s ease;
156
+ }
157
+
158
+ .file-card.heatmap-active .file-card-header {
159
+ background: rgba(0, 0, 0, 0.3) !important;
160
+ }
161
+
162
+ .heatmap-badge {
163
+ position: absolute;
164
+ top: 4px;
165
+ right: 4px;
166
+ padding: 2px 8px;
167
+ border-radius: 12px;
168
+ font-size: 11px;
169
+ font-weight: 600;
170
+ color: #fff;
171
+ background: rgba(0, 0, 0, 0.6);
172
+ backdrop-filter: blur(4px);
173
+ z-index: 10;
174
+ pointer-events: none;
175
+ }
176
+ `;
177
+ document.head.appendChild(style);
178
+ }