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,1747 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Canvas interaction setup + global event listeners.
|
|
4
|
+
*
|
|
5
|
+
* Ported faithfully from the original page.client.tsx monolith.
|
|
6
|
+
* Wheel behavior:
|
|
7
|
+
* Ctrl/Meta + scroll → zoom canvas (always, even over cards)
|
|
8
|
+
* Over scrollable hunk/preview → scroll that pane (Shift = horiz)
|
|
9
|
+
* Space held + scroll → pan canvas
|
|
10
|
+
* Plain scroll (no Space) → no-op
|
|
11
|
+
* Mouse:
|
|
12
|
+
* Space/middle-click/Alt+click → pan
|
|
13
|
+
* Left click on empty canvas → rectangle selection
|
|
14
|
+
* Shift+click → additive selection
|
|
15
|
+
* Keyboard:
|
|
16
|
+
* Space hold → pan mode
|
|
17
|
+
* H/V/G → arrange row/column/grid
|
|
18
|
+
* Ctrl+A → select all
|
|
19
|
+
* Escape → deselect + close modals
|
|
20
|
+
* Delete/Backspace → hide selected
|
|
21
|
+
*/
|
|
22
|
+
import { measure } from 'measure-fn';
|
|
23
|
+
import { render } from 'melina/client';
|
|
24
|
+
import type { CanvasContext } from './context';
|
|
25
|
+
import { showToast, escapeHtml } from './utils';
|
|
26
|
+
import { createLayer, getActiveLayer, addSectionToLayer } from './layers';
|
|
27
|
+
import { updateCanvasTransform, updateZoomUI, updateMinimap, fitAllFiles, setupMinimapClick } from './canvas';
|
|
28
|
+
import { zoomTowardScreen, panByDelta, screenToWorld, getCardManager } from './galaxydraw-bridge';
|
|
29
|
+
import { hideSelectedFiles, showHiddenFilesModal as showHiddenModal } from './hidden-files';
|
|
30
|
+
import { updatePillSelectionHighlights } from './viewport-culling';
|
|
31
|
+
import { clearSelectionHighlights, updateSelectionHighlights, updateArrangeToolbar, arrangeRow, arrangeColumn, arrangeGrid, toggleCardExpand, fitScreenSize, changeCardsFontSize } from './cards';
|
|
32
|
+
import { loadRepository, rerenderCurrentView, selectCommit } from './repo';
|
|
33
|
+
import { toggleCanvasChat } from './chat';
|
|
34
|
+
import { exportCanvasAsPNG, exportViewportAsPNG } from './canvas-export';
|
|
35
|
+
import { cancelPendingConnection, hasPendingConnection } from './connections';
|
|
36
|
+
import { promptAddSection } from './layers';
|
|
37
|
+
|
|
38
|
+
// ─── Recent repos helper ────────────────────────────────
|
|
39
|
+
function _addRecentRepo(path: string) {
|
|
40
|
+
const key = 'gitcanvas:recentRepos';
|
|
41
|
+
const recent: string[] = JSON.parse(localStorage.getItem(key) || '[]');
|
|
42
|
+
// Remove if already exists, then prepend
|
|
43
|
+
const filtered = recent.filter(r => r !== path);
|
|
44
|
+
filtered.unshift(path);
|
|
45
|
+
// Keep max 10
|
|
46
|
+
localStorage.setItem(key, JSON.stringify(filtered.slice(0, 10)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _refreshRepoDropdown() {
|
|
50
|
+
const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
51
|
+
if (!repoSel) return;
|
|
52
|
+
const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
|
|
53
|
+
while (repoSel.options.length > 1) repoSel.remove(1);
|
|
54
|
+
updatedRepos.forEach(repo => {
|
|
55
|
+
const opt = document.createElement('option');
|
|
56
|
+
opt.value = repo;
|
|
57
|
+
opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
|
|
58
|
+
opt.title = repo;
|
|
59
|
+
repoSel.add(opt);
|
|
60
|
+
});
|
|
61
|
+
const newOpt = document.createElement('option');
|
|
62
|
+
newOpt.value = '__new__';
|
|
63
|
+
newOpt.textContent = '+ Open new repo...';
|
|
64
|
+
repoSel.add(newOpt);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Canvas interaction (pan/zoom/select) ───────────────
|
|
68
|
+
export function setupCanvasInteraction(ctx: CanvasContext) {
|
|
69
|
+
if (!ctx.canvasViewport) return;
|
|
70
|
+
measure('canvas:setupInteraction', () => {
|
|
71
|
+
let rafPendingPan = false;
|
|
72
|
+
let rafPendingSelect = false;
|
|
73
|
+
|
|
74
|
+
// Delta-based drag state — tracks last mouse position for panByDelta()
|
|
75
|
+
let lastDragX = 0;
|
|
76
|
+
let lastDragY = 0;
|
|
77
|
+
|
|
78
|
+
// ── Wheel behavior ──
|
|
79
|
+
ctx.canvasViewport.addEventListener('wheel', (e) => {
|
|
80
|
+
const state = ctx.snap().context;
|
|
81
|
+
|
|
82
|
+
// Ctrl+scroll = zoom (ALWAYS, even over file cards)
|
|
83
|
+
if (e.ctrlKey || e.metaKey) {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
86
|
+
zoomTowardScreen(ctx, e.clientX, e.clientY, factor);
|
|
87
|
+
updateCanvasTransform(ctx);
|
|
88
|
+
updateZoomUI(ctx);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if hovering over a scrollable pane
|
|
93
|
+
const target = e.target as HTMLElement;
|
|
94
|
+
const hunkPane = target.closest('.hunk-pane, .diff-hunk-body') as HTMLElement | null;
|
|
95
|
+
const previewPre = target.closest('.file-content-preview pre') as HTMLElement | null;
|
|
96
|
+
const cardBody = target.closest('.file-card-body') as HTMLElement | null;
|
|
97
|
+
const scrollContainer = hunkPane || previewPre || cardBody;
|
|
98
|
+
|
|
99
|
+
if (scrollContainer) {
|
|
100
|
+
// Always consume scroll events inside scrollable content
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
|
|
104
|
+
if (e.shiftKey) {
|
|
105
|
+
// Shift+scroll = horizontal scroll within pane
|
|
106
|
+
scrollContainer.scrollLeft += e.deltaY;
|
|
107
|
+
} else {
|
|
108
|
+
// Plain scroll = vertical scroll within pane
|
|
109
|
+
scrollContainer.scrollTop += e.deltaY;
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Canvas behavior when not over scrollable content
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
|
|
117
|
+
// In simple mode: plain scroll = zoom (like WARMAPS)
|
|
118
|
+
if (ctx.controlMode === 'simple') {
|
|
119
|
+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
120
|
+
zoomTowardScreen(ctx, e.clientX, e.clientY, factor);
|
|
121
|
+
updateCanvasTransform(ctx);
|
|
122
|
+
updateZoomUI(ctx);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Advanced mode: pan only when Space is held
|
|
127
|
+
if (!ctx.spaceHeld) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (e.shiftKey) {
|
|
132
|
+
const panSpeed = 1.5;
|
|
133
|
+
panByDelta(ctx, -(e.deltaY * panSpeed), 0);
|
|
134
|
+
updateCanvasTransform(ctx);
|
|
135
|
+
updateMinimap(ctx);
|
|
136
|
+
} else {
|
|
137
|
+
const panSpeed = 1.5;
|
|
138
|
+
panByDelta(ctx, -(e.deltaX * panSpeed), -(e.deltaY * panSpeed));
|
|
139
|
+
updateCanvasTransform(ctx);
|
|
140
|
+
updateMinimap(ctx);
|
|
141
|
+
}
|
|
142
|
+
}, { passive: false });
|
|
143
|
+
|
|
144
|
+
// ── Selection rectangle state ──
|
|
145
|
+
let selectionRect: HTMLElement | null = null;
|
|
146
|
+
let selRectStartWorldX = 0, selRectStartWorldY = 0;
|
|
147
|
+
let isRectSelecting = false;
|
|
148
|
+
|
|
149
|
+
// ── Mousedown on viewport ──
|
|
150
|
+
ctx.canvasViewport.addEventListener('mousedown', (e) => {
|
|
151
|
+
// Space held, middle-click or Alt+click = pan (ALWAYS, both modes)
|
|
152
|
+
if (e.button === 1 || e.altKey || ctx.spaceHeld) {
|
|
153
|
+
ctx.isDragging = true;
|
|
154
|
+
lastDragX = e.clientX;
|
|
155
|
+
lastDragY = e.clientY;
|
|
156
|
+
ctx.canvasViewport.style.cursor = 'grabbing';
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
e.stopPropagation();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const insideCard = (e.target as HTMLElement).closest('.file-card') || (e.target as HTMLElement).closest('.file-pill');
|
|
163
|
+
if (insideCard) return;
|
|
164
|
+
|
|
165
|
+
// Left click on empty canvas — behavior depends on control mode
|
|
166
|
+
if (e.button === 0) {
|
|
167
|
+
if (ctx.controlMode === 'simple') {
|
|
168
|
+
// SIMPLE MODE: left-click on empty canvas = pan
|
|
169
|
+
ctx.isDragging = true;
|
|
170
|
+
lastDragX = e.clientX;
|
|
171
|
+
lastDragY = e.clientY;
|
|
172
|
+
ctx.canvasViewport.style.cursor = 'grabbing';
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
} else {
|
|
175
|
+
// ADVANCED MODE: left-click on empty canvas = rect selection
|
|
176
|
+
if (!e.shiftKey) {
|
|
177
|
+
ctx.actor.send({ type: 'DESELECT_ALL' });
|
|
178
|
+
clearSelectionHighlights(ctx);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
isRectSelecting = true;
|
|
182
|
+
const world = screenToWorld(ctx, e.clientX, e.clientY);
|
|
183
|
+
selRectStartWorldX = world.x;
|
|
184
|
+
selRectStartWorldY = world.y;
|
|
185
|
+
|
|
186
|
+
selectionRect = document.createElement('div');
|
|
187
|
+
selectionRect.className = 'selection-rect';
|
|
188
|
+
selectionRect.style.left = `${selRectStartWorldX}px`;
|
|
189
|
+
selectionRect.style.top = `${selRectStartWorldY}px`;
|
|
190
|
+
selectionRect.style.width = '0px';
|
|
191
|
+
selectionRect.style.height = '0px';
|
|
192
|
+
ctx.canvas.appendChild(selectionRect);
|
|
193
|
+
ctx.canvasViewport.style.cursor = 'crosshair';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── Global mousemove (pan + rect select) ──
|
|
199
|
+
window.addEventListener('mousemove', (e) => {
|
|
200
|
+
if (ctx.isDragging) {
|
|
201
|
+
// Delta-based pan via galaxydraw engine
|
|
202
|
+
const dx = e.clientX - lastDragX;
|
|
203
|
+
const dy = e.clientY - lastDragY;
|
|
204
|
+
lastDragX = e.clientX;
|
|
205
|
+
lastDragY = e.clientY;
|
|
206
|
+
|
|
207
|
+
panByDelta(ctx, dx, dy);
|
|
208
|
+
|
|
209
|
+
// Throttle transform + minimap to one frame
|
|
210
|
+
if (!rafPendingPan) {
|
|
211
|
+
rafPendingPan = true;
|
|
212
|
+
requestAnimationFrame(() => {
|
|
213
|
+
rafPendingPan = false;
|
|
214
|
+
updateCanvasTransform(ctx);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isRectSelecting && selectionRect) {
|
|
221
|
+
const world = screenToWorld(ctx, e.clientX, e.clientY);
|
|
222
|
+
const worldX = world.x;
|
|
223
|
+
const worldY = world.y;
|
|
224
|
+
|
|
225
|
+
const rx = Math.min(selRectStartWorldX, worldX);
|
|
226
|
+
const ry = Math.min(selRectStartWorldY, worldY);
|
|
227
|
+
const rw = Math.abs(worldX - selRectStartWorldX);
|
|
228
|
+
const rh = Math.abs(worldY - selRectStartWorldY);
|
|
229
|
+
|
|
230
|
+
selectionRect.style.left = `${rx}px`;
|
|
231
|
+
selectionRect.style.top = `${ry}px`;
|
|
232
|
+
selectionRect.style.width = `${rw}px`;
|
|
233
|
+
selectionRect.style.height = `${rh}px`;
|
|
234
|
+
|
|
235
|
+
// Throttle live-highlight to one per frame
|
|
236
|
+
if (!rafPendingSelect) {
|
|
237
|
+
rafPendingSelect = true;
|
|
238
|
+
requestAnimationFrame(() => {
|
|
239
|
+
rafPendingSelect = false;
|
|
240
|
+
// Highlight DOM cards
|
|
241
|
+
ctx.fileCards.forEach((card, path) => {
|
|
242
|
+
const cx = parseFloat(card.style.left) || 0;
|
|
243
|
+
const cy = parseFloat(card.style.top) || 0;
|
|
244
|
+
const cw = card.offsetWidth || 580;
|
|
245
|
+
const ch = card.offsetHeight || 200;
|
|
246
|
+
const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
|
|
247
|
+
card.classList.toggle('selected', overlaps);
|
|
248
|
+
});
|
|
249
|
+
// Also highlight pill cards (zoomed out)
|
|
250
|
+
const pillEls = ctx.canvas?.querySelectorAll('.file-pill') as NodeListOf<HTMLElement>;
|
|
251
|
+
if (pillEls) {
|
|
252
|
+
pillEls.forEach(pill => {
|
|
253
|
+
const cx = parseFloat(pill.style.left) || 0;
|
|
254
|
+
const cy = parseFloat(pill.style.top) || 0;
|
|
255
|
+
const cw = parseFloat(pill.style.width) || 580;
|
|
256
|
+
const ch = parseFloat(pill.style.height) || 700;
|
|
257
|
+
const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
|
|
258
|
+
if (overlaps) {
|
|
259
|
+
pill.style.outline = '8px solid rgba(124, 58, 237, 1)';
|
|
260
|
+
pill.style.outlineOffset = '6px';
|
|
261
|
+
pill.style.filter = 'brightness(1.3)';
|
|
262
|
+
} else {
|
|
263
|
+
pill.style.outline = '';
|
|
264
|
+
pill.style.outlineOffset = '';
|
|
265
|
+
pill.style.filter = '';
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Global mouseup (pan + rect select) ──
|
|
275
|
+
window.addEventListener('mouseup', (e) => {
|
|
276
|
+
if (ctx.isDragging) {
|
|
277
|
+
ctx.isDragging = false;
|
|
278
|
+
ctx.canvasViewport.style.cursor = '';
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (isRectSelecting) {
|
|
283
|
+
isRectSelecting = false;
|
|
284
|
+
ctx.canvasViewport.style.cursor = '';
|
|
285
|
+
|
|
286
|
+
if (selectionRect) {
|
|
287
|
+
const rx = parseFloat(selectionRect.style.left);
|
|
288
|
+
const ry = parseFloat(selectionRect.style.top);
|
|
289
|
+
const rw = parseFloat(selectionRect.style.width);
|
|
290
|
+
const rh = parseFloat(selectionRect.style.height);
|
|
291
|
+
|
|
292
|
+
const selected: string[] = [];
|
|
293
|
+
// Check materialized DOM cards
|
|
294
|
+
ctx.fileCards.forEach((card, path) => {
|
|
295
|
+
const cx = parseFloat(card.style.left) || 0;
|
|
296
|
+
const cy = parseFloat(card.style.top) || 0;
|
|
297
|
+
const cw = card.offsetWidth || 580;
|
|
298
|
+
const ch = card.offsetHeight || 200;
|
|
299
|
+
|
|
300
|
+
const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
|
|
301
|
+
if (overlaps) selected.push(path);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Also check deferred cards (pill mode / zoomed out)
|
|
305
|
+
if (ctx.deferredCards) {
|
|
306
|
+
for (const [path, entry] of ctx.deferredCards) {
|
|
307
|
+
if (selected.includes(path)) continue;
|
|
308
|
+
const { x: cx, y: cy, size } = entry;
|
|
309
|
+
const cw = size?.width || 580;
|
|
310
|
+
const ch = size?.height || 700;
|
|
311
|
+
const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
|
|
312
|
+
if (overlaps) selected.push(path);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (selected.length > 0) {
|
|
317
|
+
selected.forEach((path, i) => {
|
|
318
|
+
ctx.actor.send({ type: 'SELECT_CARD', path, shift: i > 0 || e.shiftKey });
|
|
319
|
+
});
|
|
320
|
+
} else if (!e.shiftKey) {
|
|
321
|
+
ctx.actor.send({ type: 'DESELECT_ALL' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
updatePillSelectionHighlights(ctx);
|
|
325
|
+
updateArrangeToolbar(ctx);
|
|
326
|
+
|
|
327
|
+
selectionRect.remove();
|
|
328
|
+
selectionRect = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── Paste repo path from clipboard ─────────────────────
|
|
336
|
+
async function pasteRepoPath(ctx: CanvasContext) {
|
|
337
|
+
return measure('repo:paste', async () => {
|
|
338
|
+
try {
|
|
339
|
+
const text = await navigator.clipboard.readText();
|
|
340
|
+
if (text && text.trim()) {
|
|
341
|
+
const input = document.getElementById('repoPath') as HTMLInputElement;
|
|
342
|
+
input.value = text.trim();
|
|
343
|
+
input.focus();
|
|
344
|
+
showToast('Pasted from clipboard', 'info');
|
|
345
|
+
} else {
|
|
346
|
+
showToast('Clipboard is empty — type or paste a repo path', 'info');
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
measure('repo:pasteError', () => err);
|
|
350
|
+
showToast('Paste failed — type the path manually', 'error');
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Preview modal close ────────────────────────────────
|
|
356
|
+
function closePreview() {
|
|
357
|
+
const modal = document.getElementById('filePreviewModal');
|
|
358
|
+
if (modal) modal.classList.remove('active');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Changed files panel setup ──────────────────────────
|
|
362
|
+
function setupChangedFilesPanel() {
|
|
363
|
+
measure('panel:setupChangedFiles', () => {
|
|
364
|
+
const toggleBtn = document.getElementById('toggleChangedFiles');
|
|
365
|
+
const panel = document.getElementById('changedFilesPanel');
|
|
366
|
+
const closeBtn = document.getElementById('closeChangedFiles');
|
|
367
|
+
|
|
368
|
+
// Restore persisted state — default to open so changed files appear on commit select
|
|
369
|
+
if (panel) {
|
|
370
|
+
const wasClosed = localStorage.getItem('gitcanvas:changedFilesPanelClosed');
|
|
371
|
+
// Default open unless explicitly closed by user
|
|
372
|
+
panel.dataset.manuallyClosed = wasClosed === 'true' ? 'true' : 'false';
|
|
373
|
+
panel.style.display = 'none';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (toggleBtn && panel) {
|
|
377
|
+
toggleBtn.addEventListener('click', () => {
|
|
378
|
+
const isVisible = panel.style.display !== 'none';
|
|
379
|
+
panel.style.display = isVisible ? 'none' : 'flex';
|
|
380
|
+
panel.dataset.manuallyClosed = isVisible ? 'true' : 'false';
|
|
381
|
+
localStorage.setItem('gitcanvas:changedFilesPanelClosed', isVisible ? 'true' : 'false');
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (closeBtn && panel) {
|
|
386
|
+
closeBtn.addEventListener('click', () => {
|
|
387
|
+
panel.style.display = 'none';
|
|
388
|
+
panel.dataset.manuallyClosed = 'true';
|
|
389
|
+
localStorage.setItem('gitcanvas:changedFilesPanelClosed', 'true');
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function setupConnectionsPanel(ctx: CanvasContext) {
|
|
396
|
+
measure('panel:setupConnections', () => {
|
|
397
|
+
const toggleBtn = document.getElementById('toggleConnectionsPanel');
|
|
398
|
+
const panel = document.getElementById('connectionsPanel');
|
|
399
|
+
const closeBtn = document.getElementById('closeConnectionsPanel');
|
|
400
|
+
|
|
401
|
+
if (toggleBtn && panel) {
|
|
402
|
+
toggleBtn.addEventListener('click', () => {
|
|
403
|
+
const isVisible = panel.style.display !== 'none';
|
|
404
|
+
panel.style.display = isVisible ? 'none' : 'flex';
|
|
405
|
+
if (!isVisible) {
|
|
406
|
+
import('./connections').then(m => m.populateConnectionsList(ctx));
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (closeBtn && panel) {
|
|
411
|
+
closeBtn.addEventListener('click', () => panel.style.display = 'none');
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ─── Global event listeners ─────────────────────────────
|
|
417
|
+
export function setupEventListeners(ctx: CanvasContext) {
|
|
418
|
+
measure('events:setup', () => {
|
|
419
|
+
setupChangedFilesPanel();
|
|
420
|
+
setupConnectionsPanel(ctx);
|
|
421
|
+
|
|
422
|
+
// Text rendering mode toggle (Canvas vs DOM)
|
|
423
|
+
const textToggle = document.getElementById('toggleCanvasText');
|
|
424
|
+
if (textToggle) {
|
|
425
|
+
ctx.useCanvasText = localStorage.getItem('gitcanvas:useCanvasText') !== 'false';
|
|
426
|
+
textToggle.classList.toggle('active', ctx.useCanvasText);
|
|
427
|
+
textToggle.addEventListener('click', () => {
|
|
428
|
+
ctx.useCanvasText = !ctx.useCanvasText;
|
|
429
|
+
localStorage.setItem('gitcanvas:useCanvasText', String(ctx.useCanvasText));
|
|
430
|
+
textToggle.classList.toggle('active', ctx.useCanvasText);
|
|
431
|
+
|
|
432
|
+
// Re-render currently visible cards
|
|
433
|
+
rerenderCurrentView(ctx);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Control mode toggle (Simple vs Advanced)
|
|
438
|
+
const modeToggle = document.getElementById('toggleControlMode');
|
|
439
|
+
if (modeToggle) {
|
|
440
|
+
// Set initial icon based on stored mode
|
|
441
|
+
const updateModeIcon = () => {
|
|
442
|
+
const icon = document.getElementById('controlModeIcon');
|
|
443
|
+
if (!icon) return;
|
|
444
|
+
if (ctx.controlMode === 'simple') {
|
|
445
|
+
// Hand icon for simple mode
|
|
446
|
+
icon.innerHTML = '<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1M14 7V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v3M10 7V5a2 2 0 0 0-2-2a2 2 0 0 0-2 2v5M6 10V8a2 2 0 0 0-2-2a2 2 0 0 0-2 2v7a7 7 0 0 0 7 7h3a7 7 0 0 0 7-7v-3a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/>';
|
|
447
|
+
modeToggle.classList.add('active');
|
|
448
|
+
modeToggle.title = 'Simple mode (drag = pan). Click to switch to Advanced.';
|
|
449
|
+
} else {
|
|
450
|
+
// Crosshair icon for advanced mode
|
|
451
|
+
icon.innerHTML = '<circle cx="12" cy="12" r="10"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>';
|
|
452
|
+
modeToggle.classList.remove('active');
|
|
453
|
+
modeToggle.title = 'Advanced mode (space+drag = pan). Click to switch to Simple.';
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
updateModeIcon();
|
|
457
|
+
|
|
458
|
+
modeToggle.addEventListener('click', () => {
|
|
459
|
+
ctx.controlMode = ctx.controlMode === 'simple' ? 'advanced' : 'simple';
|
|
460
|
+
localStorage.setItem('gitcanvas:controlMode', ctx.controlMode);
|
|
461
|
+
updateModeIcon();
|
|
462
|
+
|
|
463
|
+
// Update cursor
|
|
464
|
+
if (ctx.canvasViewport) {
|
|
465
|
+
ctx.canvasViewport.style.cursor = '';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Show toast
|
|
469
|
+
import('./utils').then(m => {
|
|
470
|
+
m.showToast(
|
|
471
|
+
ctx.controlMode === 'simple'
|
|
472
|
+
? 'Simple mode: Drag to pan, scroll to zoom'
|
|
473
|
+
: 'Advanced mode: Space+drag to pan, Ctrl+scroll to zoom',
|
|
474
|
+
'info'
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Connections visibility toggle
|
|
481
|
+
const connToggle = document.getElementById('toggleConnections');
|
|
482
|
+
if (connToggle) {
|
|
483
|
+
// Default OFF — connections are distracting on first load
|
|
484
|
+
let connectionsVisible = localStorage.getItem('gitcanvas:connectionsVisible') === 'true';
|
|
485
|
+
const svg = document.getElementById('connectionsOverlay') as HTMLElement;
|
|
486
|
+
if (svg && !connectionsVisible) svg.style.display = 'none';
|
|
487
|
+
connToggle.classList.toggle('active', connectionsVisible);
|
|
488
|
+
connToggle.addEventListener('click', () => {
|
|
489
|
+
connectionsVisible = !connectionsVisible;
|
|
490
|
+
if (svg) svg.style.display = connectionsVisible ? '' : 'none';
|
|
491
|
+
// Also toggle marker strips on cards
|
|
492
|
+
document.querySelectorAll('.connection-markers').forEach(el => {
|
|
493
|
+
(el as HTMLElement).style.display = connectionsVisible ? '' : 'none';
|
|
494
|
+
});
|
|
495
|
+
connToggle.classList.toggle('active', connectionsVisible);
|
|
496
|
+
connToggle.title = connectionsVisible ? 'Hide connection lines' : 'Show connection lines';
|
|
497
|
+
localStorage.setItem('gitcanvas:connectionsVisible', String(connectionsVisible));
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Auto-detect imports button
|
|
502
|
+
const autoImportsBtn = document.getElementById('autoDetectImports');
|
|
503
|
+
if (autoImportsBtn) {
|
|
504
|
+
autoImportsBtn.addEventListener('click', () => {
|
|
505
|
+
import('./connections').then(m => m.autoDetectImports(ctx));
|
|
506
|
+
});
|
|
507
|
+
// Show button when repo is loaded (observer or direct check)
|
|
508
|
+
const showIfRepo = () => {
|
|
509
|
+
const state = ctx.snap().context;
|
|
510
|
+
if (state.repoPath) autoImportsBtn.style.display = '';
|
|
511
|
+
};
|
|
512
|
+
// Check periodically until repo loads
|
|
513
|
+
const checkInterval = setInterval(() => {
|
|
514
|
+
showIfRepo();
|
|
515
|
+
if (ctx.snap().context.repoPath) clearInterval(checkInterval);
|
|
516
|
+
}, 2000);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Dependency graph toggle (button is in layout.tsx toolbar)
|
|
520
|
+
const depBtn = document.getElementById('dep-graph-btn');
|
|
521
|
+
if (depBtn) {
|
|
522
|
+
depBtn.addEventListener('click', () => {
|
|
523
|
+
import('./dependency-graph').then(m => m.toggleDependencyGraph(ctx));
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
import('./dependency-graph').then(m => m.setupDependencyGraphShortcut(ctx));
|
|
527
|
+
|
|
528
|
+
// Repo dropdown selector
|
|
529
|
+
const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
530
|
+
if (repoSelect) {
|
|
531
|
+
// Populate dropdown from recent repos
|
|
532
|
+
const recentRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
|
|
533
|
+
// Clear except first placeholder
|
|
534
|
+
while (repoSelect.options.length > 1) repoSelect.remove(1);
|
|
535
|
+
recentRepos.forEach(repo => {
|
|
536
|
+
const opt = document.createElement('option');
|
|
537
|
+
opt.value = repo;
|
|
538
|
+
// Show short name (last folder part) + full path
|
|
539
|
+
const shortName = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
|
|
540
|
+
opt.textContent = shortName;
|
|
541
|
+
opt.title = repo;
|
|
542
|
+
repoSelect.add(opt);
|
|
543
|
+
});
|
|
544
|
+
// "Open new repo..." option at the end
|
|
545
|
+
const newOpt = document.createElement('option');
|
|
546
|
+
newOpt.value = '__new__';
|
|
547
|
+
newOpt.textContent = '+ Open new repo...';
|
|
548
|
+
repoSelect.add(newOpt);
|
|
549
|
+
|
|
550
|
+
// Set initial value from hash — otherwise keep placeholder
|
|
551
|
+
const hashPath = decodeURIComponent(location.hash.slice(1));
|
|
552
|
+
if (hashPath && recentRepos.includes(hashPath)) {
|
|
553
|
+
repoSelect.value = hashPath;
|
|
554
|
+
} else if (!hashPath) {
|
|
555
|
+
repoSelect.value = ''; // Keep "Select a repository..." shown
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
repoSelect.addEventListener('change', async () => {
|
|
559
|
+
const val = repoSelect.value;
|
|
560
|
+
if (val === '__new__') {
|
|
561
|
+
// Ask the user via native browser prompt instead of buggy OS-level popup
|
|
562
|
+
const path = window.prompt('Enter the absolute path to your Git repository\n\nExample: C:\\Code\\my-project', '');
|
|
563
|
+
if (path && path.trim()) {
|
|
564
|
+
const cleanPath = path.trim();
|
|
565
|
+
_addRecentRepo(cleanPath);
|
|
566
|
+
loadRepository(ctx, cleanPath);
|
|
567
|
+
// Re-populate dropdown options
|
|
568
|
+
const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
|
|
569
|
+
while (repoSelect.options.length > 1) repoSelect.remove(1);
|
|
570
|
+
updatedRepos.forEach(repo => {
|
|
571
|
+
const opt = document.createElement('option');
|
|
572
|
+
opt.value = repo;
|
|
573
|
+
opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
|
|
574
|
+
opt.title = repo;
|
|
575
|
+
repoSelect.add(opt);
|
|
576
|
+
});
|
|
577
|
+
const newOptRefresh = document.createElement('option');
|
|
578
|
+
newOptRefresh.value = '__new__';
|
|
579
|
+
newOptRefresh.textContent = '+ Open new repo...';
|
|
580
|
+
newOptRefresh.id = 'optNewLocal';
|
|
581
|
+
repoSelect.add(newOptRefresh);
|
|
582
|
+
repoSelect.value = cleanPath;
|
|
583
|
+
} else {
|
|
584
|
+
// Reset selection
|
|
585
|
+
repoSelect.value = '';
|
|
586
|
+
}
|
|
587
|
+
} else if (val) {
|
|
588
|
+
loadRepository(ctx, val);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// ── Mode detection: hide local-only options in SaaS mode ──
|
|
593
|
+
fetch('/api/repo/mode').then(r => r.json()).then((modeData: any) => {
|
|
594
|
+
if (modeData.mode === 'saas') {
|
|
595
|
+
// Hide the "Open new repo..." local path option
|
|
596
|
+
const localOpt = repoSelect.querySelector('option[value="__new__"]');
|
|
597
|
+
if (localOpt) (localOpt as HTMLElement).style.display = 'none';
|
|
598
|
+
}
|
|
599
|
+
}).catch(() => { });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── Featured repo cards on landing page ──
|
|
603
|
+
document.querySelectorAll('.repo-card-btn[data-repo]').forEach(btn => {
|
|
604
|
+
btn.addEventListener('click', () => {
|
|
605
|
+
const repoUrl = (btn as HTMLElement).dataset.repo;
|
|
606
|
+
if (repoUrl) {
|
|
607
|
+
_triggerClone(ctx, repoUrl);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// Zoom slider
|
|
613
|
+
document.getElementById('zoomSlider')?.addEventListener('input', (e) => {
|
|
614
|
+
ctx.actor.send({ type: 'SET_ZOOM', zoom: parseFloat((e.target as HTMLInputElement).value) });
|
|
615
|
+
updateCanvasTransform(ctx);
|
|
616
|
+
updateZoomUI(ctx);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ── Sticky Zoom Pill controls ──
|
|
620
|
+
document.getElementById('stickyZoomSlider')?.addEventListener('input', (e) => {
|
|
621
|
+
ctx.actor.send({ type: 'SET_ZOOM', zoom: parseFloat((e.target as HTMLInputElement).value) });
|
|
622
|
+
updateCanvasTransform(ctx);
|
|
623
|
+
updateZoomUI(ctx);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
document.getElementById('stickyZoomOut')?.addEventListener('click', () => {
|
|
627
|
+
const state = ctx.snap().context;
|
|
628
|
+
const newZoom = Math.max(0.1, state.zoom - 0.1);
|
|
629
|
+
ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
|
|
630
|
+
updateCanvasTransform(ctx);
|
|
631
|
+
updateZoomUI(ctx);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
document.getElementById('stickyZoomIn')?.addEventListener('click', () => {
|
|
635
|
+
const state = ctx.snap().context;
|
|
636
|
+
const newZoom = Math.min(3, state.zoom + 0.1);
|
|
637
|
+
ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
|
|
638
|
+
updateCanvasTransform(ctx);
|
|
639
|
+
updateZoomUI(ctx);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
document.getElementById('stickyFitAll')?.addEventListener('click', () => fitAllFiles(ctx));
|
|
643
|
+
|
|
644
|
+
// Reset
|
|
645
|
+
document.getElementById('resetView')?.addEventListener('click', () => {
|
|
646
|
+
ctx.actor.send({ type: 'SET_ZOOM', zoom: 1 });
|
|
647
|
+
ctx.actor.send({ type: 'SET_OFFSET', x: 0, y: 0 });
|
|
648
|
+
updateCanvasTransform(ctx);
|
|
649
|
+
updateZoomUI(ctx);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Fit All
|
|
653
|
+
document.getElementById('fitAll')?.addEventListener('click', () => fitAllFiles(ctx));
|
|
654
|
+
|
|
655
|
+
// All-files mode is always active — no view switching needed
|
|
656
|
+
|
|
657
|
+
// Hidden files button
|
|
658
|
+
document.getElementById('showHidden')?.addEventListener('click', () => showHiddenModal(ctx, () => rerenderCurrentView(ctx)));
|
|
659
|
+
|
|
660
|
+
// Arrange toolbar buttons
|
|
661
|
+
document.getElementById('arrangeRow')?.addEventListener('click', () => arrangeRow(ctx));
|
|
662
|
+
document.getElementById('arrangeCol')?.addEventListener('click', () => arrangeColumn(ctx));
|
|
663
|
+
document.getElementById('arrangeColumn')?.addEventListener('click', () => arrangeColumn(ctx));
|
|
664
|
+
document.getElementById('arrangeGrid')?.addEventListener('click', () => arrangeGrid(ctx));
|
|
665
|
+
document.getElementById('arrangeExpand')?.addEventListener('click', () => {
|
|
666
|
+
const selected = ctx.snap().context.selectedCards;
|
|
667
|
+
if (selected.length > 0) toggleCardExpand(ctx);
|
|
668
|
+
});
|
|
669
|
+
document.getElementById('arrangeFit')?.addEventListener('click', () => {
|
|
670
|
+
const selected = ctx.snap().context.selectedCards;
|
|
671
|
+
if (selected.length > 0) fitScreenSize(ctx);
|
|
672
|
+
});
|
|
673
|
+
document.getElementById('arrangeAI')?.addEventListener('click', () => {
|
|
674
|
+
const selected = ctx.snap().context.selectedCards;
|
|
675
|
+
if (selected.length > 0) toggleCanvasChat(ctx);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// Close preview
|
|
679
|
+
document.getElementById('closePreview')?.addEventListener('click', closePreview);
|
|
680
|
+
document.querySelector('.modal-backdrop')?.addEventListener('click', closePreview);
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
// AI chat toggle
|
|
684
|
+
document.getElementById('toggleCanvasChat')?.addEventListener('click', () => toggleCanvasChat(ctx));
|
|
685
|
+
|
|
686
|
+
// Replayable onboarding
|
|
687
|
+
document.getElementById('helpOnboarding')?.addEventListener('click', () => {
|
|
688
|
+
import('./onboarding').then(m => m.startOnboarding(ctx));
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Share Layout
|
|
692
|
+
document.getElementById('shareLayout')?.addEventListener('click', () => {
|
|
693
|
+
measure('share:layout', () => {
|
|
694
|
+
const state = ctx.snap();
|
|
695
|
+
if (!state.context.repoPath) {
|
|
696
|
+
showToast('Load a repository first to share its layout.', 'error');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const layoutData = {
|
|
700
|
+
positions: Object.fromEntries(ctx.positions),
|
|
701
|
+
hiddenFiles: Array.from(ctx.hiddenFiles),
|
|
702
|
+
zoom: state.context.zoom,
|
|
703
|
+
offsetX: state.context.offsetX,
|
|
704
|
+
offsetY: state.context.offsetY,
|
|
705
|
+
cardSizes: state.context.cardSizes,
|
|
706
|
+
};
|
|
707
|
+
const encoded = btoa(JSON.stringify(layoutData));
|
|
708
|
+
const url = new URL(window.location.href);
|
|
709
|
+
// Strip existing layout param if any
|
|
710
|
+
url.searchParams.set('layout', encoded);
|
|
711
|
+
|
|
712
|
+
navigator.clipboard.writeText(url.toString()).then(() => {
|
|
713
|
+
showToast('Layout link copied to clipboard!', 'success');
|
|
714
|
+
}).catch(() => {
|
|
715
|
+
showToast('Failed to copy to clipboard', 'error');
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Settings modal
|
|
721
|
+
document.getElementById('openSettings')?.addEventListener('click', () => {
|
|
722
|
+
import('./settings-modal').then(({ openSettingsModal }) => openSettingsModal(ctx));
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Global search
|
|
726
|
+
document.getElementById('openGlobalSearch')?.addEventListener('click', () => {
|
|
727
|
+
import('./global-search').then(({ toggleGlobalSearch }) => toggleGlobalSearch(ctx));
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Branch comparison
|
|
731
|
+
document.getElementById('openBranchCompare')?.addEventListener('click', () => {
|
|
732
|
+
import('./branch-compare').then(({ toggleDrawer }) => toggleDrawer(ctx));
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// Apply saved settings on startup
|
|
736
|
+
import('./settings-modal').then(({ applyAllSettings }) => applyAllSettings(ctx));
|
|
737
|
+
|
|
738
|
+
// Clean up expired auto-save drafts
|
|
739
|
+
import('./auto-save').then(({ cleanExpiredDrafts }) => cleanExpiredDrafts());
|
|
740
|
+
|
|
741
|
+
// ── Keyboard shortcuts ──
|
|
742
|
+
window.addEventListener('keydown', (e) => {
|
|
743
|
+
// Space-bar canvas panning
|
|
744
|
+
if (e.code === 'Space' && !e.repeat) {
|
|
745
|
+
if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
|
|
746
|
+
e.preventDefault();
|
|
747
|
+
ctx.spaceHeld = true;
|
|
748
|
+
ctx.canvasViewport.classList.add('space-panning');
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Don't interfere with input fields for all other shortcuts
|
|
753
|
+
if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
|
|
754
|
+
|
|
755
|
+
if (e.key === 'Escape') {
|
|
756
|
+
closePreview();
|
|
757
|
+
const hiddenModal = document.getElementById('hiddenFilesModal');
|
|
758
|
+
if (hiddenModal) hiddenModal.remove();
|
|
759
|
+
// Cancel click-to-connect if pending
|
|
760
|
+
if (hasPendingConnection()) {
|
|
761
|
+
cancelPendingConnection(ctx);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (ctx.snap().context.pendingConnection) {
|
|
765
|
+
ctx.actor.send({ type: 'CANCEL_CONNECTION' });
|
|
766
|
+
}
|
|
767
|
+
// Deselect all cards
|
|
768
|
+
ctx.actor.send({ type: 'DESELECT_ALL' });
|
|
769
|
+
clearSelectionHighlights(ctx);
|
|
770
|
+
updatePillSelectionHighlights(ctx);
|
|
771
|
+
updateArrangeToolbar(ctx);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
775
|
+
const selected = ctx.snap().context.selectedCards;
|
|
776
|
+
if (selected.length > 0) {
|
|
777
|
+
e.preventDefault();
|
|
778
|
+
hideSelectedFiles(ctx, selected);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Arrangement hotkeys
|
|
783
|
+
if (e.key === 'h' || e.key === 'H') {
|
|
784
|
+
const selected = ctx.snap().context.selectedCards;
|
|
785
|
+
if (selected.length >= 2) {
|
|
786
|
+
e.preventDefault(); arrangeRow(ctx);
|
|
787
|
+
} else if (selected.length === 0) {
|
|
788
|
+
// Toggle git heatmap overlay
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
const repoPath = ctx.snap().context.repoPath;
|
|
791
|
+
if (repoPath) {
|
|
792
|
+
import('./heatmap').then(async ({ toggleHeatmap, injectHeatmapCSS }) => {
|
|
793
|
+
injectHeatmapCSS();
|
|
794
|
+
const active = await toggleHeatmap(repoPath);
|
|
795
|
+
import('./settings').then(({ updateSettings }) => updateSettings({ heatmapEnabled: active }));
|
|
796
|
+
import('./utils').then(m => m.showToast(
|
|
797
|
+
active ? '🔥 Heatmap ON — hot files glow red' : 'Heatmap OFF',
|
|
798
|
+
'info'
|
|
799
|
+
));
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (e.key === 'v' || e.key === 'V') {
|
|
805
|
+
const selected = ctx.snap().context.selectedCards;
|
|
806
|
+
if (selected.length >= 2) { e.preventDefault(); arrangeColumn(ctx); }
|
|
807
|
+
}
|
|
808
|
+
if (e.key === 'g' || e.key === 'G') {
|
|
809
|
+
const selected = ctx.snap().context.selectedCards;
|
|
810
|
+
if (selected.length >= 2) { e.preventDefault(); arrangeGrid(ctx); }
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Select all with Ctrl+A
|
|
814
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
815
|
+
e.preventDefault();
|
|
816
|
+
ctx.fileCards.forEach((card, path) => {
|
|
817
|
+
ctx.actor.send({ type: 'SELECT_CARD', path, shift: true });
|
|
818
|
+
});
|
|
819
|
+
// Also select deferred cards (pill mode at low zoom)
|
|
820
|
+
if (ctx.deferredCards) {
|
|
821
|
+
for (const [path] of ctx.deferredCards) {
|
|
822
|
+
ctx.actor.send({ type: 'SELECT_CARD', path, shift: true });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
updatePillSelectionHighlights(ctx);
|
|
826
|
+
updateArrangeToolbar(ctx);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// F key: no longer used for expand (canvas text handles all lines)
|
|
830
|
+
|
|
831
|
+
// W = Fit selected cards to screen/viewport size
|
|
832
|
+
if (e.key === 'w' || e.key === 'W') {
|
|
833
|
+
const selected = ctx.snap().context.selectedCards;
|
|
834
|
+
if (selected.length > 0) {
|
|
835
|
+
e.preventDefault();
|
|
836
|
+
fitScreenSize(ctx);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Ctrl + / Ctrl - = increase/decrease card font size
|
|
841
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
|
|
842
|
+
e.preventDefault();
|
|
843
|
+
changeCardsFontSize(ctx, 1);
|
|
844
|
+
}
|
|
845
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '_')) {
|
|
846
|
+
e.preventDefault();
|
|
847
|
+
changeCardsFontSize(ctx, -1);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Removed: I key AI chat toggle (conflicts with typing, not useful in production)
|
|
851
|
+
|
|
852
|
+
// ← → = Navigate commits
|
|
853
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
854
|
+
const state = ctx.snap().context;
|
|
855
|
+
const commits = state.commits;
|
|
856
|
+
if (commits.length === 0) return;
|
|
857
|
+
const currentIdx = commits.findIndex(c => c.hash === state.currentCommitHash);
|
|
858
|
+
let newIdx;
|
|
859
|
+
if (e.key === 'ArrowLeft') {
|
|
860
|
+
newIdx = currentIdx > 0 ? currentIdx - 1 : commits.length - 1;
|
|
861
|
+
} else {
|
|
862
|
+
newIdx = currentIdx < commits.length - 1 ? currentIdx + 1 : 0;
|
|
863
|
+
}
|
|
864
|
+
e.preventDefault();
|
|
865
|
+
selectCommit(ctx, commits[newIdx].hash);
|
|
866
|
+
// Scroll the commit into view in sidebar
|
|
867
|
+
const commitEl = document.querySelector(`.commit-item[data-hash="${commits[newIdx].hash}"]`);
|
|
868
|
+
if (commitEl) commitEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Ctrl+F = Global search sidebar
|
|
872
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
873
|
+
e.preventDefault();
|
|
874
|
+
import('./global-search').then(m => m.toggleGlobalSearch(ctx));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Ctrl+O or Ctrl+K or Ctrl+P = File search / command palette
|
|
879
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key.toLowerCase() === 'o' || e.key.toLowerCase() === 'k' || e.key.toLowerCase() === 'p')) {
|
|
880
|
+
e.preventDefault();
|
|
881
|
+
openFileSearch(ctx);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Ctrl+Shift+E = Export canvas as PNG
|
|
886
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'e') {
|
|
887
|
+
e.preventDefault();
|
|
888
|
+
exportCanvasAsPNG(ctx);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Ctrl+Shift+V = Export viewport as PNG
|
|
892
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'v') {
|
|
893
|
+
e.preventDefault();
|
|
894
|
+
exportViewportAsPNG(ctx);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Ctrl+N = Create new file
|
|
898
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'n') {
|
|
899
|
+
e.preventDefault();
|
|
900
|
+
import('./new-file-dialog').then(m => m.showNewFileDialog(ctx));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Ctrl+Shift+F removed — Ctrl+F now opens global search directly
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// ── Prevent browser page zoom (Ctrl+scroll, Ctrl+0) ──
|
|
907
|
+
// Ctrl+scroll is already handled by the canvas wheel handler above.
|
|
908
|
+
// This global handler catches it at document level for any remaining cases.
|
|
909
|
+
document.addEventListener('wheel', (e) => {
|
|
910
|
+
if (e.ctrlKey || e.metaKey) {
|
|
911
|
+
e.preventDefault();
|
|
912
|
+
}
|
|
913
|
+
}, { passive: false });
|
|
914
|
+
|
|
915
|
+
// Prevent Ctrl+0 (reset browser zoom)
|
|
916
|
+
window.addEventListener('keydown', (e) => {
|
|
917
|
+
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
|
|
918
|
+
e.preventDefault();
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// Space-bar release
|
|
923
|
+
window.addEventListener('keyup', (e) => {
|
|
924
|
+
if (e.code === 'Space') {
|
|
925
|
+
ctx.spaceHeld = false;
|
|
926
|
+
ctx.canvasViewport.classList.remove('space-panning');
|
|
927
|
+
if (ctx.isDragging) {
|
|
928
|
+
ctx.isDragging = false;
|
|
929
|
+
ctx.canvasViewport.style.cursor = '';
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// Window blur to reset space state
|
|
935
|
+
window.addEventListener('blur', () => {
|
|
936
|
+
if (ctx.spaceHeld) {
|
|
937
|
+
ctx.spaceHeld = false;
|
|
938
|
+
ctx.canvasViewport.classList.remove('space-panning');
|
|
939
|
+
if (ctx.isDragging) {
|
|
940
|
+
ctx.isDragging = false;
|
|
941
|
+
ctx.canvasViewport.style.cursor = '';
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// GitHub Import Modal
|
|
947
|
+
setupGithubImport(ctx);
|
|
948
|
+
|
|
949
|
+
// Minimap click navigation
|
|
950
|
+
setupMinimapClick(ctx);
|
|
951
|
+
|
|
952
|
+
// Local Directory Drag-and-Drop
|
|
953
|
+
setupDragAndDrop(ctx);
|
|
954
|
+
|
|
955
|
+
// Collaborative cursor sharing (WebSocket)
|
|
956
|
+
import('./cursor-sharing').then(({ initCursorSharing }) => initCursorSharing(ctx));
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ─── File search overlay ────────────────────────────────
|
|
961
|
+
function openFileSearch(ctx: CanvasContext) {
|
|
962
|
+
// Remove existing if open
|
|
963
|
+
document.getElementById('fileSearchOverlay')?.remove();
|
|
964
|
+
|
|
965
|
+
const overlay = document.createElement('div');
|
|
966
|
+
overlay.id = 'fileSearchOverlay';
|
|
967
|
+
overlay.className = 'file-search-overlay';
|
|
968
|
+
document.body.appendChild(overlay);
|
|
969
|
+
|
|
970
|
+
interface SearchMatch {
|
|
971
|
+
path: string;
|
|
972
|
+
line?: number;
|
|
973
|
+
snippet?: string;
|
|
974
|
+
isContentMatch: boolean;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function getAllFiles() {
|
|
978
|
+
if (ctx.allFilesData && ctx.allFilesData.length > 0) {
|
|
979
|
+
return ctx.allFilesData;
|
|
980
|
+
}
|
|
981
|
+
return [];
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
let selectedIdx = 0;
|
|
985
|
+
let currentQuery = '';
|
|
986
|
+
|
|
987
|
+
function navigateToFile(match: SearchMatch) {
|
|
988
|
+
const mgr = getCardManager();
|
|
989
|
+
const activeCard = mgr?.cards.get(match.path);
|
|
990
|
+
const deferredCard = mgr?.deferred.get(match.path);
|
|
991
|
+
|
|
992
|
+
if (!activeCard && !deferredCard) {
|
|
993
|
+
const layer = getActiveLayer();
|
|
994
|
+
if (layer && ctx.allFilesActive) {
|
|
995
|
+
// Instantly add the whole file to the active layer
|
|
996
|
+
addSectionToLayer(ctx, layer.id, match.path, '', '');
|
|
997
|
+
|
|
998
|
+
// Wait for the active layer to apply/render then jump
|
|
999
|
+
setTimeout(() => {
|
|
1000
|
+
const card = ctx.fileCards.get(match.path);
|
|
1001
|
+
if (card) {
|
|
1002
|
+
close();
|
|
1003
|
+
doNavigate(match.path, card, match.line);
|
|
1004
|
+
}
|
|
1005
|
+
}, 50);
|
|
1006
|
+
} else if (!ctx.allFilesActive) {
|
|
1007
|
+
showToast("File was not modified in the current view.", 'info');
|
|
1008
|
+
}
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
close();
|
|
1013
|
+
|
|
1014
|
+
// If active, use its DOM element. If deferred, use its stored coordinates.
|
|
1015
|
+
if (activeCard) {
|
|
1016
|
+
doNavigate(match.path, activeCard, match.line);
|
|
1017
|
+
} else if (deferredCard) {
|
|
1018
|
+
doNavigateDeferred(match.path, deferredCard, match.line);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function doNavigateDeferred(path: string, deferredCard: any, line?: number) {
|
|
1023
|
+
const vpRect = ctx.canvasViewport.getBoundingClientRect();
|
|
1024
|
+
const state = ctx.snap().context;
|
|
1025
|
+
const newOffsetX = -(deferredCard.x + deferredCard.width / 2) * state.zoom + vpRect.width / 2;
|
|
1026
|
+
const newOffsetY = -(deferredCard.y + deferredCard.height / 2) * state.zoom + vpRect.height / 2;
|
|
1027
|
+
|
|
1028
|
+
ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
|
|
1029
|
+
updateCanvasTransform(ctx); // This triggers CardManager.materializeInRect()
|
|
1030
|
+
|
|
1031
|
+
// Wait a frame for materialization to hit DOM
|
|
1032
|
+
requestAnimationFrame(() => {
|
|
1033
|
+
const materializedCard = getCardManager()?.cards.get(path);
|
|
1034
|
+
if (materializedCard) {
|
|
1035
|
+
_animateAndSelectCard(path, materializedCard, line);
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function doNavigate(path: string, card: HTMLElement, line?: number) {
|
|
1041
|
+
const vpRect = ctx.canvasViewport.getBoundingClientRect();
|
|
1042
|
+
const state = ctx.snap().context;
|
|
1043
|
+
const cardX = parseFloat(card.style.left) || 0;
|
|
1044
|
+
const cardY = parseFloat(card.style.top) || 0;
|
|
1045
|
+
const newOffsetX = -(cardX + card.offsetWidth / 2) * state.zoom + vpRect.width / 2;
|
|
1046
|
+
const newOffsetY = -(cardY + card.offsetHeight / 2) * state.zoom + vpRect.height / 2;
|
|
1047
|
+
|
|
1048
|
+
ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
|
|
1049
|
+
updateCanvasTransform(ctx);
|
|
1050
|
+
|
|
1051
|
+
_animateAndSelectCard(path, card, line);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function _animateAndSelectCard(path: string, card: HTMLElement, line?: number) {
|
|
1055
|
+
card.classList.add('card-flash');
|
|
1056
|
+
setTimeout(() => card.classList.remove('card-flash'), 1500);
|
|
1057
|
+
ctx.actor.send({ type: 'SELECT_CARD', path, shift: false });
|
|
1058
|
+
updatePillSelectionHighlights(ctx);
|
|
1059
|
+
updateArrangeToolbar(ctx);
|
|
1060
|
+
|
|
1061
|
+
if (line) {
|
|
1062
|
+
requestAnimationFrame(() => {
|
|
1063
|
+
const body = card.querySelector('.file-card-body');
|
|
1064
|
+
if (body) {
|
|
1065
|
+
const rowHeight = 20; // approximate row height
|
|
1066
|
+
body.scrollTop = (line - 1) * rowHeight - body.clientHeight / 2;
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function close() {
|
|
1073
|
+
render(null, overlay);
|
|
1074
|
+
overlay.remove();
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function highlightMatch(text: string, q: string): string {
|
|
1078
|
+
if (!q) return escapeHtml(text);
|
|
1079
|
+
const lowText = text.toLowerCase();
|
|
1080
|
+
q = q.toLowerCase();
|
|
1081
|
+
|
|
1082
|
+
const exactIdx = lowText.indexOf(q);
|
|
1083
|
+
if (exactIdx >= 0) {
|
|
1084
|
+
return escapeHtml(text.substring(0, exactIdx)) +
|
|
1085
|
+
'<mark>' + escapeHtml(text.substring(exactIdx, exactIdx + q.length)) + '</mark>' +
|
|
1086
|
+
escapeHtml(text.substring(exactIdx + q.length));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
let qIdx = 0;
|
|
1090
|
+
let result = '';
|
|
1091
|
+
for (let i = 0; i < text.length; i++) {
|
|
1092
|
+
if (qIdx < q.length && lowText[i] === q[qIdx]) {
|
|
1093
|
+
result += '<mark>' + escapeHtml(text[i]) + '</mark>';
|
|
1094
|
+
qIdx++;
|
|
1095
|
+
} else {
|
|
1096
|
+
result += escapeHtml(text[i]);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return result;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function fuzzyScore(str: string, query: string): number {
|
|
1103
|
+
const strictIdx = str.toLowerCase().indexOf(query);
|
|
1104
|
+
if (strictIdx >= 0) return 1000 - strictIdx; // Exact matches are highly ranked
|
|
1105
|
+
|
|
1106
|
+
let qIdx = 0;
|
|
1107
|
+
let sIdx = 0;
|
|
1108
|
+
let score = 0;
|
|
1109
|
+
let streak = 0;
|
|
1110
|
+
const lowStr = str.toLowerCase();
|
|
1111
|
+
|
|
1112
|
+
while (sIdx < lowStr.length && qIdx < query.length) {
|
|
1113
|
+
if (lowStr[sIdx] === query[qIdx]) {
|
|
1114
|
+
score += 1 + (streak * 2);
|
|
1115
|
+
streak++;
|
|
1116
|
+
qIdx++;
|
|
1117
|
+
} else {
|
|
1118
|
+
streak = 0;
|
|
1119
|
+
}
|
|
1120
|
+
sIdx++;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return qIdx === query.length ? score : -Infinity;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function getMatches(): SearchMatch[] {
|
|
1127
|
+
const files = getAllFiles();
|
|
1128
|
+
const q = currentQuery.toLowerCase().trim();
|
|
1129
|
+
|
|
1130
|
+
let pathOnlySearch = false; // By default search both
|
|
1131
|
+
if (q.startsWith('f:')) {
|
|
1132
|
+
pathOnlySearch = true;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const actualQuery = q.replace(/^f:/, '').trim();
|
|
1136
|
+
if (!actualQuery) {
|
|
1137
|
+
// Return top files randomly if no query yet
|
|
1138
|
+
return files.slice(0, 15).map(f => ({ path: f.path, isContentMatch: false }));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const rawResults: { match: SearchMatch, score: number }[] = [];
|
|
1142
|
+
let itemsScanned = 0;
|
|
1143
|
+
|
|
1144
|
+
for (const f of files) {
|
|
1145
|
+
if (itemsScanned > 5000) break; // Prevent deep-stall on massive repos
|
|
1146
|
+
|
|
1147
|
+
// Path match check
|
|
1148
|
+
const pathScore = fuzzyScore(f.path, actualQuery);
|
|
1149
|
+
if (pathScore > -Infinity) {
|
|
1150
|
+
rawResults.push({ match: { path: f.path, isContentMatch: false }, score: pathScore + 500 }); // Bonus for path
|
|
1151
|
+
}
|
|
1152
|
+
itemsScanned++;
|
|
1153
|
+
|
|
1154
|
+
// Content match check
|
|
1155
|
+
if (!pathOnlySearch && f.content) {
|
|
1156
|
+
const lines = f.content.split('\n');
|
|
1157
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1158
|
+
const lineScore = fuzzyScore(lines[i], actualQuery);
|
|
1159
|
+
if (lineScore > -Infinity) {
|
|
1160
|
+
rawResults.push({
|
|
1161
|
+
match: {
|
|
1162
|
+
path: f.path,
|
|
1163
|
+
line: i + 1,
|
|
1164
|
+
snippet: lines[i].trim().substring(0, 100), // Max 100 chars in preview
|
|
1165
|
+
isContentMatch: true
|
|
1166
|
+
},
|
|
1167
|
+
score: lineScore
|
|
1168
|
+
});
|
|
1169
|
+
itemsScanned++;
|
|
1170
|
+
if (rawResults.length > 500) break; // Hard limit pool
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Sort by score descending and return top 15
|
|
1177
|
+
rawResults.sort((a, b) => b.score - a.score);
|
|
1178
|
+
return rawResults.slice(0, 15).map(r => r.match);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
1182
|
+
const matches = getMatches();
|
|
1183
|
+
if (e.key === 'ArrowDown') {
|
|
1184
|
+
e.preventDefault();
|
|
1185
|
+
selectedIdx = Math.min(selectedIdx + 1, matches.length - 1);
|
|
1186
|
+
rerenderResults();
|
|
1187
|
+
} else if (e.key === 'ArrowUp') {
|
|
1188
|
+
e.preventDefault();
|
|
1189
|
+
selectedIdx = Math.max(selectedIdx - 1, 0);
|
|
1190
|
+
rerenderResults();
|
|
1191
|
+
} else if (e.key === 'Enter') {
|
|
1192
|
+
e.preventDefault();
|
|
1193
|
+
if (matches[selectedIdx]) navigateToFile(matches[selectedIdx]);
|
|
1194
|
+
} else if (e.key === 'Escape') {
|
|
1195
|
+
e.preventDefault();
|
|
1196
|
+
close();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function handleOverlayClick(e: MouseEvent) {
|
|
1201
|
+
if ((e.target as HTMLElement) === overlay || (e.target as HTMLElement).classList.contains('file-search-overlay')) {
|
|
1202
|
+
close();
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Build the container with a stable input + a results div that gets re-rendered
|
|
1207
|
+
const container = document.createElement('div');
|
|
1208
|
+
container.className = 'file-search-container';
|
|
1209
|
+
|
|
1210
|
+
const input = document.createElement('input');
|
|
1211
|
+
input.type = 'text';
|
|
1212
|
+
input.className = 'file-search-input';
|
|
1213
|
+
input.placeholder = 'Search paths (f:) or full text...';
|
|
1214
|
+
input.autocomplete = 'off';
|
|
1215
|
+
input.addEventListener('input', (e) => {
|
|
1216
|
+
currentQuery = (e.target as HTMLInputElement).value;
|
|
1217
|
+
selectedIdx = 0;
|
|
1218
|
+
rerenderResults();
|
|
1219
|
+
});
|
|
1220
|
+
input.addEventListener('keydown', handleKeydown);
|
|
1221
|
+
container.appendChild(input);
|
|
1222
|
+
|
|
1223
|
+
const resultsContainer = document.createElement('div');
|
|
1224
|
+
resultsContainer.className = 'file-search-results';
|
|
1225
|
+
container.appendChild(resultsContainer);
|
|
1226
|
+
overlay.appendChild(container);
|
|
1227
|
+
|
|
1228
|
+
function rerenderResults() {
|
|
1229
|
+
const matches = getMatches();
|
|
1230
|
+
const q = currentQuery.replace(/^f:/, '').toLowerCase().trim();
|
|
1231
|
+
if (matches.length === 0 && q) {
|
|
1232
|
+
resultsContainer.innerHTML = `<div class="file-search-empty">No results for "${escapeHtml(q)}"</div>`;
|
|
1233
|
+
} else {
|
|
1234
|
+
resultsContainer.innerHTML = matches.map((m, i) => {
|
|
1235
|
+
if (m.isContentMatch) {
|
|
1236
|
+
return `
|
|
1237
|
+
<div class="file-search-item ${i === selectedIdx ? 'selected' : ''}" data-path="${escapeHtml(m.path)}" data-line="${m.line}">
|
|
1238
|
+
<div class="search-file-name" style="font-size: 0.75rem; color: var(--text-muted)">${escapeHtml(m.path)}:${m.line}</div>
|
|
1239
|
+
<div class="search-file-snippet">${highlightMatch(m.snippet || '', q)}</div>
|
|
1240
|
+
</div>`;
|
|
1241
|
+
} else {
|
|
1242
|
+
return `
|
|
1243
|
+
<div class="file-search-item ${i === selectedIdx ? 'selected' : ''}" data-path="${escapeHtml(m.path)}">
|
|
1244
|
+
<span class="search-file-name">${highlightMatch(m.path, q)}</span>
|
|
1245
|
+
</div>`;
|
|
1246
|
+
}
|
|
1247
|
+
}).join('');
|
|
1248
|
+
// Attach click handlers
|
|
1249
|
+
resultsContainer.querySelectorAll('.file-search-item').forEach(el => {
|
|
1250
|
+
el.addEventListener('click', () => {
|
|
1251
|
+
const path = (el as HTMLElement).dataset.path!;
|
|
1252
|
+
const line = (el as HTMLElement).dataset.line ? parseInt((el as HTMLElement).dataset.line!) : undefined;
|
|
1253
|
+
navigateToFile({ path, line, isContentMatch: !!line });
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Scroll selected into view securely
|
|
1259
|
+
const selectedEl = resultsContainer.querySelector('.file-search-item.selected');
|
|
1260
|
+
if (selectedEl) {
|
|
1261
|
+
selectedEl.scrollIntoView({ block: 'nearest' });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
rerenderResults();
|
|
1266
|
+
setTimeout(() => input.focus(), 50);
|
|
1267
|
+
|
|
1268
|
+
overlay.addEventListener('click', handleOverlayClick);
|
|
1269
|
+
requestAnimationFrame(() => {
|
|
1270
|
+
const input = overlay.querySelector('.file-search-input') as HTMLInputElement;
|
|
1271
|
+
if (input) input.focus();
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ─── Language color map ─────────────────────────────────
|
|
1276
|
+
const LANG_COLORS: Record<string, string> = {
|
|
1277
|
+
TypeScript: '#3178c6', JavaScript: '#f1e05a', Python: '#3572A5',
|
|
1278
|
+
Rust: '#dea584', Go: '#00ADD8', Java: '#b07219', C: '#555555',
|
|
1279
|
+
'C++': '#f34b7d', 'C#': '#178600', Ruby: '#701516', PHP: '#4F5D95',
|
|
1280
|
+
Swift: '#F05138', Kotlin: '#A97BFF', Dart: '#00B4AB', Lua: '#000080',
|
|
1281
|
+
Shell: '#89e051', HTML: '#e34c26', CSS: '#563d7c', Vue: '#41b883',
|
|
1282
|
+
Svelte: '#ff3e00', Zig: '#ec915c', Elixir: '#6e4a7e', Haskell: '#5e5086',
|
|
1283
|
+
Scala: '#c22d40', OCaml: '#3be133', Nix: '#7e7eff',
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
// ─── GitHub Import Modal Handler ────────────────────────
|
|
1287
|
+
function setupGithubImport(ctx: CanvasContext) {
|
|
1288
|
+
const modal = document.getElementById('githubModal');
|
|
1289
|
+
const openBtn = document.getElementById('githubImportBtn');
|
|
1290
|
+
const closeBtn = document.getElementById('githubModalClose');
|
|
1291
|
+
const backdrop = modal?.querySelector('.github-modal-backdrop');
|
|
1292
|
+
const searchBtn = document.getElementById('githubSearchBtn');
|
|
1293
|
+
const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
|
|
1294
|
+
const sortSelect = document.getElementById('githubSortSelect') as HTMLSelectElement;
|
|
1295
|
+
const grid = document.getElementById('githubReposGrid');
|
|
1296
|
+
const profileDiv = document.getElementById('githubProfile');
|
|
1297
|
+
const pagination = document.getElementById('githubPagination');
|
|
1298
|
+
const prevBtn = document.getElementById('githubPrevPage') as HTMLButtonElement;
|
|
1299
|
+
const nextBtn = document.getElementById('githubNextPage') as HTMLButtonElement;
|
|
1300
|
+
const pageInfo = document.getElementById('githubPageInfo');
|
|
1301
|
+
const urlCloneRow = document.getElementById('githubUrlCloneRow');
|
|
1302
|
+
const urlCloneBtn = document.getElementById('githubUrlCloneBtn');
|
|
1303
|
+
const detectedUrlSpan = document.getElementById('githubDetectedUrl');
|
|
1304
|
+
const filterRow = document.getElementById('githubFilterRow');
|
|
1305
|
+
const filterInput = document.getElementById('githubRepoFilter') as HTMLInputElement;
|
|
1306
|
+
|
|
1307
|
+
if (!modal || !openBtn || !grid) return;
|
|
1308
|
+
|
|
1309
|
+
let currentPage = 1;
|
|
1310
|
+
let currentUser = '';
|
|
1311
|
+
let isLoading = false;
|
|
1312
|
+
let allRenderedCards: HTMLElement[] = [];
|
|
1313
|
+
|
|
1314
|
+
// ── URL detection ──
|
|
1315
|
+
const GITHUB_URL_RE = /^https?:\/\/(www\.)?github\.com\/[^/]+\/[^/]+/;
|
|
1316
|
+
function extractRepoUrl(text: string): string | null {
|
|
1317
|
+
const match = text.trim().match(GITHUB_URL_RE);
|
|
1318
|
+
return match ? match[0].replace(/\.git$/, '') + '.git' : null;
|
|
1319
|
+
}
|
|
1320
|
+
function extractUserFromUrl(text: string): string | null {
|
|
1321
|
+
const m = text.trim().match(/github\.com\/([^/]+)/);
|
|
1322
|
+
return m ? m[1] : null;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function updateUrlDetection() {
|
|
1326
|
+
const val = userInput?.value.trim() || '';
|
|
1327
|
+
const url = extractRepoUrl(val);
|
|
1328
|
+
if (url && urlCloneRow && detectedUrlSpan) {
|
|
1329
|
+
// URL detected — show clone row, extract repo name
|
|
1330
|
+
const parts = url.replace('.git', '').split('/');
|
|
1331
|
+
const repoName = parts.slice(-2).join('/');
|
|
1332
|
+
detectedUrlSpan.textContent = repoName;
|
|
1333
|
+
urlCloneRow.style.display = 'flex';
|
|
1334
|
+
} else if (urlCloneRow) {
|
|
1335
|
+
urlCloneRow.style.display = 'none';
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
userInput?.addEventListener('input', updateUrlDetection);
|
|
1340
|
+
|
|
1341
|
+
function openModal() {
|
|
1342
|
+
modal!.classList.add('active');
|
|
1343
|
+
requestAnimationFrame(() => userInput?.focus());
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function closeModal() {
|
|
1347
|
+
modal!.classList.remove('active');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
openBtn.addEventListener('click', openModal);
|
|
1351
|
+
closeBtn?.addEventListener('click', closeModal);
|
|
1352
|
+
backdrop?.addEventListener('click', closeModal);
|
|
1353
|
+
window.addEventListener('keydown', (e) => {
|
|
1354
|
+
if (e.key === 'Escape' && modal!.classList.contains('active')) closeModal();
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// ── Direct URL clone from modal ──
|
|
1358
|
+
urlCloneBtn?.addEventListener('click', () => {
|
|
1359
|
+
const url = extractRepoUrl(userInput?.value.trim() || '');
|
|
1360
|
+
if (url) {
|
|
1361
|
+
closeModal();
|
|
1362
|
+
_triggerClone(ctx, url);
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// ── Repo name filter ──
|
|
1367
|
+
filterInput?.addEventListener('input', () => {
|
|
1368
|
+
const q = filterInput.value.trim().toLowerCase();
|
|
1369
|
+
for (const card of allRenderedCards) {
|
|
1370
|
+
const name = (card.dataset.name || '').toLowerCase();
|
|
1371
|
+
const desc = card.querySelector('.github-repo-desc')?.textContent?.toLowerCase() || '';
|
|
1372
|
+
card.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
async function searchRepos(page = 1) {
|
|
1377
|
+
let user = userInput?.value.trim();
|
|
1378
|
+
if (!user || isLoading) return;
|
|
1379
|
+
|
|
1380
|
+
// If it's a URL, extract the username/org from it
|
|
1381
|
+
const urlUser = extractUserFromUrl(user);
|
|
1382
|
+
if (urlUser) user = urlUser;
|
|
1383
|
+
|
|
1384
|
+
isLoading = true;
|
|
1385
|
+
currentUser = user;
|
|
1386
|
+
currentPage = page;
|
|
1387
|
+
const sort = sortSelect?.value || 'updated';
|
|
1388
|
+
|
|
1389
|
+
// Save last searched user
|
|
1390
|
+
localStorage.setItem('gitcanvas:lastGithubUser', user);
|
|
1391
|
+
|
|
1392
|
+
grid!.innerHTML = `
|
|
1393
|
+
<div class="github-loading">
|
|
1394
|
+
<div class="github-spinner"></div>
|
|
1395
|
+
<p>Fetching repos for <strong>${escapeHtml(user)}</strong>...</p>
|
|
1396
|
+
</div>
|
|
1397
|
+
`;
|
|
1398
|
+
if (pagination) pagination.style.display = 'none';
|
|
1399
|
+
if (filterRow) filterRow.style.display = 'none';
|
|
1400
|
+
if (filterInput) filterInput.value = '';
|
|
1401
|
+
allRenderedCards = [];
|
|
1402
|
+
|
|
1403
|
+
try {
|
|
1404
|
+
const res = await fetch(`/api/github/repos?user=${encodeURIComponent(user)}&page=${page}&sort=${sort}`);
|
|
1405
|
+
const data = await res.json();
|
|
1406
|
+
|
|
1407
|
+
if (!res.ok || data.error) {
|
|
1408
|
+
grid!.innerHTML = `<div class="github-error">${escapeHtml(data.error || 'Failed to fetch')}</div>`;
|
|
1409
|
+
isLoading = false;
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Render profile
|
|
1414
|
+
if (data.profile && profileDiv) {
|
|
1415
|
+
profileDiv.style.display = 'flex';
|
|
1416
|
+
profileDiv.innerHTML = `
|
|
1417
|
+
<img class="github-avatar" src="${data.profile.avatar_url}" alt="${escapeHtml(data.profile.login)}" />
|
|
1418
|
+
<div class="github-profile-info">
|
|
1419
|
+
<strong>${escapeHtml(data.profile.name || data.profile.login)}</strong>
|
|
1420
|
+
<span class="github-profile-meta">
|
|
1421
|
+
@${escapeHtml(data.profile.login)} · ${data.profile.public_repos} repos
|
|
1422
|
+
${data.profile.type === 'Organization' ? ' · Organization' : ''}
|
|
1423
|
+
</span>
|
|
1424
|
+
${data.profile.bio ? `<span class="github-profile-bio">${escapeHtml(data.profile.bio)}</span>` : ''}
|
|
1425
|
+
</div>
|
|
1426
|
+
`;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Render repos
|
|
1430
|
+
if (data.repos.length === 0) {
|
|
1431
|
+
grid!.innerHTML = `<div class="github-empty-state"><p>No repositories found for "${escapeHtml(user)}"</p></div>`;
|
|
1432
|
+
} else {
|
|
1433
|
+
// Show filter row when there are results
|
|
1434
|
+
if (filterRow) filterRow.style.display = 'flex';
|
|
1435
|
+
|
|
1436
|
+
grid!.innerHTML = data.repos.map((repo: any) => {
|
|
1437
|
+
const langColor = LANG_COLORS[repo.language] || '#8b8b8b';
|
|
1438
|
+
const sizeStr = repo.size > 1024 ? `${(repo.size / 1024).toFixed(1)} MB` : `${repo.size} KB`;
|
|
1439
|
+
const updatedDate = new Date(repo.updated_at);
|
|
1440
|
+
const timeAgo = _timeAgo(updatedDate);
|
|
1441
|
+
|
|
1442
|
+
return `
|
|
1443
|
+
<div class="github-repo-card" data-clone-url="${escapeHtml(repo.clone_url)}" data-name="${escapeHtml(repo.name)}">
|
|
1444
|
+
<div class="github-repo-header">
|
|
1445
|
+
<span class="github-repo-name">${escapeHtml(repo.name)}</span>
|
|
1446
|
+
${repo.stars > 0 ? `<span class="github-repo-stars">\u2b50 ${repo.stars}</span>` : ''}
|
|
1447
|
+
</div>
|
|
1448
|
+
${repo.description ? `<p class="github-repo-desc">${escapeHtml(repo.description.length > 120 ? repo.description.slice(0, 117) + '...' : repo.description)}</p>` : '<p class="github-repo-desc" style="opacity:0.3">No description</p>'}
|
|
1449
|
+
<div class="github-repo-meta">
|
|
1450
|
+
${repo.language ? `<span class="github-repo-lang"><span class="lang-dot" style="background:${langColor}"></span>${escapeHtml(repo.language)}</span>` : ''}
|
|
1451
|
+
<span class="github-repo-size">${sizeStr}</span>
|
|
1452
|
+
<span class="github-repo-updated">${timeAgo}</span>
|
|
1453
|
+
</div>
|
|
1454
|
+
<button class="github-clone-btn" data-url="${escapeHtml(repo.clone_url)}">Clone & Open</button>
|
|
1455
|
+
</div>
|
|
1456
|
+
`;
|
|
1457
|
+
}).join('');
|
|
1458
|
+
|
|
1459
|
+
// Track rendered cards for filtering
|
|
1460
|
+
allRenderedCards = Array.from(grid!.querySelectorAll('.github-repo-card')) as HTMLElement[];
|
|
1461
|
+
|
|
1462
|
+
// Attach clone handlers
|
|
1463
|
+
grid!.querySelectorAll('.github-clone-btn').forEach(btn => {
|
|
1464
|
+
btn.addEventListener('click', (e) => {
|
|
1465
|
+
e.stopPropagation();
|
|
1466
|
+
const url = (btn as HTMLElement).dataset.url!;
|
|
1467
|
+
closeModal();
|
|
1468
|
+
_triggerClone(ctx, url);
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
// Click on card opens GitHub page
|
|
1473
|
+
grid!.querySelectorAll('.github-repo-card').forEach(card => {
|
|
1474
|
+
card.addEventListener('click', (e) => {
|
|
1475
|
+
if ((e.target as HTMLElement).closest('.github-clone-btn')) return;
|
|
1476
|
+
const name = (card as HTMLElement).dataset.name;
|
|
1477
|
+
window.open(`https://github.com/${currentUser}/${name}`, '_blank');
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Pagination
|
|
1483
|
+
if (data.hasNext || data.hasPrev) {
|
|
1484
|
+
if (pagination) pagination.style.display = 'flex';
|
|
1485
|
+
if (prevBtn) prevBtn.disabled = !data.hasPrev;
|
|
1486
|
+
if (nextBtn) nextBtn.disabled = !data.hasNext;
|
|
1487
|
+
if (pageInfo) pageInfo.textContent = `Page ${data.page}`;
|
|
1488
|
+
} else {
|
|
1489
|
+
if (pagination) pagination.style.display = 'none';
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
} catch (err: any) {
|
|
1493
|
+
grid!.innerHTML = `<div class="github-error">Network error: ${escapeHtml(err.message)}</div>`;
|
|
1494
|
+
} finally {
|
|
1495
|
+
isLoading = false;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
searchBtn?.addEventListener('click', () => searchRepos(1));
|
|
1500
|
+
userInput?.addEventListener('keydown', (e) => {
|
|
1501
|
+
if (e.key === 'Enter') {
|
|
1502
|
+
e.preventDefault();
|
|
1503
|
+
// If URL detected, clone directly on Enter
|
|
1504
|
+
const url = extractRepoUrl(userInput.value.trim());
|
|
1505
|
+
if (url) {
|
|
1506
|
+
closeModal();
|
|
1507
|
+
_triggerClone(ctx, url);
|
|
1508
|
+
} else {
|
|
1509
|
+
searchRepos(1);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
sortSelect?.addEventListener('change', () => {
|
|
1514
|
+
if (currentUser) searchRepos(1);
|
|
1515
|
+
});
|
|
1516
|
+
prevBtn?.addEventListener('click', () => searchRepos(currentPage - 1));
|
|
1517
|
+
nextBtn?.addEventListener('click', () => searchRepos(currentPage + 1));
|
|
1518
|
+
|
|
1519
|
+
// Load last searched user from localStorage
|
|
1520
|
+
const lastUser = localStorage.getItem('gitcanvas:lastGithubUser');
|
|
1521
|
+
if (lastUser && userInput) userInput.value = lastUser;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// ─── Trigger clone (self-contained, uses clone-stream API) ──
|
|
1525
|
+
function _triggerClone(ctx: CanvasContext, url: string) {
|
|
1526
|
+
const cloneStatus = document.getElementById('cloneStatus');
|
|
1527
|
+
if (!cloneStatus) return;
|
|
1528
|
+
|
|
1529
|
+
cloneStatus.style.display = 'block';
|
|
1530
|
+
cloneStatus.className = 'clone-status cloning';
|
|
1531
|
+
cloneStatus.innerHTML = `
|
|
1532
|
+
<div class="clone-progress-text">⏳ Cloning...</div>
|
|
1533
|
+
<div class="clone-progress-bar"><div class="clone-progress-fill" style="width: 0%"></div></div>
|
|
1534
|
+
`;
|
|
1535
|
+
|
|
1536
|
+
const progressText = cloneStatus.querySelector('.clone-progress-text') as HTMLElement;
|
|
1537
|
+
const progressFill = cloneStatus.querySelector('.clone-progress-fill') as HTMLElement;
|
|
1538
|
+
|
|
1539
|
+
fetch('/api/repo/clone-stream', {
|
|
1540
|
+
method: 'POST',
|
|
1541
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1542
|
+
body: JSON.stringify({ url })
|
|
1543
|
+
}).then(async (res) => {
|
|
1544
|
+
const contentType = res.headers.get('content-type') || '';
|
|
1545
|
+
if (contentType.includes('application/json')) {
|
|
1546
|
+
const data = await res.json();
|
|
1547
|
+
if (!res.ok || data.error) {
|
|
1548
|
+
cloneStatus.className = 'clone-status error';
|
|
1549
|
+
cloneStatus.textContent = '❌ ' + (data.error || 'Clone failed');
|
|
1550
|
+
setTimeout(() => { cloneStatus.style.display = 'none'; }, 5000);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
// Cached
|
|
1554
|
+
cloneStatus.className = 'clone-status success';
|
|
1555
|
+
cloneStatus.textContent = '✅ Updated — loading...';
|
|
1556
|
+
_addRecentRepo(data.path);
|
|
1557
|
+
_refreshRepoDropdown();
|
|
1558
|
+
const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
1559
|
+
if (repoSel) repoSel.value = data.path;
|
|
1560
|
+
loadRepository(ctx, data.path);
|
|
1561
|
+
setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// SSE stream
|
|
1566
|
+
const reader = res.body!.getReader();
|
|
1567
|
+
const decoder = new TextDecoder();
|
|
1568
|
+
let buffer = '';
|
|
1569
|
+
while (true) {
|
|
1570
|
+
const { done, value } = await reader.read();
|
|
1571
|
+
if (done) break;
|
|
1572
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1573
|
+
const events = buffer.split('\n\n');
|
|
1574
|
+
buffer = events.pop() || '';
|
|
1575
|
+
for (const evt of events) {
|
|
1576
|
+
if (!evt.trim()) continue;
|
|
1577
|
+
const eventMatch = evt.match(/^event:\s*(.+)/m);
|
|
1578
|
+
const dataMatch = evt.match(/^data:\s*(.+)/m);
|
|
1579
|
+
if (!dataMatch) continue;
|
|
1580
|
+
try {
|
|
1581
|
+
const payload = JSON.parse(dataMatch[1]);
|
|
1582
|
+
const evtType = eventMatch?.[1] || 'progress';
|
|
1583
|
+
if (evtType === 'progress' && progressText && progressFill) {
|
|
1584
|
+
progressText.textContent = `⏳ ${payload.message || 'Cloning...'}`;
|
|
1585
|
+
if (payload.percent != null) progressFill.style.width = `${payload.percent}%`;
|
|
1586
|
+
} else if (evtType === 'done') {
|
|
1587
|
+
cloneStatus.className = 'clone-status success';
|
|
1588
|
+
cloneStatus.textContent = '✅ Cloned — loading...';
|
|
1589
|
+
_addRecentRepo(payload.path);
|
|
1590
|
+
_refreshRepoDropdown();
|
|
1591
|
+
const repoSel2 = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
1592
|
+
if (repoSel2) repoSel2.value = payload.path;
|
|
1593
|
+
loadRepository(ctx, payload.path);
|
|
1594
|
+
setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
|
|
1595
|
+
} else if (evtType === 'error') {
|
|
1596
|
+
cloneStatus.className = 'clone-status error';
|
|
1597
|
+
cloneStatus.textContent = '❌ ' + (payload.error || 'Clone failed');
|
|
1598
|
+
setTimeout(() => { cloneStatus.style.display = 'none'; }, 5000);
|
|
1599
|
+
}
|
|
1600
|
+
} catch { /* skip unparseable */ }
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}).catch(err => {
|
|
1604
|
+
cloneStatus.className = 'clone-status error';
|
|
1605
|
+
cloneStatus.textContent = '❌ ' + err.message;
|
|
1606
|
+
setTimeout(() => { cloneStatus.style.display = 'none'; }, 5000);
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ─── Time ago helper ────────────────────────────────────
|
|
1611
|
+
function _timeAgo(date: Date): string {
|
|
1612
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
1613
|
+
if (seconds < 60) return 'just now';
|
|
1614
|
+
const minutes = Math.floor(seconds / 60);
|
|
1615
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1616
|
+
const hours = Math.floor(minutes / 60);
|
|
1617
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1618
|
+
const days = Math.floor(hours / 24);
|
|
1619
|
+
if (days < 30) return `${days}d ago`;
|
|
1620
|
+
const months = Math.floor(days / 30);
|
|
1621
|
+
if (months < 12) return `${months}mo ago`;
|
|
1622
|
+
return `${Math.floor(months / 12)}y ago`;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// ─── Local Directory Drag and Drop ──────────────────────
|
|
1626
|
+
function setupDragAndDrop(ctx: CanvasContext) {
|
|
1627
|
+
window.addEventListener('dragover', (e) => {
|
|
1628
|
+
// Allow dropping if we drag over canvas/viewport
|
|
1629
|
+
e.preventDefault();
|
|
1630
|
+
if (e.dataTransfer) {
|
|
1631
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
window.addEventListener('drop', async (e) => {
|
|
1636
|
+
e.preventDefault();
|
|
1637
|
+
|
|
1638
|
+
if (!e.dataTransfer || !e.dataTransfer.items) return;
|
|
1639
|
+
const items = e.dataTransfer.items;
|
|
1640
|
+
|
|
1641
|
+
const filesToUpload: File[] = [];
|
|
1642
|
+
|
|
1643
|
+
// Helper to recursively read directory contents
|
|
1644
|
+
async function readEntry(entry: any, path = '') {
|
|
1645
|
+
if (entry.isFile) {
|
|
1646
|
+
const file: any = await new Promise(resolve => entry.file(resolve));
|
|
1647
|
+
// Ignore heavy directories
|
|
1648
|
+
if (!path.includes('node_modules/') && !path.includes('.git/') && !path.includes('.bun/')) {
|
|
1649
|
+
file.fullPath = path + entry.name;
|
|
1650
|
+
filesToUpload.push(file);
|
|
1651
|
+
}
|
|
1652
|
+
} else if (entry.isDirectory) {
|
|
1653
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.bun') return;
|
|
1654
|
+
const dirReader = entry.createReader();
|
|
1655
|
+
const entries: any[] = await new Promise(resolve => {
|
|
1656
|
+
const results: any[] = [];
|
|
1657
|
+
const readNext = () => {
|
|
1658
|
+
dirReader.readEntries((ent: any[]) => {
|
|
1659
|
+
if (ent.length === 0) resolve(results);
|
|
1660
|
+
else { results.push(...ent); readNext(); }
|
|
1661
|
+
});
|
|
1662
|
+
};
|
|
1663
|
+
readNext();
|
|
1664
|
+
});
|
|
1665
|
+
for (const ent of entries) {
|
|
1666
|
+
await readEntry(ent, path + entry.name + '/');
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Display a loading indication
|
|
1672
|
+
const cloneStatus = document.getElementById('cloneStatus');
|
|
1673
|
+
const cloneInput = document.getElementById('cloneUrlInput') as HTMLInputElement;
|
|
1674
|
+
if (cloneStatus) {
|
|
1675
|
+
cloneStatus.style.display = 'block';
|
|
1676
|
+
cloneStatus.className = 'clone-status cloning';
|
|
1677
|
+
cloneStatus.innerHTML = `
|
|
1678
|
+
<div class="clone-progress-text">⏳ Reading dropped files...</div>
|
|
1679
|
+
<div class="clone-progress-bar"><div class="clone-progress-fill" style="width: 50%"></div></div>
|
|
1680
|
+
`;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
try {
|
|
1684
|
+
for (let i = 0; i < items.length; i++) {
|
|
1685
|
+
const item = items[i];
|
|
1686
|
+
if (item.kind === 'file') {
|
|
1687
|
+
const entry = item.webkitGetAsEntry();
|
|
1688
|
+
if (entry) await readEntry(entry);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
if (filesToUpload.length === 0) {
|
|
1693
|
+
if (cloneStatus) {
|
|
1694
|
+
cloneStatus.className = 'clone-status error';
|
|
1695
|
+
cloneStatus.textContent = '❌ No valid files found in drop';
|
|
1696
|
+
setTimeout(() => cloneStatus.style.display = 'none', 3000);
|
|
1697
|
+
}
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
if (cloneStatus) {
|
|
1702
|
+
const progressText = cloneStatus.querySelector('.clone-progress-text');
|
|
1703
|
+
if (progressText) progressText.textContent = `⏳ Uploading ${filesToUpload.length} files...`;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const formData = new FormData();
|
|
1707
|
+
filesToUpload.forEach(f => {
|
|
1708
|
+
formData.append('files', f, (f as any).fullPath);
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
const res = await fetch('/api/repo/upload', {
|
|
1712
|
+
method: 'POST',
|
|
1713
|
+
body: formData
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
const data = await res.json();
|
|
1717
|
+
|
|
1718
|
+
if (!res.ok || data.error) {
|
|
1719
|
+
throw new Error(data.error || 'Upload failed');
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (cloneStatus) {
|
|
1723
|
+
cloneStatus.className = 'clone-status success';
|
|
1724
|
+
cloneStatus.textContent = '✅ Upload complete — loading...';
|
|
1725
|
+
if (cloneInput) cloneInput.value = '';
|
|
1726
|
+
|
|
1727
|
+
// Add to recent repos dropdown and load it
|
|
1728
|
+
const repoPath = data.path;
|
|
1729
|
+
_addRecentRepo(repoPath);
|
|
1730
|
+
_refreshRepoDropdown();
|
|
1731
|
+
const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
1732
|
+
if (repoSel) repoSel.value = repoPath;
|
|
1733
|
+
import('./repo').then(m => m.loadRepository(ctx, repoPath));
|
|
1734
|
+
|
|
1735
|
+
setTimeout(() => cloneStatus.style.display = 'none', 3000);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
} catch (err: any) {
|
|
1739
|
+
if (cloneStatus) {
|
|
1740
|
+
cloneStatus.className = 'clone-status error';
|
|
1741
|
+
cloneStatus.textContent = '❌ ' + (err.message || 'Error processing drop');
|
|
1742
|
+
setTimeout(() => cloneStatus.style.display = 'none', 5000);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|