gitmaps 1.1.5 → 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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { getLowZoomPreviewText, getLowZoomScale } from './low-zoom-preview';
2
+ import { estimatePreviewCharsPerLine, estimatePreviewLineCapacity, getLowZoomPreviewText, getLowZoomScale, 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', () => {
@@ -24,4 +24,15 @@ describe('low zoom preview helpers', () => {
24
24
  expect(far.titleFont).toBeGreaterThan(near.titleFont);
25
25
  expect(far.bodyFont).toBeGreaterThan(near.bodyFont);
26
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
+ });
27
38
  });
@@ -29,3 +29,165 @@ export function getLowZoomPreviewText(file: any, scrollTop: number): string {
29
29
  const startLine = Math.max(0, Math.floor(scrollTop / approxLineHeight));
30
30
  return lines.slice(startLine, startLine + 60).join('\n').trim();
31
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,7 +23,7 @@
23
23
  */
24
24
  import { measure } from 'measure-fn';
25
25
  import type { CanvasContext } from './context';
26
- import { getLowZoomPreviewText, getLowZoomScale } from './low-zoom-preview';
26
+ import { getLowZoomScale, renderLowZoomPreviewCanvas } from './low-zoom-preview';
27
27
  import { materializeViewport } from './xydraw-bridge';
28
28
 
29
29
  // ── Culling state ──────────────────────────────────────────
@@ -133,14 +133,13 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
133
133
  const pill = document.createElement('div');
134
134
  pill.className = 'file-pill';
135
135
  pill.dataset.path = path;
136
- pill.dataset.previewMode = 'content';
136
+ pill.dataset.previewMode = 'canvas';
137
137
  pill.style.cssText = `
138
138
  position: absolute;
139
139
  left: ${x}px;
140
140
  top: ${y}px;
141
141
  width: ${w}px;
142
142
  height: ${h}px;
143
- background: linear-gradient(180deg, rgba(15,23,42,0.96) 0%, rgba(2,6,23,0.96) 100%);
144
143
  border-radius: 6px;
145
144
  opacity: ${animate ? '0' : '0.94'};
146
145
  contain: layout style paint;
@@ -151,45 +150,17 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
151
150
  user-select: none;
152
151
  transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
153
152
  transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
153
+ background: transparent;
154
154
  `;
155
155
 
156
- const accent = document.createElement('div');
157
- accent.className = 'file-pill-accent';
158
- accent.style.cssText = `
159
- position:absolute;
160
- top:0;
161
- left:0;
162
- width:${Math.max(10, w * 0.02)}px;
163
- height:100%;
164
- background:${getPillColor(path, isChanged)};
165
- opacity:0.95;
166
- pointer-events:none;
167
- `;
168
- pill.appendChild(accent);
169
-
170
- const inner = document.createElement('div');
171
- inner.className = 'file-pill-inner';
172
- inner.style.cssText = 'position:absolute; inset:0; transform-origin:top left; pointer-events:none;';
173
- pill.appendChild(inner);
174
-
175
- const title = document.createElement('div');
176
- title.className = 'file-pill-title';
177
- title.textContent = path.split('/').pop() || path;
178
- inner.appendChild(title);
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);
179
160
 
180
- const sub = document.createElement('div');
181
- sub.className = 'file-pill-subtitle';
182
- sub.textContent = path.includes('/') ? path.split('/').slice(0, -1).join('/') : 'root';
183
- inner.appendChild(sub);
161
+ (pill as any)._fileData = file;
162
+ updatePillCardLayout(ctx, pill, zoom, isChanged);
184
163
 
