reslop 0.2.1 → 0.2.4

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 (81) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +19 -4
  3. package/bin/reslop.js +4 -2
  4. package/lib/ansi.js +120 -4
  5. package/lib/api.js +2 -1
  6. package/lib/cli.js +21 -9
  7. package/lib/clipboard.js +6 -3
  8. package/lib/deps-diff.js +61 -115
  9. package/lib/deps.js +57 -54
  10. package/lib/detect.js +4 -2
  11. package/lib/diff/align.js +2 -1
  12. package/lib/diff/diff.js +6 -3
  13. package/lib/diff/display.js +2 -1
  14. package/lib/diff/inline.js +2 -1
  15. package/lib/diff/patch.js +2 -1
  16. package/lib/diff/unit.js +4 -2
  17. package/lib/editor.js +22 -4
  18. package/lib/files.js +38 -12
  19. package/lib/git-branches.js +150 -165
  20. package/lib/git-deps.js +20 -60
  21. package/lib/git-worktree.js +508 -0
  22. package/lib/git.js +23 -580
  23. package/lib/github.js +2 -1
  24. package/lib/gitlab.js +2 -1
  25. package/lib/highlight/bash.js +2 -1
  26. package/lib/highlight/css.js +2 -1
  27. package/lib/highlight/csv.js +2 -1
  28. package/lib/highlight/dot.js +2 -1
  29. package/lib/highlight/html.js +2 -1
  30. package/lib/highlight/json.js +2 -1
  31. package/lib/highlight/log.js +2 -1
  32. package/lib/highlight/txt.js +2 -1
  33. package/lib/highlight.js +2 -1
  34. package/lib/keys.js +12 -6
  35. package/lib/manifest.js +4 -2
  36. package/lib/npm-commands.js +592 -0
  37. package/lib/remote.js +20 -13
  38. package/lib/render/branches.js +25 -41
  39. package/lib/render/chrome.js +163 -128
  40. package/lib/render/commits.js +727 -65
  41. package/lib/render/diff.js +130 -47
  42. package/lib/render/files.js +4 -12
  43. package/lib/render/frame.js +41 -13
  44. package/lib/render/notes.js +4 -11
  45. package/lib/render/npm.js +216 -0
  46. package/lib/render/present.js +2 -1
  47. package/lib/render/primitives.js +34 -50
  48. package/lib/render/render.js +2 -1
  49. package/lib/render/unit.js +14 -11
  50. package/lib/report-model.js +129 -0
  51. package/lib/report-parse.js +89 -0
  52. package/lib/report-render.js +104 -0
  53. package/lib/report-run.js +166 -0
  54. package/lib/report-test.js +507 -0
  55. package/lib/report-tools.js +337 -0
  56. package/lib/review-merge.js +2 -1
  57. package/lib/review.js +6 -2
  58. package/lib/session/actions.js +77 -44
  59. package/lib/session/branches.js +13 -49
  60. package/lib/session/change-actions.js +2 -1
  61. package/lib/session/commit.js +113 -53
  62. package/lib/session/compose.js +83 -14
  63. package/lib/session/dispatch.js +42 -22
  64. package/lib/session/files-pane.js +16 -21
  65. package/lib/session/items.js +6 -3
  66. package/lib/session/lifecycle.js +12 -5
  67. package/lib/session/load.js +4 -2
  68. package/lib/session/navigation.js +2 -0
  69. package/lib/session/npm.js +388 -0
  70. package/lib/session/ops.js +15 -1
  71. package/lib/session/pointer.js +30 -3
  72. package/lib/session/review.js +2 -1
  73. package/lib/session/terminal.js +4 -2
  74. package/lib/session/update.js +2 -1
  75. package/lib/session/view.js +41 -4
  76. package/lib/session.js +44 -22
  77. package/lib/source.js +6 -3
  78. package/lib/update.js +5 -9
  79. package/lib/utilities.js +4 -2
  80. package/lib/wrap.js +3 -1
  81. package/package.json +6 -6
package/lib/remote.js CHANGED
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
2
 
