reslop 0.2.5 → 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 (105) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/LICENSE +1 -1
  3. package/README.md +11 -11
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +32 -19
  6. package/lib/capabilities.js +8 -8
  7. package/lib/cli.js +40 -74
  8. package/lib/clipboard.js +39 -47
  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 +197 -402
  14. package/lib/deps.js +113 -240
  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 +76 -144
  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 +79 -178
  25. package/lib/editor.js +52 -69
  26. package/lib/files.js +71 -89
  27. package/lib/git-branches.js +254 -416
  28. package/lib/git-deps.js +147 -272
  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 +155 -141
  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 +14 -14
  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 +19 -25
  44. package/lib/highlight/txt.js +7 -10
  45. package/lib/highlight.js +17 -46
  46. package/lib/keys.js +49 -82
  47. package/lib/manifest.js +67 -126
  48. package/lib/npm-commands.js +120 -169
  49. package/lib/remote.js +140 -190
  50. package/lib/render/branches.js +63 -61
  51. package/lib/render/chrome.js +220 -333
  52. package/lib/render/commits.js +160 -361
  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 -258
  58. package/lib/render/files.js +40 -85
  59. package/lib/render/frame.js +78 -117
  60. package/lib/render/notes.js +139 -210
  61. package/lib/render/npm.js +213 -177
  62. package/lib/render/present.js +4 -11
  63. package/lib/render/primitives.js +72 -174
  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 -31
  68. package/lib/report-model.js +110 -33
  69. package/lib/report-parse.js +56 -37
  70. package/lib/report-render.js +102 -59
  71. package/lib/report-run.js +87 -77
  72. package/lib/report-test.js +221 -99
  73. package/lib/report-tools.js +96 -154
  74. package/lib/review-merge.js +33 -41
  75. package/lib/review.js +68 -129
  76. package/lib/runs.js +225 -0
  77. package/lib/select.js +53 -91
  78. package/lib/session/accessors.js +14 -21
  79. package/lib/session/actions.js +61 -43
  80. package/lib/session/branches.js +166 -222
  81. package/lib/session/change-actions.js +49 -119
  82. package/lib/session/commit.js +274 -355
  83. package/lib/session/compose.js +556 -714
  84. package/lib/session/dashboard.js +385 -0
  85. package/lib/session/dispatch.js +162 -170
  86. package/lib/session/files-pane.js +76 -44
  87. package/lib/session/items.js +6 -17
  88. package/lib/session/lifecycle.js +393 -466
  89. package/lib/session/load.js +1 -14
  90. package/lib/session/navigation.js +2 -2
  91. package/lib/session/npm.js +403 -420
  92. package/lib/session/ops.js +5 -13
  93. package/lib/session/pointer.js +16 -14
  94. package/lib/session/progress.js +39 -30
  95. package/lib/session/review.js +1 -3
  96. package/lib/session/tasks.js +276 -235
  97. package/lib/session/templates.js +51 -59
  98. package/lib/session/terminal.js +85 -132
  99. package/lib/session/view.js +47 -81
  100. package/lib/session/watch.js +196 -212
  101. package/lib/session.js +81 -161
  102. package/lib/update.js +44 -111
  103. package/lib/utilities.js +46 -88
  104. package/lib/wrap.js +29 -55
  105. package/package.json +1 -1
package/lib/remote.js CHANGED
@@ -15,8 +15,6 @@ const RETRY_ATTEMPTS = 3;
15
15
  const RETRY_DELAY_MS = 200;
16
16
  const TRANSIENT_STATUS = [429, 502, 503, 504];
17
17
 
