gitmaps 1.1.4 → 1.1.6

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.
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { estimatePreviewCharsPerLine, estimatePreviewLineCapacity, getLowZoomPreviewText, getLowZoomScale, wrapPreviewText } from './low-zoom-preview';
3
+
4
+ describe('low zoom preview helpers', () => {
5
+ test('anchors preview text to approximate saved scroll position', () => {
6
+ const file = {
7
+ path: 'src/example.ts',
8
+ ext: 'ts',
9
+ content: Array.from({ length: 12 }, (_, i) => `line-${i + 1}`).join('\n'),
10
+ };
11
+
12
+ expect(getLowZoomPreviewText(file, 0).startsWith('line-1')).toBe(true);
13
+ expect(getLowZoomPreviewText(file, 40).startsWith('line-3')).toBe(true);
14
+ });
15
+
16
+ test('skips binary or unsupported files', () => {
17
+ expect(getLowZoomPreviewText({ path: 'image.png', ext: 'png', content: 'abc' }, 0)).toBe('');
18
+ expect(getLowZoomPreviewText({ path: 'bin.dat', ext: 'dat', isBinary: true, content: 'abc' }, 0)).toBe('');
19
+ });
20
+
21
+ test('increases world-space font size as zoom goes down', () => {
22
+ const near = getLowZoomScale(0.25);
23
+ const far = getLowZoomScale(0.1);
24
+ expect(far.titleFont).toBeGreaterThan(near.titleFont);
25
+ expect(far.bodyFont).toBeGreaterThan(near.bodyFont);
26
+ });
27
+
28
+ test('wraps preview text into bounded lines with ellipsis', () => {
29
+ const lines = wrapPreviewText('alpha beta gamma delta epsilon zeta eta theta', 10, 3);
30
+ expect(lines.length).toBeLessThanOrEqual(3);
31
+ expect(lines[lines.length - 1]?.endsWith('…')).toBe(true);
32
+ });
33
+
34
+ test('preview capacity estimates stay positive', () => {
35
+ expect(estimatePreviewCharsPerLine(580, 0.25)).toBeGreaterThan(8);
36
+ expect(estimatePreviewLineCapacity(700, 0.25)).toBeGreaterThanOrEqual(2);
37
+ });
38
+ });
@@ -0,0 +1,193 @@
1
+ const PREVIEWABLE_EXTS = new Set([
2
+ 'ts', 'tsx', 'js', 'jsx', 'json', 'css', 'scss', 'html', 'md', 'py', 'rs', 'go', 'vue', 'svelte', 'toml', 'yaml', 'yml', 'sh', 'sql', 'txt'
3
+ ]);
4
+
5
+ export function getLowZoomScale(zoom: number) {
6
+ const clampedZoom = Math.max(0.08, Math.min(0.25, zoom));
7
+ const progress = (0.25 - clampedZoom) / (0.25 - 0.08);
8
+ const desiredScreenTitle = 10 + progress * 4;
9
+ const desiredScreenBody = 8 + progress * 4;
10
+ return {
11
+ titleFont: desiredScreenTitle / clampedZoom,
12
+ bodyFont: desiredScreenBody / clampedZoom,
13
+ bodyLineHeight: (desiredScreenBody * 1.45) / clampedZoom,
14
+ padding: (10 + progress * 4) / clampedZoom,
15
+ gap: (6 + progress * 3) / clampedZoom,
16
+ radius: 8 / clampedZoom,
17
+ };
18
+ }
19
+
20
+ export function getLowZoomPreviewText(file: any, scrollTop: number): string {
21
+ if (!file || file.isBinary || !file.content) return '';
22
+
23
+ const ext = (file.ext || file.path?.split('.').pop() || '').toLowerCase();
24
+ if (!PREVIEWABLE_EXTS.has(ext)) return '';
25
+
26
+ const normalized = String(file.content).replace(/\t/g, ' ');
27
+ const lines = normalized.split('\n');
28
+ const approxLineHeight = 20;
29
+ const startLine = Math.max(0, Math.floor(scrollTop / approxLineHeight));
30
+ return lines.slice(startLine, startLine + 60).join('\n').trim();
31
+ }
32
+
33
+ export function wrapPreviewText(text: string, maxCharsPerLine: number, maxLines: number): string[] {
34
+ const safeMaxChars = Math.max(8, Math.floor(maxCharsPerLine));
35
+ const safeMaxLines = Math.max(1, Math.floor(maxLines));
36
+ const sourceLines = String(text || '').split('\n');
37
+ const out: string[] = [];
38
+
39
+ for (const sourceLine of sourceLines) {
40
+ const words = sourceLine.length === 0 ? [''] : sourceLine.split(/(\s+)/).filter(Boolean);
41
+ let current = '';
42
+
43
+ for (const part of words) {
44
+ if (part.length > safeMaxChars) {
45
+ if (current.trim().length > 0) {
46
+ out.push(current.trimEnd());
47
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
48
+ current = '';
49
+ }
50
+ for (let i = 0; i < part.length; i += safeMaxChars) {
51
+ out.push(part.slice(i, i + safeMaxChars));
52
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
53
+ }
54
+ continue;
55
+ }
56
+
57
+ if ((current + part).length > safeMaxChars && current.length > 0) {
58
+ out.push(current.trimEnd());
59
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
60
+ current = part.trimStart();
61
+ } else {
62
+ current += part;
63
+ }
64
+ }
65
+
66
+ if (current.length > 0 || sourceLine.length === 0) {
67
+ out.push(current.trimEnd());
68
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
69
+ }
70
+ }
71
+
72
+ return out.slice(0, safeMaxLines);
73
+ }
74
+
75
+ function ellipsizeWrappedLines(lines: string[], maxLines: number) {
76
+ const sliced = lines.slice(0, maxLines);
77
+ if (sliced.length === 0) return sliced;
78
+ const last = sliced[sliced.length - 1].replace(/[\s.…]+$/g, '');
79
+ sliced[sliced.length - 1] = `${last}…`;
80
+ return sliced;
81
+ }
82
+
83
+ export function estimatePreviewLineCapacity(height: number, zoom: number): number {
84
+ const scale = getLowZoomScale(zoom);
85
+ const available = Math.max(scale.bodyLineHeight, height - scale.padding * 2 - scale.titleFont - scale.bodyFont - scale.gap * 3);
86
+ return Math.max(2, Math.floor(available / scale.bodyLineHeight));
87
+ }
88
+
89
+ export function estimatePreviewCharsPerLine(width: number, zoom: number): number {
90
+ const scale = getLowZoomScale(zoom);
91
+ const available = Math.max(60, width - scale.padding * 2 - Math.max(14, width * 0.02));
92
+ const avgCharWidth = Math.max(6, scale.bodyFont * 0.6);
93
+ return Math.max(8, Math.floor(available / avgCharWidth));
94
+ }
95
+
96
+ export function renderLowZoomPreviewCanvas(
97
+ canvas: HTMLCanvasElement,
98
+ params: {
99
+ path: string;
100
+ file: any;
101
+ width: number;
102
+ height: number;
103
+ zoom: number;
104
+ scrollTop: number;
105
+ accentColor: string;
106
+ isChanged: boolean;
107
+ },
108
+ ) {
109
+ const { path, file, width, height, zoom, scrollTop, accentColor } = params;
110
+ const dpr = (globalThis.devicePixelRatio || 1);
111
+ const scale = getLowZoomScale(zoom);
112
+ const ctx = canvas.getContext('2d');
113
+ if (!ctx) return;
114
+
115
+ canvas.width = Math.max(1, Math.floor(width * dpr));
116
+ canvas.height = Math.max(1, Math.floor(height * dpr));
117
+ canvas.style.width = `${width}px`;
118
+ canvas.style.height = `${height}px`;
119
+
120
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
121
+ ctx.clearRect(0, 0, width, height);
122
+
123
+ const gradient = ctx.createLinearGradient(0, 0, 0, height);
124
+ gradient.addColorStop(0, 'rgba(15,23,42,0.96)');
125
+ gradient.addColorStop(1, 'rgba(2,6,23,0.96)');
126
+ ctx.fillStyle = gradient;
127
+ roundRect(ctx, 0, 0, width, height, Math.max(6, scale.radius));
128
+ ctx.fill();
129
+
130
+ ctx.fillStyle = accentColor;
131
+ ctx.fillRect(0, 0, Math.max(10, width * 0.02), height);
132
+
133
+ const leftInset = scale.padding + Math.max(14, width * 0.02);
134
+ const topInset = scale.padding;
135
+ const maxTextWidth = Math.max(40, width - leftInset - scale.padding);
136
+
137
+ ctx.textBaseline = 'top';
138
+ ctx.font = `700 ${scale.titleFont}px "JetBrains Mono", monospace`;
139
+ ctx.fillStyle = '#f8fafc';
140
+ const title = path.split('/').pop() || path;
141
+ ctx.fillText(trimToWidth(ctx, title, maxTextWidth), leftInset, topInset);
142
+
143
+ const subtitleY = topInset + scale.titleFont + scale.gap;
144
+ ctx.font = `${Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08))}px "JetBrains Mono", monospace`;
145
+ ctx.fillStyle = 'rgba(226,232,240,0.72)';
146
+ const subtitle = path.includes('/') ? path.split('/').slice(0, -1).join('/') : 'root';
147
+ ctx.fillText(trimToWidth(ctx, subtitle, maxTextWidth), leftInset, subtitleY);
148
+
149
+ const previewY = subtitleY + Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08)) + scale.gap * 1.5;
150
+ const rawPreview = getLowZoomPreviewText(file, scrollTop) || 'Preview unavailable';
151
+ const wrapped = wrapPreviewText(
152
+ rawPreview,
153
+ estimatePreviewCharsPerLine(width, zoom),
154
+ estimatePreviewLineCapacity(height, zoom),
155
+ );
156
+
157
+ ctx.font = `${scale.bodyFont}px "JetBrains Mono", monospace`;
158
+ ctx.fillStyle = 'rgba(226,232,240,0.92)';
159
+
160
+ const fadeStart = Math.max(previewY, height - scale.bodyLineHeight * 2.2);
161
+ const bodyHeight = Math.max(scale.bodyLineHeight * 2, height - previewY - scale.padding);
162
+ const mask = ctx.createLinearGradient(0, previewY, 0, previewY + bodyHeight);
163
+ mask.addColorStop(0, 'rgba(226,232,240,0.92)');
164
+ mask.addColorStop(Math.max(0, (fadeStart - previewY) / Math.max(1, bodyHeight)), 'rgba(226,232,240,0.92)');
165
+ mask.addColorStop(1, 'rgba(226,232,240,0)');
166
+ ctx.fillStyle = mask;
167
+
168
+ wrapped.forEach((line, index) => {
169
+ const y = previewY + index * scale.bodyLineHeight;
170
+ if (y > height - scale.padding) return;
171
+ ctx.fillText(line, leftInset, y);
172
+ });
173
+ }
174
+
175
+ function trimToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
176
+ if (ctx.measureText(text).width <= maxWidth) return text;
177
+ let out = text;
178
+ while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) {
179
+ out = out.slice(0, -1);
180
+ }
181
+ return `${out}…`;
182
+ }
183
+
184
+ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
185
+ const r = Math.min(radius, width / 2, height / 2);
186
+ ctx.beginPath();
187
+ ctx.moveTo(x + r, y);
188
+ ctx.arcTo(x + width, y, x + width, y + height, r);
189
+ ctx.arcTo(x + width, y + height, x, y + height, r);
190
+ ctx.arcTo(x, y + height, x, y, r);
191
+ ctx.arcTo(x, y, x + width, y, r);
192
+ ctx.closePath();
193
+ }
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { measure } from 'measure-fn';
25
25
  import type { CanvasContext } from './context';
