reslop 0.2.0 → 0.2.3

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +13 -2
  3. package/lib/ansi.js +263 -6
  4. package/lib/api.js +2 -0
  5. package/lib/capabilities.js +6 -0
  6. package/lib/cli.js +13 -2
  7. package/lib/deps.js +43 -3
  8. package/lib/git-branches.js +228 -17
  9. package/lib/git-reword-todo.js +20 -0
  10. package/lib/git.js +20 -3
  11. package/lib/keys.js +10 -4
  12. package/lib/npm-commands.js +513 -0
  13. package/lib/render/branches.js +23 -58
  14. package/lib/render/chrome.js +156 -99
  15. package/lib/render/commits.js +643 -31
  16. package/lib/render/diff.js +4 -4
  17. package/lib/render/frame.js +51 -5
  18. package/lib/render/notes.js +22 -5
  19. package/lib/render/npm.js +228 -0
  20. package/lib/render/primitives.js +48 -34
  21. package/lib/render/render.js +3 -3
  22. package/lib/report-model.js +127 -0
  23. package/lib/report-parse.js +94 -0
  24. package/lib/report-render.js +87 -0
  25. package/lib/report-run.js +164 -0
  26. package/lib/report-test.js +506 -0
  27. package/lib/report-tools.js +311 -0
  28. package/lib/review-merge.js +270 -0
  29. package/lib/review.js +38 -6
  30. package/lib/session/actions.js +81 -5
  31. package/lib/session/commit.js +187 -19
  32. package/lib/session/compose.js +96 -32
  33. package/lib/session/dispatch.js +36 -2
  34. package/lib/session/files-pane.js +36 -6
  35. package/lib/session/lifecycle.js +116 -16
  36. package/lib/session/navigation.js +1 -0
  37. package/lib/session/npm.js +335 -0
  38. package/lib/session/pointer.js +12 -1
  39. package/lib/session/todos.js +29 -1
  40. package/lib/session/view.js +14 -3
  41. package/lib/session/watch.js +120 -8
  42. package/lib/session.js +11 -2
  43. package/package.json +7 -7
@@ -10,21 +10,33 @@ const { ACTION_IDS, BRANCH_ACTIONS, COMMIT_ACTIONS, actionLetter } = keys;
10
10
  const { buttonWord } = keys;
11
11
  const { FILES_HIDDEN, FILES_GIT_DISABLED, BRANCHES_DISABLED } = keys;
12
12
  const { COMMITS_DISABLED, TODO_DISABLED, DIFF_DISABLED, UNIT_DISABLED } = keys;
13
+ const { NPM_DISABLED } = keys;
13
14
  const { diffGitDisabled, branchGitDisabled, commitGitDisabled } = keys;
14
15
  const { CONFIRM, confirmPrefix, promptFromChoices } = keys;
15
16
  const { THEME, paint, visibleWidth } = ansi;
16
17
  const { truncateVisible, fg, RESET, EL, seq } = ansi;
17
- const { fill, branchLabel, paintBracketName } = primitives;
18
+ const { fill, branchLabel } = primitives;
18
19
 
19
20
  const HEADER_ROLE_FG = {
20
21
  repo: THEME.headerRepoFg,
21
22
  dir: THEME.headerDirFg,
22
23
  slash: THEME.headerSlashFg,
23
24
  file: THEME.headerFileFg,
24
- meta: THEME.headerMetaFg,
25
25
  };
26
26
 