3
- const { delay, jsonParse, isHashObject } = require('metautil');
4
- const { oneLine } = require('./utilities.js');
5
- const { itemPath, isPathInScope } = require('./files.js');
6
- const { parseDiff, itemsFromFiles, isCtxType } = require('./diff/diff.js');
7
- const { foldDepItems } = require('./deps.js');
3
+ const metautil = require('metautil');
4
+ const { delay, jsonParse, isHashObject } = metautil;
5
+ const utilities = require('./utilities.js');
6
+ const { oneLine } = utilities;
7
+ const files = require('./files.js');
8
+ const { itemPath, isPathInScope } = files;
9
+ const diff = require('./diff/diff.js');
10
+ const { parseDiff, itemsFromFiles, isCtxType } = diff;
11
+ const deps = require('./deps.js');
12
+ const { foldDepItems } = deps;
8
13
 
9
14
  const RETRY_ATTEMPTS = 3;
10
15
  const RETRY_DELAY_MS = 200;
@@ -323,13 +328,15 @@ const changeFiles = (items) => {
323
328
 
324
329
  const loadChange = async (ref, options, source) => {
325
330
  const { client, url, diffUrl = url, diffAccept } = source;
326
- const paths = options.paths ?? [];
327
- const request = {
328
- fetch: options.fetch ?? globalThis.fetch,
329
- token: options.token ?? '',
330
- retry: options.retry ?? {},
331
- signal: options.signal,
332
- };
331
+ const {
332
+ cwd,
333
+ paths = [],
334
+ fetch = globalThis.fetch,
335
+ token = '',
336
+ retry = {},
337
+ signal,
338
+ } = options;
339
+ const request = { fetch, token, retry, signal };
333
340
  const jsonPage = await client.request(url, request);
334
341
  const data = client.parseJson(jsonPage.body, `${client.subject} payload`);
335
342
  const diffPage = await client.request(diffUrl, {
@@ -348,7 +355,7 @@ const loadChange = async (ref, options, source) => {
348
355
  if (isAbortError(error, request.signal)) throw error;
349
356
  // Discussion import is optional.
350
357
  }
351
- return { top: options.cwd, items, sourceLabel, change, imported };
358
+ return { top: cwd, items, sourceLabel, change, imported };
352
359
  };
353
360
 
354
361
  module.exports = {
@@ -1,13 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  const ansi = require('../ansi.js');
4
+ const files = require('../files.js');
4
5
  const primitives = require('./primitives.js');
6
+ const { shortAge } = files;
5
7
  const { measureColumns, padColumns, joinColumns, paintColumns } = primitives;
6
8
 
7
9
  const { THEME, paint, visibleWidth, truncateVisible, seq, EL } = ansi;
8
- const { fill, FILE_MARK, paneResult } = primitives;
10
+ const { FILE_MARK } = primitives;
9
11
  const { paintCursorList, paintListEdit } = primitives;
10
- const { branchLabel, paintBracketName } = primitives;
12
+ const { branchLabel } = primitives;
11
13
 
12
14
  const BRANCH_COL_ALIGN = {
13
15
  sha: 'end',
@@ -31,13 +33,11 @@ const BRANCH_CURRENT_COL_FG = {
31
33
  };
32
34
 
33
35
  const BRANCH_NAME_MAX = 24;
34
- const BRANCH_EDGE = 2;
35
36
  const BRANCH_TAIL = 1;
36
37
  const branchNameChip = (entry, rowBg) => {
37
38
  if (entry.current) {
38
39
  return {
39
40
  nameFg: THEME.mutedFg,
40
- wrapFg: THEME.mutedFg,
41
41
  nameBg: THEME.currentBg,
42
42
  nameBold: false,
43
43
  };
@@ -45,14 +45,12 @@ const branchNameChip = (entry, rowBg) => {
45
45
  if (entry.isDefault) {
46
46
  return {
47
47
  nameFg: THEME.warnFg,
48
- wrapFg: THEME.mutedFg,
49
48
  nameBg: rowBg,
50
49
  nameBold: true,
51
50
  };
52
51
  }
53
52
  return {
54
53
  nameFg: THEME.buttonHotFg,
55
- wrapFg: THEME.mutedFg,
56
54
  nameBg: rowBg,
57
55
  nameBold: false,
58
56
  };
@@ -65,15 +63,21 @@ const branchRowFg = (entry, selected) => {
65
63
 
66
64
  const branchRowFields = (entry) => {
67
65
  const sha = entry.sha ?? '';
68
- const date = entry.date ?? '';
66
+ const date = shortAge(entry.date ?? '');
69
67
  const gone = entry.gone ? 'gone' : '';
70
68
  const ahead = entry.ahead ? `⇡${entry.ahead}` : '';
71
69
  const behind = entry.behind ? `⇣${entry.behind}` : '';
72
70
  return { sha, ahead, behind, gone, date };
73
71
  };
74
72
 
73
+ const branchRowBg = (entry, selected) => {
74
+ if (entry.current) return THEME.currentBg;
75
+ if (selected) return THEME.buttonBg;
76
+ return THEME.ctxBg;
77
+ };
78
+
75
79
  const paintBranchRow = (entry, width, color, selected, cols) => {
76
- const inner = Math.max(1, width - BRANCH_EDGE - BRANCH_TAIL);
80
+ const inner = Math.max(1, width - BRANCH_TAIL);
77
81
  const mark = selected ? FILE_MARK : ' ';
78
82
  const prefix = ` ${mark} `;
79
83
  const parts = padColumns(branchRowFields(entry), cols, BRANCH_COL_ALIGN);
@@ -91,35 +95,23 @@ const paintBranchRow = (entry, width, color, selected, cols) => {
91
95
  const after = `${' '.repeat(namePad)}${gap}${subject}${' '.repeat(pad)}`;
92
96
  const left = `${prefix}${label}${after}`;
93
97
  const fgRgb = branchRowFg(entry, selected);
94
- const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
95
- const chip = branchNameChip(entry, bgRgb);
96
- const markFg = entry.current ? THEME.headerFg : fgRgb;
97
- const markBg = entry.current ? THEME.currentBg : bgRgb;
98
+ const rowBg = branchRowBg(entry, selected);
99
+ const chip = branchNameChip(entry, rowBg);
100
+ const toneFg = entry.current ? THEME.headerFg : fgRgb;
98
101
  const tail = ' '.repeat(BRANCH_TAIL);
99
- const edge = fill(BRANCH_EDGE, fgRgb, THEME.ctxBg, color);
100
- if (!color) return `${left}${suffix}${tail}${edge}`;
101
- let out = `${seq(fgRgb, bgRgb)}${EL}`;
102
- out += paint(prefix, fgRgb, bgRgb, color);
103
- out += paintBracketName(
104
- label,
105
- chip.nameFg,
106
- chip.wrapFg,
107
- chip.nameBg,
108
- color,
109
- chip.nameBold,
110
- );
111
- out += paint(after, markFg, markBg, color);
102
+ if (!color) return `${left}${suffix}${tail}`;
103
+ let out = `${seq(toneFg, rowBg)}${EL}`;
104
+ out += paint(prefix, toneFg, rowBg, color);
105
+ out += paint(label, chip.nameFg, chip.nameBg, color, chip.nameBold);
106
+ out += paint(after, toneFg, rowBg, color);
112
107
  const tones = entry.current
113
108
  ? { ...BRANCH_COL_FG, ...BRANCH_CURRENT_COL_FG }
114
109
  : BRANCH_COL_FG;
115
- out += paintColumns(parts, cols, markFg, markBg, color, tones);
116
- out += paint(tail, markFg, markBg, color);
117
- return out + edge;
110
+ out += paintColumns(parts, cols, toneFg, rowBg, color, tones);
111
+ out += paint(tail, toneFg, rowBg, color);
112
+ return out;
118
113
  };
119
114
 
120
- const paintBranchEdit = (compose, width, color) =>
121
- paintListEdit(compose, width, color, BRANCH_EDGE);
122
-
123
115
  const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
124
116
  const branches = view.branches ?? [];
125
117
  const cols = measureColumns(branches, branchRowFields, BRANCH_COL_ALIGN);
@@ -131,10 +123,10 @@ const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
131
123
  const cursor = view.branchCursor ?? 0;
132
124
  const compose = view.compose;
133
125
  const editing = compose && compose.kind === 'branch';
134
- const tail = editing ? paintBranchEdit(compose, width, color) : null;
126
+ const tail = editing ? paintListEdit(compose, width, color) : null;
135
127
  const paintRow = (entry, rowWidth, rowColor, selected) =>
136
128
  paintBranchRow(entry, rowWidth, rowColor, selected, cols);
137
- const painted = paintCursorList(
129
+ return paintCursorList(
138
130
  branches,
139
131
  cursor,
140
132
  width,
@@ -145,14 +137,6 @@ const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
145
137
  tail,
146
138
  view.listScroll,
147
139
  );
148
- return paneResult({
149
- body: painted.body,
150
- fileHits: painted.fileHits,
151
- todoOwners: [],
152
- cursor: painted.cursor,
153
- splitBody: false,
154
- listScroll: painted.offset,
155
- });
156
140
  };
157
141
 
158
142
  module.exports = { paintBodyBranches };
@@ -5,26 +5,38 @@ const keys = require('../keys.js');
5
5
  const files = require('../files.js');
6
6
  const primitives = require('./primitives.js');
7
7
 
8
- const { itemPath, TODO_FILE, isTodosEntry, fileTotals } = files;
8
+ const { itemPath, REPO_TODOS_LABEL, isTodosEntry, fileTotals } = files;
9
9
  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,6 +46,7 @@ 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',
@@ -105,21 +118,12 @@ const dropPrompt = (name) => ` drop ${name}? y/n`;
105
118
  const PUSH_REJECT_PREFIX = CONFIRM.confirmPush.prefix;
106
119
  const PUSH_PROMPT = `${PUSH_REJECT_PREFIX} force push`;
107
120
 
121
+ const BUTTON_LEAD = ' ';
108
122
  const BUTTON_GAP = ' ';
123
+ const BUTTON_EDGE = 1;
124
+ const buttonGap = (index) => (index ? BUTTON_GAP : BUTTON_LEAD);
109
125
  const LOGO = '👁️';
110
126
 
111
- const ORIGIN_LABEL = {
112
- commit: (view) => view.revShort || 'commit',
113
- pr: (view) => view.sourceLabel || 'pr',
114
- todo: () => 'todo',
115
- };
116
-
117
- const originLabel = (origin, view) => {
118
- const named = ORIGIN_LABEL[origin];
119
- if (named) return named(view);
120
- return origin ?? 'unknown';
121
- };
122
-
123
127
  const pathSegments = (rel) => {
124
128
  const segs = [];
125
129
  if (!rel) return segs;
@@ -137,126 +141,59 @@ const pathSegments = (rel) => {
137
141
  return segs;
138
142
  };
139
143
 
140
- const headerTarget = (view) => {
141
- const pane = view.pane ?? 'diff';
142
- if (pane === 'files') {
143
- const files = view.files ?? [];
144
- const cursor = view.fileCursor ?? 0;
145
- const entry = files[cursor];
146
- const total = files.length;
147
- const n = total ? cursor + 1 : 0;
148
- if (!entry) return { path: '', origin: '', n, total };
149
- if (isTodosEntry(entry)) {
150
- return { path: TODO_FILE, origin: 'todo', n, total };
151
- }
152
- return { path: entry.path, origin: entry.status, n, total };
153
- }
154
- if (pane === 'branches') {
155
- const compose = view.compose;
156
- const branches = view.branches ?? [];
157
- if (compose && compose.kind === 'branch') {
158
- const total = branches.length + 1;
159
- return { path: compose.text || '', origin: 'new', n: total, total };
160
- }
161
- const cursor = view.branchCursor ?? 0;
162
- const entry = branches[cursor];
163
- const total = branches.length;
164
- const n = total ? cursor + 1 : 0;
165
- if (!entry) return { path: '', origin: 'branch', n, total };
166
- const origin = entry.current ? 'current' : 'branch';
167
- return { path: entry.name, origin, n, total };
168
- }
169
- if (pane === 'commits') {
170
- const compose = view.compose;
171
- const commits = view.commits ?? [];
172
- if (compose && compose.kind === 'commit') {
173
- const path = `${compose.text || ''}`.replaceAll('\n', ' ');
174
- if (compose.commitKind === 'amend' || compose.commitKind === 'reword') {
175
- const cursor = view.commitCursor ?? 0;
176
- const entry = commits[cursor];
177
- const total = commits.length;
178
- const n = total ? cursor + 1 : 0;
179
- if (!entry) return { path, origin: 'commit', n, total };
180
- const origin = entry.head ? 'HEAD' : 'commit';
181
- return { path, origin, n, total };
182
- }
183
- const total = commits.length + 1;
184
- return { path, origin: 'new', n: 1, total };
185
- }
186
- const cursor = view.commitCursor ?? 0;
187
- const entry = commits[cursor];
188
- const total = commits.length;
189
- const n = total ? cursor + 1 : 0;
190
- if (!entry) return { path: '', origin: 'commit', n, total };
191
- const origin = entry.head ? 'HEAD' : 'commit';
192
- return { path: entry.subject ?? '', origin, n, total };
193
- }
194
- if (pane === 'unit') {
195
- const path = view.reviewPath ?? '';
196
- const lines = view.unitLines ?? [];
197
- const top = Math.max(0, (view.scroll ?? 0) - 1);
198
- const n = lines.length ? Math.min(lines.length, top + 1) : 0;
199
- const origin = view.item ? originLabel(view.item.origin, view) : 'unit';
200
- return { path, origin, n, total: lines.length };
201
- }
202
- if (!view.item) return { path: '', origin: '', n: 0, total: view.total ?? 0 };
203
- if (view.item.origin === 'todo') {
204
- return { path: TODO_FILE, origin: 'todo', n: 1, total: 1 };
205
- }
206
- return {
207
- path: itemPath(view.item),
208
- origin: originLabel(view.item.origin, view),
209
- n: view.index + 1,
210
- total: view.total,
211
- };
212
- };
213
-
214
144
  const headerScreen = (view) => {
215
145
  const pane = view.pane ?? 'diff';
216
146
  if (pane === 'branches') return 'branches';
217
- if (pane === 'commits') return 'commits';
147
+ if (pane === 'commits') {
148
+ const mode = view.commitView === 'full' ? 'full' : 'brief';
149
+ return `commits ${mode}`;
150
+ }
151
+ if (pane === 'npm') return 'npm';
218
152
  if (pane === 'files') {
219
153
  const files = view.files ?? [];
220
154
  const entry = files[view.fileCursor ?? 0];
221
- if (isTodosEntry(entry)) return TODO_FILE;
155
+ if (isTodosEntry(entry)) return REPO_TODOS_LABEL;
222
156
  return '';
223
157
  }
224
- if (view.item && view.item.origin === 'todo') return TODO_FILE;
158
+ if (view.item && view.item.origin === 'todo') return REPO_TODOS_LABEL;
225
159
  return '';
226
160
  };
227
161
 
228
- const headerLabel = (view, target) => headerScreen(view) || target.path;
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);
229
175
 
230
176
  const headerSep = (view) => (headerScreen(view) ? ': ' : '/');
231
177
 
232
- const headerPrefix = (view) => {
178
+ const headerPrefix = (view, label) => {
233
179
  const repo = view.repoName || '';
234
- const target = headerTarget(view);
235
- const label = headerLabel(view, target);
236
180
  if (repo && label) return ` ${LOGO} reslop: ${repo}${headerSep(view)}`;
237
181
  if (repo) return ` ${LOGO} reslop: ${repo} `;
238
182
  return ` ${LOGO} reslop: `;
239
183
  };
240
184
 
241
- const headerSuffix = ({ origin, n, total }) =>
242
- origin ? ` ${origin} ${n}/${total}` : ` ${n}/${total}`;
243
-
244
185
  const headerText = (view) => {
245
- const prefix = headerPrefix(view);
246
- const target = headerTarget(view);
247
- const label = headerLabel(view, target);
248
- return `${prefix}${label}${headerSuffix(target)}`;
186
+ const label = headerLabel(view);
187
+ return `${headerPrefix(view, label)}${label}`;
249
188
  };
250
189
 
251
190
  const headerPieces = (view, width) => {
252
191
  const repo = view.repoName || '';
253
- const target = headerTarget(view);
254
- const path = headerLabel(view, target);
255
- const suffix = headerSuffix(target);
192
+ const path = headerLabel(view);
256
193
  const right = ' ';
257
194
  const chrome = ` ${LOGO} reslop: `;
258
- const prefixW = visibleWidth(headerPrefix(view));
259
- const used = prefixW + visibleWidth(suffix) + visibleWidth(right);
195
+ const prefixW = visibleWidth(headerPrefix(view, path));
196
+ const used = prefixW + visibleWidth(right);
260
197
  const room = Math.max(0, width - used);
261
198
  const clipped = truncateVisible(path, room);
262
199
  const pad = Math.max(0, room - visibleWidth(clipped));
@@ -277,7 +214,6 @@ const headerPieces = (view, width) => {
277
214
  if (pad) {
278
215
  pieces.push({ text: ' '.repeat(pad), role: 'chrome' });
279
216
  }
280
- pieces.push({ text: suffix, role: 'meta' });
281
217
  pieces.push({ text: right, role: 'chrome' });
282
218
  return pieces;
283
219
  };
@@ -340,6 +276,7 @@ const layoutButtons = (width, compact, hidden, extra, disabled) => {
340
276
  for (const add of extraIds) {
341
277
  if (!ids.includes(add)) ids.push(add);
342
278
  }
279
+ continue;
343
280
  }
344
281
  if (hiddenIds.includes(id)) continue;
345
282
  ids.push(id);
@@ -348,13 +285,15 @@ const layoutButtons = (width, compact, hidden, extra, disabled) => {
348
285
  const hits = [];
349
286
  const parts = [];
350
287
  let x = 0;
288
+ const limit = Math.max(0, width - BUTTON_EDGE);
351
289
  for (const id of ids) {
352
290
  const letter = actionLetter(id);
353
291
  const word = buttonWord(id);
354
292
  const label = short ? letter : word;
355
- const piece = `${BUTTON_GAP}${label}`;
293
+ const gap = buttonGap(parts.length);
294
+ const piece = `${gap}${label}`;
356
295
  const w = visibleWidth(piece);
357
- if (x + w > width && parts.length) break;
296
+ if (x + w > limit && parts.length) break;
358
297
  const off = disabledIds.includes(id);
359
298
  if (!off) hits.push({ id, x0: x, x1: x + w });
360
299
  parts.push({ action: { id }, piece, letter, label, disabled: off });
@@ -373,8 +312,10 @@ const paintButtons = (layout, width, color) => {
373
312
  let used = 0;
374
313
  const fgRgb = THEME.buttonFg;
375
314
  const bgRgb = THEME.buttonBg;
376
- for (const part of layout.parts) {
377
- 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);
378
319
  if (part.disabled) out += paint(part.label, fgRgb, bgRgb, color);
379
320
  else out += paintMarkedWord(part.label, part.letter, color, BUTTON_MARK);
380
321
  used += visibleWidth(part.piece);
@@ -456,7 +397,9 @@ const statusStats = (view) => {
456
397
  const fallbackN = counts.staged ?? 0;
457
398
  const n = hasGit ? total.staged : fallbackN;
458
399
  const m = hasGit ? total.remaining : counted;
459
- return joinStatus([statusDelta(total), `${n}/${m}`]);
400
+ const git = joinStatus([statusDelta(total), `${n}/${m}`]);
401
+ if (!view.npmLogs) return git;
402
+ return joinStatus([view.npmLogs, git]);
460
403
  };
461
404
 
462
405
  const statusLayout = (view, status, width) => {
@@ -512,14 +455,11 @@ const paintChoicePrompt = (choices, width, color, prefix = '') => {
512
455
  return out + fill(pad, THEME.mutedFg, bgRgb, color);
513
456
  };
514
457
 
515
- const STATUS_MARK = /(\[[^\]]+\]|\+[0-9]+(?:\/[0-9]+)?|-[0-9]+(?:\/[0-9]+)?)/g;
458
+ const STATUS_MARK = /(\+[0-9]+(?:\/[0-9]+)?|-[0-9]+(?:\/[0-9]+)?)/g;
459
+ const OLD_LOGS = /old logs \S+/;
516
460
 
517
461
  const paintStatusMark = (token, color) => {
518
462
  const bgRgb = THEME.chromeBg;
519
- const muted = THEME.mutedFg;
520
- if (token.startsWith('[')) {
521
- return paintBracketName(token, THEME.buttonHotFg, muted, bgRgb, color);
522
- }
523
463
  if (token.startsWith('+')) {
524
464
  return paint(token, THEME.addLineFg, bgRgb, color);
525
465
  }
@@ -530,6 +470,18 @@ const paintStatusLeft = (text, color) => {
530
470
  const bgRgb = THEME.chromeBg;
531
471
  const muted = THEME.mutedFg;
532
472
  if (!color) return text;
473
+ const found = text.match(OLD_LOGS);
474
+ if (found) {
475
+ const at = found.index;
476
+ const token = found[0];
477
+ const before = text.slice(0, at);
478
+ const after = text.slice(at + token.length);
479
+ return (
480
+ paintStatusLeft(before, color) +
481
+ paint(token, THEME.shaFg, bgRgb, color) +
482
+ paintStatusLeft(after, color)
483
+ );
484
+ }
533
485
  let out = '';
534
486
  let last = 0;
535
487
  for (const match of text.matchAll(STATUS_MARK)) {
@@ -547,6 +499,68 @@ const paintStatusLeft = (text, color) => {
547
499
  return out;
548
500
  };
549
501
 
502
+ const paintBranchHead = (text, branch, color) => {
503
+ const name = branchLabel(branch);
504
+ if (!name || !text.startsWith(' ')) return null;
505
+ const head = ` ${name}`;
506
+ const shown = text.startsWith(head) ? name : text.slice(1);
507
+ if (!shown || !name.startsWith(shown)) return null;
508
+ const rest = text.slice(1 + shown.length);
509
+ const bgRgb = THEME.chromeBg;
510
+ return (
511
+ paint(' ', THEME.mutedFg, bgRgb, color) +
512
+ paint(shown, THEME.buttonHotFg, bgRgb, color, true) +
513
+ paintStatusLeft(rest, color)
514
+ );
515
+ };
516
+
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
+ };
537
+
538
+ const paintSaveHintBar = (width, color) => {
539
+ 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
+ }
545
+ 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);
549
+ }
550
+ const pad = Math.max(0, width - visibleWidth(plain));
551
+ return out + fill(pad, THEME.buttonFg, bgRgb, color);
552
+ };
553
+
554
+ const paintFooter = (view, layout, width, color) => {
555
+ if (commitSaveHint(view)) return paintSaveHintBar(width, color);
556
+ return paintButtons(layout, width, color);
557
+ };
558
+
559
+ const footerHits = (view, layout) => {
560
+ if (commitSaveHint(view)) return [];
561
+ return layout.hits;
562
+ };
563
+
550
564
  const paintStatusLine = (view, status, width, color) => {
551
565
  const confirm = CONFIRM[view.mode];
552
566
  if (confirm) {
@@ -560,8 +574,13 @@ const paintStatusLine = (view, status, width, color) => {
560
574
  return layout.clipped + leftGap + layout.msg + rightGap + layout.right;
561
575
  }
562
576
  const bgRgb = THEME.chromeBg;
577
+ const worktree = countsKind(view.sourceKind, view.revShort) === 'worktree';
578
+ const head = worktree
579
+ ? paintBranchHead(layout.clipped, view.branch, color)
580
+ : null;
581
+ const left = head ?? paintStatusLeft(layout.clipped, color);
563
582
  return (
564
- paintStatusLeft(layout.clipped, color) +
583
+ left +
565
584
  paint(leftGap, THEME.mutedFg, bgRgb, color) +
566
585
  paint(layout.msg, layout.msgFg, bgRgb, color) +
567
586
  paint(rightGap, THEME.mutedFg, bgRgb, color) +
@@ -583,10 +602,10 @@ const statusBranchHit = (view, width, y) => {
583
602
  if (!branchLabel(branch)) return null;
584
603
  if (countsKind(view.sourceKind, view.revShort) !== 'worktree') return null;
585
604
  const layout = statusLayout(view, view.status ?? '', width);
586
- const open = layout.clipped.indexOf('[');
587
- const close = layout.clipped.indexOf(']');
588
- if (open < 0 || close <= open) return null;
589
- return { id: 'branch', y, x0: open, x1: close + 1 };
605
+ const name = branchLabel(branch);
606
+ const at = layout.clipped.indexOf(name);
607
+ if (at < 0) return null;
608
+ return { id: 'branch', y, x0: at, x1: at + visibleWidth(name) };
590
609
  };
591
610
 
592
611
  const selectedBranch = (view) => (view.branches ?? [])[view.branchCursor ?? 0];
@@ -599,6 +618,7 @@ const hiddenActions = (view) => {
599
618
  if (pane === 'unit') return UNIT_DISABLED;
600
619
  if (pane === 'branches') return BRANCHES_DISABLED;
601
620
  if (pane === 'commits') return COMMITS_DISABLED;
621
+ if (pane === 'npm') return NPM_DISABLED;
602
622
  if (view.item && view.item.origin === 'todo') return TODO_DISABLED;
603
623
  return DIFF_DISABLED;
604
624
  };
@@ -616,9 +636,18 @@ const dimFooterActions = (view) => {
616
636
  if (pane === 'commits') {
617
637
  const entry = selectedCommit(view);
618
638
  const staged = (view.counts?.staged ?? 0) > 0;
619
- if (!entry) return commitGitDisabled({ canCommit: staged });
620
- return commitGitDisabled({ ...entry, canCommit: staged });
639
+ const unstagedCurrent = !view.rev && !view.revShort;
640
+ return commitGitDisabled({
641
+ ...entry,
642
+ canCommit: staged,
643
+ unstagedCurrent,
644
+ });
645
+ }
646
+ if (pane === 'npm' && view.npmView) {
647
+ if (view.npmRunning) return ['npmRerun'];
648
+ return [];
621
649
  }
650
+ if (pane === 'npm' && !view.npmLogs) return ['npmLogs'];
622
651
  return [];
623
652
  };
624
653
 
@@ -626,10 +655,14 @@ const extraFooterActions = (view) => {
626
655
  const pane = view.pane ?? 'diff';
627
656
  if (pane === 'files') {
628
657
  const scope = view.fileScope === 'file' ? 'diff' : 'file';
629
- return [scope, 'commit', 'pull', 'push'];
658
+ return ['npm', scope, 'commit', 'pull', 'push'];
630
659
  }
631
660
  if (pane === 'branches') return Object.keys(BRANCH_ACTIONS);
632
661
  if (pane === 'commits') return Object.keys(COMMIT_ACTIONS);
662
+ if (pane === 'npm') {
663
+ if (view.npmView) return ['npmStop', 'npmRerun'];
664
+ return ['npmEdit', 'npmNew', 'npmDrop', 'npmLogs'];
665
+ }
633
666
  return [];
634
667
  };
635
668
 
@@ -643,6 +676,8 @@ module.exports = {
643
676
  paintHeader,
644
677
  layoutButtons,
645
678
  paintButtons,
679
+ paintFooter,
680
+ footerHits,
646
681
  paintStatusLine,
647
682
  statusChoiceHits,
648
683
  statusBranchHit,