reslop 0.1.9 → 0.2.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +2 -1
  3. package/bin/reslop.js +2 -2
  4. package/lib/ansi.js +4 -2
  5. package/lib/api.js +2 -2
  6. package/lib/capabilities.js +7 -14
  7. package/lib/cli.js +28 -47
  8. package/lib/clipboard.js +1 -1
  9. package/lib/{deps/changes.js → deps-diff.js} +309 -19
  10. package/lib/deps.js +498 -29
  11. package/lib/detect.js +1 -7
  12. package/lib/diff/align.js +7 -10
  13. package/lib/diff/diff.js +53 -0
  14. package/lib/diff/display.js +5 -4
  15. package/lib/diff/inline.js +26 -21
  16. package/lib/diff/items.js +6 -4
  17. package/lib/diff/patch.js +1 -3
  18. package/lib/diff/unit.js +392 -0
  19. package/lib/files.js +5 -8
  20. package/lib/{git/branches.js → git-branches.js} +6 -5
  21. package/lib/{git/dependencies.js → git-deps.js} +111 -38
  22. package/lib/git.js +589 -68
  23. package/lib/github.js +28 -197
  24. package/lib/gitlab.js +24 -195
  25. package/lib/highlight/bash.js +1 -3
  26. package/lib/highlight/css.js +1 -3
  27. package/lib/highlight/js.js +11 -14
  28. package/lib/highlight.js +6 -9
  29. package/lib/keys.js +14 -8
  30. package/lib/manifest.js +330 -0
  31. package/lib/remote.js +374 -0
  32. package/lib/render/branches.js +14 -68
  33. package/lib/render/chrome.js +24 -9
  34. package/lib/render/commits.js +6 -59
  35. package/lib/render/diff.js +16 -16
  36. package/lib/render/files.js +6 -34
  37. package/lib/render/frame.js +23 -12
  38. package/lib/render/notes.js +4 -3
  39. package/lib/render/primitives.js +45 -0
  40. package/lib/{render.js → render/render.js} +5 -5
  41. package/lib/render/unit.js +41 -0
  42. package/lib/review.js +750 -14
  43. package/lib/select.js +5 -10
  44. package/lib/session/accessors.js +9 -1
  45. package/lib/session/actions.js +32 -16
  46. package/lib/session/branches.js +116 -128
  47. package/lib/session/change-actions.js +202 -123
  48. package/lib/session/commit.js +105 -112
  49. package/lib/session/compose.js +158 -13
  50. package/lib/session/dispatch.js +104 -60
  51. package/lib/session/files-pane.js +142 -148
  52. package/lib/session/items.js +22 -30
  53. package/lib/session/lifecycle.js +44 -44
  54. package/lib/session/load.js +19 -33
  55. package/lib/session/navigation.js +12 -1
  56. package/lib/session/ops.js +28 -41
  57. package/lib/session/pointer.js +54 -57
  58. package/lib/session/review.js +23 -37
  59. package/lib/session/terminal.js +6 -12
  60. package/lib/session/todos.js +8 -12
  61. package/lib/session/unit.js +208 -0
  62. package/lib/session/update.js +33 -46
  63. package/lib/session/view.js +78 -9
  64. package/lib/session/watch.js +11 -19
  65. package/lib/session.js +21 -246
  66. package/lib/source.js +3 -3
  67. package/lib/update.js +40 -75
  68. package/lib/{git/proc.js → utilities.js} +75 -11
  69. package/lib/wrap.js +1 -3
  70. package/package.json +1 -1
  71. package/lib/deps/items.js +0 -134
  72. package/lib/deps/manifest.js +0 -152
  73. package/lib/deps/merge.js +0 -120
  74. package/lib/deps/proposals.js +0 -164
  75. package/lib/deps/reports.js +0 -180
  76. package/lib/deps/rows.js +0 -176
  77. package/lib/deps/scan.js +0 -138
  78. package/lib/deps/usage.js +0 -68
  79. package/lib/deps/versions.js +0 -119
  80. package/lib/diff.js +0 -33
  81. package/lib/git/changes.js +0 -164
  82. package/lib/git/read.js +0 -253
  83. package/lib/git/run.js +0 -100
  84. package/lib/npm/commands.js +0 -100
  85. package/lib/remote/changes.js +0 -37
  86. package/lib/remote/headers.js +0 -36
  87. package/lib/remote/http.js +0 -122
  88. package/lib/remote/notes.js +0 -94
  89. package/lib/review/format.js +0 -434
  90. package/lib/review/storage.js +0 -150
  91. package/lib/review/store.js +0 -178
  92. package/lib/review/templates.js +0 -49
  93. package/lib/session/origins.js +0 -7
  94. package/lib/sys.js +0 -84
