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,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, '<').replace(/>/g, '>')}</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, '<').replace(/>/g, '>')}</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
|
+
}
|