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,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
|
+
}
|