gitmaps 1.0.0 → 1.1.1

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 (145) hide show
  1. package/README.md +265 -122
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/[owner]/[repo]/page.client.tsx +5 -0
  5. package/app/[slug]/page.client.tsx +5 -0
  6. package/app/analytics.db +0 -0
  7. package/app/api/analytics/route.ts +64 -0
  8. package/app/api/auth/positions/route.ts +95 -33
  9. package/app/api/build-info/route.ts +19 -0
  10. package/app/api/chat/route.ts +13 -2
  11. package/app/api/manifest.json/route.ts +20 -0
  12. package/app/api/og-image/route.ts +14 -0
  13. package/app/api/pwa-icon/route.ts +14 -0
  14. package/app/api/repo/clone-stream/route.ts +20 -12
  15. package/app/api/repo/file-content/route.ts +73 -20
  16. package/app/api/repo/imports/route.ts +21 -3
  17. package/app/api/repo/list/route.ts +30 -0
  18. package/app/api/repo/load/route.test.ts +62 -0
  19. package/app/api/repo/load/route.ts +41 -1
  20. package/app/api/repo/pdf-thumb/route.ts +127 -0
  21. package/app/api/repo/resolve-slug/route.ts +51 -0
  22. package/app/api/repo/tree/route.ts +188 -104
  23. package/app/api/repo/upload/route.ts +6 -9
  24. package/app/api/sw.js/route.ts +70 -0
  25. package/app/api/version/route.ts +26 -0
  26. package/app/galaxy-canvas/page.client.tsx +2 -0
  27. package/app/galaxy-canvas/page.tsx +5 -0
  28. package/app/globals.css +5844 -4694
  29. package/app/icon.png +0 -0
  30. package/app/layout.tsx +1284 -467
  31. package/app/lib/auto-arrange.test.ts +158 -0
  32. package/app/lib/auto-arrange.ts +147 -0
  33. package/app/lib/canvas-export.ts +358 -358
  34. package/app/lib/canvas-text.ts +4 -72
  35. package/app/lib/canvas.ts +625 -564
  36. package/app/lib/card-arrangement.ts +21 -7
  37. package/app/lib/card-context-menu.tsx +2 -2
  38. package/app/lib/card-groups.ts +9 -2
  39. package/app/lib/cards.tsx +1361 -914
  40. package/app/lib/chat.tsx +65 -9
  41. package/app/lib/code-editor.ts +86 -2
  42. package/app/lib/connections.tsx +34 -43
  43. package/app/lib/context.test.ts +32 -0
  44. package/app/lib/context.ts +19 -3
  45. package/app/lib/cursor-sharing.ts +34 -0
  46. package/app/lib/events.tsx +76 -73
  47. package/app/lib/export-canvas.ts +287 -0
  48. package/app/lib/file-card-plugin.ts +148 -134
  49. package/app/lib/file-modal.tsx +49 -0
  50. package/app/lib/file-preview.ts +486 -400
  51. package/app/lib/github-import.test.ts +424 -0
  52. package/app/lib/global-search.ts +48 -27
  53. package/app/lib/initial-route-hydration.test.ts +283 -0
  54. package/app/lib/initial-route-hydration.ts +202 -0
  55. package/app/lib/landing-reset.test.ts +99 -0
  56. package/app/lib/landing-reset.ts +106 -0
  57. package/app/lib/landing-shell.test.ts +75 -0
  58. package/app/lib/large-repo-optimization.ts +37 -0
  59. package/app/lib/layers.tsx +17 -18
  60. package/app/lib/layout-snapshots.ts +320 -0
  61. package/app/lib/loading.test.ts +69 -0
  62. package/app/lib/loading.tsx +160 -45
  63. package/app/lib/mount-cleanup.test.ts +52 -0
  64. package/app/lib/mount-cleanup.ts +34 -0
  65. package/app/lib/mount-init.test.ts +123 -0
  66. package/app/lib/mount-init.ts +107 -0
  67. package/app/lib/mount-lifecycle.test.ts +39 -0
  68. package/app/lib/mount-lifecycle.ts +12 -0
  69. package/app/lib/mount-route-wiring.test.ts +87 -0
  70. package/app/lib/mount-route-wiring.ts +84 -0
  71. package/app/lib/multi-repo.ts +14 -0
  72. package/app/lib/onboarding-tutorial.ts +278 -0
  73. package/app/lib/perf-overlay.ts +78 -0
  74. package/app/lib/positions.ts +191 -122
  75. package/app/lib/recent-commits.test.ts +869 -0
  76. package/app/lib/recent-commits.ts +227 -0
  77. package/app/lib/repo-handoff.test.ts +23 -0
  78. package/app/lib/repo-handoff.ts +16 -0
  79. package/app/lib/repo-progressive.ts +119 -0
  80. package/app/lib/repo-select.test.ts +61 -0
  81. package/app/lib/repo-select.ts +74 -0
  82. package/app/lib/repo.tsx +1383 -977
  83. package/app/lib/role.ts +228 -0
  84. package/app/lib/route-catchall.test.ts +27 -0
  85. package/app/lib/route-repo-entry.test.ts +95 -0
  86. package/app/lib/route-repo-entry.ts +36 -0
  87. package/app/lib/router-contract.test.ts +22 -0
  88. package/app/lib/router-contract.ts +19 -0
  89. package/app/lib/shared-layout.test.ts +86 -0
  90. package/app/lib/shared-layout.ts +82 -0
  91. package/app/lib/shortcuts-panel.ts +2 -0
  92. package/app/lib/status-bar.test.ts +118 -0
  93. package/app/lib/status-bar.ts +365 -128
  94. package/app/lib/sync-controls.test.ts +43 -0
  95. package/app/lib/sync-controls.tsx +303 -0
  96. package/app/lib/test-dom.ts +145 -0
  97. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  98. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  99. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  100. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  101. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  102. package/app/lib/transclusion-smoke.test.ts +163 -0
  103. package/app/lib/tutorial.ts +301 -0
  104. package/app/lib/version.ts +93 -0
  105. package/app/lib/viewport-culling.ts +740 -728
  106. package/app/lib/virtual-files.ts +456 -0
  107. package/app/lib/webgl-text.ts +189 -0
  108. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -477
  109. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  110. package/app/og-image.png +0 -0
  111. package/app/page.client.tsx +70 -215
  112. package/app/page.tsx +27 -92
  113. package/app/state/machine.js +13 -0
  114. package/banner.png +0 -0
  115. package/package.json +17 -8
  116. package/server.ts +11 -1
  117. package/app/api/connections/route.ts +0 -72
  118. package/app/api/positions/route.ts +0 -80
  119. package/app/api/repo/browse/route.ts +0 -55
  120. package/app/lib/pr-review.ts +0 -374
  121. package/packages/galaxydraw/README.md +0 -296
  122. package/packages/galaxydraw/banner.png +0 -0
  123. package/packages/galaxydraw/demo/build-static.ts +0 -100
  124. package/packages/galaxydraw/demo/client.ts +0 -154
  125. package/packages/galaxydraw/demo/dist/client.js +0 -8
  126. package/packages/galaxydraw/demo/index.html +0 -256
  127. package/packages/galaxydraw/demo/server.ts +0 -96
  128. package/packages/galaxydraw/dist/index.js +0 -984
  129. package/packages/galaxydraw/dist/index.js.map +0 -16
  130. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  131. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  132. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  133. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  134. package/packages/galaxydraw/package.json +0 -49
  135. package/packages/galaxydraw/perf.test.ts +0 -284
  136. package/packages/galaxydraw/src/core/cards.ts +0 -435
  137. package/packages/galaxydraw/src/core/engine.ts +0 -339
  138. package/packages/galaxydraw/src/core/events.ts +0 -81
  139. package/packages/galaxydraw/src/core/layout.ts +0 -136
  140. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  141. package/packages/galaxydraw/src/core/state.ts +0 -177
  142. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  143. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  144. package/packages/galaxydraw/src/index.ts +0 -40
  145. package/packages/galaxydraw/tsconfig.json +0 -30
