gitmaps 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +5 -11
  2. package/app/[owner]/[repo]/page.client.tsx +5 -0
  3. package/app/[owner]/[repo]/page.tsx +6 -0
  4. package/app/[slug]/page.client.tsx +5 -0
  5. package/app/[slug]/page.tsx +6 -0
  6. package/app/api/manifest.json/route.ts +20 -0
  7. package/app/api/pwa-icon/route.ts +14 -0
  8. package/app/api/repo/clone-stream/route.ts +20 -12
  9. package/app/api/repo/imports/route.ts +21 -3
  10. package/app/api/repo/list/route.ts +30 -0
  11. package/app/api/repo/upload/route.ts +6 -9
  12. package/app/api/sw.js/route.ts +70 -0
  13. package/app/galaxy-canvas/page.client.tsx +2 -0
  14. package/app/galaxy-canvas/page.tsx +5 -0
  15. package/app/globals.css +477 -95
  16. package/app/icon.png +0 -0
  17. package/app/layout.tsx +30 -2
  18. package/app/lib/canvas-text.ts +4 -72
  19. package/app/lib/canvas.ts +1 -1
  20. package/app/lib/card-arrangement.ts +21 -7
  21. package/app/lib/card-context-menu.tsx +2 -2
  22. package/app/lib/card-groups.ts +9 -2
  23. package/app/lib/cards.tsx +3 -1
  24. package/app/lib/connections.tsx +34 -43
  25. package/app/lib/events.tsx +25 -0
  26. package/app/lib/file-card-plugin.ts +14 -0
  27. package/app/lib/file-preview.ts +68 -41
  28. package/app/lib/galaxydraw-bridge.ts +5 -0
  29. package/app/lib/global-search.ts +48 -27
  30. package/app/lib/layers.tsx +17 -18
  31. package/app/lib/perf-overlay.ts +78 -0
  32. package/app/lib/positions.ts +1 -1
  33. package/app/lib/repo.tsx +18 -8
  34. package/app/lib/shortcuts-panel.ts +2 -0
  35. package/app/lib/viewport-culling.ts +7 -0
  36. package/app/page.client.tsx +72 -18
  37. package/app/page.tsx +22 -86
  38. package/banner.png +0 -0
  39. package/package.json +2 -2
  40. package/packages/galaxydraw/README.md +2 -2
  41. package/packages/galaxydraw/package.json +1 -1
  42. package/server.ts +1 -1
  43. package/app/api/connections/route.ts +0 -72
  44. package/app/api/positions/route.ts +0 -80
  45. package/app/api/repo/browse/route.ts +0 -55
  46. package/app/lib/pr-review.ts +0 -374
