reslop 0.1.7 → 0.1.9

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 (98) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +26 -38
  3. package/bin/reslop.js +3 -1
  4. package/lib/ansi.js +2 -0
  5. package/lib/capabilities.js +109 -0
  6. package/lib/cli.js +18 -14
  7. package/lib/deps/changes.js +422 -0
  8. package/lib/deps/items.js +134 -0
  9. package/lib/deps/manifest.js +152 -0
  10. package/lib/deps/merge.js +120 -0
  11. package/lib/deps/proposals.js +164 -0
  12. package/lib/deps/reports.js +180 -0
  13. package/lib/deps/rows.js +176 -0
  14. package/lib/deps/scan.js +138 -0
  15. package/lib/deps/usage.js +68 -0
  16. package/lib/deps/versions.js +119 -0
  17. package/lib/deps.js +117 -1345
  18. package/lib/diff/align.js +226 -0
  19. package/lib/diff/display.js +101 -0
  20. package/lib/diff/edit.js +47 -0
  21. package/lib/diff/inline.js +300 -0
  22. package/lib/diff/items.js +101 -0
  23. package/lib/diff/parse.js +156 -0
  24. package/lib/diff/patch.js +103 -0
  25. package/lib/diff.js +13 -932
  26. package/lib/editor.js +37 -0
  27. package/lib/files.js +118 -4
  28. package/lib/git/branches.js +383 -0
  29. package/lib/git/changes.js +164 -0
  30. package/lib/git/dependencies.js +489 -0
  31. package/lib/git/proc.js +162 -0
  32. package/lib/git/read.js +253 -0
  33. package/lib/git/run.js +100 -0
  34. package/lib/git.js +88 -1193
  35. package/lib/github.js +92 -222
  36. package/lib/gitlab.js +72 -216
  37. package/lib/highlight/bash.js +5 -9
  38. package/lib/highlight/core.js +1 -1
  39. package/lib/highlight/css.js +1 -3
  40. package/lib/highlight/csv.js +1 -3
  41. package/lib/highlight/dart.js +6 -20
  42. package/lib/highlight/dot.js +13 -13
  43. package/lib/highlight/html.js +1 -5
  44. package/lib/highlight/js.js +151 -115
  45. package/lib/highlight/json.js +1 -3
  46. package/lib/highlight/log.js +1 -3
  47. package/lib/highlight/txt.js +5 -10
  48. package/lib/highlight.js +4 -4
  49. package/lib/keys.js +67 -168
  50. package/lib/npm/commands.js +100 -0
  51. package/lib/remote/changes.js +37 -0
  52. package/lib/remote/headers.js +36 -0
  53. package/lib/remote/http.js +122 -0
  54. package/lib/remote/notes.js +94 -0
  55. package/lib/render/branches.js +240 -0
  56. package/lib/render/chrome.js +595 -0
  57. package/lib/render/commits.js +138 -0
  58. package/lib/render/diff.js +307 -0
  59. package/lib/render/files.js +186 -0
  60. package/lib/render/frame.js +202 -0
  61. package/lib/render/notes.js +201 -0
  62. package/lib/render/present.js +25 -0
  63. package/lib/render/primitives.js +200 -0
  64. package/lib/render.js +13 -1688
  65. package/lib/review/format.js +434 -0
  66. package/lib/review/storage.js +150 -0
  67. package/lib/review/store.js +178 -0
  68. package/lib/review/templates.js +49 -0
  69. package/lib/review.js +14 -747
  70. package/lib/select.js +3 -5
  71. package/lib/session/accessors.js +25 -0
  72. package/lib/session/actions.js +316 -0
  73. package/lib/session/branches.js +302 -0
  74. package/lib/session/change-actions.js +323 -0
  75. package/lib/session/commit.js +286 -0
  76. package/lib/session/compose.js +540 -0
  77. package/lib/session/dispatch.js +115 -0
  78. package/lib/session/files-pane.js +243 -0
  79. package/lib/session/items.js +282 -0
  80. package/lib/session/lifecycle.js +516 -0
  81. package/lib/session/load.js +85 -0
  82. package/lib/session/navigation.js +94 -0
  83. package/lib/session/ops.js +110 -0
  84. package/lib/session/origins.js +7 -0
  85. package/lib/session/pointer.js +161 -0
  86. package/lib/session/progress.js +29 -0
  87. package/lib/session/review.js +88 -0
  88. package/lib/session/templates.js +64 -0
  89. package/lib/session/terminal.js +169 -0
  90. package/lib/session/todos.js +269 -0
  91. package/lib/session/update.js +125 -0
  92. package/lib/session/view.js +131 -0
  93. package/lib/session/watch.js +219 -0
  94. package/lib/session.js +598 -2350
  95. package/lib/source.js +11 -11
  96. package/lib/sys.js +25 -4
  97. package/lib/wrap.js +4 -2
  98. package/package.json +2 -2
package/lib/session.js CHANGED
@@ -1,2448 +1,707 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
-
6
3
  const render = require('./render.js');
7
4
  const keys = require('./keys.js');
8
5
  const review = require('./review.js');
9
- const wrap = require('./wrap.js');
10
- const { extractText, isRange } = require('./select.js');
11
6
  const { copyText } = require('./clipboard.js');
12
- const { watchResize } = require('./sys.js');
13
7
  const files = require('./files.js');
14
- const { fileEntries, itemPath, TODO_FILE } = files;
15
- const { isTodoItem, isTodosEntry, REPO_TODOS_LABEL, makeTodoItem } = files;
16
- const { Editor } = require('./editor.js');
17
- const { refreshIndexPatches, blockAddText } = require('./diff.js');
18
- const update = require('./update.js');
19
- const { checkUpdate, installUpdate, markSkipped } = update;
20
-
21
- const { renderFrame, presentRows, presentCursor } = render;
22
- const { noteInnerWidth, codeInnerWidth } = render;
23
- const { actionFromKey, hitAction, decodeChunk } = keys;
24
- const { disabledActions } = keys;
25
- const { cursorInWrap, wrapDoc } = wrap;
26
- const { AUTOSAVE_MS, listReviewNames, loadTemplates } = review;
27
- const { resolveReviewPath, loadReview, createStore } = review;
28
- const { noteCounts, hasNotes, setFeedback, setCode, addTodo } = review;
29
- const { applyImportedNotes } = review;
30
- const { setTodoText, removeTodo, flushReview } = review;
31
- const { feedbackKey, checkLabel, rankedTemplates } = review;
32
- const { prefixTemplates, TEMPLATE_SHOW, rememberTemplate } = review;
8
+ const { makeTodoItem } = files;
9
+ const { renderFrame } = render;
10
+ const { decodeChunk } = keys;
11
+ const { AUTOSAVE_MS, hasNotes } = review;
12
+ const capabilities = require('./capabilities.js');
13
+ const { sessionCapabilities, createReviewStorage } = capabilities;
14
+ const { createTerminal, LEAVE_TERM } = require('./session/terminal.js');
15
+ const { createLoadCoordinator } = require('./session/load.js');
16
+ const items = require('./session/items.js');
17
+ const { createItemCollection } = items;
18
+ const { createNavigation } = require('./session/navigation.js');
19
+ const { createReviewController } = require('./session/review.js');
20
+ const { createUpdateCoordinator } = require('./session/update.js');
21
+ const { createProgress } = require('./session/progress.js');
22
+ const { createComposer } = require('./session/compose.js');
23
+ const { createOpsRunner } = require('./session/ops.js');
24
+ const { createChangeActions } = require('./session/change-actions.js');
25
+ const { createCommitController } = require('./session/commit.js');
26
+ const { createBranchController } = require('./session/branches.js');
27
+ const accessors = require('./session/accessors.js');
28
+ const { bindAccessors, bindGetters, bindAlias } = accessors;
29
+ const { createView } = require('./session/view.js');
30
+ const { createFilesPane } = require('./session/files-pane.js');
31
+ const { createLifecycle } = require('./session/lifecycle.js');
32
+ const { createPointer } = require('./session/pointer.js');
33
+ const { createDispatcher, createInput } = require('./session/dispatch.js');
33
34
 
34
- const LAYOUTS = ['unified', 'mixed', 'side'];
35
- const READ_ONLY_ORIGINS = ['commit', 'pr'];
36
- const SOURCE_KIND = {
37
- 'github-pr': 'pr',
38
- 'gitlab-mr': 'mr',
39
- };
40
- const ENTER_TERM = '\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1002h\x1b[?1006h';
41
- const LEAVE_TERM =
42
- '\x1b[?1006l\x1b[?1002l\x1b[?7h\x1b[0 q\x1b[?12l\x1b[?25h\x1b[?1049l';
43
35
  const CURSOR_BLINK_MS = 500;
44
36
  const PROGRESS_MS = 80;
45
37
 
46
38
  const systemNow = () => new Date();
47
39
 