package/app/lib/chat.tsx CHANGED
@@ -24,9 +24,29 @@ interface ChatState {
24
24
  const fileChatStates = new Map<string, ChatState>();
25
25
  let canvasChatState: ChatState = { messages: [], isStreaming: false };
26
26
 
27
- // ─── Render markdown-ish content to safe HTML string ────
27
+ // Store parsed refactors to avoid passing huge string payloads via DOM attributes
28
+ const pendingRefactors = new Map<string, { path: string, content: string }>();
29
+
28
30
  function renderChatContent(text: string): string {
29
31
  let html = escapeHtml(text);
32
+
33
+ // Parse escaped <edit_file path="...">...</edit_file>
34
+ const editRegex = /&lt;edit_file path=&quot;([^&]+)&quot;&gt;([\s\S]*?)&lt;\/edit_file&gt;/g;
35
+ html = html.replace(editRegex, (_, path, content) => {
36
+ const decodedContent = content.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
37
+ const id = Math.random().toString(36).substring(7);
38
+ pendingRefactors.set(id, { path, content: decodedContent });
39
+ return `
40
+ <div class="chat-refactor-block" style="margin: 10px 0; border: 1px solid var(--border); border-radius: 6px; overflow: hidden;">
41
+ <div class="chat-refactor-header" style="background: var(--bg-card); padding: 4px 8px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border);">
42
+ <span style="font-family: monospace;">📄 ${path}</span>
43
+ <button class="chat-refactor-apply btn-primary btn-xs" data-refactor-id="${id}" style="padding: 2px 8px; font-size: 10px; border-radius: 4px;">Apply Edit</button>
44
+ </div>
45
+ <pre class="chat-code-block" style="margin: 0; border-radius: 0; max-height: 200px; overflow-y: auto;"><code class="language-typescript">${content.trim()}</code></pre>
46
+ </div>
47
+ `;
48
+ });
49
+
30
50
  html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) =>
31
51
  `<pre class="chat-code-block"><code class="language-${lang || 'text'}">${code.trim()}</code></pre>`);