package/app/icon.png ADDED
Binary file
package/app/layout.tsx CHANGED
@@ -11,9 +11,11 @@ export default function RootLayout({ children }: { children: any }) {
11
11
  <head>
12
12
  <meta charSet="UTF-8" />
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
14
- <meta name="description" content="GitMaps - See your codebase in a new dimension. Spatial code explorer." />
14
+ <meta name="description" content="Transcend the file tree. GitMaps renders knowledge on an infinite canvas — with layers, time-travel, and a minimap to never lose context." />
15
15
  <title>GitMaps — Spatial Code Explorer</title>
16
- <link rel="icon" href="data:," />
16
+ <link rel="icon" type="image/png" href="/api/pwa-icon" />
17
+ <link rel="manifest" href="/api/manifest.json" />
18
+ <meta name="theme-color" content="#7c3aed" />
17
19
  <link
18
20
  href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
19
21
  rel="stylesheet"
@@ -279,6 +281,25 @@ export default function RootLayout({ children }: { children: any }) {
279
281
  <div className="changed-files-list" id="changedFilesList"></div>
280
282
  </div>
281
283
 
284
+ {/* Connections Panel */}
285
+ <div className="connections-panel" id="connectionsPanel" style={{ display: 'none' }}>
286
+ <div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
287
+ <span className="panel-title" style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
288
+ Connections <span id="connCount" style={{ marginLeft: '6px', background: 'rgba(255,255,255,0.1)', padding: '2px 8px', borderRadius: '12px', fontSize: '0.75rem' }}>0</span>
289
+ </span>
290
+ <button id="closeConnectionsPanel" className="btn-ghost btn-xs" title="Close">
291
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5">
292
+ <line x1="18" y1="6" x2="6" y2="18" />
293
+ <line x1="6" y1="6" x2="18" y2="18" />
294
+ </svg>
295
+ </button>
296
+ </div>
297
+ <div style={{ padding: '8px 16px', fontSize: '0.75rem', color: 'var(--text-muted)', borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,0.2)' }}>
298
+ 💡 Tip: <strong>Alt+Click</strong> any line number then select a target to connect them.
299
+ </div>
300
+ <div className="connections-list" id="connectionsList" style={{ flex: 1, overflowY: 'auto' }}></div>
301
+ </div>
302
+
282
303
 
283
304
 
284
305
  <div className="minimap-container">
@@ -487,6 +508,13 @@ export default function RootLayout({ children }: { children: any }) {
487
508
  </div>
488
509
  </div>
489
510
  </div>
511
+ <script dangerouslySetInnerHTML={{
512
+ __html: `
513
+ if ('serviceWorker' in navigator) {
514
+ navigator.serviceWorker.register('/api/sw.js', { scope: '/' })
515
+ .catch(function(e) { console.warn('[SW] Registration failed:', e); });
516
+ }
517
+ ` }} />
490
518
  </body >
491
519
  </html >
492
520
  );
@@ -5,7 +5,7 @@ export interface CanvasTextOptions {
5
5
  isAllAdded?: boolean;
6
6
  isAllDeleted?: boolean;
7
7
  visibleLineIndices?: Set<number>;
8
- /** File path for PR review comments (if set, enables inline commenting) */
8
+ /** File path for connections (if set, enables line-click for connections) */
9
9
  filePath?: string;
10
10
  }
11
11
 
@@ -43,10 +43,7 @@ export class CanvasTextRenderer {
43
43
  private _longLineGradW: number = 0;
44
44
  /** rAF batching — only one render per animation frame */
45
45
  private _rafId: number = 0;
46
- /** Cached file comments for rendering markers (refreshed on changes) */
47
- private _fileComments: Map<number, import('./pr-review').ReviewComment[]> = new Map();
48
- /** Unsubscribe from review change listener */
49
- private _reviewUnsub: (() => void) | null = null;
46
+
50
47
 
51
48
  constructor(container: HTMLElement, options: CanvasTextOptions) {
52
49
  this.options = options;
@@ -221,46 +218,6 @@ export class CanvasTextRenderer {
221
218
  this.render();
222
219
  }
223
220
  }) as EventListener);
224
- // PR Review — load file comments and wire click handler
225
- if (options.filePath) {
226
- this._loadFileComments();
227
- // Re-render when comments change
228
- import('./pr-review').then(({ onReviewChange }) => {
229
- this._reviewUnsub = onReviewChange(() => {
230
- this._loadFileComments();
231
- this.render();
232
- });
233
- });
234
-
235
- // Click handler on gutter area — open comment popup
236
- container.addEventListener('click', (e: MouseEvent) => {
237
- if (!this.options.filePath) return;
238
- const rect = container.getBoundingClientRect();
239
- const x = e.clientX - rect.left;
240
- const y = e.clientY - rect.top;
241
-
242
- // Only trigger on gutter click (left of content area)
243
- if (x > this.contentX) return;
244
-
245
- // Calculate which line was clicked
246
- const lineIdx = Math.floor((y + this.scrollTop) / this.lineHeight);
247
- if (lineIdx < 0 || lineIdx >= this.drawnLines.length) return;
248
-
249
- const lineData = this.drawnLines[lineIdx];
250
- import('./pr-review').then(({ showCommentPopup }) => {
251
- showCommentPopup(
252
- this.options.filePath!,
253
- lineData.num,
254
- e.clientX,
255
- e.clientY,
256
- () => {
257
- this._loadFileComments();
258
- this.render();
259
- }
260
- );
261
- });
262
- });
263
- }
264
221
 
265
222
  this.render();
266
223
  }
@@ -283,16 +240,7 @@ export class CanvasTextRenderer {
283
240
  }
284
241
  }
285
242
 
