gitmaps 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/app/api/auth/favorites/route.ts +56 -0
- package/app/api/auth/github/callback/route.ts +103 -0
- package/app/api/auth/github/route.ts +32 -0
- package/app/api/auth/me/route.ts +52 -0
- package/app/api/auth/positions/route.ts +50 -0
- package/app/api/chat/route.ts +101 -0
- package/app/api/connections/route.ts +72 -0
- package/app/api/github/repos/route.ts +111 -0
- package/app/api/positions/route.ts +80 -0
- package/app/api/repo/branch-diff/route.ts +201 -0
- package/app/api/repo/branches/route.ts +53 -0
- package/app/api/repo/browse/route.ts +55 -0
- package/app/api/repo/clone/route.ts +78 -0
- package/app/api/repo/clone-stream/route.ts +131 -0
- package/app/api/repo/file-content/route.ts +28 -0
- package/app/api/repo/file-delete/route.ts +62 -0
- package/app/api/repo/file-history/route.ts +45 -0
- package/app/api/repo/file-rename/route.ts +83 -0
- package/app/api/repo/file-save/route.ts +45 -0
- package/app/api/repo/files/route.ts +169 -0
- package/app/api/repo/git-blame/route.ts +86 -0
- package/app/api/repo/git-commit/route.ts +40 -0
- package/app/api/repo/git-heatmap/route.ts +55 -0
- package/app/api/repo/imports/route.ts +154 -0
- package/app/api/repo/load/route.ts +56 -0
- package/app/api/repo/mode/route.ts +14 -0
- package/app/api/repo/search/route.ts +127 -0
- package/app/api/repo/tree/route.ts +104 -0
- package/app/api/repo/upload/route.ts +53 -0
- package/app/api/repo/validate-path.ts +53 -0
- package/app/canvas_users.db +0 -0
- package/app/canvas_users.db-shm +0 -0
- package/app/canvas_users.db-wal +0 -0
- package/app/globals.css +7899 -0
- package/app/layout.tsx +493 -0
- package/app/lib/auth.ts +193 -0
- package/app/lib/auto-save.ts +137 -0
- package/app/lib/branch-compare.ts +443 -0
- package/app/lib/breadcrumbs.ts +170 -0
- package/app/lib/canvas-export.ts +358 -0
- package/app/lib/canvas-text.ts +912 -0
- package/app/lib/canvas.ts +564 -0
- package/app/lib/card-arrangement.ts +188 -0
- package/app/lib/card-context-menu.tsx +453 -0
- package/app/lib/card-diff-markers.ts +270 -0
- package/app/lib/card-expand.ts +189 -0
- package/app/lib/card-groups.ts +246 -0
- package/app/lib/cards.tsx +914 -0
- package/app/lib/chat.tsx +308 -0
- package/app/lib/code-editor.ts +508 -0
- package/app/lib/command-palette.ts +262 -0
- package/app/lib/connections.tsx +1037 -0
- package/app/lib/context.ts +94 -0
- package/app/lib/cursor-sharing.ts +281 -0
- package/app/lib/dependency-graph.ts +438 -0
- package/app/lib/events.tsx +1747 -0
- package/app/lib/file-card-plugin.ts +134 -0
- package/app/lib/file-modal.tsx +849 -0
- package/app/lib/file-preview.ts +400 -0
- package/app/lib/file-tabs.ts +318 -0
- package/app/lib/galaxydraw-bridge.ts +477 -0
- package/app/lib/galaxydraw.test.ts +229 -0
- package/app/lib/global-search.ts +264 -0
- package/app/lib/goto-definition.ts +224 -0
- package/app/lib/heatmap.ts +178 -0
- package/app/lib/hidden-files.tsx +222 -0
- package/app/lib/layers.ts +0 -0
- package/app/lib/layers.tsx +365 -0
- package/app/lib/loading.tsx +45 -0
- package/app/lib/multi-repo.ts +286 -0
- package/app/lib/new-file-dialog.tsx +230 -0
- package/app/lib/onboarding.tsx +213 -0
- package/app/lib/perf-overlay.ts +360 -0
- package/app/lib/positions.ts +176 -0
- package/app/lib/pr-review.ts +374 -0
- package/app/lib/production-mode.ts +47 -0
- package/app/lib/repo.tsx +977 -0
- package/app/lib/settings-modal.tsx +374 -0
- package/app/lib/settings.ts +97 -0
- package/app/lib/shortcuts-panel.ts +141 -0
- package/app/lib/status-bar.ts +128 -0
- package/app/lib/symbol-outline.ts +212 -0
- package/app/lib/syntax.ts +177 -0
- package/app/lib/tab-diff.ts +238 -0
- package/app/lib/user.tsx +133 -0
- package/app/lib/utils.ts +78 -0
- package/app/lib/viewport-culling.ts +728 -0
- package/app/page.client.tsx +215 -0
- package/app/page.tsx +291 -0
- package/app/state/machine.js +196 -0
- package/app/styles/main.css +2168 -0
- package/banner.png +0 -0
- package/cli.ts +44 -0
- package/package.json +75 -0
- package/packages/galaxydraw/README.md +296 -0
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +100 -0
- package/packages/galaxydraw/demo/client.ts +154 -0
- package/packages/galaxydraw/demo/dist/client.js +8 -0
- package/packages/galaxydraw/demo/index.html +256 -0
- package/packages/galaxydraw/demo/server.ts +96 -0
- package/packages/galaxydraw/dist/index.js +984 -0
- package/packages/galaxydraw/dist/index.js.map +16 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +49 -0
- package/packages/galaxydraw/perf.test.ts +284 -0
- package/packages/galaxydraw/src/core/cards.ts +435 -0
- package/packages/galaxydraw/src/core/engine.ts +339 -0
- package/packages/galaxydraw/src/core/events.ts +81 -0
- package/packages/galaxydraw/src/core/layout.ts +136 -0
- package/packages/galaxydraw/src/core/minimap.ts +216 -0
- package/packages/galaxydraw/src/core/state.ts +177 -0
- package/packages/galaxydraw/src/core/viewport.ts +106 -0
- package/packages/galaxydraw/src/galaxydraw.css +166 -0
- package/packages/galaxydraw/src/index.ts +40 -0
- package/packages/galaxydraw/tsconfig.json +30 -0
- package/server.ts +62 -0
|
@@ -0,0 +1,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
|
+
})
|