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.
Files changed (121) hide show
  1. package/README.md +167 -0
  2. package/app/api/auth/favorites/route.ts +56 -0
  3. package/app/api/auth/github/callback/route.ts +103 -0
  4. package/app/api/auth/github/route.ts +32 -0
  5. package/app/api/auth/me/route.ts +52 -0
  6. package/app/api/auth/positions/route.ts +50 -0
  7. package/app/api/chat/route.ts +101 -0
  8. package/app/api/connections/route.ts +72 -0
  9. package/app/api/github/repos/route.ts +111 -0
  10. package/app/api/positions/route.ts +80 -0
  11. package/app/api/repo/branch-diff/route.ts +201 -0
  12. package/app/api/repo/branches/route.ts +53 -0
  13. package/app/api/repo/browse/route.ts +55 -0
  14. package/app/api/repo/clone/route.ts +78 -0
  15. package/app/api/repo/clone-stream/route.ts +131 -0
  16. package/app/api/repo/file-content/route.ts +28 -0
  17. package/app/api/repo/file-delete/route.ts +62 -0
  18. package/app/api/repo/file-history/route.ts +45 -0
  19. package/app/api/repo/file-rename/route.ts +83 -0
  20. package/app/api/repo/file-save/route.ts +45 -0
  21. package/app/api/repo/files/route.ts +169 -0
  22. package/app/api/repo/git-blame/route.ts +86 -0
  23. package/app/api/repo/git-commit/route.ts +40 -0
  24. package/app/api/repo/git-heatmap/route.ts +55 -0
  25. package/app/api/repo/imports/route.ts +154 -0
  26. package/app/api/repo/load/route.ts +56 -0
  27. package/app/api/repo/mode/route.ts +14 -0
  28. package/app/api/repo/search/route.ts +127 -0
  29. package/app/api/repo/tree/route.ts +104 -0
  30. package/app/api/repo/upload/route.ts +53 -0
  31. package/app/api/repo/validate-path.ts +53 -0
  32. package/app/canvas_users.db +0 -0
  33. package/app/canvas_users.db-shm +0 -0
  34. package/app/canvas_users.db-wal +0 -0
  35. package/app/globals.css +7899 -0
  36. package/app/layout.tsx +493 -0
  37. package/app/lib/auth.ts +193 -0
  38. package/app/lib/auto-save.ts +137 -0
  39. package/app/lib/branch-compare.ts +443 -0
  40. package/app/lib/breadcrumbs.ts +170 -0
  41. package/app/lib/canvas-export.ts +358 -0
  42. package/app/lib/canvas-text.ts +912 -0
  43. package/app/lib/canvas.ts +564 -0
  44. package/app/lib/card-arrangement.ts +188 -0
  45. package/app/lib/card-context-menu.tsx +453 -0
  46. package/app/lib/card-diff-markers.ts +270 -0
  47. package/app/lib/card-expand.ts +189 -0
  48. package/app/lib/card-groups.ts +246 -0
  49. package/app/lib/cards.tsx +914 -0
  50. package/app/lib/chat.tsx +308 -0
  51. package/app/lib/code-editor.ts +508 -0
  52. package/app/lib/command-palette.ts +262 -0
  53. package/app/lib/connections.tsx +1037 -0
  54. package/app/lib/context.ts +94 -0
  55. package/app/lib/cursor-sharing.ts +281 -0
  56. package/app/lib/dependency-graph.ts +438 -0
  57. package/app/lib/events.tsx +1747 -0
  58. package/app/lib/file-card-plugin.ts +134 -0
  59. package/app/lib/file-modal.tsx +849 -0
  60. package/app/lib/file-preview.ts +400 -0
  61. package/app/lib/file-tabs.ts +318 -0
  62. package/app/lib/galaxydraw-bridge.ts +477 -0
  63. package/app/lib/galaxydraw.test.ts +229 -0
  64. package/app/lib/global-search.ts +264 -0
  65. package/app/lib/goto-definition.ts +224 -0
  66. package/app/lib/heatmap.ts +178 -0
  67. package/app/lib/hidden-files.tsx +222 -0
  68. package/app/lib/layers.ts +0 -0
  69. package/app/lib/layers.tsx +365 -0
  70. package/app/lib/loading.tsx +45 -0
  71. package/app/lib/multi-repo.ts +286 -0
  72. package/app/lib/new-file-dialog.tsx +230 -0
  73. package/app/lib/onboarding.tsx +213 -0
  74. package/app/lib/perf-overlay.ts +360 -0
  75. package/app/lib/positions.ts +176 -0
  76. package/app/lib/pr-review.ts +374 -0
  77. package/app/lib/production-mode.ts +47 -0
  78. package/app/lib/repo.tsx +977 -0
  79. package/app/lib/settings-modal.tsx +374 -0
  80. package/app/lib/settings.ts +97 -0
  81. package/app/lib/shortcuts-panel.ts +141 -0
  82. package/app/lib/status-bar.ts +128 -0
  83. package/app/lib/symbol-outline.ts +212 -0
  84. package/app/lib/syntax.ts +177 -0
  85. package/app/lib/tab-diff.ts +238 -0
  86. package/app/lib/user.tsx +133 -0
  87. package/app/lib/utils.ts +78 -0
  88. package/app/lib/viewport-culling.ts +728 -0
  89. package/app/page.client.tsx +215 -0
  90. package/app/page.tsx +291 -0
  91. package/app/state/machine.js +196 -0
  92. package/app/styles/main.css +2168 -0
  93. package/banner.png +0 -0
  94. package/cli.ts +44 -0
  95. package/package.json +75 -0
  96. package/packages/galaxydraw/README.md +296 -0
  97. package/packages/galaxydraw/banner.png +0 -0
  98. package/packages/galaxydraw/demo/build-static.ts +100 -0
  99. package/packages/galaxydraw/demo/client.ts +154 -0
  100. package/packages/galaxydraw/demo/dist/client.js +8 -0
  101. package/packages/galaxydraw/demo/index.html +256 -0
  102. package/packages/galaxydraw/demo/server.ts +96 -0
  103. package/packages/galaxydraw/dist/index.js +984 -0
  104. package/packages/galaxydraw/dist/index.js.map +16 -0
  105. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  106. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  109. package/packages/galaxydraw/package.json +49 -0
  110. package/packages/galaxydraw/perf.test.ts +284 -0
  111. package/packages/galaxydraw/src/core/cards.ts +435 -0
  112. package/packages/galaxydraw/src/core/engine.ts +339 -0
  113. package/packages/galaxydraw/src/core/events.ts +81 -0
  114. package/packages/galaxydraw/src/core/layout.ts +136 -0
  115. package/packages/galaxydraw/src/core/minimap.ts +216 -0
  116. package/packages/galaxydraw/src/core/state.ts +177 -0
  117. package/packages/galaxydraw/src/core/viewport.ts +106 -0
  118. package/packages/galaxydraw/src/galaxydraw.css +166 -0
  119. package/packages/galaxydraw/src/index.ts +40 -0
  120. package/packages/galaxydraw/tsconfig.json +30 -0
  121. package/server.ts +62 -0