286
- /** Load file comments from the review store */
287
- private _loadFileComments() {
288
- if (!this.options.filePath) {
289
- this._fileComments = new Map();
290
- return;
291
- }
292
- import('./pr-review').then(({ getAllFileComments }) => {
293
- this._fileComments = getAllFileComments(this.options.filePath!);
294
- });
295
- }
243
+
296
244
 
297
245
  /** Build a custom scrollbar track on the right side */
298
246
  private _buildScrollTrack(container: HTMLElement) {
@@ -890,23 +838,7 @@ export class CanvasTextRenderer {
890
838
  this.ctx.fillRect(w - 3, y + 3, 2, lh - 6);
891
839
  }
892
840
 
893
- // PR Review comment marker (purple dot in gutter)
894
- if (this._fileComments.has(lineNum)) {
895
- const commentCount = this._fileComments.get(lineNum)!.length;
896
- // Purple dot
897
- this.ctx.beginPath();
898
- this.ctx.arc(diffGutterW - 3, y + lh / 2, 3, 0, Math.PI * 2);
899
- this.ctx.fillStyle = 'rgba(168, 139, 250, 0.9)';
900
- this.ctx.fill();
901
- // Count badge if > 1
902
- if (commentCount > 1) {
903
- this.ctx.fillStyle = 'rgba(168, 139, 250, 0.7)';
904
- this.ctx.font = '9px sans-serif';
905
- this.ctx.fillText(String(commentCount), diffGutterW - 7, y + 2);
906
- // Restore font
907
- this.ctx.font = `${this.fontSize}px "JetBrains Mono", Consolas, monospace`;
908
- }
909
- }
841
+
910
842
  }
911
843
  }
912
844
  }
package/app/lib/canvas.ts CHANGED
@@ -148,7 +148,7 @@ function _rebuildMinimap(ctx: CanvasContext) {
148
148
  // Skip cards with invalid positions (NaN poisons Math.min/max)
149
149
  if (isNaN(x) || isNaN(y)) return;
150
150
  const w = card.offsetWidth || 580;
151
- const h = card.offsetHeight || 200;
151
+ const h = card.offsetHeight || 700;
152
152
  const name = path.split('/').pop() || path;
153
153
  const parts = path.split('/');
154
154
  const displayPath = parts.length > 1 ? parts.slice(-2).join('/') : name;
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { measure } from 'measure-fn';
10
10
  import type { CanvasContext } from './context';
11
- import { savePosition } from './positions';
11
+ import { savePosition, flushPositions } from './positions';
12
12
  import { updateMinimap } from './canvas';
13
13
  import { renderConnections } from './connections';
14
14
 
@@ -35,11 +35,19 @@ function getSelectedCardsInfo(ctx: CanvasContext): CardInfo[] {
35
35
  const x = parseFloat(card.style.left);
36
36
  const y = parseFloat(card.style.top);
37
37
  if (isNaN(x) || isNaN(y)) return;
38
+ // In pill mode, card might be display:none (0px) or rendered as a pill (~24px).
39
+ let cw = card.offsetWidth;
40
+ let ch = card.offsetHeight;
41
+ if (!cw || cw < 100) cw = 580;
42
+ if (!ch || ch < 100) ch = 700;
43
+
44
+ const pos = ctx.positions?.get(path);
45
+ const def = ctx.deferredCards?.get(path);
38
46
  infos.push({
39
47
  path, card,
40
48
  x, y,
41
- w: card.offsetWidth || 580,
42
- h: card.offsetHeight || 400,
49
+ w: pos?.width || def?.size?.width || cw,
50
+ h: pos?.height || def?.size?.height || ch,
43
51
  });
44
52
  }
45
53
  });
