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,1037 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Connections — click-on-line to connect, render SVG lines with labels,
|
|
4
|
+
* left-side marker strip, navigate.
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* 1. User clicks a line number/gutter → starts pending connection (source)
|
|
8
|
+
* 2. All cards show a subtle "click a target line" visual hint (border glow)
|
|
9
|
+
* 3. User clicks a line in another card → completes connection
|
|
10
|
+
* 4. Connection markers appear on the LEFT side of both cards
|
|
11
|
+
* 5. SVG bezier curves with gradient & filename labels connect the two lines
|
|
12
|
+
* 6. No toasts — visual feedback only (glows, highlights, labels)
|
|
13
|
+
*/
|
|
14
|
+
import { measure } from 'measure-fn';
|
|
15
|
+
import { render } from 'melina/client';
|
|
16
|
+
import type { CanvasContext } from './context';
|
|
17
|
+
import { escapeHtml } from './utils';
|
|
18
|
+
import { updateCanvasTransform, updateZoomUI } from './canvas';
|
|
19
|
+
|
|
20
|
+
// ─── Pending connection state ────────────────────────────
|
|
21
|
+
let pendingConnection: {
|
|
22
|
+
sourceFile: string;
|
|
23
|
+
sourceLine: number;
|
|
24
|
+
sourceCard: HTMLElement;
|
|
25
|
+
} | null = null;
|
|
26
|
+
|
|
27
|
+
// ─── rAF-coalesced render scheduler ─────────────────────
|
|
28
|
+
// Multiple callers (scroll, drag, resize) may trigger renderConnections
|
|
29
|
+
// in quick succession. This batches them into a single animation frame.
|
|
30
|
+
let _renderPending = false;
|
|
31
|
+
let _renderCtx: CanvasContext | null = null;
|
|
32
|
+
|
|
33
|
+
/** Schedule a connection re-render on the next animation frame. Coalesces rapid calls. */
|
|
34
|
+
export function scheduleRenderConnections(ctx: CanvasContext) {
|
|
35
|
+
_renderCtx = ctx;
|
|
36
|
+
if (_renderPending) return;
|
|
37
|
+
_renderPending = true;
|
|
38
|
+
requestAnimationFrame(() => {
|
|
39
|
+
_renderPending = false;
|
|
40
|
+
if (_renderCtx) renderConnections(_renderCtx);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Status indicator element ────────────────────────────
|
|
45
|
+
let statusIndicator: HTMLElement | null = null;
|
|
46
|
+
|
|
47
|
+
function _showStatus(text: string) {
|
|
48
|
+
if (!statusIndicator) {
|
|
49
|
+
statusIndicator = document.createElement('div');
|
|
50
|
+
statusIndicator.className = 'conn-status-indicator';
|
|
51
|
+
document.body.appendChild(statusIndicator);
|
|
52
|
+
}
|
|
53
|
+
statusIndicator.textContent = text;
|
|
54
|
+
statusIndicator.classList.add('visible');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _hideStatus() {
|
|
58
|
+
if (statusIndicator) {
|
|
59
|
+
statusIndicator.classList.remove('visible');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Setup click-on-line connection for a card ──────────
|
|
64
|
+
export function setupLineClickConnection(ctx: CanvasContext, card: HTMLElement, filePath: string) {
|
|
65
|
+
const body = card.querySelector('.file-card-body') as HTMLElement;
|
|
66
|
+
if (!body) return;
|
|
67
|
+
|
|
68
|
+
body.addEventListener('click', (e) => {
|
|
69
|
+
const lineEl = (e.target as HTMLElement).closest('.diff-line') as HTMLElement;
|
|
70
|
+
if (!lineEl) return;
|
|
71
|
+
|
|
72
|
+
const lineNum = parseInt(lineEl.dataset.line);
|
|
73
|
+
if (!lineNum) return;
|
|
74
|
+
|
|
75
|
+
// If we're in "connecting" mode and clicking a line in a DIFFERENT card
|
|
76
|
+
if (pendingConnection && pendingConnection.sourceCard !== card) {
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
|
|
80
|
+
// Complete the connection
|
|
81
|
+
const srcName = pendingConnection.sourceFile.split('/').pop();
|
|
82
|
+
const tgtName = filePath.split('/').pop();
|
|
83
|
+
const conn = {
|
|
84
|
+
id: `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
85
|
+
sourceFile: pendingConnection.sourceFile,
|
|
86
|
+
sourceLineStart: pendingConnection.sourceLine,
|
|
87
|
+
sourceLineEnd: pendingConnection.sourceLine,
|
|
88
|
+
targetFile: filePath,
|
|
89
|
+
targetLineStart: lineNum,
|
|
90
|
+
targetLineEnd: lineNum,
|
|
91
|
+
comment: `${srcName}:${pendingConnection.sourceLine} → ${tgtName}:${lineNum}`,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
ctx.actor.send({
|
|
95
|
+
type: 'START_CONNECTION',
|
|
96
|
+
sourceFile: conn.sourceFile,
|
|
97
|
+
lineStart: conn.sourceLineStart,
|
|
98
|
+
lineEnd: conn.sourceLineEnd,
|
|
99
|
+
});
|
|
100
|
+
ctx.actor.send({
|
|
101
|
+
type: 'COMPLETE_CONNECTION',
|
|
102
|
+
targetFile: conn.targetFile,
|
|
103
|
+
lineStart: conn.targetLineStart,
|
|
104
|
+
lineEnd: conn.targetLineEnd,
|
|
105
|
+
comment: conn.comment,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Clear pending
|
|
109
|
+
_clearPending(ctx);
|
|
110
|
+
|
|
111
|
+
renderConnections(ctx);
|
|
112
|
+
buildConnectionMarkers(ctx);
|
|
113
|
+
saveConnections(ctx);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If clicking same card and already pending → cancel
|
|
118
|
+
if (pendingConnection && pendingConnection.sourceCard === card) {
|
|
119
|
+
_clearPending(ctx);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Must hold Alt key to START a new connection, to prevent conflict with dragging
|
|
124
|
+
if (!e.altKey) return;
|
|
125
|
+
|
|
126
|
+
// Start a new pending connection
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
const fileName = filePath.split('/').pop();
|
|
129
|
+
pendingConnection = { sourceFile: filePath, sourceLine: lineNum, sourceCard: card };
|
|
130
|
+
|
|
131
|
+
// Visual: highlight the source line
|
|
132
|
+
lineEl.classList.add('connection-source-line');
|
|
133
|
+
card.classList.add('connecting-source');
|
|
134
|
+
|
|
135
|
+
// Show file picker to select target file
|
|
136
|
+
_showTargetFilePicker(ctx, filePath);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _clearPending(ctx: CanvasContext) {
|
|
141
|
+
if (!pendingConnection) return;
|
|
142
|
+
// Remove visual highlights
|
|
143
|
+
pendingConnection.sourceCard.querySelector('.connection-source-line')?.classList.remove('connection-source-line');
|
|
144
|
+
pendingConnection.sourceCard.classList.remove('connecting-source');
|
|
145
|
+
ctx.fileCards.forEach((c) => c.classList.remove('connect-target-ready'));
|
|
146
|
+
pendingConnection = null;
|
|
147
|
+
_hideStatus();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Cancel pending connection (called from Escape key) ──
|
|
151
|
+
export function cancelPendingConnection(ctx: CanvasContext) {
|
|
152
|
+
if (pendingConnection) {
|
|
153
|
+
_clearPending(ctx);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hasPendingConnection(): boolean {
|
|
158
|
+
return pendingConnection !== null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Target file picker for connections ─────────────────
|
|
162
|
+
function _showTargetFilePicker(ctx: CanvasContext, sourceFile: string) {
|
|
163
|
+
// Remove existing picker if any
|
|
164
|
+
document.getElementById('connFilePickerOverlay')?.remove();
|
|
165
|
+
|
|
166
|
+
const overlay = document.createElement('div');
|
|
167
|
+
overlay.id = 'connFilePickerOverlay';
|
|
168
|
+
overlay.className = 'file-search-overlay';
|
|
169
|
+
document.body.appendChild(overlay);
|
|
170
|
+
|
|
171
|
+
const container = document.createElement('div');
|
|
172
|
+
container.className = 'file-search-container';
|
|
173
|
+
|
|
174
|
+
const header = document.createElement('div');
|
|
175
|
+
header.className = 'conn-picker-header';
|
|
176
|
+
const srcName = sourceFile.split('/').pop();
|
|
177
|
+
const srcLine = pendingConnection?.sourceLine || '?';
|
|
178
|
+
header.innerHTML = `<span class="conn-picker-from">Connect from <strong>${escapeHtml(srcName!)}:${srcLine}</strong> → select target file:</span>`;
|
|
179
|
+
container.appendChild(header);
|
|
180
|
+
|
|
181
|
+
const input = document.createElement('input');
|
|
182
|
+
input.type = 'text';
|
|
183
|
+
input.className = 'file-search-input';
|
|
184
|
+
input.placeholder = 'Filter files...';
|
|
185
|
+
input.autocomplete = 'off';
|
|
186
|
+
container.appendChild(input);
|
|
187
|
+
|
|
188
|
+
const resultsContainer = document.createElement('div');
|
|
189
|
+
resultsContainer.className = 'file-search-results';
|
|
190
|
+
container.appendChild(resultsContainer);
|
|
191
|
+
overlay.appendChild(container);
|
|
192
|
+
|
|
193
|
+
// Get all file paths except source
|
|
194
|
+
const allPaths = Array.from(ctx.fileCards.keys()).filter(p => p !== sourceFile);
|
|
195
|
+
let currentQuery = '';
|
|
196
|
+
let selectedIdx = 0;
|
|
197
|
+
|
|
198
|
+
function getMatches() {
|
|
199
|
+
const q = currentQuery.toLowerCase().trim();
|
|
200
|
+
return q ? allPaths.filter(p => p.toLowerCase().includes(q)).slice(0, 15) : allPaths.slice(0, 15);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function selectFile(path: string) {
|
|
204
|
+
overlay.remove();
|
|
205
|
+
// Navigate to the target card
|
|
206
|
+
const targetCard = ctx.fileCards.get(path);
|
|
207
|
+
if (!targetCard) return;
|
|
208
|
+
|
|
209
|
+
// Scroll viewport to center on target card
|
|
210
|
+
const vpRect = ctx.canvasViewport.getBoundingClientRect();
|
|
211
|
+
const state = ctx.snap().context;
|
|
212
|
+
const cardX = parseFloat(targetCard.style.left) || 0;
|
|
213
|
+
const cardY = parseFloat(targetCard.style.top) || 0;
|
|
214
|
+
const newOffsetX = -(cardX + targetCard.offsetWidth / 2) * state.zoom + vpRect.width / 2;
|
|
215
|
+
const newOffsetY = -(cardY + targetCard.offsetHeight / 2) * state.zoom + vpRect.height / 2;
|
|
216
|
+
ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
|
|
217
|
+
updateCanvasTransform(ctx);
|
|
218
|
+
|
|
219
|
+
// Highlight target card
|
|
220
|
+
targetCard.classList.add('connect-target-ready');
|
|
221
|
+
targetCard.classList.add('card-flash');
|
|
222
|
+
setTimeout(() => targetCard.classList.remove('card-flash'), 1500);
|
|
223
|
+
|
|
224
|
+
const tgtName = path.split('/').pop();
|
|
225
|
+
_showStatus(`Click a line in ${tgtName} to complete connection`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function close() {
|
|
229
|
+
overlay.remove();
|
|
230
|
+
_clearPending(ctx);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function renderResults() {
|
|
234
|
+
const matches = getMatches();
|
|
235
|
+
const q = currentQuery.toLowerCase().trim();
|
|
236
|
+
if (matches.length === 0 && q) {
|
|
237
|
+
resultsContainer.innerHTML = `<div class="file-search-empty">No files matching "${escapeHtml(q)}"</div>`;
|
|
238
|
+
} else {
|
|
239
|
+
resultsContainer.innerHTML = matches.map((path, i) => {
|
|
240
|
+
const name = path.split('/').pop() || path;
|
|
241
|
+
const dir = path.substring(0, path.length - name.length);
|
|
242
|
+
return `<div class="file-search-item ${i === selectedIdx ? 'selected' : ''}" data-path="${escapeHtml(path)}">
|
|
243
|
+
<span class="search-file-dir">${escapeHtml(dir)}</span><span class="search-file-name">${escapeHtml(name)}</span>
|
|
244
|
+
</div>`;
|
|
245
|
+
}).join('');
|
|
246
|
+
resultsContainer.querySelectorAll('.file-search-item').forEach(el => {
|
|
247
|
+
el.addEventListener('click', () => selectFile((el as HTMLElement).dataset.path!));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
input.addEventListener('input', () => {
|
|
253
|
+
currentQuery = input.value;
|
|
254
|
+
selectedIdx = 0;
|
|
255
|
+
renderResults();
|
|
256
|
+
});
|
|
257
|
+
input.addEventListener('keydown', (e) => {
|
|
258
|
+
const matches = getMatches();
|
|
259
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, matches.length - 1); renderResults(); }
|
|
260
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); renderResults(); }
|
|
261
|
+
else if (e.key === 'Enter') { e.preventDefault(); if (matches[selectedIdx]) selectFile(matches[selectedIdx]); }
|
|
262
|
+
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
263
|
+
});
|
|
264
|
+
overlay.addEventListener('click', (e) => {
|
|
265
|
+
if ((e.target as HTMLElement) === overlay) close();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
renderResults();
|
|
269
|
+
requestAnimationFrame(() => input.focus());
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Build connection marker strips (LEFT side of cards) ─
|
|
273
|
+
export function buildConnectionMarkers(ctx: CanvasContext) {
|
|
274
|
+
const state = ctx.snap().context;
|
|
275
|
+
const connections = state.connections || [];
|
|
276
|
+
|
|
277
|
+
// First clean up all existing markers
|
|
278
|
+
ctx.fileCards.forEach((card) => {
|
|
279
|
+
card.querySelectorAll('.conn-marker-strip').forEach(el => el.remove());
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (connections.length === 0) return;
|
|
283
|
+
|
|
284
|
+
// Group connections by file
|
|
285
|
+
const connsByFile = new Map<string, Array<{ line: number; conn: any; role: 'source' | 'target' }>>();
|
|
286
|
+
|
|
287
|
+
connections.forEach(conn => {
|
|
288
|
+
// Source side
|
|
289
|
+
if (!connsByFile.has(conn.sourceFile)) connsByFile.set(conn.sourceFile, []);
|
|
290
|
+
connsByFile.get(conn.sourceFile)!.push({
|
|
291
|
+
line: conn.sourceLineStart,
|
|
292
|
+
conn,
|
|
293
|
+
role: 'source',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Target side
|
|
297
|
+
if (!connsByFile.has(conn.targetFile)) connsByFile.set(conn.targetFile, []);
|
|
298
|
+
connsByFile.get(conn.targetFile)!.push({
|
|
299
|
+
line: conn.targetLineStart,
|
|
300
|
+
conn,
|
|
301
|
+
role: 'target',
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Build marker strip for each file card
|
|
306
|
+
connsByFile.forEach((markers, filePath) => {
|
|
307
|
+
const card = ctx.fileCards.get(filePath);
|
|
308
|
+
if (!card) return;
|
|
309
|
+
|
|
310
|
+
const body = card.querySelector('.file-card-body') as HTMLElement;
|
|
311
|
+
if (!body) return;
|
|
312
|
+
|
|
313
|
+
const pre = body.querySelector('pre') as HTMLElement;
|
|
314
|
+
if (!pre) return;
|
|
315
|
+
|
|
316
|
+
// Make the pre position:relative so absolute markers inside it scroll with content
|
|
317
|
+
if (getComputedStyle(pre).position === 'static') {
|
|
318
|
+
pre.style.position = 'relative';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const strip = document.createElement('div');
|
|
322
|
+
strip.className = 'conn-marker-strip';
|
|
323
|
+
|
|
324
|
+
markers.forEach(({ line, conn, role }) => {
|
|
325
|
+
const marker = document.createElement('div');
|
|
326
|
+
marker.className = `conn-marker conn-marker--${role}`;
|
|
327
|
+
|
|
328
|
+
// Try to find the actual line element and use its offsetTop
|
|
329
|
+
const lineEl = pre.querySelector(`.diff-line[data-line="${line}"]`) as HTMLElement;
|
|
330
|
+
if (lineEl) {
|
|
331
|
+
// Position marker at the line element's vertical position
|
|
332
|
+
marker.style.top = `${lineEl.offsetTop + lineEl.offsetHeight / 2}px`;
|
|
333
|
+
} else {
|
|
334
|
+
// Fallback: estimate from total lines
|
|
335
|
+
const totalLines = pre.querySelectorAll('.diff-line').length || 1;
|
|
336
|
+
const lineH = pre.scrollHeight / totalLines;
|
|
337
|
+
marker.style.top = `${(line - 1) * lineH + lineH / 2}px`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const otherFile = role === 'source' ? conn.targetFile : conn.sourceFile;
|
|
341
|
+
const otherLine = role === 'source' ? conn.targetLineStart : conn.sourceLineStart;
|
|
342
|
+
const otherName = otherFile.split('/').pop();
|
|
343
|
+
marker.title = `${role === 'source' ? '→' : '←'} ${otherName}:${otherLine}`;
|
|
344
|
+
|
|
345
|
+
marker.addEventListener('click', (e) => {
|
|
346
|
+
e.stopPropagation();
|
|
347
|
+
navigateToConnection(ctx, conn, role === 'source' ? 'target' : 'source');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Right-click to delete
|
|
351
|
+
marker.addEventListener('contextmenu', (e) => {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
e.stopPropagation();
|
|
354
|
+
deleteConnection(ctx, conn.id);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
strip.appendChild(marker);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Append strip INSIDE the pre element so it scrolls with content
|
|
361
|
+
pre.appendChild(strip);
|
|
362
|
+
|
|
363
|
+
// ── Connection navigation in the file-path bar ──
|
|
364
|
+
const filePathEl = body.querySelector('.file-path') as HTMLElement;
|
|
365
|
+
if (filePathEl && markers.length > 0) {
|
|
366
|
+
// Remove any previously injected connection nav
|
|
367
|
+
filePathEl.querySelectorAll('.conn-nav-inline').forEach(e => e.remove());
|
|
368
|
+
|
|
369
|
+
// Ensure file-path is flex (might have been set by diff nav already)
|
|
370
|
+
filePathEl.style.display = 'flex';
|
|
371
|
+
filePathEl.style.alignItems = 'center';
|
|
372
|
+
filePathEl.style.justifyContent = 'space-between';
|
|
373
|
+
|
|
374
|
+
// If the path text wasn't already wrapped in a span (by diff nav), wrap it
|
|
375
|
+
if (!filePathEl.querySelector('.file-path-text')) {
|
|
376
|
+
const existingText = filePathEl.childNodes[0];
|
|
377
|
+
if (existingText && existingText.nodeType === Node.TEXT_NODE) {
|
|
378
|
+
const pathSpan = document.createElement('span');
|
|
379
|
+
pathSpan.className = 'file-path-text';
|
|
380
|
+
pathSpan.textContent = existingText.textContent || '';
|
|
381
|
+
pathSpan.style.overflow = 'hidden';
|
|
382
|
+
pathSpan.style.textOverflow = 'ellipsis';
|
|
383
|
+
filePathEl.replaceChild(pathSpan, existingText);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let connIdx = -1;
|
|
388
|
+
const sorted = [...markers].sort((a, b) => a.line - b.line);
|
|
389
|
+
|
|
390
|
+
const connNav = document.createElement('span');
|
|
391
|
+
connNav.className = 'conn-nav-inline';
|
|
392
|
+
connNav.title = `${sorted.length} connection${sorted.length > 1 ? 's' : ''}`;
|
|
393
|
+
|
|
394
|
+
const connLabel = document.createElement('span');
|
|
395
|
+
connLabel.className = 'conn-nav-label';
|
|
396
|
+
connLabel.textContent = `🔗${sorted.length}`;
|
|
397
|
+
|
|
398
|
+
const connPrev = document.createElement('button');
|
|
399
|
+
connPrev.className = 'conn-nav-btn';
|
|
400
|
+
connPrev.textContent = '◀';
|
|
401
|
+
connPrev.title = 'Previous connection';
|
|
402
|
+
connPrev.addEventListener('click', (e) => {
|
|
403
|
+
e.stopPropagation();
|
|
404
|
+
if (connIdx <= 0) connIdx = sorted.length - 1;
|
|
405
|
+
else connIdx--;
|
|
406
|
+
const m = sorted[connIdx];
|
|
407
|
+
navigateToConnection(ctx, m.conn, m.role === 'source' ? 'target' : 'source');
|
|
408
|
+
connLabel.textContent = `🔗${connIdx + 1}/${sorted.length}`;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const connNext = document.createElement('button');
|
|
412
|
+
connNext.className = 'conn-nav-btn';
|
|
413
|
+
connNext.textContent = '▶';
|
|
414
|
+
connNext.title = 'Next connection';
|
|
415
|
+
connNext.addEventListener('click', (e) => {
|
|
416
|
+
e.stopPropagation();
|
|
417
|
+
if (connIdx >= sorted.length - 1) connIdx = 0;
|
|
418
|
+
else connIdx++;
|
|
419
|
+
const m = sorted[connIdx];
|
|
420
|
+
navigateToConnection(ctx, m.conn, m.role === 'source' ? 'target' : 'source');
|
|
421
|
+
connLabel.textContent = `🔗${connIdx + 1}/${sorted.length}`;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
connNav.appendChild(connPrev);
|
|
425
|
+
connNav.appendChild(connLabel);
|
|
426
|
+
connNav.appendChild(connNext);
|
|
427
|
+
filePathEl.appendChild(connNav);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── SVG defs (gradients, filters) ──────────────────────
|
|
433
|
+
function _ensureSvgDefs(svg: SVGSVGElement) {
|
|
434
|
+
if (svg.querySelector('defs#conn-defs')) return;
|
|
435
|
+
|
|
436
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
437
|
+
defs.id = 'conn-defs';
|
|
438
|
+
|
|
439
|
+
// Connection gradient
|
|
440
|
+
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
|
|
441
|
+
grad.id = 'conn-gradient';
|
|
442
|
+
grad.setAttribute('gradientUnits', 'userSpaceOnUse');
|
|
443
|
+
|
|
444
|
+
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
|
445
|
+
stop1.setAttribute('offset', '0%');
|
|
446
|
+
stop1.setAttribute('stop-color', '#a78bfa');
|
|
447
|
+
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
|
448
|
+
stop2.setAttribute('offset', '100%');
|
|
449
|
+
stop2.setAttribute('stop-color', '#60a5fa');
|
|
450
|
+
grad.appendChild(stop1);
|
|
451
|
+
grad.appendChild(stop2);
|
|
452
|
+
defs.appendChild(grad);
|
|
453
|
+
|
|
454
|
+
// Glow filter
|
|
455
|
+
const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
|
|
456
|
+
filter.id = 'conn-glow';
|
|
457
|
+
filter.setAttribute('x', '-20%');
|
|
458
|
+
filter.setAttribute('y', '-20%');
|
|
459
|
+
filter.setAttribute('width', '140%');
|
|
460
|
+
filter.setAttribute('height', '140%');
|
|
461
|
+
const blur = document.createElementNS('http://www.w3.org/2000/svg', 'feGaussianBlur');
|
|
462
|
+
blur.setAttribute('stdDeviation', '3');
|
|
463
|
+
blur.setAttribute('result', 'glow');
|
|
464
|
+
const merge = document.createElementNS('http://www.w3.org/2000/svg', 'feMerge');
|
|
465
|
+
const mNode1 = document.createElementNS('http://www.w3.org/2000/svg', 'feMergeNode');
|
|
466
|
+
mNode1.setAttribute('in', 'glow');
|
|
467
|
+
const mNode2 = document.createElementNS('http://www.w3.org/2000/svg', 'feMergeNode');
|
|
468
|
+
mNode2.setAttribute('in', 'SourceGraphic');
|
|
469
|
+
merge.appendChild(mNode1);
|
|
470
|
+
merge.appendChild(mNode2);
|
|
471
|
+
filter.appendChild(blur);
|
|
472
|
+
filter.appendChild(merge);
|
|
473
|
+
defs.appendChild(filter);
|
|
474
|
+
|
|
475
|
+
svg.insertBefore(defs, svg.firstChild);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ─── Zoom-aware LOD helpers ─────────────────────────────
|
|
479
|
+
function _getFileDir(filePath: string): string {
|
|
480
|
+
const parts = filePath.split('/');
|
|
481
|
+
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function _isSameDirectory(fileA: string, fileB: string): boolean {
|
|
485
|
+
return _getFileDir(fileA) === _getFileDir(fileB);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ─── Render all SVG connection lines ────────────────────
|
|
489
|
+
export function renderConnections(ctx: CanvasContext) {
|
|
490
|
+
if (!ctx.svgOverlay) return;
|
|
491
|
+
ctx.svgOverlay.innerHTML = '';
|
|
492
|
+
|
|
493
|
+
const state = ctx.snap().context;
|
|
494
|
+
const connections = state.connections || [];
|
|
495
|
+
if (connections.length === 0) return;
|
|
496
|
+
|
|
497
|
+
_ensureSvgDefs(ctx.svgOverlay);
|
|
498
|
+
|
|
499
|
+
// ── Zoom-aware LOD ──────────────────────────────────
|
|
500
|
+
// At low zoom, connections become visual noise. Apply progressive filtering:
|
|
501
|
+
// zoom < 0.35 → only inter-directory connections, very faded
|
|
502
|
+
// zoom 0.35–0.6 → all connections, same-dir connections fade in
|
|
503
|
+
// zoom > 0.6 → everything at full visibility
|
|
504
|
+
const zoom = state.zoom || 1;
|
|
505
|
+
const FADE_LOW = 0.35; // below this: only cross-dir connections
|
|
506
|
+
const FADE_HIGH = 0.6; // above this: everything fully visible
|
|
507
|
+
const showLabels = zoom > 0.5; // labels unreadable below 50% anyway
|
|
508
|
+
|
|
509
|
+
connections.forEach(conn => {
|
|
510
|
+
const sourceCard = ctx.fileCards.get(conn.sourceFile);
|
|
511
|
+
const targetCard = ctx.fileCards.get(conn.targetFile);
|
|
512
|
+
if (!sourceCard || !targetCard) return;
|
|
513
|
+
|
|
514
|
+
// LOD: filter same-directory connections at low zoom
|
|
515
|
+
const sameDir = _isSameDirectory(conn.sourceFile, conn.targetFile);
|
|
516
|
+
if (sameDir && zoom < FADE_LOW) return; // skip entirely
|
|
517
|
+
|
|
518
|
+
// Compute opacity factor for this connection
|
|
519
|
+
let opacityFactor = 1;
|
|
520
|
+
if (sameDir && zoom < FADE_HIGH) {
|
|
521
|
+
// Smoothly fade same-dir connections between FADE_LOW and FADE_HIGH
|
|
522
|
+
opacityFactor = (zoom - FADE_LOW) / (FADE_HIGH - FADE_LOW);
|
|
523
|
+
opacityFactor = Math.max(0.08, Math.min(1, opacityFactor));
|
|
524
|
+
}
|
|
525
|
+
// All connections get slightly reduced opacity at very low zoom
|
|
526
|
+
if (zoom < FADE_LOW) {
|
|
527
|
+
opacityFactor *= 0.5;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Scale stroke width inversely so lines don't bloat when zoomed out
|
|
531
|
+
const strokeScale = zoom < 1 ? Math.max(0.6, zoom) : 1;
|
|
532
|
+
|
|
533
|
+
// Get canvas-space coordinates for the line endpoints
|
|
534
|
+
const startPt = _getLinePoint(sourceCard, conn.sourceLineStart, 'right');
|
|
535
|
+
const endPt = _getLinePoint(targetCard, conn.targetLineStart, 'left');
|
|
536
|
+
|
|
537
|
+
// Decide curve direction based on card positions
|
|
538
|
+
const goingRight = endPt.x >= startPt.x;
|
|
539
|
+
const dx = Math.abs(endPt.x - startPt.x);
|
|
540
|
+
const dy = Math.abs(endPt.y - startPt.y);
|
|
541
|
+
const ctrlOffset = Math.max(60, Math.min(dx * 0.4, 200));
|
|
542
|
+
|
|
543
|
+
// Smooth Bezier Routing (More robust, avoids overlapping vertical lines)
|
|
544
|
+
let d: string;
|
|
545
|
+
if (goingRight) {
|
|
546
|
+
d = `M ${startPt.x} ${startPt.y} C ${startPt.x + ctrlOffset} ${startPt.y}, ${endPt.x - ctrlOffset} ${endPt.y}, ${endPt.x} ${endPt.y}`;
|
|
547
|
+
} else {
|
|
548
|
+
// Cards overlap or wrong order — route elegantly above them using smooth curves
|
|
549
|
+
const arcHeight = Math.max(120, dy * 0.4);
|
|
550
|
+
const topY = Math.min(startPt.y, endPt.y) - arcHeight;
|
|
551
|
+
const midX = (startPt.x + endPt.x) / 2;
|
|
552
|
+
d = `M ${startPt.x} ${startPt.y} C ${startPt.x + ctrlOffset} ${startPt.y}, ${startPt.x + ctrlOffset} ${topY}, ${midX} ${topY} C ${endPt.x - ctrlOffset} ${topY}, ${endPt.x - ctrlOffset} ${endPt.y}, ${endPt.x} ${endPt.y}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Connection group
|
|
556
|
+
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
557
|
+
group.classList.add('conn-group');
|
|
558
|
+
group.dataset.connId = conn.id;
|
|
559
|
+
|
|
560
|
+
// Glow path (underneath)
|
|
561
|
+
const glowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
562
|
+
glowPath.setAttribute('d', d);
|
|
563
|
+
glowPath.setAttribute('stroke', '#a78bfa');
|
|
564
|
+
glowPath.setAttribute('stroke-width', String(6 * strokeScale));
|
|
565
|
+
glowPath.setAttribute('fill', 'none');
|
|
566
|
+
glowPath.setAttribute('stroke-linejoin', 'round');
|
|
567
|
+
glowPath.setAttribute('opacity', '0');
|
|
568
|
+
glowPath.classList.add('conn-glow-path');
|
|
569
|
+
group.appendChild(glowPath);
|
|
570
|
+
|
|
571
|
+
// Main path
|
|
572
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
573
|
+
path.setAttribute('d', d);
|
|
574
|
+
path.setAttribute('stroke', 'url(#conn-gradient)');
|
|
575
|
+
path.setAttribute('stroke-width', String(2.5 * strokeScale));
|
|
576
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
577
|
+
path.setAttribute('fill', 'none');
|
|
578
|
+
path.setAttribute('opacity', String(0.25 * opacityFactor));
|
|
579
|
+
path.classList.add('conn-main-path');
|
|
580
|
+
path.style.pointerEvents = 'none';
|
|
581
|
+
|
|
582
|
+
// Animated dash — scale dash pattern with zoom
|
|
583
|
+
const dashScale = Math.max(0.5, strokeScale);
|
|
584
|
+
path.setAttribute('stroke-dasharray', `${8 * dashScale} ${4 * dashScale}`);
|
|
585
|
+
|
|
586
|
+
group.appendChild(path);
|
|
587
|
+
|
|
588
|
+
// Endpoint circles — scale radius with zoom
|
|
589
|
+
const endpointRadius = Math.max(3, 5 * strokeScale);
|
|
590
|
+
const srcCircle = _makeEndpoint(startPt.x, startPt.y, '#a78bfa', endpointRadius, opacityFactor);
|
|
591
|
+
const tgtCircle = _makeEndpoint(endPt.x, endPt.y, '#60a5fa', endpointRadius, opacityFactor);
|
|
592
|
+
group.appendChild(srcCircle);
|
|
593
|
+
group.appendChild(tgtCircle);
|
|
594
|
+
|
|
595
|
+
// Label badge at midpoint (skip at low zoom — unreadable)
|
|
596
|
+
if (showLabels) {
|
|
597
|
+
const midX = (startPt.x + endPt.x) / 2;
|
|
598
|
+
const midY = (startPt.y + endPt.y) / 2 - (goingRight ? 0 : ctrlOffset * 0.3);
|
|
599
|
+
|
|
600
|
+
const srcName = conn.sourceFile.split('/').pop() || '';
|
|
601
|
+
const tgtName = conn.targetFile.split('/').pop() || '';
|
|
602
|
+
const labelText = `${srcName}:${conn.sourceLineStart} → ${tgtName}:${conn.targetLineStart}`;
|
|
603
|
+
|
|
604
|
+
const { group: labelGroup, deleteGroup } = _makeLabel(midX, midY, labelText);
|
|
605
|
+
labelGroup.classList.add('conn-label');
|
|
606
|
+
group.appendChild(labelGroup);
|
|
607
|
+
|
|
608
|
+
// Delete button logic
|
|
609
|
+
deleteGroup.addEventListener('click', (e) => {
|
|
610
|
+
e.preventDefault();
|
|
611
|
+
e.stopPropagation();
|
|
612
|
+
deleteConnection(ctx, conn.id);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Navigate on circle click
|
|
617
|
+
srcCircle.addEventListener('click', (e) => {
|
|
618
|
+
e.stopPropagation();
|
|
619
|
+
navigateToConnection(ctx, conn, 'source');
|
|
620
|
+
});
|
|
621
|
+
tgtCircle.addEventListener('click', (e) => {
|
|
622
|
+
e.stopPropagation();
|
|
623
|
+
navigateToConnection(ctx, conn, 'target');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Right-click circles → delete
|
|
627
|
+
srcCircle.addEventListener('contextmenu', (e) => {
|
|
628
|
+
e.preventDefault();
|
|
629
|
+
e.stopPropagation();
|
|
630
|
+
deleteConnection(ctx, conn.id);
|
|
631
|
+
});
|
|
632
|
+
tgtCircle.addEventListener('contextmenu', (e) => {
|
|
633
|
+
e.preventDefault();
|
|
634
|
+
e.stopPropagation();
|
|
635
|
+
deleteConnection(ctx, conn.id);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
ctx.svgOverlay.appendChild(group);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function _makeEndpoint(x: number, y: number, color: string, radius = 5, opacityFactor = 1): SVGCircleElement {
|
|
643
|
+
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
644
|
+
circle.setAttribute('cx', String(x));
|
|
645
|
+
circle.setAttribute('cy', String(y));
|
|
646
|
+
circle.setAttribute('r', String(radius));
|
|
647
|
+
circle.setAttribute('fill', color);
|
|
648
|
+
circle.setAttribute('stroke', 'rgba(0,0,0,0.5)');
|
|
649
|
+
circle.setAttribute('stroke-width', '1');
|
|
650
|
+
if (opacityFactor < 1) circle.setAttribute('opacity', String(opacityFactor));
|
|
651
|
+
circle.style.transition = 'r 0.15s ease';
|
|
652
|
+
circle.style.pointerEvents = 'none';
|
|
653
|
+
return circle;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function _makeLabel(x: number, y: number, text: string): { group: SVGGElement, deleteGroup: SVGGElement } {
|
|
657
|
+
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
658
|
+
group.style.opacity = '0.85';
|
|
659
|
+
group.style.transition = 'opacity 0.15s ease';
|
|
660
|
+
group.style.pointerEvents = 'none';
|
|
661
|
+
|
|
662
|
+
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
663
|
+
textEl.setAttribute('x', String(x));
|
|
664
|
+
textEl.setAttribute('y', String(y));
|
|
665
|
+
textEl.setAttribute('text-anchor', 'middle');
|
|
666
|
+
textEl.setAttribute('alignment-baseline', 'middle');
|
|
667
|
+
textEl.setAttribute('fill', '#e0e0f0');
|
|
668
|
+
textEl.setAttribute('font-size', '10');
|
|
669
|
+
textEl.setAttribute('font-family', "'JetBrains Mono', 'Fira Code', monospace");
|
|
670
|
+
textEl.setAttribute('font-weight', '500');
|
|
671
|
+
textEl.textContent = text;
|
|
672
|
+
|
|
673
|
+
// Background rect — sized by text length estimate
|
|
674
|
+
const padding = 8;
|
|
675
|
+
const charW = 6.2;
|
|
676
|
+
const textW = text.length * charW;
|
|
677
|
+
const w = textW + padding * 2 + 20; // Extra 20px for the X button
|
|
678
|
+
const h = 20;
|
|
679
|
+
|
|
680
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
681
|
+
rect.setAttribute('x', String(x - w / 2));
|
|
682
|
+
rect.setAttribute('y', String(y - h / 2));
|
|
683
|
+
rect.setAttribute('width', String(w));
|
|
684
|
+
rect.setAttribute('height', String(h));
|
|
685
|
+
rect.setAttribute('rx', '6');
|
|
686
|
+
rect.setAttribute('fill', 'rgba(20, 15, 40, 0.92)');
|
|
687
|
+
rect.setAttribute('stroke', 'rgba(167, 139, 250, 0.3)');
|
|
688
|
+
rect.setAttribute('stroke-width', '1');
|
|
689
|
+
|
|
690
|
+
group.appendChild(rect);
|
|
691
|
+
group.appendChild(textEl);
|
|
692
|
+
|
|
693
|
+
// X / Delete button group
|
|
694
|
+
const deleteGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
695
|
+
const delX = x + textW / 2 + 6;
|
|
696
|
+
|
|
697
|
+
const delRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
698
|
+
delRect.setAttribute('x', String(delX));
|
|
699
|
+
delRect.setAttribute('y', String(y - h / 2 + 3));
|
|
700
|
+
delRect.setAttribute('width', '14');
|
|
701
|
+
delRect.setAttribute('height', '14');
|
|
702
|
+
delRect.setAttribute('rx', '3');
|
|
703
|
+
delRect.setAttribute('fill', 'rgba(239, 68, 68, 0.15)');
|
|
704
|
+
delRect.style.cursor = 'pointer';
|
|
705
|
+
|
|
706
|
+
const delText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
707
|
+
delText.setAttribute('x', String(delX + 7)); // center of 14px rect
|
|
708
|
+
delText.setAttribute('y', String(y));
|
|
709
|
+
delText.setAttribute('text-anchor', 'middle');
|
|
710
|
+
delText.setAttribute('alignment-baseline', 'middle');
|
|
711
|
+
delText.setAttribute('fill', '#ef4444');
|
|
712
|
+
delText.setAttribute('font-size', '10');
|
|
713
|
+
delText.setAttribute('font-family', 'sans-serif');
|
|
714
|
+
delText.setAttribute('font-weight', 'bold');
|
|
715
|
+
delText.textContent = '×';
|
|
716
|
+
delText.style.pointerEvents = 'none';
|
|
717
|
+
|
|
718
|
+
deleteGroup.appendChild(delRect);
|
|
719
|
+
deleteGroup.appendChild(delText);
|
|
720
|
+
|
|
721
|
+
// Delete hover effect
|
|
722
|
+
deleteGroup.addEventListener('mouseenter', () => delRect.setAttribute('fill', 'rgba(239, 68, 68, 0.4)'));
|
|
723
|
+
deleteGroup.addEventListener('mouseleave', () => delRect.setAttribute('fill', 'rgba(239, 68, 68, 0.15)'));
|
|
724
|
+
|
|
725
|
+
group.appendChild(deleteGroup);
|
|
726
|
+
|
|
727
|
+
return { group, deleteGroup };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function _estimatePathLength(p1: { x: number, y: number }, p2: { x: number, y: number }): number {
|
|
731
|
+
const dx = p2.x - p1.x;
|
|
732
|
+
const dy = p2.y - p1.y;
|
|
733
|
+
return Math.sqrt(dx * dx + dy * dy) * 1.3; // rough bezier estimate
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function _getLinePoint(card: HTMLElement, lineNum: number, side: 'left' | 'right'): { x: number; y: number } {
|
|
737
|
+
const cardX = parseFloat(card.style.left) || 0;
|
|
738
|
+
const cardY = parseFloat(card.style.top) || 0;
|
|
739
|
+
const cardW = card.offsetWidth;
|
|
740
|
+
const cardH = card.offsetHeight;
|
|
741
|
+
|
|
742
|
+
// Try to find the specific line element
|
|
743
|
+
const lineEl = card.querySelector(`.diff-line[data-line="${lineNum}"]`) as HTMLElement;
|
|
744
|
+
const body = card.querySelector('.file-card-body') as HTMLElement;
|
|
745
|
+
|
|
746
|
+
if (lineEl && body) {
|
|
747
|
+
const pre = body.querySelector('pre') as HTMLElement;
|
|
748
|
+
|
|
749
|
+
// Line's position within the pre/body content
|
|
750
|
+
// offsetTop is relative to offsetParent (pre if position:relative, otherwise body)
|
|
751
|
+
const lineOffsetInContent = lineEl.offsetTop;
|
|
752
|
+
|
|
753
|
+
// How much the body has scrolled
|
|
754
|
+
const scrollTop = body.scrollTop;
|
|
755
|
+
|
|
756
|
+
// The body's top edge relative to the card
|
|
757
|
+
// (header height + any other elements above body)
|
|
758
|
+
const bodyTopInCard = body.offsetTop;
|
|
759
|
+
|
|
760
|
+
// The pre's top edge relative to body (accounts for file-path bar)
|
|
761
|
+
const preTopInBody = pre ? pre.offsetTop : 0;
|
|
762
|
+
|
|
763
|
+
// Final Y: card position + body offset + pre offset + line position - scroll + half line height
|
|
764
|
+
const y = cardY + bodyTopInCard + preTopInBody + lineOffsetInContent - scrollTop + lineEl.offsetHeight / 2;
|
|
765
|
+
const x = side === 'left' ? cardX : cardX + cardW;
|
|
766
|
+
|
|
767
|
+
// Clamp y to be within visible card bounds
|
|
768
|
+
return {
|
|
769
|
+
x,
|
|
770
|
+
y: Math.max(cardY + bodyTopInCard, Math.min(cardY + cardH - 5, y)),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Fallback: estimate from line number
|
|
775
|
+
const totalLines = card.querySelectorAll('.diff-line').length || 100;
|
|
776
|
+
const pct = lineNum / totalLines;
|
|
777
|
+
const headerH = 36;
|
|
778
|
+
const bodyH = cardH - headerH;
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
x: side === 'left' ? cardX : cardX + cardW,
|
|
782
|
+
y: cardY + headerH + pct * bodyH,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ─── Navigate to connection endpoint ────────────────────
|
|
787
|
+
export function navigateToConnection(ctx: CanvasContext, conn: any, navigateTo: 'source' | 'target' = 'target') {
|
|
788
|
+
measure('connection:navigate', () => {
|
|
789
|
+
const file = navigateTo === 'target' ? conn.targetFile : conn.sourceFile;
|
|
790
|
+
const line = navigateTo === 'target' ? conn.targetLineStart : conn.sourceLineStart;
|
|
791
|
+
const targetCard = ctx.fileCards.get(file);
|
|
792
|
+
if (!targetCard) return;
|
|
793
|
+
|
|
794
|
+
// Pan canvas to center on the target card
|
|
795
|
+
const vpRect = ctx.canvasViewport.getBoundingClientRect();
|
|
796
|
+
const state = ctx.snap().context;
|
|
797
|
+
const cardX = parseFloat(targetCard.style.left) || 0;
|
|
798
|
+
const cardY = parseFloat(targetCard.style.top) || 0;
|
|
799
|
+
const newOffsetX = -(cardX + targetCard.offsetWidth / 2) * state.zoom + vpRect.width / 2;
|
|
800
|
+
const newOffsetY = -(cardY + targetCard.offsetHeight / 2) * state.zoom + vpRect.height / 2;
|
|
801
|
+
|
|
802
|
+
ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
|
|
803
|
+
updateCanvasTransform(ctx);
|
|
804
|
+
|
|
805
|
+
// Scroll to target line inside the card
|
|
806
|
+
const body = targetCard.querySelector('.file-card-body') as HTMLElement;
|
|
807
|
+
const lineEl = targetCard.querySelector(`.diff-line[data-line="${line}"]`) as HTMLElement;
|
|
808
|
+
if (body && lineEl) {
|
|
809
|
+
const pre = body.querySelector('pre') as HTMLElement || body;
|
|
810
|
+
const preRect = pre.getBoundingClientRect();
|
|
811
|
+
const lineRect = lineEl.getBoundingClientRect();
|
|
812
|
+
const zoom = preRect.height / pre.clientHeight || 1;
|
|
813
|
+
pre.scrollTop += (lineRect.top - preRect.top) / zoom;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Flash the target line
|
|
817
|
+
targetCard.querySelectorAll('.diff-line').forEach(l => {
|
|
818
|
+
const ln = parseInt((l as HTMLElement).dataset.line);
|
|
819
|
+
if (ln === line) {
|
|
820
|
+
l.classList.add('line-flash');
|
|
821
|
+
setTimeout(() => l.classList.remove('line-flash'), 1500);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ─── Save connections to server ─────────────────────────
|
|
828
|
+
export async function saveConnections(ctx: CanvasContext) {
|
|
829
|
+
const state = ctx.snap().context;
|
|
830
|
+
try {
|
|
831
|
+
await fetch('/api/connections', {
|
|
832
|
+
method: 'POST',
|
|
833
|
+
headers: { 'Content-Type': 'application/json' },
|
|
834
|
+
body: JSON.stringify({ connections: state.connections })
|
|
835
|
+
});
|
|
836
|
+
} catch (e) {
|
|
837
|
+
measure('connections:saveError', () => e);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ─── Load connections from server ───────────────────────
|
|
842
|
+
export async function loadConnections(ctx: CanvasContext) {
|
|
843
|
+
return measure('connections:load', async () => {
|
|
844
|
+
try {
|
|
845
|
+
const response = await fetch('/api/connections');
|
|
846
|
+
if (!response.ok) return;
|
|
847
|
+
const data = await response.json();
|
|
848
|
+
|
|
849
|
+
if (data.connections && data.connections.length > 0) {
|
|
850
|
+
const conns = data.connections.map(c => ({
|
|
851
|
+
id: c.conn_id,
|
|
852
|
+
sourceFile: c.source_file,
|
|
853
|
+
sourceLineStart: c.source_line_start,
|
|
854
|
+
sourceLineEnd: c.source_line_end,
|
|
855
|
+
targetFile: c.target_file,
|
|
856
|
+
targetLineStart: c.target_line_start,
|
|
857
|
+
targetLineEnd: c.target_line_end,
|
|
858
|
+
comment: c.comment || '',
|
|
859
|
+
}));
|
|
860
|
+
|
|
861
|
+
conns.forEach(conn => {
|
|
862
|
+
ctx.actor.send({
|
|
863
|
+
type: 'START_CONNECTION',
|
|
864
|
+
sourceFile: conn.sourceFile,
|
|
865
|
+
lineStart: conn.sourceLineStart,
|
|
866
|
+
lineEnd: conn.sourceLineEnd,
|
|
867
|
+
});
|
|
868
|
+
ctx.actor.send({
|
|
869
|
+
type: 'COMPLETE_CONNECTION',
|
|
870
|
+
targetFile: conn.targetFile,
|
|
871
|
+
lineStart: conn.targetLineStart,
|
|
872
|
+
lineEnd: conn.targetLineEnd,
|
|
873
|
+
comment: conn.comment,
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
renderConnections(ctx);
|
|
878
|
+
buildConnectionMarkers(ctx);
|
|
879
|
+
}
|
|
880
|
+
} catch (e) {
|
|
881
|
+
measure('connections:loadError', () => e);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ─── Delete a connection ────────────────────────────────
|
|
887
|
+
export function deleteConnection(ctx: CanvasContext, connId: string) {
|
|
888
|
+
ctx.actor.send({ type: 'DELETE_CONNECTION', id: connId });
|
|
889
|
+
renderConnections(ctx);
|
|
890
|
+
buildConnectionMarkers(ctx);
|
|
891
|
+
saveConnections(ctx);
|
|
892
|
+
populateConnectionsList();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ─── Populate connections UI list ───────────────────────
|
|
896
|
+
let _cachedCtxForConnections: CanvasContext | null = null;
|
|
897
|
+
export function populateConnectionsList(ctx?: CanvasContext) {
|
|
898
|
+
if (ctx) _cachedCtxForConnections = ctx;
|
|
899
|
+
const currentCtx = ctx || _cachedCtxForConnections;
|
|
900
|
+
if (!currentCtx) return;
|
|
901
|
+
|
|
902
|
+
const listEl = document.getElementById('connectionsList');
|
|
903
|
+
const panel = document.getElementById('connectionsPanel');
|
|
904
|
+
const countEl = document.getElementById('connCount');
|
|
905
|
+
if (!listEl || !panel) return;
|
|
906
|
+
|
|
907
|
+
const connections = currentCtx.snap().context.connections || [];
|
|
908
|
+
if (countEl) countEl.textContent = String(connections.length);
|
|
909
|
+
|
|
910
|
+
if (connections.length === 0) {
|
|
911
|
+
render(<div style="padding: 16px; text-align: center; color: var(--text-muted); font-size: 0.8rem;">No connections</div>, listEl);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
render(
|
|
916
|
+
<div style="display: flex; flex-direction: column; gap: 4px; padding: 8px;">
|
|
917
|
+
{connections.map(conn => (
|
|
918
|
+
<div key={conn.id} className="changed-file-item" style="justify-content: space-between;">
|
|
919
|
+
<div style="display: flex; flex-direction: column; flex: 1; overflow: hidden; margin-right: 8px;" onClick={() => navigateToConnection(currentCtx, conn, 'target')}>
|
|
920
|
+
<span style="font-size: 0.72rem; color: var(--accent-tertiary); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
|
921
|
+
{conn.sourceFile.split('/').pop()}:{conn.sourceLineStart} → {conn.targetFile.split('/').pop()}:{conn.targetLineStart}
|
|
922
|
+
</span>
|
|
923
|
+
</div>
|
|
924
|
+
<button className="btn-ghost btn-xs" style="color: var(--error);" onClick={(e) => { e.stopPropagation(); deleteConnection(currentCtx, conn.id); }} title="Delete Connection">
|
|
925
|
+
✕
|
|
926
|
+
</button>
|
|
927
|
+
</div>
|
|
928
|
+
))}
|
|
929
|
+
</div>,
|
|
930
|
+
listEl
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ─── Legacy compat: setupConnectionDrag (now no-op) ─────
|
|
935
|
+
export function setupConnectionDrag(ctx: CanvasContext, card: HTMLElement, filePath: string) {
|
|
936
|
+
// Replaced by setupLineClickConnection
|
|
937
|
+
setupLineClickConnection(ctx, card, filePath);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ─── Start connection from context menu ─────────────────
|
|
941
|
+
export function startConnectionFrom(ctx: CanvasContext, filePath: string) {
|
|
942
|
+
const card = ctx.fileCards.get(filePath);
|
|
943
|
+
if (!card) return;
|
|
944
|
+
|
|
945
|
+
// Set pending connection from line 1 of the file
|
|
946
|
+
pendingConnection = { sourceFile: filePath, sourceLine: 1, sourceCard: card };
|
|
947
|
+
card.classList.add('connecting-source');
|
|
948
|
+
|
|
949
|
+
// Open the target file picker
|
|
950
|
+
_showTargetFilePicker(ctx, filePath);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─── Auto-detect import connections ─────────────────────
|
|
954
|
+
export async function autoDetectImports(ctx: CanvasContext) {
|
|
955
|
+
return measure('connections:autoDetect', async () => {
|
|
956
|
+
const state = ctx.snap().context;
|
|
957
|
+
const repoPath = state.repoPath;
|
|
958
|
+
const commit = state.currentCommitHash || 'HEAD';
|
|
959
|
+
|
|
960
|
+
if (!repoPath) {
|
|
961
|
+
_showStatus('No repo loaded');
|
|
962
|
+
setTimeout(_hideStatus, 2000);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
_showStatus('🔍 Scanning imports...');
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
const res = await fetch('/api/repo/imports', {
|
|
970
|
+
method: 'POST',
|
|
971
|
+
headers: { 'Content-Type': 'application/json' },
|
|
972
|
+
body: JSON.stringify({ path: repoPath, commit }),
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
if (!res.ok) {
|
|
976
|
+
_showStatus('❌ Import scan failed');
|
|
977
|
+
setTimeout(_hideStatus, 2000);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const data = await res.json();
|
|
982
|
+
const edges = data.edges || [];
|
|
983
|
+
|
|
984
|
+
if (edges.length === 0) {
|
|
985
|
+
_showStatus('No imports found');
|
|
986
|
+
setTimeout(_hideStatus, 2000);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Filter to edges where BOTH files are on the canvas
|
|
991
|
+
const canvasFiles = new Set(ctx.fileCards.keys());
|
|
992
|
+
const existingConnections = state.connections || [];
|
|
993
|
+
const existingKeys = new Set(
|
|
994
|
+
existingConnections.map(c => `${c.sourceFile}→${c.targetFile}`)
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
let added = 0;
|
|
998
|
+
for (const edge of edges) {
|
|
999
|
+
if (!canvasFiles.has(edge.source) || !canvasFiles.has(edge.target)) continue;
|
|
1000
|
+
if (existingKeys.has(`${edge.source}→${edge.target}`)) continue;
|
|
1001
|
+
|
|
1002
|
+
const srcName = edge.source.split('/').pop();
|
|
1003
|
+
const tgtName = edge.target.split('/').pop();
|
|
1004
|
+
|
|
1005
|
+
ctx.actor.send({
|
|
1006
|
+
type: 'START_CONNECTION',
|
|
1007
|
+
sourceFile: edge.source,
|
|
1008
|
+
lineStart: edge.line,
|
|
1009
|
+
lineEnd: edge.line,
|
|
1010
|
+
});
|
|
1011
|
+
ctx.actor.send({
|
|
1012
|
+
type: 'COMPLETE_CONNECTION',
|
|
1013
|
+
targetFile: edge.target,
|
|
1014
|
+
lineStart: 1,
|
|
1015
|
+
lineEnd: 1,
|
|
1016
|
+
comment: `${srcName} → ${tgtName}`,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
added++;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (added > 0) {
|
|
1023
|
+
renderConnections(ctx);
|
|
1024
|
+
buildConnectionMarkers(ctx);
|
|
1025
|
+
saveConnections(ctx);
|
|
1026
|
+
_showStatus(`✅ ${added} import connections added`);
|
|
1027
|
+
} else {
|
|
1028
|
+
_showStatus('All imports already connected');
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
setTimeout(_hideStatus, 3000);
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
_showStatus('❌ Import scan error');
|
|
1034
|
+
setTimeout(_hideStatus, 2000);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
}
|