26
+ import { getLowZoomScale, renderLowZoomPreviewCanvas } from './low-zoom-preview';
26
27
  import { materializeViewport } from './xydraw-bridge';
27
28
 
28
29
  // ── Culling state ──────────────────────────────────────────
@@ -89,7 +90,7 @@ export function getPinnedCards(): Set<string> {
89
90
  return _pinnedCards;
90
91
  }
91
92
 
92
- // ── Status colors for pill cards
93
+ // ── Status colors for low-zoom cards
93
94
  const PILL_COLORS: Record<string, string> = {
94
95
  'ts': '#3178c6',
95
96
  'tsx': '#3178c6',
@@ -118,25 +119,30 @@ function getPillColor(path: string, isChanged: boolean): string {
118
119
  return PILL_COLORS[ext] || '#6b7280'; // Default gray
119
120
  }
120
121
 
122
+ function getSavedScrollTop(ctx: CanvasContext, path: string): number {
123
+ const saved = ctx.positions.get(`scroll:${path}`);
124
+ return saved?.x || 0;
125
+ }
126
+
121
127
  /**
122
128
  * Create a lightweight pill placeholder for a file.
123
129
  * ~3 DOM nodes vs ~100+ for a full card = massive perf win at low zoom.
124
130
  * Uses vertical text to fit file names in compact card footprint.
125
131
  */
126
- function createPillCard(path: string, x: number, y: number, w: number, h: number, isChanged: boolean, animate = false): HTMLElement {
132
+ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number, y: number, w: number, h: number, isChanged: boolean, zoom: number, animate = false): HTMLElement {
127
133
  const pill = document.createElement('div');
128
134
  pill.className = 'file-pill';
129
135
  pill.dataset.path = path;
136
+ pill.dataset.previewMode = 'canvas';
130
137
  pill.style.cssText = `
131
138
  position: absolute;
132
139
  left: ${x}px;
133
140
  top: ${y}px;
134
141
  width: ${w}px;
135
142
  height: ${h}px;
136
- background: ${getPillColor(path, isChanged)};
137
143
  border-radius: 6px;
138
- opacity: ${animate ? '0' : '0.9'};
139
- contain: layout style;
144
+ opacity: ${animate ? '0' : '0.94'};
145
+ contain: layout style paint;
140
146
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
141
147
  border: 1px solid rgba(255,255,255,0.12);
142
148
  overflow: hidden;
@@ -144,49 +150,51 @@ function createPillCard(path: string, x: number, y: number, w: number, h: number
144
150
  user-select: none;
145
151
  transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
146
152
  transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
153
+ background: transparent;
147
154
  `;
148
155
 
149
- // Animate pill entrance
156
+ const canvas = document.createElement('canvas');
157
+ canvas.className = 'file-pill-canvas';
158
+ canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none;';
159
+ pill.appendChild(canvas);
160
+
161
+ (pill as any)._fileData = file;
162
+ updatePillCardLayout(ctx, pill, zoom, isChanged);
163
+
150
164
  if (animate) {
151
165
  requestAnimationFrame(() => {
152
- pill.style.opacity = '0.9';
166
+ pill.style.opacity = '0.94';
153
167
  pill.style.transform = 'scale(1)';
154
168
  });
155
169
  }
156
170
 
157
- // File name label — show parent dir for common ambiguous filenames
158
- const parts = path.split('/');
159
- const filename = parts.pop() || path;
160
- const AMBIGUOUS = ['route.ts', 'route.tsx', 'page.tsx', 'page.ts', 'index.ts', 'index.tsx', 'index.js', 'layout.tsx', 'middleware.ts'];
161
- const name = AMBIGUOUS.includes(filename) && parts.length > 0
162
- ? `${parts[parts.length - 1]}/${filename}`
163
- : filename;
164
- const label = document.createElement('span');
165
- label.className = 'file-pill-label';
166
- label.textContent = name;
167
- label.style.cssText = `
168
- position: absolute;
169
- top: 50%;
170
- left: 50%;
171
- transform: translate(-50%, -50%) rotate(-90deg);
172
- white-space: nowrap;
173
- font-size: 48px;
174
- font-weight: 700;
175
- color: #fff;
176
- overflow: hidden;
177
- text-overflow: ellipsis;
178
- max-width: ${h - 40}px;
179
- line-height: 1;
180
- letter-spacing: 2px;
181
- font-family: 'JetBrains Mono', monospace;
182
- text-shadow: 0 2px 8px rgba(0,0,0,0.7);
183
- pointer-events: none;
184
- `;
185
- pill.appendChild(label);
186
-
187
171
  return pill;
188
172
  }
189
173
 
174
+ function updatePillCardLayout(ctx: CanvasContext, pill: HTMLElement, zoom: number, isChanged?: boolean) {
175
+ const w = parseFloat(pill.style.width) || 580;
176
+ const h = parseFloat(pill.style.height) || 700;
177
+ const scale = getLowZoomScale(zoom);
178
+ const path = pill.dataset.path || '';
179
+ const canvas = pill.querySelector('.file-pill-canvas') as HTMLCanvasElement | null;
180
+ const file = (pill as any)._fileData || ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
181
+ const changed = isChanged ?? pill.dataset.changed === 'true';
182
+ pill.dataset.zoomBucket = zoom.toFixed(3);
183
+ pill.style.borderRadius = `${Math.max(6, scale.radius)}px`;
184
+ if (!canvas) return;
185
+
186
+ renderLowZoomPreviewCanvas(canvas, {
187
+ path,
188
+ file,
189
+ width: w,
190
+ height: h,
191
+ zoom,
192
+ scrollTop: getSavedScrollTop(ctx, path),
193
+ accentColor: getPillColor(path, changed),
194
+ isChanged: changed,
195
+ });
196
+ }
197
+
190
198
  /**
191
199
  * Computes the visible world-coordinate rectangle from the current
192
200
  * viewport size, zoom, and offset.
@@ -399,9 +407,12 @@ export function performViewportCulling(ctx: CanvasContext) {
399
407
  );
400
408
 
401
409
  if (inView && !pillCards.has(path)) {
402
- const pill = createPillCard(path, x, y, cardW, cardH, !!isChanged, true);
410
+ const pill = createPillCard(ctx, file, path, x, y, cardW, cardH, !!isChanged, zoom, true);
411
+ if (isChanged) pill.dataset.changed = 'true';
403
412
  ctx.canvas.appendChild(pill);
404
413
  pillCards.set(path, pill);
414
+ } else if (inView && pillCards.has(path)) {
415
+ updatePillCardLayout(ctx, pillCards.get(path)!, zoom, !!isChanged);
405
416
  } else if (!inView && pillCards.has(path)) {
406
417
  removePillForPath(path);
407
418
  }
@@ -424,10 +435,14 @@ export function performViewportCulling(ctx: CanvasContext) {
424
435
  );
425
436
 
426
437
  if (inView) {
427
- const pill = createPillCard(path, x, y, w, h, isChanged, true);
438
+ const file = ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
439
+ const pill = createPillCard(ctx, file, path, x, y, w, h, isChanged, zoom, true);
440
+ if (isChanged) pill.dataset.changed = 'true';
428
441
  ctx.canvas.appendChild(pill);
429
442
  pillCards.set(path, pill);
430
443
  }
444
+ } else {
445
+ updatePillCardLayout(ctx, pillCards.get(path)!, zoom, card.dataset.changed === 'true');
431
446
  }
432
447
  }
433
448
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmaps",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gitmaps": "cli.ts"
@@ -16,13 +16,14 @@
16
16
  "scripts": {
17
17
  "dev": "bun run server.ts",
18
18
  "start": "bun run server.ts",
19
- "test": "bun test app/api/repo/load/route.test.ts app/lib/route-catchall.test.ts app/lib/status-bar.test.ts app/lib/xydraw.test.ts app/lib/transclusion-smoke.test.ts app/lib/repo-select.test.ts packages/galaxydraw/perf.test.ts",
19
+ "test": "bun test app/api/repo/load/route.test.ts app/lib/route-catchall.test.ts app/lib/status-bar.test.ts app/lib/xydraw.test.ts app/lib/transclusion-smoke.test.ts app/lib/repo-select.test.ts app/lib/low-zoom-preview.test.ts packages/galaxydraw/perf.test.ts",
20
20
  "smoke:browser": "bun scripts/browser-smoke-local.ts",
21
21
  "smoke:browser-tools": "bash scripts/browser-smoke-local.sh",
22
22
  "smoke:browser-tools:load": "bash scripts/browser-repo-load-smoke.sh",
23
23
  "smoke:browser-tools:guard": "bash scripts/browser-smoke-guard.sh",
24
24
  "smoke:browser-tools:self-check": "bash scripts/browser-smoke-self-check.sh",
25
25
  "smoke:browser-tools:check": "bash scripts/browser-smoke-check.sh",
26
+ "smoke:docker-image": "bash scripts/docker-image-smoke.sh",
26
27
  "prepublishOnly": "echo 'Publishing gitmaps to npm'"
27
28
  },
28
29
  "keywords": [