@@ -69,16 +77,19 @@ function getSelectedCardsInfo(ctx: CanvasContext): CardInfo[] {
69
77
  if (infos.length < selected.length) {
70
78
  selected.forEach(path => {
71
79
  if (seen.has(path)) return;
72
- const pill = document.querySelector(`.file-card-pill[data-path="${CSS.escape(path)}"]`) as HTMLElement;
80
+ const pill = document.querySelector(`.file-pill[data-path="${CSS.escape(path)}"]`) as HTMLElement;
73
81
  if (pill) {
74
82
  const x = parseFloat(pill.style.left);
75
83
  const y = parseFloat(pill.style.top);
76
84
  if (isNaN(x) || isNaN(y)) return;
85
+ // Pills are tiny (~24px) — use stored size or default card size
86
+ const pos = ctx.positions?.get(path);
87
+ const def = ctx.deferredCards?.get(path);
77
88
  infos.push({
78
89
  path, card: null,
79
90
  x, y,
80
- w: pill.offsetWidth || 580,
81
- h: pill.offsetHeight || 400,
91
+ w: pos?.width || def?.size?.width || 580,
92
+ h: pos?.height || def?.size?.height || 700,
82
93
  });
83
94
  }
84
95
  });
@@ -104,7 +115,7 @@ function applyPosition(ctx: CanvasContext, info: CardInfo, newX: number, newY: n
104
115
  }
105
116
 
106
117
  // Update pill element
107
- const pill = document.querySelector(`.file-card-pill[data-path="${CSS.escape(info.path)}"]`) as HTMLElement;
118
+ const pill = document.querySelector(`.file-pill[data-path="${CSS.escape(info.path)}"]`) as HTMLElement;
108
119
  if (pill) {
109
120
  pill.style.left = `${newX}px`;
110
121
  pill.style.top = `${newY}px`;
@@ -126,6 +137,7 @@ export function arrangeRow(ctx: CanvasContext) {
126
137
  savePosition(ctx, commitHash, info.path, curX, startY);
127
138
  curX += info.w + gap;
128
139
  });
140
+ flushPositions(ctx); // Force immediate save — don't rely on debounce for batch arrange
129
141
  renderConnections(ctx);
130
142
  updateMinimap(ctx);
131
143
  });
@@ -146,6 +158,7 @@ export function arrangeColumn(ctx: CanvasContext) {
146
158
  savePosition(ctx, commitHash, info.path, startX, curY);
147
159
  curY += info.h + gap;
148
160
  });
161
+ flushPositions(ctx);
149
162
  renderConnections(ctx);
150
163
  updateMinimap(ctx);
151
164
  });
@@ -182,6 +195,7 @@ export function arrangeGrid(ctx: CanvasContext) {
182
195
  savePosition(ctx, commitHash, info.path, x, y);
183
196
  });
184
197
 
198
+ flushPositions(ctx);
185
199
  renderConnections(ctx);
186
200
  updateMinimap(ctx);
187
201
  });
@@ -93,8 +93,8 @@ function ContextMenu({ onAction, onActionLayer, onSelectFolder, isInActiveLayer,
93
93
  </div>
94
94
  </div>
95
95
  {isInActiveLayer && (
96
- <button className="ctx-item" onClick={() => onAction('remove-from-layer')} style="color: #ef4444">
97
- Remove from Layer
96
+ <button className="ctx-item" onClick={() => onAction('remove-from-layer')} style="color: #60a5fa">
97
+ Move to Main
98
98
  </button>
99
99
  )}
100
100
  <div className="ctx-divider"></div>
@@ -24,8 +24,8 @@ const activeGroups = new Map<string, CollapsedGroup>();
24
24
 
25
25
  // ─── Persistence ─────────────────────────────────────────
26
26
  function getStorageKey(): string {
27
- const hash = location.hash?.slice(1) || 'default';
28
- return `gitmaps:collapsed-dirs:${hash}`;
27
+ const repoPath = _ctx?.snap().context.repoPath || 'default';
28
+ return `gitmaps:collapsed-dirs:${repoPath}`;
29
29
  }
30
30
 
31
31
  function saveState() {
@@ -45,6 +45,13 @@ function loadState() {
45
45
  } catch { }
46
46
  }
47
47
 
48
+ export function resetCardGroups() {
49
+ for (const group of activeGroups.values()) {
50
+ group.groupCard.remove();
51
+ }
52
+ activeGroups.clear();
53
+ }
54
+
48
55
  // ─── Group card rendering ────────────────────────────────
49
56
  function createGroupCard(dir: string, files: CollapsedGroup['files']): HTMLElement {
50
57
  const card = document.createElement('div');
package/app/lib/cards.tsx CHANGED
@@ -9,7 +9,7 @@ import type { CanvasContext } from './context';
9
9
  import { escapeHtml, getFileIcon, getFileIconClass, showToast } from './utils';
10
10
  import { hideSelectedFiles } from './hidden-files';
11
11
  import { savePosition, getPositionKey, isPathExpandedInPositions, setPathExpandedInPositions } from './positions';
12
- import { updateMinimap, updateCanvasTransform, updateZoomUI, jumpToFile } from './canvas';
12
+ import { updateMinimap, updateCanvasTransform, updateZoomUI, jumpToFile, forceMinimapRebuild } from './canvas';
13
13
  import { updateStatusBarSelected } from './status-bar';
14
14
  import { renderConnections, scheduleRenderConnections, setupConnectionDrag, hasPendingConnection } from './connections';
15
15
  import { highlightSyntax, buildModalDiffHTML } from './syntax';
@@ -226,6 +226,8 @@ export function setupCardInteraction(ctx: CanvasContext, card: HTMLElement, comm
226
226
  savePosition(ctx, commitHash, info.path, x, y);
227
227
  });
228
228
  moveStartPositions = [];
229
+ // Force minimap rebuild so dot positions reflect the drag result
230
+ forceMinimapRebuild(ctx);
229
231
  }
230
232
 
231
233
  action = null;
@@ -824,59 +824,50 @@ export function navigateToConnection(ctx: CanvasContext, conn: any, navigateTo:
824
824
  });