@@ -0,0 +1,477 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * GalaxyDraw Bridge — Adapter between GitMaps and the galaxydraw engine.
4
+ *
5
+ * Wires galaxydraw's CanvasState + CardManager into the existing
6
+ * server-rendered DOM and XState persistence layer.
7
+ *
8
+ * Architecture:
9
+ * - CanvasState manages zoom/pan/transform (replaces manual math)
10
+ * - CardManager creates/defers cards via FileCardPlugin + DiffCardPlugin
11
+ * - XState actor remains source-of-truth for persistence
12
+ * - Server-rendered DOM (#canvasViewport, #canvasContent) stays intact
13
+ */
14
+
15
+ import { CanvasState } from '../../packages/galaxydraw/src/core/state';
16
+ import { CardManager } from '../../packages/galaxydraw/src/core/cards';
17
+ import { EventBus } from '../../packages/galaxydraw/src/core/events';
18
+ import { createFileCardPlugin, createDiffCardPlugin } from './file-card-plugin';
19
+ import type { CanvasContext } from './context';
20
+
21
+ /**
22
+ * Shared galaxydraw state instance.
23
+ * Replaces manual `ctx.canvas.style.transform = ...` calls.
24
+ */
25
+ let _gdState: CanvasState | null = null;
26
+ let _cardManager: CardManager | null = null;
27
+ let _eventBus: EventBus | null = null;
28
+
29
+ /**
30
+ * Initialize the galaxydraw state engine and bind to existing DOM.
31
+ * Call this after ctx.canvas and ctx.canvasViewport are set.
32
+ */
33
+ export function initGalaxyDrawState(ctx: CanvasContext): CanvasState {
34
+ _gdState = new CanvasState();
35
+
36
+ if (ctx.canvasViewport && ctx.canvas) {
37
+ _gdState.bind(ctx.canvasViewport, ctx.canvas);
38
+ }
39
+
40
+ // Sync initial state from XState
41
+ const state = ctx.snap().context;
42
+ if (state.zoom) _gdState.zoom = state.zoom;
43
+ if (state.offsetX) _gdState.offsetX = state.offsetX;
44
+ if (state.offsetY) _gdState.offsetY = state.offsetY;
45
+ _gdState.applyTransform();
46
+
47
+ return _gdState;
48
+ }
49
+
50
+ /**
51
+ * Get the shared CanvasState instance.
52
+ */
53
+ export function getGalaxyDrawState(): CanvasState | null {
54
+ return _gdState;
55
+ }
56
+
57
+ /**
58
+ * Zoom toward a screen point using galaxydraw's engine,
59
+ * then sync the computed state back to XState for persistence.
60
+ *
61
+ * @returns The new zoom/offset values (for callers that need them)
62
+ */
63
+ export function zoomTowardScreen(
64
+ ctx: CanvasContext,
65
+ screenX: number,
66
+ screenY: number,
67
+ factor: number,
68
+ ): { zoom: number; offsetX: number; offsetY: number } {
69
+ const gd = _gdState;
70
+
71
+ if (gd) {
72
+ // Delegate to galaxydraw engine
73
+ gd.zoomToward(screenX, screenY, factor);
74
+ // Sync back to XState for persistence
75
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: gd.zoom });
76
+ ctx.actor.send({ type: 'SET_OFFSET', x: gd.offsetX, y: gd.offsetY });
77
+ return { zoom: gd.zoom, offsetX: gd.offsetX, offsetY: gd.offsetY };
78
+ }
79
+
80
+ // Fallback: manual math (pre-bridge init)
81
+ const state = ctx.snap().context;
82
+ const rect = ctx.canvasViewport?.getBoundingClientRect();
83
+ const mouseX = screenX - (rect?.left ?? 0);
84
+ const mouseY = screenY - (rect?.top ?? 0);
85
+ const newZoom = Math.min(3, Math.max(0.1, state.zoom * factor));
86
+ const scale = newZoom / state.zoom;
87
+ const newOffsetX = mouseX - (mouseX - state.offsetX) * scale;
88
+ const newOffsetY = mouseY - (mouseY - state.offsetY) * scale;
89
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
90
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
91
+ return { zoom: newZoom, offsetX: newOffsetX, offsetY: newOffsetY };
92
+ }
93
+
94
+ /**
95
+ * Pan by pixel delta via galaxydraw's engine.
96
+ * Syncs back to XState for persistence.
97
+ */
98
+ export function panByDelta(
99
+ ctx: CanvasContext,
100
+ dx: number,
101
+ dy: number,
102
+ ): void {
103
+ const gd = _gdState;
104
+
105
+ if (gd) {
106
+ gd.pan(dx, dy);
107
+ ctx.actor.send({ type: 'SET_OFFSET', x: gd.offsetX, y: gd.offsetY });
108
+ return;
109
+ }
110
+
111
+ // Fallback
112
+ const state = ctx.snap().context;
113
+ ctx.actor.send({ type: 'SET_OFFSET', x: state.offsetX + dx, y: state.offsetY + dy });
114
+ }
115
+
116
+ /**
117
+ * Convert screen coordinates to world coordinates.
118
+ * Delegates to CanvasState.screenToWorld() when available.
119
+ */
120
+ export function screenToWorld(
121
+ ctx: CanvasContext,
122
+ screenX: number,
123
+ screenY: number,
124
+ ): { x: number; y: number } {
125
+ const gd = _gdState;
126
+
127
+ if (gd) {
128
+ return gd.screenToWorld(screenX, screenY);
129
+ }
130
+
131
+ // Fallback
132
+ const state = ctx.snap().context;
133
+ const rect = ctx.canvasViewport?.getBoundingClientRect();
134
+ return {
135
+ x: (screenX - (rect?.left ?? 0) - state.offsetX) / state.zoom,
136
+ y: (screenY - (rect?.top ?? 0) - state.offsetY) / state.zoom,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Center the viewport on a world coordinate.
142
+ * Delegates to CanvasState.panTo() when available.
143
+ */
144
+ export function panToWorld(
145
+ ctx: CanvasContext,
146
+ worldX: number,
147
+ worldY: number,
148
+ ): void {
149
+ const gd = _gdState;
150
+
151
+ if (gd) {
152
+ gd.panTo(worldX, worldY);
153
+ ctx.actor.send({ type: 'SET_OFFSET', x: gd.offsetX, y: gd.offsetY });
154
+ return;
155
+ }
156
+
157
+ // Fallback
158
+ const state = ctx.snap().context;
159
+ const vp = ctx.canvasViewport;
160
+ if (vp) {
161
+ const vpW = vp.clientWidth;
162
+ const vpH = vp.clientHeight;
163
+ const newOffsetX = vpW / 2 - worldX * state.zoom;
164
+ const newOffsetY = vpH / 2 - worldY * state.zoom;
165
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
166
+ }
167
+ }
168
+
169
+ // ─── Card Manager ───────────────────────────────────────
170
+
171
+ /**
172
+ * Initialize the CardManager with file card plugins.
173
+ * Call after initGalaxyDrawState() when ctx.canvas is available.
174
+ *
175
+ * The CardManager handles:
176
+ * - Card creation via plugins (FileCardPlugin, DiffCardPlugin)
177
+ * - Drag, resize, z-order management
178
+ * - Selection (single, multi)
179
+ * - Deferred rendering (virtualization)
180
+ */
181
+ import { scheduleRenderConnections } from './connections';
182
+
183
+ export function initCardManager(ctx: CanvasContext): CardManager | null {
184
+ if (!_gdState || !ctx.canvas) {
185
+ console.warn('[galaxydraw-bridge] Cannot init CardManager: state or canvas not ready');
186
+ return null;
187
+ }
188
+
189
+ _eventBus = new EventBus();
190
+ _cardManager = new CardManager(_gdState, _eventBus, ctx.canvas, {
191
+ defaultWidth: 580,
192
+ defaultHeight: 700,
193
+ minWidth: 280,
194
+ minHeight: 200,
195
+ gridSize: 0,
196
+ cornerSize: 40,
197
+ });
198
+
199
+ // Register plugins
200
+ _cardManager.registerPlugin(createFileCardPlugin());
201
+ _cardManager.registerPlugin(createDiffCardPlugin());
202
+
203
+ // Sync card events back to XState for persistence
204
+ _eventBus.on('card:move', (ev) => {
205
+ const { id, x, y } = ev;
206
+ ctx.actor.send({ type: 'SAVE_POSITION', path: id, x, y });
207
+ scheduleRenderConnections(ctx);
208
+ });
209
+
210
+ _eventBus.on('card:resize', (ev) => {
211
+ const { id, width, height } = ev;
212
+ ctx.actor.send({ type: 'RESIZE_CARD', path: id, width, height });
213
+ scheduleRenderConnections(ctx);
214
+ });
215
+
216
+ console.log('[galaxydraw-bridge] CardManager initialized with file + diff plugins');
217
+ return _cardManager;
218
+ }
219
+
220
+ /**
221
+ * Get the shared CardManager instance.
222
+ */
223
+ export function getCardManager(): CardManager | null {
224
+ return _cardManager;
225
+ }
226
+
227
+ /**
228
+ * Get the shared EventBus instance.
229
+ */
230
+ export function getEventBus(): EventBus | null {
231
+ return _eventBus;
232
+ }
233
+
234
+ // ─── Card Creation via CardManager ──────────────────────
235
+
236
+ import { FILE_CARD_TYPE, DIFF_CARD_TYPE } from './file-card-plugin';
237
+ import { getActiveLayer } from './layers';
238
+ import { updateHiddenUI } from './hidden-files';
239
+ import type { ViewportRect } from '../../packages/galaxydraw/src/core/state';
240
+
241
+ /**
242
+ * Render all files on canvas using CardManager instead of direct DOM.
243
+ *
244
+ * This replaces the viewport culling logic in renderAllFilesOnCanvas():
245
+ * - Cards in/near viewport → CardManager.create() (immediate DOM)
246
+ * - Cards outside viewport → CardManager.defer() (lazy materialization)
247
+ * - On scroll/zoom → materializeViewport() creates deferred cards
248
+ *
249
+ * Benefits over the legacy approach:
250
+ * - Drag/resize/z-order handled uniformly by CardManager
251
+ * - EventBus emits card:create/card:move/card:resize for persistence
252
+ * - Cleaner separation between rendering and interaction
253
+ */
254
+ export function renderAllFilesViaCardManager(ctx: CanvasContext, files: any[]) {
255
+ if (!_cardManager || !_gdState) {
256
+ // Fallback to legacy if CardManager not initialized
257
+ console.warn('[galaxydraw-bridge] CardManager not ready, falling back to legacy render');
258
+ return false; // Signal caller to use legacy path
259
+ }
260
+
261
+ _cardManager.clear();
262
+
263
+ // Also clear existing DOM cards, pills, and deferred state
264
+ // Without this, layer switching leaves orphaned elements
265
+ ctx.fileCards.forEach(card => card.remove());
266
+ ctx.fileCards.clear();
267
+ ctx.deferredCards.clear();
268
+ ctx.canvas?.querySelectorAll('.dir-label').forEach(el => el.remove());
269
+ ctx.canvas?.querySelectorAll('.file-pill').forEach(el => el.remove());
270
+ // Clear pill tracking Map
271
+ const { clearAllPills } = require('./viewport-culling');
272
+ clearAllPills(ctx);
273
+ if (ctx.svgOverlay) ctx.svgOverlay.innerHTML = '';
274
+
275
+ const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
276
+ updateHiddenUI(ctx);
277
+
278
+ // Build changed file data map
279
+ const changedFileDataMap = new Map<string, any>();
280
+ if (ctx.commitFilesData) {
281
+ ctx.commitFilesData.forEach(f => changedFileDataMap.set(f.path, f));
282
+ }
283
+
284
+ let layerFiles = visibleFiles;
285
+ const activeLayer = getActiveLayer();
286
+ if (activeLayer) {
287
+ layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
288
+ } else {
289
+ // Default layer: exclude files that have been moved to other layers
290
+ const { isFileMovedFromDefault } = require('./layers');
291
+ layerFiles = visibleFiles.filter(f => !isFileMovedFromDefault(f.path));
292
+ }
293
+
294
+ // Grid layout: square-ish
295
+ const count = layerFiles.length;
296
+ const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
297
+ const defaultCardWidth = 580;
298
+ const defaultCardHeight = 700;
299
+ const gap = 20;
300
+ const cellW = defaultCardWidth + gap;
301
+ const cellH = defaultCardHeight + gap;
302
+
303
+ // Viewport rect for initial visibility check
304
+ const MARGIN = 800;
305
+ const state = _gdState.snapshot();
306
+ const vpEl = ctx.canvasViewport;
307
+ const vpW = vpEl?.clientWidth || window.innerWidth;
308
+ const vpH = vpEl?.clientHeight || window.innerHeight;
309
+ const zoom = state.zoom || 1;
310
+ const offsetX = state.offsetX || 0;
311
+ const offsetY = state.offsetY || 0;
312
+ const worldLeft = (-offsetX - MARGIN) / zoom;
313
+ const worldTop = (-offsetY - MARGIN) / zoom;
314
+ const worldRight = (vpW - offsetX + MARGIN) / zoom;
315
+ const worldBottom = (vpH - offsetY + MARGIN) / zoom;
316
+
317
+ let createdCount = 0;
318
+ let deferredCount = 0;
319
+
320
+ // Cache XState state once outside the loop — avoids N snapshots for N files
321
+ const cachedCardSizes = ctx.snap().context.cardSizes || {};
322
+
323
+ layerFiles.forEach((f, index) => {
324
+ const posKey = `allfiles:${f.path}`;
325
+ let x: number, y: number;
326
+
327
+ if (ctx.positions.has(posKey)) {
328
+ const pos = ctx.positions.get(posKey);
329
+ x = pos.x; y = pos.y;
330
+ } else {
331
+ const col = index % cols;
332
+ const row = Math.floor(index / cols);
333
+ x = 50 + col * cellW;
334
+ y = 50 + row * cellH;
335
+ }
336
+
337
+ // Get saved size (from cached snapshot — no per-file ctx.snap() call)
338
+ let size = cachedCardSizes[f.path];
339
+ if (!size && ctx.positions.has(posKey)) {
340
+ const pos = ctx.positions.get(posKey);
341
+ if (pos.width) size = { width: pos.width, height: pos.height };
342
+ }
343
+
344
+ // Merge diff/layer data
345
+ let fileWithDiff = { ...f };
346
+ if (activeLayer && activeLayer.files[fileWithDiff.path]) {
347
+ fileWithDiff.layerSections = activeLayer.files[fileWithDiff.path].sections;
348
+ }
349
+
350
+ const isChanged = ctx.changedFilePaths.has(f.path);
351
+ if (isChanged && changedFileDataMap.has(fileWithDiff.path)) {
352
+ const diffData = changedFileDataMap.get(fileWithDiff.path);
353
+ if (diffData.content) {
354
+ fileWithDiff.content = diffData.content;
355
+ fileWithDiff.lines = diffData.content.split('\n').length;
356
+ }
357
+ fileWithDiff.status = diffData.status;
358
+ fileWithDiff.hunks = diffData.hunks;
359
+
360
+ if (diffData.hunks?.length > 0) {
361
+ const addedLines = new Set<number>();
362
+ const deletedBeforeLine = new Map<number, string[]>();
363
+ for (const hunk of diffData.hunks) {
364
+ let newLine = hunk.newStart;
365
+ let pendingDeleted: string[] = [];
366
+ for (const l of hunk.lines) {
367
+ if (l.type === 'add') {
368
+ addedLines.add(newLine);
369
+ if (pendingDeleted.length > 0) {
370
+ const existing = deletedBeforeLine.get(newLine) || [];
371
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
372
+ pendingDeleted = [];
373
+ }
374
+ newLine++;
375
+ } else if (l.type === 'del') {
376
+ pendingDeleted.push(l.content);
377
+ } else {
378
+ if (pendingDeleted.length > 0) {
379
+ const existing = deletedBeforeLine.get(newLine) || [];
380
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
381
+ pendingDeleted = [];
382
+ }
383
+ newLine++;
384
+ }
385
+ }
386
+ if (pendingDeleted.length > 0) {
387
+ const existing = deletedBeforeLine.get(newLine) || [];
388
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
389
+ }
390
+ }
391
+ fileWithDiff.addedLines = addedLines;
392
+ fileWithDiff.deletedBeforeLine = deletedBeforeLine;
393
+ }
394
+ }
395
+
396
+ const cardData = {
397
+ id: f.path,
398
+ x, y,
399
+ width: size?.width || defaultCardWidth,
400
+ height: size?.height || defaultCardHeight,
401
+ meta: { file: fileWithDiff, ctx, savedSize: size },
402
+ };
403
+
404
+ // Check if in viewport
405
+ const inViewport =
406
+ x + (size?.width || defaultCardWidth) > worldLeft &&
407
+ x < worldRight &&
408
+ y + (size?.height || defaultCardHeight) > worldTop &&
409
+ y < worldBottom;
410
+
411
+ if (inViewport) {
412
+ const card = _cardManager!.create(FILE_CARD_TYPE, cardData);
413
+ if (card) {
414
+ // Sync to ctx.fileCards so minimap, fitAll, etc. can find it
415
+ ctx.fileCards.set(f.path, card);
416
+ // Apply change markers for diff highlighting
417
+ if (isChanged) {
418
+ card.classList.add('file-card--changed');
419
+ card.dataset.changed = 'true';
420
+ }
421
+ }
422
+ createdCount++;
423
+ } else {
424
+ _cardManager!.defer(FILE_CARD_TYPE, cardData);
425
+ // Also store in ctx.deferredCards so minimap, fitAll, etc. can see ALL files
426
+ ctx.deferredCards.set(f.path, {
427
+ file: fileWithDiff, x, y,
428
+ size: { width: cardData.width, height: cardData.height },
429
+ isChanged,
430
+ });
431
+ deferredCount++;
432
+ }
433
+ });
434
+
435
+ console.log(`[gd-bridge] ${createdCount} created, ${deferredCount} deferred (${layerFiles.length} total)`);
436
+ return true; // Signal: we handled it
437
+ }
438
+
439
+ /**
440
+ * Materialize deferred cards that are now in the viewport.
441
+ * Call this on zoom/pan changes.
442
+ */
443
+ export function materializeViewport(ctx: CanvasContext): number {
444
+ if (!_cardManager || !_gdState) return 0;
445
+
446
+ const MARGIN = 800;
447
+ const state = _gdState.snapshot();
448
+ const vpEl = ctx.canvasViewport;
449
+ const vpW = vpEl?.clientWidth || window.innerWidth;
450
+ const vpH = vpEl?.clientHeight || window.innerHeight;
451
+ const zoom = state.zoom || 1;
452
+ const offsetX = state.offsetX || 0;
453
+ const offsetY = state.offsetY || 0;
454
+
455
+ const rect: ViewportRect = {
456
+ left: (-offsetX - MARGIN) / zoom,
457
+ top: (-offsetY - MARGIN) / zoom,
458
+ right: (vpW - offsetX + MARGIN) / zoom,
459
+ bottom: (vpH - offsetY + MARGIN) / zoom,
460
+ };
461
+
462
+ const count = _cardManager.materializeInRect(rect);
463
+
464
+ // Sync newly materialized cards to ctx.fileCards for minimap/fitAll
465
+ // AND remove from ctx.deferredCards so viewport-culling doesn't re-create them
466
+ if (count > 0) {
467
+ for (const [id, card] of _cardManager.cards) {
468
+ if (!ctx.fileCards.has(id)) {
469
+ ctx.fileCards.set(id, card);
470
+ }
471
+ // Remove from deferredCards to prevent duplicate creation by viewport-culling
472
+ ctx.deferredCards.delete(id);
473
+ }
474
+ }
475
+
476
+ return count;
477
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * galaxydraw core unit tests — CanvasState & EventBus
3
+ *
4
+ * Pure logic tests (no DOM). Validates coordinate conversion,
5
+ * zoom clamping, snapshot/subscribe, and pub/sub.
6
+ *
7
+ * Run: bun test app/lib/galaxydraw.test.ts
8
+ */
9
+ import { describe, expect, test } from 'bun:test'
10
+ import { CanvasState } from 'galaxydraw'
11
+ import { EventBus } from 'galaxydraw'
12
+
13
+ // ─── CanvasState ────────────────────────────────────────
14
+
15
+ describe('CanvasState', () => {
16
+ test('initial state is zoom=1, offset=0,0', () => {
17
+ const s = new CanvasState()
18
+ expect(s.zoom).toBe(1)
19
+ expect(s.offsetX).toBe(0)
20
+ expect(s.offsetY).toBe(0)
21
+ })
22
+
23
+ test('snapshot returns a copy', () => {
24
+ const s = new CanvasState()
25
+ const snap = s.snapshot()
26
+ expect(snap).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
27
+
28
+ // Mutation doesn't affect original
29
+ snap.zoom = 999
30
+ expect(s.zoom).toBe(1)
31
+ })
32
+
33
+ test('set() updates zoom and offset', () => {
34
+ const s = new CanvasState()
35
+ s.set(2, 100, 200)
36
+ expect(s.zoom).toBe(2)
37
+ expect(s.offsetX).toBe(100)
38
+ expect(s.offsetY).toBe(200)
39
+ })
40
+
41
+ test('set() clamps zoom to MIN_ZOOM', () => {
42
+ const s = new CanvasState()
43
+ s.set(0.001, 0, 0)
44
+ expect(s.zoom).toBe(s.MIN_ZOOM)
45
+ })
46
+
47
+ test('set() clamps zoom to MAX_ZOOM', () => {
48
+ const s = new CanvasState()
49
+ s.set(100, 0, 0)
50
+ expect(s.zoom).toBe(s.MAX_ZOOM)
51
+ })
52
+
53
+ test('pan() accumulates delta', () => {
54
+ const s = new CanvasState()
55
+ s.pan(10, 20)
56
+ expect(s.offsetX).toBe(10)
57
+ expect(s.offsetY).toBe(20)
58
+ s.pan(5, -10)
59
+ expect(s.offsetX).toBe(15)
60
+ expect(s.offsetY).toBe(10)
61
+ })
62
+
63
+ test('subscribe() is called on set()', () => {
64
+ const s = new CanvasState()
65
+ let callCount = 0
66
+ s.subscribe(() => { callCount++ })
67
+ s.set(2, 0, 0)
68
+ expect(callCount).toBe(1)
69
+ })
70
+
71
+ test('unsubscribe works', () => {
72
+ const s = new CanvasState()
73
+ let callCount = 0
74
+ const unsub = s.subscribe(() => { callCount++ })
75
+ s.set(2, 0, 0)
76
+ expect(callCount).toBe(1)
77
+ unsub()
78
+ s.set(3, 0, 0)
79
+ expect(callCount).toBe(1) // No additional call
80
+ })
81
+
82
+ test('subscribe() is called on pan()', () => {
83
+ const s = new CanvasState()
84
+ let called = false
85
+ s.subscribe(() => { called = true })
86
+ s.pan(10, 20)
87
+ expect(called).toBe(true)
88
+ })
89
+
90
+ test('screenToWorld identity at zoom=1 offset=0 (no viewport)', () => {
91
+ const s = new CanvasState()
92
+ // Without a viewport, rect defaults are 0, so screenToWorld
93
+ // just divides by zoom and subtracts offset
94
+ const p = s.screenToWorld(100, 200)
95
+ expect(p.x).toBe(100)
96
+ expect(p.y).toBe(200)
97
+ })
98
+
99
+ test('screenToWorld with zoom=2', () => {
100
+ const s = new CanvasState()
101
+ s.set(2, 0, 0)
102
+ const p = s.screenToWorld(200, 400)
103
+ expect(p.x).toBe(100)
104
+ expect(p.y).toBe(200)
105
+ })
106
+
107
+ test('screenToWorld with offset', () => {
108
+ const s = new CanvasState()
109
+ s.set(1, 50, 100)
110
+ const p = s.screenToWorld(150, 200)
111
+ expect(p.x).toBe(100)
112
+ expect(p.y).toBe(100)
113
+ })
114
+
115
+ test('worldToScreen identity at zoom=1 offset=0 (no viewport)', () => {
116
+ const s = new CanvasState()
117
+ const p = s.worldToScreen(100, 200)
118
+ expect(p.x).toBe(100)
119
+ expect(p.y).toBe(200)
120
+ })
121
+
122
+ test('worldToScreen with zoom=2', () => {
123
+ const s = new CanvasState()
124
+ s.set(2, 0, 0)
125
+ const p = s.worldToScreen(100, 200)
126
+ expect(p.x).toBe(200)
127
+ expect(p.y).toBe(400)
128
+ })
129
+
130
+ test('screenToWorld/worldToScreen roundtrip', () => {
131
+ const s = new CanvasState()
132
+ s.set(1.5, 30, -40)
133
+ const world = s.screenToWorld(300, 250)
134
+ const screen = s.worldToScreen(world.x, world.y)
135
+ expect(screen.x).toBeCloseTo(300, 5)
136
+ expect(screen.y).toBeCloseTo(250, 5)
137
+ })
138
+ })
139
+
140
+ // ─── EventBus ───────────────────────────────────────────
141
+
142
+ describe('EventBus', () => {
143
+ test('on() receives emitted events', () => {
144
+ const bus = new EventBus()
145
+ let received: any = null
146
+ bus.on('canvas:pan', (data) => { received = data })
147
+ bus.emit('canvas:pan', { offsetX: 10, offsetY: 20 })
148
+ expect(received).toEqual({ offsetX: 10, offsetY: 20 })
149
+ })
150
+
151
+ test('multiple handlers receive same event', () => {
152
+ const bus = new EventBus()
153
+ let count = 0
154
+ bus.on('canvas:zoom', () => { count++ })
155
+ bus.on('canvas:zoom', () => { count++ })
156
+ bus.emit('canvas:zoom', { zoom: 2, centerX: 0, centerY: 0 })
157
+ expect(count).toBe(2)
158
+ })
159
+
160
+ test('unsubscribe via returned function', () => {
161
+ const bus = new EventBus()
162
+ let count = 0
163
+ const unsub = bus.on('canvas:pan', () => { count++ })
164
+ bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })
165
+ expect(count).toBe(1)
166
+ unsub()
167
+ bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })
168
+ expect(count).toBe(1)
169
+ })
170
+
171
+ test('once() fires only once', () => {
172
+ const bus = new EventBus()
173
+ let count = 0
174
+ bus.once('card:create', () => { count++ })
175
+ bus.emit('card:create', { id: '1', x: 0, y: 0 })
176
+ bus.emit('card:create', { id: '2', x: 0, y: 0 })
177
+ expect(count).toBe(1)
178
+ })
179
+
180
+ test('off() without handler removes all handlers for event', () => {
181
+ const bus = new EventBus()
182
+ let count = 0
183
+ bus.on('canvas:pan', () => { count++ })
184
+ bus.on('canvas:pan', () => { count++ })
185
+ bus.off('canvas:pan')
186
+ bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })
187
+ expect(count).toBe(0)
188
+ })
189
+
190
+ test('off() with handler removes only that handler', () => {
191
+ const bus = new EventBus()
192
+ let aCount = 0
193
+ let bCount = 0
194
+ const handlerA = () => { aCount++ }
195
+ const handlerB = () => { bCount++ }
196
+ bus.on('canvas:pan', handlerA)
197
+ bus.on('canvas:pan', handlerB)
198
+ bus.off('canvas:pan', handlerA)
199
+ bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })
200
+ expect(aCount).toBe(0)
201
+ expect(bCount).toBe(1)
202
+ })
203
+
204
+ test('clear() removes all event handlers', () => {
205
+ const bus = new EventBus()
206
+ let count = 0
207
+ bus.on('canvas:pan', () => { count++ })
208
+ bus.on('canvas:zoom', () => { count++ })
209
+ bus.clear()
210
+ bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })
211
+ bus.emit('canvas:zoom', { zoom: 1, centerX: 0, centerY: 0 })
212
+ expect(count).toBe(0)
213
+ })
214
+
215
+ test('emit with no handlers does not throw', () => {
216
+ const bus = new EventBus()
217
+ expect(() => bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })).not.toThrow()
218
+ })
219
+
220
+ test('handler error does not break other handlers', () => {
221
+ const bus = new EventBus()
222
+ let secondCalled = false
223
+ bus.on('canvas:pan', () => { throw new Error('boom') })
224
+ bus.on('canvas:pan', () => { secondCalled = true })
225
+ // Should not throw, errors are caught internally
226
+ bus.emit('canvas:pan', { offsetX: 0, offsetY: 0 })
227
+ expect(secondCalled).toBe(true)
228
+ })
229
+ })