package/lib/remote.js ADDED
@@ -0,0 +1,374 @@
1
+ 'use strict';
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');
8
+
9
+ const RETRY_ATTEMPTS = 3;
10
+ const RETRY_DELAY_MS = 200;
11
+ const TRANSIENT_STATUS = [429, 502, 503, 504];
12
+
13
+ const isRecord = isHashObject;
14
+
15
+ const abortError = (signal) => {
16
+ if (signal && signal.reason !== undefined) return signal.reason;
17
+ const error = new Error('This operation was aborted');
18
+ error.name = 'AbortError';
19
+ return error;
20
+ };
21
+
22
+ const isAbortError = (error, signal) => {
23
+ if (signal && signal.aborted) return true;
24
+ if (!error || typeof error !== 'object') return false;
25
+ if (error.name === 'AbortError') return true;
26
+ if (error.code === 'ABORT') return true;
27
+ return false;
28
+ };
29
+
30
+ const throwIfAborted = (signal) => {
31
+ if (signal && signal.aborted) throw abortError(signal);
32
+ };
33
+
34
+ const wait = async (ms, abortSignal) => {
35
+ try {
36
+ await delay(ms, abortSignal);
37
+ } catch (error) {
38
+ throwIfAborted(abortSignal);
39
+ throw error;
40
+ }
41
+ };
42
+
43
+ const createTransport = (deps = {}) => {
44
+ const fetchImpl = deps.fetch;
45
+ const signal = deps.signal;
46
+
47
+ const retry = async (run, options = {}) => {
48
+ const attempts = options.attempts ?? RETRY_ATTEMPTS;
49
+ const delayMs = options.delayMs ?? RETRY_DELAY_MS;
50
+ const isRetryable = options.isRetryable;
51
+ for (let i = 0; ; i++) {
52
+ try {
53
+ return await run();
54
+ } catch (error) {
55
+ if (isAbortError(error, signal)) throw error;
56
+ const isLast = i === attempts - 1;
57
+ if (isLast || !isRetryable(error)) throw error;
58
+ throwIfAborted(signal);
59
+ await wait(delayMs * (i + 1), signal);
60
+ }
61
+ }
62
+ };
63
+
64
+ const request = async (url, options = {}) => {
65
+ throwIfAborted(signal);
66
+ if (typeof fetchImpl !== 'function') {
67
+ throw options.missingFetch();
68
+ }
69
+ const headers = options.headers;
70
+ const method = options.method || 'GET';
71
+ const body = options.body;
72
+ const wrapNetwork = options.wrapNetwork;
73
+ const toHttpError = options.toHttpError;
74
+ const retryOpts = options.retry ?? {};
75
+ return retry(
76
+ async () => {
77
+ let response;
78
+ try {
79
+ const init = { method, headers };
80
+ if (body !== undefined) init.body = body;
81
+ if (signal) init.signal = signal;
82
+ response = await fetchImpl(url, init);
83
+ } catch (error) {
84
+ if (isAbortError(error, signal)) throw error;
85
+ throw wrapNetwork(error);
86
+ }
87
+ const text = await response.text();
88
+ if (!response.ok) throw toHttpError(response.status, text);
89
+ return { body: text, headers: response.headers };
90
+ },
91
+ { ...retryOpts, isRetryable: options.isRetryable },
92
+ );
93
+ };
94
+
95
+ const listPages = async (url, options = {}) => {
96
+ const items = [];
97
+ let next = url;
98
+ const parsePage = options.parsePage;
99
+ const nextPage = options.nextPage;
100
+ while (next) {
101
+ const page = await request(next, options);
102
+ const entries = parsePage(page.body);
103
+ for (const entry of entries) items.push(entry);
104
+ next = nextPage(page.headers, next);
105
+ }
106
+ return items;
107
+ };
108
+
109
+ return { retry, request, listPages };
110
+ };
111
+
112
+ const headerValue = (headers, name) => {
113
+ if (!headers) return '';
114
+ if (typeof headers.get === 'function') {
115
+ return headers.get(name) || '';
116
+ }
117
+ const wanted = name.toLowerCase();
118
+ for (const key of Object.keys(headers)) {
119
+ const value = headers[key];
120
+ if (key.toLowerCase() === wanted) return `${value}`;
121
+ }
122
+ return '';
123
+ };
124
+
125
+ const parseLinkNext = (headers) => {
126
+ const link = headerValue(headers, 'link');
127
+ if (!link) return '';
128
+ for (const part of link.split(',')) {
129
+ const match = /<([^>]+)>\s*;\s*rel="?next"?/i.exec(part.trim());
130
+ if (match) return match[1];
131
+ }
132
+ return '';
133
+ };
134
+
135
+ const parseNextPage = (headers, currentUrl) => {
136
+ const fromLink = parseLinkNext(headers);
137
+ if (fromLink) return fromLink;
138
+ const page = headerValue(headers, 'x-next-page');
139
+ if (!page) return '';
140
+ const url = new URL(currentUrl);
141
+ url.searchParams.set('page', page);
142
+ return url.toString();
143
+ };
144
+
145
+ const listUrl = (base) => {
146
+ const url = new URL(base);
147
+ url.searchParams.set('per_page', '100');
148
+ return url.toString();
149
+ };
150
+
151
+ const createClient = ({ name, subject, headers, nextPage }) => {
152
+ const prefix = name.toUpperCase();
153
+ const error = (code, message, extra = {}) =>
154
+ Object.assign(new Error(message), { code: `${prefix}_${code}`, ...extra });
155
+
156
+ const parseJson = (text, label, validate = isHashObject) => {
157
+ const data = jsonParse(text ?? '');
158
+ if (!validate(data)) throw error('PARSE', `${name} API: invalid ${label}`);
159
+ return data;
160
+ };
161
+
162
+ const httpError = (status, body, token) => {
163
+ const text = `${body ?? ''}`.trim();
164
+ const parsed = jsonParse(text);
165
+ const fields = name === 'GitLab' ? ['message', 'error'] : ['message'];
166
+ const field = fields.find((key) => typeof parsed?.[key] === 'string');
167
+ const message =
168
+ isHashObject(parsed) && field ? parsed[field] : oneLine(text);
169
+ if (status === 404) {
170
+ const hint = token
171
+ ? ''
172
+ : ` (set ${prefix}_TOKEN for private repositories)`;
173
+ return error('NOT_FOUND', `${name} ${subject} not found${hint}`, {
174
+ status,
175
+ });
176
+ }
177
+ if (status === 401) {
178
+ return error('AUTH', `${name} authentication failed`, { status });
179
+ }
180
+ const rateLimit = status === 403 && /rate limit/i.test(message);
181
+ if (rateLimit || (name === 'GitLab' && status === 429)) {
182
+ return error('RATE_LIMIT', `${name} API rate limit exceeded`, { status });
183
+ }
184
+ const detail = message ? `: ${oneLine(message)}` : ` HTTP ${status}`;
185
+ return error('HTTP', `${name} API${detail}`, { status });
186
+ };
187
+
188
+ const policy = (options) => ({
189
+ retry: options.retry ?? {},
190
+ isRetryable: (err) =>
191
+ err.code === `${prefix}_NETWORK` || TRANSIENT_STATUS.includes(err.status),
192
+ missingFetch: () => error('FETCH', 'fetch is not available'),
193
+ toHttpError: (status, body) => httpError(status, body, options.token),
194
+ wrapNetwork: (cause) => {
195
+ const detail = oneLine(cause && cause.message);
196
+ const suffix = detail ? `: ${detail}` : '';
197
+ return error('NETWORK', `${name} request failed${suffix}`, { cause });
198
+ },
199
+ method: options.method || 'GET',
200
+ headers: headers(options.token || '', options.accept),
201
+ });
202
+
203
+ const request = (url, options) => {
204
+ const init = policy(options);
205
+ if (options.contentType) init.headers['Content-Type'] = options.contentType;
206
+ return createTransport(options).request(url, {
207
+ ...init,
208
+ body: options.body,
209
+ });
210
+ };
211
+
212
+ const list = (url, options) =>
213
+ createTransport(options).listPages(url, {
214
+ ...policy(options),
215
+ method: 'GET',
216
+ parsePage: (text) => parseJson(text, 'list payload', Array.isArray),
217
+ nextPage,
218
+ });
219
+
220
+ return { parseJson, request, list, subject };
221
+ };
222
+
223
+ const sideLineNumber = (type, oldNo, newNo, side) => {
224
+ if (side === 'LEFT') {
225
+ if (type === 'add') return 0;
226
+ return oldNo;
227
+ }
228
+ if (type === 'del') return 0;
229
+ return newNo;
230
+ };
231
+
232
+ const locateLineInItem = (item, rel, line, side) => {
233
+ const path = itemPath(item);
234
+ if (path !== rel) return null;
235
+ const hunk = item.hunk;
236
+ if (!hunk || !line) return null;
237
+ let oldNo = hunk.oldStart;
238
+ let newNo = hunk.newStart;
239
+ for (const entry of hunk.lines) {
240
+ const at = sideLineNumber(entry.type, oldNo, newNo, side);
241
+ if (at === line) {
242
+ const inBlock = entry.blockId === item.blockId;
243
+ return { inBlock };
244
+ }
245
+ if (isCtxType(entry.type) || entry.type === 'del') {
246
+ oldNo += 1;
247
+ }
248
+ if (isCtxType(entry.type) || entry.type === 'add') {
249
+ newNo += 1;
250
+ }
251
+ }
252
+ return null;
253
+ };
254
+
255
+ const findItemForLine = (items, rel, line, side) => {
256
+ let fallback = null;
257
+ for (const item of items) {
258
+ const hit = locateLineInItem(item, rel, line, side);
259
+ if (!hit) continue;
260
+ if (hit.inBlock) return item;
261
+ if (!fallback) fallback = item;
262
+ }
263
+ return fallback;
264
+ };
265
+
266
+ const importedText = (entry, host) => {
267
+ const reviewer = entry.reviewer || 'unknown';
268
+ const body = `${entry.body ?? ''}`.trim();
269
+ return `@${reviewer} review at ${host}: ${body}`;
270
+ };
271
+
272
+ const importedTodo = (file, entry, host) => ({
273
+ kind: 'todo',
274
+ file,
275
+ text: importedText(entry, host),
276
+ done: entry.resolved === true,
277
+ });
278
+
279
+ const importedFeedback = (item, path, entry, host) => {
280
+ const hunk = item.hunk;
281
+ return {
282
+ kind: 'feedback',
283
+ file: itemPath(item) || path,
284
+ oldStart: hunk ? hunk.oldStart : 0,
285
+ newStart: hunk ? hunk.newStart : 0,
286
+ blockId: item.blockId ?? 0,
287
+ origin: item.origin ?? 'pr',
288
+ header: hunk ? hunk.header : '',
289
+ text: importedText(entry, host),
290
+ done: entry.resolved === true,
291
+ };
292
+ };
293
+
294
+ const noteFromLocation = (items, loc, entry, host, fallbackFile) => {
295
+ const path = loc.path || '';
296
+ const line = loc.line;
297
+ const side = loc.side;
298
+ if (line && path) {
299
+ const item = findItemForLine(items, path, line, side);
300
+ if (item) return importedFeedback(item, path, entry, host);
301
+ }
302
+ if (path) return importedTodo(path, entry, host);
303
+ return importedTodo(fallbackFile, entry, host);
304
+ };
305
+
306
+ const changePath = (file) => file.newPath || file.oldPath || '';
307
+
308
+ const filterChangeFiles = (files, paths) => {
309
+ if (!paths.length) return files;
310
+ return files.filter((file) => isPathInScope(changePath(file), paths));
311
+ };
312
+
313
+ const changeFiles = (items) => {
314
+ const files = new Set();
315
+ for (const item of items) {
316
+ const paths = item.dep ? item.dep.files : [itemPath(item)];
317
+ for (const rel of paths ?? []) {
318
+ if (rel) files.add(rel);
319
+ }
320
+ }
321
+ return [...files];
322
+ };
323
+
324
+ const loadChange = async (ref, options, source) => {
325
+ 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
+ };
333
+ const jsonPage = await client.request(url, request);
334
+ const data = client.parseJson(jsonPage.body, `${client.subject} payload`);
335
+ const diffPage = await client.request(diffUrl, {
336
+ ...request,
337
+ accept: diffAccept,
338
+ });
339
+ const files = filterChangeFiles(parseDiff(diffPage.body), paths);
340
+ const items = foldDepItems(itemsFromFiles(files, 'pr'));
341
+ const change = source.toChange(ref, data, items);
342
+ const sourceLabel = `${source.prefix}${change.number}`;
343
+ let imported = { feedback: [], todos: [] };
344
+ try {
345
+ const discussion = await source.loadDiscussion(ref, request);
346
+ imported = source.discussionToNotes(discussion, items, paths);
347
+ } catch (error) {
348
+ if (isAbortError(error, request.signal)) throw error;
349
+ // Discussion import is optional.
350
+ }
351
+ return { top: options.cwd, items, sourceLabel, change, imported };
352
+ };
353
+
354
+ module.exports = {
355
+ RETRY_ATTEMPTS,
356
+ RETRY_DELAY_MS,
357
+ TRANSIENT_STATUS,
358
+ isRecord,
359
+ isAbortError,
360
+ createTransport,
361
+ headerValue,
362
+ parseLinkNext,
363
+ parseNextPage,
364
+ createClient,
365
+ listUrl,
366
+ findItemForLine,
367
+ importedText,
368
+ importedTodo,
369
+ noteFromLocation,
370
+ changePath,
371
+ filterChangeFiles,
372
+ changeFiles,
373
+ loadChange,
374
+ };
@@ -2,13 +2,12 @@
2
2
 