825
825
  }
826
826
 
827
- // ─── Save connections to server ─────────────────────────
828
- export async function saveConnections(ctx: CanvasContext) {
827
+ // ─── Save connections to localStorage ───────────────────
828
+ export function saveConnections(ctx: CanvasContext) {
829
829
  const state = ctx.snap().context;
830
+ const repoPath = state.repoPath;
831
+ if (!repoPath) return;
830
832
  try {
831
- await fetch('/api/connections', {
832
- method: 'POST',
833
- headers: { 'Content-Type': 'application/json' },
834
- body: JSON.stringify({ connections: state.connections })
835
- });
833
+ const key = `gitcanvas:connections:${repoPath}`;
834
+ localStorage.setItem(key, JSON.stringify(state.connections));
836
835
  } catch (e) {
837
836
  measure('connections:saveError', () => e);
838
837
  }
839
838
  }
840
839
 
841
- // ─── Load connections from server ───────────────────────
842
- export async function loadConnections(ctx: CanvasContext) {
843
- return measure('connections:load', async () => {
840
+ // ─── Load connections from localStorage ─────────────────
841
+ export function loadConnections(ctx: CanvasContext) {
842
+ return measure('connections:load', () => {
844
843
  try {
845
- const response = await fetch('/api/connections');
846
- if (!response.ok) return;
847
- const data = await response.json();
848
-
849
- if (data.connections && data.connections.length > 0) {
850
- const conns = data.connections.map(c => ({
851
- id: c.conn_id,
852
- sourceFile: c.source_file,
853
- sourceLineStart: c.source_line_start,
854
- sourceLineEnd: c.source_line_end,
855
- targetFile: c.target_file,
856
- targetLineStart: c.target_line_start,
857
- targetLineEnd: c.target_line_end,
858
- comment: c.comment || '',
859
- }));
860
-
861
- conns.forEach(conn => {
862
- ctx.actor.send({
863
- type: 'START_CONNECTION',
864
- sourceFile: conn.sourceFile,
865
- lineStart: conn.sourceLineStart,
866
- lineEnd: conn.sourceLineEnd,
867
- });
868
- ctx.actor.send({
869
- type: 'COMPLETE_CONNECTION',
870
- targetFile: conn.targetFile,
871
- lineStart: conn.targetLineStart,
872
- lineEnd: conn.targetLineEnd,
873
- comment: conn.comment,
874
- });
844
+ const repoPath = ctx.snap().context.repoPath;
845
+ if (!repoPath) return;
846
+ const key = `gitcanvas:connections:${repoPath}`;
847
+ const stored = localStorage.getItem(key);
848
+ if (!stored) return;
849
+
850
+ const connections = JSON.parse(stored);
851
+ if (!Array.isArray(connections) || connections.length === 0) return;
852
+
853
+ connections.forEach(conn => {
854
+ ctx.actor.send({
855
+ type: 'START_CONNECTION',
856
+ sourceFile: conn.sourceFile,
857
+ lineStart: conn.sourceLineStart,
858
+ lineEnd: conn.sourceLineEnd,
859
+ });
860
+ ctx.actor.send({
861
+ type: 'COMPLETE_CONNECTION',
862
+ targetFile: conn.targetFile,
863
+ lineStart: conn.targetLineStart,
864
+ lineEnd: conn.targetLineEnd,
865
+ comment: conn.comment || '',
875
866
  });
867
+ });
876
868
 
877
- renderConnections(ctx);
878
- buildConnectionMarkers(ctx);
879
- }
869
+ renderConnections(ctx);
870
+ buildConnectionMarkers(ctx);
880
871
  } catch (e) {
881
872
  measure('connections:loadError', () => e);
882
873
  }
@@ -555,6 +555,31 @@ export function setupEventListeners(ctx: CanvasContext) {
555
555
  repoSelect.value = ''; // Keep "Select a repository..." shown
556
556
  }
557
557
 
558
+ // ── Also discover on-disk repos that may not be in localStorage ──
559
+ fetch('/api/repo/list').then(r => r.json()).then((data: any) => {
560
+ if (!data.repos || data.repos.length === 0) return;
561
+ const currentPaths = new Set(recentRepos);
562
+ let added = false;
563
+ for (const repo of data.repos) {
564
+ if (!currentPaths.has(repo.path)) {
565
+ // Add to localStorage recent repos
566
+ _addRecentRepo(repo.path);
567
+ // Add to dropdown (before the __new__ option)
568
+ const opt = document.createElement('option');
569
+ opt.value = repo.path;
570
+ opt.textContent = repo.name;
571
+ opt.title = repo.path;
572
+ const newOpt2 = repoSelect.querySelector('option[value="__new__"]');
573
+ if (newOpt2) {
574
+ repoSelect.insertBefore(opt, newOpt2);
575
+ } else {
576
+ repoSelect.add(opt);
577
+ }
578
+ added = true;
579
+ }
580
+ }
581
+ }).catch(() => { });
582
+
558
583
  repoSelect.addEventListener('change', async () => {
559
584
  const val = repoSelect.value;
560
585
  if (val === '__new__') {
@@ -47,6 +47,13 @@ export function createFileCardPlugin(): CardPlugin {
47
47
  // skipInteraction=true: CardManager handles drag/resize/z-order
48
48
  const { createAllFileCard } = require('./cards');
49
49
  const card = createAllFileCard(ctx, file, data.x, data.y, savedSize, true);
50
+
51
+ const { isDirCollapsed } = require('./card-groups');
52
+ const fileDir = file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/')) : '.';
53
+ if (isDirCollapsed(fileDir)) {
54
+ card.style.display = 'none';
55
+ }
56
+
50
57
  return card;
51
58
  },
52
59
 
@@ -104,6 +111,13 @@ export function createDiffCardPlugin(): CardPlugin {
104
111
 
105
112
  const { createFileCard } = require('./cards');
106
113
  const card = createFileCard(ctx, file, data.x, data.y, commitHash, true);
114
+
115
+ const { isDirCollapsed } = require('./card-groups');
116
+ const fileDir = file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/')) : '.';
117
+ if (isDirCollapsed(fileDir)) {
118
+ card.style.display = 'none';
119
+ }
120
+
107
121
  return card;
108
122
  },
109
123
 
@@ -98,6 +98,7 @@ function renderPreviewCard(path: string): HTMLElement | null {
98
98
  clone.style.position = 'relative';
99
99
  clone.style.left = '0';
100
100
  clone.style.top = '0';
101
+ clone.style.display = 'block'; // CRITICAL: cards are display:none in pill mode
101
102
  clone.style.visibility = 'visible';
102
103
  clone.style.contentVisibility = 'visible';
103
104
  clone.style.opacity = '1';
@@ -112,22 +113,24 @@ function renderPreviewCard(path: string): HTMLElement | null {
112
113
  delete clone.dataset.culled;
113
114
  delete clone.dataset.expanded;
114
115
 
115
- // If the card used canvas-text rendering, re-render body as DOM HTML
116
- const canvasContainer = clone.querySelector('.canvas-container');
117
- if (canvasContainer) {
118
- const { _getCardFileData, _buildFileContentHTML } = require('./cards');
119
- const file = _getCardFileData(existingCard);
120
- if (file?.content) {
121
- const addedLines = file.addedLines || new Set();
122
- const deletedBeforeLine = file.deletedBeforeLine || new Map();
123
- const isAllAdded = file.status === 'added';
124
- const isAllDeleted = file.status === 'deleted';
125
- const html = _buildFileContentHTML(
126
- file.content, file.layerSections, addedLines, deletedBeforeLine,
127
- isAllAdded, isAllDeleted, false, file.lines
128
- );
129
- canvasContainer.outerHTML = html;
130
- }
116
+ // Always re-render body with ALL lines (cards are 120-line limited)
117
+ const { _getCardFileData, _buildFileContentHTML } = require('./cards');
118
+ const file = _getCardFileData(existingCard);
119
+ if (file?.content) {
120
+ const addedLines = file.addedLines || new Set();
121
+ const deletedBeforeLine = file.deletedBeforeLine || new Map();
122
+ const isAllAdded = file.status === 'added';
123
+ const isAllDeleted = file.status === 'deleted';
124
+ const html = _buildFileContentHTML(
125
+ file.content, file.layerSections, addedLines, deletedBeforeLine,
126
+ isAllAdded, isAllDeleted, true, file.lines // true = expanded, show ALL lines
127
+ );
128
+ // Replace body content with full file
129
+ const body = clone.querySelector('.file-card-body');
130
+ if (body) body.innerHTML = html;
131
+ // Also replace canvas-text container if present
132
+ const canvasContainer = clone.querySelector('.canvas-container');
133
+ if (canvasContainer) canvasContainer.outerHTML = html;
131
134
  }
132
135
 
133
136
  return clone;
@@ -267,6 +270,13 @@ function onMouseMove(e: MouseEvent) {
267
270
  if (!isPreviewEnabled) return;
268
271
  if (_isHoveringPopup) return; // Don't hide while interacting with popup
269
272
 
273
+ // Suppress popup during canvas panning (middle-button drag, space-held, isDragging)
274
+ if (e.buttons & 4) { hidePopup(); return; } // middle mouse button held
275
+ if (e.buttons & 1) { hidePopup(); return; } // left mouse button held (dragging)
276
+ const viewport = document.querySelector('.canvas-viewport');
277
+ if (viewport?.classList.contains('space-panning')) { hidePopup(); return; }
278
+ if (_ctx?.isDragging) { hidePopup(); return; }
279
+
270
280
  const gdState = getGalaxyDrawState();
271
281
  if (!gdState || gdState.zoom >= PREVIEW_ZOOM_THRESHOLD) {
272
282
  hidePopup();
@@ -291,19 +301,8 @@ function onMouseMove(e: MouseEvent) {
291
301
  }
292
302
 
293
303
  if (path === currentCardPath) {
294
- // Already showing for this card — just reposition
295
- if (popup && popup.style.opacity === '1') {
296
- const vw = window.innerWidth;
297
- const vh = window.innerHeight;
298
- let x = e.clientX + OFFSET_X;
299
- let y = e.clientY + OFFSET_Y;
300
- if (x + POPUP_MAX_W > vw - 12) x = e.clientX - POPUP_MAX_W - OFFSET_X;
301
- if (y + POPUP_MAX_H > vh - 12) y = e.clientY - POPUP_MAX_H - OFFSET_Y;
302
- x = Math.max(8, x);
303
- y = Math.max(8, y);
304
- popup.style.left = `${x}px`;
305
- popup.style.top = `${y}px`;
306
- }
304
+ // Already showing for this card — DON'T reposition.
305
+ // Keep popup stationary so user can move their mouse to it for scrolling.
307
306
  return;
308
307
  }
309
308
 
@@ -327,6 +326,43 @@ function onMouseOut(e: MouseEvent) {
327
326
  hidePopup();
328
327
  }
329
328
 
329
+ /**
330
+ * Wheel handler on viewport:
331
+ * - If popup visible + mouse over pill/placeholder + NO Ctrl → scroll popup
332
+ * - If Ctrl held → always let canvas zoom (never intercept)
333
+ * - If zoom crosses threshold → hide popup
334
+ */
335
+ function onViewportWheel(e: WheelEvent) {
336
+ // Ctrl+wheel = zoom → never intercept
337
+ if (e.ctrlKey || e.metaKey) {
338
+ // Check if zoom crossed threshold after a tick
339
+ if (currentCardPath) {
340
+ setTimeout(() => {
341
+ const gd = getGalaxyDrawState();
342
+ if (gd && gd.zoom >= PREVIEW_ZOOM_THRESHOLD) hidePopup();
343
+ }, 50);
344
+ }
345
+ return;
346
+ }
347
+
348
+ // If popup is visible and user is hovering over the pill/card that triggered it,
349
+ // forward wheel events to scroll the popup content
350
+ if (popup && currentCardPath && popup.style.opacity === '1') {
351
+ const target = e.target as HTMLElement;
352
+ const pill = target.closest?.('.file-pill') as HTMLElement | null;
353
+ const card = target.closest?.('.file-card') as HTMLElement | null;
354
+ const element = pill || card;
355
+ if (element && element.dataset.path === currentCardPath) {
356
+ // Only intercept if popup has scrollable content
357
+ if (popup.scrollHeight > popup.clientHeight) {
358
+ e.preventDefault();
359
+ e.stopPropagation();
360
+ popup.scrollTop += e.deltaY;
361
+ }
362
+ }
363
+ }
364
+ }
365
+
330
366
  // ─── Public API ──────────────────────────────────────────
331
367
 
332
368
  /**
@@ -343,18 +379,8 @@ export function initFilePreview(viewportEl: HTMLElement, ctx?: CanvasContext) {
343
379
  viewportEl.addEventListener('mousemove', onMouseMove, { passive: true });
344
380
  viewportEl.addEventListener('mouseout', onMouseOut, { passive: true });
345
381
 
346
- // Hide on zoom change (catches scroll-zoom)
347
- viewportEl.addEventListener('wheel', () => {
348
- // Don't hide popup if user is hovering/scrolling it
349
- if (_isHoveringPopup) return;
350
- setTimeout(() => {
351
- if (_isHoveringPopup) return;
352
- const gd = getGalaxyDrawState();
353
- if (gd && gd.zoom >= PREVIEW_ZOOM_THRESHOLD) {
354
- hidePopup();
355
- }
356
- }, 50);
357
- }, { passive: true });
382
+ // Wheel: scroll popup when hovering pill, Ctrl+wheel always zooms
383
+ viewportEl.addEventListener('wheel', onViewportWheel, { passive: false });
358
384
 
359
385
  console.log('[file-preview] Initialized — full card preview below', (PREVIEW_ZOOM_THRESHOLD * 100).toFixed(0) + '% zoom');
360
386
  }
@@ -365,6 +391,7 @@ export function initFilePreview(viewportEl: HTMLElement, ctx?: CanvasContext) {
365
391
  export function destroyFilePreview(viewportEl: HTMLElement) {
366
392
  viewportEl.removeEventListener('mousemove', onMouseMove);
367
393
  viewportEl.removeEventListener('mouseout', onMouseOut);
394
+ viewportEl.removeEventListener('wheel', onViewportWheel);
368
395
  if (popup) {
369
396
  popup.remove();
370
397
  popup = null;
@@ -272,6 +272,9 @@ export function renderAllFilesViaCardManager(ctx: CanvasContext, files: any[]) {
272
272
  clearAllPills(ctx);
273
273
  if (ctx.svgOverlay) ctx.svgOverlay.innerHTML = '';
274
274
 
275
+ const { resetCardGroups, restoreCollapsedDirs } = require('./card-groups');
276
+ resetCardGroups();
277
+
275
278
  const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
276
279
  updateHiddenUI(ctx);
277
280
 
@@ -432,6 +435,8 @@ export function renderAllFilesViaCardManager(ctx: CanvasContext, files: any[]) {
432
435
  }
433
436
  });
434
437
 
438
+ restoreCollapsedDirs(ctx);
439
+
435
440
  console.log(`[gd-bridge] ${createdCount} created, ${deferredCount} deferred (${layerFiles.length} total)`);
436
441
  return true; // Signal: we handled it
437
442
  }