reslop 0.2.4 → 0.2.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/LICENSE +1 -1
  3. package/README.md +12 -13
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +32 -19
  6. package/lib/capabilities.js +8 -12
  7. package/lib/cli.js +40 -74
  8. package/lib/clipboard.js +48 -18
  9. package/lib/dashboard/git.js +128 -0
  10. package/lib/dashboard/model.js +332 -0
  11. package/lib/dashboard/npm.js +193 -0
  12. package/lib/dashboard/tree.js +278 -0
  13. package/lib/deps-diff.js +196 -404
  14. package/lib/deps.js +240 -399
  15. package/lib/detect.js +8 -18
  16. package/lib/diff/align.js +41 -113
  17. package/lib/diff/diff.js +12 -43
  18. package/lib/diff/display.js +25 -36
  19. package/lib/diff/edit.js +6 -20
  20. package/lib/diff/inline.js +138 -141
  21. package/lib/diff/items.js +33 -47
  22. package/lib/diff/parse.js +28 -56
  23. package/lib/diff/patch.js +12 -33
  24. package/lib/diff/unit.js +90 -206
  25. package/lib/editor.js +98 -55
  26. package/lib/files.js +86 -103
  27. package/lib/git-branches.js +254 -416
  28. package/lib/git-deps.js +182 -344
  29. package/lib/git-reword-todo.js +5 -13
  30. package/lib/git-worktree.js +141 -212
  31. package/lib/git.js +21 -49
  32. package/lib/github.js +67 -181
  33. package/lib/gitlab.js +40 -90
  34. package/lib/highlight/bash.js +55 -177
  35. package/lib/highlight/core.js +157 -132
  36. package/lib/highlight/css.js +25 -58
  37. package/lib/highlight/csv.js +19 -37
  38. package/lib/highlight/dart.js +72 -213
  39. package/lib/highlight/dot.js +16 -26
  40. package/lib/highlight/html.js +42 -63
  41. package/lib/highlight/js.js +177 -506
  42. package/lib/highlight/json.js +17 -37
  43. package/lib/highlight/log.js +22 -35
  44. package/lib/highlight/txt.js +9 -22
  45. package/lib/highlight.js +17 -46
  46. package/lib/keys.js +62 -85
  47. package/lib/manifest.js +67 -130
  48. package/lib/npm-commands.js +119 -189
  49. package/lib/remote.js +141 -197
  50. package/lib/render/branches.js +66 -73
  51. package/lib/render/chrome.js +264 -323
  52. package/lib/render/commits.js +187 -500
  53. package/lib/render/dash-activity.js +212 -0
  54. package/lib/render/dash-blocks.js +245 -0
  55. package/lib/render/dash-table.js +290 -0
  56. package/lib/render/dashboard.js +62 -0
  57. package/lib/render/diff.js +123 -228
  58. package/lib/render/files.js +45 -99
  59. package/lib/render/frame.js +129 -183
  60. package/lib/render/notes.js +144 -160
  61. package/lib/render/npm.js +234 -112
  62. package/lib/render/present.js +4 -11
  63. package/lib/render/primitives.js +131 -170
  64. package/lib/render/render.js +5 -14
  65. package/lib/render/report.js +43 -0
  66. package/lib/render/tiles.js +260 -0
  67. package/lib/render/unit.js +15 -28
  68. package/lib/report-model.js +110 -33
  69. package/lib/report-parse.js +56 -38
  70. package/lib/report-render.js +102 -59
  71. package/lib/report-run.js +87 -78
  72. package/lib/report-test.js +256 -141
  73. package/lib/report-tools.js +96 -161
  74. package/lib/review-merge.js +114 -152
  75. package/lib/review.js +115 -189
  76. package/lib/runs.js +225 -0
  77. package/lib/select.js +89 -75
  78. package/lib/session/accessors.js +14 -21
  79. package/lib/session/actions.js +82 -63
  80. package/lib/session/branches.js +193 -247
  81. package/lib/session/change-actions.js +171 -214
  82. package/lib/session/commit.js +280 -342
  83. package/lib/session/compose.js +559 -674
  84. package/lib/session/dashboard.js +385 -0
  85. package/lib/session/dispatch.js +164 -157
  86. package/lib/session/edit-click.js +57 -0
  87. package/lib/session/files-pane.js +85 -53
  88. package/lib/session/items.js +73 -104
  89. package/lib/session/lifecycle.js +403 -541
  90. package/lib/session/load.js +48 -68
  91. package/lib/session/navigation.js +96 -87
  92. package/lib/session/npm.js +409 -345
  93. package/lib/session/ops.js +196 -63
  94. package/lib/session/pointer.js +23 -15
  95. package/lib/session/progress.js +33 -21
  96. package/lib/session/review.js +33 -54
  97. package/lib/session/tasks.js +333 -0
  98. package/lib/session/templates.js +51 -59
  99. package/lib/session/terminal.js +85 -136
  100. package/lib/session/unit.js +3 -4
  101. package/lib/session/update.js +76 -97
  102. package/lib/session/view.js +52 -83
  103. package/lib/session/watch.js +208 -204
  104. package/lib/session.js +112 -237
  105. package/lib/update.js +45 -116
  106. package/lib/utilities.js +67 -89
  107. package/lib/wrap.js +33 -44
  108. package/package.json +1 -1
  109. package/lib/session/todos.js +0 -293
@@ -5,38 +5,24 @@ const keys = require('../keys.js');
5
5
  const files = require('../files.js');
6
6
  const primitives = require('./primitives.js');
7
7
 
