reslop 0.0.1 → 0.1.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/lib/render.js ADDED
@@ -0,0 +1,964 @@
1
+ 'use strict';
2
+
3
+ const ansi = require('./ansi.js');
4
+ const { displayLines } = require('./diff.js');
5
+ const { tokenize, overlayTokens } = require('./highlight.js');
6
+ const { detectLang } = require('./detect.js');
7
+ const { ACTIONS, actionHot, actionLetter, buttonWord } = require('./keys.js');
8
+ const { overlayRows } = require('./select.js');
9
+ const { itemPath } = require('./files.js');
10
+ const { isDoneMark } = require('./review.js');
11
+ const wrap = require('./wrap.js');
12
+ const { wrapPlain, wrapDoc, wrapMultiline, cursorInWrap, wrapMove } = wrap;
13
+
14
+ const { THEME, CODE_FG, codeFg, paint, visibleWidth, truncateVisible, fg } =
15
+ ansi;
16
+ const { RESET, ESC, EL, seq } = ansi;
17
+
18
+ const STYLES = {
19
+ del: {
20
+ baseFg: THEME.delLineFg,
21
+ baseBg: THEME.delLineBg,
22
+ chFg: THEME.delCharFg,
23
+ chBg: THEME.delCharBg,
24
+ mark: '-',
25
+ },
26
+ add: {
27
+ baseFg: THEME.addLineFg,
28
+ baseBg: THEME.addLineBg,
29
+ chFg: THEME.addCharFg,
30
+ chBg: THEME.addCharBg,
31
+ mark: '+',
32
+ },
33
+ ctx: {
34
+ baseFg: THEME.ctxFg,
35
+ baseBg: THEME.ctxBg,
36
+ chFg: THEME.ctxFg,
37
+ chBg: THEME.ctxBg,
38
+ mark: ' ',
39
+ },
40
+ };
41
+
42
+ const STAGED_STYLES = {
43
+ del: {
44
+ baseFg: THEME.stagedDelLineFg,
45
+ baseBg: THEME.stagedDelLineBg,
46
+ chFg: THEME.stagedDelCharFg,
47
+ chBg: THEME.stagedDelCharBg,
48
+ mark: '-',
49
+ },
50
+ add: {
51
+ baseFg: THEME.stagedAddLineFg,
52
+ baseBg: THEME.stagedAddLineBg,
53
+ chFg: THEME.stagedAddCharFg,
54
+ chBg: THEME.stagedAddCharBg,
55
+ mark: '+',
56
+ },
57
+ ctx: STYLES.ctx,
58
+ };
59
+
60
+ const HEADER_ROLE_FG = Object.assign(Object.create(null), {
61
+ repo: THEME.headerRepoFg,
62
+ dir: THEME.headerDirFg,
63
+ slash: THEME.headerSlashFg,
64
+ file: THEME.headerFileFg,
65
+ meta: THEME.headerMetaFg,
66
+ });
67
+
68
+ const INFO_STATUS = [
69
+ 'copied',
70
+ 'skipped',
71
+ 'staged',
72
+ 'unstaged',
73
+ 'not staged',
74
+ 'reverted',
75
+ 'already staged',
76
+ 'read only',
77
+ 'last block',
78
+ 'first block',
79
+ 'all skipped',
80
+ 'first file',
81
+ 'last file',
82
+ 'nothing to review',
83
+ 'unified',
84
+ 'mixed',
85
+ 'side-by-side',
86
+ 'saved',
87
+ 'not a diff block',
88
+ ];
89
+
90
+ const QUIT_FINISH = 'f';
91
+ const QUIT_CONTINUE = 'c';
92
+ const QUIT_FINISH_LABEL = 'finish as ready';
93
+ const QUIT_CONTINUE_LABEL = 'continue next time';
94
+ const QUIT_PROMPT = ` ${QUIT_FINISH_LABEL} ${QUIT_CONTINUE_LABEL}`;
95
+
96
+ const GUTTER_W = 2;
97
+ const BUTTON_GAP = ' ';
98
+ const LOGO = '👁️';
99
+ const FILE_CURSOR = '▷';
100
+ const FILE_CURRENT = '▶';
101
+ const TODO_CAPTION = 'TODO:';
102
+ const INPUT_MAX = 4;
103
+ const NOTE_MAX = 4;
104
+ const NOTE_PAD = 1;
105
+
106
+ const lineStyles = (origin) => (origin === 'staged' ? STAGED_STYLES : STYLES);
107
+
108
+ const fill = (width, fgRgb, bgRgb, color) =>
109
+ paint(' '.repeat(Math.max(0, width)), fgRgb, bgRgb, color);
110
+
111
+ const paintSpans = (pieces, lineStyle, budget, color) => {
112
+ let out = '';
113
+ let used = 0;
114
+ for (const piece of pieces) {
115
+ if (used >= budget) break;
116
+ const room = budget - used;
117
+ const text = truncateVisible(piece.text, room);
118
+ const w = visibleWidth(text);
119
+ if (w <= 0) continue;
120
+ const fgRgb = codeFg(piece.style);
121
+ const bgRgb = piece.changed ? lineStyle.chBg : lineStyle.baseBg;
122
+ out += paint(text, fgRgb, bgRgb, color);
123
+ used += w;
124
+ }
125
+ if (used < budget) {
126
+ out += fill(budget - used, CODE_FG.plain, lineStyle.baseBg, color);
127
+ }
128
+ return { text: out, used };
129
+ };
130
+
131
+ const paintDiffLine = (line, width, color, origin, lang) => {
132
+ const styles = lineStyles(origin);
133
+ const style = styles[line.type] ?? styles.ctx;
134
+ const diffSpans = line.spans ?? [{ text: line.text, changed: false }];
135
+ const tokens = tokenize(lang, line.text ?? '');
136
+ const pieces = overlayTokens(tokens, diffSpans);
137
+ const gutterW = Math.min(GUTTER_W, Math.max(0, width));
138
+ const label = `${style.mark} `.slice(0, gutterW);
139
+ const gutter = paint(label, CODE_FG.punct, style.baseBg, color);
140
+ const budget = Math.max(0, width - gutterW);
141
+ const rest = paintSpans(pieces, style, budget, color);
142
+ return gutter + rest.text;
143
+ };
144
+
145
+ const splitWidths = (width) => {
146
+ const inner = Math.max(0, width - 1);
147
+ const leftW = Math.floor(inner / 2);
148
+ return { leftW, rightW: inner - leftW };
149
+ };
150
+
151
+ const paintSplitRule = (color) =>
152
+ fill(1, THEME.splitGutterFg, THEME.splitGutterBg, color);
153
+
154
+ const paintSplitHalf = (line, width, color, origin, lang) => {
155
+ if (!line || width <= 0) {
156
+ return fill(width, THEME.ctxFg, THEME.ctxBg, color);
157
+ }
158
+ return paintDiffLine(line, width, color, origin, lang);
159
+ };
160
+
161
+ const paintSplitRow = (row, width, color, origin, lang) => {
162
+ const { leftW, rightW } = splitWidths(width);
163
+ const left = paintSplitHalf(row.left, leftW, color, origin, lang);
164
+ const right = paintSplitHalf(row.right, rightW, color, origin, lang);
165
+ return `${left}${paintSplitRule(color)}${right}`;
166
+ };
167
+
168
+ const paintSplitBlank = (width, color) => {
169
+ const { leftW, rightW } = splitWidths(width);
170
+ return (
171
+ fill(leftW, THEME.ctxFg, THEME.ctxBg, color) +
172
+ paintSplitRule(color) +
173
+ fill(rightW, THEME.ctxFg, THEME.ctxBg, color)
174
+ );
175
+ };
176
+
177
+ const FILL_KIND = {
178
+ files: (width, color) => {
179
+ const inner = Math.max(1, width - 1);
180
+ const main = fill(inner, THEME.ctxFg, THEME.ctxBg, color);
181
+ const edge = fill(1, THEME.ctxFg, THEME.ctxBg, color);
182
+ return main + edge;
183
+ },
184
+ split: (width, color) => paintSplitBlank(width, color),
185
+ };
186
+
187
+ const paintBodyFill = (width, color, kind) => {
188
+ const paint = FILL_KIND[kind];
189
+ if (paint) return paint(width, color);
190
+ return fill(width, THEME.ctxFg, THEME.ctxBg, color);
191
+ };
192
+
193
+ const paintBar = (text, width, fgRgb, bgRgb, color) => {
194
+ const clipped = truncateVisible(text, width);
195
+ const pad = Math.max(0, width - visibleWidth(clipped));
196
+ const filled = clipped + ' '.repeat(pad);
197
+ if (!color) return filled;
198
+ return `${seq(fgRgb, bgRgb)}${EL}${filled}${RESET}`;
199
+ };
200
+
201
+ const CHECK_TOKEN = /\[([ xX])\]/;
202
+ const CHECK_LEAD = /^\[[ xX]\] /;
203
+
204
+ const splitCheck = (text) => {
205
+ const match = CHECK_TOKEN.exec(text);
206
+ if (!match) return null;
207
+ const start = match.index;
208
+ const token = match[0];
209
+ return {
210
+ before: text.slice(0, start),
211
+ mark: match[1],
212
+ token,
213
+ after: text.slice(start + token.length),
214
+ };
215
+ };
216
+
217
+ const paintCheckBar = (text, width, fgRgb, bgRgb, color) => {
218
+ const clipped = truncateVisible(text, width);
219
+ const pad = Math.max(0, width - visibleWidth(clipped));
220
+ const filled = clipped + ' '.repeat(pad);
221
+ if (!color) return filled;
222
+ const parts = splitCheck(clipped);
223
+ if (!parts) return `${seq(fgRgb, bgRgb)}${EL}${filled}${RESET}`;
224
+ const done = isDoneMark(parts.mark);
225
+ const chipFg = done ? THEME.checkDoneFg : THEME.checkFg;
226
+ const chipBg = done ? THEME.checkDoneBg : THEME.checkBg;
227
+ const after = `${parts.after}${' '.repeat(pad)}`;
228
+ let out = `${seq(fgRgb, bgRgb)}${EL}`;
229
+ if (parts.before) out += paint(parts.before, fgRgb, bgRgb, color);
230
+ out += paint(parts.token, chipFg, chipBg, color);
231
+ if (after) out += paint(after, fgRgb, bgRgb, color);
232
+ return out;
233
+ };
234
+
235
+ const originLabel = (origin, revShort) => {
236
+ if (origin === 'commit') return revShort || 'commit';
237
+ if (origin === 'todo') return 'todo';
238
+ return origin ?? 'unknown';
239
+ };
240
+
241
+ const headerRoleFg = (role) => HEADER_ROLE_FG[role] ?? THEME.headerChromeFg;
242
+
243
+ const pathSegments = (rel) => {
244
+ const segs = [];
245
+ if (!rel) return segs;
246
+ const bits = rel.split('/');
247
+ for (let i = 0; i < bits.length; i++) {
248
+ if (i) {
249
+ segs.push({ text: '/', role: 'slash' });
250
+ }
251
+ if (!bits[i]) {
252
+ continue;
253
+ }
254
+ const last = i === bits.length - 1;
255
+ segs.push({ text: bits[i], role: last ? 'file' : 'dir' });
256
+ }
257
+ return segs;
258
+ };
259
+
260
+ const headerTarget = (view) => {
261
+ const pane = view.pane ?? 'diff';
262
+ if (pane === 'files') {
263
+ const files = view.files ?? [];
264
+ const cursor = view.fileCursor ?? 0;
265
+ const entry = files[cursor];
266
+ const total = files.length;
267
+ const n = total ? cursor + 1 : 0;
268
+ if (!entry) return { path: '', origin: '', n, total };
269
+ return { path: entry.path, origin: entry.status, n, total };
270
+ }
271
+ if (!view.item) return { path: '', origin: '', n: 0, total: view.total ?? 0 };
272
+ return {
273
+ path: itemPath(view.item),
274
+ origin: originLabel(view.item.origin, view.revShort),
275
+ n: view.index + 1,
276
+ total: view.total,
277
+ };
278
+ };
279
+
280
+ const headerPrefix = (view) => {
281
+ const repo = view.repoName || '';
282
+ const { path } = headerTarget(view);
283
+ if (repo && path) return ` ${LOGO} reslop: ${repo}/`;
284
+ if (repo) return ` ${LOGO} reslop: ${repo} `;
285
+ return ` ${LOGO} reslop: `;
286
+ };
287
+
288
+ const headerText = (view) => {
289
+ const prefix = headerPrefix(view);
290
+ const { path, origin, n, total } = headerTarget(view);
291
+ const suffix = origin ? ` ${origin} ${n}/${total}` : ` ${n}/${total}`;
292
+ return `${prefix}${path}${suffix}`;
293
+ };
294
+
295
+ const headerPieces = (view, width) => {
296
+ const repo = view.repoName || '';
297
+ const { path, origin, n, total } = headerTarget(view);
298
+ const suffix = origin ? ` ${origin} ${n}/${total}` : ` ${n}/${total}`;
299
+ const right = ' ';
300
+ const chrome = ` ${LOGO} reslop: `;
301
+ const prefixW = visibleWidth(headerPrefix(view));
302
+ const used = prefixW + visibleWidth(suffix) + visibleWidth(right);
303
+ const room = Math.max(0, width - used);
304
+ const clipped = truncateVisible(path, room);
305
+ const pad = Math.max(0, room - visibleWidth(clipped));
306
+ const pieces = [{ text: chrome, role: 'chrome' }];
307
+ if (repo) {
308
+ pieces.push({ text: repo, role: 'repo' });
309
+ if (path) {
310
+ pieces.push({ text: '/', role: 'slash' });
311
+ } else {
312
+ pieces.push({ text: ' ', role: 'chrome' });
313
+ }
314
+ }
315
+ for (const seg of pathSegments(clipped)) {
316
+ pieces.push(seg);
317
+ }
318
+ if (pad) {
319
+ pieces.push({ text: ' '.repeat(pad), role: 'chrome' });
320
+ }
321
+ pieces.push({ text: suffix, role: 'meta' });
322
+ pieces.push({ text: right, role: 'chrome' });
323
+ return pieces;
324
+ };
325
+
326
+ const paintHeader = (view, width, color) => {
327
+ const pieces = headerPieces(view, width);
328
+ if (!color) {
329
+ return pieces.map((p) => p.text).join('');
330
+ }
331
+ const bgRgb = THEME.headerBg;
332
+ let out = `${seq(THEME.headerChromeFg, bgRgb)}${EL}`;
333
+ for (const piece of pieces) {
334
+ if (!piece.text) {
335
+ continue;
336
+ }
337
+ out += `${fg(headerRoleFg(piece.role))}${piece.text}`;
338
+ }
339
+ return `${out}${RESET}`;
340
+ };
341
+
342
+ const bodyLines = (item, layout) => {
343
+ if (item.file.isBinary) {
344
+ const text = '(binary)';
345
+ return [{ type: 'ctx', text, spans: [{ text, changed: false }] }];
346
+ }
347
+ if (!item.hunk) return [];
348
+ return displayLines(item.hunk, item.blockId, layout);
349
+ };
350
+
351
+ const sourceEmpty = (line) => !line || !(line.text ?? '').trim();
352
+
353
+ const displayRowEmpty = (row, split) => {
354
+ if (!split) return sourceEmpty(row);
355
+ return sourceEmpty(row.left) && sourceEmpty(row.right);
356
+ };
357
+
358
+ const dropLeadingEmpty = (lines, split) => {
359
+ let i = 0;
360
+ while (i < lines.length && displayRowEmpty(lines[i], split)) {
361
+ i += 1;
362
+ }
363
+ return i ? lines.slice(i) : lines;
364
+ };
365
+
366
+ const helpLines = () => {
367
+ const lines = [' keys and buttons', ''];
368
+ for (const action of ACTIONS) {
369
+ const keys = actionHot(action);
370
+ let label = action.label;
371
+ if (action.id === 'layout') {
372
+ label = 'Mode (unified / mixed / side)';
373
+ }
374
+ lines.push(` ${keys.padEnd(16)} ${label}`);
375
+ }
376
+ lines.push(' enter open file / save note');
377
+ lines.push(' esc files, then quit');
378
+ lines.push(' del, backspace remove todo');
379
+ lines.push(' j, k, wheel scroll / move');
380
+ lines.push(' drag copy');
381
+ lines.push(' ctrl-z, ctrl-y undo / redo');
382
+ lines.push(' ?, h help');
383
+ return lines;
384
+ };
385
+
386
+ const BUTTON_MARK = {
387
+ bgRgb: THEME.buttonBg,
388
+ fgRgb: THEME.buttonFg,
389
+ hotFg: THEME.buttonHotFg,
390
+ missFg: THEME.buttonHotFg,
391
+ missBold: true,
392
+ };
393
+
394
+ const QUIT_MARK = {
395
+ bgRgb: THEME.chromeBg,
396
+ fgRgb: THEME.mutedFg,
397
+ hotFg: THEME.warnFg,
398
+ missFg: THEME.mutedFg,
399
+ missBold: false,
400
+ };
401
+
402
+ const paintMarkedWord = (label, letter, color, mark) => {
403
+ const at = label.indexOf(letter);
404
+ if (at < 0) {
405
+ return paint(label, mark.missFg, mark.bgRgb, color, mark.missBold);
406
+ }
407
+ const before = label.slice(0, at);
408
+ const hot = label.slice(at, at + letter.length);
409
+ const after = label.slice(at + letter.length);
410
+ let out = '';
411
+ if (before) out += paint(before, mark.fgRgb, mark.bgRgb, color);
412
+ out += paint(hot, mark.hotFg, mark.bgRgb, color, true);
413
+ if (after) out += paint(after, mark.fgRgb, mark.bgRgb, color);
414
+ return out;
415
+ };
416
+
417
+ const layoutButtons = (width, compact = false) => {
418
+ const build = (short) => {
419
+ const hits = [];
420
+ const parts = [];
421
+ let x = 0;
422
+ for (const action of ACTIONS) {
423
+ const letter = actionLetter(action);
424
+ const word = buttonWord(action);
425
+ const label = short ? letter : word;
426
+ const piece = `${BUTTON_GAP}${label}`;
427
+ const w = visibleWidth(piece);
428
+ if (x + w > width && parts.length) break;
429
+ hits.push({ id: action.id, x0: x, x1: x + w });
430
+ parts.push({ action, piece, letter, label });
431
+ x += w;
432
+ }
433
+ return { hits, parts, width: x };
434
+ };
435
+ if (compact) return build(true);
436
+ const words = build(false);
437
+ if (words.parts.length === ACTIONS.length) return words;
438
+ return build(true);
439
+ };
440
+
441
+ const paintButtons = (layout, width, color) => {
442
+ let out = '';
443
+ let used = 0;
444
+ for (const part of layout.parts) {
445
+ out += paint(BUTTON_GAP, THEME.buttonFg, THEME.buttonBg, color);
446
+ out += paintMarkedWord(part.label, part.letter, color, BUTTON_MARK);
447
+ used += visibleWidth(part.piece);
448
+ }
449
+ if (used < width) {
450
+ out += fill(width - used, THEME.buttonFg, THEME.buttonBg, color);
451
+ }
452
+ return out;
453
+ };
454
+
455
+ const countsLine = (counts, revShort) => {
456
+ const skipped = counts.skipped ?? 0;
457
+ const feedback = counts.feedback ?? 0;
458
+ const todo = counts.todo ?? 0;
459
+ const notes = `feedback ${feedback} todo ${todo}`;
460
+ const extra = skipped ? ` skipped ${skipped}` : '';
461
+ if (revShort) {
462
+ const n = counts.commit ?? 0;
463
+ return ` commit ${revShort} ${n} ${notes}${extra}`;
464
+ }
465
+ const staged = counts.staged ?? 0;
466
+ const unstaged = counts.unstaged ?? 0;
467
+ const untracked = counts.untracked ?? 0;
468
+ const git = `staged ${staged} unstaged ${unstaged} untracked ${untracked}`;
469
+ return ` ${git} ${notes}${extra}`;
470
+ };
471
+
472
+ const paintQuitPrompt = (width, color) => {
473
+ const bgRgb = THEME.chromeBg;
474
+ let body = QUIT_PROMPT;
475
+ if (color) {
476
+ const finish = ` ${QUIT_FINISH_LABEL}`;
477
+ const rest = ` ${QUIT_CONTINUE_LABEL}`;
478
+ const paintQuit = (label, letter) =>
479
+ paintMarkedWord(label, letter, color, QUIT_MARK);
480
+ body = paintQuit(finish, QUIT_FINISH) + paintQuit(rest, QUIT_CONTINUE);
481
+ }
482
+ const pad = Math.max(0, width - visibleWidth(body));
483
+ return body + fill(pad, THEME.mutedFg, bgRgb, color);
484
+ };
485
+
486
+ const paintStatusLine = (view, status, width, color) => {
487
+ if (view.mode === 'confirmQuit') return paintQuitPrompt(width, color);
488
+ const left = countsLine(view.counts ?? {}, view.revShort);
489
+ let right = '';
490
+ let msgFg = THEME.chromeFg;
491
+ if (status) {
492
+ right = ` ${status} `;
493
+ if (!INFO_STATUS.includes(status)) msgFg = THEME.errorFg;
494
+ }
495
+ const rightW = visibleWidth(right);
496
+ const room = Math.max(0, width - rightW);
497
+ const clipped = truncateVisible(left, room);
498
+ const pad = Math.max(0, width - visibleWidth(clipped) - rightW);
499
+ const gap = ' '.repeat(pad);
500
+ const paintedRight = paint(right, msgFg, THEME.chromeBg, color);
501
+ return (
502
+ paint(clipped, THEME.mutedFg, THEME.chromeBg, color) +
503
+ paint(gap, THEME.mutedFg, THEME.chromeBg, color) +
504
+ paintedRight
505
+ );
506
+ };
507
+
508
+ const fileColumns = (files) => {
509
+ let statusW = 0;
510
+ let countW = 0;
511
+ for (const entry of files) {
512
+ const status = entry.status ?? '';
513
+ const count = `${entry.remaining ?? 0}`;
514
+ const sw = visibleWidth(status);
515
+ const cw = visibleWidth(count);
516
+ if (sw > statusW) statusW = sw;
517
+ if (cw > countW) countW = cw;
518
+ }
519
+ return { statusW, countW };
520
+ };
521
+
522
+ const padStartVisible = (text, width) => {
523
+ const n = Math.max(0, width - visibleWidth(text));
524
+ return `${' '.repeat(n)}${text}`;
525
+ };
526
+
527
+ const paintFileRow = (entry, width, color, selected, here, cols) => {
528
+ const inner = Math.max(1, width - 1);
529
+ let mark = ' ';
530
+ if (here) mark = FILE_CURRENT;
531
+ else if (selected) mark = FILE_CURSOR;
532
+ const prefix = ` ${mark} `;
533
+ const status = entry.status ?? '';
534
+ const count = `${entry.remaining ?? 0}`;
535
+ const statusW = cols?.statusW ?? visibleWidth(status);
536
+ const countW = cols?.countW ?? visibleWidth(count);
537
+ const statusCol = padStartVisible(status, statusW);
538
+ const countCol = padStartVisible(count, countW);
539
+ const suffix = ` ${statusCol} ${countCol}`;
540
+ const pathW = Math.max(1, inner - visibleWidth(prefix + suffix));
541
+ const clipped = truncateVisible(entry.path, pathW);
542
+ const pad = Math.max(0, pathW - visibleWidth(clipped));
543
+ const line = `${prefix}${clipped}${' '.repeat(pad)}${suffix}`;
544
+ const fgRgb = selected ? THEME.chromeFg : THEME.mutedFg;
545
+ const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
546
+ const row = paintBar(line, inner, fgRgb, bgRgb, color);
547
+ return row + fill(1, fgRgb, bgRgb, color);
548
+ };
549
+
550
+ const todoInnerWidth = (width) => Math.max(1, width - 2);
551
+
552
+ const checkLeadLen = (text) => {
553
+ const match = CHECK_LEAD.exec(text);
554
+ return match ? match[0].length : 0;
555
+ };
556
+
557
+ const paintTodoList = (texts, focus, width, color, edit) => {
558
+ const inner = todoInnerWidth(width);
559
+ const rows = [];
560
+ const owners = [];
561
+ let cursor = null;
562
+ for (let i = 0; i < texts.length; i++) {
563
+ const selected = i === focus;
564
+ const fgRgb = selected ? THEME.chromeFg : THEME.mutedFg;
565
+ const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
566
+ const text = texts[i];
567
+ const wrapped = wrapMultiline(text, inner);
568
+ const rowStart = rows.length;
569
+ for (const line of wrapped) {
570
+ rows.push(paintCheckBar(` ${line}`, width, fgRgb, bgRgb, color));
571
+ owners.push(i);
572
+ }
573
+ if (!edit || !selected) continue;
574
+ const lead = checkLeadLen(text);
575
+ const pos = cursorInWrap(text, lead + (edit.cursor ?? 0), inner);
576
+ cursor = { x: pos.col + 2, row: rowStart + pos.row };
577
+ }
578
+ return { rows, owners, cursor };
579
+ };
580
+
581
+ const windowRows = (rows, focus, cap) => {
582
+ if (rows.length <= cap) return { rows, offset: 0 };
583
+ let start = focus - cap + 1;
584
+ if (start < 0) start = 0;
585
+ if (start + cap > rows.length) start = rows.length - cap;
586
+ return { rows: rows.slice(start, start + cap), offset: start };
587
+ };
588
+
589
+ const noteInnerWidth = (width) => Math.max(1, width - NOTE_PAD * 2);
590
+
591
+ const paintNoteLine = (text, width, fgRgb, bgRgb, color, withCheck) => {
592
+ const inner = noteInnerWidth(width);
593
+ const clipped = truncateVisible(text, inner);
594
+ const line = `${' '.repeat(NOTE_PAD)}${clipped}`;
595
+ if (withCheck) return paintCheckBar(line, width, fgRgb, bgRgb, color);
596
+ return paintBar(line, width, fgRgb, bgRgb, color);
597
+ };
598
+
599
+ const paintTemplateRow = (text, selected, width, color) => {
600
+ const fgRgb = selected ? THEME.chromeFg : THEME.mutedFg;
601
+ const bgRgb = selected ? THEME.buttonBg : THEME.noteBg;
602
+ return paintNoteLine(text, width, fgRgb, bgRgb, color);
603
+ };
604
+
605
+ const paintComposeNote = (view, width, color, cap) => {
606
+ const inner = noteInnerWidth(width);
607
+ const bgRgb = THEME.noteBg;
608
+ const compose = view.compose;
609
+ const wrapped = wrapMultiline(compose.text, inner);
610
+ const { row, col } = cursorInWrap(compose.text, compose.cursor ?? 0, inner);
611
+ const shown = windowRows(wrapped, row, INPUT_MAX);
612
+ const rows = [];
613
+ const templateHits = [];
614
+ if (compose.kind === 'feedback') {
615
+ const all = view.templates ?? [];
616
+ const room =
617
+ cap === undefined ? all.length : Math.max(0, cap - shown.rows.length);
618
+ const n = Math.min(all.length, room);
619
+ const focus = view.templateIndex ?? -1;
620
+ for (let i = 0; i < n; i++) {
621
+ const text = all[i].text ?? '';
622
+ const selected = i === focus;
623
+ rows.push(paintTemplateRow(text, selected, width, color));
624
+ templateHits.push({ row: i, cursor: i });
625
+ }
626
+ }
627
+ const templateCount = rows.length;
628
+ for (const line of shown.rows) {
629
+ rows.push(paintNoteLine(line, width, THEME.chromeFg, bgRgb, color));
630
+ }
631
+ return {
632
+ rows,
633
+ cursor: {
634
+ x: NOTE_PAD + col + 1,
635
+ row: templateCount + row - shown.offset,
636
+ },
637
+ templateHits,
638
+ };
639
+ };
640
+
641
+ const paintIdleNote = (view, width, color) => {
642
+ const empty = { rows: [], cursor: null, templateHits: [] };
643
+ const text = view.noteText;
644
+ if (!text) return empty;
645
+ const inner = noteInnerWidth(width);
646
+ const prefix = view.noteKind ? `${view.noteKind}: ` : '';
647
+ const wrapped = wrapMultiline(`${prefix}${text}`, inner);
648
+ const rows = [];
649
+ for (const line of wrapped.slice(0, NOTE_MAX)) {
650
+ const painted = paintNoteLine(
651
+ line,
652
+ width,
653
+ THEME.chromeFg,
654
+ THEME.noteBg,
655
+ color,
656
+ true,
657
+ );
658
+ rows.push(painted);
659
+ }
660
+ return { rows, cursor: null, templateHits: [] };
661
+ };
662
+
663
+ const paintNotePanel = (view, width, color, cap) => {
664
+ const empty = { rows: [], cursor: null, templateHits: [] };
665
+ if (view.help || (view.pane ?? 'diff') === 'files') return empty;
666
+ const compose = view.compose;
667
+ if (compose && compose.kind === 'feedback') {
668
+ return paintComposeNote(view, width, color, cap);
669
+ }
670
+ return paintIdleNote(view, width, color);
671
+ };
672
+
673
+ const paintBodyHelp = (width, color) => {
674
+ const body = [];
675
+ for (const line of helpLines()) {
676
+ for (const row of wrapPlain(line, width)) {
677
+ body.push(paintBar(row, width, THEME.chromeFg, THEME.ctxBg, color));
678
+ }
679
+ }
680
+ return body;
681
+ };
682
+
683
+ const paintBodyFiles = (view, width, color, bodyH, headerLines) => {
684
+ const files = view.files ?? [];
685
+ const cols = fileColumns(files);
686
+ const cursor = view.fileCursor ?? 0;
687
+ const start = cursor >= bodyH ? cursor - bodyH + 1 : 0;
688
+ const body = [];
689
+ const fileHits = [];
690
+ for (let i = 0; i < bodyH; i++) {
691
+ const idx = start + i;
692
+ const entry = files[idx];
693
+ if (!entry) break;
694
+ const selected = idx === cursor;
695
+ const here = view.reviewPath === entry.path;
696
+ body.push(paintFileRow(entry, width, color, selected, here, cols));
697
+ fileHits.push({ y: headerLines + i + 1, cursor: idx });
698
+ }
699
+ return { body, fileHits };
700
+ };
701
+
702
+ const paintTodoCaption = (width, color) =>
703
+ paintBar(` ${TODO_CAPTION}`, width, THEME.chromeFg, THEME.ctxBg, color);
704
+
705
+ const paintBodyTodo = (view, width, color) => {
706
+ const texts = view.todos ?? [];
707
+ let lines = texts;
708
+ if (!lines.length && view.noteText) lines = [view.noteText];
709
+ const painted = paintTodoList(
710
+ lines,
711
+ view.todoFocus ?? 0,
712
+ width,
713
+ color,
714
+ view.todoEdit,
715
+ );
716
+ const caption = paintTodoCaption(width, color);
717
+ const body = [caption, ...painted.rows];
718
+ const todoOwners = [null, ...painted.owners];
719
+ const cursor = painted.cursor
720
+ ? { x: painted.cursor.x, row: painted.cursor.row + 1 }
721
+ : null;
722
+ return { body, todoOwners, cursor };
723
+ };
724
+
725
+ const paintBodyDiff = (item, layout, width, color) => {
726
+ const lang = detectLang(itemPath(item));
727
+ const splitBody = layout === 'side' && !item.file.isBinary;
728
+ const lines = dropLeadingEmpty(bodyLines(item, layout), splitBody);
729
+ const body = [];
730
+ if (splitBody) {
731
+ for (const row of lines) {
732
+ body.push(paintSplitRow(row, width, color, item.origin, lang));
733
+ }
734
+ } else {
735
+ for (const line of lines) {
736
+ body.push(paintDiffLine(line, width, color, item.origin, lang));
737
+ }
738
+ }
739
+ return { body, splitBody };
740
+ };
741
+
742
+ const bodyKind = (view) => {
743
+ if (view.help) return 'help';
744
+ if ((view.pane ?? 'diff') === 'files') return 'files';
745
+ if (!view.item) return 'empty';
746
+ if (view.item.origin === 'todo') return 'todo';
747
+ return 'diff';
748
+ };
749
+
750
+ const BODY_PAINT = {
751
+ help: (view, width, color) => ({
752
+ body: paintBodyHelp(width, color),
753
+ fileHits: [],
754
+ todoOwners: [],
755
+ }),
756
+ files: (view, width, color, bodyH, headerLines) => ({
757
+ ...paintBodyFiles(view, width, color, bodyH, headerLines),
758
+ todoOwners: [],
759
+ }),
760
+ empty: () => ({ body: [], fileHits: [], todoOwners: [] }),
761
+ todo: (view, width, color) => ({
762
+ ...paintBodyTodo(view, width, color),
763
+ fileHits: [],
764
+ }),
765
+ diff: (view, width, color) => ({
766
+ ...paintBodyDiff(view.item, view.layout ?? 'unified', width, color),
767
+ fileHits: [],
768
+ todoOwners: [],
769
+ }),
770
+ };
771
+
772
+ const paintFrameBody = (view, width, color, bodyH, headerLines) => {
773
+ const paint = BODY_PAINT[bodyKind(view)];
774
+ return paint(view, width, color, bodyH, headerLines);
775
+ };
776
+
777
+ const frameNotes = (view, width, height, color) => {
778
+ const notesCap = Math.max(0, height - 4);
779
+ const notesWanted = paintNotePanel(view, width, color, notesCap);
780
+ const noteRows = notesWanted.rows.slice(0, notesCap);
781
+ let noteCursor = notesWanted.cursor;
782
+ if (noteCursor && noteCursor.row >= noteRows.length) noteCursor = null;
783
+ const chrome = 3 + noteRows.length;
784
+ const bodyH = Math.max(1, height - chrome);
785
+ return { notesWanted, noteRows, noteCursor, bodyH };
786
+ };
787
+
788
+ const bodyFillKind = (listPane, splitBody) => {
789
+ if (listPane) return 'files';
790
+ if (splitBody) return 'split';
791
+ return 'plain';
792
+ };
793
+
794
+ const mapBodyCursor = (cursor, shift, scroll, headerLines, shown) => {
795
+ if (!cursor) return null;
796
+ const row = cursor.row + shift;
797
+ if (row < scroll || row >= scroll + shown) return null;
798
+ return {
799
+ x: cursor.x,
800
+ y: headerLines + (row - scroll) + 1,
801
+ };
802
+ };
803
+
804
+ const windowBody = (view, width, color, bodyH, headerLines, paintedBody) => {
805
+ const body = paintedBody.body;
806
+ const todoOwners = paintedBody.todoOwners;
807
+ const splitBody = paintedBody.splitBody === true;
808
+ const pane = view.pane ?? 'diff';
809
+ const listPane = pane === 'files' && !view.help;
810
+ const fillKind = bodyFillKind(listPane, splitBody);
811
+ let shift = 0;
812
+ if (!listPane && !view.help && body.length > 0) {
813
+ body.unshift(paintBodyFill(width, color, fillKind));
814
+ if (todoOwners.length) todoOwners.unshift(null);
815
+ shift = 1;
816
+ }
817
+ const scrollRaw = view.scroll ?? 0;
818
+ const scrollMax = Math.max(0, body.length - bodyH);
819
+ const scroll = listPane ? 0 : Math.min(scrollRaw, scrollMax);
820
+ const slice = body.slice(scroll, scroll + bodyH);
821
+ const todoHits = [];
822
+ for (let i = 0; i < slice.length; i++) {
823
+ const owner = todoOwners[scroll + i];
824
+ if (owner === undefined || owner === null) continue;
825
+ todoHits.push({ y: headerLines + i + 1, cursor: owner });
826
+ }
827
+ const cursor = mapBodyCursor(
828
+ paintedBody.cursor,
829
+ shift,
830
+ scroll,
831
+ headerLines,
832
+ slice.length,
833
+ );
834
+ while (slice.length < bodyH) {
835
+ slice.push(paintBodyFill(width, color, fillKind));
836
+ }
837
+ return { slice, scroll, todoHits, fileHits: paintedBody.fileHits, cursor };
838
+ };
839
+
840
+ const notePointerHits = (notesWanted, noteRows, noteCursor, bodyH) => {
841
+ const cursor = noteCursor
842
+ ? { x: noteCursor.x, y: 2 + bodyH + noteCursor.row }
843
+ : null;
844
+ const templateHits = [];
845
+ for (const hit of notesWanted.templateHits ?? []) {
846
+ if (hit.row >= noteRows.length) continue;
847
+ templateHits.push({ y: 2 + bodyH + hit.row, cursor: hit.cursor });
848
+ }
849
+ return { cursor, templateHits };
850
+ };
851
+
852
+ const stackFrame = (view, width, color, rows, layout, windowed, notes) => {
853
+ for (const row of windowed.slice) rows.push(row);
854
+ for (const row of notes.noteRows) rows.push(row);
855
+ const status = view.status ?? '';
856
+ rows.push(paintStatusLine(view, status, width, color));
857
+ rows.push(paintButtons(layout, width, color));
858
+ return overlayRows(rows, view.selection);
859
+ };
860
+
861
+ const frameResult = (text, painted, layout, windowed, hits) => {
862
+ const fileHits = windowed.fileHits;
863
+ const todoHits = windowed.todoHits;
864
+ const templateHits = hits.templateHits;
865
+ const footerY = painted.length;
866
+ const scroll = windowed.scroll;
867
+ const cursor = hits.cursor ?? windowed.cursor;
868
+ return {
869
+ text,
870
+ rows: painted,
871
+ buttons: layout.hits,
872
+ fileHits,
873
+ todoHits,
874
+ templateHits,
875
+ footerY,
876
+ height: painted.length,
877
+ scroll,
878
+ cursor,
879
+ };
880
+ };
881
+
882
+ const renderFrame = (view, options = {}) => {
883
+ const width = Math.max(20, options.width ?? 80);
884
+ const height = Math.max(6, options.height ?? 24);
885
+ const color = options.color ?? true;
886
+ const rows = [paintHeader(view, width, color)];
887
+ const layout = layoutButtons(width);
888
+ const notes = frameNotes(view, width, height, color);
889
+ const headerLines = rows.length;
890
+ const bodyH = notes.bodyH;
891
+ const paintedBody = paintFrameBody(view, width, color, bodyH, headerLines);
892
+ const windowed = windowBody(
893
+ view,
894
+ width,
895
+ color,
896
+ bodyH,
897
+ headerLines,
898
+ paintedBody,
899
+ );
900
+ const painted = stackFrame(view, width, color, rows, layout, windowed, notes);
901
+ const text = painted.map((row) => (color ? row + RESET : row)).join('\n');
902
+ const hits = notePointerHits(
903
+ notes.notesWanted,
904
+ notes.noteRows,
905
+ notes.noteCursor,
906
+ bodyH,
907
+ );
908
+ return frameResult(text, painted, layout, windowed, hits);
909
+ };
910
+
911
+ const presentCursor = (cursor) => {
912
+ if (!cursor || cursor.x <= 0 || cursor.y <= 0) return `${ESC}[?25l`;
913
+ const pos = `${ESC}[${cursor.y};${cursor.x}H`;
914
+ return `${pos}${ESC}[1 q${ESC}[?12h${ESC}[?25h`;
915
+ };
916
+
917
+ const presentRows = (rows, options = {}) => {
918
+ const clear = options.clear === true;
919
+ const cursor = options.cursor;
920
+ const parts = [`${ESC}[?25l`, `${ESC}[?2026h`];
921
+ if (clear) parts.push(`${ESC}[H${ESC}[2J`);
922
+ for (let i = 0; i < rows.length; i++) {
923
+ parts.push(`${ESC}[${i + 1};1H`);
924
+ parts.push(rows[i]);
925
+ }
926
+ parts.push(`${ESC}[?2026l`);
927
+ parts.push(presentCursor(cursor));
928
+ return parts.join('');
929
+ };
930
+
931
+ const DEL_CHAR_BG = ansi.bg(THEME.delCharBg);
932
+ const ADD_CHAR_BG = ansi.bg(THEME.addCharBg);
933
+ const DEL_LINE_FG = ansi.fg(THEME.delLineFg);
934
+ const ADD_LINE_FG = ansi.fg(THEME.addLineFg);
935
+
936
+ module.exports = {
937
+ LOGO,
938
+ FILE_CURSOR,
939
+ FILE_CURRENT,
940
+ QUIT_FINISH,
941
+ QUIT_CONTINUE,
942
+ QUIT_PROMPT,
943
+ STYLES,
944
+ STAGED_STYLES,
945
+ renderFrame,
946
+ presentCursor,
947
+ presentRows,
948
+ layoutButtons,
949
+ paintDiffLine,
950
+ paintSplitRow,
951
+ bodyLines,
952
+ headerText,
953
+ wrapPlain,
954
+ wrapDoc,
955
+ wrapMultiline,
956
+ cursorInWrap,
957
+ wrapMove,
958
+ noteInnerWidth,
959
+ todoInnerWidth,
960
+ DEL_CHAR_BG,
961
+ ADD_CHAR_BG,
962
+ DEL_LINE_FG,
963
+ ADD_LINE_FG,
964
+ };