48
- const isReadOnlyOrigin = (origin) => READ_ONLY_ORIGINS.includes(origin);
49
-
50
- const relabelStatus = (entries, from, to) => {
51
- if (!to) return entries;
52
- return entries.map((entry) => {
53
- if (entry.status !== from) return entry;
54
- return { ...entry, status: to };
55
- });
56
- };
57
-
58
- const depChangeKey = (item) => {
59
- const change = item.dep && item.dep.change;
60
- if (!change) return '';
61
- return `${change.section}/${change.name}`;
62
- };
63
-
64
- const needsReload = (item) => item.reload === true;
65
-
66
- const npmBusyKind = (item) => {
67
- const change = item && item.dep && item.dep.change;
68
- if (!change || !change.propose) return '';
69
- if (change.unused) return 'npm uninstall';
70
- if (change.section === 'resolved') return 'npm audit fix';
71
- return 'npm i';
72
- };
73
-
74
- const itemKey = (item) => {
75
- if (!item || !item.file) return '';
76
- const rel = itemPath(item);
77
- if (isTodoItem(item)) return `todo:${TODO_FILE}`;
78
- const origin = item.origin ?? '';
79
- const depId = depChangeKey(item);
80
- if (depId) return `${origin}:${rel}:dep:${depId}`;
81
- if (!item.hunk) {
82
- const block = item.blockId ?? '-';
83
- return `${origin}:${rel}:file:${block}`;
84
- }
85
- const oldStart = item.hunk.oldStart;
86
- const newStart = item.hunk.newStart;
87
- const block = item.blockId ?? '-';
88
- return `${origin}:${rel}:${oldStart}:${newStart}:${block}`;
89
- };
90
-
91
- const itemFeedKey = (item) => {
92
- if (!item || !item.file || isTodoItem(item)) return '';
93
- const hunk = item.hunk;
94
- const depId = depChangeKey(item);
95
- const file = depId ? `${itemPath(item)}#${depId}` : itemPath(item);
96
- const oldStart = hunk ? hunk.oldStart : 0;
97
- const newStart = hunk ? hunk.newStart : 0;
98
- const blockId = item.blockId ?? 0;
99
- return feedbackKey({ file, oldStart, newStart, blockId });
100
- };
101
-
102
- const canEditCode = (item) => {
103
- if (!item || isTodoItem(item)) return false;
104
- if (!item.hunk || !item.file) return false;
105
- if (item.file.isBinary) return false;
106
- return true;
107
- };
108
-
109
- const isAbortError = (error) => !!(error && error.code === 'ABORT');
110
-
111
- const sameCursor = (a, b) => {
112
- if (!a && !b) return true;
113
- if (!a || !b) return false;
114
- return a.x === b.x && a.y === b.y;
115
- };
116
-
117
- const QUIT_CONFIRM = {
118
- f: (session) => session.finishQuit('ready'),
119
- c: (session) => session.finishQuit('editing'),
120
- 'ctrl-c': (session) => session.finishQuit('ready'),
121
- escape: (session) => session.cancelQuit(),
122
- };
123
-
124
- const COMMIT_CONFIRM = {
125
- c: (session) => session.startGitCommit('commit'),
126
- a: (session) => session.startGitCommit('amend'),
127
- f: (session) => session.startGitCommit('fixup'),
128
- escape: (session) => session.cancelCommit(),
129
- };
130
-
131
- const UPDATE_CONFIRM = {
132
- y: (session) => session.acceptUpdate(),
133
- n: (session) => session.declineUpdate(),
134
- escape: (session) => session.declineUpdate(),
135
- };
136
-
137
- const DROP_CONFIRM = {
138
- y: (session) => session.confirmDropBranch(),
139
- n: (session) => session.cancelDropBranch(),
140
- escape: (session) => session.cancelDropBranch(),
141
- };
142
-
143
- const PUSH_CONFIRM = {
144
- f: (session) => session.onForcePush(),
145
- escape: (session) => session.cancelPush(),
146
- };
147
-
148
- const CONFIRM_MODE = [
149
- 'confirmQuit',
150
- 'confirmCommit',
151
- 'confirmUpdate',
152
- 'confirmDrop',
153
- 'confirmPush',
154
- ];
155
-
156
- const COMMIT_STATUS = {
157
- commit: 'committed',
158
- amend: 'amended',
159
- fixup: 'fixup',
160
- };
161
-
162
- const COMPOSE_EDITS = ['backspace', 'delete', 'ctrl-z', 'ctrl-y'];
163
-
164
- const COMPOSE_SPECIAL = {
165
- backspace: (session) => session.editor.backspace(),
166
- delete: (session) => session.editor.delete(),
167
- left: (session) => session.editor.move(-1),
168
- right: (session) => session.editor.move(1),
169
- up: (session) => session.moveComposeLine(-1),
170
- down: (session) => session.moveComposeLine(1),
171
- home: (session) => session.editor.home(),
172
- end: (session) => session.editor.end(),
173
- 'ctrl-a': (session) => session.editor.home(),
174
- 'ctrl-e': (session) => session.editor.end(),
175
- 'ctrl-z': (session) => session.editor.undoEdit(),
176
- 'ctrl-y': (session) => session.editor.redoEdit(),
177
- tab: (session) => session.composeTab(),
178
- };
179
-
180
- const HANDLERS = {
181
- add: (session) => session.onAdd(),
182
- unstage: (session) => session.onUnstage(),
183
- revert: (session) => session.onRevert(),
184
- next: (session) => session.onNext(),
185
- prev: (session) => session.onPrev(),
186
- layout: (session) => session.onLayout(),
187
- files: (session) => session.onFiles(),
188
- feedback: (session) => session.onFeedback(),
189
- todo: (session) => session.onTodo(),
190
- code: (session) => session.onCode(),
191
- reload: (session) => session.onReload(),
192
- commit: (session) => session.onCommit(),
193
- branch: (session) => session.onBranch(),
194
- pull: (session) => session.onPull(),
195
- push: (session) => session.onPush(),
196
- newBranch: (session) => session.onNewBranch(),
197
- rebase: (session) => session.onRebaseBranch(),
198
- drop: (session) => session.onDropBranch(),
199
- open: (session) => session.onOpenFile(),
200
- removeTodo: (session) => session.removeFocusedTodo(),
201
- quit: (session) => session.onQuit(),
202
- scrollUp: (session) => session.onScroll(-1),
203
- scrollDown: (session) => session.onScroll(1),
204
- pageUp: (session) => session.onScroll(session.scrollStep(-1)),
205
- pageDown: (session) => session.onScroll(session.scrollStep(1)),
206
- halfUp: (session) => session.onScroll(session.scrollStep(-0.5)),
207
- halfDown: (session) => session.onScroll(session.scrollStep(0.5)),
208
- };
209
-
210
- const KEY_MODE = {
211
- confirmQuit: (session, key) => session.onConfirmQuit(key),
212
- confirmCommit: (session, key) => session.onConfirmCommit(key),
213
- confirmUpdate: (session, key) => session.onConfirmUpdate(key),
214
- confirmDrop: (session, key) => session.onConfirmDrop(key),
215
- confirmPush: (session, key) => session.onConfirmPush(key),
216
- compose: (session, key) => session.handleComposeKey(key),
217
- };
218
-
219
- const COMPOSE_LEAVE = ['next', 'prev', 'todo'];
220
- const TODO_TYPE_SKIP = [
221
- 'enter',
222
- 'escape',
223
- 'tab',
224
- 'backspace',
225
- 'delete',
226
- 'left',
227
- 'right',
228
- 'up',
229
- 'down',
230
- 'home',
231
- 'end',
232
- 'j',
233
- 'k',
234
- 'n',
235
- 'p',
236
- 'q',
237
- ];
238
-
239
- const isTodoTypeKey = (key) => {
240
- if (!key) return false;
241
- if (TODO_TYPE_SKIP.includes(key)) return false;
242
- if (key.startsWith('ctrl-')) return false;
243
- if (key.startsWith('alt-')) return false;
244
- return key.charCodeAt(0) >= 32;
245
- };
246
-
247
- const MOUSE_KIND = {
248
- wheelUp: (session) => session.onScroll(-1),
249
- wheelDown: (session) => session.onScroll(1),
250
- press: (session, event) => session.onMousePress(event),
251
- drag: (session, event) => session.onMouseDrag(event),
252
- release: (session, event) => session.onMouseRelease(event),
40
+ const targetHasStaged = (session) => {
41
+ if (session.repo.hasStaged) return session.repo.hasStaged(session.top);
42
+ return session.items.some((item) => item.origin === 'staged');
253
43
  };
254
44
 
255
- const RELEASE_HITS = [
256
- {
257
- match: () => true,
258
- hits: (frame) => frame?.statusHits,
259
- onHit: (session) => session.onBranch(),
260
- },
261
- {
262
- match: (session) => session.pane === 'files',
263
- hits: (frame) => frame?.fileHits,
264
- onHit: (session, hit) => {
265
- session.fileCursor = hit.cursor;
266
- session.onOpenFile();
267
- },
268
- },
269
- {
270
- match: (session) => session.pane === 'branches',
271
- hits: (frame) => frame?.fileHits,
272
- onHit: (session, hit) => {
273
- session.branchCursor = hit.cursor;
274
- session.onCheckoutBranch();
275
- },
276
- },
277
- {
278
- match: (session) => session.pane === 'diff',
279
- hits: (frame) => frame?.todoHits,
280
- onHit: (session, hit) => session.onTodoHit(hit.cursor),
281
- },
282
- {
283
- match: (session) => session.mode === 'compose',
284
- hits: (frame) => frame?.templateHits,
285
- onHit: (session, hit) => session.selectTemplate(hit.cursor),
286
- },
287
- ];
288
-
289
- class Session {
290
- assignOptions(options) {
291
- this.repo = options.repo;
292
- this.cwd = options.cwd;
293
- this.paths = options.paths ?? [];
294
- this.stdin = options.stdin ?? null;
295
- this.stdout = options.stdout;
296
- this.color = options.color ?? true;
297
- this.copyText = options.copyText ?? copyText;
298
- const termSize = () => {
299
- const width = this.stdout.columns ?? 80;
300
- const height = this.stdout.rows ?? 24;
301
- return { width, height };
302
- };
303
- this.getSize = options.getSize ?? termSize;
304
- this.pane = options.startPane ?? 'files';
305
- this.rev = options.rev ?? null;
306
- this.revShort = '';
307
- this.sourceLabel = options.sourceLabel ?? '';
308
- this.repoName = options.repoName ?? '';
309
- this.change = options.change ?? null;
310
- this.readdirSync = options.readdirSync ?? fs.readdirSync;
311
- this.readFileSync = options.readFileSync ?? fs.readFileSync;
312
- this.writeFileSync = options.writeFileSync ?? fs.writeFileSync;
313
- this.mkdirSync = options.mkdirSync ?? fs.mkdirSync;
314
- this.now = options.now ?? systemNow;
315
- this.forceNewReview = options.newReview === true;
316
- this.readOnly = options.readOnly === true;
317
- this.audit = options.audit === true;
318
- this.outdatedMap = options.outdatedMap;
319
- this.auditMap = options.auditMap;
320
- this.setInterval = options.setInterval ?? setInterval;
321
- this.clearInterval = options.clearInterval ?? clearInterval;
322
- this.updateOpts = options.update;
323
- }
324
-
325
- resetState() {
326
- this.items = [];
327
- this.dismissed = new Set();
328
- this.top = this.cwd;
329
- this.index = 0;
330
- this.fileCursor = 0;
331
- this.reviewPath = null;
332
- this.branch = '';
333
- this.branches = [];
334
- this.branchCursor = 0;
335
- this.dropName = '';
336
- this.scroll = 0;
337
- this.status = '';
338
- this.layout = 'unified';
339
- this.done = false;
340
- this.exitCode = 0;
341
- this.carry = '';
342
- this.lastFrame = null;
343
- this.lastPresentedCursor = null;
344
- this.lastSize = null;
345
- this.selection = null;
346
- this.mouseAnchor = null;
347
- this.pendingClick = null;
348
- this.abortController = null;
349
- this.notes = null;
350
- this.didResumeReview = false;
351
- this.importedRemoteNotes = false;
352
- this.mode = 'review';
353
- this.editor = null;
354
- this.composeKind = null;
355
- this.commitKind = null;
356
- this.composeTodoId = null;
357
- this.composeBaseline = '';
358
- this.todoFocus = 0;
359
- this.todoOpen = false;
360
- this.todoItem = makeTodoItem();
361
- this.templateIndex = -1;
362
- this.templateFocus = false;
363
- this.saveTimer = null;
364
- this.blinkTimer = null;
365
- this.blinkOn = true;
366
- this.progressTimer = null;
367
- this.progressFrame = 0;
368
- this.gitBusy = false;
369
- this.loadGen = 0;
370
- this.loadAbort = null;
371
- this.loadPromise = null;
372
- this.busy = '';
373
- this.emptyReview = false;
374
- this.loadError = null;
375
- this.didLoad = false;
376
- this.pendingExtras = false;
377
- this.uiOpen = false;
378
- this.finishReadLoop = null;
379
- this.updateOffer = false;
380
- this.updateFrom = '';
381
- this.updateTo = '';
382
- this.updatePromise = null;
383
- this.installPromise = null;
384
- }
385
-
386
- constructor(options) {
387
- this.assignOptions(options);
388
- this.resetState();
389
- }
390
-
391
- loadOptions(extra = {}) {
392
- const commit = this.rev;
393
- const audit = this.audit;
394
- const outdatedMap = this.outdatedMap;
395
- const auditMap = this.auditMap;
396
- return { commit, audit, outdatedMap, auditMap, ...extra };
397
- }
398
-
399
- shouldDeferExtras() {
400
- if (!this.audit) return false;
401
- if (this.outdatedMap !== undefined && this.auditMap !== undefined) {
402
- return false;
403
- }
404
- return true;
405
- }
406
-
407
- bumpLoad() {
408
- this.loadGen += 1;
409
- if (this.loadAbort) this.loadAbort.abort();
410
- this.loadAbort = new AbortController();
411
- }
412
-
413
- ensureRepo() {
414
- if (!this.repo.toplevel) return;
415
- this.top = this.repo.toplevel(this.cwd);
416
- }
417
-
418
- applyLoaded(loaded) {
419
- const wasTodo = this.todoOpen === true;
420
- const here = itemKey(this.current());
421
- this.top = loaded.top ?? this.cwd;
422
- const items = loaded.items;
423
- this.items = items.filter((item) => !this.dismissed.has(itemKey(item)));
424
- if (loaded.rev) {
425
- this.rev = loaded.rev;
426
- }
427
- if (loaded.revShort) {
428
- this.revShort = loaded.revShort;
429
- } else if (this.rev && !this.revShort) {
430
- this.revShort = this.rev.slice(0, 7);
431
- }
432
- if (loaded.sourceLabel) this.sourceLabel = loaded.sourceLabel;
433
- if (loaded.change) this.change = loaded.change;
434
- if (loaded.branch !== undefined) this.branch = loaded.branch || '';
435
- const repository = this.change ? this.change.repository : '';
436
- if (!this.repoName && repository) this.repoName = repository;
437
- if (!this.notes) this.initReview();
438
- const imported = loaded.imported;
439
- if (imported && !this.didResumeReview && !this.importedRemoteNotes) {
440
- applyImportedNotes(this.notes, imported);
441
- this.importedRemoteNotes = true;
442
- }
443
- this.todoOpen = wasTodo;
444
- if (here && !wasTodo) {
445
- const idx = this.items.findIndex((item) => itemKey(item) === here);
446
- if (idx >= 0) this.index = idx;
447
- }
448
- this.clampIndex();
449
- this.syncReviewPath();
450
- this.syncFileCursor();
451
- this.didLoad = true;
452
- this.pendingExtras = loaded.pending === true;
453
- }
454
-
455
- load() {
456
- const loaded = this.repo.load(this.cwd, this.paths, this.loadOptions());
457
- this.applyLoaded(loaded);
458
- }
459
-
460
- paint() {
461
- if (!this.uiOpen) return;
462
- this.draw();
463
- }
464
-
465
- viewStatus() {
466
- if (this.status) return this.status;
467
- return this.busy;
468
- }
469
-
470
- finishIfEmpty(kind) {
471
- if (this.items.length) return;
472
- if (this.notes && hasNotes(this.notes)) return;
473
- if (kind === 'keep') return;
474
- if (this.notes && hasNotes(this.notes)) this.flushReview();
475
- this.done = true;
476
- this.exitCode = 0;
477
- this.status = 'nothing to review';
478
- this.emptyReview = kind === 'open';
479
- if (this.finishReadLoop) this.finishReadLoop();
480
- }
481
-
482
- failOpen(error) {
483
- if (isAbortError(error)) return;
484
- this.loadError = error;
485
- this.done = true;
486
- this.exitCode = 1;
487
- this.busy = '';
488
- this.stopProgress();
489
- if (this.finishReadLoop) this.finishReadLoop();
490
- }
491
-
492
- async followExtras(loaded, gen, kind) {
493
- if (!this.repo.loadExtras) {
494
- this.clearBusy();
495
- this.pendingExtras = false;
496
- this.paint();
497
- this.finishIfEmpty(kind);
498
- return;
499
- }
500
- const signal = this.loadAbort ? this.loadAbort.signal : undefined;
501
- const extra = this.loadOptions({
502
- paths: this.paths,
503
- signal,
504
- });
505
- let next;
506
- try {
507
- next = await this.repo.loadExtras(loaded, extra);
508
- } catch (error) {
509
- if (gen !== this.loadGen) return;
510
- this.failOpen(error);
511
- return;
512
- }
513
- if (gen !== this.loadGen || this.done) return;
514
- this.applyLoaded(next);
515
- this.clearBusy();
516
- this.paint();
517
- this.finishIfEmpty(kind);
518
- }
519
-
520
- openLoad() {
521
- if (this.loadPromise) return this.loadPromise;
522
- if (this.didLoad && !this.pendingExtras) {
523
- this.loadPromise = Promise.resolve();
524
- return this.loadPromise;
525
- }
526
- this.bumpLoad();
527
- this.loadPromise = this.runOpenLoad();
528
- return this.loadPromise;
529
- }
530
-
531
- async runOpenLoad() {
532
- const gen = this.loadGen;
533
- const signal = this.loadAbort.signal;
534
- const options = this.loadOptions({
535
- deferExtras: this.shouldDeferExtras(),
536
- signal,
537
- });
538
- this.progressFrame = 0;
539
- this.setBusy('loading');
540
- this.paint();
541
- let loaded;
542
- try {
543
- if (this.repo.loadAsync) {
544
- loaded = await this.repo.loadAsync(this.cwd, this.paths, options);
545
- } else {
546
- loaded = this.repo.load(this.cwd, this.paths, options);
547
- }
548
- } catch (error) {
549
- if (gen !== this.loadGen) return;
550
- this.failOpen(error);
551
- return;
552
- }
553
- if (gen !== this.loadGen || this.done) return;
554
- this.applyLoaded(loaded);
555
- if (loaded.pending) {
556
- this.setBusy('checking npm');
557
- this.paint();
558
- await this.followExtras(loaded, gen, 'open');
559
- return;
560
- }
561
- this.clearBusy();
562
- this.paint();
563
- this.finishIfEmpty('open');
564
- }
565
-
566
- async loadReady() {
567
- this.ensureRepo();
568
- await this.openLoad();
569
- if (this.loadError) throw this.loadError;
570
- }
571
-
572
- updateOptions() {
573
- if (!this.updateOpts || this.updateOpts === true) return {};
574
- return this.updateOpts;
575
- }
576
-
577
- maybePromptUpdate() {
578
- if (!this.updateOffer) return;
579
- if (!this.didLoad) return;
580
- if (this.mode !== 'review') return;
581
- this.mode = 'confirmUpdate';
582
- }
583
-
584
- openUpdate() {
585
- if (this.updateOpts === false) return Promise.resolve();
586
- if (this.updatePromise) return this.updatePromise;
587
- this.updatePromise = this.runOpenUpdate();
588
- return this.updatePromise;
589
- }
590
-
591
- async runOpenUpdate() {
592
- let plan;
593
- try {
594
- plan = await checkUpdate(this.updateOptions());
595
- } catch {
596
- return;
597
- }
598
- if (this.done) return;
599
- if (plan.action === 'install') {
600
- this.installPromise = this.runInstall(plan.latest);
601
- await this.installPromise;
602
- return;
603
- }
604
- if (plan.action !== 'confirm') return;
605
- this.updateFrom = plan.current;
606
- this.updateTo = plan.latest;
607
- this.updateOffer = true;
608
- this.paint();
609
- }
610
-
611
- async runInstall(version) {
612
- this.status = 'updating reslop';
613
- this.progressFrame = 0;
614
- this.startProgress();
615
- this.paint();
616
- let ok = false;
617
- try {
618
- await installUpdate(version, this.updateOptions());
619
- ok = true;
620
- } catch {
621
- // ignore install errors
622
- }
623
- this.stopProgress();
624
- if (this.done || !this.uiOpen) return;
625
- this.status = ok ? 'updated' : 'update failed';
626
- this.paint();
627
- }
628
-
629
- onConfirmUpdate(key) {
630
- const updateKey = key.length === 1 ? key.toLowerCase() : key;
631
- const handler = UPDATE_CONFIRM[updateKey];
632
- if (handler) handler(this);
633
- }
634
-
635
- acceptUpdate() {
636
- this.mode = 'review';
637
- this.updateOffer = false;
638
- const version = this.updateTo;
639
- this.installPromise = this.runInstall(version);
640
- }
641
-
642
- declineUpdate() {
643
- this.mode = 'review';
644
- this.updateOffer = false;
645
- this.status = '';
646
- markSkipped(this.updateTo, this.updateOptions());
647
- }
648
-
649
- initReview() {
650
- const names = listReviewNames(this.top, this.readdirSync);
651
- const templates = loadTemplates(this.top, this.readFileSync);
652
- const resolved = resolveReviewPath(this.top, this.now(), names, {
653
- forceNew: this.forceNewReview,
654
- readFileSync: this.readFileSync,
655
- });
656
- if (resolved.resume) {
657
- this.notes = loadReview(
658
- resolved.reviewPath,
659
- templates,
660
- this.readFileSync,
661
- );
662
- this.didResumeReview = true;
663
- return;
664
- }
665
- this.notes = createStore(resolved.reviewPath, templates);
666
- }
667
-
668
- repoNameOf() {
669
- return this.repoName || path.basename(this.top || this.cwd || '');
670
- }
671
-
672
- todosEntry() {
673
- const notes = noteCounts(this.notes);
674
- return {
675
- path: REPO_TODOS_LABEL,
676
- kind: 'todos',
677
- status: 'todos',
678
- remaining: notes.todo,
679
- staged: notes.todoDone,
680
- firstIndex: 0,
681
- openIndex: 0,
682
- added: 0,
683
- removed: 0,
684
- unstaged: 0,
685
- };
686
- }
687
-
688
- clampIndex() {
689
- if (!this.items.length) {
690
- this.index = 0;
691
- return;
692
- }
693
- if (this.index < 0) this.index = 0;
694
- if (this.index >= this.items.length) {
695
- this.index = this.items.length - 1;
696
- }
697
- }
698
-
699
- fileList() {
700
- const entries = fileEntries(this.items);
701
- const labeled = this.sourceLabel
702
- ? relabelStatus(entries, 'pr', this.sourceLabel)
703
- : relabelStatus(entries, 'commit', this.revShort);
704
- return [this.todosEntry(), ...labeled];
705
- }
706
-
707
- syncReviewPath() {
708
- if (this.pane !== 'diff') return;
709
- const item = this.current();
710
- this.reviewPath = item ? itemPath(item) : null;
711
- }
712
-
713
- clampFileCursor() {
714
- const files = this.fileList();
715
- if (!files.length) {
716
- this.fileCursor = 0;
717
- return;
718
- }
719
- if (this.fileCursor >= files.length) {
720
- this.fileCursor = files.length - 1;
721
- }
722
- }
723
-
724
- followReviewPath() {
725
- const files = this.fileList();
726
- if (!files.length) {
727
- this.fileCursor = 0;
728
- return;
729
- }
730
- const item = this.current();
731
- const idx = isTodoItem(item)
732
- ? files.findIndex((entry) => isTodosEntry(entry))
733
- : files.findIndex(
734
- (entry) => !isTodosEntry(entry) && entry.path === this.reviewPath,
735
- );
736
- if (idx >= 0) {
737
- this.fileCursor = idx;
738
- return;
739
- }
740
- this.clampFileCursor();
741
- }
742
-
743
- syncFileCursor() {
744
- if (this.pane === 'files') {
745
- this.clampFileCursor();
746
- return;
747
- }
748
- this.followReviewPath();
749
- }
750
-
751
- counts() {
752
- const counts = {
753
- staged: 0,
754
- unstaged: 0,
755
- untracked: 0,
756
- commit: 0,
757
- pr: 0,
758
- todo: 0,
759
- feedback: 0,
760
- code: 0,
761
- };
762
- for (const item of this.items) {
763
- if (!Object.hasOwn(counts, item.origin)) continue;
764
- counts[item.origin] += 1;
765
- }
766
- const notes = noteCounts(this.notes);
767
- counts.feedback = notes.feedback;
768
- counts.todo = notes.todo;
769
- counts.code = notes.code;
770
- return counts;
771
- }
772
-
773
- view() {
774
- const templates = this.shownTemplates();
775
- const change = this.change;
776
- const sourceKind = (change && SOURCE_KIND[change.source]) || '';
777
- const repoName = this.repoNameOf();
778
- return {
779
- item: this.current(),
780
- index: this.index,
781
- total: this.items.length,
782
- scroll: this.scroll,
783
- status: this.viewStatus(),
784
- progressFrame: this.progressFrame,
785
- layout: this.layout,
786
- counts: this.counts(),
787
- selection: this.selection,
788
- pane: this.pane,
789
- files: this.fileList(),
790
- fileCursor: this.fileCursor,
791
- reviewPath: this.reviewPath,
792
- branch: this.branch,
793
- branches: this.branches,
794
- branchCursor: this.branchCursor,
795
- repoName,
796
- revShort: this.revShort || '',
797
- sourceLabel: this.sourceLabel || '',
798
- sourceKind,
799
- mode: this.mode,
800
- compose: this.composeView(),
801
- noteText: this.idleNoteText(),
802
- todos: this.todoTexts(),
803
- todoFocus: this.clampedTodoFocus(),
804
- todoEdit: this.todoEditView(),
805
- codeOverlay: this.codeOverlayView(),
806
- templates,
807
- templateIndex: this.clampedTemplateIndex(templates),
808
- updateFrom: this.updateFrom,
809
- updateTo: this.updateTo,
810
- dropName: this.dropName,
811
- };
812
- }
813
-
814
- draw() {
815
- this.maybePromptUpdate();
816
- const size = this.getSize();
817
- const resized =
818
- !this.lastSize ||
819
- this.lastSize.width !== size.width ||
820
- this.lastSize.height !== size.height;
821
- const frame = renderFrame(this.view(), {
822
- width: size.width,
823
- height: size.height,
824
- color: this.color,
825
- });
826
- const composing = this.mode === 'compose';
827
- const cursor = composing && this.blinkOn ? frame.cursor : null;
828
- if (!resized && this.lastFrame && this.lastFrame.text === frame.text) {
829
- if (sameCursor(this.lastPresentedCursor, cursor)) return;
830
- this.lastPresentedCursor = cursor;
831
- this.stdout.write(presentCursor(cursor));
832
- return;
833
- }
834
- this.lastSize = size;
835
- this.lastFrame = frame;
836
- this.lastPresentedCursor = cursor;
837
- this.stdout.write(presentRows(frame.rows, { clear: resized, cursor }));
838
- }
839
-
840
- dispatch(action) {
841
- if (CONFIRM_MODE.includes(this.mode)) return;
842
- if (this.mode === 'compose') {
843
- if (!COMPOSE_LEAVE.includes(action)) return;
844
- this.commitCompose();
845
- this.closeCompose();
846
- }
847
- if (this.pane === 'files') {
848
- if (action === 'next') return void this.onFileMove(1);
849
- if (action === 'prev') return void this.onFileMove(-1);
850
- }
851
- if (this.pane === 'branches') {
852
- if (action === 'next') return void this.onBranchMove(1);
853
- if (action === 'prev') return void this.onBranchMove(-1);
854
- }
855
- let target = this.current();
856
- if (this.pane === 'files') target = this.fileCursorEntry();
857
- if (this.pane === 'branches') target = this.branches[this.branchCursor];
858
- const hidden = disabledActions(this.pane, target);
859
- if (hidden.includes(action)) return;
860
- const handler = HANDLERS[action];
861
- if (!handler) return;
862
- handler(this);
863
- }
864
-
865
- current() {
866
- if (this.todoOpen) return this.todoItem;
867
- return this.items[this.index] ?? null;
868
- }
869
-
870
- refreshFromRepo(options = {}) {
871
- this.loadPromise = null;
872
- this.bumpLoad();
873
- if (this.uiOpen && this.repo.loadAsync) {
874
- return void this.refreshFromRepoAsync(options);
875
- }
876
- const loadedOpts = this.loadOptions({
877
- deferExtras: this.shouldDeferExtras(),
878
- });
879
- const loaded = this.repo.load(this.cwd, this.paths, loadedOpts);
880
- this.applyLoaded(loaded);
881
- this.scroll = 0;
882
- this.clearSelection();
883
- if (options.afterLoad) options.afterLoad();
884
- const keepEmpty = options.keepEmpty === true;
885
- if (loaded.pending) {
886
- this.setBusy('checking npm');
887
- const kind = keepEmpty ? 'keep' : 'reload';
888
- this.followExtras(loaded, this.loadGen, kind);
889
- return;
890
- }
891
- if (keepEmpty) return;
892
- this.finishIfEmpty('reload');
893
- if (options.doneStatus && !this.done && !this.pendingExtras) {
894
- this.status = options.doneStatus;
895
- }
896
- }
897
-
898
- async refreshFromRepoAsync(options = {}) {
899
- const gen = this.loadGen;
900
- const keepEmpty = options.keepEmpty === true;
901
- const loadedOpts = this.loadOptions({
902
- deferExtras: this.shouldDeferExtras(),
903
- signal: this.loadAbort ? this.loadAbort.signal : undefined,
904
- });
905
- this.progressFrame = 0;
906
- this.setBusy('loading');
907
- this.paint();
908
- let loaded;
909
- try {
910
- loaded = await this.repo.loadAsync(this.cwd, this.paths, loadedOpts);
911
- } catch (error) {
912
- if (gen !== this.loadGen) return;
913
- this.failOpen(error);
914
- return;
915
- }
916
- if (gen !== this.loadGen || this.done) return;
917
- this.applyLoaded(loaded);
918
- this.scroll = 0;
919
- this.clearSelection();
920
- if (options.afterLoad) options.afterLoad();
921
- if (loaded.pending) {
922
- this.setBusy('checking npm');
923
- const kind = keepEmpty ? 'keep' : 'reload';
924
- await this.followExtras(loaded, gen, kind);
925
- return;
926
- }
927
- this.clearBusy();
928
- if (!keepEmpty) this.finishIfEmpty('reload');
929
- if (options.doneStatus && !this.status && !this.busy) {
930
- this.status = options.doneStatus;
931
- }
932
- this.paint();
933
- }
934
-
935
- reloadAfterChange(afterLoad) {
936
- const here = this.index;
937
- const restore = () => {
938
- this.index = here;
939
- this.clampIndex();
940
- this.syncReviewPath();
941
- this.syncFileCursor();
942
- if (afterLoad) afterLoad();
943
- };
944
- if (this.uiOpen && this.repo.loadAsync) {
945
- this.refreshFromRepo({ afterLoad: restore });
946
- return;
947
- }
948
- this.refreshFromRepo();
949
- restore();
950
- }
951
-
952
- onReload() {
953
- this.dismissed.clear();
954
- this.status = '';
955
- this.refreshFromRepo({ doneStatus: 'reloaded' });
956
- }
957
-
958
- clearSelection() {
959
- this.selection = null;
960
- this.mouseAnchor = null;
961
- this.dragging = false;
962
- this.pendingClick = null;
963
- }
964
-
965
- applyGit(item, label, write, after) {
966
- try {
967
- write(this.top, item);
968
- this.status = label;
969
- after();
970
- } catch (error) {
971
- this.status = error.message;
972
- }
973
- }
974
-
975
- keepOrigin(item, origin) {
976
- item.origin = origin;
977
- if (!item.dep) refreshIndexPatches(this.items, item);
978
- this.clearSelection();
979
- this.syncReviewPath();
980
- this.syncFileCursor();
981
- }
982
-
983
- activeDiffItem() {
984
- const item = this.current();
985
- if (!item) return null;
986
- if (isTodoItem(item)) {
987
- this.status = 'not a diff block';
988
- return null;
989
- }
990
- return item;
991
- }
992
-
993
- fileCursorEntry() {
994
- const files = this.fileList();
995
- return files[this.fileCursor] ?? null;
996
- }
997
-
998
- fileActionItems() {
999
- const entry = this.fileCursorEntry();
1000
- if (!entry || isTodosEntry(entry)) return [];
1001
- const rel = entry.path;
1002
- const items = [];
1003
- for (const item of this.items) {
1004
- if (isTodoItem(item)) continue;
1005
- if (itemPath(item) !== rel) continue;
1006
- items.push(item);
1007
- }
1008
- return items;
1009
- }
1010
-
1011
- guardWritable(item) {
1012
- if (!item) return false;
1013
- if (this.readOnly || isReadOnlyOrigin(item.origin)) {
1014
- this.status = 'read only';
1015
- return false;
1016
- }
1017
- return true;
1018
- }
1019
-
1020
- guardFileGit(items) {
1021
- if (!items.length) return false;
1022
- return this.guardWritable(items[0]);
1023
- }
1024
-
1025
- applyFileGit(items, label, write, afterItem) {
1026
- try {
1027
- for (const item of items) {
1028
- write(this.top, item);
1029
- afterItem(item);
1030
- }
1031
- this.status = label;
1032
- return true;
1033
- } catch (error) {
1034
- this.status = error.message;
1035
- return false;
1036
- }
1037
- }
1038
-
1039
- selectNextFileAfter(rel) {
1040
- const files = this.fileList();
1041
- if (!files.length) {
1042
- this.fileCursor = 0;
1043
- this.reviewPath = null;
1044
- return;
1045
- }
1046
- const idx = files.findIndex((entry) => entry.path === rel);
1047
- let at = idx < 0 ? this.fileCursor : idx + 1;
1048
- if (at >= files.length) at = files.length - 1;
1049
- const entry = files[at];
1050
- this.fileCursor = at;
1051
- this.reviewPath = entry.path;
1052
- this.index = entry.firstIndex;
1053
- }
1054
-
1055
- onFileAdd() {
1056
- const items = this.fileActionItems();
1057
- if (!items.length) {
1058
- this.status = 'not a diff block';
1059
- return;
1060
- }
1061
- if (!this.guardFileGit(items)) return;
1062
- const rel = itemPath(items[0]);
1063
- const targets = items.filter((item) => item.origin !== 'staged');
1064
- if (!targets.length) {
1065
- this.status = 'already staged';
1066
- this.selectNextFileAfter(rel);
1067
- return;
1068
- }
1069
- const proposed = targets.some((item) => needsReload(item));
1070
- const busy = targets.map(npmBusyKind).find((kind) => kind);
1071
- if (this.uiOpen && busy && this.repo.addAsync) {
1072
- const write = async (top) => {
1073
- for (const item of targets) {
1074
- await this.repo.addAsync(top, item);
1075
- this.keepOrigin(item, 'staged');
1076
- }
1077
- };
1078
- const after = () => {
1079
- if (proposed) {
1080
- this.reloadAfterChange(() => this.selectNextFileAfter(rel));
1081
- return;
1082
- }
1083
- this.selectNextFileAfter(rel);
1084
- };
1085
- return void this.runGitBusy(busy, 'staged', write, after);
1086
- }
1087
- const ok = this.applyFileGit(
1088
- targets,
1089
- 'staged',
1090
- (top, current) => this.repo.add(top, current),
1091
- (item) => this.keepOrigin(item, 'staged'),
1092
- );
1093
- if (ok && proposed) this.reloadAfterChange();
1094
- if (ok) this.selectNextFileAfter(rel);
1095
- }
1096
-
1097
- onFileUnstage() {
1098
- const items = this.fileActionItems();
1099
- if (!items.length) {
1100
- this.status = 'not a diff block';
1101
- return;
1102
- }
1103
- if (!this.guardFileGit(items)) return;
1104
- const rel = itemPath(items[0]);
1105
- const targets = items.filter((item) => item.origin === 'staged');
1106
- if (!targets.length) {
1107
- this.status = 'not staged';
1108
- this.selectNextFileAfter(rel);
1109
- return;
1110
- }
1111
- const ok = this.applyFileGit(
1112
- targets,
1113
- 'unstaged',
1114
- (top, current) => this.repo.unstage(top, current),
1115
- (item) => {
1116
- const origin = item.file.isNew === true ? 'untracked' : 'unstaged';
1117
- this.keepOrigin(item, origin);
1118
- },
1119
- );
1120
- if (ok) this.selectNextFileAfter(rel);
1121
- }
1122
-
1123
- onFileRevert() {
1124
- const items = this.fileActionItems();
1125
- if (!items.length) {
1126
- this.status = 'not a diff block';
1127
- return;
1128
- }
1129
- if (!this.guardFileGit(items)) return;
1130
- const rel = itemPath(items[0]);
1131
- for (const item of items) {
1132
- if (needsReload(item)) this.dismissed.add(itemKey(item));
1133
- }
1134
- let ok = false;
1135
- try {
1136
- this.repo.revertFile(this.top, rel, items);
1137
- this.status = 'dropped';
1138
- ok = true;
1139
- } catch (error) {
1140
- this.status = error.message;
1141
- }
1142
- this.reloadAfterChange(ok ? () => this.selectNextFileAfter(rel) : null);
1143
- }
1144
-
1145
- onAdd() {
1146
- if (this.pane === 'files') return void this.onFileAdd();
1147
- const item = this.activeDiffItem();
1148
- if (!this.guardWritable(item)) return;
1149
- if (item.origin === 'staged') {
1150
- this.status = 'already staged';
1151
- return;
1152
- }
1153
- const busy = npmBusyKind(item);
1154
- if (this.uiOpen && busy && this.repo.addAsync) {
1155
- const after = () => {
1156
- if (needsReload(item)) this.reloadAfterChange();
1157
- else this.keepOrigin(item, 'staged');
1158
- };
1159
- const write = (top) => this.repo.addAsync(top, item);
1160
- return void this.runGitBusy(busy, 'staged', write, after);
1161
- }
1162
- this.applyGit(
1163
- item,
1164
- 'staged',
1165
- (top, current) => this.repo.add(top, current),
1166
- () => {
1167
- if (needsReload(item)) {
1168
- this.reloadAfterChange();
1169
- return;
1170
- }
1171
- this.keepOrigin(item, 'staged');
1172
- },
1173
- );
1174
- }
1175
-
1176
- onUnstage() {
1177
- if (this.pane === 'files') return void this.onFileUnstage();
1178
- const item = this.activeDiffItem();
1179
- if (!this.guardWritable(item)) return;
1180
- if (item.origin !== 'staged') {
1181
- this.status = 'not staged';
1182
- return;
1183
- }
1184
- const origin = item.file.isNew === true ? 'untracked' : 'unstaged';
1185
- this.applyGit(
1186
- item,
1187
- 'unstaged',
1188
- (top, current) => this.repo.unstage(top, current),
1189
- () => this.keepOrigin(item, origin),
1190
- );
1191
- }
1192
-
1193
- onRevert() {
1194
- if (this.pane === 'files') return void this.onFileRevert();
1195
- const item = this.activeDiffItem();
1196
- if (!this.guardWritable(item)) return;
1197
- this.applyGit(
1198
- item,
1199
- 'dropped',
1200
- (top, current) => this.repo.revert(top, current),
1201
- () => {
1202
- if (needsReload(item)) this.dismissed.add(itemKey(item));
1203
- this.reloadAfterChange();
1204
- },
1205
- );
1206
- }
1207
-
1208
- moveRemaining(step) {
1209
- if (!this.items.length) {
1210
- this.status = 'nothing to review';
1211
- return;
1212
- }
1213
- const next = this.index + step;
1214
- if (next < 0 || next >= this.items.length) return;
1215
- this.index = next;
1216
- this.scroll = 0;
1217
- this.status = '';
1218
- this.clearSelection();
1219
- this.syncReviewPath();
1220
- }
1221
-
1222
- onNext() {
1223
- if (this.todoOpen) {
1224
- this.closeTodoPage();
1225
- if (this.items.length) this.index = 0;
1226
- this.scroll = 0;
1227
- this.syncReviewPath();
1228
- this.syncFileCursor();
1229
- return;
1230
- }
1231
- this.moveRemaining(1);
1232
- }
1233
-
1234
- onPrev() {
1235
- if (this.todoOpen) return;
1236
- if (this.index <= 0) {
1237
- this.openTodoPage();
1238
- return;
1239
- }
1240
- this.moveRemaining(-1);
1241
- }
1242
-
1243
- onQuit() {
1244
- if (this.mode === 'compose') {
1245
- this.commitCompose();
1246
- this.closeCompose();
1247
- }
1248
- if (hasNotes(this.notes)) {
1249
- this.mode = 'confirmQuit';
1250
- this.status = '';
1251
- return;
1252
- }
1253
- this.done = true;
1254
- this.exitCode = 0;
1255
- }
1256
-
1257
- finishQuit(status) {
1258
- if (this.notes) {
1259
- this.notes.status = status;
1260
- this.notes.dirty = true;
1261
- }
1262
- this.flushReview();
1263
- this.done = true;
1264
- this.exitCode = 0;
1265
- }
1266
-
1267
- cancelQuit() {
1268
- this.mode = 'review';
1269
- this.status = '';
1270
- }
1271
-
1272
- onConfirmQuit(key) {
1273
- const quitKey = key.length === 1 ? key.toLowerCase() : key;
1274
- const handler = QUIT_CONFIRM[quitKey];
1275
- if (handler) handler(this);
1276
- }
1277
-
1278
- canCommit() {
1279
- if (this.readOnly || this.rev || this.change) {
1280
- this.status = 'read only';
1281
- return false;
1282
- }
1283
- for (const item of this.items) {
1284
- if (isReadOnlyOrigin(item.origin)) {
1285
- this.status = 'read only';
1286
- return false;
1287
- }
1288
- }
1289
- return true;
1290
- }
1291
-
1292
- onCommit() {
1293
- if (!this.canCommit()) return;
1294
- this.mode = 'confirmCommit';
1295
- this.status = '';
1296
- }
1297
-
1298
- cancelCommit() {
1299
- this.mode = 'review';
1300
- this.commitKind = null;
1301
- this.status = '';
1302
- }
1303
-
1304
- startGitCommit(kind) {
1305
- if (kind !== 'amend' && !this.hasStagedChanges()) {
1306
- this.mode = 'review';
1307
- this.commitKind = null;
1308
- this.status = 'nothing to commit';
1309
- return;
1310
- }
1311
- this.commitKind = kind;
1312
- let text = '';
1313
- if (kind === 'fixup') text = 'HEAD';
1314
- if (kind === 'amend' && this.repo.lastMessage) {
1315
- text = this.repo.lastMessage(this.top) ?? '';
1316
- }
1317
- this.openCompose('commit', text);
1318
- }
1319
-
1320
- hasStagedChanges() {
1321
- if (this.repo.hasStaged) return this.repo.hasStaged(this.top);
1322
- for (const item of this.items) {
1323
- if (item.origin === 'staged') return true;
1324
- }
1325
- return false;
1326
- }
1327
-
1328
- onConfirmCommit(key) {
1329
- const commitKey = key.length === 1 ? key.toLowerCase() : key;
1330
- const handler = COMMIT_CONFIRM[commitKey];
1331
- if (handler) handler(this);
1332
- }
1333
-
1334
- finishGitCommit() {
1335
- const kind = this.commitKind;
1336
- const message = this.editor ? this.editor.text : '';
1337
- if (kind !== 'fixup' && !message.trim()) {
1338
- this.status = 'empty commit message';
1339
- return;
1340
- }
1341
- if (kind !== 'amend' && !this.hasStagedChanges()) {
1342
- this.closeCompose();
1343
- this.status = 'nothing to commit';
1344
- return;
1345
- }
1346
- const done = COMMIT_STATUS[kind] ?? 'committed';
1347
- if (this.uiOpen && this.repo.commitAsync) {
1348
- this.closeCompose();
1349
- const write = (top) => this.repo.commitAsync(top, kind, message);
1350
- const after = () => {
1351
- this.dismissed.clear();
1352
- this.refreshFromRepo({ doneStatus: done });
1353
- };
1354
- return void this.runGitBusy('committing', done, write, after);
1355
- }
1356
- try {
1357
- this.repo.commit(this.top, kind, message);
1358
- } catch (error) {
1359
- this.status = error.message;
1360
- return;
1361
- }
1362
- this.closeCompose();
1363
- this.dismissed.clear();
1364
- this.refreshFromRepo();
1365
- if (!this.done && !this.pendingExtras) this.status = done;
1366
- }
1367
-
1368
- startProgress() {
1369
- if (this.progressTimer !== null) return;
1370
- this.progressTimer = this.setInterval(
1371
- () => this.tickProgress(),
1372
- PROGRESS_MS,
1373
- );
1374
- }
1375
-
1376
- stopProgress() {
1377
- if (this.progressTimer === null) return;
1378
- this.clearInterval(this.progressTimer);
1379
- this.progressTimer = null;
1380
- }
1381
-
1382
- tickProgress() {
1383
- this.progressFrame += 1;
1384
- this.paint();
1385
- }
1386
-
1387
- setBusy(kind) {
1388
- this.busy = kind;
1389
- this.startProgress();
1390
- }
1391
-
1392
- clearBusy() {
1393
- this.busy = '';
1394
- this.stopProgress();
1395
- }
1396
-
1397
- async runGitBusy(kind, done, write, after) {
1398
- if (this.gitBusy) return;
1399
- this.gitBusy = true;
1400
- this.status = '';
1401
- this.progressFrame = 0;
1402
- this.setBusy(kind);
1403
- this.paint();
1404
- try {
1405
- await write(this.top);
1406
- } catch (error) {
1407
- if (error.rejected && kind !== 'force pushing') {
1408
- this.mode = 'confirmPush';
1409
- } else {
1410
- this.status = error.message;
1411
- }
1412
- }
1413
- this.clearBusy();
1414
- this.gitBusy = false;
1415
- if (this.done) {
1416
- this.paint();
1417
- return;
1418
- }
1419
- if (this.mode === 'confirmPush') {
1420
- this.paint();
1421
- return;
1422
- }
1423
- if (!this.status && after) after();
1424
- if (!this.status && !this.busy) this.status = done;
1425
- this.paint();
1426
- }
1427
-
1428
- runRepo(label, write) {
1429
- try {
1430
- write(this.top);
1431
- this.status = label;
1432
- return true;
1433
- } catch (error) {
1434
- this.status = error.message;
1435
- return false;
1436
- }
1437
- }
1438
-
1439
- onBranch() {
1440
- if (this.mode === 'compose') return;
1441
- if (!this.canCommit()) return;
1442
- if (!this.repo.listBranches) {
1443
- this.status = 'read only';
1444
- return;
1445
- }
1446
- try {
1447
- this.branches = this.repo.listBranches(this.top);
1448
- } catch (error) {
1449
- this.status = error.message;
1450
- return;
1451
- }
1452
- const current = this.branches.findIndex((entry) => entry.current);
1453
- this.branchCursor = current < 0 ? 0 : current;
1454
- this.pane = 'branches';
1455
- this.status = '';
1456
- this.clearSelection();
1457
- }
1458
-
1459
- onBranchMove(delta) {
1460
- const branches = this.branches;
1461
- if (!branches.length) {
1462
- this.status = 'nothing to review';
1463
- return;
1464
- }
1465
- const next = Math.min(
1466
- branches.length - 1,
1467
- Math.max(0, this.branchCursor + delta),
1468
- );
1469
- if (next === this.branchCursor) return;
1470
- this.branchCursor = next;
1471
- this.status = '';
1472
- }
1473
-
1474
- onCheckoutBranch() {
1475
- if (!this.canCommit()) return;
1476
- const entry = this.branches[this.branchCursor];
1477
- if (!entry) return;
1478
- if (entry.current) {
1479
- this.showFiles();
1480
- return;
1481
- }
1482
- const name = entry.name;
1483
- const done = `checked out ${name}`;
1484
- if (this.uiOpen && this.repo.checkoutAsync) {
1485
- this.showFiles();
1486
- const write = (top) => this.repo.checkoutAsync(top, name);
1487
- const after = () =>
1488
- this.refreshFromRepo({ keepEmpty: true, doneStatus: done });
1489
- return void this.runGitBusy('checking out', done, write, after);
1490
- }
1491
- const ok = this.runRepo(done, (top) => {
1492
- this.repo.checkout(top, name);
1493
- });
1494
- if (!ok) return;
1495
- this.showFiles();
1496
- this.refreshFromRepo({ keepEmpty: true });
1497
- if (!this.status) this.status = done;
1498
- }
1499
-
1500
- refreshBranchList() {
1501
- if (!this.repo.listBranches) return;
1502
- try {
1503
- this.branches = this.repo.listBranches(this.top);
1504
- } catch (error) {
1505
- this.status = error.message;
1506
- return;
1507
- }
1508
- const last = this.branches.length - 1;
1509
- if (this.branchCursor > last) this.branchCursor = Math.max(0, last);
1510
- }
1511
-
1512
- onRebaseBranch() {
1513
- if (this.pane !== 'branches') return;
1514
- if (!this.canCommit()) return;
1515
- const entry = this.branches[this.branchCursor];
1516
- if (!entry || entry.current) return;
1517
- const onto = entry.name;
1518
- const done = `rebased onto ${onto}`;
1519
- if (this.uiOpen && this.repo.rebaseAsync) {
1520
- const write = (top) => this.repo.rebaseAsync(top, onto);
1521
- const after = () => {
1522
- this.refreshBranchList();
1523
- this.refreshFromRepo({ keepEmpty: true, doneStatus: done });
1524
- };
1525
- return void this.runGitBusy('rebasing', done, write, after);
1526
- }
1527
- const ok = this.runRepo(done, (top) => this.repo.rebase(top, onto));
1528
- if (!ok) return;
1529
- this.refreshBranchList();
1530
- this.refreshFromRepo({ keepEmpty: true });
1531
- if (!this.status) this.status = done;
1532
- }
1533
-
1534
- onDropBranch() {
1535
- if (this.pane !== 'branches') return;
1536
- if (!this.canCommit()) return;
1537
- const entry = this.branches[this.branchCursor];
1538
- if (!entry || entry.current) return;
1539
- this.dropName = entry.name;
1540
- this.mode = 'confirmDrop';
1541
- this.status = '';
1542
- }
1543
-
1544
- onConfirmDrop(key) {
1545
- const dropKey = key.length === 1 ? key.toLowerCase() : key;
1546
- const handler = DROP_CONFIRM[dropKey];
1547
- if (handler) handler(this);
1548
- }
1549
-
1550
- cancelDropBranch() {
1551
- this.mode = 'review';
1552
- this.dropName = '';
1553
- this.status = '';
1554
- }
1555
-
1556
- confirmDropBranch() {
1557
- const name = this.dropName;
1558
- this.mode = 'review';
1559
- this.dropName = '';
1560
- if (!name) return;
1561
- const done = `dropped ${name}`;
1562
- if (this.uiOpen && this.repo.dropAsync) {
1563
- const write = (top) => this.repo.dropAsync(top, name);
1564
- const after = () => {
1565
- this.refreshBranchList();
1566
- this.refreshFromRepo({ keepEmpty: true, doneStatus: done });
1567
- };
1568
- return void this.runGitBusy('dropping', done, write, after);
1569
- }
1570
- const ok = this.runRepo(done, (top) => this.repo.drop(top, name));
1571
- if (!ok) return;
1572
- this.refreshBranchList();
1573
- this.refreshFromRepo({ keepEmpty: true });
1574
- if (!this.status) this.status = done;
1575
- }
1576
-
1577
- onNewBranch() {
1578
- if (this.pane !== 'branches') return;
1579
- if (!this.canCommit()) return;
1580
- this.openCompose('branch', '');
1581
- }
1582
-
1583
- finishCreateBranch() {
1584
- const name = this.editor ? this.editor.text.trim() : '';
1585
- if (!name) {
1586
- this.status = 'empty branch name';
1587
- return;
1588
- }
1589
- const done = `created ${name}`;
1590
- if (this.uiOpen && this.repo.createBranchAsync) {
1591
- this.closeCompose();
1592
- this.showFiles();
1593
- const write = (top) => this.repo.createBranchAsync(top, name);
1594
- const after = () =>
1595
- this.refreshFromRepo({ keepEmpty: true, doneStatus: done });
1596
- return void this.runGitBusy('creating branch', done, write, after);
1597
- }
1598
- const ok = this.runRepo(done, (top) => {
1599
- this.repo.createBranch(top, name);
1600
- });
1601
- if (!ok) return;
1602
- this.closeCompose();
1603
- this.showFiles();
1604
- this.refreshFromRepo({ keepEmpty: true });
1605
- if (!this.status) this.status = done;
1606
- }
1607
-
1608
- onPull() {
1609
- if (!this.canCommit()) return;
1610
- if (this.repo.pullAsync) {
1611
- const after = () => this.refreshFromRepo({ keepEmpty: true });
1612
- const write = (top) => this.repo.pullAsync(top);
1613
- return void this.runGitBusy('pulling', 'pulled', write, after);
1614
- }
1615
- const ok = this.runRepo('pulled', (top) => this.repo.pull(top));
1616
- if (!ok) return;
1617
- this.refreshFromRepo({ keepEmpty: true });
1618
- if (!this.status) this.status = 'pulled';
1619
- }
1620
-
1621
- onPush() {
1622
- this.startPush(false);
1623
- }
1624
-
1625
- onForcePush() {
1626
- this.mode = 'review';
1627
- this.startPush(true);
1628
- }
1629
-
1630
- cancelPush() {
1631
- this.mode = 'review';
1632
- this.status = '';
1633
- }
1634
-
1635
- onConfirmPush(key) {
1636
- const pushKey = key.length === 1 ? key.toLowerCase() : key;
1637
- const handler = PUSH_CONFIRM[pushKey];
1638
- if (handler) handler(this);
1639
- }
1640
-
1641
- startPush(force) {
1642
- if (!this.canCommit()) return;
1643
- const done = force ? 'force pushed' : 'pushed';
1644
- const kind = force ? 'force pushing' : 'pushing';
1645
- if (this.repo.pushAsync) {
1646
- const write = (top) => this.repo.pushAsync(top, force);
1647
- return void this.runGitBusy(kind, done, write);
1648
- }
1649
- try {
1650
- this.repo.push(this.top, force);
1651
- } catch (error) {
1652
- if (error.rejected && !force) {
1653
- this.mode = 'confirmPush';
1654
- this.status = '';
1655
- return;
1656
- }
1657
- this.status = error.message;
1658
- return;
1659
- }
1660
- this.status = done;
1661
- }
1662
-
1663
- onFeedback() {
1664
- if (this.pane === 'files') {
1665
- this.status = 'not a diff block';
1666
- return;
1667
- }
1668
- const item = this.current();
1669
- if (!item || isTodoItem(item)) {
1670
- this.status = 'not a diff block';
1671
- return;
1672
- }
1673
- const prev = this.notes.feedback.get(itemFeedKey(item));
1674
- const text = prev ? prev.text : '';
1675
- this.openCompose('feedback', text);
1676
- }
1677
-
1678
- onTodo() {
1679
- if (this.pane === 'files') {
1680
- const entry = this.fileCursorEntry();
1681
- if (entry && !isTodosEntry(entry)) {
1682
- this.index = entry.openIndex ?? entry.firstIndex;
1683
- }
1684
- }
1685
- this.createTodo();
1686
- }
1687
-
1688
- onCode() {
1689
- if (this.pane === 'files') {
1690
- this.status = 'not a diff block';
1691
- return;
1692
- }
1693
- const item = this.current();
1694
- if (!canEditCode(item)) {
1695
- this.status = 'not a diff block';
1696
- return;
1697
- }
1698
- const prev = this.notes.code.get(itemFeedKey(item));
1699
- const original = blockAddText(item.hunk, item.blockId);
1700
- const text = prev ? prev.text : original;
1701
- this.openCompose('code', text);
1702
- }
1703
-
1704
- focusTodoPage(todoId) {
1705
- this.openTodoPage();
1706
- const todos = this.listTodos();
1707
- let focus = 0;
1708
- if (todoId === null) {
1709
- focus = todos.length;
1710
- } else if (todoId !== undefined) {
1711
- const at = todos.findIndex((todo) => todo.id === todoId);
1712
- if (at >= 0) focus = at;
1713
- else if (todos.length) focus = todos.length - 1;
1714
- }
1715
- this.todoFocus = focus;
1716
- this.scroll = 0;
1717
- return true;
1718
- }
1719
-
1720
- isDraftCompose() {
1721
- return (
1722
- this.mode === 'compose' &&
1723
- this.composeKind === 'todo' &&
1724
- this.composeTodoId === null
1725
- );
1726
- }
1727
-
1728
- openTodoPage() {
1729
- this.todoOpen = true;
1730
- this.pane = 'diff';
1731
- this.scroll = 0;
1732
- this.clearSelection();
1733
- this.syncReviewPath();
1734
- }
1735
-
1736
- closeTodoPage() {
1737
- this.todoOpen = false;
1738
- this.syncReviewPath();
1739
- this.syncFileCursor();
1740
- }
1741
-
1742
- startDraftCompose() {
1743
- this.focusTodoPage(null);
1744
- this.openCompose('todo', '', null);
1745
- }
1746
-
1747
- editFocusedTodo() {
1748
- if (!this.todoOpen) return;
1749
- const todos = this.listTodos();
1750
- const focus = this.clampedTodoFocus();
1751
- const todo = todos[focus];
1752
- if (!todo) {
1753
- this.startDraftCompose();
1754
- return;
1755
- }
1756
- this.openCompose('todo', todo.text, todo.id);
1757
- }
1758
-
1759
- editNextTodo() {
1760
- if (!this.todoOpen) return;
1761
- const last = this.todoRowCount() - 1;
1762
- const next = this.clampedTodoFocus() + 1;
1763
- if (next <= last) this.todoFocus = next;
1764
- this.editFocusedTodo();
1765
- }
1766
-
1767
- moveTodoCompose(delta) {
1768
- if (!this.todoOpen) return;
1769
- const last = this.todoRowCount() - 1;
1770
- const next = this.clampedTodoFocus() + delta;
1771
- if (next < 0 || next > last) return;
1772
- this.commitCompose();
1773
- this.flushReview();
1774
- this.closeCompose();
1775
- const max = Math.max(0, this.todoRowCount() - 1);
1776
- this.todoFocus = Math.min(next, max);
1777
- this.editFocusedTodo();
1778
- }
45
+ const NAV_FIELDS = [
46
+ 'pane',
47
+ 'index',
48
+ 'fileCursor',
49
+ 'branchCursor',
50
+ 'commitCursor',
51
+ 'scroll',
52
+ 'listScroll',
53
+ 'reviewPath',
54
+ 'selection',
55
+ 'mouseAnchor',
56
+ 'pendingClick',
57
+ 'todoOpen',
58
+ 'todoFocus',
59
+ ];
1779
60
 
1780
- typeIntoTodo(key) {
1781
- if (!this.todoOpen) return false;
1782
- if (this.pane !== 'diff') return false;
1783
- if (this.mode !== 'review') return false;
1784
- if (!isTodoTypeKey(key)) return false;
1785
- this.editFocusedTodo();
1786
- this.editor.insert(key);
1787
- this.resetBlink();
1788
- return true;
1789
- }
61
+ const COMPOSER_FIELDS = [
62
+ 'editor',
63
+ 'composeKind',
64
+ 'commitKind',
65
+ 'composeTodoId',
66
+ 'composeBaseline',
67
+ 'templateIndex',
68
+ 'templateFocus',
69
+ 'blinkOn',
70
+ ];
1790
71
 
1791
- removeFocusedTodo() {
1792
- if (this.pane !== 'diff' || !this.todoOpen) return;
1793
- const todos = this.listTodos();
1794
- const focus = this.clampedTodoFocus();
1795
- const todo = todos[focus];
1796
- if (!todo) return;
1797
- removeTodo(this.notes, todo.id);
1798
- const left = this.listTodos();
1799
- if (this.todoFocus >= left.length) {
1800
- this.todoFocus = Math.max(0, left.length - 1);
1801
- }
1802
- this.flushReview(true);
1803
- }
72
+ const assignOptions = (session, options) => {
73
+ session.repo = options.repo;
74
+ session.cwd = options.cwd;
75
+ session.paths = options.paths ?? [];
76
+ session.stdin = options.stdin ?? null;
77
+ session.stdout = options.stdout;
78
+ session.proc = options.proc ?? process;
79
+ session.color = options.color ?? true;
80
+ session.copyText = options.copyText ?? copyText;
81
+ const termSize = () => {
82
+ const width = session.stdout.columns ?? 80;
83
+ const height = session.stdout.rows ?? 24;
84
+ return { width, height };
85
+ };
86
+ session.getSize = options.getSize ?? termSize;
87
+ session.rev = options.rev ?? null;
88
+ session.revShort = '';
89
+ session.sourceLabel = options.sourceLabel ?? '';
90
+ session.repoName = options.repoName ?? '';
91
+ session.change = options.change ?? null;
92
+ session.now = options.now ?? systemNow;
93
+ session.forceNewReview = options.newReview === true;
94
+ session.readOnly = options.readOnly === true;
95
+ session.capabilities = sessionCapabilities(options);
96
+ const storage = options.reviewStorage ?? createReviewStorage(options);
97
+ session.audit = options.audit === true;
98
+ session.outdatedMap = options.outdatedMap;
99
+ session.auditMap = options.auditMap;
100
+ session.setInterval = options.setInterval ?? setInterval;
101
+ session.clearInterval = options.clearInterval ?? clearInterval;
102
+ session.watchOpts = {
103
+ watch: options.watch,
104
+ readdirSync: options.watchReaddir,
105
+ statSync: options.watchStat,
106
+ setTimeout: options.setTimeout ?? setTimeout,
107
+ clearTimeout: options.clearTimeout ?? clearTimeout,
108
+ debounceMs: options.watchDebounceMs,
109
+ recursive: options.watchRecursive,
110
+ };
111
+ session.updateOpts = options.update;
112
+ session.term = createTerminal({
113
+ stdin: session.stdin,
114
+ stdout: session.stdout,
115
+ proc: session.proc,
116
+ setInterval: session.setInterval,
117
+ clearInterval: session.clearInterval,
118
+ });
119
+ session.loader = createLoadCoordinator();
120
+ session.collection = createItemCollection();
121
+ session.nav = createNavigation({
122
+ startPane: options.startPane ?? 'files',
123
+ });
124
+ session.review = createReviewController({ storage });
125
+ session.progress = createProgress(
126
+ session.term,
127
+ () => session.tickProgress(),
128
+ PROGRESS_MS,
129
+ );
130
+ };
1804
131
 
1805
- onTodoHit(cursor) {
1806
- if (!this.todoOpen) return;
1807
- const todos = this.listTodos();
1808
- const draft = cursor === todos.length;
1809
- const target = todos[cursor];
1810
- if (!draft && !target) return;
1811
- if (this.mode === 'compose' && this.composeKind === 'todo') {
1812
- if (draft && this.composeTodoId === null) return;
1813
- if (target && target.id === this.composeTodoId) return;
1814
- this.commitCompose();
1815
- this.closeCompose();
1816
- }
1817
- this.todoFocus = cursor;
1818
- this.editFocusedTodo();
1819
- }
132
+ const assignUpdater = (session) => {
133
+ session.updater = createUpdateCoordinator({
134
+ updateOpts: () => session.updateOpts,
135
+ didLoad: () => session.loader.didLoad,
136
+ getMode: () => session.mode,
137
+ setMode: (mode) => {
138
+ session.mode = mode;
139
+ },
140
+ isDone: () => session.done,
141
+ uiOpen: () => session.uiOpen,
142
+ paint: () => session.paint(),
143
+ setStatus: (value) => {
144
+ session.status = value;
145
+ },
146
+ startInstallProgress: () => {
147
+ session.progressFrame = 0;
148
+ session.progress.start('install');
149
+ },
150
+ stopInstallProgress: () => session.progress.stop('install'),
151
+ });
152
+ };
1820
153
 
1821
- createTodo() {
1822
- this.startDraftCompose();
1823
- }
154
+ const assignComposer = (session) => {
155
+ session.composer = createComposer({
156
+ nav: session.nav,
157
+ review: session.review,
158
+ ui: session,
159
+ restartBlink: () => {
160
+ if (!session.term.hasTimer('blink')) return;
161
+ session.term.restartTimer(
162
+ 'blink',
163
+ () => session.tickBlink(),
164
+ CURSOR_BLINK_MS,
165
+ );
166
+ },
167
+ });
168
+ };
1824
169
 
1825
- openCompose(kind, text, todoId) {
1826
- this.mode = 'compose';
1827
- this.composeKind = kind;
1828
- this.composeTodoId = todoId ?? null;
1829
- this.composeBaseline = text;
1830
- this.editor = new Editor(text);
1831
- this.templateIndex = -1;
1832
- this.templateFocus = false;
1833
- this.status = '';
1834
- this.clearSelection();
1835
- this.resetBlink();
1836
- }
170
+ const assignControllers = (session) => {
171
+ const setStatus = (value) => {
172
+ session.status = value;
173
+ };
174
+ const setMode = (mode) => {
175
+ session.mode = mode;
176
+ };
177
+ session.ops = createOpsRunner({
178
+ top: () => session.top,
179
+ isDone: () => session.done,
180
+ getMode: () => session.mode,
181
+ setMode,
182
+ getStatus: () => session.status,
183
+ setStatus,
184
+ paint: () => session.paint(),
185
+ startProgress: () => session.startProgress(),
186
+ stopProgress: () => session.stopProgress(),
187
+ resetProgressFrame: () => {
188
+ session.progressFrame = 0;
189
+ },
190
+ });
191
+ session.changeActions = createChangeActions({
192
+ repo: () => session.repo,
193
+ top: () => session.top,
194
+ capabilities: () => session.capabilities,
195
+ uiOpen: () => session.uiOpen,
196
+ collection: session.collection,
197
+ getItems: () => session.items,
198
+ currentItem: () => session.current(),
199
+ nav: session.nav,
200
+ fileList: () => session.fileList(),
201
+ runBusy: session.ops.runBusy,
202
+ reloadAfterChange: (afterLoad) => session.reloadAfterChange(afterLoad),
203
+ setStatus,
204
+ syncReviewPath: () => session.syncReviewPath(),
205
+ syncFileCursor: () => session.syncFileCursor(),
206
+ ignoreWatch: () => session.lifecycle.ignoreWatch(),
207
+ });
208
+ session.commits = createCommitController({
209
+ repo: () => session.repo,
210
+ top: () => session.top,
211
+ capabilities: () => session.capabilities,
212
+ getItems: () => session.items,
213
+ uiOpen: () => session.uiOpen,
214
+ composer: session.composer,
215
+ collection: session.collection,
216
+ runner: session.ops,
217
+ refreshFromRepo: (opts) => session.refreshFromRepo(opts),
218
+ setMode,
219
+ setStatus,
220
+ isDone: () => session.done,
221
+ pendingExtras: () => session.pendingExtras,
222
+ getMode: () => session.mode,
223
+ getStatus: () => session.status,
224
+ nav: session.nav,
225
+ });
226
+ session.gitBranches = createBranchController({
227
+ repo: () => session.repo,
228
+ top: () => session.top,
229
+ capabilities: () => session.capabilities,
230
+ uiOpen: () => session.uiOpen,
231
+ composer: session.composer,
232
+ nav: session.nav,
233
+ runner: session.ops,
234
+ canCommit: session.commits.canCommit,
235
+ refreshFromRepo: (opts) => session.refreshFromRepo(opts),
236
+ showFiles: () => session.showFiles(),
237
+ getMode: () => session.mode,
238
+ setMode,
239
+ getStatus: () => session.status,
240
+ setStatus,
241
+ });
242
+ };
1837
243
 
1838
- commitCompose() {
1839
- if (this.mode !== 'compose' || !this.editor || !this.notes) return;
1840
- const text = this.editor.text;
1841
- if (this.composeKind === 'feedback') {
1842
- const item = this.current();
1843
- if (!item || isTodoItem(item)) return;
1844
- const hunk = item.hunk;
1845
- setFeedback(this.notes, itemFeedKey(item), {
1846
- file: itemPath(item),
1847
- oldStart: hunk ? hunk.oldStart : 0,
1848
- newStart: hunk ? hunk.newStart : 0,
1849
- blockId: item.blockId ?? 0,
1850
- origin: item.origin,
1851
- header: hunk ? hunk.header : '',
1852
- text,
1853
- });
1854
- return;
1855
- }
1856
- if (this.composeKind === 'todo') this.commitTodo(text);
1857
- if (this.composeKind === 'code') this.commitCode(text);
1858
- }
244
+ const bindSessionAccessors = (session) => {
245
+ bindAccessors(session, session.nav, NAV_FIELDS);
246
+ bindAccessors(session, session.composer.state, COMPOSER_FIELDS);
247
+ bindAccessors(session, session.loader, ['pendingExtras']);
248
+ bindAlias(
249
+ session,
250
+ 'loadPromise',
251
+ () => session.loader.promise,
252
+ (value) => {
253
+ session.loader.promise = value;
254
+ },
255
+ );
256
+ bindAccessors(session, session.collection, ['items']);
257
+ bindGetters(session, session.collection, ['dismissed']);
258
+ bindAccessors(session, session.updater, ['installPromise']);
259
+ bindAccessors(session, session.ops, ['gitBusy', 'busy']);
260
+ bindAccessors(session, session.gitBranches, ['branches', 'dropName']);
261
+ bindGetters(session, session.term, ['lastFrame', 'lastSize']);
262
+ bindAlias(session, 'loadGen', () => session.loader.gen);
263
+ bindAlias(
264
+ session,
265
+ 'didLoad',
266
+ () => session.loader.didLoad,
267
+ (value) => {
268
+ session.loader.didLoad = value;
269
+ if (value) session.updater.maybePrompt();
270
+ },
271
+ );
272
+ bindAlias(session, 'notes', () => session.review.store);
273
+ bindAlias(session, 'didResumeReview', () => session.review.didResume);
274
+ bindAlias(
275
+ session,
276
+ 'importedRemoteNotes',
277
+ () => session.review.importedRemote,
278
+ );
279
+ bindAlias(
280
+ session,
281
+ 'updateOffer',
282
+ () => session.updater.offer,
283
+ (value) => {
284
+ session.updater.offer = value;
285
+ },
286
+ );
287
+ bindAlias(session, 'updateFrom', () => session.updater.from);
288
+ bindAlias(session, 'updateTo', () => session.updater.to);
289
+ bindAlias(session, 'updatePromise', () => session.updater.checkPromise);
290
+ };
1859
291
 
1860
- commitCode(text) {
1861
- const item = this.current();
1862
- if (!canEditCode(item)) return;
1863
- const original = blockAddText(item.hunk, item.blockId);
1864
- const key = itemFeedKey(item);
1865
- const hunk = item.hunk;
1866
- const note = {
1867
- file: itemPath(item),
1868
- oldStart: hunk.oldStart ?? 0,
1869
- newStart: hunk.newStart ?? 0,
1870
- blockId: item.blockId ?? 0,
1871
- origin: item.origin,
1872
- header: hunk.header ?? '',
1873
- text,
1874
- };
1875
- if (text === original) {
1876
- setCode(this.notes, key, { ...note, clear: true });
1877
- return;
1878
- }
1879
- setCode(this.notes, key, note);
1880
- }
292
+ const bindViewer = (session) => {
293
+ session.viewer = createView({
294
+ nav: session.nav,
295
+ composer: session.composer,
296
+ collection: session.collection,
297
+ review: session.review,
298
+ gitBranches: session.gitBranches,
299
+ commits: session.commits,
300
+ updater: session.updater,
301
+ ui: session,
302
+ });
303
+ };
1881
304
 
1882
- commitTodo(text) {
1883
- const id = this.composeTodoId;
1884
- const todos = this.notes.todos;
1885
- const prev = id === null ? null : todos.find((entry) => entry.id === id);
1886
- if (prev) {
1887
- setTodoText(this.notes, id, text);
1888
- return;
1889
- }
1890
- if (!text.trim()) return;
1891
- const todo = addTodo(this.notes, TODO_FILE, text);
1892
- this.composeTodoId = todo.id;
1893
- }
305
+ const bindFilesPane = (session) => {
306
+ session.filesPane = createFilesPane({
307
+ nav: session.nav,
308
+ composer: session.composer,
309
+ items: () => session.items,
310
+ todoItem: () => session.todoItem,
311
+ fileList: () => session.fileList(),
312
+ setStatus: (value) => {
313
+ session.status = value;
314
+ },
315
+ onQuit: () => session.onQuit(),
316
+ onCheckoutBranch: session.gitBranches.onCheckoutBranch,
317
+ onBranchMove: session.gitBranches.onBranchMove,
318
+ onCommitMove: session.commits.onCommitMove,
319
+ layout: () => session.layout,
320
+ setLayout: (value) => {
321
+ session.layout = value;
322
+ },
323
+ lastFrame: () => session.lastFrame,
324
+ getSize: () => session.lastSize ?? session.getSize(),
325
+ });
326
+ };
1894
327
 
1895
- closeCompose() {
1896
- this.mode = 'review';
1897
- this.editor = null;
1898
- this.composeKind = null;
1899
- this.commitKind = null;
1900
- this.composeTodoId = null;
1901
- this.composeBaseline = '';
1902
- this.templateIndex = -1;
1903
- this.templateFocus = false;
1904
- this.clampIndex();
1905
- this.syncReviewPath();
1906
- this.syncFileCursor();
1907
- }
328
+ const bindLifecycle = (session) => {
329
+ session.lifecycle = createLifecycle({
330
+ loader: session.loader,
331
+ collection: session.collection,
332
+ review: session.review,
333
+ nav: session.nav,
334
+ ops: session.ops,
335
+ updater: session.updater,
336
+ loadConfig: {
337
+ repo: session.repo,
338
+ cwd: session.cwd,
339
+ paths: session.paths,
340
+ audit: session.audit,
341
+ outdatedMap: session.outdatedMap,
342
+ auditMap: session.auditMap,
343
+ },
344
+ watch: session.watchOpts,
345
+ ui: session,
346
+ });
347
+ };
1908
348
 
1909
- shownTemplates() {
1910
- if (!this.notes) return [];
1911
- if (this.mode === 'compose' && this.composeKind !== 'feedback') return [];
1912
- const ranked = rankedTemplates(this.notes.templates);
1913
- const editing = this.mode === 'compose' && this.editor;
1914
- const typed = editing ? this.editor.text : '';
1915
- const exact = ranked.some((entry) => entry.text === typed);
1916
- if (exact) return [];
1917
- const matched = prefixTemplates(ranked, typed);
1918
- return matched.slice(0, TEMPLATE_SHOW);
1919
- }
349
+ const bindDispatch = (session) => {
350
+ const filesPane = session.filesPane;
351
+ session.dispatcher = createDispatcher({
352
+ getMode: () => session.mode,
353
+ getPane: () => session.pane,
354
+ currentTarget: () => {
355
+ if (session.pane === 'files') return session.fileCursorEntry();
356
+ if (session.pane === 'branches') {
357
+ return session.branches[session.branchCursor];
358
+ }
359
+ if (session.pane === 'commits') {
360
+ const entry = session.commits.commits[session.commitCursor];
361
+ const staged = targetHasStaged(session);
362
+ if (!entry) return { canCommit: staged };
363
+ return { ...entry, canCommit: staged };
364
+ }
365
+ return session.current();
366
+ },
367
+ composer: session.composer,
368
+ changeActions: session.changeActions,
369
+ commits: session.commits,
370
+ gitBranches: session.gitBranches,
371
+ onFileMove: filesPane.onFileMove,
372
+ onBranchMove: session.gitBranches.onBranchMove,
373
+ onCommitMove: session.commits.onCommitMove,
374
+ onNext: filesPane.onNext,
375
+ onPrev: filesPane.onPrev,
376
+ onOpenFile: filesPane.onOpenFile,
377
+ onFiles: filesPane.onFiles,
378
+ onQuit: () => session.onQuit(),
379
+ onLayout: filesPane.onLayout,
380
+ onReload: session.lifecycle.onReload,
381
+ onScroll: filesPane.onScroll,
382
+ onHome: filesPane.onHome,
383
+ onEnd: filesPane.onEnd,
384
+ scrollStep: filesPane.scrollStep,
385
+ });
386
+ session.pointer = createPointer({
387
+ nav: session.nav,
388
+ getSize: () => session.lastSize ?? session.getSize(),
389
+ lastFrame: () => session.lastFrame,
390
+ copyText: (text, stdout) => session.copyText(text, stdout),
391
+ stdout: () => session.stdout,
392
+ setStatus: (value) => {
393
+ session.status = value;
394
+ },
395
+ dispatch: (action) => session.dispatcher.dispatch(action),
396
+ getPane: () => session.pane,
397
+ getMode: () => session.mode,
398
+ confirmChoice: (key) => session.input.confirmChoice(key),
399
+ onBranch: session.gitBranches.onBranch,
400
+ onOpenFile: filesPane.onOpenFile,
401
+ onCheckoutBranch: session.gitBranches.onCheckoutBranch,
402
+ onTodoHit: (cursor) => session.composer.onTodoHit(cursor),
403
+ selectTemplate: (index) => session.selectTemplate(index),
404
+ onScroll: filesPane.onScroll,
405
+ });
406
+ session.input = createInput({
407
+ getMode: () => session.mode,
408
+ getPane: () => session.pane,
409
+ composer: session.composer,
410
+ pointer: session.pointer,
411
+ dispatch: (action) => session.dispatcher.dispatch(action),
412
+ onEscape: filesPane.onEscape,
413
+ onQuit: () => session.onQuit(),
414
+ runComposeCommand: (result) => session.runComposeCommand(result),
415
+ quit: {
416
+ ready: () => session.finishQuit('ready'),
417
+ editing: () => session.finishQuit('editing'),
418
+ cancel: () => session.cancelQuit(),
419
+ },
420
+ update: {
421
+ accept: session.updater.accept,
422
+ decline: session.updater.decline,
423
+ },
424
+ drop: {
425
+ confirm: () => {
426
+ if (session.commits.dropName) {
427
+ return void session.commits.confirmDropCommit();
428
+ }
429
+ session.gitBranches.confirmDropBranch();
430
+ },
431
+ cancel: () => {
432
+ if (session.commits.dropName) {
433
+ return void session.commits.cancelDropCommit();
434
+ }
435
+ session.gitBranches.cancelDropBranch();
436
+ },
437
+ },
438
+ push: {
439
+ force: session.gitBranches.onForcePush,
440
+ cancel: session.gitBranches.cancelPush,
441
+ },
442
+ });
443
+ };
1920
444
 
1921
- clampedTemplateIndex(shown) {
1922
- if (!shown.length) return -1;
1923
- if (this.templateIndex >= shown.length) return shown.length - 1;
1924
- return this.templateIndex;
1925
- }
445
+ const composeSession = (session, options) => {
446
+ assignOptions(session, options);
447
+ assignUpdater(session);
448
+ assignComposer(session);
449
+ assignControllers(session);
450
+ bindSessionAccessors(session);
451
+ bindViewer(session);
452
+ bindFilesPane(session);
453
+ bindLifecycle(session);
454
+ bindDispatch(session);
455
+ };
1926
456
 
1927
- clearTemplatePick() {
1928
- this.templateIndex = -1;
1929
- this.templateFocus = false;
457
+ class Session {
458
+ constructor(options) {
459
+ composeSession(this, options);
460
+ this.resetState();
1930
461
  }
1931
462
 
1932
- focusTemplate(index, apply) {
1933
- const shown = this.shownTemplates();
1934
- if (!shown.length) return;
1935
- let i = index;
1936
- if (i < 0) i = shown.length - 1;
1937
- if (i >= shown.length) i = 0;
1938
- this.templateIndex = i;
1939
- this.templateFocus = true;
1940
- if (apply) this.editor.replace(shown[i].text);
463
+ resetState() {
464
+ if (this.collection) this.collection.reset();
465
+ if (this.nav) this.nav.reset();
466
+ if (this.review) this.review.reset();
467
+ if (this.composer) this.composer.reset();
468
+ if (this.ops) this.ops.reset();
469
+ if (this.gitBranches) this.gitBranches.reset();
470
+ if (this.commits) this.commits.reset();
471
+ this.top = this.cwd;
472
+ this.branch = '';
473
+ this.status = '';
474
+ this.layout = 'unified';
475
+ this.done = false;
476
+ this.exitCode = 0;
477
+ this.mode = 'review';
478
+ this.todoItem = makeTodoItem();
479
+ this.progressFrame = 0;
480
+ this.emptyReview = false;
481
+ this.loadError = null;
482
+ this.uiOpen = false;
483
+ this.finishReadLoop = null;
484
+ if (this.lifecycle) this.lifecycle.stopWatch();
485
+ if (this.lifecycle) this.lifecycle.clearNpmExtras();
486
+ if (this.term) this.term.resetCache();
487
+ if (this.loader) this.loader.reset();
488
+ if (this.updater) this.updater.reset();
489
+ if (this.progress) this.progress.clear();
1941
490
  }
1942
491
 
1943
- applyTemplate() {
1944
- if (this.composeKind !== 'feedback') return;
1945
- const index = this.templateFocus ? this.templateIndex : 0;
1946
- this.selectTemplate(index);
492
+ ensureRepo() {
493
+ if (!this.repo.toplevel) return;
494
+ this.top = this.repo.toplevel(this.cwd);
1947
495
  }
1948
496
 
1949
- selectTemplate(index) {
1950
- const shown = this.shownTemplates();
1951
- if (!shown.length) return;
1952
- this.focusTemplate(index, true);
1953
- this.saveCompose();
497
+ load() {
498
+ this.lifecycle.load();
1954
499
  }
1955
500
 
1956
- composeView() {
1957
- if (this.mode !== 'compose' || !this.editor) return null;
1958
- const noteKinds = ['feedback', 'commit', 'branch'];
1959
- if (!noteKinds.includes(this.composeKind)) return null;
1960
- const kind = this.composeKind;
1961
- const text = this.editor.text;
1962
- const cursor = this.editor.cursor;
1963
- return { kind, text, cursor };
501
+ loadReady() {
502
+ return this.lifecycle.loadReady();
1964
503
  }
1965
504
 
1966
- todoEditView() {
1967
- if (this.mode !== 'compose' || this.composeKind !== 'todo') return null;
1968
- if (!this.editor) return null;
1969
- return { cursor: this.editor.cursor };
505
+ openLoad() {
506
+ return this.lifecycle.openLoad();
1970
507
  }
1971
508
 
1972
- liveCodeText() {
1973
- const item = this.current();
1974
- if (!canEditCode(item)) return null;
1975
- if (this.mode === 'compose' && this.composeKind === 'code' && this.editor) {
1976
- return this.editor.text;
1977
- }
1978
- if (!this.notes) return null;
1979
- const note = this.notes.code.get(itemFeedKey(item));
1980
- if (!note) return null;
1981
- return note.text;
509
+ refreshFromRepo(options) {
510
+ return this.lifecycle.refreshFromRepo(options);
1982
511
  }
1983
512
 
1984
- codeOverlayView() {
1985
- const text = this.liveCodeText();
1986
- if (text === null) return null;
1987
- const editing = this.mode === 'compose' && this.composeKind === 'code';
1988
- const cursor = editing && this.editor ? this.editor.cursor : null;
1989
- return { text, keepEmpty: editing, cursor };
513
+ reloadAfterChange(afterLoad) {
514
+ this.lifecycle.reloadAfterChange(afterLoad);
1990
515
  }
1991
516
 
1992
- composeTab() {
1993
- if (this.composeKind === 'code') {
1994
- this.editor.insert('\t');
1995
- return;
1996
- }
1997
- this.applyTemplate();
517
+ applyLoaded(loaded) {
518
+ return this.lifecycle.applyLoaded(loaded);
1998
519
  }
1999
520
 
2000
- listTodos() {
2001
- if (!this.notes) return [];
2002
- return this.notes.todos;
521
+ paint() {
522
+ if (!this.uiOpen) return;
523
+ this.draw();
2003
524
  }
2004
525
 
2005
- todoTexts() {
2006
- if (!this.todoOpen) return [];
2007
- const texts = [];
2008
- const editingTodo = this.mode === 'compose' && this.composeKind === 'todo';
2009
- const editingId = editingTodo ? this.composeTodoId : null;
2010
- for (const todo of this.listTodos()) {
2011
- const live = editingId === todo.id && this.editor;
2012
- const text = live ? this.editor.text : todo.text;
2013
- texts.push(checkLabel(text, todo.done));
526
+ draw() {
527
+ if (this.term.disposed) return;
528
+ const size = this.getSize();
529
+ const frame = renderFrame(this.view(), {
530
+ width: size.width,
531
+ height: size.height,
532
+ color: this.color,
533
+ });
534
+ if (
535
+ this.pane === 'files' ||
536
+ this.pane === 'branches' ||
537
+ this.pane === 'commits'
538
+ ) {
539
+ this.nav.listScroll = frame.listScroll;
2014
540
  }
2015
- const draft = this.isDraftCompose() && this.editor ? this.editor.text : '';
2016
- texts.push(checkLabel(draft, false));
2017
- return texts;
2018
- }
2019
-
2020
- focusedTodo() {
2021
- if (!this.todoOpen) return null;
2022
- const todos = this.listTodos();
2023
- return todos[this.clampedTodoFocus()] ?? null;
541
+ const composing = this.mode === 'compose';
542
+ const cursor = composing && this.blinkOn ? frame.cursor : null;
543
+ this.term.paint(frame, size, cursor);
2024
544
  }
2025
545
 
2026
- clampedTodoFocus() {
2027
- const last = Math.max(0, this.todoRowCount() - 1);
2028
- if (this.todoFocus < 0) return 0;
2029
- if (this.todoFocus > last) return last;
2030
- return this.todoFocus;
546
+ view() {
547
+ return this.viewer.view();
2031
548
  }
2032
549
 
2033
- todoRowCount() {
2034
- if (!this.todoOpen) return 0;
2035
- return this.listTodos().length + 1;
550
+ fileList() {
551
+ return this.viewer.fileList();
2036
552
  }
2037
553
 
2038
- moveTodoFocus(delta) {
2039
- if (!this.todoOpen) return;
2040
- const n = this.todoRowCount();
2041
- if (!n) return;
2042
- const next = this.clampedTodoFocus() + delta;
2043
- if (next < 0) this.todoFocus = 0;
2044
- else if (next >= n) this.todoFocus = n - 1;
2045
- else this.todoFocus = next;
554
+ counts() {
555
+ return this.viewer.counts();
2046
556
  }
2047
557
 
2048
- idleNoteText() {
2049
- if (!this.notes) return '';
2050
- if (this.mode === 'compose') return '';
2051
- const item = this.current();
2052
- if (!item || isTodoItem(item)) return '';
2053
- const note = this.notes.feedback.get(itemFeedKey(item));
2054
- if (!note || !note.text.trim()) return '';
2055
- return checkLabel(note.text, note.done);
558
+ viewStatus() {
559
+ return this.viewer.viewStatus();
2056
560
  }
2057
561
 
2058
- flushReview(force) {
2059
- if (!this.notes) return false;
2060
- try {
2061
- const writeFileSync = this.writeFileSync;
2062
- const mkdirSync = this.mkdirSync;
2063
- const io = { writeFileSync, mkdirSync, force: force === true };
2064
- return flushReview(this.notes, io);
2065
- } catch (error) {
2066
- this.status = error.message;
2067
- return false;
2068
- }
562
+ current() {
563
+ return this.nav.current(this.items, this.todoItem);
2069
564
  }
2070
565
 
2071
- saveCompose(options) {
2072
- const opts = options ?? {};
2073
- if (this.composeKind === 'commit') {
2074
- this.finishGitCommit();
2075
- return;
2076
- }
2077
- if (this.composeKind === 'branch') {
2078
- this.finishCreateBranch();
2079
- return;
2080
- }
2081
- const editNext = opts.editNext === true;
2082
- this.commitCompose();
2083
- if (this.composeKind === 'feedback') {
2084
- const next = this.editor ? this.editor.text : '';
2085
- rememberTemplate(this.notes, this.composeBaseline, next);
2086
- }
2087
- this.flushReview();
2088
- this.closeCompose();
2089
- this.status = 'saved';
2090
- if (editNext) this.editNextTodo();
566
+ fileCursorEntry() {
567
+ return this.changeActions.fileCursorEntry();
2091
568
  }
2092
569
 
2093
- autosave() {
2094
- const text = this.editor ? this.editor.text : '';
2095
- const editingCode = this.mode === 'compose' && this.composeKind === 'code';
2096
- if (this.mode === 'compose' && (editingCode || text.trim())) {
2097
- this.commitCompose();
2098
- }
2099
- if (this.notes && this.notes.dirty) this.flushReview();
570
+ clampIndex() {
571
+ this.filesPane.clampIndex();
2100
572
  }
2101
573
 
2102
- moveComposeLine(delta) {
2103
- const size = this.lastSize ?? this.getSize();
2104
- if (this.composeKind === 'todo') {
2105
- this.moveTodoCompose(delta);
2106
- return;
2107
- }
2108
- if (this.composeKind === 'code') {
2109
- const inner = codeInnerWidth(size.width, this.layout);
2110
- this.editor.moveLine(delta, inner);
2111
- return;
2112
- }
2113
- const width = noteInnerWidth(size.width);
2114
- const shown = this.shownTemplates();
2115
- if (shown.length && this.composeKind === 'feedback') {
2116
- if (this.templateFocus) {
2117
- const next = this.templateIndex + delta;
2118
- if (next < 0 || next >= shown.length) {
2119
- this.templateFocus = false;
2120
- return;
2121
- }
2122
- this.focusTemplate(next, false);
2123
- return;
2124
- }
2125
- const pos = cursorInWrap(this.editor.text, this.editor.cursor, width);
2126
- const last = wrapDoc(this.editor.text, width).length - 1;
2127
- if (delta < 0 && pos.row === 0) {
2128
- this.focusTemplate(shown.length - 1, false);
2129
- return;
2130
- }
2131
- if (delta > 0 && pos.row === last) {
2132
- this.focusTemplate(0, false);
2133
- return;
2134
- }
2135
- }
2136
- this.editor.moveLine(delta, width);
574
+ syncReviewPath() {
575
+ this.filesPane.syncReviewPath();
2137
576
  }
2138
577
 
2139
- resetBlink() {
2140
- this.blinkOn = true;
2141
- if (this.blinkTimer === null) return;
2142
- this.clearInterval(this.blinkTimer);
2143
- this.blinkTimer = this.setInterval(() => this.tickBlink(), CURSOR_BLINK_MS);
578
+ syncFileCursor() {
579
+ this.filesPane.syncFileCursor();
2144
580
  }
2145
581
 
2146
- tickBlink() {
2147
- if (this.mode !== 'compose') return;
2148
- this.blinkOn = !this.blinkOn;
2149
- this.draw();
582
+ showFiles() {
583
+ this.filesPane.showFiles();
2150
584
  }
2151
585
 
2152
- handleComposeKey(key) {
2153
- if (key === 'ctrl-c') {
2154
- this.onQuit();
2155
- return;
2156
- }
2157
- if (key === 'enter') {
2158
- if (this.templateFocus && this.composeKind === 'feedback') {
2159
- this.selectTemplate(this.templateIndex);
2160
- return;
2161
- }
2162
- if (this.composeKind === 'code') {
2163
- this.editor.insert('\n');
2164
- this.resetBlink();
2165
- return;
2166
- }
2167
- if (this.composeKind === 'todo') {
2168
- this.saveCompose({ editNext: true });
2169
- return;
2170
- }
2171
- this.saveCompose();
2172
- return;
2173
- }
2174
- if (key === 'escape' || key === 'ctrl-s') {
2175
- const cancel = ['commit', 'branch'];
2176
- if (cancel.includes(this.composeKind) && key === 'escape') {
2177
- this.closeCompose();
2178
- this.status = '';
2179
- return;
2180
- }
2181
- this.saveCompose();
2182
- return;
2183
- }
2184
- const special = COMPOSE_SPECIAL[key];
2185
- if (special) {
2186
- special(this);
2187
- if (COMPOSE_EDITS.includes(key)) this.clearTemplatePick();
2188
- this.resetBlink();
2189
- return;
2190
- }
2191
- if (!key.length) return;
2192
- if (key.charCodeAt(0) >= 32) {
2193
- this.clearTemplatePick();
2194
- this.editor.insert(key);
2195
- this.resetBlink();
2196
- }
586
+ dispatch(action) {
587
+ this.dispatcher.dispatch(action);
2197
588
  }
2198
589
 
2199
- showFiles() {
2200
- this.pane = 'files';
2201
- this.clearSelection();
2202
- this.followReviewPath();
2203
- this.todoOpen = false;
590
+ handleEvent(event) {
591
+ this.input.handleEvent(event);
2204
592
  }
2205
593
 
2206
- onEscape() {
2207
- if (this.pane === 'branches') {
2208
- this.showFiles();
2209
- return;
594
+ pushInput(text) {
595
+ const decoded = decodeChunk(text, this.term.carry);
596
+ this.term.carry = decoded.carry;
597
+ for (const event of decoded.events) {
598
+ this.handleEvent(event);
599
+ if (this.done) return;
2210
600
  }
2211
- if (this.pane === 'files') {
2212
- this.onQuit();
2213
- return;
601
+ if (this.term.carry === '\x1b') {
602
+ this.term.carry = '';
603
+ this.handleEvent({ type: 'key', key: 'escape' });
2214
604
  }
2215
- this.showFiles();
2216
605
  }
2217
606
 
2218
- onFiles() {
2219
- if (this.pane === 'files') {
2220
- if (!this.reviewPath) return;
2221
- this.pane = 'diff';
2222
- this.clearSelection();
2223
- if (this.reviewPath === TODO_FILE) this.openTodoPage();
2224
- return;
2225
- }
2226
- this.showFiles();
607
+ initReview() {
608
+ this.review.init(this.top, this.now(), {
609
+ forceNew: this.forceNewReview,
610
+ });
2227
611
  }
2228
612
 
2229
- onOpenFile() {
2230
- if (this.pane === 'branches') return void this.onCheckoutBranch();
2231
- if (this.pane !== 'files') {
2232
- if (this.todoOpen) this.editFocusedTodo();
2233
- return;
2234
- }
2235
- const files = this.fileList();
2236
- const entry = files[this.fileCursor];
2237
- if (!entry) return;
2238
- if (isTodosEntry(entry)) {
2239
- this.openTodoPage();
2240
- this.status = '';
2241
- return;
613
+ flushReview(force) {
614
+ const result = this.review.flush(force === true);
615
+ if (!result.ok) {
616
+ this.status = result.error.message;
617
+ return false;
2242
618
  }
2243
- this.todoOpen = false;
2244
- this.index = entry.openIndex ?? entry.firstIndex;
2245
- this.pane = 'diff';
2246
- this.scroll = 0;
2247
- this.status = '';
2248
- this.clearSelection();
2249
- this.syncReviewPath();
619
+ return result.wrote;
2250
620
  }
2251
621
 
2252
- onFileMove(delta) {
2253
- const files = this.fileList();
2254
- if (!files.length) {
2255
- this.status = 'nothing to review';
2256
- return;
2257
- }
2258
- const next = Math.min(
2259
- files.length - 1,
2260
- Math.max(0, this.fileCursor + delta),
2261
- );
2262
- if (next === this.fileCursor) return;
2263
- this.fileCursor = next;
2264
- const entry = files[next];
2265
- if (isTodosEntry(entry)) {
2266
- this.reviewPath = TODO_FILE;
2267
- } else {
2268
- this.reviewPath = entry.path;
2269
- this.index = entry.firstIndex;
2270
- }
2271
- this.status = '';
622
+ runComposeCommand(result) {
623
+ if (!result) return;
624
+ if (result.kind === 'commit') this.commits.finishGitCommit();
625
+ if (result.kind === 'branch') this.gitBranches.finishCreateBranch();
2272
626
  }
2273
627
 
2274
- scrollStep(fraction) {
2275
- const bodyH = this.lastFrame?.bodyH;
2276
- const size = this.lastSize ?? this.getSize();
2277
- const fallback = Math.max(1, (size.height ?? 24) - 4);
2278
- const height = Math.max(1, bodyH ?? fallback);
2279
- const n = Math.max(1, Math.floor(height * Math.abs(fraction)));
2280
- if (fraction < 0) return -n;
2281
- return n;
628
+ selectTemplate(index) {
629
+ this.runComposeCommand(this.composer.selectTemplate(index));
2282
630
  }
2283
631
 
2284
- onScroll(delta) {
2285
- if (this.pane === 'files') {
2286
- this.onFileMove(delta);
2287
- return;
2288
- }
2289
- if (this.pane === 'branches') {
2290
- this.onBranchMove(delta);
2291
- return;
2292
- }
2293
- if (this.todoOpen && this.todoRowCount() > 1) {
2294
- this.moveTodoFocus(delta);
2295
- return;
2296
- }
2297
- this.scroll = Math.max(0, this.scroll + delta);
2298
- this.clearSelection();
632
+ idleNoteText() {
633
+ return this.composer.idleNoteText();
2299
634
  }
2300
635
 
2301
- onLayout() {
2302
- const i = LAYOUTS.indexOf(this.layout);
2303
- const next = (i < 0 ? 0 : i + 1) % LAYOUTS.length;
2304
- this.layout = LAYOUTS[next];
2305
- this.status = this.layout === 'side' ? 'side-by-side' : this.layout;
2306
- this.scroll = 0;
2307
- this.clearSelection();
636
+ shownTemplates() {
637
+ return this.composer.shownTemplates();
2308
638
  }
2309
639
 
2310
- mouseCell(event) {
2311
- const size = this.lastSize ?? this.getSize();
2312
- const x = Math.min(Math.max(1, event.x), size.width);
2313
- const y = Math.min(Math.max(1, event.y), size.height);
2314
- return { x, y };
640
+ startDraftCompose() {
641
+ this.composer.startDraftCompose();
2315
642
  }
2316
643
 
2317
- onMousePress(event) {
2318
- const cell = this.mouseCell(event);
2319
- this.mouseAnchor = cell;
2320
- this.pendingClick = null;
2321
- this.selection = null;
2322
- if (this.lastFrame && cell.y === this.lastFrame.height) {
2323
- this.pendingClick = hitAction(this.lastFrame.buttons, cell.x - 1);
2324
- }
644
+ tickBlink() {
645
+ this.composer.tickBlink();
2325
646
  }
2326
647
 
2327
- onMouseDrag(event) {
2328
- if (!this.mouseAnchor) return;
2329
- this.pendingClick = null;
2330
- this.selection = {
2331
- start: this.mouseAnchor,
2332
- end: this.mouseCell(event),
2333
- };
648
+ autosave() {
649
+ this.composer.autosave();
2334
650
  }
2335
651
 
2336
- clickButton(cell, clickId) {
2337
- if (!clickId || !this.lastFrame || isRange(this.selection)) return false;
2338
- const id = hitAction(this.lastFrame.buttons, cell.x - 1);
2339
- if (id === clickId && cell.y === this.lastFrame.height) {
2340
- this.dispatch(id);
652
+ onQuit() {
653
+ if (this.mode === 'compose') {
654
+ this.composer.commitCompose();
655
+ this.composer.closeCompose();
2341
656
  }
2342
- this.selection = null;
2343
- return true;
2344
- }
2345
-
2346
- clickHit(hits, cell, onHit) {
2347
- if (!hits || isRange(this.selection)) return false;
2348
- const x = cell.x - 1;
2349
- const hit = hits.find((row) => {
2350
- if (row.y !== cell.y) return false;
2351
- if (row.x0 === undefined) return true;
2352
- return x >= row.x0 && x < row.x1;
2353
- });
2354
- if (!hit) return false;
2355
- onHit(hit);
2356
- this.selection = null;
2357
- return true;
657
+ if (hasNotes(this.notes)) {
658
+ this.mode = 'confirmQuit';
659
+ this.status = '';
660
+ return;
661
+ }
662
+ this.done = true;
663
+ this.exitCode = 0;
2358
664
  }
2359
665
 
2360
- copySelection() {
2361
- if (!isRange(this.selection) || !this.lastFrame) return;
2362
- const text = extractText(this.lastFrame.rows, this.selection);
2363
- if (!text) return;
2364
- const ok = this.copyText(text, this.stdout);
2365
- this.status = ok ? 'copied' : 'copy failed';
666
+ finishQuit(status) {
667
+ const result = this.review.finishQuit(status);
668
+ if (!result.ok) this.status = result.error.message;
669
+ this.done = true;
670
+ this.exitCode = 0;
2366
671
  }
2367
672
 
2368
- onMouseRelease(event) {
2369
- const cell = this.mouseCell(event);
2370
- if (this.mouseAnchor) {
2371
- this.selection = { start: this.mouseAnchor, end: cell };
2372
- }
2373
- const clickId = this.pendingClick;
2374
- this.pendingClick = null;
2375
- this.mouseAnchor = null;
2376
- if (this.clickButton(cell, clickId)) return;
2377
- const frame = this.lastFrame;
2378
- for (const target of RELEASE_HITS) {
2379
- if (!target.match(this)) continue;
2380
- const hits = target.hits(frame);
2381
- const opened = this.clickHit(hits, cell, (hit) => {
2382
- target.onHit(this, hit);
2383
- });
2384
- if (opened) return;
2385
- }
2386
- this.copySelection();
673
+ cancelQuit() {
674
+ this.mode = 'review';
675
+ this.status = '';
2387
676
  }
2388
677
 
2389
- handleEvent(event) {
2390
- if (event.type === 'key') {
2391
- const mode = KEY_MODE[this.mode];
2392
- if (mode) {
2393
- mode(this, event.key);
2394
- return;
2395
- }
2396
- if (event.key === 'escape') {
2397
- this.onEscape();
2398
- return;
2399
- }
2400
- if (this.typeIntoTodo(event.key)) return;
2401
- const action = actionFromKey(event.key, this.pane);
2402
- if (action) this.dispatch(action);
2403
- return;
2404
- }
2405
- if (event.type !== 'mouse') return;
2406
- const kind = event.kind;
2407
- const wheel = kind === 'wheelUp' || kind === 'wheelDown';
2408
- if (!wheel && event.btn !== 0 && event.btn !== undefined) return;
2409
- const handler = MOUSE_KIND[kind];
2410
- if (handler) handler(this, event);
678
+ startProgress() {
679
+ this.progress.start('busy');
2411
680
  }
2412
681
 
2413
- pushInput(text) {
2414
- const decoded = decodeChunk(text, this.carry);
2415
- this.carry = decoded.carry;
2416
- for (const event of decoded.events) {
2417
- this.handleEvent(event);
2418
- if (this.done) return;
2419
- }
2420
- if (this.carry === '\x1b') {
2421
- this.carry = '';
2422
- this.handleEvent({ type: 'key', key: 'escape' });
2423
- }
682
+ stopProgress() {
683
+ this.progress.stop('busy');
2424
684
  }
2425
685
 
2426
- enterTerm() {
2427
- if (this.stdin && this.stdin.setRawMode) this.stdin.setRawMode(true);
2428
- if (this.stdin) this.stdin.resume();
2429
- this.stdout.write(ENTER_TERM);
686
+ tickProgress() {
687
+ this.progressFrame += 1;
688
+ this.paint();
2430
689
  }
2431
690
 
2432
- leaveTerm() {
2433
- this.stdout.write(LEAVE_TERM);
2434
- if (this.stdin && this.stdin.setRawMode) this.stdin.setRawMode(false);
691
+ openUpdate() {
692
+ return this.updater.open();
2435
693
  }
2436
694
 
2437
695
  async startUi() {
2438
696
  const empty = !this.items.length;
2439
697
  const notes = this.notes && hasNotes(this.notes);
2440
- if (this.didLoad && empty && !notes) {
698
+ const worktree = !this.rev && !this.change;
699
+ if (this.didLoad && empty && !notes && !worktree) {
2441
700
  this.emptyReview = true;
2442
701
  return 0;
2443
702
  }
2444
703
  this.uiOpen = true;
2445
- this.enterTerm();
704
+ this.term.enter();
2446
705
  try {
2447
706
  this.openLoad();
2448
707
  this.openUpdate();
@@ -2450,8 +709,8 @@ class Session {
2450
709
  await this.readLoop();
2451
710
  } finally {
2452
711
  this.uiOpen = false;
2453
- this.leaveTerm();
2454
- if (this.loadAbort) this.loadAbort.abort();
712
+ this.term.close();
713
+ this.loader.abort();
2455
714
  if (this.installPromise) {
2456
715
  try {
2457
716
  await this.installPromise;
@@ -2465,25 +724,14 @@ class Session {
2465
724
 
2466
725
  readLoop() {
2467
726
  return new Promise((resolve) => {
2468
- this.abortController = new AbortController();
2469
- const { signal } = this.abortController;
2470
727
  let settled = false;
2471
728
  const finish = () => {
2472
729
  if (settled) return;
2473
730
  settled = true;
2474
- if (this.saveTimer !== null) {
2475
- this.clearInterval(this.saveTimer);
2476
- this.saveTimer = null;
2477
- }
2478
- if (this.blinkTimer !== null) {
2479
- this.clearInterval(this.blinkTimer);
2480
- this.blinkTimer = null;
2481
- }
2482
- if (this.progressTimer !== null) {
2483
- this.clearInterval(this.progressTimer);
2484
- this.progressTimer = null;
2485
- }
2486
- this.abortController.abort();
731
+ this.lifecycle.stopWatch();
732
+ this.term.stopListening();
733
+ this.progress.clear();
734
+ this.term.clearTimers();
2487
735
  this.finishReadLoop = null;
2488
736
  resolve();
2489
737
  };
@@ -2500,13 +748,13 @@ class Session {
2500
748
  }
2501
749
  this.draw();
2502
750
  };
2503
- this.saveTimer = this.setInterval(() => this.autosave(), AUTOSAVE_MS);
2504
- this.blinkTimer = this.setInterval(
2505
- () => this.tickBlink(),
2506
- CURSOR_BLINK_MS,
2507
- );
2508
- this.stdin.on('data', (chunk) => onData(chunk), { signal });
2509
- watchResize(this.stdout, process, () => this.draw(), signal);
751
+ this.term.startListening({
752
+ onData,
753
+ onResize: () => this.draw(),
754
+ });
755
+ this.term.startTimer('save', () => this.autosave(), AUTOSAVE_MS);
756
+ this.term.startTimer('blink', () => this.tickBlink(), CURSOR_BLINK_MS);
757
+ this.lifecycle.startWatch();
2510
758
  });
2511
759
  }
2512
760
  }