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,912 @@
1
+ export interface CanvasTextOptions {
2
+ content: string;
3
+ addedLines?: Set<number>;
4
+ deletedBeforeLine?: Map<number, string[]>;
5
+ isAllAdded?: boolean;
6
+ isAllDeleted?: boolean;
7
+ visibleLineIndices?: Set<number>;
8
+ /** File path for PR review comments (if set, enables inline commenting) */
9
+ filePath?: string;
10
+ }
11
+
12
+ export class CanvasTextRenderer {
13
+ private canvas: HTMLCanvasElement;
14
+ private ctx: CanvasRenderingContext2D;
15
+ private lines: string[];
16
+ private drawnLines: { index: number; content: string; num: number }[] = [];
17
+ private lineHeight: number = 20;
18
+ private charWidth: number = 7.2;
19
+ private scrollTop: number = 0;
20
+ private scrollLeft: number = 0;
21
+ private viewportHeight: number = 0;
22
+ private viewportWidth: number = 0;
23
+ private options: CanvasTextOptions;
24
+ private container: HTMLElement;
25
+ private hunkRanges: { startIdx: number; endIdx: number; type: 'add' | 'del' }[] = [];
26
+ private maxLineWidth: number = 0;
27
+ private fontSize: number = 12;
28
+ private hoverPopup: HTMLElement | null = null;
29
+ /** Index of the currently highlighted hunk (for nav flash), -1 = none */
30
+ private _highlightedHunkIdx: number = -1;
31
+ /** Dynamic gutter: diff marker (6px) + line number chars + padding */
32
+ private gutterLeft: number = 6;
33
+ private lineNumWidth: number = 42;
34
+ private get contentX() { return this.gutterLeft + this.lineNumWidth; }
35
+ /** Cached DPR to avoid reading window.devicePixelRatio every frame */
36
+ private _dpr: number = 1;
37
+ /** Cached number of digits for line numbers */
38
+ private _numDigits: number = 3;
39
+ /** Pre-computed padded line number strings (avoids String+padStart per line per frame) */
40
+ private _lineNumStrings: string[] = [];
41
+ /** Cached gradient for long-line fade (recreated only on resize) */
42
+ private _longLineGrad: CanvasGradient | null = null;
43
+ private _longLineGradW: number = 0;
44
+ /** rAF batching — only one render per animation frame */
45
+ private _rafId: number = 0;
46
+ /** Cached file comments for rendering markers (refreshed on changes) */
47
+ private _fileComments: Map<number, import('./pr-review').ReviewComment[]> = new Map();
48
+ /** Unsubscribe from review change listener */
49
+ private _reviewUnsub: (() => void) | null = null;
50
+
51
+ constructor(container: HTMLElement, options: CanvasTextOptions) {
52
+ this.options = options;
53
+ this.container = container;
54
+ this.lines = options.content.split('\n');
55
+
56
+ // Read font size from settings
57
+ try {
58
+ const stored = localStorage.getItem('gitcanvas:settings');
59
+ if (stored) {
60
+ const parsed = JSON.parse(stored);
61
+ if (parsed.fontSize) this.fontSize = parsed.fontSize;
62
+ }
63
+ } catch { }
64
+
65
+ // Compute char width based on font size
66
+ this.charWidth = this.fontSize * 0.6;
67
+ this.lineHeight = this.fontSize + 8;
68
+
69
+ // Pre-compute visible drawn lines
70
+ for (let i = 0; i < this.lines.length; i++) {
71
+ if (options.visibleLineIndices && !options.visibleLineIndices.has(i)) continue;
72
+ this.drawnLines.push({ index: i, content: this.lines[i], num: i + 1 });
73
+ }
74
+
75
+ // Compute dynamic gutter width based on max line number digits
76
+ this._recomputeGutter();
77
+
78
+ // Compute max line width for horizontal scroll
79
+ for (const dl of this.drawnLines) {
80
+ const w = this.contentX + dl.content.length * this.charWidth + 20;
81
+ if (w > this.maxLineWidth) this.maxLineWidth = w;
82
+ }
83
+
84
+ // Pre-compute hunk ranges (contiguous blocks of added/deleted lines)
85
+ this._computeHunkRanges();
86
+
87
+ // Create canvas — absolute positioned, pinned to visible area on scroll
88
+ this.canvas = document.createElement('canvas');
89
+ this.canvas.className = 'canvas-text-layer';
90
+ this.canvas.style.position = 'absolute';
91
+ this.canvas.style.top = '0';
92
+ this.canvas.style.left = '0';
93
+ this.canvas.style.width = '100%';
94
+ this.canvas.style.display = 'block';
95
+ this.canvas.style.pointerEvents = 'none';
96
+
97
+ // High DPI support
98
+ this._dpr = window.devicePixelRatio || 1;
99
+ const rect = container.getBoundingClientRect();
100
+ this.canvas.width = rect.width * this._dpr;
101
+ this.canvas.height = rect.height * this._dpr;
102
+ this.canvas.style.height = `${rect.height}px`;
103
+
104
+ this.ctx = this.canvas.getContext('2d')!;
105
+ this.ctx.scale(this._dpr, this._dpr);
106
+
107
+ // Setup font
108
+ this.ctx.font = `${this.fontSize}px "JetBrains Mono", Consolas, monospace`;
109
+ this.ctx.textBaseline = 'top';
110
+
111
+ // Pre-compute padded line number strings for all drawn lines
112
+ this._cacheLineNumStrings();
113
+
114
+ // Ensure container is a positioned scroll parent
115
+ container.style.position = 'relative';
116
+
117
+ container.appendChild(this.canvas);
118
+
119
+ this.viewportHeight = rect.height;
120
+ this.viewportWidth = rect.width;
121
+
122
+ // Scroll shim — tall div giving the container scrollable height (vertical only)
123
+ const scrollShim = document.createElement('div');
124
+ scrollShim.className = 'canvas-scroll-shim';
125
+ scrollShim.style.height = `${this.drawnLines.length * this.lineHeight}px`;
126
+ scrollShim.style.width = '1px';
127
+ scrollShim.style.pointerEvents = 'none';
128
+ container.appendChild(scrollShim);
129
+
130
+ // Hide horizontal scrollbar
131
+ container.style.overflowX = 'hidden';
132
+
133
+ // Custom scrollbar track for vertical position indicator
134
+ this._buildScrollTrack(container);
135
+
136
+ // Change markers gutter (scrollbar-like overlay on right side)
137
+ this._buildChangeGutter(container);
138
+
139
+ // Hover popup for long lines and diff markers
140
+ this._setupHoverPopup(container);
141
+
142
+ container.addEventListener('scroll', () => {
143
+ this.scrollTop = container.scrollTop;
144
+ // Pin canvas to the visible area of the scrolling container
145
+ this.canvas.style.top = `${this.scrollTop}px`;
146
+ this._updateScrollTrack();
147
+ this.render();
148
+ });
149
+
150
+ // Direct wheel handler so mouse wheel scrolling works (viewport-level
151
+ // handler intercepts wheel events, so we must also listen here)
152
+ container.addEventListener('wheel', (e: WheelEvent) => {
153
+ // Don't interfere with Ctrl+scroll zoom
154
+ if (e.ctrlKey || e.metaKey) return;
155
+ e.preventDefault();
156
+ e.stopPropagation();
157
+
158
+ // If popup is visible and has overflowing content, scroll the popup
159
+ if (this.hoverPopup && this.hoverPopup.style.display === 'block' &&
160
+ this.hoverPopup.scrollHeight > this.hoverPopup.clientHeight) {
161
+ this.hoverPopup.scrollTop += e.deltaY;
162
+ return;
163
+ }
164
+
165
+ const maxScrollY = (this.drawnLines.length * this.lineHeight) - this.viewportHeight;
166
+
167
+ // Vertical scroll only
168
+ this.scrollTop = Math.max(0, Math.min(maxScrollY, this.scrollTop + e.deltaY));
169
+ container.scrollTop = this.scrollTop;
170
+
171
+ this._updateScrollTrack();
172
+ this.render();
173
+ }, { passive: false });
174
+
175
+ const resizeObserver = new ResizeObserver((entries) => {
176
+ for (const entry of entries) {
177
+ const r = entry.contentRect;
178
+ this.canvas.width = r.width * this._dpr;
179
+ this.canvas.height = r.height * this._dpr;
180
+ this.canvas.style.width = `${r.width}px`;
181
+ this.canvas.style.height = `${r.height}px`;
182
+ this.viewportHeight = r.height;
183
+ this.viewportWidth = r.width;
184
+ // Reset transform to identity before re-applying DPR scale
185
+ // (prevents compounding on repeated resize events)
186
+ this.ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
187
+ this.ctx.font = `${this.fontSize}px "JetBrains Mono", Consolas, monospace`;
188
+ this.ctx.textBaseline = 'top';
189
+ // Invalidate cached gradient on resize
190
+ this._longLineGrad = null;
191
+ this._updateScrollTrack();
192
+ this.render();
193
+ }
194
+ });
195
+ resizeObserver.observe(container);
196
+
197
+ // Listen for settings changes
198
+ window.addEventListener('gitcanvas:settings-changed', ((e: CustomEvent) => {
199
+ const newSize = e.detail?.fontSize;
200
+ if (newSize && newSize !== this.fontSize) {
201
+ this.fontSize = newSize;
202
+ this.charWidth = this.fontSize * 0.6;
203
+ this.lineHeight = this.fontSize + 8;
204
+ this.ctx.font = `${this.fontSize}px "JetBrains Mono", Consolas, monospace`;
205
+ // Recompute gutter and max line width
206
+ this._recomputeGutter();
207
+ this.maxLineWidth = 0;
208
+ for (const dl of this.drawnLines) {
209
+ const w = this.contentX + dl.content.length * this.charWidth + 20;
210
+ if (w > this.maxLineWidth) this.maxLineWidth = w;
211
+ }
212
+ // Update scroll shim
213
+ const shim = container.querySelector('.canvas-scroll-shim') as HTMLElement;
214
+ if (shim) {
215
+ shim.style.height = `${this.drawnLines.length * this.lineHeight}px`;
216
+ shim.style.width = `${Math.max(this.maxLineWidth, this.viewportWidth)}px`;
217
+ }
218
+ this._updateScrollTrack();
219
+ this._cacheLineNumStrings();
220
+ this._longLineGrad = null;
221
+ this.render();
222
+ }
223
+ }) as EventListener);
224
+ // PR Review — load file comments and wire click handler
225
+ if (options.filePath) {
226
+ this._loadFileComments();
227
+ // Re-render when comments change
228
+ import('./pr-review').then(({ onReviewChange }) => {
229
+ this._reviewUnsub = onReviewChange(() => {
230
+ this._loadFileComments();
231
+ this.render();
232
+ });
233
+ });
234
+
235
+ // Click handler on gutter area — open comment popup
236
+ container.addEventListener('click', (e: MouseEvent) => {
237
+ if (!this.options.filePath) return;
238
+ const rect = container.getBoundingClientRect();
239
+ const x = e.clientX - rect.left;
240
+ const y = e.clientY - rect.top;
241
+
242
+ // Only trigger on gutter click (left of content area)
243
+ if (x > this.contentX) return;
244
+
245
+ // Calculate which line was clicked
246
+ const lineIdx = Math.floor((y + this.scrollTop) / this.lineHeight);
247
+ if (lineIdx < 0 || lineIdx >= this.drawnLines.length) return;
248
+
249
+ const lineData = this.drawnLines[lineIdx];
250
+ import('./pr-review').then(({ showCommentPopup }) => {
251
+ showCommentPopup(
252
+ this.options.filePath!,
253
+ lineData.num,
254
+ e.clientX,
255
+ e.clientY,
256
+ () => {
257
+ this._loadFileComments();
258
+ this.render();
259
+ }
260
+ );
261
+ });
262
+ });
263
+ }
264
+
265
+ this.render();
266
+ }
267
+
268
+ /** Recompute gutter width based on font size and line count */
269
+ private _recomputeGutter() {
270
+ const maxNum = this.drawnLines.length > 0
271
+ ? this.drawnLines[this.drawnLines.length - 1].num
272
+ : 1;
273
+ this._numDigits = Math.max(3, String(maxNum).length);
274
+ // gutter = diff marker (6px) + line num chars + padding (12px)
275
+ this.lineNumWidth = this._numDigits * this.charWidth + 12;
276
+ }
277
+
278
+ /** Pre-compute padded line number strings to avoid per-frame allocation */
279
+ private _cacheLineNumStrings() {
280
+ this._lineNumStrings = new Array(this.drawnLines.length);
281
+ for (let i = 0; i < this.drawnLines.length; i++) {
282
+ this._lineNumStrings[i] = String(this.drawnLines[i].num).padStart(this._numDigits, ' ');
283
+ }
284
+ }
285
+
286
+ /** Load file comments from the review store */
287
+ private _loadFileComments() {
288
+ if (!this.options.filePath) {
289
+ this._fileComments = new Map();
290
+ return;
291
+ }
292
+ import('./pr-review').then(({ getAllFileComments }) => {
293
+ this._fileComments = getAllFileComments(this.options.filePath!);
294
+ });
295
+ }
296
+
297
+ /** Build a custom scrollbar track on the right side */
298
+ private _buildScrollTrack(container: HTMLElement) {
299
+ const track = document.createElement('div');
300
+ track.className = 'canvas-scroll-track';
301
+ track.style.cssText = `
302
+ position: absolute; top: 0; right: 0; width: 8px;
303
+ height: 100%; z-index: 10; pointer-events: auto;
304
+ background: rgba(255, 255, 255, 0.03);
305
+ border-radius: 4px; opacity: 0.5;
306
+ transition: opacity 0.2s;
307
+ `;
308
+
309
+ const thumb = document.createElement('div');
310
+ thumb.className = 'canvas-scroll-thumb';
311
+ thumb.style.cssText = `
312
+ position: absolute; right: 1px; width: 6px;
313
+ min-height: 24px; border-radius: 3px;
314
+ background: rgba(124, 58, 237, 0.5);
315
+ transition: background 0.15s;
316
+ cursor: pointer;
317
+ `;
318
+
319
+ track.appendChild(thumb);
320
+ container.appendChild(track);
321
+
322
+ // Scrollbar is always minimally visible; brightens on hover/scroll
323
+ let hideTimeout: any = null;
324
+ const BASELINE_OPACITY = '0.5';
325
+ const ACTIVE_OPACITY = '1';
326
+ const showTrack = () => {
327
+ track.style.opacity = ACTIVE_OPACITY;
328
+ if (hideTimeout) clearTimeout(hideTimeout);
329
+ hideTimeout = setTimeout(() => { track.style.opacity = BASELINE_OPACITY; }, 1500);
330
+ };
331
+
332
+ container.addEventListener('scroll', showTrack);
333
+ container.addEventListener('mouseenter', showTrack);
334
+ track.addEventListener('mouseenter', () => {
335
+ track.style.opacity = ACTIVE_OPACITY;
336
+ thumb.style.background = 'rgba(124, 58, 237, 0.7)';
337
+ if (hideTimeout) clearTimeout(hideTimeout);
338
+ });
339
+ track.addEventListener('mouseleave', () => {
340
+ thumb.style.background = 'rgba(124, 58, 237, 0.5)';
341
+ hideTimeout = setTimeout(() => { track.style.opacity = BASELINE_OPACITY; }, 800);
342
+ });
343
+
344
+ // Click on track background to jump, drag thumb to scrub
345
+ let dragging = false;
346
+ let dragStartY = 0;
347
+ let dragStartScroll = 0;
348
+
349
+ track.addEventListener('mousedown', (e) => {
350
+ e.preventDefault();
351
+ e.stopPropagation();
352
+ const trackRect = track.getBoundingClientRect();
353
+ const thumbRect = thumb.getBoundingClientRect();
354
+
355
+ // Check if click is on the thumb
356
+ if (e.clientY >= thumbRect.top && e.clientY <= thumbRect.bottom) {
357
+ // Start dragging the thumb
358
+ dragging = true;
359
+ dragStartY = e.clientY;
360
+ dragStartScroll = this.scrollTop;
361
+ thumb.style.background = 'rgba(124, 58, 237, 0.9)';
362
+ track.style.opacity = ACTIVE_OPACITY;
363
+
364
+ const onDragMove = (me: MouseEvent) => {
365
+ if (!dragging) return;
366
+ const totalContent = this.drawnLines.length * this.lineHeight;
367
+ const maxScroll = totalContent - this.viewportHeight;
368
+ const thumbH = parseFloat(thumb.style.height) || 24;
369
+ const trackH = trackRect.height - thumbH;
370
+ const dy = me.clientY - dragStartY;
371
+ const scrollDelta = (dy / trackH) * maxScroll;
372
+ this.scrollTop = Math.max(0, Math.min(maxScroll, dragStartScroll + scrollDelta));
373
+ container.scrollTop = this.scrollTop;
374
+ this._updateScrollTrack();
375
+ this.render();
376
+ };
377
+
378
+ const onDragEnd = () => {
379
+ dragging = false;
380
+ thumb.style.background = 'rgba(124, 58, 237, 0.5)';
381
+ window.removeEventListener('mousemove', onDragMove);
382
+ window.removeEventListener('mouseup', onDragEnd);
383
+ hideTimeout = setTimeout(() => { track.style.opacity = BASELINE_OPACITY; }, 800);
384
+ };
385
+
386
+ window.addEventListener('mousemove', onDragMove);
387
+ window.addEventListener('mouseup', onDragEnd);
388
+ } else {
389
+ // Click on track background → jump to position
390
+ const clickY = e.clientY - trackRect.top;
391
+ const totalContent = this.drawnLines.length * this.lineHeight;
392
+ const scrollPct = clickY / trackRect.height;
393
+ this.scrollTop = Math.max(0, scrollPct * (totalContent - this.viewportHeight));
394
+ container.scrollTop = this.scrollTop;
395
+ this._updateScrollTrack();
396
+ this.render();
397
+ }
398
+ });
399
+
400
+ // Pin track on scroll
401
+ container.addEventListener('scroll', () => {
402
+ track.style.top = `${container.scrollTop}px`;
403
+ });
404
+
405
+ this._updateScrollTrack();
406
+ }
407
+
408
+ /** Update custom scrollbar thumb position and size */
409
+ private _updateScrollTrack() {
410
+ const thumb = this.container.querySelector('.canvas-scroll-thumb') as HTMLElement;
411
+ if (!thumb) return;
412
+
413
+ const totalContent = this.drawnLines.length * this.lineHeight;
414
+ if (totalContent <= this.viewportHeight) {
415
+ thumb.style.display = 'none';
416
+ return;
417
+ }
418
+
419
+ thumb.style.display = 'block';
420
+ const thumbHeight = Math.max(24, (this.viewportHeight / totalContent) * this.viewportHeight);
421
+ const thumbTop = (this.scrollTop / (totalContent - this.viewportHeight)) * (this.viewportHeight - thumbHeight);
422
+ thumb.style.height = `${thumbHeight}px`;
423
+ thumb.style.top = `${thumbTop}px`;
424
+ }
425
+
426
+ /** Compute contiguous hunk ranges from addedLines/deletedBeforeLine */
427
+ private _computeHunkRanges() {
428
+ const { addedLines, deletedBeforeLine, isAllAdded } = this.options;
429
+ if (isAllAdded) {
430
+ this.hunkRanges.push({ startIdx: 0, endIdx: this.drawnLines.length - 1, type: 'add' });
431
+ return;
432
+ }
433
+
434
+ // Map drawn-line indices to their change type
435
+ const changeMap = new Map<number, 'add' | 'del'>();
436
+ for (let i = 0; i < this.drawnLines.length; i++) {
437
+ const num = this.drawnLines[i].num;
438
+ if (addedLines?.has(num)) changeMap.set(i, 'add');
439
+ else if (deletedBeforeLine?.has(num)) changeMap.set(i, 'del');
440
+ }
441
+
442
+ // Build contiguous ranges
443
+ let currentRange: { startIdx: number; endIdx: number; type: 'add' | 'del' } | null = null;
444
+ for (let i = 0; i < this.drawnLines.length; i++) {
445
+ const type = changeMap.get(i);
446
+ if (type) {
447
+ if (currentRange && currentRange.type === type && i - currentRange.endIdx <= 2) {
448
+ // Extend current range (allow 1-line gaps to group nearby hunks)
449
+ currentRange.endIdx = i;
450
+ } else {
451
+ if (currentRange) this.hunkRanges.push(currentRange);
452
+ currentRange = { startIdx: i, endIdx: i, type };
453
+ }
454
+ } else {
455
+ if (currentRange && i - currentRange.endIdx > 2) {
456
+ this.hunkRanges.push(currentRange);
457
+ currentRange = null;
458
+ }
459
+ }
460
+ }
461
+ if (currentRange) this.hunkRanges.push(currentRange);
462
+ }
463
+
464
+ /** Build a DOM-based change gutter alongside the scrollbar */
465
+ private _buildChangeGutter(container: HTMLElement) {
466
+ if (this.hunkRanges.length === 0) return;
467
+
468
+ const totalLines = this.drawnLines.length;
469
+ if (totalLines === 0) return;
470
+
471
+ // Remove any existing change gutter (prevents duplicates on re-render)
472
+ container.querySelector('.canvas-change-gutter')?.remove();
473
+
474
+ // Gutter container — overlays on the right side of the card
475
+ const gutter = document.createElement('div');
476
+ gutter.className = 'canvas-change-gutter';
477
+ gutter.style.cssText = `
478
+ position: absolute; top: 0; right: 10px;
479
+ width: 10px; height: ${this.viewportHeight}px;
480
+ z-index: 5; pointer-events: auto;
481
+ `;
482
+
483
+ // Re-pin gutter on scroll
484
+ container.addEventListener('scroll', () => {
485
+ gutter.style.top = `${container.scrollTop}px`;
486
+ });
487
+
488
+ // Add markers for each hunk
489
+ for (const hunk of this.hunkRanges) {
490
+ const startPct = (hunk.startIdx / totalLines) * 100;
491
+ const endPct = ((hunk.endIdx + 1) / totalLines) * 100;
492
+ const heightPct = Math.max(1.5, endPct - startPct); // Min 1.5% height for visibility
493
+
494
+ const marker = document.createElement('div');
495
+ marker.className = `canvas-gutter-marker canvas-gutter-marker--${hunk.type}`;
496
+ marker.style.cssText = `
497
+ position: absolute;
498
+ top: ${startPct}%;
499
+ height: ${heightPct}%;
500
+ width: 8px;
501
+ left: 1px;
502
+ border-radius: 2px;
503
+ cursor: pointer;
504
+ background: ${hunk.type === 'add' ? 'rgba(46, 160, 67, 0.7)' : 'rgba(248, 81, 73, 0.7)'};
505
+ transition: background 0.15s;
506
+ min-height: 4px;
507
+ `;
508
+ marker.title = `${hunk.type === 'add' ? 'Added' : 'Deleted'} lines ${this.drawnLines[hunk.startIdx].num}–${this.drawnLines[hunk.endIdx].num}`;
509
+
510
+ // Click → scroll to that hunk
511
+ marker.addEventListener('mousedown', (e) => { e.stopPropagation(); });
512
+ marker.addEventListener('click', (e) => {
513
+ e.stopPropagation();
514
+ const targetScroll = hunk.startIdx * this.lineHeight - this.viewportHeight / 4;
515
+ this.scrollTop = Math.max(0, targetScroll);
516
+ container.scrollTop = this.scrollTop;
517
+ this._updateScrollTrack();
518
+ this.render();
519
+ });
520
+
521
+ marker.addEventListener('mouseenter', () => {
522
+ marker.style.background = hunk.type === 'add' ? 'rgba(46, 160, 67, 1)' : 'rgba(248, 81, 73, 1)';
523
+ marker.style.width = '10px';
524
+ });
525
+ marker.addEventListener('mouseleave', () => {
526
+ marker.style.background = hunk.type === 'add' ? 'rgba(46, 160, 67, 0.7)' : 'rgba(248, 81, 73, 0.7)';
527
+ marker.style.width = '8px';
528
+ });
529
+
530
+ gutter.appendChild(marker);
531
+ }
532
+
533
+ // Navigation arrows (up/down between hunks)
534
+ if (this.hunkRanges.length > 1) {
535
+ const navContainer = document.createElement('div');
536
+ navContainer.style.cssText = `
537
+ position: absolute; top: ${this.viewportHeight - 44}px; right: 24px;
538
+ display: flex; flex-direction: column; gap: 2px;
539
+ z-index: 6; pointer-events: auto;
540
+ `;
541
+
542
+ // Re-pin nav on scroll
543
+ container.addEventListener('scroll', () => {
544
+ navContainer.style.top = `${container.scrollTop + this.viewportHeight - 44}px`;
545
+ });
546
+
547
+ let currentHunkIdx = -1;
548
+
549
+ // Flash a gutter marker when navigated to
550
+ const flashMarker = (idx: number) => {
551
+ const markers = gutter.querySelectorAll('.canvas-gutter-marker');
552
+ const marker = markers[idx] as HTMLElement;
553
+ if (!marker) return;
554
+ marker.style.background = this.hunkRanges[idx].type === 'add'
555
+ ? 'rgba(46, 160, 67, 1)' : 'rgba(248, 81, 73, 1)';
556
+ marker.style.width = '12px';
557
+ marker.style.boxShadow = this.hunkRanges[idx].type === 'add'
558
+ ? '0 0 8px rgba(46, 160, 67, 0.8)' : '0 0 8px rgba(248, 81, 73, 0.8)';
559
+ setTimeout(() => {
560
+ marker.style.background = this.hunkRanges[idx].type === 'add'
561
+ ? 'rgba(46, 160, 67, 0.7)' : 'rgba(248, 81, 73, 0.7)';
562
+ marker.style.width = '8px';
563
+ marker.style.boxShadow = '';
564
+ }, 600);
565
+ };
566
+
567
+ // Counter label
568
+ const counterLabel = document.createElement('span');
569
+ counterLabel.style.cssText = `
570
+ font-size: 8px; color: rgba(201, 209, 217, 0.6);
571
+ font-family: 'JetBrains Mono', monospace;
572
+ text-align: center; line-height: 1; pointer-events: none;
573
+ `;
574
+ counterLabel.textContent = `${this.hunkRanges.length}`;
575
+
576
+ const makeArrow = (label: string, direction: 'up' | 'down') => {
577
+ const btn = document.createElement('button');
578
+ btn.textContent = label;
579
+ btn.title = direction === 'up' ? 'Previous change (↑)' : 'Next change (↓)';
580
+ btn.style.cssText = `
581
+ width: 18px; height: 18px; font-size: 10px; line-height: 1;
582
+ background: rgba(30, 30, 50, 0.85); border: 1px solid rgba(255,255,255,0.1);
583
+ border-radius: 3px; color: #c9d1d9; cursor: pointer;
584
+ display: flex; align-items: center; justify-content: center;
585
+ padding: 0;
586
+ `;
587
+ btn.addEventListener('mousedown', (e) => { e.stopPropagation(); });
588
+ btn.addEventListener('click', (e) => {
589
+ e.stopPropagation();
590
+ if (direction === 'down') {
591
+ currentHunkIdx = Math.min(currentHunkIdx + 1, this.hunkRanges.length - 1);
592
+ } else {
593
+ currentHunkIdx = Math.max(currentHunkIdx - 1, 0);
594
+ }
595
+ const hunk = this.hunkRanges[currentHunkIdx];
596
+ const targetScroll = hunk.startIdx * this.lineHeight - this.viewportHeight / 4;
597
+ this.scrollTop = Math.max(0, targetScroll);
598
+ container.scrollTop = this.scrollTop;
599
+ this._updateScrollTrack();
600
+
601
+ // Highlight the navigated hunk briefly in the canvas render
602
+ this._highlightedHunkIdx = currentHunkIdx;
603
+ this.render();
604
+ setTimeout(() => {
605
+ this._highlightedHunkIdx = -1;
606
+ this.render();
607
+ }, 500);
608
+
609
+ // Flash the gutter marker
610
+ flashMarker(currentHunkIdx);
611
+
612
+ // Update counter
613
+ counterLabel.textContent = `${currentHunkIdx + 1}/${this.hunkRanges.length}`;
614
+ });
615
+ return btn;
616
+ };
617
+
618
+ navContainer.appendChild(makeArrow('▲', 'up'));
619
+ navContainer.appendChild(counterLabel);
620
+ navContainer.appendChild(makeArrow('▼', 'down'));
621
+ container.appendChild(navContainer);
622
+ }
623
+
624
+ container.appendChild(gutter);
625
+ }
626
+
627
+ /** Setup hover popup for long lines and diff markers */
628
+ private _setupHoverPopup(container: HTMLElement) {
629
+ let hideTimeout: any = null;
630
+ let activeLineIdx = -1; // Track which line the popup is currently showing for
631
+
632
+ const scheduleHide = () => {
633
+ if (!hideTimeout) {
634
+ hideTimeout = setTimeout(() => {
635
+ this._hideHoverPopup();
636
+ activeLineIdx = -1;
637
+ hideTimeout = null;
638
+ }, 200);
639
+ }
640
+ };
641
+
642
+ const ensurePopup = () => {
643
+ if (!this.hoverPopup) {
644
+ // Read popup font size from settings
645
+ let popupFontSize = 14;
646
+ try {
647
+ const stored = localStorage.getItem('gitcanvas:settings');
648
+ if (stored) {
649
+ const parsed = JSON.parse(stored);
650
+ if (parsed.popupFontSize) popupFontSize = parsed.popupFontSize;
651
+ }
652
+ } catch { }
653
+
654
+ this.hoverPopup = document.createElement('div');
655
+ this.hoverPopup.className = 'canvas-text-hover-popup';
656
+ this.hoverPopup.style.cssText = `
657
+ position: fixed; z-index: 10000;
658
+ background: rgba(15, 15, 25, 0.95);
659
+ border: 1px solid rgba(124, 58, 237, 0.3);
660
+ border-radius: 6px;
661
+ padding: 8px 12px;
662
+ max-width: 700px;
663
+ max-height: 300px;
664
+ overflow: auto;
665
+ font-family: "JetBrains Mono", Consolas, monospace;
666
+ font-size: ${popupFontSize}px;
667
+ line-height: 1.4;
668
+ color: #c9d1d9;
669
+ backdrop-filter: blur(8px);
670
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5), 0 0 10px rgba(124, 58, 237, 0.15);
671
+ white-space: pre-wrap;
672
+ word-break: break-all;
673
+ pointer-events: none;
674
+ `;
675
+ document.body.appendChild(this.hoverPopup);
676
+ }
677
+ };
678
+
679
+ // Recreate popup when popupFontSize setting changes
680
+ window.addEventListener('gitcanvas:settings-changed', () => {
681
+ if (this.hoverPopup) {
682
+ this.hoverPopup.remove();
683
+ this.hoverPopup = null;
684
+ }
685
+ });
686
+
687
+ container.addEventListener('mousemove', (e) => {
688
+ const rect = container.getBoundingClientRect();
689
+ const scaleX = container.offsetWidth > 0 ? rect.width / container.offsetWidth : 1;
690
+ const scaleY = container.offsetHeight > 0 ? rect.height / container.offsetHeight : 1;
691
+
692
+ const mouseX = (e.clientX - rect.left) / scaleX;
693
+ const mouseY = (e.clientY - rect.top) / scaleY + this.scrollTop;
694
+
695
+ const lineIdx = Math.floor(mouseY / this.lineHeight);
696
+ if (lineIdx < 0 || lineIdx >= this.drawnLines.length) {
697
+ scheduleHide();
698
+ return;
699
+ }
700
+
701
+ const lineData = this.drawnLines[lineIdx];
702
+ const linePixelWidth = this.contentX + lineData.content.length * this.charWidth;
703
+ const isLongLine = linePixelWidth > this.viewportWidth;
704
+ const hasDelBefore = this.options.deletedBeforeLine?.has(lineData.num);
705
+
706
+ if (!isLongLine && !hasDelBefore) {
707
+ // Hysteresis: keep popup for THIS line visible
708
+ if (activeLineIdx === lineIdx && this.hoverPopup?.style.display === 'block') {
709
+ if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
710
+ return;
711
+ }
712
+ scheduleHide();
713
+ return;
714
+ }
715
+
716
+ // Cancel any pending hide
717
+ if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
718
+
719
+ // Same line — just reposition
720
+ if (activeLineIdx === lineIdx && this.hoverPopup?.style.display === 'block') {
721
+ let px = e.clientX + 12;
722
+ const popupRect = this.hoverPopup.getBoundingClientRect();
723
+ const popupH = popupRect.height || 120;
724
+ let py = e.clientY - popupH - 8;
725
+ if (py < 8) py = e.clientY + 16;
726
+ if (px + 700 > window.innerWidth) px = e.clientX - 400;
727
+ this.hoverPopup.style.left = `${px}px`;
728
+ this.hoverPopup.style.top = `${py}px`;
729
+ return;
730
+ }
731
+
732
+ // New line — update popup content instantly (no debounce)
733
+ activeLineIdx = lineIdx;
734
+ ensurePopup();
735
+
736
+ let popupHTML = '';
737
+
738
+ if (hasDelBefore) {
739
+ const delLines = this.options.deletedBeforeLine!.get(lineData.num)!;
740
+ popupHTML += `<div style="color: #f87171; font-size: 10px; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em;">${delLines.length} deleted line${delLines.length > 1 ? 's' : ''}</div>`;
741
+ popupHTML += delLines.map(l =>
742
+ `<div style="color: #ffa198; background: rgba(248,81,73,0.1); padding: 1px 4px; border-radius: 2px;">− ${l.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`
743
+ ).join('');
744
+ if (isLongLine) {
745
+ popupHTML += `<div style="margin-top: 6px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 6px;">`;
746
+ }
747
+ }
748
+
749
+ if (isLongLine) {
750
+ const isAdded = this.options.isAllAdded || (this.options.addedLines?.has(lineData.num));
751
+ const lineColor = isAdded ? '#7ee787' : this.options.isAllDeleted ? '#ffa198' : '#c9d1d9';
752
+ popupHTML += `<div style="color: rgba(110,118,129,0.7); font-size: 10px; margin-bottom: 2px;">Line ${lineData.num} (${lineData.content.length} chars)</div>`;
753
+ popupHTML += `<div style="color: ${lineColor};">${lineData.content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`;
754
+ if (hasDelBefore) popupHTML += `</div>`;
755
+ }
756
+
757
+ this.hoverPopup!.innerHTML = popupHTML;
758
+ this.hoverPopup!.style.display = 'block';
759
+
760
+ // Position above cursor, fall below near top edge
761
+ let px = e.clientX + 12;
762
+ const popupRect = this.hoverPopup!.getBoundingClientRect();
763
+ const popupH = popupRect.height || 120;
764
+ let py = e.clientY - popupH - 8;
765
+ if (py < 8) py = e.clientY + 16;
766
+ if (px + 700 > window.innerWidth) px = e.clientX - 400;
767
+ this.hoverPopup!.style.left = `${px}px`;
768
+ this.hoverPopup!.style.top = `${py}px`;
769
+ });
770
+
771
+ container.addEventListener('mouseleave', () => {
772
+ scheduleHide();
773
+ });
774
+ }
775
+
776
+ private _hideHoverPopup() {
777
+ if (this.hoverPopup) {
778
+ this.hoverPopup.style.display = 'none';
779
+ }
780
+ }
781
+
782
+ /** Scroll to a specific line number */
783
+ public scrollToLine(lineNum: number) {
784
+ const idx = this.drawnLines.findIndex(dl => dl.num === lineNum);
785
+ if (idx < 0) return;
786
+ const targetScroll = idx * this.lineHeight - this.viewportHeight / 4;
787
+ this.container.scrollTop = Math.max(0, targetScroll);
788
+ }
789
+
790
+ /** Get the line number currently at the top of the viewport */
791
+ public getVisibleLine(): number {
792
+ const idx = Math.floor(this.scrollTop / this.lineHeight);
793
+ if (idx >= 0 && idx < this.drawnLines.length) {
794
+ return this.drawnLines[idx].num;
795
+ }
796
+ return 1;
797
+ }
798
+
799
+ /** Schedule a render on the next animation frame (batches multiple scroll events) */
800
+ private render() {
801
+ if (this._rafId) return; // already scheduled
802
+ this._rafId = requestAnimationFrame(() => {
803
+ this._rafId = 0;
804
+ this._renderImmediate();
805
+ });
806
+ }
807
+
808
+ /** Force an immediate render (bypasses rAF batching — use for hunk flash) */
809
+ private _renderImmediate() {
810
+ if (!this.ctx) return;
811
+
812
+ const w = this.canvas.width / this._dpr;
813
+ const h = this.canvas.height / this._dpr;
814
+
815
+ this.ctx.clearRect(0, 0, w, h);
816
+
817
+ const startIndex = Math.max(0, Math.floor(this.scrollTop / this.lineHeight) - 2);
818
+ const endIndex = Math.min(this.drawnLines.length, startIndex + Math.ceil(this.viewportHeight / this.lineHeight) + 4);
819
+
820
+ // Left gutter width for diff markers
821
+ const diffGutterW = this.gutterLeft;
822
+
823
+ // Cache long-line gradient (recreated only on width change)
824
+ if (!this._longLineGrad || this._longLineGradW !== w) {
825
+ this._longLineGrad = this.ctx.createLinearGradient(w - 30, 0, w, 0);
826
+ this._longLineGrad.addColorStop(0, 'transparent');
827
+ this._longLineGrad.addColorStop(1, 'rgba(15, 15, 25, 0.8)');
828
+ this._longLineGradW = w;
829
+ }
830
+
831
+ // Hoist hot lookups outside the loop
832
+ const { isAllAdded, isAllDeleted, addedLines, deletedBeforeLine } = this.options;
833
+ const lh = this.lineHeight;
834
+ const cx = this.contentX;
835
+ const scrollTop = this.scrollTop;
836
+ const highlightHunk = this._highlightedHunkIdx >= 0 && this._highlightedHunkIdx < this.hunkRanges.length
837
+ ? this.hunkRanges[this._highlightedHunkIdx]
838
+ : null;
839
+
840
+ for (let i = startIndex; i < endIndex; i++) {
841
+ const y = (i * lh) - scrollTop;
842
+ const lineData = this.drawnLines[i];
843
+ const lineNum = lineData.num;
844
+ const isAdded = isAllAdded || (addedLines && addedLines.has(lineNum));
845
+ const hasDelBefore = deletedBeforeLine && deletedBeforeLine.has(lineNum);
846
+
847
+ // Background highlight
848
+ if (isAdded) {
849
+ this.ctx.fillStyle = 'rgba(46, 160, 67, 0.15)';
850
+ this.ctx.fillRect(0, y, w, lh);
851
+ this.ctx.fillStyle = 'rgba(46, 160, 67, 0.8)';
852
+ this.ctx.fillRect(0, y, diffGutterW, lh);
853
+ } else if (isAllDeleted) {
854
+ this.ctx.fillStyle = 'rgba(248, 81, 73, 0.15)';
855
+ this.ctx.fillRect(0, y, w, lh);
856
+ this.ctx.fillStyle = 'rgba(248, 81, 73, 0.8)';
857
+ this.ctx.fillRect(0, y, diffGutterW, lh);
858
+ }
859
+
860
+ // Navigation highlight flash (when using ▲/▼ buttons)
861
+ if (highlightHunk && i >= highlightHunk.startIdx && i <= highlightHunk.endIdx) {
862
+ this.ctx.fillStyle = highlightHunk.type === 'add'
863
+ ? 'rgba(46, 160, 67, 0.3)'
864
+ : 'rgba(248, 81, 73, 0.3)';
865
+ this.ctx.fillRect(0, y, w, lh);
866
+ }
867
+
868
+ // Deleted-before marker (red triangle indicator)
869
+ if (hasDelBefore) {
870
+ this.ctx.fillStyle = 'rgba(248, 81, 73, 1)';
871
+ this.ctx.fillRect(0, y, diffGutterW, 3);
872
+ this.ctx.fillStyle = 'rgba(248, 81, 73, 0.4)';
873
+ this.ctx.fillRect(diffGutterW, y, w - diffGutterW, 1);
874
+ }
875
+
876
+ // Line numbers (pre-computed string, no per-frame allocation)
877
+ this.ctx.fillStyle = '#6e7681';
878
+ this.ctx.fillText(this._lineNumStrings[i], diffGutterW + 2, y + 4);
879
+
880
+ // Content
881
+ this.ctx.fillStyle = isAdded ? '#7ee787' : isAllDeleted ? '#ffa198' : '#c9d1d9';
882
+ this.ctx.fillText(lineData.content, cx, y + 4);
883
+
884
+ // Long line indicator (cached gradient)
885
+ const contentWidth = cx + lineData.content.length * this.charWidth;
886
+ if (contentWidth > w) {
887
+ this.ctx.fillStyle = this._longLineGrad!;
888
+ this.ctx.fillRect(w - 30, y, 30, lh);
889
+ this.ctx.fillStyle = 'rgba(124, 58, 237, 0.4)';
890
+ this.ctx.fillRect(w - 3, y + 3, 2, lh - 6);
891
+ }
892
+
893
+ // PR Review comment marker (purple dot in gutter)
894
+ if (this._fileComments.has(lineNum)) {
895
+ const commentCount = this._fileComments.get(lineNum)!.length;
896
+ // Purple dot
897
+ this.ctx.beginPath();
898
+ this.ctx.arc(diffGutterW - 3, y + lh / 2, 3, 0, Math.PI * 2);
899
+ this.ctx.fillStyle = 'rgba(168, 139, 250, 0.9)';
900
+ this.ctx.fill();
901
+ // Count badge if > 1
902
+ if (commentCount > 1) {
903
+ this.ctx.fillStyle = 'rgba(168, 139, 250, 0.7)';
904
+ this.ctx.font = '9px sans-serif';
905
+ this.ctx.fillText(String(commentCount), diffGutterW - 7, y + 2);
906
+ // Restore font
907
+ this.ctx.font = `${this.fontSize}px "JetBrains Mono", Consolas, monospace`;
908
+ }
909
+ }
910
+ }
911
+ }
912
+ }