18
- const isRecord = isHashObject;
19
-
20
18
  const abortError = (signal) => {
21
19
  if (signal && signal.reason !== undefined) return signal.reason;
22
20
  const error = new Error('This operation was aborted');
@@ -26,110 +24,142 @@ const abortError = (signal) => {
26
24
 
27
25
  const isAbortError = (error, signal) => {
28
26
  if (signal && signal.aborted) return true;
29
- if (!error || typeof error !== 'object') return false;
30
- if (error.name === 'AbortError') return true;
31
- if (error.code === 'ABORT') return true;
32
- return false;
27
+ return error?.name === 'AbortError' || error?.code === 'ABORT';
33
28
  };
34
29
 
35
30
  const throwIfAborted = (signal) => {
36
31
  if (signal && signal.aborted) throw abortError(signal);
37
32
  };
38
33
 
39
- const wait = async (ms, abortSignal) => {
34
+ const wait = async (ms, signal) => {
40
35
  try {
41
- await delay(ms, abortSignal);
36
+ await delay(ms, signal);
42
37
  } catch (error) {
43
- throwIfAborted(abortSignal);
38
+ throwIfAborted(signal);
44
39
  throw error;
45
40
  }
46
41
  };
47
42
 
48
- const createTransport = (deps = {}) => {
49
- const fetchImpl = deps.fetch;
50
- const signal = deps.signal;
43
+ class RemoteClient {
44
+ constructor(options) {
45
+ this.name = options.name;
46
+ this.subject = options.subject;
47
+ this.prefix = options.name.toUpperCase();
48
+ this.headers = options.headers;
49
+ this.nextPage = options.nextPage;
50
+ this.errorFields = options.errorFields ?? ['message'];
51
+ this.limitStatus = options.limitStatus ?? 0;
52
+ }
53
+
54
+ error(code, message, extra = {}) {
55
+ const fields = { code: `${this.prefix}_${code}`, ...extra };
56
+ return Object.assign(new Error(message), fields);
57
+ }
51
58
 
52
- const retry = async (run, options = {}) => {
53
- const attempts = options.attempts ?? RETRY_ATTEMPTS;
54
- const delayMs = options.delayMs ?? RETRY_DELAY_MS;
55
- const isRetryable = options.isRetryable;
56
- for (let i = 0; ; i++) {
59
+ parseJson(text, label, validate = isHashObject) {
60
+ const data = jsonParse(text ?? '');
61
+ if (validate(data)) return data;
62
+ throw this.error('PARSE', `${this.name} API: invalid ${label}`);
63
+ }
64
+
65
+ httpError(status, body, token) {
66
+ const { name, prefix } = this;
67
+ const text = `${body ?? ''}`.trim();
68
+ const parsed = jsonParse(text);
69
+ const fields = this.errorFields;
70
+ const field = fields.find((key) => typeof parsed?.[key] === 'string');
71
+ const message =
72
+ isHashObject(parsed) && field ? parsed[field] : oneLine(text);
73
+ if (status === 404) {
74
+ const hint = token
75
+ ? ''
76
+ : ` (set ${prefix}_TOKEN for private repositories)`;
77
+ const notFound = `${name} ${this.subject} not found${hint}`;
78
+ return this.error('NOT_FOUND', notFound, { status });
79
+ }
80
+ if (status === 401) {
81
+ return this.error('AUTH', `${name} authentication failed`, { status });
82
+ }
83
+ const rateLimit = status === 403 && /rate limit/i.test(message);
84
+ if (rateLimit || status === this.limitStatus) {
85
+ const limited = `${name} API rate limit exceeded`;
86
+ return this.error('RATE_LIMIT', limited, { status });
87
+ }
88
+ const detail = message ? `: ${oneLine(message)}` : ` HTTP ${status}`;
89
+ return this.error('HTTP', `${name} API${detail}`, { status });
90
+ }
91
+
92
+ isRetryable(error) {
93
+ if (error.code === `${this.prefix}_NETWORK`) return true;
94
+ return TRANSIENT_STATUS.includes(error.status);
95
+ }
96
+
97
+ async fetchPage(url, options) {
98
+ const { signal, token = '' } = options;
99
+ const headers = this.headers(token, options.accept);
100
+ if (options.contentType) headers['Content-Type'] = options.contentType;
101
+ const init = { method: options.method || 'GET', headers };
102
+ if (options.body !== undefined) init.body = options.body;
103
+ if (signal) init.signal = signal;
104
+ let response;
105
+ try {
106
+ response = await options.fetch(url, init);
107
+ } catch (cause) {
108
+ if (isAbortError(cause, signal)) throw cause;
109
+ const detail = oneLine(cause && cause.message);
110
+ const suffix = detail ? `: ${detail}` : '';
111
+ const message = `${this.name} request failed${suffix}`;
112
+ throw this.error('NETWORK', message, { cause });
113
+ }
114
+ const text = await response.text();
115
+ if (!response.ok) throw this.httpError(response.status, text, token);
116
+ return { body: text, headers: response.headers };
117
+ }
118
+
119
+ async request(url, options) {
120
+ const { signal, retry = {} } = options;
121
+ throwIfAborted(signal);
122
+ if (typeof options.fetch !== 'function') {
123
+ throw this.error('FETCH', 'fetch is not available');
124
+ }
125
+ const attempts = retry.attempts ?? RETRY_ATTEMPTS;
126
+ const delayMs = retry.delayMs ?? RETRY_DELAY_MS;
127
+ for (let attempt = 1; ; attempt++) {
57
128
  try {
58
- return await run();
129
+ return await this.fetchPage(url, options);
59
130
  } catch (error) {
60
131
  if (isAbortError(error, signal)) throw error;
61
- const isLast = i === attempts - 1;
62
- if (isLast || !isRetryable(error)) throw error;
63
- throwIfAborted(signal);
64
- await wait(delayMs * (i + 1), signal);
132
+ if (attempt >= attempts || !this.isRetryable(error)) throw error;
133
+ await wait(delayMs * attempt, signal);
65
134
  }
66
135
  }
67
- };
68
-
69
- const request = async (url, options = {}) => {
70
- throwIfAborted(signal);
71
- if (typeof fetchImpl !== 'function') {
72
- throw options.missingFetch();
73
- }
74
- const headers = options.headers;
75
- const method = options.method || 'GET';
76
- const body = options.body;
77
- const wrapNetwork = options.wrapNetwork;
78
- const toHttpError = options.toHttpError;
79
- const retryOpts = options.retry ?? {};
80
- return retry(
81
- async () => {
82
- let response;
83
- try {
84
- const init = { method, headers };
85
- if (body !== undefined) init.body = body;
86
- if (signal) init.signal = signal;
87
- response = await fetchImpl(url, init);
88
- } catch (error) {
89
- if (isAbortError(error, signal)) throw error;
90
- throw wrapNetwork(error);
91
- }
92
- const text = await response.text();
93
- if (!response.ok) throw toHttpError(response.status, text);
94
- return { body: text, headers: response.headers };
95
- },
96
- { ...retryOpts, isRetryable: options.isRetryable },
97
- );
98
- };
136
+ }
99
137
 
100
- const listPages = async (url, options = {}) => {
138
+ async list(url, options) {
101
139
  const items = [];
102
140
  let next = url;
103
- const parsePage = options.parsePage;
104
- const nextPage = options.nextPage;
105
141
  while (next) {
106
- const page = await request(next, options);
107
- const entries = parsePage(page.body);
108
- for (const entry of entries) items.push(entry);
109
- next = nextPage(page.headers, next);
142
+ const page = await this.request(next, { ...options, method: 'GET' });
143
+ const entries = this.parseJson(page.body, 'list payload', Array.isArray);
144
+ items.push(...entries);
145
+ next = this.nextPage(page.headers, next);
110
146
  }
111
147
  return items;
112
- };
113
-
114
- return { retry, request, listPages };
115
- };
148
+ }
149
+ }
116
150
 
117
151
  const headerValue = (headers, name) => {
118
152
  if (!headers) return '';
119
- if (typeof headers.get === 'function') {
120
- return headers.get(name) || '';
121
- }
153
+ if (typeof headers.get === 'function') return headers.get(name) || '';
122
154
  const wanted = name.toLowerCase();
123
155
  for (const key of Object.keys(headers)) {
124
- const value = headers[key];
125
- if (key.toLowerCase() === wanted) return `${value}`;
156
+ if (key.toLowerCase() === wanted) return `${headers[key]}`;
126
157
  }
127
158
  return '';
128
159
  };
129
160
 
130
161
  const parseLinkNext = (headers) => {
131
162
  const link = headerValue(headers, 'link');
132
- if (!link) return '';
133
163
  for (const part of link.split(',')) {
134
164
  const match = /<([^>]+)>\s*;\s*rel="?next"?/i.exec(part.trim());
135
165
  if (match) return match[1];
@@ -153,106 +183,24 @@ const listUrl = (base) => {
153
183
  return url.toString();
154
184
  };
155
185
 
156
- const createClient = ({ name, subject, headers, nextPage }) => {
157
- const prefix = name.toUpperCase();
158
- const error = (code, message, extra = {}) =>
159
- Object.assign(new Error(message), { code: `${prefix}_${code}`, ...extra });
160
-
161
- const parseJson = (text, label, validate = isHashObject) => {
162
- const data = jsonParse(text ?? '');
163
- if (!validate(data)) throw error('PARSE', `${name} API: invalid ${label}`);
164
- return data;
165
- };
166
-
167
- const httpError = (status, body, token) => {
168
- const text = `${body ?? ''}`.trim();
169
- const parsed = jsonParse(text);
170
- const fields = name === 'GitLab' ? ['message', 'error'] : ['message'];
171
- const field = fields.find((key) => typeof parsed?.[key] === 'string');
172
- const message =
173
- isHashObject(parsed) && field ? parsed[field] : oneLine(text);
174
- if (status === 404) {
175
- const hint = token
176
- ? ''
177
- : ` (set ${prefix}_TOKEN for private repositories)`;
178
- return error('NOT_FOUND', `${name} ${subject} not found${hint}`, {
179
- status,
180
- });
181
- }
182
- if (status === 401) {
183
- return error('AUTH', `${name} authentication failed`, { status });
184
- }
185
- const rateLimit = status === 403 && /rate limit/i.test(message);
186
- if (rateLimit || (name === 'GitLab' && status === 429)) {
187
- return error('RATE_LIMIT', `${name} API rate limit exceeded`, { status });
188
- }
189
- const detail = message ? `: ${oneLine(message)}` : ` HTTP ${status}`;
190
- return error('HTTP', `${name} API${detail}`, { status });
191
- };
192
-
193
- const policy = (options) => ({
194
- retry: options.retry ?? {},
195
- isRetryable: (err) =>
196
- err.code === `${prefix}_NETWORK` || TRANSIENT_STATUS.includes(err.status),
197
- missingFetch: () => error('FETCH', 'fetch is not available'),
198
- toHttpError: (status, body) => httpError(status, body, options.token),
199
- wrapNetwork: (cause) => {
200
- const detail = oneLine(cause && cause.message);
201
- const suffix = detail ? `: ${detail}` : '';
202
- return error('NETWORK', `${name} request failed${suffix}`, { cause });
203
- },
204
- method: options.method || 'GET',
205
- headers: headers(options.token || '', options.accept),
206
- });
207
-
208
- const request = (url, options) => {
209
- const init = policy(options);
210
- if (options.contentType) init.headers['Content-Type'] = options.contentType;
211
- return createTransport(options).request(url, {
212
- ...init,
213
- body: options.body,
214
- });
215
- };
216
-
217
- const list = (url, options) =>
218
- createTransport(options).listPages(url, {
219
- ...policy(options),
220
- method: 'GET',
221
- parsePage: (text) => parseJson(text, 'list payload', Array.isArray),
222
- nextPage,
223
- });
224
-
225
- return { parseJson, request, list, subject };
226
- };
186
+ const commentBody = (value) => `${value ?? ''}`.trim();
227
187
 
228
188
  const sideLineNumber = (type, oldNo, newNo, side) => {
229
- if (side === 'LEFT') {
230
- if (type === 'add') return 0;
231
- return oldNo;
232
- }
233
- if (type === 'del') return 0;
234
- return newNo;
189
+ if (side === 'LEFT') return type === 'add' ? 0 : oldNo;
190
+ return type === 'del' ? 0 : newNo;
235
191
  };
236
192
 
237
193
  const locateLineInItem = (item, rel, line, side) => {
238
- const path = itemPath(item);
239
- if (path !== rel) return null;
240
194
  const hunk = item.hunk;
241
- if (!hunk || !line) return null;
195
+ if (itemPath(item) !== rel || !hunk || !line) return null;
242
196
  let oldNo = hunk.oldStart;
243
197
  let newNo = hunk.newStart;
244
198
  for (const entry of hunk.lines) {
245
199
  const at = sideLineNumber(entry.type, oldNo, newNo, side);
246
- if (at === line) {
247
- const inBlock = entry.blockId === item.blockId;
248
- return { inBlock };
249
- }
250
- if (isCtxType(entry.type) || entry.type === 'del') {
251
- oldNo += 1;
252
- }
253
- if (isCtxType(entry.type) || entry.type === 'add') {
254
- newNo += 1;
255
- }
200
+ if (at === line) return { inBlock: entry.blockId === item.blockId };
201
+ const isCtx = isCtxType(entry.type);
202
+ if (isCtx || entry.type === 'del') oldNo += 1;
203
+ if (isCtx || entry.type === 'add') newNo += 1;
256
204
  }
257
205
  return null;
258
206
  };
@@ -270,8 +218,7 @@ const findItemForLine = (items, rel, line, side) => {
270
218
 
271
219
  const importedText = (entry, host) => {
272
220
  const reviewer = entry.reviewer || 'unknown';
273
- const body = `${entry.body ?? ''}`.trim();
274
- return `@${reviewer} review at ${host}: ${body}`;
221
+ return `@${reviewer} review at ${host}: ${commentBody(entry.body)}`;
275
222
  };
276
223
 
277
224
  const importedTodo = (file, entry, host) => ({
@@ -298,44 +245,46 @@ const importedFeedback = (item, path, entry, host) => {
298
245
 
299
246
  const noteFromLocation = (items, loc, entry, host, fallbackFile) => {
300
247
  const path = loc.path || '';
301
- const line = loc.line;
302
- const side = loc.side;
303
- if (line && path) {
304
- const item = findItemForLine(items, path, line, side);
248
+ if (loc.line && path) {
249
+ const item = findItemForLine(items, path, loc.line, loc.side);
305
250
  if (item) return importedFeedback(item, path, entry, host);
306
251
  }
307
- if (path) return importedTodo(path, entry, host);
308
- return importedTodo(fallbackFile, entry, host);
252
+ return importedTodo(path || fallbackFile, entry, host);
309
253
  };
310
254
 
311
- const changePath = (file) => file.newPath || file.oldPath || '';
255
+ const splitNotes = (notes) => {
256
+ const feedback = [];
257
+ const tasks = [];
258
+ for (const note of notes) {
259
+ if (!note) continue;
260
+ if (note.kind === 'feedback') feedback.push(note);
261
+ else tasks.push(note);
262
+ }
263
+ return { feedback, tasks };
264
+ };
312
265
 
313
266
  const filterChangeFiles = (files, paths) => {
314
267
  if (!paths.length) return files;
315
- return files.filter((file) => isPathInScope(changePath(file), paths));
268
+ return files.filter((file) =>
269
+ isPathInScope(file.newPath || file.oldPath || '', paths),
270
+ );
316
271
  };
317
272
 
318
273
  const changeFiles = (items) => {
319
- const files = new Set();
274
+ const rels = new Set();
320
275
  for (const item of items) {
321
276
  const paths = item.dep ? item.dep.files : [itemPath(item)];
322
277
  for (const rel of paths ?? []) {
323
- if (rel) files.add(rel);
278
+ if (rel) rels.add(rel);
324
279
  }
325
280
  }
326
- return [...files];
281
+ return [...rels];
327
282
  };
328
283
 
329
284
  const loadChange = async (ref, options, source) => {
330
285
  const { client, url, diffUrl = url, diffAccept } = source;
331
- const {
332
- cwd,
333
- paths = [],
334
- fetch = globalThis.fetch,
335
- token = '',
336
- retry = {},
337
- signal,
338
- } = options;
286
+ const { cwd, paths = [], fetch = globalThis.fetch } = options;
287
+ const { token = '', retry = {}, signal } = options;
339
288
  const request = { fetch, token, retry, signal };
340
289
  const jsonPage = await client.request(url, request);
341
290
  const data = client.parseJson(jsonPage.body, `${client.subject} payload`);
@@ -345,31 +294,32 @@ const loadChange = async (ref, options, source) => {
345
294
  });
346
295
  const files = filterChangeFiles(parseDiff(diffPage.body), paths);
347
296
  const items = foldDepItems(itemsFromFiles(files, 'pr'));
348
- const change = source.toChange(ref, data, items);
297
+ const change = {
298
+ ...source.toChange(ref, data),
299
+ files: changeFiles(items),
300
+ status: 'reviewing',
301
+ };
349
302
  const sourceLabel = `${source.prefix}${change.number}`;
350
303
  let imported = { feedback: [], tasks: [] };
351
304
  try {
352
305
  const discussion = await source.loadDiscussion(ref, request);
353
306
  imported = source.discussionToNotes(discussion, items, paths);
354
307
  } catch (error) {
355
- if (isAbortError(error, request.signal)) throw error;
356
- // Discussion import is optional.
308
+ if (isAbortError(error, signal)) throw error;
357
309
  }
358
310
  return { top: cwd, items, sourceLabel, change, imported };
359
311
  };
360
312
 
361
313
  module.exports = {
362
- isRecord,
314
+ RemoteClient,
363
315
  isAbortError,
364
- createTransport,
365
316
  parseLinkNext,
366
317
  parseNextPage,
367
- createClient,
368
318
  listUrl,
369
- importedText,
319
+ commentBody,
370
320
  importedTodo,
371
321
  noteFromLocation,
322
+ splitNotes,
372
323
  filterChangeFiles,
373
- changeFiles,
374
324
  loadChange,
375
325
  };
@@ -3,14 +3,13 @@
3
3
  const ansi = require('../ansi.js');
4
4
  const files = require('../files.js');
5
5
  const primitives = require('./primitives.js');
6
+
6
7
  const { shortAge } = files;
8
+ const { THEME, paint, visibleWidth, trimVisible, seq, EL } = ansi;
9
+ const { MARK_W, markPrefix, branchLabel, editSpan } = primitives;
10
+ const { paintCursorList, fieldWindow } = primitives;
7
11
  const { measureColumns, padColumns, joinColumns, paintColumns } = primitives;
8
12
 
9
- const { THEME, paint, visibleWidth, truncateVisible, seq, EL } = ansi;
10
- const { FILE_MARK } = primitives;
11
- const { paintCursorList, paintListEdit } = primitives;
12
- const { branchLabel } = primitives;
13
-
14
13
  const BRANCH_COL_ALIGN = {
15
14
  sha: 'end',
16
15
  ahead: 'start',
@@ -28,88 +27,91 @@ const BRANCH_COL_FG = {
28
27
  };
29
28
 
30
29
  const BRANCH_CURRENT_COL_FG = {
30
+ ...BRANCH_COL_FG,
31
31
  sha: THEME.shaDarkFg,
32
32
  date: THEME.headerFg,
33
33
  };
34
34
 
35
35
  const BRANCH_NAME_MAX = 24;
36
36
  const BRANCH_TAIL = 1;
37
+
37
38
  const branchNameChip = (entry, rowBg) => {
38
39
  if (entry.current) {
39
- return {
40
- nameFg: THEME.mutedFg,
41
- nameBg: THEME.currentBg,
42
- nameBold: false,
43
- };
40
+ return { nameFg: THEME.mutedFg, nameBg: THEME.currentBg, nameBold: false };
44
41
  }
45
42
  if (entry.isDefault) {
46
- return {
47
- nameFg: THEME.warnFg,
48
- nameBg: rowBg,
49
- nameBold: true,
50
- };
43
+ return { nameFg: THEME.warnFg, nameBg: rowBg, nameBold: true };
51
44
  }
52
- return {
53
- nameFg: THEME.buttonHotFg,
54
- nameBg: rowBg,
55
- nameBold: false,
56
- };
45
+ return { nameFg: THEME.buttonHotFg, nameBg: rowBg, nameBold: false };
57
46
  };
58
47
 
59
- const branchRowFg = (entry, selected) => {
60
- if (selected) return THEME.chromeFg;
61
- return THEME.mutedFg;
62
- };
48
+ const branchRowFields = (entry) => ({
49
+ sha: entry.sha ?? '',
50
+ ahead: entry.ahead ? `⇡${entry.ahead}` : '',
51
+ behind: entry.behind ? `⇣${entry.behind}` : '',
52
+ gone: entry.gone ? 'gone' : '',
53
+ date: shortAge(entry.date),
54
+ });
63
55
 
64
- const branchRowFields = (entry) => {
65
- const sha = entry.sha ?? '';
66
- const date = shortAge(entry.date ?? '');
67
- const gone = entry.gone ? 'gone' : '';
68
- const ahead = entry.ahead ? `⇡${entry.ahead}` : '';
69
- const behind = entry.behind ? `⇣${entry.behind}` : '';
70
- return { sha, ahead, behind, gone, date };
56
+ const branchRowFg = (entry, selected) => {
57
+ if (entry.current) return THEME.headerFg;
58
+ return selected ? THEME.chromeFg : THEME.mutedFg;
71
59
  };
72
60
 
73
61
  const branchRowBg = (entry, selected) => {
74
62
  if (entry.current) return THEME.currentBg;
75
- if (selected) return THEME.buttonBg;
76
- return THEME.ctxBg;
63
+ return selected ? THEME.buttonBg : THEME.ctxBg;
77
64
  };
78
65
 
79
66
  const paintBranchRow = (entry, width, color, selected, cols) => {
80
67
  const inner = Math.max(1, width - BRANCH_TAIL);
81
- const mark = selected ? FILE_MARK : ' ';
82
- const prefix = ` ${mark} `;
68
+ const prefix = markPrefix(selected);
83
69
  const parts = padColumns(branchRowFields(entry), cols, BRANCH_COL_ALIGN);
84
70
  const suffix = joinColumns(parts, cols);
85
71
  const rest = Math.max(1, inner - visibleWidth(prefix + suffix));
86
72
  const nameW = Math.min(cols.name, rest);
87
- const label = truncateVisible(branchLabel(entry.name), nameW);
88
- const namePad = Math.max(0, nameW - visibleWidth(label));
73
+ const label = trimVisible(branchLabel(entry.name), nameW);
74
+ const namePad = ' '.repeat(Math.max(0, nameW - visibleWidth(label)));
89
75
  const canSubject = rest >= nameW + 3;
90
76
  const gap = canSubject ? ' ' : '';
91
- const subjectW = canSubject ? rest - nameW - visibleWidth(gap) : 0;
92
- const subject = truncateVisible(entry.subject ?? '', subjectW);
93
- const mid = `${label}${' '.repeat(namePad)}${gap}${subject}`;
94
- const pad = Math.max(0, rest - visibleWidth(mid));
95
- const after = `${' '.repeat(namePad)}${gap}${subject}${' '.repeat(pad)}`;
96
- const left = `${prefix}${label}${after}`;
97
- const fgRgb = branchRowFg(entry, selected);
98
- const rowBg = branchRowBg(entry, selected);
99
- const chip = branchNameChip(entry, rowBg);
100
- const toneFg = entry.current ? THEME.headerFg : fgRgb;
77
+ const subjectW = canSubject ? rest - nameW - gap.length : 0;
78
+ const subject = trimVisible(entry.subject ?? '', subjectW);
79
+ const mid = `${label}${namePad}${gap}${subject}`;
80
+ const pad = ' '.repeat(Math.max(0, rest - visibleWidth(mid)));
81
+ const after = `${namePad}${gap}${subject}${pad}`;
101
82
  const tail = ' '.repeat(BRANCH_TAIL);
102
- if (!color) return `${left}${suffix}${tail}`;
103
- let out = `${seq(toneFg, rowBg)}${EL}`;
104
- out += paint(prefix, toneFg, rowBg, color);
83
+ if (!color) return `${prefix}${label}${after}${suffix}${tail}`;
84
+ const fgRgb = branchRowFg(entry, selected);
85
+ const bgRgb = branchRowBg(entry, selected);
86
+ const chip = branchNameChip(entry, bgRgb);
87
+ const tones = entry.current ? BRANCH_CURRENT_COL_FG : BRANCH_COL_FG;
88
+ let out = `${seq(fgRgb, bgRgb)}${EL}`;
89
+ out += paint(prefix, fgRgb, bgRgb, color);
105
90
  out += paint(label, chip.nameFg, chip.nameBg, color, chip.nameBold);
106
- out += paint(after, toneFg, rowBg, color);
107
- const tones = entry.current
108
- ? { ...BRANCH_COL_FG, ...BRANCH_CURRENT_COL_FG }
109
- : BRANCH_COL_FG;
110
- out += paintColumns(parts, cols, toneFg, rowBg, color, tones);
111
- out += paint(tail, toneFg, rowBg, color);
112
- return out;
91
+ out += paint(after, fgRgb, bgRgb, color);
92
+ out += paintColumns(parts, cols, fgRgb, bgRgb, color, tones);
93
+ return out + paint(tail, fgRgb, bgRgb, color);
94
+ };
95
+
96
+ const paintBranchEdit = (compose, width, color) => {
97
+ const prefix = markPrefix(true);
98
+ const rest = Math.max(1, width - MARK_W);
99
+ const text = `${compose.text ?? ''}`.replaceAll('\n', ' ');
100
+ const win = fieldWindow(text, compose.cursor, compose.scrollCol, rest);
101
+ const gap = ' '.repeat(Math.max(0, rest - visibleWidth(win.text)));
102
+ const fgRgb = THEME.chromeFg;
103
+ const bgRgb = THEME.buttonBg;
104
+ let row = `${prefix}${win.text}${gap}`;
105
+ if (color) {
106
+ row = `${seq(fgRgb, bgRgb)}${EL}`;
107
+ row += paint(prefix, fgRgb, bgRgb, color);
108
+ row += paint(win.text, THEME.buttonHotFg, bgRgb, color);
109
+ row += paint(gap, fgRgb, bgRgb, color);
110
+ }
111
+ const textX = MARK_W + 1;
112
+ const edit = editSpan(1, textX, textX + rest, 0, text);
113
+ const cursor = { x: textX + win.col, scroll: win.scroll };
114
+ return { row, cursor, edits: [{ ...edit, scroll: win.scroll }] };
113
115
  };
114
116
 
115
117
  const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
@@ -121,13 +123,13 @@ const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
121
123
  }
122
124
  cols.name = Math.min(BRANCH_NAME_MAX, cols.name);
123
125
  const cursor = view.branchCursor ?? 0;
124
- const compose = view.compose;
125
- const editing = compose && compose.kind === 'branch';
126
- const tail = editing ? paintListEdit(compose, width, color) : null;
126
+ const { compose } = view;
127
+ const edit =
128
+ compose?.kind === 'branch' ? paintBranchEdit(compose, width, color) : null;
127
129
  const pane = { width, color, bodyH, headerLines, offset: view.listScroll };
128
130
  const paintRow = (entry, selected) =>
129
131
  paintBranchRow(entry, width, color, selected, cols);
130
- return paintCursorList(branches, cursor, pane, paintRow, tail);
132
+ return paintCursorList(branches, cursor, pane, paintRow, edit);
131
133
  };
132
134
 
133
135
  module.exports = { paintBodyBranches };