8
- const { itemPath, REPO_TODOS_LABEL, isTodosEntry, fileTotals } = files;
9
- const { ACTION_IDS, BRANCH_ACTIONS, COMMIT_ACTIONS, actionLetter } = keys;
10
- const { buttonWord } = keys;
8
+ const { itemPath, REPO_TASKS_LABEL, isTasksEntry, fileTotals } = files;
9
+ const { ACTION_IDS, actionLetter } = keys;
10
+ const { DASH_IDS, DASH_HIDDEN, buttonWord } = keys;
11
11
  const { FILES_HIDDEN, FILES_GIT_DISABLED, BRANCHES_DISABLED } = keys;
12
- const { COMMITS_DISABLED, TODO_DISABLED, DIFF_DISABLED, UNIT_DISABLED } = keys;
12
+ const { COMMITS_DISABLED, TASKS_DISABLED, DIFF_DISABLED, UNIT_DISABLED } = keys;
13
13
  const { NPM_DISABLED } = keys;
14
14
  const { diffGitDisabled, branchGitDisabled, commitGitDisabled } = keys;
15
15
  const { CONFIRM, confirmPrefix, promptFromChoices } = keys;
16
16
  const { THEME, paint, visibleWidth } = ansi;
17
17
  const { truncateVisible, fg, RESET, EL, seq } = ansi;
18
- const { fill, branchLabel } = primitives;
19
-
20
- const HEADER_ROLE_FG = {
21
- repo: THEME.headerRepoFg,
22
- dir: THEME.headerDirFg,
23
- slash: THEME.headerSlashFg,
24
- file: THEME.headerFileFg,
25
- };
18
+ const { fill, branchLabel, isTaskView } = primitives;
26
19
 
27
20
  const WAVE = '·•●•·';
21
+ const BUSY_BAR = Array.from(WAVE, (_, frame) => {
22
+ const start = (WAVE.length - frame) % WAVE.length;
23
+ return WAVE.slice(start) + WAVE.slice(0, start);
24
+ });
28
25
 
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);
40
26
  const BUSY_PROGRESS = [
41
27
  'pulling',
42
28
  'pushing',
@@ -112,143 +98,159 @@ const INFO_STATUS = [
112
98
  ];
113
99
  const INFO_PREFIX = ['checked out ', 'created ', 'rebased onto ', 'dropped '];
114
100
 
115
- const QUIT_PROMPT = promptFromChoices(CONFIRM.confirmQuit.choices);
116
- const updatePrompt = (from, to) => ` update reslop ${from} → ${to}? y/n`;
117
- const dropPrompt = (name) => ` drop ${name}? y/n`;
118
- const PUSH_REJECT_PREFIX = CONFIRM.confirmPush.prefix;
119
- const PUSH_PROMPT = `${PUSH_REJECT_PREFIX} force push`;
101
+ const LOGO = '👁️';
102
+ const HEADER_LEAD = ` ${LOGO} reslop: `;
103
+ const HEADER_ROLE_FG = {
104
+ chrome: THEME.headerChromeFg,
105
+ repo: THEME.headerRepoFg,
106
+ dir: THEME.headerDirFg,
107
+ slash: THEME.headerSlashFg,
108
+ file: THEME.headerFileFg,
109
+ };
120
110
 
121
111
  const BUTTON_LEAD = ' ';
122
112
  const BUTTON_GAP = ' ';
123
113
  const BUTTON_EDGE = 1;
124
- const buttonGap = (index) => (index ? BUTTON_GAP : BUTTON_LEAD);
125
- const LOGO = '👁️';
114
+ const BACK_ARROW = '🢐';
115
+ const STOP_MARK = '⊗';
116
+ const BACK_KEY = 'esc';
117
+
118
+ const BUTTON_MARK = {
119
+ bgRgb: THEME.buttonBg,
120
+ fgRgb: THEME.buttonFg,
121
+ hotFg: THEME.buttonHotFg,
122
+ missFg: THEME.buttonHotFg,
123
+ missBold: true,
124
+ };
125
+
126
+ const QUIT_MARK = {
127
+ bgRgb: THEME.chromeBg,
128
+ fgRgb: THEME.mutedFg,
129
+ hotFg: THEME.warnFg,
130
+ missFg: THEME.mutedFg,
131
+ missBold: false,
132
+ };
133
+
134
+ const STATUS_MARK = /(\+[0-9]+(?:\/[0-9]+)?|-[0-9]+(?:\/[0-9]+)?)/g;
135
+ const OLD_LOGS = /old logs \S+/;
136
+ const SAVE_HINT = 'Press **Ctrl-S** or **Enter** at EOF to save';
137
+ const HINT_HOT_FG = 0xff;
138
+
139
+ const selectedFile = (view) => (view.files ?? [])[view.fileCursor ?? 0];
140
+
141
+ const selectedBranch = (view) => (view.branches ?? [])[view.branchCursor ?? 0];
142
+
143
+ const selectedCommit = (view) => (view.commits ?? [])[view.commitCursor ?? 0];
144
+
145
+ const piecesText = (pieces) => pieces.map((piece) => piece.text).join('');
126
146
 
127
147
  const pathSegments = (rel) => {
128
148
  const segs = [];
129
149
  if (!rel) return segs;
130
150
  const bits = rel.split('/');
131
151
  for (let i = 0; i < bits.length; i++) {
132
- if (i) {
133
- segs.push({ text: '/', role: 'slash' });
134
- }
135
- if (!bits[i]) {
136
- continue;
137
- }
138
- const last = i === bits.length - 1;
139
- segs.push({ text: bits[i], role: last ? 'file' : 'dir' });
152
+ if (i) segs.push({ text: '/', role: 'slash' });
153
+ const role = i === bits.length - 1 ? 'file' : 'dir';
154
+ if (bits[i]) segs.push({ text: bits[i], role });
140
155
  }
141
156
  return segs;
142
157
  };
143
158
 
144
159
  const headerScreen = (view) => {
145
- const pane = view.pane ?? 'diff';
146
- if (pane === 'branches') return 'branches';
160
+ const { pane } = view;
161
+ if (
162
+ pane === 'dashboard' ||
163
+ pane === 'branches' ||
164
+ pane === 'npm' ||
165
+ pane === 'tasks'
166
+ ) {
167
+ return pane;
168
+ }
147
169
  if (pane === 'commits') {
148
- const mode = view.commitView === 'full' ? 'full' : 'brief';
149
- return `commits ${mode}`;
170
+ return view.commitView === 'full' ? 'commits full' : 'commits brief';
150
171
  }
151
- if (pane === 'npm') return 'npm';
152
172
  if (pane === 'files') {
153
- const files = view.files ?? [];
154
- const entry = files[view.fileCursor ?? 0];
155
- if (isTodosEntry(entry)) return REPO_TODOS_LABEL;
156
- return '';
173
+ return isTasksEntry(selectedFile(view)) ? REPO_TASKS_LABEL : '';
157
174
  }
158
- if (view.item && view.item.origin === 'todo') return REPO_TODOS_LABEL;
159
- return '';
175
+ return isTaskView(view) ? REPO_TASKS_LABEL : '';
160
176
  };
161
177
 
162
178
  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 '';
179
+ if (view.pane === 'files') {
180
+ const entry = selectedFile(view);
181
+ if (!entry || isTasksEntry(entry)) return '';
167
182
  return entry.path;
168
183
  }
169
- if (pane === 'unit') return view.reviewPath ?? '';
170
- if (!view.item || view.item.origin === 'todo') return '';
184
+ if (view.pane === 'unit') return view.reviewPath ?? '';
185
+ if (isTaskView(view)) return '';
171
186
  return itemPath(view.item);
172
187
  };