32
52
  html = html.replace(/`([^`]+)`/g, '<code class="chat-inline-code">$1</code>');
@@ -60,12 +80,47 @@ function EmptyChat() {
60
80
  );
61
81
  }
62
82
 
63
- function MessageList({ messages, isTyping }: { messages: ChatMessage[]; isTyping: boolean }) {
83
+ function MessageList({ messages, isTyping, repoPath }: { messages: ChatMessage[]; isTyping: boolean; repoPath?: string }) {
64
84
  if (messages.length === 0 && !isTyping) {
65
85
  return <EmptyChat />;
66
86
  }
87
+
88
+ const handleClick = async (e: any) => {
89
+ const btn = e.target.closest('.chat-refactor-apply');
90
+ if (!btn || btn.disabled) return;
91
+
92
+ const id = btn.getAttribute('data-refactor-id');
93
+ const refactor = pendingRefactors.get(id);
94
+ if (!refactor) return;
95
+
96
+ const actualRepoPath = repoPath || canvasChatState.canvasContext?.repoPath;
97
+ if (!actualRepoPath) {
98
+ btn.textContent = 'No Repo Context';
99
+ return;
100
+ }
101
+
102
+ btn.disabled = true;
103
+ btn.textContent = 'Applying...';
104
+
105
+ try {
106
+ const res = await fetch('/api/repo/file-save', {
107
+ method: 'POST',
108
+ body: JSON.stringify({ path: actualRepoPath, filePath: refactor.path, content: refactor.content })
109
+ });
110
+ if (res.ok) {
111
+ btn.textContent = 'Applied ✓';
112
+ btn.style.background = 'var(--accent-success, #10b981)';
113
+ btn.style.borderColor = 'var(--accent-success, #10b981)';
114
+ } else {
115
+ btn.textContent = 'Error';
116
+ }
117
+ } catch (err) {
118
+ btn.textContent = 'Failed';
119
+ }
120
+ };
121
+
67
122
  return (
68
- <div className="chat-message-list-inner">
123
+ <div className="chat-message-list-inner" onClick={handleClick}>
69
124
  {messages.map((msg, i) => (
70
125
  <div key={i} className={`chat-message chat-${msg.role}`}>
71
126
  <div className="chat-message-role">{msg.role === 'user' ? 'You' : 'AI'}</div>
@@ -78,10 +133,10 @@ function MessageList({ messages, isTyping }: { messages: ChatMessage[]; isTyping
78
133
  }
79
134
 
80
135
  function ChatPanel({
81
- containerId, title, messages, isTyping, onSend, onClose
136
+ containerId, title, messages, isTyping, repoPath, onSend, onClose
82
137
  }: {
83
138
  containerId: string; title: string; messages: ChatMessage[];
84
- isTyping: boolean; onSend: (text: string) => void; onClose: () => void;
139
+ isTyping: boolean; repoPath?: string; onSend: (text: string) => void; onClose: () => void;
85
140
  }) {
86
141
  const handleSend = () => {
87
142
  const input = document.getElementById(`${containerId}Input`) as HTMLTextAreaElement;
@@ -108,7 +163,7 @@ function ChatPanel({
108
163
  <button className="chat-close btn-ghost btn-xs" onClick={onClose}>✕</button>
109
164
  </div>
110
165
  <div className="chat-messages" id={`${containerId}Messages`}>
111
- <MessageList messages={messages} isTyping={isTyping} />
166
+ <MessageList messages={messages} isTyping={isTyping} repoPath={repoPath} />
112
167
  </div>
113
168
  <div className="chat-input-area">
114
169
  <textarea
@@ -146,6 +201,7 @@ function renderChatPanel(container: HTMLElement, containerId: string, title: str
146
201
  title={title}
147
202
  messages={state.messages}
148
203
  isTyping={state.isStreaming && state.messages[state.messages.length - 1]?.content === ''}
204
+ repoPath={state.fileContext?.repoPath || state.canvasContext?.repoPath}
149
205
  onSend={onSend}
150
206
  onClose={onClose}
151
207
  />,
@@ -240,16 +296,16 @@ async function streamResponse(
240
296
  }
241
297
 
242
298
  // ─── Open file chat in modal ────────────────────────────
243
- export function openFileChatInModal(filePath: string, content: string, status: string, diff?: string) {
299
+ export function openFileChatInModal(repoPath: string, filePath: string, content: string, status: string, diff?: string) {
244
300
  measure('chat:openFileChat', () => {
245
301
  if (!fileChatStates.has(filePath)) {
246
302
  fileChatStates.set(filePath, {
247
303
  messages: [], isStreaming: false,
248
- fileContext: { path: filePath, content, status, diff },
304
+ fileContext: { repoPath, path: filePath, content, status, diff },
249
305
  });
250
306
  }
251
307
  const state = fileChatStates.get(filePath)!;
252
- state.fileContext = { path: filePath, content, status, diff };
308
+ state.fileContext = { repoPath, path: filePath, content, status, diff };
253
309
 
254
310
  let chatContainer = document.getElementById('modalChatContainer');
255
311
  if (!chatContainer) return;
@@ -4,8 +4,8 @@
4
4
  * Creates a CodeMirror 6 editor instance with syntax highlighting,
5
5
  * dark theme, and integration with the save/commit workflow.
6
6
  */
7
- import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection, highlightSpecialChars, ViewPlugin, ViewUpdate } from '@codemirror/view';
8
- import { EditorState, Compartment } from '@codemirror/state';
7
+ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection, highlightSpecialChars, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
8
+ import { EditorState, Compartment, StateField, StateEffect } from '@codemirror/state';
9
9
  import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap, HighlightStyle } from '@codemirror/language';
10
10
  import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
11
11
  import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
@@ -219,6 +219,81 @@ function getLanguageExtension(ext: string) {
219
219
  }
220
220
  }
221
221
 
222
+ // ─── Remote Cursors ─────────────────────────────────────
223
+ export const remoteCursorsEffect = StateEffect.define<any[]>();
224
+
225
+ class RemoteCursorWidget extends WidgetType {
226
+ constructor(readonly color: string, readonly name: string, readonly typing: boolean) { super(); }
227
+ toDOM() {
228
+ const dec = document.createElement("span");
229
+ dec.style.position = "relative";
230
+ dec.style.borderLeft = `2px solid ${this.color}`;
231
+ dec.style.marginLeft = "-1px";
232
+ dec.style.marginRight = "-1px";
233
+ dec.style.zIndex = "10";
234
+ if (this.typing) {
235
+ dec.style.opacity = "0.7";
236
+ }
237
+
238
+ const tooltip = document.createElement("div");
239
+ tooltip.textContent = this.name + (this.typing ? '...' : '');
240
+ tooltip.style.position = "absolute";
241
+ tooltip.style.top = "-1.5em";
242
+ tooltip.style.left = "-2px";
243
+ tooltip.style.padding = "1px 4px";
244
+ tooltip.style.borderRadius = "3px";
245
+ tooltip.style.fontSize = "10px";
246
+ tooltip.style.color = "white";
247
+ tooltip.style.whiteSpace = "nowrap";
248
+ tooltip.style.pointerEvents = "none";
249
+ tooltip.style.zIndex = "10";
250
+ tooltip.style.backgroundColor = this.color;
251
+
252
+ dec.appendChild(tooltip);
253
+ return dec;
254
+ }
255
+ ignoreEvent() { return true; }
256
+ }
257
+
258
+ const remoteCursorsField = StateField.define<DecorationSet>({
259
+ create() { return Decoration.none; },
260
+ update(decorations, tr) {
261
+ decorations = decorations.map(tr.changes);
262
+ for (let e of tr.effects) {
263
+ if (e.is(remoteCursorsEffect)) {
264
+ let builders: any[] = [];
265
+ for (let cursor of e.value) {
266
+ if (cursor.selections) {
267
+ for (let sel of cursor.selections) {
268
+ let from = Math.min(sel.anchor, sel.head);
269
+ let to = Math.max(sel.anchor, sel.head);
270
+ from = Math.max(0, Math.min(from, tr.state.doc.length));
271
+ to = Math.max(0, Math.min(to, tr.state.doc.length));
272
+
273
+ if (from !== to) {
274
+ builders.push(Decoration.mark({
275
+ attributes: { style: `background-color: ${cursor.color}40` } // 25% opacity
276
+ }).range(from, to));
277
+ }
278
+
279
+ let head = Math.max(0, Math.min(sel.head, tr.state.doc.length));
280
+ builders.push(Decoration.widget({
281
+ widget: new RemoteCursorWidget(cursor.color, cursor.name, cursor.typing || false),
282
+ side: 1
283
+ }).range(head));
284
+ }
285
+ }
286
+ }
287
+ // Sort builders by start position
288
+ builders.sort((a, b) => a.from - b.from);
289
+ decorations = Decoration.set(builders, true);
290
+ }
291
+ }
292
+ return decorations;
293
+ },
294
+ provide: f => EditorView.decorations.from(f)
295
+ });
296
+
222
297
  // ─── Create Editor ──────────────────────────────────────
223
298
  export interface EditorInstance {
224
299
  view: EditorView;
@@ -226,6 +301,7 @@ export interface EditorInstance {
226
301
  setContent: (content: string) => void;
227
302
  getCursorPosition: () => { line: number; col: number };
228
303
  scrollToLine: (line: number) => void;
304
+ setRemoteCursors: (cursors: any[]) => void;
229
305
  destroy: () => void;
230
306
  focus: () => void;
231
307
  }
@@ -238,6 +314,7 @@ export function createCodeEditor(
238
314
  onSave?: () => void;
239
315
  onChange?: (content: string) => void;
240
316
  onCursorMove?: (line: number, col: number) => void;
317
+ onSelectionChange?: (selections: { anchor: number; head: number }[], isTyping: boolean) => void;
241
318
  } = {}
242
319
  ): EditorInstance {
243
320
  const langCompartment = new Compartment();
@@ -280,6 +357,7 @@ export function createCodeEditor(
280
357
  const pos = update.state.selection.main.head;
281
358
  const line = update.state.doc.lineAt(pos);
282
359
  options.onCursorMove?.(line.number, pos - line.from + 1);
360
+ options.onSelectionChange?.(update.state.selection.ranges.map(r => ({ anchor: r.anchor, head: r.head })), update.docChanged);
283
361
  }
284
362
  if (update.docChanged) {
285
363
  options.onChange?.(update.state.doc.toString());
@@ -296,6 +374,7 @@ export function createCodeEditor(
296
374
  }),
297
375
  EditorView.lineWrapping,
298
376
  minimapExtension(),
377
+ remoteCursorsField,
299
378
  ];
300
379
 
301
380
  if (langExt) {
@@ -336,6 +415,11 @@ export function createCodeEditor(
336
415
  scrollIntoView: true,
337
416
  });
338
417
  },
418
+ setRemoteCursors: (cursors: any[]) => {
419
+ view.dispatch({
420
+ effects: remoteCursorsEffect.of(cursors)
421
+ });
422
+ },
339
423
  destroy: () => view.destroy(),
340
424
  focus: () => view.focus(),
341
425
  };
@@ -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
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createCanvasContext, getCanvasContext, setCanvasContext } from './context';
3
+ import { setupDomTest } from './test-dom';
4
+
5
+ describe('shared canvas context lifecycle', () => {
6
+ test('createCanvasContext registers the created context globally', () => {
7
+ const handle = setupDomTest();
8
+ try {
9
+ const actor = { getSnapshot: () => ({ context: {} }) };
10
+ const ctx = createCanvasContext(actor);
11
+ expect(getCanvasContext()).toBe(ctx);
12
+ expect(ctx.snap()).toEqual({ context: {} });
13
+ } finally {
14
+ setCanvasContext(null);
15
+ handle.cleanup();
16
+ }
17
+ });
18
+
19
+ test('setCanvasContext clears the shared context reference', () => {
20
+ const handle = setupDomTest();
21
+ try {
22
+ const actor = { getSnapshot: () => ({ context: { repoPath: 'x' } }) };
23
+ createCanvasContext(actor);
24
+ expect(getCanvasContext()).not.toBeNull();
25
+ setCanvasContext(null);
26
+ expect(getCanvasContext()).toBeNull();
27
+ } finally {
28
+ setCanvasContext(null);
29
+ handle.cleanup();
30
+ }
31
+ });
32
+ });
@@ -6,6 +6,8 @@
6
6
  * Every module gets read/write access to the same mutable state.
7
7
  */
8
8
 
9
+ let currentCanvasContext: CanvasContext | null = null;
10
+
9
11
  export interface CanvasContext {
10
12
  /** XState actor */
11
13
  actor: any;
@@ -41,7 +43,10 @@ export interface CanvasContext {
41
43
  loadingOverlay: HTMLElement | null;
42
44
 
43
45
  // ─── Text rendering mode ──────────────────
44
- useCanvasText: boolean;
46
+ // 'dom' = DOM-based rendering (default, best compatibility)
47
+ // 'canvas' = Canvas 2D API (better performance for medium repos)
48
+ // 'webgl' = Pixi.js WebGL (best performance for 1000+ files)
49
+ textRendererMode: 'dom' | 'canvas' | 'webgl';
45
50
 
46
51
  // ─── All-files mode state ─────────────────
47
52
  allFilesActive: boolean;
@@ -62,7 +67,7 @@ export interface CanvasContext {
62
67
 
63
68
  /** Creates a fresh context (call once per mount). */
64
69
  export function createCanvasContext(actor: any): CanvasContext {
65
- return {
70
+ const ctx: CanvasContext = {
66
71
  actor,
67
72
  snap: () => actor.getSnapshot(),
68
73
 
@@ -82,7 +87,7 @@ export function createCanvasContext(actor: any): CanvasContext {
82
87
  scrollTimers: {},
83
88
  connectionDragState: null,
84
89
  loadingOverlay: null,
85
- useCanvasText: true,
90
+ textRendererMode: (localStorage.getItem('gitcanvas:textRendererMode') as any) || 'dom',
86
91
 
87
92
  allFilesActive: true,
88
93
  changedFilePaths: new Set(),
@@ -91,4 +96,15 @@ export function createCanvasContext(actor: any): CanvasContext {
91
96
  deferredCards: new Map(),
92
97
  controlMode: (localStorage.getItem('gitcanvas:controlMode') as any) || 'advanced',
93
98
  };
99
+
100
+ currentCanvasContext = ctx;
101
+ return ctx;
102
+ }
103
+
104
+ export function setCanvasContext(ctx: CanvasContext | null): void {
105
+ currentCanvasContext = ctx;
106
+ }
107
+
108
+ export function getCanvasContext(): CanvasContext | null {
109
+ return currentCanvasContext;
94
110
  }
@@ -34,6 +34,36 @@ const REMOVE_MS = 15000; // remove after 15s
34
34
 
35
35
  let _lastBroadcast = 0;
36
36
 
37
+ // ─── Editor Sync Events ─────────────────────────────────
38
+ export interface EditorSyncData {
39
+ peerId: string;
40
+ color: string;
41
+ name: string;
42
+ file: string;
43
+ selections: { anchor: number; head: number }[];
44
+ typing?: boolean;
45
+ }
46
+
47
+ type EditorSyncListener = (data: EditorSyncData) => void;
48
+ const _editorSyncListeners = new Set<EditorSyncListener>();
49
+
50
+ export function onEditorSync(listener: EditorSyncListener) {
51
+ _editorSyncListeners.add(listener);
52
+ return () => _editorSyncListeners.delete(listener);
53
+ }
54
+
55
+ export function broadcastEditorSync(file: string, selections: { anchor: number; head: number }[], typing: boolean = false) {
56
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
57
+ const userName = localStorage.getItem('gitcanvas:username') || _peerId || 'anonymous';
58
+ _ws.send(JSON.stringify({
59
+ type: 'editor_sync',
60
+ name: userName,
61
+ file,
62
+ selections,
63
+ typing
64
+ }));
65
+ }
66
+
37
67
  // ─── Initialize ─────────────────────────────────────────
38
68
 
39
69
  export function initCursorSharing(ctx: CanvasContext) {
@@ -111,6 +141,10 @@ function connectWebSocket() {
111
141
  _color = data.color;
112
142
  } else if (data.type === 'cursor') {
113
143
  handleRemoteCursor(data);
144
+ } else if (data.type === 'editor_sync') {
145
+ for (const listener of _editorSyncListeners) {
146
+ listener(data);
147
+ }
114
148
  } else if (data.type === 'leave') {
115
149
  removeRemoteCursor(data.peerId);
116
150
  }