gitmaps 1.1.19 → 1.1.21

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/app/analytics.db CHANGED
Binary file
package/app/globals.css CHANGED
@@ -1145,17 +1145,24 @@ body {
1145
1145
 
1146
1146
  /* ── Sticky Zoom Controls ── */
1147
1147
  body.repo-loading .sticky-zoom-pill,
1148
- body.repo-loading .detail-mode-switch,
1148
+ body.repo-loading .floating-top-controls,
1149
1149
  body.landing-placeholder-visible .sticky-zoom-pill,
1150
- body.landing-placeholder-visible .detail-mode-switch {
1150
+ body.landing-placeholder-visible .floating-top-controls {
1151
1151
  display: none;
1152
1152
  }
1153
1153
 
1154
- .detail-mode-switch {
1154
+ .floating-top-controls {
1155
1155
  position: fixed;
1156
1156
  top: 12px;
1157
1157
  right: 144px;
1158
1158
  z-index: 10001;
1159
+ display: flex;
1160
+ align-items: center;
1161
+ gap: 8px;
1162
+ }
1163
+
1164
+ .detail-mode-switch {
1165
+ position: relative;
1159
1166
  }
1160
1167
 
1161
1168
  .detail-mode-btn {
@@ -1192,6 +1199,15 @@ body.landing-placeholder-visible .detail-mode-switch {
1192
1199
  box-shadow: 0 10px 28px rgba(59, 130, 246, 0.16);
1193
1200
  }
1194
1201
 
1202
+ .detail-mode-btn--secondary {
1203
+ border-color: rgba(148, 163, 184, 0.28);
1204
+ color: #e2e8f0;
1205
+ }
1206
+
1207
+ .detail-mode-btn--secondary:hover {
1208
+ border-color: rgba(148, 163, 184, 0.48);
1209
+ }
1210
+
1195
1211
  .detail-mode-label {
1196
1212
  opacity: 0.72;
1197
1213
  text-transform: uppercase;
package/app/layout.tsx CHANGED
@@ -875,10 +875,16 @@ export default function RootLayout({ children }: { children: any }) {
875
875
  </div>
876
876
 
877
877
  {/* Sticky Zoom Controls — floating pill, bottom-right */}
878
- <div id="detailModeSwitch" className="detail-mode-switch" title="Toggle preview-focused detail mode">
879
- <button id="toggleDetailMode" type="button" className="detail-mode-btn">
880
- <span className="detail-mode-label">Renderer</span>
881
- <span id="detailModeState" className="detail-mode-state">Preview</span>
878
+ <div className="floating-top-controls">
879
+ <div id="detailModeSwitch" className="detail-mode-switch" title="Toggle preview-focused detail mode">
880
+ <button id="toggleDetailMode" type="button" className="detail-mode-btn">
881
+ <span className="detail-mode-label">Renderer</span>
882
+ <span id="detailModeState" className="detail-mode-state">Preview</span>
883
+ </button>
884
+ </div>
885
+ <button id="openSettingsFloating" type="button" className="detail-mode-btn detail-mode-btn--secondary" title="Open settings">
886
+ <span className="detail-mode-label">Settings</span>
887
+ <span className="detail-mode-state">Tune</span>
882
888
  </button>
883
889
  </div>
884
890
 
@@ -740,9 +740,11 @@ export function setupEventListeners(ctx: CanvasContext) {
740
740
  });
741
741
 
742
742
  // Settings modal
743
- document.getElementById('openSettings')?.addEventListener('click', () => {
743
+ const openSettings = () => {
744
744
  import('./settings-modal').then(({ openSettingsModal }) => openSettingsModal(ctx));
745
- });
745
+ };
746
+ document.getElementById('openSettings')?.addEventListener('click', openSettings);
747
+ document.getElementById('openSettingsFloating')?.addEventListener('click', openSettings);
746
748
 
747
749
  // Global search
748
750
  document.getElementById('openGlobalSearch')?.addEventListener('click', () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { collectPreviewDiffMarkers, estimatePreviewCharsPerLine, estimatePreviewLineCapacity, estimatePreviewMaxScroll, estimateTitleCharsPerLine, getLowZoomPreviewText, getLowZoomScale, getPreviewScrollMetrics, wrapPreviewText } from './low-zoom-preview';
2
+ import { collectPreviewDiffMarkers, estimatePreviewCharsPerLine, estimatePreviewLineCapacity, estimatePreviewMaxScroll, estimateTitleCharsPerLine, getLowZoomPreviewText, getLowZoomScale, getPreviewRenderableLines, getPreviewScrollMetrics, wrapPreviewText } from './low-zoom-preview';
3
3
 
4
4
  describe('low zoom preview helpers', () => {
5
5
  test('anchors preview text to approximate saved scroll position', () => {
@@ -21,6 +21,18 @@ describe('low zoom preview helpers', () => {
21
21
  expect(getLowZoomPreviewText({ path: 'bin.dat', ext: 'dat', isBinary: true, content: 'abc' }, 0)).toBe('');
22
22
  });
23
23
 
24
+ test('preview renderable lines preserve added and deleted diff context', () => {
25
+ const lines = getPreviewRenderableLines({
26
+ path: 'src/example.ts',
27
+ ext: 'ts',
28
+ content: ['one', 'two', 'three'].join('\n'),
29
+ addedLines: new Set([2]),
30
+ deletedBeforeLine: new Map([[2, ['old-two']]]),
31
+ }, 0);
32
+ expect(lines.some((line) => line.tone === 'deleted' && line.text.includes('old-two'))).toBe(true);
33
+ expect(lines.some((line) => line.tone === 'added' && line.text === 'two')).toBe(true);
34
+ });
35
+
24
36
  test('keeps zoomed-out on-screen text smaller than zoomed-in text', () => {
25
37
  const far = getLowZoomScale(0.1);
26
38
  const near = getLowZoomScale(1);
@@ -25,17 +25,81 @@ export function getLowZoomScale(zoom: number) {
25
25
  };
26
26
  }
27
27
 
28
- export function getLowZoomPreviewText(file: any, scrollTop: number): string {
29
- if (!file || file.isBinary || !file.content) return '';
28
+ type PreviewRenderableLine = {
29
+ text: string;
30
+ tone: 'normal' | 'added' | 'deleted';
31
+ sourceLine?: number;
32
+ };
33
+
34
+ function getPreviewDiffMaps(file: any) {
35
+ const addedLines = new Set<number>(file?.addedLines instanceof Set ? Array.from(file.addedLines) : []);
36
+ const deletedBeforeLine = new Map<number, string[]>(file?.deletedBeforeLine instanceof Map ? Array.from(file.deletedBeforeLine.entries()) : []);
37
+
38
+ if ((addedLines.size === 0 && deletedBeforeLine.size === 0) && Array.isArray(file?.hunks)) {
39
+ for (const hunk of file.hunks) {
40
+ let newLine = hunk?.newStart || 1;
41
+ let pendingDeleted: string[] = [];
42
+ for (const line of hunk?.lines || []) {
43
+ if (line?.type === 'add') {
44
+ addedLines.add(newLine);
45
+ if (pendingDeleted.length > 0) {
46
+ const existing = deletedBeforeLine.get(newLine) || [];
47
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
48
+ pendingDeleted = [];
49
+ }
50
+ newLine += 1;
51
+ } else if (line?.type === 'del') {
52
+ pendingDeleted.push(String(line?.content || ''));
53
+ } else {
54
+ if (pendingDeleted.length > 0) {
55
+ const existing = deletedBeforeLine.get(newLine) || [];
56
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
57
+ pendingDeleted = [];
58
+ }
59
+ newLine += 1;
60
+ }
61
+ }
62
+ if (pendingDeleted.length > 0) {
63
+ const existing = deletedBeforeLine.get(newLine) || [];
64
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
65
+ }
66
+ }
67
+ }
68
+
69
+ return { addedLines, deletedBeforeLine };
70
+ }
71
+
72
+ export function getPreviewRenderableLines(file: any, scrollTop: number): PreviewRenderableLine[] {
73
+ if (!file || file.isBinary || !file.content) return [];
30
74
 
31
75
  const ext = (file.ext || file.path?.split('.').pop() || '').toLowerCase();
32
- if (!PREVIEWABLE_EXTS.has(ext)) return '';
76
+ if (!PREVIEWABLE_EXTS.has(ext)) return [];
33
77
 
34
78
  const normalized = String(file.content).replace(/\t/g, ' ');
35
79
  const lines = normalized.split('\n');
36
80
  const approxLineHeight = 20;
37
81
  const startLine = Math.max(0, Math.floor(scrollTop / approxLineHeight));
38
- return lines.slice(startLine).join('\n').trim();
82
+ const { addedLines, deletedBeforeLine } = getPreviewDiffMaps(file);
83
+ const out: PreviewRenderableLine[] = [];
84
+
85
+ for (let i = startLine; i < lines.length; i += 1) {
86
+ const lineNum = i + 1;
87
+ const deleted = deletedBeforeLine.get(lineNum) || [];
88
+ for (const deletedLine of deleted) {
89
+ out.push({ text: `- ${String(deletedLine).replace(/\t/g, ' ')}`, tone: 'deleted', sourceLine: lineNum });
90
+ }
91
+ out.push({
92
+ text: lines[i],
93
+ tone: file?.status === 'added' || addedLines.has(lineNum) ? 'added' : file?.status === 'deleted' ? 'deleted' : 'normal',
94
+ sourceLine: lineNum,
95
+ });
96
+ }
97
+
98
+ return out;
99
+ }
100
+
101
+ export function getLowZoomPreviewText(file: any, scrollTop: number): string {
102
+ return getPreviewRenderableLines(file, scrollTop).map((line) => line.text).join('\n').trim();
39
103
  }
40
104
 
41
105
  export function wrapPreviewText(text: string, maxCharsPerLine: number, maxLines: number): string[] {
@@ -137,8 +201,7 @@ export function getPreviewScrollMetrics(file: any, height: number, zoom: number,
137
201
  export function collectPreviewDiffMarkers(file: any, totalLines: number) {
138
202
  const markers: Array<{ ratio: number; color: string; height?: number }> = [];
139
203
  const safeTotal = Math.max(1, totalLines);
140
- let added = file?.addedLines instanceof Set ? Array.from(file.addedLines) : [];
141
- let deletedBefore = file?.deletedBeforeLine instanceof Map ? Array.from(file.deletedBeforeLine.keys()) : [];
204
+ const { addedLines, deletedBeforeLine } = getPreviewDiffMaps(file);
142
205
 
143
206
  if (file?.status === 'added') {
144
207
  markers.push({ ratio: 0, color: '#22c55e', height: 1 });
@@ -149,42 +212,10 @@ export function collectPreviewDiffMarkers(file: any, totalLines: number) {
149
212
  return markers;
150
213
  }
151
214
 
152
- if ((added.length === 0 && deletedBefore.length === 0) && Array.isArray(file?.hunks)) {
153
- const derivedAdded: number[] = [];
154
- const derivedDeleted: number[] = [];
155
- for (const hunk of file.hunks) {
156
- let newLine = hunk?.newStart || 1;
157
- let sawPendingDelete = false;
158
- for (const line of hunk?.lines || []) {
159
- if (line?.type === 'add') {
160
- derivedAdded.push(newLine);
161
- if (sawPendingDelete) {
162
- derivedDeleted.push(newLine);
163
- sawPendingDelete = false;
164
- }
165
- newLine += 1;
166
- } else if (line?.type === 'del') {
167
- sawPendingDelete = true;
168
- } else {
169
- if (sawPendingDelete) {
170
- derivedDeleted.push(newLine);
171
- sawPendingDelete = false;
172
- }
173
- newLine += 1;
174
- }
175
- }
176
- if (sawPendingDelete) {
177
- derivedDeleted.push(newLine);
178
- }
179
- }
180
- added = derivedAdded;
181
- deletedBefore = derivedDeleted;
182
- }
183
-
184
- for (const line of added) {
215
+ for (const line of addedLines) {
185
216
  markers.push({ ratio: Math.max(0, Math.min(1, (line - 1) / safeTotal)), color: '#22c55e' });
186
217
  }
187
- for (const line of deletedBefore) {
218
+ for (const line of deletedBeforeLine.keys()) {
188
219
  markers.push({ ratio: Math.max(0, Math.min(1, (line - 1) / safeTotal)), color: '#ef4444' });
189
220
  }
190
221
  return markers;
@@ -255,28 +286,50 @@ export function renderLowZoomPreviewCanvas(
255
286
  ctx.fillText(trimToWidth(ctx, subtitle, maxTextWidth), leftInset, subtitleY);
256
287
 
257
288
  const previewY = subtitleY + subtitleFont + scale.gap;
258
- const rawPreview = getLowZoomPreviewText(file, scrollTop) || 'Preview unavailable';
259
- const wrapped = wrapPreviewText(
260
- rawPreview,
261
- estimatePreviewCharsPerLine(width, zoom),
262
- estimatePreviewLineCapacity(height, zoom),
263
- );
289
+ const previewLines = getPreviewRenderableLines(file, scrollTop);
290
+ const maxCharsPerLine = estimatePreviewCharsPerLine(width, zoom);
291
+ const maxVisibleLines = estimatePreviewLineCapacity(height, zoom);
292
+ const wrapped: Array<{ text: string; tone: 'normal' | 'added' | 'deleted' }> = [];
293
+
294
+ for (const line of previewLines) {
295
+ const parts = wrapPreviewText(line.text, maxCharsPerLine, Math.max(1, maxVisibleLines - wrapped.length));
296
+ for (const part of parts) {
297
+ wrapped.push({ text: part, tone: line.tone });
298
+ if (wrapped.length >= maxVisibleLines) break;
299
+ }
300
+ if (wrapped.length >= maxVisibleLines) break;
301
+ }
302
+
303
+ if (wrapped.length === 0) {
304
+ wrapped.push({ text: 'Preview unavailable', tone: 'normal' });
305
+ }
264
306
 
265
307
  ctx.font = `${scale.bodyFont}px "JetBrains Mono", monospace`;
266
- ctx.fillStyle = 'rgba(226,232,240,0.92)';
267
308
 
268
309
  const fadeStart = Math.max(previewY, height - scale.bodyLineHeight * 2.2);
269
310
  const bodyHeight = Math.max(scale.bodyLineHeight * 2, height - previewY - scale.padding);
270
- const mask = ctx.createLinearGradient(0, previewY, 0, previewY + bodyHeight);
271
- mask.addColorStop(0, 'rgba(226,232,240,0.92)');
272
- mask.addColorStop(Math.max(0, (fadeStart - previewY) / Math.max(1, bodyHeight)), 'rgba(226,232,240,0.92)');
273
- mask.addColorStop(1, 'rgba(226,232,240,0)');
274
- ctx.fillStyle = mask;
311
+ const fadeRatio = Math.max(0, (fadeStart - previewY) / Math.max(1, bodyHeight));
275
312
 
276
313
  wrapped.forEach((line, index) => {
277
314
  const y = previewY + index * scale.bodyLineHeight;
278
315
  if (y > height - scale.padding) return;
279
- ctx.fillText(line, leftInset, y);
316
+
317
+ const t = Math.max(0, Math.min(1, (y - previewY) / Math.max(1, bodyHeight)));
318
+ const alpha = t <= fadeRatio ? 1 : Math.max(0, 1 - ((t - fadeRatio) / Math.max(0.0001, 1 - fadeRatio)));
319
+
320
+ if (line.tone === 'added') {
321
+ ctx.fillStyle = `rgba(34,197,94,${0.12 * alpha})`;
322
+ ctx.fillRect(leftInset - 4, y, maxTextWidth + 4, scale.bodyLineHeight - 1);
323
+ ctx.fillStyle = `rgba(134,239,172,${0.98 * alpha})`;
324
+ } else if (line.tone === 'deleted') {
325
+ ctx.fillStyle = `rgba(239,68,68,${0.1 * alpha})`;
326
+ ctx.fillRect(leftInset - 4, y, maxTextWidth + 4, scale.bodyLineHeight - 1);
327
+ ctx.fillStyle = `rgba(252,165,165,${0.96 * alpha})`;
328
+ } else {
329
+ ctx.fillStyle = `rgba(226,232,240,${0.92 * alpha})`;
330
+ }
331
+
332
+ ctx.fillText(trimToWidth(ctx, line.text, maxTextWidth), leftInset, y);
280
333
  });
281
334
 
282
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmaps",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gitmaps": "cli.ts"