173
188
 
174
- const headerLabel = (view) => headerScreen(view) || headerTarget(view);
175
-
176
- const headerSep = (view) => (headerScreen(view) ? ': ' : '/');
177
-
178
- const headerPrefix = (view, label) => {
189
+ const headerLead = (view) => {
190
+ const screen = headerScreen(view);
191
+ const label = screen || headerTarget(view);
179
192
  const repo = view.repoName || '';
180
- if (repo && label) return ` ${LOGO} reslop: ${repo}${headerSep(view)}`;
181
- if (repo) return ` ${LOGO} reslop: ${repo} `;
182
- return ` ${LOGO} reslop: `;
193
+ const pieces = [{ text: HEADER_LEAD, role: 'chrome' }];
194
+ if (repo) {
195
+ pieces.push({ text: repo, role: 'repo' });
196
+ if (!label) pieces.push({ text: ' ', role: 'chrome' });
197
+ else if (screen) pieces.push({ text: ': ', role: 'chrome' });
198
+ else pieces.push({ text: '/', role: 'slash' });
199
+ }
200
+ return { pieces, label };
183
201
  };
184
202
 
185
203
  const headerText = (view) => {
186
- const label = headerLabel(view);
187
- return `${headerPrefix(view, label)}${label}`;
204
+ const { pieces, label } = headerLead(view);
205
+ return `${piecesText(pieces)}${label}`;
188
206
  };
189
207
 
190
- const headerPieces = (view, width) => {
191
- const repo = view.repoName || '';
192
- const path = headerLabel(view);
208
+ const paintHeader = (view, width, color) => {
209
+ const { pieces, label } = headerLead(view);
193
210
  const right = ' ';
194
- const chrome = ` ${LOGO} reslop: `;
195
- const prefixW = visibleWidth(headerPrefix(view, path));
196
- const used = prefixW + visibleWidth(right);
211
+ const used = visibleWidth(piecesText(pieces)) + visibleWidth(right);
197
212
  const room = Math.max(0, width - used);
198
- const clipped = truncateVisible(path, room);
213
+ const clipped = truncateVisible(label, room);
199
214
  const pad = Math.max(0, room - visibleWidth(clipped));
200
- const pieces = [{ text: chrome, role: 'chrome' }];
201
- if (repo) {
202
- pieces.push({ text: repo, role: 'repo' });
203
- if (path) {
204
- const sep = headerSep(view);
205
- const role = sep === '/' ? 'slash' : 'chrome';
206
- pieces.push({ text: sep, role });
207
- } else {
208
- pieces.push({ text: ' ', role: 'chrome' });
209
- }
210
- }
211
- for (const seg of pathSegments(clipped)) {
212
- pieces.push(seg);
213
- }
214
- if (pad) {
215
- pieces.push({ text: ' '.repeat(pad), role: 'chrome' });
216
- }
215
+ pieces.push(...pathSegments(clipped));
216
+ if (pad) pieces.push({ text: ' '.repeat(pad), role: 'chrome' });
217
217
  pieces.push({ text: right, role: 'chrome' });
218
- return pieces;
219
- };
220
-
221
- const paintHeader = (view, width, color) => {
222
- const pieces = headerPieces(view, width);
223
- if (!color) {
224
- return pieces.map((piece) => piece.text).join('');
225
- }
226
- const bgRgb = THEME.headerBg;
227
- let out = `${seq(THEME.headerChromeFg, bgRgb)}${EL}`;
218
+ if (!color) return piecesText(pieces);
219
+ let out = `${seq(THEME.headerChromeFg, THEME.headerBg)}${EL}`;
228
220
  for (const piece of pieces) {
229
- if (!piece.text) {
230
- continue;
231
- }
232
- const roleFg = HEADER_ROLE_FG[piece.role] ?? THEME.headerChromeFg;
233
- out += `${fg(roleFg)}${piece.text}`;
221
+ out += `${fg(HEADER_ROLE_FG[piece.role])}${piece.text}`;
234
222
  }
235
223
  return `${out}${RESET}`;
236
224
  };
237
225
 
238
- const BUTTON_MARK = {
239
- bgRgb: THEME.buttonBg,
240
- fgRgb: THEME.buttonFg,
241
- hotFg: THEME.buttonHotFg,
242
- missFg: THEME.buttonHotFg,
243
- missBold: true,
226
+ const backFooterId = (view) => {
227
+ const { pane } = view;
228
+ if (pane === 'branches' || pane === 'commits' || pane === 'tasks') {
229
+ return 'back';
230
+ }
231
+ if (pane === 'npm') return view.npmView ? 'npmStop' : 'back';
232
+ return isTaskView(view) ? 'back' : '';
244
233
  };
245
234
 
246
- const QUIT_MARK = {
247
- bgRgb: THEME.chromeBg,
248
- fgRgb: THEME.mutedFg,
249
- hotFg: THEME.warnFg,
250
- missFg: THEME.mutedFg,
251
- missBold: false,
235
+ const backHintMark = (view) => {
236
+ if (view.pane === 'npm' && view.npmView && view.npmRunning) return STOP_MARK;
237
+ return BACK_ARROW;
238
+ };
239
+
240
+ const buttonGap = (index) => (index ? BUTTON_GAP : BUTTON_LEAD);
241
+
242
+ const hintBody = (mark) => {
243
+ const gap = mark === STOP_MARK ? ' ' : '';
244
+ return `${mark}${gap}${BACK_KEY}`;
245
+ };
246
+
247
+ const buttonLabel = (id, short) => (short ? actionLetter(id) : buttonWord(id));
248
+
249
+ const paintBackHint = (color, mark) => {
250
+ const bgRgb = THEME.buttonBg;
251
+ const rest = hintBody(mark).slice(mark.length);
252
+ const arrow = paint(mark, THEME.buttonHotFg, bgRgb, color, true);
253
+ return arrow + paint(rest, THEME.buttonFg, bgRgb, color);
252
254
  };
253
255
 
254
256
  const paintMarkedWord = (label, letter, color, mark) => {
@@ -259,47 +261,46 @@ const paintMarkedWord = (label, letter, color, mark) => {
259
261
  const before = label.slice(0, at);
260
262
  const hot = label.slice(at, at + letter.length);
261
263
  const after = label.slice(at + letter.length);
262
- let out = '';
263
- if (before) out += paint(before, mark.fgRgb, mark.bgRgb, color);
264
+ let out = paint(before, mark.fgRgb, mark.bgRgb, color);
264
265
  out += paint(hot, mark.hotFg, mark.bgRgb, color, true);
265
- if (after) out += paint(after, mark.fgRgb, mark.bgRgb, color);
266
- return out;
266
+ return out + paint(after, mark.fgRgb, mark.bgRgb, color);
267
267
  };
268
268
 
269
- const layoutButtons = (width, compact, hidden, extra, disabled) => {
270
- const hiddenIds = hidden ?? [];
271
- const extraIds = extra ?? [];
272
- const disabledIds = disabled ?? [];
273
- const ids = [];
269
+ const footerIds = (hidden, extra, backId) => {
270
+ const ids = backId ? [backId] : [];
271
+ for (const add of extra) {
272
+ if (!ids.includes(add)) ids.push(add);
273
+ }
274
274
  for (const id of ACTION_IDS) {
275
- if (id === 'quit') {
276
- for (const add of extraIds) {
277
- if (!ids.includes(add)) ids.push(add);
278
- }
279
- continue;
280
- }
281
- if (hiddenIds.includes(id)) continue;
275
+ if (id === 'quit' || hidden.includes(id) || ids.includes(id)) continue;
282
276
  ids.push(id);
283
277
  }
278
+ return ids;
279
+ };
280
+
281
+ const layoutButtons = (width, options = {}) => {
282
+ const { compact = false, hidden = [], extra = [], disabled = [] } = options;
283
+ const { backId = '', mark = BACK_ARROW } = options;
284
+ const ids = footerIds(hidden, extra, backId);
285
+ const limit = Math.max(0, width - BUTTON_EDGE);
284
286
  const build = (short) => {
285
287
  const hits = [];
286
288
  const parts = [];
287
289
  let x = 0;
288
- const limit = Math.max(0, width - BUTTON_EDGE);
289
290
  for (const id of ids) {
290
- const letter = actionLetter(id);
291
- const word = buttonWord(id);
292
- const label = short ? letter : word;
293
- const gap = buttonGap(parts.length);
294
- const piece = `${gap}${label}`;
291
+ const back = id === backId;
292
+ const letter = back ? BACK_KEY : actionLetter(id);
293
+ const label = back ? hintBody(mark) : buttonLabel(id, short);
294
+ const piece = `${buttonGap(parts.length)}${label}`;
295
295
  const w = visibleWidth(piece);
296
296
  if (x + w > limit && parts.length) break;
297
- const off = disabledIds.includes(id);
297
+ const off = disabled.includes(id);
298
298
  if (!off) hits.push({ id, x0: x, x1: x + w });
299
- parts.push({ action: { id }, piece, letter, label, disabled: off });
299
+ const hint = back ? mark : '';
300
+ parts.push({ id, piece, letter, label, disabled: off, back, mark: hint });
300
301
  x += w;
301
302
  }
302
- return { hits, parts, width: x };
303
+ return { hits, parts };
303
304
  };
304
305
  if (compact) return build(true);
305
306
  const words = build(false);
@@ -308,72 +309,55 @@ const layoutButtons = (width, compact, hidden, extra, disabled) => {
308
309
  };
309
310
 
310
311
  const paintButtons = (layout, width, color) => {
311
- let out = '';
312
- let used = 0;
313
312
  const fgRgb = THEME.buttonFg;
314
313
  const bgRgb = THEME.buttonBg;
314
+ let out = '';
315
+ let used = 0;
315
316
  for (let i = 0; i < layout.parts.length; i++) {
316
317
  const part = layout.parts[i];
317
- const gap = buttonGap(i);
318
- out += paint(gap, fgRgb, bgRgb, color);
319
- if (part.disabled) out += paint(part.label, fgRgb, bgRgb, color);
318
+ out += paint(buttonGap(i), fgRgb, bgRgb, color);
319
+ if (part.back) out += paintBackHint(color, part.mark);
320
+ else if (part.disabled) out += paint(part.label, fgRgb, bgRgb, color);
320
321
  else out += paintMarkedWord(part.label, part.letter, color, BUTTON_MARK);
321
322
  used += visibleWidth(part.piece);
322
323
  }
323
- if (used < width) {
324
- out += fill(width - used, THEME.buttonFg, THEME.buttonBg, color);
325
- }
326
- return out;
324
+ return out + fill(width - used, fgRgb, bgRgb, color);
327
325
  };
328
326
 
329
- const isInfoStatus = (status) => {
330
- if (INFO_STATUS.includes(status)) return true;
331
- for (const prefix of INFO_PREFIX) {
332
- if (status.startsWith(prefix)) return true;
333
- }
334
- return false;
335
- };
327
+ const isInfoStatus = (status) =>
328
+ INFO_STATUS.includes(status) ||
329
+ INFO_PREFIX.some((prefix) => status.startsWith(prefix));
336
330
 
337
- const joinStatus = (parts) => {
338
- const bits = [];
339
- for (const part of parts) {
340
- if (!part) continue;
341
- bits.push(part);
342
- }
343
- return bits.join(' ');
344
- };
331
+ const joinStatus = (parts) => parts.filter(Boolean).join(' ');
345
332
 
346
333
  const countsKind = (sourceKind, revShort) => {
347
334
  if (sourceKind === 'pr' || sourceKind === 'mr') return sourceKind;
348
- if (revShort) return 'commit';
349
- return 'worktree';
335
+ return revShort ? 'commit' : 'worktree';
350
336
  };
351
337
 
338
+ const isWorktree = (view) =>
339
+ countsKind(view.sourceKind, view.revShort) === 'worktree';
340
+
352
341
  const notesLine = (counts) => {
353
342
  const feedback = counts.feedback ?? 0;
354
- const todo = counts.todo ?? 0;
343
+ const tasks = counts.tasks ?? 0;
355
344
  const code = counts.code ?? 0;
356
- return `feedback ${feedback} todo ${todo} code ${code}`;
345
+ return `feedback ${feedback} tasks ${tasks} code ${code}`;
357
346
  };
358
347
 
359
348
  const statusLead = (view) => {
360
349
  const counts = view.counts ?? {};
361
350
  const kind = countsKind(view.sourceKind, view.revShort);
362
- if (kind === 'worktree') return branchLabel(view.branch ?? '');
351
+ if (kind === 'worktree') return branchLabel(view.branch);
363
352
  if (kind === 'commit') {
364
- const n = counts.commit ?? 0;
365
- return `commit ${view.revShort} ${n}`;
353
+ return `commit ${view.revShort} ${counts.commit ?? 0}`;
366
354
  }
367
- const n = counts.pr ?? 0;
368
355
  const label = view.sourceLabel || kind;
369
- return `${kind} ${label} ${n}`;
356
+ return `${kind} ${label} ${counts.pr ?? 0}`;
370
357
  };
371
358
 
372
- const statusLeft = (view) => {
373
- const body = joinStatus([statusLead(view), notesLine(view.counts ?? {})]);
374
- if (!body) return '';
375
- return ` ${body}`;
376
- };
359
+ const statusLeft = (view) =>
360
+ ` ${joinStatus([statusLead(view), notesLine(view.counts ?? {})])}`;
377
361
 
378
362
  const statusDelta = (total) => {
379
363
  const stagedAdd = total.stagedAdded ?? 0;
@@ -385,28 +369,24 @@ const statusDelta = (total) => {
385
369
  if (!hasDelta && !total.removed) return '';
386
370
  const plus = `+${stagedAdd}/${stagedAdd + unstagedAdd}`;
387
371
  const minus = `-${stagedDel}/${stagedDel + unstagedDel}`;
388
- return joinStatus([plus, minus]);
372
+ return `${plus} ${minus}`;
389
373
  };
390
374
 
391
375
  const statusStats = (view) => {
392
376
  const counts = view.counts ?? {};
377
+ const staged = counts.staged ?? 0;
393
378
  const total = fileTotals(view.files ?? []);
394
- const counted = (counts.staged ?? 0) + (counts.unstaged ?? 0);
395
379
  const hasGit =
396
380
  total.remaining || total.staged || total.added || total.removed;
397
- const fallbackN = counts.staged ?? 0;
398
- const n = hasGit ? total.staged : fallbackN;
399
- const m = hasGit ? total.remaining : counted;
400
- const git = joinStatus([statusDelta(total), `${n}/${m}`]);
401
- if (!view.npmLogs) return git;
402
- return joinStatus([view.npmLogs, git]);
381
+ const n = hasGit ? total.staged : staged;
382
+ const m = hasGit ? total.remaining : staged + (counts.unstaged ?? 0);
383
+ return joinStatus([view.npmLogs, statusDelta(total), `${n}/${m}`]);
403
384
  };
404
385
 
405
386
  const statusLayout = (view, status, width) => {
406
- const shown = formatBusyStatus(status, view.progressFrame ?? 0);
407
- const msg = shown;
408
- let msgFg = THEME.chromeFg;
409
- if (msg && !isInfoStatus(status)) msgFg = THEME.errorFg;
387
+ const msg = formatBusyStatus(status, view.progressFrame ?? 0);
388
+ const isError = msg && !isInfoStatus(status);
389
+ const msgFg = isError ? THEME.errorFg : THEME.chromeFg;
410
390
  const stats = statusStats(view);
411
391
  const right = stats ? `${stats} ` : '';
412
392
  const rightW = visibleWidth(right);
@@ -422,60 +402,36 @@ const statusLayout = (view, status, width) => {
422
402
  };
423
403
 
424
404
  const choiceHits = (choices, prefix, y) => {
425
- const { length } = choices;
426
- const hits = new Array(length);
427
405
  let x = visibleWidth(prefix);
428
- for (let i = 0; i < length; i++) {
429
- const choice = choices[i];
430
- const piece = `${choice.lead}${choice.label}`;
431
- const w = visibleWidth(piece);
432
- hits[i] = { id: choice.letter, y, x0: x, x1: x + w };
406
+ return choices.map((choice) => {
407
+ const w = visibleWidth(`${choice.lead}${choice.label}`);
408
+ const hit = { id: choice.letter, y, x0: x, x1: x + w };
433
409
  x += w;
434
- }
435
- return hits;
410
+ return hit;
411
+ });
436
412
  };
437
413
 
438
- const paintChoicePrompt = (choices, width, color, prefix = '') => {
414
+ const paintChoicePrompt = (choices, width, color, prefix) => {
439
415
  const bgRgb = THEME.chromeBg;
440
- const labels = choices
441
- .map((choice) => `${choice.lead}${choice.label}`)
442
- .join('');
443
- const body = `${prefix}${labels}`;
444
- if (!color) {
445
- const pad = Math.max(0, width - visibleWidth(body));
446
- return body + ' '.repeat(pad);
447
- }
448
- let out = '';
449
- if (prefix) out += paint(prefix, THEME.mutedFg, bgRgb, color);
416
+ const body = `${prefix}${promptFromChoices(choices)}`;
417
+ const pad = Math.max(0, width - visibleWidth(body));
418
+ if (!color) return body + ' '.repeat(pad);
419
+ let out = paint(prefix, THEME.mutedFg, bgRgb, color);
450
420
  for (const choice of choices) {
451
421
  const label = `${choice.lead}${choice.label}`;
452
422
  out += paintMarkedWord(label, choice.letter, color, QUIT_MARK);
453
423
  }
454
- const pad = Math.max(0, width - visibleWidth(body));
455
424
  return out + fill(pad, THEME.mutedFg, bgRgb, color);
456
425
  };
457
426
 
458
- const STATUS_MARK = /(\+[0-9]+(?:\/[0-9]+)?|-[0-9]+(?:\/[0-9]+)?)/g;
459
- const OLD_LOGS = /old logs \S+/;
460
-
461
- const paintStatusMark = (token, color) => {
462
- const bgRgb = THEME.chromeBg;
463
- if (token.startsWith('+')) {
464
- return paint(token, THEME.addLineFg, bgRgb, color);
465
- }
466
- return paint(token, THEME.delLineFg, bgRgb, color);
467
- };
468
-
469
427
  const paintStatusLeft = (text, color) => {
470
- const bgRgb = THEME.chromeBg;
471
- const muted = THEME.mutedFg;
472
428
  if (!color) return text;
429
+ const bgRgb = THEME.chromeBg;
473
430
  const found = text.match(OLD_LOGS);
474
431
  if (found) {
475
- const at = found.index;
476
432
  const token = found[0];
477
- const before = text.slice(0, at);
478
- const after = text.slice(at + token.length);
433
+ const before = text.slice(0, found.index);
434
+ const after = text.slice(found.index + token.length);
479
435
  return (
480
436
  paintStatusLeft(before, color) +
481
437
  paint(token, THEME.shaFg, bgRgb, color) +
@@ -486,24 +442,18 @@ const paintStatusLeft = (text, color) => {
486
442
  let last = 0;
487
443
  for (const match of text.matchAll(STATUS_MARK)) {
488
444
  const token = match[0];
489
- const start = match.index;
490
- if (start > last) {
491
- out += paint(text.slice(last, start), muted, bgRgb, color);
492
- }
493
- out += paintStatusMark(token, color);
494
- last = start + token.length;
445
+ const tokenFg = token.startsWith('+') ? THEME.addLineFg : THEME.delLineFg;
446
+ out += paint(text.slice(last, match.index), THEME.mutedFg, bgRgb, color);
447
+ out += paint(token, tokenFg, bgRgb, color);
448
+ last = match.index + token.length;
495
449
  }
496
- if (last < text.length) {
497
- out += paint(text.slice(last), muted, bgRgb, color);
498
- }
499
- return out;
450
+ return out + paint(text.slice(last), THEME.mutedFg, bgRgb, color);
500
451
  };
501
452
 
502
453
  const paintBranchHead = (text, branch, color) => {
503
454
  const name = branchLabel(branch);
504
455
  if (!name || !text.startsWith(' ')) return null;
505
- const head = ` ${name}`;
506
- const shown = text.startsWith(head) ? name : text.slice(1);
456
+ const shown = text.startsWith(` ${name}`) ? name : text.slice(1);
507
457
  if (!shown || !name.startsWith(shown)) return null;
508
458
  const rest = text.slice(1 + shown.length);
509
459
  const bgRgb = THEME.chromeBg;
@@ -514,40 +464,23 @@ const paintBranchHead = (text, branch, color) => {
514
464
  );
515
465
  };
516
466
 
517
- const SAVE_HINT = 'Press **Ctrl-S** or **Enter** at EOF to save';
518
- const HINT_HOT_FG = 0xff;
519
-
520
- const hintParts = (source) => {
521
- const bits = source.split('**');
522
- const parts = [];
523
- for (let i = 0; i < bits.length; i++) {
524
- if (!bits[i]) continue;
525
- parts.push({ text: bits[i], hot: i % 2 === 1 });
526
- }
527
- return parts;
528
- };
529
-
530
- const saveHintText = () => SAVE_HINT.replaceAll('**', '');
531
-
532
- const commitSaveHint = (view) => {
533
- if (view.mode !== 'compose' || view.commitView !== 'full') return false;
534
- const compose = view.compose;
535
- return Boolean(compose && compose.kind === 'commit');
536
- };
467
+ const commitSaveHint = (view) =>
468
+ view.mode === 'compose' &&
469
+ view.commitView === 'full' &&
470
+ view.compose?.kind === 'commit';
537
471
 
538
472
  const paintSaveHintBar = (width, color) => {
539
473
  const bgRgb = THEME.buttonBg;
540
- const plain = `${BUTTON_LEAD}${saveHintText()}`;
541
- if (!color) {
542
- const pad = Math.max(0, width - visibleWidth(plain));
543
- return plain + ' '.repeat(pad);
544
- }
474
+ const plain = `${BUTTON_LEAD}${SAVE_HINT.replaceAll('**', '')}`;
475
+ const pad = Math.max(0, width - visibleWidth(plain));
476
+ if (!color) return plain + ' '.repeat(pad);
545
477
  let out = paint(BUTTON_LEAD, THEME.buttonFg, bgRgb, color);
546
- for (const part of hintParts(SAVE_HINT)) {
547
- const tone = part.hot ? HINT_HOT_FG : THEME.buttonFg;
548
- out += paint(part.text, tone, bgRgb, color, part.hot);
478
+ const bits = SAVE_HINT.split('**');
479
+ for (let i = 0; i < bits.length; i++) {
480
+ const hot = i % 2 === 1;
481
+ const tone = hot ? HINT_HOT_FG : THEME.buttonFg;
482
+ out += paint(bits[i], tone, bgRgb, color, hot);
549
483
  }
550
- const pad = Math.max(0, width - visibleWidth(plain));
551
484
  return out + fill(pad, THEME.buttonFg, bgRgb, color);
552
485
  };
553
486
 
@@ -556,10 +489,7 @@ const paintFooter = (view, layout, width, color) => {
556
489
  return paintButtons(layout, width, color);
557
490
  };
558
491
 
559
- const footerHits = (view, layout) => {
560
- if (commitSaveHint(view)) return [];
561
- return layout.hits;
562
- };
492
+ const footerHits = (view, layout) => (commitSaveHint(view) ? [] : layout.hits);
563
493
 
564
494
  const paintStatusLine = (view, status, width, color) => {
565
495
  const confirm = CONFIRM[view.mode];
@@ -574,8 +504,7 @@ const paintStatusLine = (view, status, width, color) => {
574
504
  return layout.clipped + leftGap + layout.msg + rightGap + layout.right;
575
505
  }
576
506
  const bgRgb = THEME.chromeBg;
577
- const worktree = countsKind(view.sourceKind, view.revShort) === 'worktree';
578
- const head = worktree
507
+ const head = isWorktree(view)
579
508
  ? paintBranchHead(layout.clipped, view.branch, color)
580
509
  : null;
581
510
  const left = head ?? paintStatusLeft(layout.clipped, color);
@@ -591,97 +520,109 @@ const paintStatusLine = (view, status, width, color) => {
591
520
  const statusChoiceHits = (view, y) => {
592
521
  const spec = CONFIRM[view.mode];
593
522
  if (!spec) return [];
594
- const prefix = confirmPrefix(spec, view);
595
- return choiceHits(spec.choices, prefix, y);
523
+ return choiceHits(spec.choices, confirmPrefix(spec, view), y);
596
524
  };
597
525
 
598
526
  const statusBranchHit = (view, width, y) => {
599
- const mode = view.mode;
600
- if (CONFIRM[mode]) return null;
601
- const branch = view.branch ?? '';
602
- if (!branchLabel(branch)) return null;
603
- if (countsKind(view.sourceKind, view.revShort) !== 'worktree') return null;
527
+ if (CONFIRM[view.mode]) return null;
528
+ const name = branchLabel(view.branch);
529
+ if (!name || !isWorktree(view)) return null;
604
530
  const layout = statusLayout(view, view.status ?? '', width);
605
- const name = branchLabel(branch);
606
531
  const at = layout.clipped.indexOf(name);
607
532
  if (at < 0) return null;
608
533
  return { id: 'branch', y, x0: at, x1: at + visibleWidth(name) };
609
534
  };
610
535
 
611
- const selectedBranch = (view) => (view.branches ?? [])[view.branchCursor ?? 0];
612
-
613
- const selectedCommit = (view) => (view.commits ?? [])[view.commitCursor ?? 0];
536
+ const withHidden = (ids, more) => [...ids, ...more];
614
537
 
615
538
  const hiddenActions = (view) => {
616
- const pane = view.pane ?? 'diff';
539
+ const { pane } = view;
540
+ if (pane === 'dashboard') return DASH_HIDDEN;
617
541
  if (pane === 'files') return FILES_HIDDEN;
618
- if (pane === 'unit') return UNIT_DISABLED;
542
+ if (pane === 'unit') return withHidden(UNIT_DISABLED, ['feedback', 'tasks']);
619
543
  if (pane === 'branches') return BRANCHES_DISABLED;
620
544
  if (pane === 'commits') return COMMITS_DISABLED;
621
545
  if (pane === 'npm') return NPM_DISABLED;
622
- if (view.item && view.item.origin === 'todo') return TODO_DISABLED;
623
- return DIFF_DISABLED;
546
+ if (pane === 'tasks' || isTaskView(view)) {
547
+ return withHidden(TASKS_DISABLED, ['prev', 'next']);
548
+ }
549
+ return withHidden(DIFF_DISABLED, ['feedback', 'tasks']);
624
550
  };
625
551
 
626
552
  const dimFooterActions = (view) => {
627
553
  const pane = view.pane ?? 'diff';
628
554
  if (pane === 'files') {
629
- const files = view.files ?? [];
630
- const entry = files[view.fileCursor ?? 0];
631
- if (!entry || entry.kind !== 'todos') return [];
632
- return FILES_GIT_DISABLED;
555
+ return isTasksEntry(selectedFile(view)) ? FILES_GIT_DISABLED : [];
633
556
  }
634
557
  if (pane === 'diff' || pane === 'unit') return diffGitDisabled(view.item);
635
558
  if (pane === 'branches') return branchGitDisabled(selectedBranch(view));
636
559
  if (pane === 'commits') {
637
- const entry = selectedCommit(view);
638
- const staged = (view.counts?.staged ?? 0) > 0;
639
- const unstagedCurrent = !view.rev && !view.revShort;
640
560
  return commitGitDisabled({
641
- ...entry,
642
- canCommit: staged,
643
- unstagedCurrent,
561
+ ...selectedCommit(view),
562
+ canCommit: (view.counts?.staged ?? 0) > 0,
563
+ unstagedCurrent: !view.rev && !view.revShort,
644
564
  });
645
565
  }
646
- if (pane === 'npm' && view.npmView) {
647
- if (view.npmRunning) return ['npmRerun'];
648
- return [];
649
- }
650
- if (pane === 'npm' && !view.npmLogs) return ['npmLogs'];
651
- return [];
566
+ if (pane !== 'npm') return [];
567
+ if (view.npmView) return view.npmRunning ? ['npmRerun'] : [];
568
+ return view.npmLogs ? [] : ['npmLogs'];
652
569
  };
653
570
 
571
+ const navExcept = (skip) => DASH_IDS.filter((id) => !skip.includes(id));
572
+
654
573
  const extraFooterActions = (view) => {
655
- const pane = view.pane ?? 'diff';
656
- if (pane === 'files') {
657
- const scope = view.fileScope === 'file' ? 'diff' : 'file';
658
- return ['npm', scope, 'commit', 'pull', 'push'];
574
+ const { pane } = view;
575
+ if (pane === 'dashboard') return DASH_IDS;
576
+ if (pane === 'branches') {
577
+ return [
578
+ ...navExcept(['dashNpm']),
579
+ 'newBranch',
580
+ 'rebase',
581
+ 'drop',
582
+ 'pull',
583
+ 'push',
584
+ ];
585
+ }
586
+ if (pane === 'commits') {
587
+ return [
588
+ ...navExcept(['dashCommits']),
589
+ 'commit',
590
+ 'amend',
591
+ 'apply',
592
+ 'reword',
593
+ 'fixup',
594
+ 'drop',
595
+ 'view',
596
+ ];
659
597
  }
660
- if (pane === 'branches') return Object.keys(BRANCH_ACTIONS);
661
- if (pane === 'commits') return Object.keys(COMMIT_ACTIONS);
662
598
  if (pane === 'npm') {
663
- if (view.npmView) return ['npmStop', 'npmRerun'];
664
- return ['npmEdit', 'npmNew', 'npmDrop', 'npmLogs'];
599
+ if (view.npmView) {
600
+ return [...navExcept(['dashRun']), 'npmVerbose', 'npmRerun'];
601
+ }
602
+ return [...DASH_IDS, 'npmEdit', 'npmNew', 'npmDrop', 'npmLogs'];
665
603
  }
666
- return [];
604
+ if (pane === 'tasks' || isTaskView(view)) return [...DASH_IDS, 'drop'];
605
+ return navExcept(['dashDiffs']);
667
606
  };
668
607
 
608
+ const footerLayout = (view, width) =>
609
+ layoutButtons(width, {
610
+ hidden: hiddenActions(view),
611
+ extra: extraFooterActions(view),
612
+ disabled: dimFooterActions(view),
613
+ backId: backFooterId(view),
614
+ mark: backHintMark(view),
615
+ });
616
+
669
617
  module.exports = {
670
- QUIT_PROMPT,
671
- PUSH_PROMPT,
672
- updatePrompt,
673
- dropPrompt,
674
618
  formatBusyStatus,
675
619
  headerText,
676
620
  paintHeader,
677
621
  layoutButtons,
678
- paintButtons,
622
+ footerLayout,
679
623
  paintFooter,
680
624
  footerHits,
681
625
  paintStatusLine,
682
626
  statusChoiceHits,
683
627
  statusBranchHit,
684
- hiddenActions,
685
- dimFooterActions,
686
- extraFooterActions,
687
628
  };