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
package/app/lib/repo.tsx
ADDED
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Repository management — load, commit timeline, select commit, all-files.
|
|
4
|
+
*/
|
|
5
|
+
import { measure } from 'measure-fn';
|
|
6
|
+
import { render } from 'melina/client';
|
|
7
|
+
import type { CanvasContext } from './context';
|
|
8
|
+
import { escapeHtml, formatDate, showToast } from './utils';
|
|
9
|
+
import { clearCanvas, getAutoColumnCount, updateCanvasTransform, updateZoomUI, updateMinimap, forceMinimapRebuild } from './canvas';
|
|
10
|
+
import { performViewportCulling } from './viewport-culling';
|
|
11
|
+
import { getPositionKey, loadSavedPositions } from './positions';
|
|
12
|
+
import { updateHiddenUI } from './hidden-files';
|
|
13
|
+
import { showLoadingProgress, updateLoadingProgress, hideLoadingProgress } from './loading';
|
|
14
|
+
import { createFileCard, createAllFileCard, debounceSaveScroll, expandCardByPath } from './cards';
|
|
15
|
+
import { getActiveLayer } from './layers';
|
|
16
|
+
import { renderConnections, buildConnectionMarkers } from './connections';
|
|
17
|
+
import { renderAllFilesViaCardManager, materializeViewport } from './galaxydraw-bridge';
|
|
18
|
+
import { registerRepo, renderRepoTabs, getNextRepoOffset, isMultiRepoLoad, getLoadedRepos } from './multi-repo';
|
|
19
|
+
import { updateStatusBarRepo, updateStatusBarCommit, updateStatusBarFiles } from './status-bar';
|
|
20
|
+
|
|
21
|
+
// Shared: reference to ctx for changed-files panel navigation
|
|
22
|
+
let _panelCtx: CanvasContext | null = null;
|
|
23
|
+
export function setPanelCtx(ctx: CanvasContext) { _panelCtx = ctx; }
|
|
24
|
+
|
|
25
|
+
// Dedup guard: prevent concurrent or duplicate loadRepository calls
|
|
26
|
+
let _loadingRepo: string | null = null;
|
|
27
|
+
|
|
28
|
+
// ─── Load repository ─────────────────────────────────────
|
|
29
|
+
export async function loadRepository(ctx: CanvasContext, repoPath: string) {
|
|
30
|
+
if (!repoPath) return;
|
|
31
|
+
|
|
32
|
+
// Prevent duplicate loads of the same repo (e.g. mount triggers both hash + localStorage paths)
|
|
33
|
+
if (_loadingRepo === repoPath) {
|
|
34
|
+
console.log(`[repo] Skipping duplicate load for "${repoPath}" — already loading`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
_loadingRepo = repoPath;
|
|
38
|
+
_panelCtx = ctx;
|
|
39
|
+
ctx.actor.send({ type: 'LOAD_REPO', path: repoPath });
|
|
40
|
+
|
|
41
|
+
return measure('repo:load', async () => {
|
|
42
|
+
try {
|
|
43
|
+
showLoadingProgress(ctx, 'Loading repository...');
|
|
44
|
+
updateLoadingProgress(ctx, repoPath);
|
|
45
|
+
|
|
46
|
+
const response = await fetch('/api/repo/load', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ path: repoPath })
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!response.ok) throw new Error(await response.text());
|
|
53
|
+
|
|
54
|
+
updateLoadingProgress(ctx, 'Parsing commits...');
|
|
55
|
+
const data = await response.json();
|
|
56
|
+
ctx.actor.send({ type: 'REPO_LOADED', commits: data.commits });
|
|
57
|
+
|
|
58
|
+
// Hide landing overlay
|
|
59
|
+
const landing = document.getElementById('landingOverlay');
|
|
60
|
+
if (landing) landing.style.display = 'none';
|
|
61
|
+
|
|
62
|
+
// Set URL hash to a friendly slug (folder name) instead of full path
|
|
63
|
+
const repoSlug = repoPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repoPath;
|
|
64
|
+
history.replaceState(null, '', '#' + encodeURIComponent(repoSlug));
|
|
65
|
+
localStorage.setItem('gitcanvas:lastRepo', repoPath);
|
|
66
|
+
// Also store slug→path mapping for URL-based loading
|
|
67
|
+
localStorage.setItem(`gitcanvas:slug:${repoSlug}`, repoPath);
|
|
68
|
+
updateStatusBarRepo(repoPath);
|
|
69
|
+
// Save to recent repos list
|
|
70
|
+
const recentKey = 'gitcanvas:recentRepos';
|
|
71
|
+
const recent: string[] = JSON.parse(localStorage.getItem(recentKey) || '[]');
|
|
72
|
+
const filtered = recent.filter(r => r !== repoPath);
|
|
73
|
+
filtered.unshift(repoPath);
|
|
74
|
+
localStorage.setItem(recentKey, JSON.stringify(filtered.slice(0, 10)));
|
|
75
|
+
// Update dropdown if it exists
|
|
76
|
+
const sel = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
77
|
+
if (sel) sel.value = repoPath;
|
|
78
|
+
|
|
79
|
+
updateLoadingProgress(ctx, `Found ${data.commits.length} commits, rendering timeline...`);
|
|
80
|
+
renderCommitTimeline(ctx);
|
|
81
|
+
|
|
82
|
+
// Reload positions for the new repo BEFORE rendering files
|
|
83
|
+
// so cards get placed at their correct saved locations
|
|
84
|
+
ctx.snap().context.repoPath = repoPath;
|
|
85
|
+
await loadSavedPositions(ctx);
|
|
86
|
+
|
|
87
|
+
const viewState = ctx.snap().value?.view;
|
|
88
|
+
// Always load all files first
|
|
89
|
+
updateLoadingProgress(ctx, 'Loading all files...');
|
|
90
|
+
await loadAllFiles(ctx);
|
|
91
|
+
|
|
92
|
+
// Then select the first commit to get diff data
|
|
93
|
+
if (data.commits.length > 0) {
|
|
94
|
+
updateLoadingProgress(ctx, 'Loading commit diff...');
|
|
95
|
+
await selectCommit(ctx, data.commits[0].hash);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
hideLoadingProgress(ctx);
|
|
99
|
+
_loadingRepo = null; // Allow future reloads
|
|
100
|
+
|
|
101
|
+
// Re-render timeline after all async work — the initial renderCommitTimeline
|
|
102
|
+
// at line 76 can get clobbered if DOM re-renders during loadAllFiles/selectCommit
|
|
103
|
+
renderCommitTimeline(ctx);
|
|
104
|
+
|
|
105
|
+
showToast(`Loaded ${data.commits.length} commits`, 'success');
|
|
106
|
+
|
|
107
|
+
// Register in multi-repo workspace
|
|
108
|
+
registerRepo(ctx, repoPath, data.commits, ctx.allFilesData || []);
|
|
109
|
+
renderRepoTabs(ctx);
|
|
110
|
+
|
|
111
|
+
// Trigger onboarding for first-time users
|
|
112
|
+
if (!localStorage.getItem('gitcanvas:onboarded')) {
|
|
113
|
+
import('./onboarding').then(m => m.startOnboarding(ctx));
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
hideLoadingProgress(ctx);
|
|
117
|
+
_loadingRepo = null; // Allow retry
|
|
118
|
+
ctx.actor.send({ type: 'REPO_ERROR', error: err.message });
|
|
119
|
+
measure('repo:loadError', () => err);
|
|
120
|
+
showToast(`Failed: ${err.message} `, 'error');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Load all files (working tree) ───────────────────────
|
|
126
|
+
export async function loadAllFiles(ctx: CanvasContext) {
|
|
127
|
+
const state = ctx.snap().context;
|
|
128
|
+
if (!state.repoPath) return;
|
|
129
|
+
|
|
130
|
+
return measure('allfiles:load', async () => {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch('/api/repo/tree', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({ path: state.repoPath })
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!response.ok) throw new Error(await response.text());
|
|
139
|
+
|
|
140
|
+
const data = await response.json();
|
|
141
|
+
ctx.actor.send({ type: 'ALL_FILES_LOADED', files: data.files });
|
|
142
|
+
ctx.allFilesData = data.files;
|
|
143
|
+
renderAllFilesOnCanvas(ctx, data.files);
|
|
144
|
+
const fileCountEl = document.getElementById('fileCount');
|
|
145
|
+
if (fileCountEl) fileCountEl.textContent = data.total;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
measure('allfiles:loadError', () => err);
|
|
148
|
+
showToast(`Failed to load files: ${err.message} `, 'error');
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── JSX Components for commit sidebar ──────────────────
|
|
154
|
+
function CommitItem({ commit, lane, color, onClick }: { commit: any; lane: number; color: string; onClick: () => void }) {
|
|
155
|
+
// Derive handle from email (part before @) — more useful than git config name
|
|
156
|
+
const handle = commit.email
|
|
157
|
+
? commit.email.split('@')[0]
|
|
158
|
+
: commit.author;
|
|
159
|
+
|
|
160
|
+
// Calculate indentation based on visual lanes
|
|
161
|
+
const paddingLeft = 16 + lane * 14;
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
className="commit-item"
|
|
166
|
+
data-hash={commit.hash}
|
|
167
|
+
data-lane={lane}
|
|
168
|
+
style={`padding-left: ${paddingLeft}px; --timeline-color: ${color};`}
|
|
169
|
+
onClick={onClick}
|
|
170
|
+
>
|
|
171
|
+
<div className="commit-hash">{commit.hash.substring(0, 7)}</div>
|
|
172
|
+
<div className="commit-message">
|
|
173
|
+
{commit.refs && commit.refs.length > 0 && (
|
|
174
|
+
<span className="commit-refs">
|
|
175
|
+
{commit.refs.map(r => <span className="commit-ref-badge">{r}</span>)}
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
{commit.message}
|
|
179
|
+
</div>
|
|
180
|
+
<div className="commit-meta">
|
|
181
|
+
<span className="commit-author">👤 {handle}</span>
|
|
182
|
+
<span>{formatDate(commit.date)}</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function CommitInfo({ hash, message, allFiles, changedCount }: {
|
|
189
|
+
hash?: string; message?: string; allFiles?: boolean; changedCount?: number;
|
|
190
|
+
}) {
|
|
191
|
+
return (
|
|
192
|
+
<>
|
|
193
|
+
{allFiles && <span style="color: var(--accent-tertiary)">All Files</span>}
|
|
194
|
+
{hash ? (
|
|
195
|
+
<span className="commit-hash">{hash.substring(0, 7)}</span>
|
|
196
|
+
) : null}
|
|
197
|
+
{message ? (
|
|
198
|
+
<span style="color: var(--text-secondary)">{message}</span>
|
|
199
|
+
) : null}
|
|
200
|
+
{!hash && allFiles ? (
|
|
201
|
+
<span style="color: var(--text-muted)">Working tree</span>
|
|
202
|
+
) : null}
|
|
203
|
+
{changedCount !== undefined ? (
|
|
204
|
+
<span style="color: var(--text-muted); font-size: 0.7rem">• {changedCount} changed</span>
|
|
205
|
+
) : null}
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function updateCommitInfo(hash?: string, message?: string, allFiles?: boolean, changedCount?: number) {
|
|
211
|
+
const el = document.getElementById('currentCommitInfo');
|
|
212
|
+
if (el) render(<CommitInfo hash={hash} message={message} allFiles={allFiles} changedCount={changedCount} />, el);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Commit timeline render ──────────────────────────────
|
|
216
|
+
export function renderCommitTimeline(ctx: CanvasContext) {
|
|
217
|
+
measure('timeline:render', () => {
|
|
218
|
+
const container = document.getElementById('timelineContainer');
|
|
219
|
+
const countBadge = document.getElementById('commitCount');
|
|
220
|
+
const state = ctx.snap().context;
|
|
221
|
+
const commitsList = state.commits;
|
|
222
|
+
|
|
223
|
+
if (countBadge) countBadge.textContent = commitsList.length;
|
|
224
|
+
|
|
225
|
+
if (!container) return;
|
|
226
|
+
|
|
227
|
+
if (commitsList.length === 0) {
|
|
228
|
+
render(
|
|
229
|
+
<div className="empty-state">
|
|
230
|
+
<span style="opacity:0.4;font-size:32px">🕐</span>
|
|
231
|
+
<p>No commits found</p>
|
|
232
|
+
</div>,
|
|
233
|
+
container
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Branch graph calculation
|
|
239
|
+
const lanes: (string | null)[] = [];
|
|
240
|
+
const nodes: any[] = [];
|
|
241
|
+
const colors = ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'];
|
|
242
|
+
|
|
243
|
+
commitsList.forEach((commit, i) => {
|
|
244
|
+
// Find the lane reserved for this commit by a previous parent assignment
|
|
245
|
+
let laneIndex = lanes.indexOf(commit.hash);
|
|
246
|
+
if (laneIndex < 0) {
|
|
247
|
+
// No lane reserved — find first empty slot
|
|
248
|
+
laneIndex = lanes.findIndex(h => !h);
|
|
249
|
+
if (laneIndex < 0) laneIndex = lanes.length;
|
|
250
|
+
}
|
|
251
|
+
// Clear the reservation (we're processing this commit now)
|
|
252
|
+
lanes[laneIndex] = null;
|
|
253
|
+
nodes.push({ hash: commit.hash, lane: laneIndex, index: i });
|
|
254
|
+
|
|
255
|
+
if (commit.parents && commit.parents.length > 0) {
|
|
256
|
+
commit.parents.forEach((pHash, pIndex) => {
|
|
257
|
+
const pLaneIndex = lanes.indexOf(pHash);
|
|
258
|
+
if (pIndex === 0) {
|
|
259
|
+
// First parent: continue in the same lane
|
|
260
|
+
if (pLaneIndex < 0) {
|
|
261
|
+
lanes[laneIndex] = pHash;
|
|
262
|
+
}
|
|
263
|
+
// If parent already has a lane (from another child),
|
|
264
|
+
// just leave laneIndex free — the edge drawing handles the visual connection
|
|
265
|
+
} else {
|
|
266
|
+
// Additional parents (merge): assign to a different lane
|
|
267
|
+
if (pLaneIndex < 0) {
|
|
268
|
+
let empty = lanes.findIndex(h => !h);
|
|
269
|
+
if (empty < 0) empty = lanes.length;
|
|
270
|
+
lanes[empty] = pHash;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
render(
|
|
278
|
+
<div style="position:relative;">
|
|
279
|
+
<svg id="timelineGraph" style="position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:0;"></svg>
|
|
280
|
+
<div id="timelineItems">
|
|
281
|
+
{commitsList.map((commit, i) => (
|
|
282
|
+
<CommitItem
|
|
283
|
+
key={commit.hash}
|
|
284
|
+
commit={commit}
|
|
285
|
+
lane={nodes[i].lane}
|
|
286
|
+
color={colors[nodes[i].lane % colors.length]}
|
|
287
|
+
onClick={() => selectCommit(ctx, commit.hash)}
|
|
288
|
+
/>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
</div>,
|
|
292
|
+
container
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
requestAnimationFrame(() => {
|
|
296
|
+
const graph = document.getElementById('timelineGraph');
|
|
297
|
+
if (!graph) return;
|
|
298
|
+
const items = document.querySelectorAll('.commit-item');
|
|
299
|
+
const coords = new Map<string, { x: number, y: number, color: string }>();
|
|
300
|
+
|
|
301
|
+
let maxLane = 0;
|
|
302
|
+
items.forEach((item: HTMLElement) => {
|
|
303
|
+
const hash = item.dataset.hash;
|
|
304
|
+
const lane = parseInt(item.dataset.lane || '0');
|
|
305
|
+
if (lane > maxLane) maxLane = lane;
|
|
306
|
+
// Center of the lane dot, shifted to accommodate the graph drawing
|
|
307
|
+
const x = 16 + lane * 14;
|
|
308
|
+
// offsetTop is relative to the relative parent div we just wrapped it in
|
|
309
|
+
const y = item.offsetTop + item.offsetHeight / 2;
|
|
310
|
+
coords.set(hash, { x, y, color: colors[lane % colors.length] });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
let svgContent = '';
|
|
314
|
+
|
|
315
|
+
// Draw edges
|
|
316
|
+
commitsList.forEach(commit => {
|
|
317
|
+
const start = coords.get(commit.hash);
|
|
318
|
+
if (!start) return;
|
|
319
|
+
|
|
320
|
+
(commit.parents || []).forEach((pHash, pIdx) => {
|
|
321
|
+
const end = coords.get(pHash);
|
|
322
|
+
if (!end) return;
|
|
323
|
+
|
|
324
|
+
const isMerge = pIdx > 0;
|
|
325
|
+
const pathColor = isMerge ? end.color : start.color;
|
|
326
|
+
|
|
327
|
+
if (start.x === end.x) {
|
|
328
|
+
svgContent += `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
|
|
329
|
+
} else {
|
|
330
|
+
const midY = start.y + (end.y - start.y) / 2;
|
|
331
|
+
svgContent += `<path d="M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}" fill="none" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Draw nodes
|
|
337
|
+
commitsList.forEach(commit => {
|
|
338
|
+
const p = coords.get(commit.hash);
|
|
339
|
+
if (!p) return;
|
|
340
|
+
let dot = `<circle cx="${p.x}" cy="${p.y}" r="4.5" fill="${p.color}" stroke="var(--bg-secondary)" stroke-width="2" />`;
|
|
341
|
+
if (commit.refs && commit.refs.length > 0) {
|
|
342
|
+
dot += `<circle cx="${p.x}" cy="${p.y}" r="7" fill="none" stroke="${p.color}" stroke-opacity="0.8" stroke-width="1.5" />`;
|
|
343
|
+
}
|
|
344
|
+
svgContent += dot;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
graph.innerHTML = svgContent;
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Select commit ───────────────────────────────────────
|
|
353
|
+
export async function selectCommit(ctx: CanvasContext, hash: string) {
|
|
354
|
+
return measure('commit:select', async () => {
|
|
355
|
+
ctx.actor.send({ type: 'SELECT_COMMIT', hash });
|
|
356
|
+
|
|
357
|
+
document.querySelectorAll('.commit-item').forEach(el => {
|
|
358
|
+
el.classList.toggle('active', el.dataset.hash === hash);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const state = ctx.snap().context;
|
|
362
|
+
const commit = state.commits.find(c => c.hash === hash);
|
|
363
|
+
|
|
364
|
+
// Show non-blocking inline progress bar (not overlay)
|
|
365
|
+
_showCommitProgress(true, `${hash.substring(0, 7)} — ${commit?.message || ''}`);
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const response = await fetch('/api/repo/files', {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
headers: { 'Content-Type': 'application/json' },
|
|
371
|
+
body: JSON.stringify({ path: state.repoPath, commit: hash })
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (!response.ok) throw new Error(await response.text());
|
|
375
|
+
|
|
376
|
+
const data = await response.json();
|
|
377
|
+
ctx.actor.send({ type: 'COMMIT_FILES_LOADED', files: data.files });
|
|
378
|
+
ctx.commitFilesData = data.files;
|
|
379
|
+
|
|
380
|
+
// Always re-render all files with highlighted changes
|
|
381
|
+
ctx.changedFilePaths = new Set(data.files.map(f => f.path));
|
|
382
|
+
if (ctx.allFilesData && ctx.allFilesData.length > 0) {
|
|
383
|
+
renderAllFilesOnCanvas(ctx, ctx.allFilesData);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
updateCommitInfo(hash, commit?.message || '', true, data.files.length);
|
|
387
|
+
|
|
388
|
+
const fileCountEl = document.getElementById('fileCount');
|
|
389
|
+
if (fileCountEl) fileCountEl.textContent = ctx.fileCards.size;
|
|
390
|
+
_showCommitProgress(false);
|
|
391
|
+
updateStatusBarCommit(hash);
|
|
392
|
+
updateStatusBarFiles(ctx.fileCards.size);
|
|
393
|
+
|
|
394
|
+
// Populate changed files panel with diff stats
|
|
395
|
+
populateChangedFilesPanel(data.files);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
_showCommitProgress(false);
|
|
398
|
+
measure('commit:selectError', () => err);
|
|
399
|
+
showToast(`Failed: ${err.message} `, 'error');
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ─── Inline commit progress bar (non-blocking) ──────────
|
|
405
|
+
function _showCommitProgress(show: boolean, text?: string) {
|
|
406
|
+
let bar = document.getElementById('commitProgressBar');
|
|
407
|
+
if (show) {
|
|
408
|
+
if (!bar) {
|
|
409
|
+
bar = document.createElement('div');
|
|
410
|
+
bar.id = 'commitProgressBar';
|
|
411
|
+
bar.className = 'commit-progress-bar';
|
|
412
|
+
const canvasArea = document.querySelector('.canvas-area');
|
|
413
|
+
if (canvasArea) {
|
|
414
|
+
canvasArea.insertBefore(bar, canvasArea.querySelector('.canvas-viewport'));
|
|
415
|
+
} else {
|
|
416
|
+
document.body.appendChild(bar);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
bar.innerHTML = `<div class="commit-progress-track"><div class="commit-progress-fill"></div></div>${text ? `<span class="commit-progress-text">${text}</span>` : ''}`;
|
|
420
|
+
bar.style.display = 'flex';
|
|
421
|
+
} else if (bar) {
|
|
422
|
+
bar.style.display = 'none';
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ─── Render files on canvas (commits mode) ───────────────
|
|
427
|
+
export function renderFilesOnCanvas(ctx: CanvasContext, files: any[], commitHash: string) {
|
|
428
|
+
measure('canvas:renderFiles', () => {
|
|
429
|
+
clearCanvas(ctx);
|
|
430
|
+
|
|
431
|
+
const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
|
|
432
|
+
let layerFiles = visibleFiles;
|
|
433
|
+
const activeLayer = getActiveLayer();
|
|
434
|
+
if (activeLayer) {
|
|
435
|
+
layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const cols = Math.min(layerFiles.length, getAutoColumnCount(ctx));
|
|
439
|
+
const cardWidth = 580;
|
|
440
|
+
const cardHeight = 700;
|
|
441
|
+
const gap = 40;
|
|
442
|
+
|
|
443
|
+
layerFiles.forEach((f, index) => {
|
|
444
|
+
const posKey = getPositionKey(f.path, commitHash);
|
|
445
|
+
let x: number, y: number;
|
|
446
|
+
|
|
447
|
+
if (ctx.positions.has(posKey)) {
|
|
448
|
+
const pos = ctx.positions.get(posKey);
|
|
449
|
+
x = pos.x; y = pos.y;
|
|
450
|
+
} else {
|
|
451
|
+
const col = index % cols;
|
|
452
|
+
const row = Math.floor(index / cols);
|
|
453
|
+
x = 50 + col * (cardWidth + gap);
|
|
454
|
+
y = 50 + row * (cardHeight + gap);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const file = { ...f };
|
|
458
|
+
if (activeLayer && activeLayer.files[file.path]) {
|
|
459
|
+
file.layerSections = activeLayer.files[file.path].sections;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const card = createFileCard(ctx, file, x, y, commitHash);
|
|
463
|
+
ctx.canvas.appendChild(card);
|
|
464
|
+
ctx.fileCards.set(file.path, card);
|
|
465
|
+
});
|
|
466
|
+
renderConnections(ctx);
|
|
467
|
+
buildConnectionMarkers(ctx);
|
|
468
|
+
forceMinimapRebuild(ctx);
|
|
469
|
+
// Cull off-screen cards after browser layout (needs rAF for valid dimensions)
|
|
470
|
+
requestAnimationFrame(() => performViewportCulling(ctx));
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Render all files on canvas (working tree) ──────────
|
|
475
|
+
// Virtualized: only creates DOM for cards in/near the viewport.
|
|
476
|
+
// Remaining cards are deferred and materialized on-demand by viewport culling.
|
|
477
|
+
export function renderAllFilesOnCanvas(ctx: CanvasContext, files: any[]) {
|
|
478
|
+
measure('canvas:renderAllFiles', () => {
|
|
479
|
+
// In multi-repo mode, don't clear canvas if adding a second repo
|
|
480
|
+
const isAdditionalRepo = isMultiRepoLoad();
|
|
481
|
+
if (!isAdditionalRepo) {
|
|
482
|
+
clearCanvas(ctx);
|
|
483
|
+
ctx.deferredCards.clear();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Phase 4c: Try CardManager path first ──
|
|
487
|
+
const handled = renderAllFilesViaCardManager(ctx, files);
|
|
488
|
+
if (handled) {
|
|
489
|
+
renderConnections(ctx);
|
|
490
|
+
buildConnectionMarkers(ctx);
|
|
491
|
+
forceMinimapRebuild(ctx);
|
|
492
|
+
// Materialize any deferred cards visible in initial viewport
|
|
493
|
+
requestAnimationFrame(() => {
|
|
494
|
+
materializeViewport(ctx);
|
|
495
|
+
performViewportCulling(ctx);
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Legacy fallback (CardManager not initialized) ──
|
|
501
|
+
const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
|
|
502
|
+
updateHiddenUI(ctx);
|
|
503
|
+
|
|
504
|
+
// Build a map of changed file data (commit diff info)
|
|
505
|
+
const changedFileDataMap = new Map<string, any>();
|
|
506
|
+
if (ctx.commitFilesData) {
|
|
507
|
+
ctx.commitFilesData.forEach(f => changedFileDataMap.set(f.path, f));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let layerFiles = visibleFiles;
|
|
511
|
+
const activeLayer = getActiveLayer();
|
|
512
|
+
if (activeLayer) {
|
|
513
|
+
layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
|
|
514
|
+
} else {
|
|
515
|
+
// Default layer: exclude files that have been moved to other layers
|
|
516
|
+
const { isFileMovedFromDefault } = require('./layers');
|
|
517
|
+
layerFiles = visibleFiles.filter(f => !isFileMovedFromDefault(f.path));
|
|
518
|
+
}
|
|
519
|
+
// Sort by directory to group files spatially (makes dir-labels coherent)
|
|
520
|
+
layerFiles.sort((a, b) => {
|
|
521
|
+
const dirA = a.path.includes('/') ? a.path.substring(0, a.path.lastIndexOf('/')) : '.';
|
|
522
|
+
const dirB = b.path.includes('/') ? b.path.substring(0, b.path.lastIndexOf('/')) : '.';
|
|
523
|
+
if (dirA !== dirB) return dirA.localeCompare(dirB);
|
|
524
|
+
const nameA = a.path.split('/').pop() || a.path;
|
|
525
|
+
const nameB = b.path.split('/').pop() || b.path;
|
|
526
|
+
return nameA.localeCompare(nameB);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Square-ish grid: use ceil(sqrt(n)) columns for a dense rectangle
|
|
530
|
+
const count = layerFiles.length;
|
|
531
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
|
|
532
|
+
const defaultCardWidth = 580;
|
|
533
|
+
const defaultCardHeight = 700;
|
|
534
|
+
const gap = 20;
|
|
535
|
+
const cellW = defaultCardWidth + gap;
|
|
536
|
+
const cellH = defaultCardHeight + gap;
|
|
537
|
+
|
|
538
|
+
// Determine initial viewport rect for virtualization
|
|
539
|
+
const MARGIN = 800; // px beyond viewport to pre-create
|
|
540
|
+
const state = ctx.snap().context;
|
|
541
|
+
const vpEl = ctx.canvasViewport;
|
|
542
|
+
const vpW = vpEl?.clientWidth || window.innerWidth;
|
|
543
|
+
const vpH = vpEl?.clientHeight || window.innerHeight;
|
|
544
|
+
const zoom = state.zoom || 1;
|
|
545
|
+
const offsetX = state.offsetX || 0;
|
|
546
|
+
const offsetY = state.offsetY || 0;
|
|
547
|
+
const worldLeft = (-offsetX - MARGIN) / zoom;
|
|
548
|
+
const worldTop = (-offsetY - MARGIN) / zoom;
|
|
549
|
+
const worldRight = (vpW - offsetX + MARGIN) / zoom;
|
|
550
|
+
const worldBottom = (vpH - offsetY + MARGIN) / zoom;
|
|
551
|
+
|
|
552
|
+
let createdCount = 0;
|
|
553
|
+
let deferredCount = 0;
|
|
554
|
+
|
|
555
|
+
// Cache XState state once outside the loop — avoids N snapshots for N files
|
|
556
|
+
const cachedCardSizes = ctx.snap().context.cardSizes || {};
|
|
557
|
+
|
|
558
|
+
// Multi-repo: offset grid origin to the right of existing repos
|
|
559
|
+
const gridOriginX = isAdditionalRepo ? getNextRepoOffset() : 50;
|
|
560
|
+
const gridOriginY = 50;
|
|
561
|
+
|
|
562
|
+
layerFiles.forEach((f, index) => {
|
|
563
|
+
const isChanged = ctx.changedFilePaths.has(f.path);
|
|
564
|
+
const posKey = `allfiles:${f.path}`;
|
|
565
|
+
let x: number, y: number;
|
|
566
|
+
|
|
567
|
+
if (ctx.positions.has(posKey)) {
|
|
568
|
+
const pos = ctx.positions.get(posKey);
|
|
569
|
+
x = pos.x; y = pos.y;
|
|
570
|
+
} else {
|
|
571
|
+
const col = index % cols;
|
|
572
|
+
const row = Math.floor(index / cols);
|
|
573
|
+
x = gridOriginX + col * cellW;
|
|
574
|
+
y = gridOriginY + row * cellH;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Get saved size (from cached snapshot — no per-file ctx.snap() call)
|
|
578
|
+
let size = cachedCardSizes[f.path];
|
|
579
|
+
if (!size && ctx.positions.has(posKey)) {
|
|
580
|
+
const pos = ctx.positions.get(posKey);
|
|
581
|
+
if (pos.width) size = { width: pos.width, height: pos.height };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Merge diff data into the file for highlighting
|
|
585
|
+
let fileWithDiff = { ...f };
|
|
586
|
+
if (activeLayer && activeLayer.files[fileWithDiff.path]) {
|
|
587
|
+
fileWithDiff.layerSections = activeLayer.files[fileWithDiff.path].sections;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (isChanged && changedFileDataMap.has(fileWithDiff.path)) {
|
|
591
|
+
const diffData = changedFileDataMap.get(fileWithDiff.path);
|
|
592
|
+
|
|
593
|
+
// Use full content from diff data if available (has the latest version)
|
|
594
|
+
if (diffData.content) {
|
|
595
|
+
fileWithDiff.content = diffData.content;
|
|
596
|
+
fileWithDiff.lines = diffData.content.split('\n').length;
|
|
597
|
+
}
|
|
598
|
+
fileWithDiff.status = diffData.status;
|
|
599
|
+
fileWithDiff.hunks = diffData.hunks;
|
|
600
|
+
|
|
601
|
+
// Compute added/deleted line info from hunks
|
|
602
|
+
if (diffData.hunks?.length > 0) {
|
|
603
|
+
const addedLines = new Set<number>();
|
|
604
|
+
// Map: newLineNumber → array of deleted line texts to show before that line
|
|
605
|
+
const deletedBeforeLine = new Map<number, string[]>();
|
|
606
|
+
for (const hunk of diffData.hunks) {
|
|
607
|
+
let newLine = hunk.newStart;
|
|
608
|
+
let pendingDeleted: string[] = [];
|
|
609
|
+
for (const l of hunk.lines) {
|
|
610
|
+
if (l.type === 'add') {
|
|
611
|
+
addedLines.add(newLine);
|
|
612
|
+
// Attach any pending deleted lines before this added line
|
|
613
|
+
if (pendingDeleted.length > 0) {
|
|
614
|
+
const existing = deletedBeforeLine.get(newLine) || [];
|
|
615
|
+
deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
|
|
616
|
+
pendingDeleted = [];
|
|
617
|
+
}
|
|
618
|
+
newLine++;
|
|
619
|
+
} else if (l.type === 'del') {
|
|
620
|
+
pendingDeleted.push(l.content);
|
|
621
|
+
} else {
|
|
622
|
+
// Context line — flush pending deleted before this
|
|
623
|
+
if (pendingDeleted.length > 0) {
|
|
624
|
+
const existing = deletedBeforeLine.get(newLine) || [];
|
|
625
|
+
deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
|
|
626
|
+
pendingDeleted = [];
|
|
627
|
+
}
|
|
628
|
+
newLine++;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Flush remaining deleted lines after the hunk
|
|
632
|
+
if (pendingDeleted.length > 0) {
|
|
633
|
+
const existing = deletedBeforeLine.get(newLine) || [];
|
|
634
|
+
deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
fileWithDiff.addedLines = addedLines;
|
|
638
|
+
fileWithDiff.deletedBeforeLine = deletedBeforeLine;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// All files use uniform default size unless user has a custom saved size
|
|
643
|
+
if (!size) {
|
|
644
|
+
size = { width: defaultCardWidth, height: defaultCardHeight };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── Virtualization: check if card is near the viewport ──
|
|
648
|
+
const cardW = size?.width || defaultCardWidth;
|
|
649
|
+
const cardH = size?.height || defaultCardHeight;
|
|
650
|
+
const inViewport = (
|
|
651
|
+
x + cardW > worldLeft &&
|
|
652
|
+
x < worldRight &&
|
|
653
|
+
y + cardH > worldTop &&
|
|
654
|
+
y < worldBottom
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
if (inViewport) {
|
|
658
|
+
// Create DOM immediately
|
|
659
|
+
const card = createAllFileCard(ctx, fileWithDiff, x, y, size);
|
|
660
|
+
if (isChanged) {
|
|
661
|
+
card.classList.add('file-card--changed');
|
|
662
|
+
card.dataset.changed = 'true';
|
|
663
|
+
}
|
|
664
|
+
ctx.canvas.appendChild(card);
|
|
665
|
+
ctx.fileCards.set(f.path, card);
|
|
666
|
+
|
|
667
|
+
// Restore scroll position
|
|
668
|
+
const scrollKey = `scroll:${f.path}`;
|
|
669
|
+
if (ctx.positions.has(scrollKey)) {
|
|
670
|
+
const savedScroll = ctx.positions.get(scrollKey);
|
|
671
|
+
requestAnimationFrame(() => {
|
|
672
|
+
const body = card.querySelector('.file-card-body');
|
|
673
|
+
if (body && savedScroll.x) body.scrollTop = savedScroll.x;
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
createdCount++;
|
|
677
|
+
} else {
|
|
678
|
+
// Defer: store data for lazy creation when it enters viewport
|
|
679
|
+
ctx.deferredCards.set(f.path, { file: fileWithDiff, x, y, size, isChanged });
|
|
680
|
+
deferredCount++;
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
console.log(`[render] Created ${createdCount} cards, deferred ${deferredCount} (total: ${count})`);
|
|
685
|
+
|
|
686
|
+
renderConnections(ctx);
|
|
687
|
+
buildConnectionMarkers(ctx);
|
|
688
|
+
renderDirectoryLabels(ctx);
|
|
689
|
+
forceMinimapRebuild(ctx);
|
|
690
|
+
// Cull off-screen cards after browser layout (needs rAF for valid dimensions)
|
|
691
|
+
requestAnimationFrame(() => performViewportCulling(ctx));
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ─── Directory labels on canvas ──────────────────────────
|
|
696
|
+
// Groups visible file cards by parent directory and renders
|
|
697
|
+
// a world-space label above each directory cluster.
|
|
698
|
+
function renderDirectoryLabels(ctx: CanvasContext) {
|
|
699
|
+
// Remove existing labels
|
|
700
|
+
ctx.canvas?.querySelectorAll('.dir-label').forEach(el => el.remove());
|
|
701
|
+
|
|
702
|
+
// Group cards by parent directory
|
|
703
|
+
const groups = new Map<string, { minX: number; minY: number; maxX: number; count: number }>();
|
|
704
|
+
|
|
705
|
+
const processCard = (path: string, x: number, y: number, w: number) => {
|
|
706
|
+
const dir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '.';
|
|
707
|
+
const g = groups.get(dir);
|
|
708
|
+
if (g) {
|
|
709
|
+
g.minX = Math.min(g.minX, x);
|
|
710
|
+
g.minY = Math.min(g.minY, y);
|
|
711
|
+
g.maxX = Math.max(g.maxX, x + w);
|
|
712
|
+
g.count++;
|
|
713
|
+
} else {
|
|
714
|
+
groups.set(dir, { minX: x, minY: y, maxX: x + w, count: 1 });
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// Created cards (in DOM)
|
|
719
|
+
ctx.fileCards.forEach((card, path) => {
|
|
720
|
+
const x = parseFloat(card.style.left) || 0;
|
|
721
|
+
const y = parseFloat(card.style.top) || 0;
|
|
722
|
+
const w = card.offsetWidth || 580;
|
|
723
|
+
processCard(path, x, y, w);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// Deferred cards (not yet in DOM)
|
|
727
|
+
ctx.deferredCards.forEach((info, path) => {
|
|
728
|
+
const w = info.size?.width || 580;
|
|
729
|
+
processCard(path, info.x, info.y, w);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Only show labels if we have multiple directories
|
|
733
|
+
if (groups.size <= 1) return;
|
|
734
|
+
|
|
735
|
+
const frag = document.createDocumentFragment();
|
|
736
|
+
for (const [dir, g] of groups) {
|
|
737
|
+
const label = document.createElement('div');
|
|
738
|
+
label.className = 'dir-label';
|
|
739
|
+
label.dataset.dir = dir;
|
|
740
|
+
const centerX = (g.minX + g.maxX) / 2;
|
|
741
|
+
label.style.left = `${centerX}px`;
|
|
742
|
+
label.style.top = `${g.minY - 36}px`;
|
|
743
|
+
label.style.transform = 'translateX(-50%)';
|
|
744
|
+
label.innerHTML = `<span class="dir-label-icon">📁</span> ${dir}<span class="dir-label-count">${g.count}</span>`;
|
|
745
|
+
|
|
746
|
+
// Click to collapse directory into a group card
|
|
747
|
+
label.addEventListener('click', (e) => {
|
|
748
|
+
e.stopPropagation();
|
|
749
|
+
import('./card-groups').then(({ toggleDirectoryCollapse }) => {
|
|
750
|
+
toggleDirectoryCollapse(ctx, dir);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
frag.appendChild(label);
|
|
755
|
+
}
|
|
756
|
+
ctx.canvas?.appendChild(frag);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ─── Highlight changed files without re-rendering ────────
|
|
760
|
+
export function highlightChangedFiles(ctx: CanvasContext) {
|
|
761
|
+
measure('allfiles:highlight', () => {
|
|
762
|
+
const hasChanges = ctx.changedFilePaths.size > 0;
|
|
763
|
+
ctx.fileCards.forEach((card, path) => {
|
|
764
|
+
const isChanged = hasChanges && ctx.changedFilePaths.has(path);
|
|
765
|
+
card.classList.toggle('file-card--changed', isChanged);
|
|
766
|
+
card.classList.toggle('file-card--unchanged', hasChanges && !isChanged);
|
|
767
|
+
card.dataset.changed = isChanged ? 'true' : '';
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Rebuild minimap to reflect new highlighting
|
|
771
|
+
forceMinimapRebuild(ctx);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ─── Switch view mode ────────────────────────────────────
|
|
776
|
+
export function switchView(ctx: CanvasContext, mode: string) {
|
|
777
|
+
if (mode === 'allfiles') {
|
|
778
|
+
ctx.actor.send({ type: 'SWITCH_TO_ALLFILES' });
|
|
779
|
+
ctx.allFilesActive = true;
|
|
780
|
+
} else {
|
|
781
|
+
ctx.actor.send({ type: 'SWITCH_TO_COMMITS' });
|
|
782
|
+
ctx.allFilesActive = false;
|
|
783
|
+
ctx.changedFilePaths.clear();
|
|
784
|
+
ctx.commitFilesData = null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
document.getElementById('modeCommits')?.classList.toggle('active', mode === 'commits');
|
|
788
|
+
document.getElementById('modeAllFiles')?.classList.toggle('active', mode === 'allfiles');
|
|
789
|
+
|
|
790
|
+
if (mode === 'allfiles') {
|
|
791
|
+
const state = ctx.snap().context;
|
|
792
|
+
const commitInfo = document.getElementById('currentCommitInfo');
|
|
793
|
+
|
|
794
|
+
if (state.currentCommitHash) {
|
|
795
|
+
const commit = state.commits.find(c => c.hash === state.currentCommitHash);
|
|
796
|
+
if (commitInfo) {
|
|
797
|
+
updateCommitInfo(state.currentCommitHash, commit?.message || '', true);
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
if (commitInfo) {
|
|
801
|
+
updateCommitInfo(undefined, undefined, true);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (state.repoPath) {
|
|
806
|
+
// If we have a selected commit, fetch its changed files first
|
|
807
|
+
// so we can properly highlight/render them as diff cards
|
|
808
|
+
const doRender = async () => {
|
|
809
|
+
// Fetch commit files if we have a commit but don't have diff data yet
|
|
810
|
+
if (state.currentCommitHash && (!ctx.commitFilesData || ctx.commitFilesData.length === 0)) {
|
|
811
|
+
try {
|
|
812
|
+
const response = await fetch('/api/repo/files', {
|
|
813
|
+
method: 'POST',
|
|
814
|
+
headers: { 'Content-Type': 'application/json' },
|
|
815
|
+
body: JSON.stringify({ path: state.repoPath, commit: state.currentCommitHash })
|
|
816
|
+
});
|
|
817
|
+
if (response.ok) {
|
|
818
|
+
const data = await response.json();
|
|
819
|
+
ctx.commitFilesData = data.files;
|
|
820
|
+
ctx.changedFilePaths = new Set(data.files.map(f => f.path));
|
|
821
|
+
ctx.actor.send({ type: 'COMMIT_FILES_LOADED', files: data.files });
|
|
822
|
+
}
|
|
823
|
+
} catch (err) {
|
|
824
|
+
// Continue without diff data
|
|
825
|
+
}
|
|
826
|
+
} else if (state.commitFiles.length > 0) {
|
|
827
|
+
ctx.commitFilesData = state.commitFiles;
|
|
828
|
+
ctx.changedFilePaths = new Set(state.commitFiles.map(f => f.path));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Now load and render all files
|
|
832
|
+
if (ctx.allFilesData && ctx.allFilesData.length > 0) {
|
|
833
|
+
renderAllFilesOnCanvas(ctx, ctx.allFilesData);
|
|
834
|
+
const fileCountEl = document.getElementById('fileCount');
|
|
835
|
+
if (fileCountEl) fileCountEl.textContent = ctx.allFilesData.length;
|
|
836
|
+
} else {
|
|
837
|
+
await loadAllFiles(ctx);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
doRender();
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
const state = ctx.snap().context;
|
|
844
|
+
|
|
845
|
+
// Always re-render the commit timeline sidebar
|
|
846
|
+
renderCommitTimeline(ctx);
|
|
847
|
+
|
|
848
|
+
if (state.currentCommitHash) {
|
|
849
|
+
const commit = state.commits.find(c => c.hash === state.currentCommitHash);
|
|
850
|
+
updateCommitInfo(state.currentCommitHash, commit?.message || '');
|
|
851
|
+
|
|
852
|
+
if (state.commitFiles.length > 0) {
|
|
853
|
+
// We have commit files in state — render them
|
|
854
|
+
ctx.commitFilesData = state.commitFiles;
|
|
855
|
+
renderFilesOnCanvas(ctx, state.commitFiles, state.currentCommitHash);
|
|
856
|
+
populateChangedFilesPanel(state.commitFiles);
|
|
857
|
+
const fileCountEl = document.getElementById('fileCount');
|
|
858
|
+
if (fileCountEl) fileCountEl.textContent = state.commitFiles.length;
|
|
859
|
+
} else {
|
|
860
|
+
// Re-fetch commit files since we cleared commitFilesData
|
|
861
|
+
selectCommit(ctx, state.currentCommitHash);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Re-highlight active commit in sidebar
|
|
865
|
+
requestAnimationFrame(() => {
|
|
866
|
+
document.querySelectorAll('.commit-item').forEach(el => {
|
|
867
|
+
(el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.hash === state.currentCommitHash);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ─── Re-render current view ──────────────────────────────
|
|
875
|
+
export function rerenderCurrentView(ctx: CanvasContext) {
|
|
876
|
+
const data = ctx.allFilesData || ctx.snap().context.allFiles;
|
|
877
|
+
if (data && data.length > 0) {
|
|
878
|
+
renderAllFilesOnCanvas(ctx, data);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ─── Changed files panel (JSX) ──────────────────────────
|
|
883
|
+
function ChangedFilesList({ fileStats, totalAdd, totalDel, count }: {
|
|
884
|
+
fileStats: any[]; totalAdd: number; totalDel: number; count: number;
|
|
885
|
+
}) {
|
|
886
|
+
const statusColors = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444', renamed: '#a78bfa', copied: '#60a5fa' };
|
|
887
|
+
const statusIcons = { added: '+', modified: '~', deleted: '−', renamed: '→', copied: '⊕' };
|
|
888
|
+
|
|
889
|
+
return (
|
|
890
|
+
<>
|
|
891
|
+
<div className="changed-files-summary">
|
|
892
|
+
<span className="stat-add">+{totalAdd}</span>
|
|
893
|
+
<span className="stat-del">−{totalDel}</span>
|
|
894
|
+
<span className="stat-files">{count} file{count > 1 ? 's' : ''}</span>
|
|
895
|
+
</div>
|
|
896
|
+
{fileStats.map(f => {
|
|
897
|
+
const color = statusColors[f.status] || '#a855f7';
|
|
898
|
+
const icon = statusIcons[f.status] || '~';
|
|
899
|
+
const name = f.path.split('/').pop();
|
|
900
|
+
const dir = f.path.includes('/') ? f.path.substring(0, f.path.lastIndexOf('/')) : '';
|
|
901
|
+
return (
|
|
902
|
+
<div
|
|
903
|
+
key={f.path}
|
|
904
|
+
className="changed-file-item"
|
|
905
|
+
title={f.path}
|
|
906
|
+
onClick={() => {
|
|
907
|
+
if (!_panelCtx) return;
|
|
908
|
+
// Animated zoom+pan to the file
|
|
909
|
+
import('./canvas').then(({ jumpToFile }) => {
|
|
910
|
+
jumpToFile(_panelCtx!, f.path);
|
|
911
|
+
});
|
|
912
|
+
}}
|
|
913
|
+
>
|
|
914
|
+
<span className="changed-file-status" style={`color: ${color} `}>{icon}</span>
|
|
915
|
+
<span className="changed-file-name">{name}</span>
|
|
916
|
+
{dir ? <span className="changed-file-dir">{dir}</span> : null}
|
|
917
|
+
<span className="changed-file-stats">
|
|
918
|
+
{f.additions > 0 ? <span className="stat-add">+{f.additions}</span> : null}
|
|
919
|
+
{f.deletions > 0 ? <span className="stat-del">−{f.deletions}</span> : null}
|
|
920
|
+
</span>
|
|
921
|
+
</div>
|
|
922
|
+
);
|
|
923
|
+
})}
|
|
924
|
+
</>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function populateChangedFilesPanel(files: any[]) {
|
|
929
|
+
const panel = document.getElementById('changedFilesPanel');
|
|
930
|
+
const listEl = document.getElementById('changedFilesList');
|
|
931
|
+
if (!panel || !listEl) return;
|
|
932
|
+
|
|
933
|
+
if (files.length === 0) {
|
|
934
|
+
panel.style.display = 'none';
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Filter by active layer — only show changed files that are in the layer
|
|
939
|
+
const activeLayer = getActiveLayer();
|
|
940
|
+
const filteredFiles = activeLayer
|
|
941
|
+
? files.filter(f => !!activeLayer.files[f.path])
|
|
942
|
+
: files;
|
|
943
|
+
|
|
944
|
+
if (filteredFiles.length === 0) {
|
|
945
|
+
panel.style.display = 'none';
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
let totalAdd = 0, totalDel = 0;
|
|
950
|
+
const fileStats = filteredFiles.map(f => {
|
|
951
|
+
let additions = 0, deletions = 0;
|
|
952
|
+
if (f.hunks) {
|
|
953
|
+
f.hunks.forEach(h => {
|
|
954
|
+
h.lines.forEach(l => {
|
|
955
|
+
if (l.type === 'add') additions++;
|
|
956
|
+
else if (l.type === 'del') deletions++;
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
} else if (f.status === 'added' && f.content) {
|
|
960
|
+
additions = f.content.split('\n').length;
|
|
961
|
+
} else if (f.status === 'deleted' && f.content) {
|
|
962
|
+
deletions = f.content.split('\n').length;
|
|
963
|
+
}
|
|
964
|
+
totalAdd += additions;
|
|
965
|
+
totalDel += deletions;
|
|
966
|
+
return { ...f, additions, deletions };
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
render(
|
|
970
|
+
<ChangedFilesList fileStats={fileStats} totalAdd={totalAdd} totalDel={totalDel} count={filteredFiles.length} />,
|
|
971
|
+
listEl
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
if (panel.dataset.manuallyClosed !== 'true') {
|
|
975
|
+
panel.style.display = 'flex';
|
|
976
|
+
}
|
|
977
|
+
}
|