3
3
  const ansi = require('../ansi.js');
4
4
  const primitives = require('./primitives.js');
5
+ const { measureColumns, padColumns, joinColumns, paintColumns } = primitives;
5
6
 
6
7
  const { THEME, paint, visibleWidth, truncateVisible, seq, EL } = ansi;
7
- const { fill, padVisible, FILE_MARK, paneResult } = primitives;
8
+ const { fill, FILE_MARK, paneResult } = primitives;
8
9
  const { paintCursorList, branchLabel, paintBracketName } = primitives;
9
10
 
10
- const BRANCH_COL_KEYS = ['sha', 'ahead', 'behind', 'gone', 'date'];
11
-
12
11
  const BRANCH_COL_ALIGN = {
13
12
  sha: 'end',
14
13
  ahead: 'start',
@@ -33,11 +32,6 @@ const BRANCH_CURRENT_COL_FG = {
33
32
  const BRANCH_NAME_MAX = 24;
34
33
  const BRANCH_EDGE = 2;
35
34
  const BRANCH_TAIL = 1;
36
- const BRANCH_GAP = ' ';
37
- const BRANCH_DATE_GAP = ' ';
38
-
39
- const colGap = (key) => (key === 'date' ? BRANCH_DATE_GAP : BRANCH_GAP);
40
-
41
35
  const branchNameChip = (entry, rowBg) => {
42
36
  if (entry.current) {
43
37
  return {
@@ -77,68 +71,12 @@ const branchRowFields = (entry) => {
77
71
  return { sha, ahead, behind, gone, date };
78
72
  };
79
73
 
80
- const branchColumns = (branches) => {
81
- const widths = { name: 0 };
82
- for (const key of BRANCH_COL_KEYS) widths[key] = 0;
83
- for (const entry of branches) {
84
- const nameW = visibleWidth(branchLabel(entry.name));
85
- if (nameW > widths.name) widths.name = nameW;
86
- const fields = branchRowFields(entry);
87
- for (const key of BRANCH_COL_KEYS) {
88
- const w = visibleWidth(fields[key]);
89
- if (w > widths[key]) widths[key] = w;
90
- }
91
- }
92
- widths.name = Math.min(BRANCH_NAME_MAX, widths.name);
93
- return widths;
94
- };
95
-
96
- const branchRowParts = (entry, cols) => {
97
- const fields = branchRowFields(entry);
98
- const parts = {};
99
- for (const key of BRANCH_COL_KEYS) {
100
- const width = cols?.[key] ?? visibleWidth(fields[key]);
101
- const align = BRANCH_COL_ALIGN[key];
102
- parts[key] = padVisible(fields[key], width, align);
103
- }
104
- return parts;
105
- };
106
-
107
- const joinBranchSuffix = (parts, cols) => {
108
- const bits = [];
109
- for (const key of BRANCH_COL_KEYS) {
110
- if (!cols[key]) continue;
111
- bits.push(colGap(key) + parts[key]);
112
- }
113
- if (!bits.length) return '';
114
- return bits.join('');
115
- };
116
-
117
- const branchColFg = (key, current) => {
118
- if (current && Object.hasOwn(BRANCH_CURRENT_COL_FG, key)) {
119
- return BRANCH_CURRENT_COL_FG[key];
120
- }
121
- return BRANCH_COL_FG[key];
122
- };
123
-
124
- const paintBranchSuffix = (parts, cols, fgRgb, bgRgb, color, current) => {
125
- const bits = [];
126
- for (const key of BRANCH_COL_KEYS) {
127
- if (!cols[key]) continue;
128
- const tone = branchColFg(key, current) ?? fgRgb;
129
- const gap = paint(colGap(key), fgRgb, bgRgb, color);
130
- bits.push(gap + paint(parts[key], tone, bgRgb, color));
131
- }
132
- if (!bits.length) return '';
133
- return bits.join('');
134
- };
135
-
136
74
  const paintBranchRow = (entry, width, color, selected, cols) => {
137
75
  const inner = Math.max(1, width - BRANCH_EDGE - BRANCH_TAIL);
138
76
  const mark = selected ? FILE_MARK : ' ';
139
77
  const prefix = ` ${mark} `;
140
- const parts = branchRowParts(entry, cols);
141
- const suffix = joinBranchSuffix(parts, cols);
78
+ const parts = padColumns(branchRowFields(entry), cols, BRANCH_COL_ALIGN);
79
+ const suffix = joinColumns(parts, cols);
142
80
  const rest = Math.max(1, inner - visibleWidth(prefix + suffix));
143
81
  const nameW = Math.min(cols.name, rest);
144
82
  const label = truncateVisible(branchLabel(entry.name), nameW);
@@ -170,7 +108,10 @@ const paintBranchRow = (entry, width, color, selected, cols) => {
170
108
  chip.nameBold,
171
109
  );
172
110
  out += paint(after, markFg, markBg, color);
173
- out += paintBranchSuffix(parts, cols, markFg, markBg, color, entry.current);
111
+ const tones = entry.current
112
+ ? { ...BRANCH_COL_FG, ...BRANCH_CURRENT_COL_FG }
113
+ : BRANCH_COL_FG;
114
+ out += paintColumns(parts, cols, markFg, markBg, color, tones);
174
115
  out += paint(tail, markFg, markBg, color);
175
116
  return out + edge;
176
117
  };
@@ -209,7 +150,12 @@ const paintBranchEdit = (compose, width, color) => {
209
150
 
210
151
  const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
211
152
  const branches = view.branches ?? [];
212
- const cols = branchColumns(branches);
153
+ const cols = measureColumns(branches, branchRowFields, BRANCH_COL_ALIGN);
154
+ cols.name = 0;
155
+ for (const entry of branches) {
156
+ cols.name = Math.max(cols.name, visibleWidth(branchLabel(entry.name)));
157
+ }
158
+ cols.name = Math.min(BRANCH_NAME_MAX, cols.name);
213
159
  const cursor = view.branchCursor ?? 0;
214
160
  const compose = view.compose;
215
161
  const editing = compose && compose.kind === 'branch';
@@ -9,7 +9,7 @@ const { itemPath, TODO_FILE, 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
- const { COMMITS_DISABLED, TODO_DISABLED, DIFF_DISABLED } = keys;
12
+ const { COMMITS_DISABLED, TODO_DISABLED, DIFF_DISABLED, UNIT_DISABLED } = keys;
13
13
  const { diffGitDisabled, branchGitDisabled, commitGitDisabled } = keys;
14
14
  const { CONFIRM, confirmPrefix, promptFromChoices } = keys;
15
15
  const { THEME, paint, visibleWidth } = ansi;
@@ -65,6 +65,8 @@ const INFO_STATUS = [
65
65
  'saved',
66
66
  'reloaded',
67
67
  'not a diff block',
68
+ 'file scope',
69
+ 'diff mode',
68
70
  'committed',
69
71
  'amended',
70
72
  'fixup',
@@ -113,8 +115,6 @@ const originLabel = (origin, view) => {
113
115
  return origin ?? 'unknown';
114
116
  };
115
117
 
116
- const headerRoleFg = (role) => HEADER_ROLE_FG[role] ?? THEME.headerChromeFg;
117
-
118
118
  const pathSegments = (rel) => {
119
119
  const segs = [];
120
120
  if (!rel) return segs;
@@ -171,6 +171,14 @@ const headerTarget = (view) => {
171
171
  const origin = entry.head ? 'HEAD' : 'commit';
172
172
  return { path: entry.subject ?? '', origin, n, total };
173
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
+ }
174
182
  if (!view.item) return { path: '', origin: '', n: 0, total: view.total ?? 0 };
175
183
  if (view.item.origin === 'todo') {
176
184
  return { path: TODO_FILE, origin: 'todo', n: 1, total: 1 };
@@ -243,7 +251,8 @@ const paintHeader = (view, width, color) => {
243
251
  if (!piece.text) {
244
252
  continue;
245
253
  }
246
- out += `${fg(headerRoleFg(piece.role))}${piece.text}`;
254
+ const roleFg = HEADER_ROLE_FG[piece.role] ?? THEME.headerChromeFg;
255
+ out += `${fg(roleFg)}${piece.text}`;
247
256
  }
248
257
  return `${out}${RESET}`;
249
258
  };
@@ -428,12 +437,14 @@ const statusLayout = (view, status, width) => {
428
437
  };
429
438
 
430
439
  const choiceHits = (choices, prefix, y) => {
431
- const hits = [];
440
+ const { length } = choices;
441
+ const hits = new Array(length);
432
442
  let x = visibleWidth(prefix);
433
- for (const choice of choices) {
443
+ for (let i = 0; i < length; i++) {
444
+ const choice = choices[i];
434
445
  const piece = `${choice.lead}${choice.label}`;
435
446
  const w = visibleWidth(piece);
436
- hits.push({ id: choice.letter, y, x0: x, x1: x + w });
447
+ hits[i] = { id: choice.letter, y, x0: x, x1: x + w };
437
448
  x += w;
438
449
  }
439
450
  return hits;
@@ -543,6 +554,7 @@ const selectedCommit = (view) => (view.commits ?? [])[view.commitCursor ?? 0];
543
554
  const hiddenActions = (view) => {
544
555
  const pane = view.pane ?? 'diff';
545
556
  if (pane === 'files') return FILES_HIDDEN;
557
+ if (pane === 'unit') return UNIT_DISABLED;
546
558
  if (pane === 'branches') return BRANCHES_DISABLED;
547
559
  if (pane === 'commits') return COMMITS_DISABLED;
548
560
  if (view.item && view.item.origin === 'todo') return TODO_DISABLED;
@@ -557,7 +569,7 @@ const dimFooterActions = (view) => {
557
569
  if (!entry || entry.kind !== 'todos') return [];
558
570
  return FILES_GIT_DISABLED;
559
571
  }
560
- if (pane === 'diff') return diffGitDisabled(view.item);
572
+ if (pane === 'diff' || pane === 'unit') return diffGitDisabled(view.item);
561
573
  if (pane === 'branches') return branchGitDisabled(selectedBranch(view));
562
574
  if (pane === 'commits') {
563
575
  const entry = selectedCommit(view);
@@ -570,7 +582,10 @@ const dimFooterActions = (view) => {
570
582
 
571
583
  const extraFooterActions = (view) => {
572
584
  const pane = view.pane ?? 'diff';
573
- if (pane === 'files') return ['commit', 'pull', 'push'];
585
+ if (pane === 'files') {
586
+ const scope = view.fileScope === 'file' ? 'diff' : 'file';
587
+ return [scope, 'commit', 'pull', 'push'];
588
+ }
574
589
  if (pane === 'branches') return Object.keys(BRANCH_ACTIONS);
575
590
  if (pane === 'commits') return Object.keys(COMMIT_ACTIONS);
576
591
  return [];
@@ -2,11 +2,10 @@
2
2
 
3
3
  const ansi = require('../ansi.js');
4
4
  const primitives = require('./primitives.js');
5
+ const { measureColumns, padColumns, joinColumns, paintColumns } = primitives;
5
6
 
6
7
  const { THEME, paint, visibleWidth, truncateVisible, seq, EL } = ansi;
7
- const { fill, padVisible, FILE_MARK, paneResult, paintCursorList } = primitives;
8
-
9
- const COMMIT_COL_KEYS = ['sha', 'author', 'date'];
8
+ const { fill, FILE_MARK, paneResult, paintCursorList } = primitives;
10
9
 
11
10
  const COMMIT_COL_ALIGN = {
12
11
  sha: 'end',
@@ -22,71 +21,19 @@ const COMMIT_COL_FG = {
22
21
 
23
22
  const COMMIT_EDGE = 2;
24
23
  const COMMIT_TAIL = 1;
25
- const COMMIT_GAP = ' ';
26
- const COMMIT_DATE_GAP = ' ';
27
-
28
- const colGap = (key) => (key === 'date' ? COMMIT_DATE_GAP : COMMIT_GAP);
29
-
30
24
  const commitRowFields = (entry) => ({
31
25
  sha: entry.shortSha ?? '',
32
26
  author: entry.author ?? '',
33
27
  date: entry.date ?? '',
34
28
  });
35
29
 
36
- const commitColumns = (commits) => {
37
- const widths = {};
38
- for (const key of COMMIT_COL_KEYS) widths[key] = 0;
39
- for (const entry of commits) {
40
- const fields = commitRowFields(entry);
41
- for (const key of COMMIT_COL_KEYS) {
42
- const w = visibleWidth(fields[key]);
43
- if (w > widths[key]) widths[key] = w;
44
- }
45
- }
46
- return widths;
47
- };
48
-
49
- const commitRowParts = (entry, cols) => {
50
- const fields = commitRowFields(entry);
51
- const parts = {};
52
- for (const key of COMMIT_COL_KEYS) {
53
- const width = cols?.[key] ?? visibleWidth(fields[key]);
54
- parts[key] = padVisible(fields[key], width, COMMIT_COL_ALIGN[key]);
55
- }
56
- return parts;
57
- };
58
-
59
- const joinCommitSuffix = (parts, cols) => {
60
- const bits = [];
61
- for (const key of COMMIT_COL_KEYS) {
62
- if (!cols[key]) continue;
63
- bits.push(colGap(key) + parts[key]);
64
- }
65
- if (!bits.length) return '';
66
- return bits.join('');
67
- };
68
-
69
- const commitColFg = (key) => COMMIT_COL_FG[key];
70
-
71
- const paintCommitSuffix = (parts, cols, fgRgb, bgRgb, color) => {
72
- const bits = [];
73
- for (const key of COMMIT_COL_KEYS) {
74
- if (!cols[key]) continue;
75
- const tone = commitColFg(key) ?? fgRgb;
76
- const gap = paint(colGap(key), fgRgb, bgRgb, color);
77
- bits.push(gap + paint(parts[key], tone, bgRgb, color));
78
- }
79
- if (!bits.length) return '';
80
- return bits.join('');
81
- };
82
-
83
30
  const paintCommitRow = (entry, width, color, selected, cols) => {
84
31
  const inner = Math.max(1, width - COMMIT_EDGE - COMMIT_TAIL);
85
32
  const mark = selected ? FILE_MARK : ' ';
86
33
  const prefix = ` ${mark} `;
87
34
  const lead = ' ';
88
- const parts = commitRowParts(entry, cols);
89
- const suffix = joinCommitSuffix(parts, cols);
35
+ const parts = padColumns(commitRowFields(entry), cols, COMMIT_COL_ALIGN);
36
+ const suffix = joinColumns(parts, cols);
90
37
  const rest = Math.max(1, inner - visibleWidth(prefix + lead + suffix));
91
38
  const subject = truncateVisible(entry.subject ?? '', rest);
92
39
  const pad = Math.max(0, rest - visibleWidth(subject));
@@ -103,14 +50,14 @@ const paintCommitRow = (entry, width, color, selected, cols) => {
103
50
  out += paint(lead, nameFg, bgRgb, color);
104
51
  out += paint(subject, nameFg, bgRgb, color);
105
52
  out += paint(after, fgRgb, bgRgb, color);
106
- out += paintCommitSuffix(parts, cols, fgRgb, bgRgb, color);
53
+ out += paintColumns(parts, cols, fgRgb, bgRgb, color, COMMIT_COL_FG);
107
54
  out += paint(tail, fgRgb, bgRgb, color);
108
55
  return out + edge;
109
56
  };
110
57
 
111
58
  const paintBodyCommits = (view, width, color, bodyH, headerLines) => {
112
59
  const commits = view.commits ?? [];
113
- const cols = commitColumns(commits);
60
+ const cols = measureColumns(commits, commitRowFields, COMMIT_COL_ALIGN);
114
61
  const cursor = view.commitCursor ?? 0;
115
62
  const paintRow = (entry, rowWidth, rowColor, selected) =>
116
63
  paintCommitRow(entry, rowWidth, rowColor, selected, cols);