reslop 0.1.8 → 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.
- package/CHANGELOG.md +25 -1
- package/README.md +27 -38
- package/bin/reslop.js +2 -2
- package/lib/ansi.js +6 -2
- package/lib/api.js +2 -2
- package/lib/capabilities.js +10 -14
- package/lib/cli.js +28 -47
- package/lib/clipboard.js +1 -1
- package/lib/deps-diff.js +712 -0
- package/lib/deps.js +556 -32
- package/lib/detect.js +1 -7
- package/lib/diff/align.js +7 -10
- package/lib/diff/diff.js +53 -0
- package/lib/diff/display.js +5 -4
- package/lib/diff/inline.js +26 -21
- package/lib/diff/items.js +6 -4
- package/lib/diff/patch.js +1 -3
- package/lib/diff/unit.js +392 -0
- package/lib/files.js +34 -8
- package/lib/{git/branches.js → git-branches.js} +103 -10
- package/lib/{git/dependencies.js → git-deps.js} +111 -38
- package/lib/git.js +592 -65
- package/lib/github.js +28 -197
- package/lib/gitlab.js +24 -195
- package/lib/highlight/bash.js +1 -3
- package/lib/highlight/css.js +1 -3
- package/lib/highlight/js.js +11 -14
- package/lib/highlight.js +6 -9
- package/lib/keys.js +22 -9
- package/lib/manifest.js +330 -0
- package/lib/remote.js +374 -0
- package/lib/render/branches.js +34 -66
- package/lib/render/chrome.js +57 -22
- package/lib/render/commits.js +85 -0
- package/lib/render/diff.js +16 -16
- package/lib/render/files.js +29 -37
- package/lib/render/frame.js +29 -13
- package/lib/render/notes.js +4 -3
- package/lib/render/primitives.js +46 -1
- package/lib/{render.js → render/render.js} +6 -7
- package/lib/render/unit.js +41 -0
- package/lib/review.js +750 -14
- package/lib/select.js +5 -10
- package/lib/session/accessors.js +9 -1
- package/lib/session/actions.js +93 -36
- package/lib/session/branches.js +130 -144
- package/lib/session/change-actions.js +206 -124
- package/lib/session/commit.js +217 -62
- package/lib/session/compose.js +158 -13
- package/lib/session/dispatch.js +109 -55
- package/lib/session/files-pane.js +145 -142
- package/lib/session/items.js +131 -34
- package/lib/session/lifecycle.js +82 -42
- package/lib/session/load.js +19 -33
- package/lib/session/navigation.js +15 -3
- package/lib/session/ops.js +28 -41
- package/lib/session/pointer.js +58 -54
- package/lib/session/review.js +23 -37
- package/lib/session/terminal.js +6 -12
- package/lib/session/todos.js +8 -12
- package/lib/session/unit.js +208 -0
- package/lib/session/update.js +33 -46
- package/lib/session/view.js +82 -11
- package/lib/session/watch.js +11 -19
- package/lib/session.js +29 -226
- package/lib/source.js +3 -3
- package/lib/update.js +40 -75
- package/lib/{git/proc.js → utilities.js} +75 -11
- package/lib/wrap.js +1 -3
- package/package.json +2 -2
- package/lib/deps/changes.js +0 -331
- package/lib/deps/items.js +0 -131
- package/lib/deps/manifest.js +0 -145
- package/lib/deps/merge.js +0 -120
- package/lib/deps/proposals.js +0 -164
- package/lib/deps/reports.js +0 -180
- package/lib/deps/rows.js +0 -177
- package/lib/deps/scan.js +0 -138
- package/lib/deps/usage.js +0 -68
- package/lib/deps/versions.js +0 -119
- package/lib/diff.js +0 -33
- package/lib/git/changes.js +0 -164
- package/lib/git/read.js +0 -253
- package/lib/git/run.js +0 -100
- package/lib/npm/commands.js +0 -100
- package/lib/remote/changes.js +0 -37
- package/lib/remote/headers.js +0 -36
- package/lib/remote/http.js +0 -122
- package/lib/remote/notes.js +0 -94
- package/lib/review/format.js +0 -434
- package/lib/review/storage.js +0 -150
- package/lib/review/store.js +0 -178
- package/lib/review/templates.js +0 -49
- package/lib/session/origins.js +0 -7
- 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
|
+
};
|
package/lib/render/branches.js
CHANGED
|
@@ -2,19 +2,18 @@
|
|
|
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,
|
|
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',
|
|
15
14
|
behind: 'start',
|
|
16
15
|
gone: 'end',
|
|
17
|
-
date: '
|
|
16
|
+
date: 'start',
|
|
18
17
|
};
|
|
19
18
|
|
|
20
19
|
const BRANCH_COL_FG = {
|
|
@@ -25,14 +24,20 @@ const BRANCH_COL_FG = {
|
|
|
25
24
|
date: THEME.mutedFg,
|
|
26
25
|
};
|
|
27
26
|
|
|
28
|
-
const
|
|
27
|
+
const BRANCH_CURRENT_COL_FG = {
|
|
28
|
+
sha: THEME.shaDarkFg,
|
|
29
|
+
date: THEME.headerFg,
|
|
30
|
+
};
|
|
29
31
|
|
|
32
|
+
const BRANCH_NAME_MAX = 24;
|
|
33
|
+
const BRANCH_EDGE = 2;
|
|
34
|
+
const BRANCH_TAIL = 1;
|
|
30
35
|
const branchNameChip = (entry, rowBg) => {
|
|
31
36
|
if (entry.current) {
|
|
32
37
|
return {
|
|
33
|
-
nameFg: THEME.
|
|
38
|
+
nameFg: THEME.mutedFg,
|
|
34
39
|
wrapFg: THEME.mutedFg,
|
|
35
|
-
nameBg: THEME.
|
|
40
|
+
nameBg: THEME.currentBg,
|
|
36
41
|
nameBold: false,
|
|
37
42
|
};
|
|
38
43
|
}
|
|
@@ -66,61 +71,12 @@ const branchRowFields = (entry) => {
|
|
|
66
71
|
return { sha, ahead, behind, gone, date };
|
|
67
72
|
};
|
|
68
73
|
|
|
69
|
-
const branchColumns = (branches) => {
|
|
70
|
-
const widths = { name: 0 };
|
|
71
|
-
for (const key of BRANCH_COL_KEYS) widths[key] = 0;
|
|
72
|
-
for (const entry of branches) {
|
|
73
|
-
const nameW = visibleWidth(branchLabel(entry.name));
|
|
74
|
-
if (nameW > widths.name) widths.name = nameW;
|
|
75
|
-
const fields = branchRowFields(entry);
|
|
76
|
-
for (const key of BRANCH_COL_KEYS) {
|
|
77
|
-
const w = visibleWidth(fields[key]);
|
|
78
|
-
if (w > widths[key]) widths[key] = w;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
widths.name = Math.min(BRANCH_NAME_MAX, widths.name);
|
|
82
|
-
return widths;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const branchRowParts = (entry, cols) => {
|
|
86
|
-
const fields = branchRowFields(entry);
|
|
87
|
-
const parts = {};
|
|
88
|
-
for (const key of BRANCH_COL_KEYS) {
|
|
89
|
-
const width = cols?.[key] ?? visibleWidth(fields[key]);
|
|
90
|
-
const align = BRANCH_COL_ALIGN[key];
|
|
91
|
-
parts[key] = padVisible(fields[key], width, align);
|
|
92
|
-
}
|
|
93
|
-
return parts;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const joinBranchSuffix = (parts, cols) => {
|
|
97
|
-
const bits = [];
|
|
98
|
-
for (const key of BRANCH_COL_KEYS) {
|
|
99
|
-
if (!cols[key]) continue;
|
|
100
|
-
bits.push(parts[key]);
|
|
101
|
-
}
|
|
102
|
-
if (!bits.length) return '';
|
|
103
|
-
return ` ${bits.join(' ')}`;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const paintBranchSuffix = (parts, cols, fgRgb, bgRgb, color) => {
|
|
107
|
-
const gap = paint(' ', fgRgb, bgRgb, color);
|
|
108
|
-
const bits = [];
|
|
109
|
-
for (const key of BRANCH_COL_KEYS) {
|
|
110
|
-
if (!cols[key]) continue;
|
|
111
|
-
const tone = BRANCH_COL_FG[key] ?? fgRgb;
|
|
112
|
-
bits.push(paint(parts[key], tone, bgRgb, color));
|
|
113
|
-
}
|
|
114
|
-
if (!bits.length) return '';
|
|
115
|
-
return gap + bits.join(gap);
|
|
116
|
-
};
|
|
117
|
-
|
|
118
74
|
const paintBranchRow = (entry, width, color, selected, cols) => {
|
|
119
|
-
const inner = Math.max(1, width -
|
|
75
|
+
const inner = Math.max(1, width - BRANCH_EDGE - BRANCH_TAIL);
|
|
120
76
|
const mark = selected ? FILE_MARK : ' ';
|
|
121
77
|
const prefix = ` ${mark} `;
|
|
122
|
-
const parts =
|
|
123
|
-
const suffix =
|
|
78
|
+
const parts = padColumns(branchRowFields(entry), cols, BRANCH_COL_ALIGN);
|
|
79
|
+
const suffix = joinColumns(parts, cols);
|
|
124
80
|
const rest = Math.max(1, inner - visibleWidth(prefix + suffix));
|
|
125
81
|
const nameW = Math.min(cols.name, rest);
|
|
126
82
|
const label = truncateVisible(branchLabel(entry.name), nameW);
|
|
@@ -136,8 +92,11 @@ const paintBranchRow = (entry, width, color, selected, cols) => {
|
|
|
136
92
|
const fgRgb = branchRowFg(entry, selected);
|
|
137
93
|
const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
|
|
138
94
|
const chip = branchNameChip(entry, bgRgb);
|
|
139
|
-
const
|
|
140
|
-
|
|
95
|
+
const markFg = entry.current ? THEME.headerFg : fgRgb;
|
|
96
|
+
const markBg = entry.current ? THEME.currentBg : bgRgb;
|
|
97
|
+
const tail = ' '.repeat(BRANCH_TAIL);
|
|
98
|
+
const edge = fill(BRANCH_EDGE, fgRgb, THEME.ctxBg, color);
|
|
99
|
+
if (!color) return `${left}${suffix}${tail}${edge}`;
|
|
141
100
|
let out = `${seq(fgRgb, bgRgb)}${EL}`;
|
|
142
101
|
out += paint(prefix, fgRgb, bgRgb, color);
|
|
143
102
|
out += paintBracketName(
|
|
@@ -148,13 +107,17 @@ const paintBranchRow = (entry, width, color, selected, cols) => {
|
|
|
148
107
|
color,
|
|
149
108
|
chip.nameBold,
|
|
150
109
|
);
|
|
151
|
-
out += paint(after,
|
|
152
|
-
|
|
110
|
+
out += paint(after, markFg, markBg, color);
|
|
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);
|
|
115
|
+
out += paint(tail, markFg, markBg, color);
|
|
153
116
|
return out + edge;
|
|
154
117
|
};
|
|
155
118
|
|
|
156
119
|
const paintBranchEdit = (compose, width, color) => {
|
|
157
|
-
const inner = Math.max(1, width -
|
|
120
|
+
const inner = Math.max(1, width - BRANCH_EDGE);
|
|
158
121
|
const prefix = ` ${FILE_MARK} `;
|
|
159
122
|
const prefixW = visibleWidth(prefix);
|
|
160
123
|
const rest = Math.max(1, inner - prefixW);
|
|
@@ -165,7 +128,7 @@ const paintBranchEdit = (compose, width, color) => {
|
|
|
165
128
|
const left = `${prefix}${clipped}${' '.repeat(pad)}`;
|
|
166
129
|
const fgRgb = THEME.chromeFg;
|
|
167
130
|
const bgRgb = THEME.buttonBg;
|
|
168
|
-
const edge = fill(
|
|
131
|
+
const edge = fill(BRANCH_EDGE, fgRgb, THEME.ctxBg, color);
|
|
169
132
|
let row = `${left}${edge}`;
|
|
170
133
|
if (color) {
|
|
171
134
|
const gap = ' '.repeat(pad);
|
|
@@ -187,7 +150,12 @@ const paintBranchEdit = (compose, width, color) => {
|
|
|
187
150
|
|
|
188
151
|
const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
|
|
189
152
|
const branches = view.branches ?? [];
|
|
190
|
-
const cols =
|
|
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);
|
|
191
159
|
const cursor = view.branchCursor ?? 0;
|
|
192
160
|
const compose = view.compose;
|
|
193
161
|
const editing = compose && compose.kind === 'branch';
|