27
- const BUSY_BAR = ['▰▰▱▱▱▱', '▱▰▰▱▱▱', '▱▱▰▰▱▱', '▱▱▱▰▰▱', '▱▱▱▱▰▰', '▰▱▱▱▱▰'];
27
+ const WAVE = '·•●•·';
28
+
29
+ const busyBars = (wave) => {
30
+ const n = wave.length;
31
+ const frames = new Array(n);
32
+ for (let frame = 0; frame < n; frame++) {
33
+ const start = (n - frame) % n;
34
+ frames[frame] = wave.slice(start) + wave.slice(0, start);
35
+ }
36
+ return frames;
37
+ };
38
+
39
+ const BUSY_BAR = busyBars(WAVE);
28
40
  const BUSY_PROGRESS = [
29
41
  'pulling',
30
42
  'pushing',
@@ -34,11 +46,13 @@ const BUSY_PROGRESS = [
34
46
  'npm i',
35
47
  'npm uninstall',
36
48
  'npm audit fix',
49
+ 'running',
37
50
  'checking out',
38
51
  'creating branch',
39
52
  'rebasing',
40
53
  'dropping',
41
54
  'committing',
55
+ 'rewording',
42
56
  'force pushing',
43
57
  ];
44
58
 
@@ -62,6 +76,8 @@ const INFO_STATUS = [
62
76
  'unified',
63
77
  'mixed',
64
78
  'side-by-side',
79
+ 'dark',
80
+ 'light',
65
81
  'saved',
66
82
  'reloaded',
67
83
  'not a diff block',
@@ -69,6 +85,7 @@ const INFO_STATUS = [
69
85
  'diff mode',
70
86
  'committed',
71
87
  'amended',
88
+ 'reworded',
72
89
  'fixup',
73
90
  'updated',
74
91
  'updating reslop',
@@ -91,6 +108,7 @@ const INFO_STATUS = [
91
108
  'checked out',
92
109
  'created',
93
110
  'empty branch name',
111
+ 'empty commit message',
94
112
  ];
95
113
  const INFO_PREFIX = ['checked out ', 'created ', 'rebased onto ', 'dropped '];
96
114
 
@@ -100,21 +118,12 @@ const dropPrompt = (name) => ` drop ${name}? y/n`;
100
118
  const PUSH_REJECT_PREFIX = CONFIRM.confirmPush.prefix;
101
119
  const PUSH_PROMPT = `${PUSH_REJECT_PREFIX} force push`;
102
120
 
121
+ const BUTTON_LEAD = ' ';
103
122
  const BUTTON_GAP = ' ';
123
+ const BUTTON_EDGE = 1;
124
+ const buttonGap = (index) => (index ? BUTTON_GAP : BUTTON_LEAD);
104
125
  const LOGO = '👁️';
105
126
 
106
- const ORIGIN_LABEL = {
107
- commit: (view) => view.revShort || 'commit',
108
- pr: (view) => view.sourceLabel || 'pr',
109
- todo: () => 'todo',
110
- };
111
-
112
- const originLabel = (origin, view) => {
113
- const named = ORIGIN_LABEL[origin];
114
- if (named) return named(view);
115
- return origin ?? 'unknown';
116
- };
117
-
118
127
  const pathSegments = (rel) => {
119
128
  const segs = [];
120
129
  if (!rel) return segs;
@@ -132,91 +141,59 @@ const pathSegments = (rel) => {
132
141
  return segs;
133
142
  };
134
143
 
135
- const headerTarget = (view) => {
144
+ const headerScreen = (view) => {
136
145
  const pane = view.pane ?? 'diff';
146
+ if (pane === 'branches') return 'branches';
147
+ if (pane === 'commits') {
148
+ const mode = view.commitView === 'full' ? 'full' : 'brief';
149
+ return `commits ${mode}`;
150
+ }
151
+ if (pane === 'npm') return 'npm';
137
152
  if (pane === 'files') {
138
153
  const files = view.files ?? [];
139
- const cursor = view.fileCursor ?? 0;
140
- const entry = files[cursor];
141
- const total = files.length;
142
- const n = total ? cursor + 1 : 0;
143
- if (!entry) return { path: '', origin: '', n, total };
144
- if (isTodosEntry(entry)) {
145
- return { path: TODO_FILE, origin: 'todo', n, total };
146
- }
147
- return { path: entry.path, origin: entry.status, n, total };
148
- }
149
- if (pane === 'branches') {
150
- const compose = view.compose;
151
- const branches = view.branches ?? [];
152
- if (compose && compose.kind === 'branch') {
153
- const total = branches.length + 1;
154
- return { path: compose.text || '', origin: 'new', n: total, total };
155
- }
156
- const cursor = view.branchCursor ?? 0;
157
- const entry = branches[cursor];
158
- const total = branches.length;
159
- const n = total ? cursor + 1 : 0;
160
- if (!entry) return { path: '', origin: 'branch', n, total };
161
- const origin = entry.current ? 'current' : 'branch';
162
- return { path: entry.name, origin, n, total };
154
+ const entry = files[view.fileCursor ?? 0];
155
+ if (isTodosEntry(entry)) return TODO_FILE;
156
+ return '';
163
157
  }
164
- if (pane === 'commits') {
165
- const commits = view.commits ?? [];
166
- const cursor = view.commitCursor ?? 0;
167
- const entry = commits[cursor];
168
- const total = commits.length;
169
- const n = total ? cursor + 1 : 0;
170
- if (!entry) return { path: '', origin: 'commit', n, total };
171
- const origin = entry.head ? 'HEAD' : 'commit';
172
- return { path: entry.subject ?? '', origin, n, total };
173
- }
174
- if (pane === 'unit') {
175
- const path = view.reviewPath ?? '';
176
- const lines = view.unitLines ?? [];
177
- const top = Math.max(0, (view.scroll ?? 0) - 1);
178
- const n = lines.length ? Math.min(lines.length, top + 1) : 0;
179
- const origin = view.item ? originLabel(view.item.origin, view) : 'unit';
180
- return { path, origin, n, total: lines.length };
181
- }
182
- if (!view.item) return { path: '', origin: '', n: 0, total: view.total ?? 0 };
183
- if (view.item.origin === 'todo') {
184
- return { path: TODO_FILE, origin: 'todo', n: 1, total: 1 };
185
- }
186
- return {
187
- path: itemPath(view.item),
188
- origin: originLabel(view.item.origin, view),
189
- n: view.index + 1,
190
- total: view.total,
191
- };
158
+ if (view.item && view.item.origin === 'todo') return TODO_FILE;
159
+ return '';
192
160
  };
193
161
 
194
- const headerPrefix = (view) => {
162
+ const headerTarget = (view) => {
163
+ const pane = view.pane ?? 'diff';
164
+ if (pane === 'files') {
165
+ const entry = (view.files ?? [])[view.fileCursor ?? 0];
166
+ if (!entry || isTodosEntry(entry)) return '';
167
+ return entry.path;
168
+ }
169
+ if (pane === 'unit') return view.reviewPath ?? '';
170
+ if (!view.item || view.item.origin === 'todo') return '';
171
+ return itemPath(view.item);
172
+ };
173
+
174
+ const headerLabel = (view) => headerScreen(view) || headerTarget(view);
175
+
176
+ const headerSep = (view) => (headerScreen(view) ? ': ' : '/');
177
+
178
+ const headerPrefix = (view, label) => {
195
179
  const repo = view.repoName || '';
196
- const { path } = headerTarget(view);
197
- if (repo && path) return ` ${LOGO} reslop: ${repo}/`;
180
+ if (repo && label) return ` ${LOGO} reslop: ${repo}${headerSep(view)}`;
198
181
  if (repo) return ` ${LOGO} reslop: ${repo} `;
199
182
  return ` ${LOGO} reslop: `;
200
183
  };
201
184
 
202
- const headerSuffix = ({ origin, n, total }) =>
203
- origin ? ` ${origin} ${n}/${total}` : ` ${n}/${total}`;
204
-
205
185
  const headerText = (view) => {
206
- const prefix = headerPrefix(view);
207
- const target = headerTarget(view);
208
- return `${prefix}${target.path}${headerSuffix(target)}`;
186
+ const label = headerLabel(view);
187
+ return `${headerPrefix(view, label)}${label}`;
209
188
  };
210
189
 
211
190
  const headerPieces = (view, width) => {
212
191
  const repo = view.repoName || '';
213
- const target = headerTarget(view);
214
- const path = target.path;
215
- const suffix = headerSuffix(target);
192
+ const path = headerLabel(view);
216
193
  const right = ' ';
217
194
  const chrome = ` ${LOGO} reslop: `;
218
- const prefixW = visibleWidth(headerPrefix(view));
219
- const used = prefixW + visibleWidth(suffix) + visibleWidth(right);
195
+ const prefixW = visibleWidth(headerPrefix(view, path));
196
+ const used = prefixW + visibleWidth(right);
220
197
  const room = Math.max(0, width - used);
221
198
  const clipped = truncateVisible(path, room);
222
199
  const pad = Math.max(0, room - visibleWidth(clipped));
@@ -224,7 +201,9 @@ const headerPieces = (view, width) => {
224
201
  if (repo) {
225
202
  pieces.push({ text: repo, role: 'repo' });
226
203
  if (path) {
227
- pieces.push({ text: '/', role: 'slash' });
204
+ const sep = headerSep(view);
205
+ const role = sep === '/' ? 'slash' : 'chrome';
206
+ pieces.push({ text: sep, role });
228
207
  } else {
229
208
  pieces.push({ text: ' ', role: 'chrome' });
230
209
  }
@@ -235,7 +214,6 @@ const headerPieces = (view, width) => {
235
214
  if (pad) {
236
215
  pieces.push({ text: ' '.repeat(pad), role: 'chrome' });
237
216
  }
238
- pieces.push({ text: suffix, role: 'meta' });
239
217
  pieces.push({ text: right, role: 'chrome' });
240
218
  return pieces;
241
219
  };
@@ -298,6 +276,7 @@ const layoutButtons = (width, compact, hidden, extra, disabled) => {
298
276
  for (const add of extraIds) {
299
277
  if (!ids.includes(add)) ids.push(add);
300
278
  }
279
+ continue;
301
280
  }
302
281
  if (hiddenIds.includes(id)) continue;
303
282
  ids.push(id);
@@ -306,13 +285,15 @@ const layoutButtons = (width, compact, hidden, extra, disabled) => {
306
285
  const hits = [];
307
286
  const parts = [];
308
287
  let x = 0;
288
+ const limit = Math.max(0, width - BUTTON_EDGE);
309
289
  for (const id of ids) {
310
290
  const letter = actionLetter(id);
311
291
  const word = buttonWord(id);
312
292
  const label = short ? letter : word;
313
- const piece = `${BUTTON_GAP}${label}`;
293
+ const gap = buttonGap(parts.length);
294
+ const piece = `${gap}${label}`;
314
295
  const w = visibleWidth(piece);
315
- if (x + w > width && parts.length) break;
296
+ if (x + w > limit && parts.length) break;
316
297
  const off = disabledIds.includes(id);
317
298
  if (!off) hits.push({ id, x0: x, x1: x + w });
318
299
  parts.push({ action: { id }, piece, letter, label, disabled: off });
@@ -331,8 +312,10 @@ const paintButtons = (layout, width, color) => {
331
312
  let used = 0;
332
313
  const fgRgb = THEME.buttonFg;
333
314
  const bgRgb = THEME.buttonBg;
334
- for (const part of layout.parts) {
335
- out += paint(BUTTON_GAP, fgRgb, bgRgb, color);
315
+ for (let i = 0; i < layout.parts.length; i++) {
316
+ const part = layout.parts[i];
317
+ const gap = buttonGap(i);
318
+ out += paint(gap, fgRgb, bgRgb, color);
336
319
  if (part.disabled) out += paint(part.label, fgRgb, bgRgb, color);
337
320
  else out += paintMarkedWord(part.label, part.letter, color, BUTTON_MARK);
338
321
  used += visibleWidth(part.piece);
@@ -470,14 +453,10 @@ const paintChoicePrompt = (choices, width, color, prefix = '') => {
470
453
  return out + fill(pad, THEME.mutedFg, bgRgb, color);
471
454
  };
472
455
 
473
- const STATUS_MARK = /(\[[^\]]+\]|\+[0-9]+(?:\/[0-9]+)?|-[0-9]+(?:\/[0-9]+)?)/g;
456
+ const STATUS_MARK = /(\+[0-9]+(?:\/[0-9]+)?|-[0-9]+(?:\/[0-9]+)?)/g;
474
457
 
475
458
  const paintStatusMark = (token, color) => {
476
459
  const bgRgb = THEME.chromeBg;
477
- const muted = THEME.mutedFg;
478
- if (token.startsWith('[')) {
479
- return paintBracketName(token, THEME.buttonHotFg, muted, bgRgb, color);
480
- }
481
460
  if (token.startsWith('+')) {
482
461
  return paint(token, THEME.addLineFg, bgRgb, color);
483
462
  }
@@ -505,6 +484,68 @@ const paintStatusLeft = (text, color) => {
505
484
  return out;
506
485
  };
507
486
 
487
+ const paintBranchHead = (text, branch, color) => {
488
+ const name = branchLabel(branch);
489
+ if (!name || !text.startsWith(' ')) return null;
490
+ const head = ` ${name}`;
491
+ const shown = text.startsWith(head) ? name : text.slice(1);
492
+ if (!shown || !name.startsWith(shown)) return null;
493
+ const rest = text.slice(1 + shown.length);
494
+ const bgRgb = THEME.chromeBg;
495
+ return (
496
+ paint(' ', THEME.mutedFg, bgRgb, color) +
497
+ paint(shown, THEME.buttonHotFg, bgRgb, color, true) +
498
+ paintStatusLeft(rest, color)
499
+ );
500
+ };
501
+
502
+ const SAVE_HINT = 'Press **Ctrl-S** or **Enter** at EOF to save';
503
+ const HINT_HOT_FG = 0xff;
504
+
505
+ const hintParts = (source) => {
506
+ const bits = source.split('**');
507
+ const parts = [];
508
+ for (let i = 0; i < bits.length; i++) {
509
+ if (!bits[i]) continue;
510
+ parts.push({ text: bits[i], hot: i % 2 === 1 });
511
+ }
512
+ return parts;
513
+ };
514
+
515
+ const saveHintText = () => SAVE_HINT.replaceAll('**', '');
516
+
517
+ const commitSaveHint = (view) => {
518
+ if (view.mode !== 'compose' || view.commitView !== 'full') return false;
519
+ const compose = view.compose;
520
+ return Boolean(compose && compose.kind === 'commit');
521
+ };
522
+
523
+ const paintSaveHintBar = (width, color) => {
524
+ const bgRgb = THEME.buttonBg;
525
+ const plain = `${BUTTON_LEAD}${saveHintText()}`;
526
+ if (!color) {
527
+ const pad = Math.max(0, width - visibleWidth(plain));
528
+ return plain + ' '.repeat(pad);
529
+ }
530
+ let out = paint(BUTTON_LEAD, THEME.buttonFg, bgRgb, color);
531
+ for (const part of hintParts(SAVE_HINT)) {
532
+ const tone = part.hot ? HINT_HOT_FG : THEME.buttonFg;
533
+ out += paint(part.text, tone, bgRgb, color, part.hot);
534
+ }
535
+ const pad = Math.max(0, width - visibleWidth(plain));
536
+ return out + fill(pad, THEME.buttonFg, bgRgb, color);
537
+ };
538
+
539
+ const paintFooter = (view, layout, width, color) => {
540
+ if (commitSaveHint(view)) return paintSaveHintBar(width, color);
541
+ return paintButtons(layout, width, color);
542
+ };
543
+
544
+ const footerHits = (view, layout) => {
545
+ if (commitSaveHint(view)) return [];
546
+ return layout.hits;
547
+ };
548
+
508
549
  const paintStatusLine = (view, status, width, color) => {
509
550
  const confirm = CONFIRM[view.mode];
510
551
  if (confirm) {
@@ -518,8 +559,13 @@ const paintStatusLine = (view, status, width, color) => {
518
559
  return layout.clipped + leftGap + layout.msg + rightGap + layout.right;
519
560
  }
520
561
  const bgRgb = THEME.chromeBg;
562
+ const worktree = countsKind(view.sourceKind, view.revShort) === 'worktree';
563
+ const head = worktree
564
+ ? paintBranchHead(layout.clipped, view.branch, color)
565
+ : null;
566
+ const left = head ?? paintStatusLeft(layout.clipped, color);
521
567
  return (
522
- paintStatusLeft(layout.clipped, color) +
568
+ left +
523
569
  paint(leftGap, THEME.mutedFg, bgRgb, color) +
524
570
  paint(layout.msg, layout.msgFg, bgRgb, color) +
525
571
  paint(rightGap, THEME.mutedFg, bgRgb, color) +
@@ -541,10 +587,10 @@ const statusBranchHit = (view, width, y) => {
541
587
  if (!branchLabel(branch)) return null;
542
588
  if (countsKind(view.sourceKind, view.revShort) !== 'worktree') return null;
543
589
  const layout = statusLayout(view, view.status ?? '', width);
544
- const open = layout.clipped.indexOf('[');
545
- const close = layout.clipped.indexOf(']');
546
- if (open < 0 || close <= open) return null;
547
- return { id: 'branch', y, x0: open, x1: close + 1 };
590
+ const name = branchLabel(branch);
591
+ const at = layout.clipped.indexOf(name);
592
+ if (at < 0) return null;
593
+ return { id: 'branch', y, x0: at, x1: at + visibleWidth(name) };
548
594
  };
549
595
 
550
596
  const selectedBranch = (view) => (view.branches ?? [])[view.branchCursor ?? 0];
@@ -557,6 +603,7 @@ const hiddenActions = (view) => {
557
603
  if (pane === 'unit') return UNIT_DISABLED;
558
604
  if (pane === 'branches') return BRANCHES_DISABLED;
559
605
  if (pane === 'commits') return COMMITS_DISABLED;
606
+ if (pane === 'npm') return NPM_DISABLED;
560
607
  if (view.item && view.item.origin === 'todo') return TODO_DISABLED;
561
608
  return DIFF_DISABLED;
562
609
  };
@@ -577,6 +624,10 @@ const dimFooterActions = (view) => {
577
624
  if (!entry) return commitGitDisabled({ canCommit: staged });
578
625
  return commitGitDisabled({ ...entry, canCommit: staged });
579
626
  }
627
+ if (pane === 'npm' && view.npmView) {
628
+ if (view.npmRunning) return ['npmRerun'];
629
+ return [];
630
+ }
580
631
  return [];
581
632
  };
582
633
 
@@ -584,10 +635,14 @@ const extraFooterActions = (view) => {
584
635
  const pane = view.pane ?? 'diff';
585
636
  if (pane === 'files') {
586
637
  const scope = view.fileScope === 'file' ? 'diff' : 'file';
587
- return [scope, 'commit', 'pull', 'push'];
638
+ return ['npm', scope, 'commit', 'pull', 'push'];
588
639
  }
589
640
  if (pane === 'branches') return Object.keys(BRANCH_ACTIONS);
590
641
  if (pane === 'commits') return Object.keys(COMMIT_ACTIONS);
642
+ if (pane === 'npm') {
643
+ if (view.npmView) return ['npmStop', 'npmRerun'];
644
+ return ['npmEdit', 'npmNew'];
645
+ }
591
646
  return [];
592
647
  };
593
648
 
@@ -601,6 +656,8 @@ module.exports = {
601
656
  paintHeader,
602
657
  layoutButtons,
603
658
  paintButtons,
659
+ paintFooter,
660
+ footerHits,
604
661
  paintStatusLine,
605
662
  statusChoiceHits,
606
663
  statusBranchHit,