185
- const preview = document.createElement('div');
186
- preview.className = 'file-pill-preview';
187
- preview.textContent = getLowZoomPreviewText(file, getSavedScrollTop(ctx, path)) || 'Preview unavailable';
188
- inner.appendChild(preview);
189
-
190
- updatePillCardLayout(pill, zoom);
191
-
192
- // Animate pill entrance
193
164
  if (animate) {
194
165
  requestAnimationFrame(() => {
195
166
  pill.style.opacity = '0.94';
@@ -200,62 +171,28 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
200
171
  return pill;
201
172
  }
202
173
 
203
- function updatePillCardLayout(pill: HTMLElement, zoom: number) {
204
- const x = parseFloat(pill.style.left) || 0;
205
- const y = parseFloat(pill.style.top) || 0;
174
+ function updatePillCardLayout(ctx: CanvasContext, pill: HTMLElement, zoom: number, isChanged?: boolean) {
206
175
  const w = parseFloat(pill.style.width) || 580;
207
176
  const h = parseFloat(pill.style.height) || 700;
208
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';
209
182
  pill.dataset.zoomBucket = zoom.toFixed(3);
210
183
  pill.style.borderRadius = `${Math.max(6, scale.radius)}px`;
211
-
212
- const inner = pill.querySelector('.file-pill-inner') as HTMLElement | null;
213
- const title = pill.querySelector('.file-pill-title') as HTMLElement | null;
214
- const sub = pill.querySelector('.file-pill-subtitle') as HTMLElement | null;
215
- const preview = pill.querySelector('.file-pill-preview') as HTMLElement | null;
216
- if (!inner || !title || !sub || !preview) return;
217
-
218
- inner.style.padding = `${scale.padding}px ${scale.padding}px ${scale.padding}px ${scale.padding + Math.max(14, w * 0.02)}px`;
219
- inner.style.display = 'flex';
220
- inner.style.flexDirection = 'column';
221
- inner.style.gap = `${scale.gap}px`;
222
-
223
- title.style.cssText = `
224
- color:#f8fafc;
225
- font-family:'JetBrains Mono', monospace;
226
- font-size:${scale.titleFont}px;
227
- font-weight:700;
228
- line-height:1.05;
229
- white-space:nowrap;
230
- overflow:hidden;
231
- text-overflow:ellipsis;
232
- text-shadow:0 2px 8px rgba(0,0,0,0.45);
233
- `;
234
-
235
- sub.style.cssText = `
236
- color:rgba(226,232,240,0.72);
237
- font-family:'JetBrains Mono', monospace;
238
- font-size:${Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08))}px;
239
- line-height:1.05;
240
- white-space:nowrap;
241
- overflow:hidden;
242
- text-overflow:ellipsis;
243
- `;
244
-
245
- preview.style.cssText = `
246
- color:rgba(226,232,240,0.92);
247
- font-family:'JetBrains Mono', monospace;
248
- font-size:${scale.bodyFont}px;
249
- line-height:${scale.bodyLineHeight}px;
250
- white-space:pre-wrap;
251
- overflow-wrap:anywhere;
252
- word-break:break-word;
253
- overflow:hidden;
254
- flex:1 1 auto;
255
- text-shadow:0 1px 4px rgba(0,0,0,0.3);
256
- mask-image:linear-gradient(to bottom, black 0%, black 84%, transparent 100%);
257
- -webkit-mask-image:linear-gradient(to bottom, black 0%, black 84%, transparent 100%);
258
- `;
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
+ });
259
196
  }
260
197
 
261
198
  /**
@@ -471,10 +408,11 @@ export function performViewportCulling(ctx: CanvasContext) {
471
408
 
472
409
  if (inView && !pillCards.has(path)) {
473
410
  const pill = createPillCard(ctx, file, path, x, y, cardW, cardH, !!isChanged, zoom, true);
411
+ if (isChanged) pill.dataset.changed = 'true';
474
412
  ctx.canvas.appendChild(pill);
475
413
  pillCards.set(path, pill);
476
414
  } else if (inView && pillCards.has(path)) {
477
- updatePillCardLayout(pillCards.get(path)!, zoom);
415
+ updatePillCardLayout(ctx, pillCards.get(path)!, zoom, !!isChanged);
478
416
  } else if (!inView && pillCards.has(path)) {
479
417
  removePillForPath(path);
480
418
  }
@@ -499,11 +437,12 @@ export function performViewportCulling(ctx: CanvasContext) {
499
437
  if (inView) {
500
438
  const file = ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
501
439
  const pill = createPillCard(ctx, file, path, x, y, w, h, isChanged, zoom, true);
440
+ if (isChanged) pill.dataset.changed = 'true';
502
441
  ctx.canvas.appendChild(pill);
503
442
  pillCards.set(path, pill);
504
443
  }
505
444
  } else {
506
- updatePillCardLayout(pillCards.get(path)!, zoom);
445
+ updatePillCardLayout(ctx, pillCards.get(path)!, zoom, card.dataset.changed === 'true');
507
446
  }
508
447
  }
509
448
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmaps",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gitmaps": "cli.ts"