reslop 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/LICENSE +1 -1
  3. package/README.md +12 -13
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +32 -19
  6. package/lib/capabilities.js +8 -12
  7. package/lib/cli.js +40 -74
  8. package/lib/clipboard.js +48 -18
  9. package/lib/dashboard/git.js +128 -0
  10. package/lib/dashboard/model.js +332 -0
  11. package/lib/dashboard/npm.js +193 -0
  12. package/lib/dashboard/tree.js +278 -0
  13. package/lib/deps-diff.js +196 -404
  14. package/lib/deps.js +240 -399
  15. package/lib/detect.js +8 -18
  16. package/lib/diff/align.js +41 -113
  17. package/lib/diff/diff.js +12 -43
  18. package/lib/diff/display.js +25 -36
  19. package/lib/diff/edit.js +6 -20
  20. package/lib/diff/inline.js +138 -141
  21. package/lib/diff/items.js +33 -47
  22. package/lib/diff/parse.js +28 -56
  23. package/lib/diff/patch.js +12 -33
  24. package/lib/diff/unit.js +90 -206
  25. package/lib/editor.js +98 -55
  26. package/lib/files.js +86 -103
  27. package/lib/git-branches.js +254 -416
  28. package/lib/git-deps.js +182 -344
  29. package/lib/git-reword-todo.js +5 -13
  30. package/lib/git-worktree.js +141 -212
  31. package/lib/git.js +21 -49
  32. package/lib/github.js +67 -181
  33. package/lib/gitlab.js +40 -90
  34. package/lib/highlight/bash.js +55 -177
  35. package/lib/highlight/core.js +157 -132
  36. package/lib/highlight/css.js +25 -58
  37. package/lib/highlight/csv.js +19 -37
  38. package/lib/highlight/dart.js +72 -213
  39. package/lib/highlight/dot.js +16 -26
  40. package/lib/highlight/html.js +42 -63
  41. package/lib/highlight/js.js +177 -506
  42. package/lib/highlight/json.js +17 -37
  43. package/lib/highlight/log.js +22 -35
  44. package/lib/highlight/txt.js +9 -22
  45. package/lib/highlight.js +17 -46
  46. package/lib/keys.js +62 -85
  47. package/lib/manifest.js +67 -130
  48. package/lib/npm-commands.js +119 -189
  49. package/lib/remote.js +141 -197
  50. package/lib/render/branches.js +66 -73
  51. package/lib/render/chrome.js +264 -323
  52. package/lib/render/commits.js +187 -500
  53. package/lib/render/dash-activity.js +212 -0
  54. package/lib/render/dash-blocks.js +245 -0
  55. package/lib/render/dash-table.js +290 -0
  56. package/lib/render/dashboard.js +62 -0
  57. package/lib/render/diff.js +123 -228
  58. package/lib/render/files.js +45 -99
  59. package/lib/render/frame.js +129 -183
  60. package/lib/render/notes.js +144 -160
  61. package/lib/render/npm.js +234 -112
  62. package/lib/render/present.js +4 -11
  63. package/lib/render/primitives.js +131 -170
  64. package/lib/render/render.js +5 -14
  65. package/lib/render/report.js +43 -0
  66. package/lib/render/tiles.js +260 -0
  67. package/lib/render/unit.js +15 -28
  68. package/lib/report-model.js +110 -33
  69. package/lib/report-parse.js +56 -38
  70. package/lib/report-render.js +102 -59
  71. package/lib/report-run.js +87 -78
  72. package/lib/report-test.js +256 -141
  73. package/lib/report-tools.js +96 -161
  74. package/lib/review-merge.js +114 -152
  75. package/lib/review.js +115 -189
  76. package/lib/runs.js +225 -0
  77. package/lib/select.js +89 -75
  78. package/lib/session/accessors.js +14 -21
  79. package/lib/session/actions.js +82 -63
  80. package/lib/session/branches.js +193 -247
  81. package/lib/session/change-actions.js +171 -214
  82. package/lib/session/commit.js +280 -342
  83. package/lib/session/compose.js +559 -674
  84. package/lib/session/dashboard.js +385 -0
  85. package/lib/session/dispatch.js +164 -157
  86. package/lib/session/edit-click.js +57 -0
  87. package/lib/session/files-pane.js +85 -53
  88. package/lib/session/items.js +73 -104
  89. package/lib/session/lifecycle.js +403 -541
  90. package/lib/session/load.js +48 -68
  91. package/lib/session/navigation.js +96 -87
  92. package/lib/session/npm.js +409 -345
  93. package/lib/session/ops.js +196 -63
  94. package/lib/session/pointer.js +23 -15
  95. package/lib/session/progress.js +33 -21
  96. package/lib/session/review.js +33 -54
  97. package/lib/session/tasks.js +333 -0
  98. package/lib/session/templates.js +51 -59
  99. package/lib/session/terminal.js +85 -136
  100. package/lib/session/unit.js +3 -4
  101. package/lib/session/update.js +76 -97
  102. package/lib/session/view.js +52 -83
  103. package/lib/session/watch.js +208 -204
  104. package/lib/session.js +112 -237
  105. package/lib/update.js +45 -116
  106. package/lib/utilities.js +67 -89
  107. package/lib/wrap.js +33 -44
  108. package/package.json +1 -1
  109. package/lib/session/todos.js +0 -293
@@ -3,12 +3,14 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
+ const utilities = require('../utilities.js');
7
+ const { isAbort } = utilities;
6
8
  const sessionLoad = require('./load.js');
7
- const { isAbortError } = sessionLoad;
9
+ const { tagLoaded, fetchSnapshot, fetchExtras } = sessionLoad;
8
10
  const watch = require('./watch.js');
9
- const { createDiskWatcher, POLL_MS, DEBOUNCE_MS } = watch;
11
+ const { DiskWatcher, DEBOUNCE_MS } = watch;
10
12
  const files = require('../files.js');
11
- const { itemPath, relativeAge, TODO_FILE } = files;
13
+ const { itemPath, relativeAge } = files;
12
14
  const deps = require('../deps.js');
13
15
  const { MANIFEST, LOCKFILE, depFileMeta } = deps;
14
16
  const review = require('../review.js');
@@ -16,34 +18,7 @@ const { hasNotes, loadReview } = review;
16
18
  const reviewMerge = require('../review-merge.js');
17
19
  const { mergeReview } = reviewMerge;
18
20
 
19
- const loadOptions = (api, extra = {}) => {
20
- const { loadConfig, ui } = api;
21
- return {
22
- commit: ui.rev,
23
- audit: loadConfig.audit,
24
- outdatedMap: loadConfig.outdatedMap,
25
- auditMap: loadConfig.auditMap,
26
- ...extra,
27
- };
28
- };
29
-
30
- const shouldDeferExtras = (api) => {
31
- const { loadConfig } = api;
32
- const audit = !!loadConfig.audit;
33
- const waitingOutdated = loadConfig.outdatedMap === undefined;
34
- const waitingAudit = loadConfig.auditMap === undefined;
35
- return audit && (waitingOutdated || waitingAudit);
36
- };
37
-
38
- const endReadLoop = (api) => {
39
- const finish = api.ui.finishReadLoop;
40
- if (finish) finish();
41
- };
42
-
43
- const markLoaded = (api) => {
44
- api.loader.didLoad = true;
45
- api.updater.maybePrompt();
46
- };
21
+ const WATCH_IGNORE_MS = 500;
47
22
 
48
23
  const isWorktree = (ui) => !ui.rev && !ui.change;
49
24
 
@@ -53,568 +28,455 @@ const kindFromExtra = (extra = {}) => {
53
28
  return 'reload';
54
29
  };
55
30
 
56
- const finishIfEmpty = (api, kind) => {
57
- const { collection, review: notes, ui, nav } = api;
58
- if (collection.items.length) return;
59
- if (notes.store && hasNotes(notes.store)) return;
60
- if (kind === 'keep') return;
61
- if (isWorktree(ui)) {
62
- if (nav.pane === 'diff') nav.pane = 'files';
63
- return;
64
- }
65
- ui.done = true;
66
- ui.exitCode = 0;
67
- ui.status = 'nothing to review';
68
- ui.emptyReview = kind === 'open';
69
- endReadLoop(api);
70
- };
71
-
72
- const failOpen = (api, error) => {
73
- if (isAbortError(error)) return;
74
- const { ui, ops } = api;
75
- ui.loadError = error;
76
- ui.done = true;
77
- ui.exitCode = 1;
78
- ops.setBusy('');
79
- ui.stopProgress();
80
- endReadLoop(api);
81
- };
82
-
83
- const restoreAfterLoad = (api, here, wasTodo) => {
84
- const { collection, nav, ui } = api;
85
- nav.todoOpen = wasTodo;
86
- if (here && !wasTodo) {
87
- const idx = collection.findRestoredIndex(here);
88
- if (idx >= 0) nav.index = idx;
89
- }
90
- ui.clampIndex();
91
- nav.restoreFileCursor(ui.fileList());
92
- ui.syncReviewPath();
93
- ui.syncFileCursor();
31
+ const statStamp = (file) => {
32
+ try {
33
+ const stat = fs.statSync(file);
34
+ return `${stat.mtimeMs}:${stat.size}`;
35
+ } catch {
36
+ return 'missing';
37
+ }
94
38
  };
95
39
 
96
- const itemRels = (collection) => {
97
- const rels = [];
98
- for (const item of collection.items) {
40
+ const itemRels = (items) => {
41
+ const rels = new Set();
42
+ for (const item of items) {
99
43
  const rel = itemPath(item);
100
- if (!rel) continue;
101
- if (rels.includes(rel)) continue;
102
- rels.push(rel);
44
+ if (rel) rels.add(rel);
103
45
  }
104
- return rels;
46
+ return [...rels];
105
47
  };
106
48
 
107
- const fileDatesFromDisk = (top, rels) => {
108
- const dates = new Map();
109
- if (!top) return dates;
49
+ const stampItemDates = (top, items) => {
50
+ if (!top) return;
110
51
  const now = Date.now();
111
- for (const rel of rels) {
52
+ const dates = new Map();
53
+ for (const rel of itemRels(items)) {
112
54
  try {
113
- const st = fs.statSync(path.join(top, rel));
114
- dates.set(rel, relativeAge(st.mtimeMs, now));
55
+ const stat = fs.statSync(path.join(top, rel));
56
+ dates.set(rel, relativeAge(stat.mtimeMs, now));
115
57
  } catch {
116
58
  continue;
117
59
  }
118
60
  }
119
- return dates;
120
- };
121
-
122
- const stampItemDates = (items, dates) => {
123
61
  for (const item of items) {
124
62
  const date = dates.get(itemPath(item));
125
63
  if (date) item.date = date;
126
64
  }
127
65
  };
128
66
 
129
- const syncUnitEditor = (api) => {
130
- const composer = api.ui.composer;
131
- if (!composer || typeof composer.applyDiskText !== 'function') return;
132
- if (api.nav.pane !== 'unit') return;
133
- const rel = api.nav.reviewPath;
134
- if (!rel) return;
135
- const repo = api.ui.repo;
136
- if (!repo || typeof repo.fileText !== 'function') return;
137
- const text = repo.fileText(api.ui.top || api.loadConfig.cwd, rel, api.ui.rev);
138
- composer.applyDiskText(text);
139
- };
140
-
141
- const syncItemWatches = (api) => {
142
- if (!api.diskWatcher) return;
143
- const rels = itemRels(api.collection);
144
- if (api.nav.pane === 'unit' && api.nav.reviewPath) {
145
- if (!rels.includes(api.nav.reviewPath)) rels.push(api.nav.reviewPath);
146
- }
147
- api.diskWatcher.watchFiles(rels);
148
- };
149
-
150
- const applyLoaded = (api, loaded) => {
151
- const { loader, collection, review: notes, nav, ui } = api;
152
- if (loaded.generation !== undefined) {
153
- if (!loader.accept(loaded.generation, ui.done)) return false;
154
- }
155
- const wasTodo = nav.todoOpen === true;
156
- const here = ui.current();
157
- ui.top = loaded.top ?? api.loadConfig.cwd;
158
- collection.replace(loaded.items);
159
- stampItemDates(
160
- collection.items,
161
- fileDatesFromDisk(ui.top, itemRels(collection)),
162
- );
163
- if (loaded.rev) ui.rev = loaded.rev;
164
- if (loaded.revShort) {
165
- ui.revShort = loaded.revShort;
166
- } else if (ui.rev && !ui.revShort) {
167
- ui.revShort = ui.rev.slice(0, 7);
168
- }
169
- if (loaded.sourceLabel) ui.sourceLabel = loaded.sourceLabel;
170
- if (loaded.change) ui.change = loaded.change;
171
- if (loaded.branch !== undefined) ui.branch = loaded.branch || '';
172
- const repository = ui.change ? ui.change.repository : '';
173
- if (!ui.repoName && repository) ui.repoName = repository;
174
- if (!notes.store) ui.initReview();
175
- notes.applyImported(loaded.imported);
176
- restoreAfterLoad(api, here, wasTodo);
177
- markLoaded(api);
178
- loader.pendingExtras = loaded.pending === true;
179
- syncItemWatches(api);
180
- syncUnitEditor(api);
181
- return true;
182
- };
183
-
184
- const load = (api) => {
185
- const { loadConfig } = api;
186
- const loaded = loadConfig.repo.load(
187
- loadConfig.cwd,
188
- loadConfig.paths,
189
- loadOptions(api),
190
- );
191
- applyLoaded(api, loaded);
192
- };
193
-
194
- const fileStamp = (top, rel) => {
195
- const full = path.join(top, rel);
196
- try {
197
- const stat = fs.statSync(full);
198
- return `${stat.mtimeMs}:${stat.size}`;
199
- } catch {
200
- return 'missing';
201
- }
202
- };
203
-
204
67
  const npmRels = (items) => {
205
- const rels = [MANIFEST, LOCKFILE];
68
+ const rels = new Set([MANIFEST, LOCKFILE]);
206
69
  for (const item of items) {
207
70
  const rel = itemPath(item);
208
- if (!depFileMeta(rel)) continue;
209
- if (rels.includes(rel)) continue;
210
- rels.push(rel);
71
+ if (depFileMeta(rel)) rels.add(rel);
211
72
  }
212
- rels.sort();
213
- return rels;
73
+ return [...rels].sort();
214
74
  };
215
75
 
216
- const npmStamp = (api) => {
217
- const top = api.ui.top || api.loadConfig.cwd;
218
- const rels = npmRels(api.collection.items);
219
- const parts = new Array(rels.length);
220
- for (let i = 0; i < rels.length; i++) {
221
- parts[i] = `${rels[i]}:${fileStamp(top, rels[i])}`;
76
+ class Lifecycle {
77
+ constructor(ui) {
78
+ this.ui = ui;
79
+ this.diskWatcher = null;
80
+ this.watchRetry = null;
81
+ this.reviewStamp = '';
82
+ this.ignoreWatchUntil = 0;
83
+ this.cachedExtras = null;
222
84
  }
223
- return parts.join('|');
224
- };
225
-
226
- const npmExtrasStale = (api) => {
227
- const cached = api.npmExtras;
228
- if (!cached || !api.loadConfig.audit) return false;
229
- return cached.stamp !== npmStamp(api);
230
- };
231
85
 
232
- const cacheNpmExtras = (api, loaded) => {
233
- api.npmExtras = {
234
- stamp: npmStamp(api),
235
- auditMap: loaded.auditMap ?? null,
236
- outdatedMap: loaded.outdatedMap ?? null,
237
- usedNames: loaded.usedNames ?? null,
238
- exportEntries: loaded.exportEntries,
239
- };
240
- };
86
+ get root() {
87
+ return this.ui.top || this.ui.cwd;
88
+ }
241
89
 
242
- const clearNpmExtras = (api) => {
243
- api.npmExtras = null;
244
- };
90
+ loadOptions(extra = {}) {
91
+ const ui = this.ui;
92
+ return {
93
+ commit: ui.rev,
94
+ audit: ui.audit,
95
+ outdatedMap: ui.outdatedMap,
96
+ auditMap: ui.auditMap,
97
+ ...extra,
98
+ };
99
+ }
245
100
 
246
- const followExtras = async (api, loaded, gen, kind) => {
247
- const extra = loadOptions(api, {
248
- paths: api.loadConfig.paths,
249
- signal: api.loader.signal,
250
- });
251
- let next;
252
- try {
253
- next = await api.loader.fetchExtras(api.loadConfig.repo, loaded, extra);
254
- } catch (error) {
255
- if (!api.loader.isCurrent(gen)) return;
256
- return void failOpen(api, error);
257
- }
258
- if (!api.loader.isCurrent(gen)) return;
259
- if (api.ui.done) {
260
- api.ops.clearBusy();
261
- return;
262
- }
263
- if (!next) {
264
- api.ops.clearBusy();
265
- api.loader.pendingExtras = false;
266
- api.ui.paint();
267
- finishIfEmpty(api, kind);
268
- return;
269
- }
270
- applyLoaded(api, api.loader.tag(next, gen));
271
- cacheNpmExtras(api, next);
272
- api.ops.clearBusy();
273
- api.ui.paint();
274
- finishIfEmpty(api, kind);
275
- };
101
+ endReadLoop() {
102
+ const finish = this.ui.finishReadLoop;
103
+ if (finish) finish();
104
+ }
276
105
 
277
- const npmReuseOpts = (api, extra = {}) => {
278
- const opts = {};
279
- if (extra.signal) opts.signal = extra.signal;
280
- const cached = api.npmExtras;
281
- if (!cached) return opts;
282
- if (cached.stamp !== npmStamp(api)) return opts;
283
- opts.deferExtras = false;
284
- opts.collect = false;
285
- opts.auditMap = cached.auditMap;
286
- opts.outdatedMap = cached.outdatedMap;
287
- opts.usedNames = cached.usedNames;
288
- opts.exportEntries = cached.exportEntries;
289
- return opts;
290
- };
106
+ finishIfEmpty(kind) {
107
+ const ui = this.ui;
108
+ if (ui.items.length) return;
109
+ const store = ui.review.store;
110
+ if (store && hasNotes(store)) return;
111
+ if (kind === 'keep') return;
112
+ if (isWorktree(ui)) {
113
+ if (ui.nav.pane === 'diff') ui.nav.pane = 'files';
114
+ return;
115
+ }
116
+ ui.done = true;
117
+ ui.exitCode = 0;
118
+ ui.status = 'nothing to review';
119
+ ui.emptyReview = kind === 'open';
120
+ this.endReadLoop();
121
+ }
122
+
123
+ failOpen(error) {
124
+ if (isAbort(error)) return;
125
+ const ui = this.ui;
126
+ ui.loadError = error;
127
+ ui.done = true;
128
+ ui.exitCode = 1;
129
+ ui.ops.setBusy('');
130
+ ui.stopProgress();
131
+ this.endReadLoop();
132
+ }
133
+
134
+ restoreAfterLoad(here, wasTasks) {
135
+ const { nav, collection } = this.ui;
136
+ nav.tasksOpen = wasTasks;
137
+ if (here && !wasTasks) {
138
+ const idx = collection.findRestoredIndex(here);
139
+ if (idx >= 0) nav.index = idx;
140
+ }
141
+ this.ui.clampIndex();
142
+ nav.restoreFileCursor(this.ui.fileList());
143
+ this.ui.syncReviewPath();
144
+ this.ui.syncFileCursor();
145
+ }
291
146
 
292
- const snapshotOpts = (api, extra = {}) =>
293
- loadOptions(api, {
294
- deferExtras: shouldDeferExtras(api),
295
- ...npmReuseOpts(api, extra),
296
- });
147
+ syncUnitEditor() {
148
+ const ui = this.ui;
149
+ const rel = ui.nav.reviewPath;
150
+ if (ui.nav.pane !== 'unit' || !rel || !ui.repo.fileText) return;
151
+ ui.composer.applyDiskText(ui.repo.fileText(this.root, rel, ui.rev));
152
+ }
297
153
 
298
- const resetNavAfterLoad = (api, extra = {}) => {
299
- api.nav.scroll = 0;
300
- api.nav.clearSelection();
301
- if (extra.afterLoad) extra.afterLoad();
302
- };
154
+ syncItemWatches() {
155
+ if (!this.diskWatcher) return;
156
+ const nav = this.ui.nav;
157
+ const rels = itemRels(this.ui.items);
158
+ if (nav.pane === 'unit' && nav.reviewPath) {
159
+ if (!rels.includes(nav.reviewPath)) rels.push(nav.reviewPath);
160
+ }
161
+ this.diskWatcher.watchFiles(rels);
162
+ }
303
163
 
304
- const afterSnapshot = (api, kind, extra = {}) => {
305
- if (kind !== 'open' && kind !== 'watch') {
306
- return void resetNavAfterLoad(api, extra);
164
+ applyLoaded(loaded) {
165
+ const ui = this.ui;
166
+ const { loader, collection, nav } = ui;
167
+ if (loaded.generation !== undefined) {
168
+ if (!loader.isCurrent(loaded.generation, ui.done)) return false;
169
+ }
170
+ const wasTasks = nav.tasksOpen === true;
171
+ const here = ui.current();
172
+ ui.top = loaded.top ?? ui.cwd;
173
+ collection.replace(loaded.items);
174
+ stampItemDates(ui.top, collection.items);
175
+ if (loaded.rev) ui.rev = loaded.rev;
176
+ if (loaded.revShort) ui.revShort = loaded.revShort;
177
+ else if (ui.rev && !ui.revShort) ui.revShort = ui.rev.slice(0, 7);
178
+ if (loaded.sourceLabel) ui.sourceLabel = loaded.sourceLabel;
179
+ if (loaded.change) ui.change = loaded.change;
180
+ if (loaded.branch !== undefined) ui.branch = loaded.branch || '';
181
+ const repository = ui.change ? ui.change.repository : '';
182
+ if (!ui.repoName && repository) ui.repoName = repository;
183
+ if (!ui.review.store) ui.initReview();
184
+ ui.review.applyImported(loaded.imported);
185
+ this.restoreAfterLoad(here, wasTasks);
186
+ loader.didLoad = true;
187
+ ui.updater.maybePrompt();
188
+ loader.pendingExtras = loaded.pending === true;
189
+ this.syncItemWatches();
190
+ this.syncUnitEditor();
191
+ ui.dashboard.noteLoaded();
192
+ return true;
193
+ }
194
+
195
+ load() {
196
+ const ui = this.ui;
197
+ const loaded = ui.repo.load(ui.cwd, ui.paths, this.loadOptions());
198
+ this.applyLoaded(loaded);
199
+ }
200
+
201
+ npmStamp() {
202
+ const root = this.root;
203
+ const rels = npmRels(this.ui.items);
204
+ const stamp = (rel) => `${rel}:${statStamp(path.join(root, rel))}`;
205
+ return rels.map(stamp).join('|');
206
+ }
207
+
208
+ npmExtrasStale() {
209
+ const cached = this.cachedExtras;
210
+ if (!cached || !this.ui.audit) return false;
211
+ return cached.stamp !== this.npmStamp();
212
+ }
213
+
214
+ cacheNpmExtras(loaded) {
215
+ this.cachedExtras = {
216
+ stamp: this.npmStamp(),
217
+ auditMap: loaded.auditMap ?? null,
218
+ outdatedMap: loaded.outdatedMap ?? null,
219
+ usedNames: loaded.usedNames ?? null,
220
+ exportEntries: loaded.exportEntries,
221
+ };
222
+ }
223
+
224
+ clearNpmExtras() {
225
+ this.cachedExtras = null;
226
+ }
227
+
228
+ npmExtras() {
229
+ if (this.cachedExtras) return this.cachedExtras;
230
+ const { auditMap, outdatedMap } = this.ui;
231
+ if (!auditMap && !outdatedMap) return null;
232
+ return { auditMap: auditMap ?? null, outdatedMap: outdatedMap ?? null };
233
+ }
234
+
235
+ isWanted(gen) {
236
+ if (!this.ui.loader.isCurrent(gen)) return false;
237
+ if (!this.ui.done) return true;
238
+ this.ui.ops.clearBusy();
239
+ return false;
240
+ }
241
+
242
+ async followExtras(loaded, gen, kind) {
243
+ const ui = this.ui;
244
+ const extra = this.loadOptions({
245
+ paths: ui.paths,
246
+ signal: ui.loader.signal,
247
+ });
248
+ let next;
249
+ try {
250
+ next = await fetchExtras(ui.repo, loaded, extra);
251
+ } catch (error) {
252
+ if (ui.loader.isCurrent(gen)) this.failOpen(error);
253
+ return;
254
+ }
255
+ if (!this.isWanted(gen)) return;
256
+ if (next) {
257
+ this.applyLoaded(tagLoaded(next, gen));
258
+ this.cacheNpmExtras(next);
259
+ } else {
260
+ ui.loader.pendingExtras = false;
261
+ }
262
+ ui.ops.clearBusy();
263
+ ui.paint();
264
+ this.finishIfEmpty(kind);
265
+ }
266
+
267
+ snapshotOpts(extra = {}) {
268
+ const ui = this.ui;
269
+ const waiting = ui.outdatedMap === undefined || ui.auditMap === undefined;
270
+ const opts = { deferExtras: ui.audit && waiting };
271
+ if (extra.signal) opts.signal = extra.signal;
272
+ const cached = this.cachedExtras;
273
+ if (cached && cached.stamp === this.npmStamp()) {
274
+ opts.deferExtras = false;
275
+ opts.collect = false;
276
+ opts.auditMap = cached.auditMap;
277
+ opts.outdatedMap = cached.outdatedMap;
278
+ opts.usedNames = cached.usedNames;
279
+ opts.exportEntries = cached.exportEntries;
280
+ }
281
+ return this.loadOptions(opts);
307
282
  }
308
- if (extra.afterLoad) extra.afterLoad();
309
- };
310
283
 
311
- const beginSnapshot = (api, extra) => {
312
- if (extra.quiet === true) return;
313
- api.ui.progressFrame = 0;
314
- api.ops.setBusy('loading');
315
- api.ui.paint();
316
- };
284
+ afterSnapshot(kind, extra = {}) {
285
+ const nav = this.ui.nav;
286
+ if (nav.pane === 'commits') this.ui.commits.refresh();
287
+ if (kind !== 'open' && kind !== 'watch') {
288
+ nav.scroll = 0;
289
+ nav.clearSelection();
290
+ }
291
+ if (extra.afterLoad) extra.afterLoad();
292
+ }
317
293
 
318
- const runSnapshot = async (api, gen, kind, extra = {}) => {
319
- beginSnapshot(api, extra);
320
- let loaded;
321
- try {
322
- loaded = await api.loader.fetchSnapshot(
323
- api.loadConfig.repo,
324
- api.loadConfig.cwd,
325
- api.loadConfig.paths,
326
- snapshotOpts(api, {
327
- signal: api.loader.signal,
328
- keepView: extra.keepView,
329
- }),
330
- );
331
- } catch (error) {
332
- if (!api.loader.isCurrent(gen)) return;
333
- return void failOpen(api, error);
334
- }
335
- if (!api.loader.isCurrent(gen)) return;
336
- if (api.ui.done) {
337
- api.ops.clearBusy();
338
- return;
339
- }
340
- applyLoaded(api, api.loader.tag(loaded, gen));
341
- afterSnapshot(api, kind, extra);
342
- if (loaded.pending === true || npmExtrasStale(api)) {
343
- api.ops.setBusy('checking npm');
344
- if (kind === 'open' || extra.quiet === true) api.ui.paint();
345
- await followExtras(api, loaded, gen, kind);
346
- return;
347
- }
348
- api.ops.clearBusy();
349
- finishIfEmpty(api, kind);
350
- if (extra.doneStatus && !api.ui.status && !api.ops.busy) {
351
- api.ui.status = extra.doneStatus;
352
- }
353
- api.ui.paint();
354
- };
294
+ async runSnapshot(gen, kind, extra = {}) {
295
+ const ui = this.ui;
296
+ if (extra.quiet !== true) {
297
+ ui.progressFrame = 0;
298
+ ui.ops.setBusy('loading');
299
+ ui.paint();
300
+ }
301
+ let loaded;
302
+ try {
303
+ const opts = this.snapshotOpts({ signal: ui.loader.signal });
304
+ loaded = await fetchSnapshot(ui.repo, ui.cwd, ui.paths, opts);
305
+ } catch (error) {
306
+ if (ui.loader.isCurrent(gen)) this.failOpen(error);
307
+ return;
308
+ }
309
+ if (!this.isWanted(gen)) return;
310
+ this.applyLoaded(tagLoaded(loaded, gen));
311
+ this.afterSnapshot(kind, extra);
312
+ if (loaded.pending === true || this.npmExtrasStale()) {
313
+ ui.ops.setBusy('checking npm');
314
+ if (kind === 'open' || extra.quiet === true) ui.paint();
315
+ await this.followExtras(loaded, gen, kind);
316
+ return;
317
+ }
318
+ ui.ops.clearBusy();
319
+ this.finishIfEmpty(kind);
320
+ if (extra.doneStatus && !ui.status && !ui.ops.busy) {
321
+ ui.status = extra.doneStatus;
322
+ }
323
+ ui.paint();
324
+ }
355
325
 
356
- const openLoad = (api) => {
357
- const { loader } = api;
358
- if (loader.promise) return loader.promise;
359
- if (loader.didLoad && !loader.pendingExtras) {
360
- loader.promise = Promise.resolve();
326
+ openLoad() {
327
+ const loader = this.ui.loader;
328
+ if (loader.promise) return loader.promise;
329
+ if (loader.didLoad && !loader.pendingExtras) {
330
+ loader.promise = Promise.resolve();
331
+ } else {
332
+ loader.promise = this.runSnapshot(loader.bump(), 'open');
333
+ }
361
334
  return loader.promise;
362
335
  }
363
- const gen = loader.bump();
364
- loader.promise = runSnapshot(api, gen, 'open');
365
- return loader.promise;
366
- };
367
-
368
- const loadReady = async (api) => {
369
- api.ui.ensureRepo();
370
- await openLoad(api);
371
- if (api.ui.loadError) throw api.ui.loadError;
372
- };
373
-
374
- const refreshFromRepo = (api, extra = {}) => {
375
- api.loader.promise = null;
376
- const gen = api.loader.bump();
377
- const kind = kindFromExtra(extra);
378
- if (api.ui.uiOpen && api.loadConfig.repo.loadAsync) {
379
- return void runSnapshot(api, gen, kind, extra);
380
- }
381
- const loaded = api.loadConfig.repo.load(
382
- api.loadConfig.cwd,
383
- api.loadConfig.paths,
384
- snapshotOpts(api, extra),
385
- );
386
- applyLoaded(api, api.loader.tag(loaded, gen));
387
- afterSnapshot(api, kind, extra);
388
- if (loaded.pending === true || npmExtrasStale(api)) {
389
- api.ops.setBusy('checking npm');
390
- return void followExtras(api, loaded, gen, kind);
391
- }
392
- if (kind === 'keep') return;
393
- finishIfEmpty(api, kind);
394
- if (extra.doneStatus && !api.ui.done && !api.loader.pendingExtras) {
395
- api.ui.status = extra.doneStatus;
396
- }
397
- if (kind === 'watch') api.ui.paint();
398
- };
399
-
400
- const reloadAfterChange = (api, afterLoad) => {
401
- const hereItem = api.ui.current();
402
- const hereIndex = api.nav.index;
403
- const restore = () => {
404
- const idx = api.collection.findRestoredIndex(hereItem);
405
- api.nav.index = idx >= 0 ? idx : hereIndex;
406
- api.ui.clampIndex();
407
- api.ui.syncReviewPath();
408
- api.ui.syncFileCursor();
409
- if (afterLoad) afterLoad();
410
- };
411
- if (api.ui.uiOpen && api.loadConfig.repo.loadAsync) {
412
- return void refreshFromRepo(api, { afterLoad: restore });
413
- }
414
- refreshFromRepo(api);
415
- restore();
416
- };
417
336
 
418
- const onReload = (api) => {
419
- api.collection.dismissed.clear();
420
- api.ui.status = '';
421
- refreshFromRepo(api, { doneStatus: 'reloaded' });
422
- };
423
-
424
- const clearWatchRetry = (api) => {
425
- if (api.watchRetry === null) return;
426
- clearTimeout(api.watchRetry);
427
- api.watchRetry = null;
428
- };
429
-
430
- const shouldDeferWatch = (api) => {
431
- const { ui, ops } = api;
432
- if (ui.done) return true;
433
- if (!ui.uiOpen) return true;
434
- if (!api.loader.didLoad) return true;
435
- if (ops.gitBusy) return true;
436
- if (ui.mode === 'compose') {
437
- const kind = ui.composer && ui.composer.state.composeKind;
438
- if (kind === 'file') return false;
439
- }
440
- if (ui.mode !== 'review') return true;
441
- return false;
442
- };
443
-
444
- const WATCH_IGNORE_MS = 500;
445
-
446
- const ignoreWatch = (api, ms = WATCH_IGNORE_MS) => {
447
- api.ignoreWatchUntil = Date.now() + ms;
448
- };
449
-
450
- const onDiskChange = (api) => {
451
- if (Date.now() < (api.ignoreWatchUntil ?? 0)) return;
452
- if (shouldDeferWatch(api)) {
453
- if (api.watchRetry !== null) return;
454
- api.watchRetry = setTimeout(() => {
455
- api.watchRetry = null;
456
- onDiskChange(api);
457
- }, DEBOUNCE_MS);
458
- if (typeof api.watchRetry.unref === 'function') api.watchRetry.unref();
459
- return;
337
+ async loadReady() {
338
+ this.ui.ensureRepo();
339
+ await this.openLoad();
340
+ if (this.ui.loadError) throw this.ui.loadError;
460
341
  }
461
- refreshFromRepo(api, { keepView: true, quiet: true });
462
- };
463
342
 
464
- const catchUpWatch = (api) => {
465
- clearWatchRetry(api);
466
- if (shouldDeferWatch(api)) return void onDiskChange(api);
467
- refreshFromRepo(api, { keepView: true, quiet: true });
468
- };
469
-
470
- const stampFile = (root, rel) => {
471
- const full = path.join(root, rel);
472
- let stat;
473
- try {
474
- stat = fs.statSync(full);
475
- } catch {
476
- return `${rel}:missing`;
343
+ refreshFromRepo(extra = {}) {
344
+ const ui = this.ui;
345
+ ui.loader.promise = null;
346
+ const gen = ui.loader.bump();
347
+ const kind = kindFromExtra(extra);
348
+ if (ui.uiOpen && ui.repo.loadAsync) {
349
+ return void this.runSnapshot(gen, kind, extra);
350
+ }
351
+ const loaded = ui.repo.load(ui.cwd, ui.paths, this.snapshotOpts(extra));
352
+ this.applyLoaded(tagLoaded(loaded, gen));
353
+ this.afterSnapshot(kind, extra);
354
+ if (loaded.pending === true || this.npmExtrasStale()) {
355
+ ui.ops.setBusy('checking npm');
356
+ return void this.followExtras(loaded, gen, kind);
357
+ }
358
+ if (kind === 'keep') return;
359
+ this.finishIfEmpty(kind);
360
+ if (extra.doneStatus && !ui.done && !ui.loader.pendingExtras) {
361
+ ui.status = extra.doneStatus;
362
+ }
363
+ if (kind === 'watch') ui.paint();
364
+ }
365
+
366
+ reloadAfterChange(afterLoad) {
367
+ const ui = this.ui;
368
+ const hereItem = ui.current();
369
+ const hereIndex = ui.nav.index;
370
+ const restore = () => {
371
+ const idx = ui.collection.findRestoredIndex(hereItem);
372
+ ui.nav.index = idx >= 0 ? idx : hereIndex;
373
+ ui.clampIndex();
374
+ ui.syncReviewPath();
375
+ ui.syncFileCursor();
376
+ if (afterLoad) afterLoad();
377
+ };
378
+ if (ui.uiOpen && ui.repo.loadAsync) {
379
+ return void this.refreshFromRepo({ afterLoad: restore });
380
+ }
381
+ this.refreshFromRepo();
382
+ restore();
477
383
  }
478
- return `${rel}:${stat.mtimeMs}:${stat.size}`;
479
- };
480
384
 
481
- const worktreeStamp = (api) => {
482
- const root = api.ui.top || api.loadConfig.cwd;
483
- const parts = [];
484
- for (const rel of itemRels(api.collection)) {
485
- if (!rel || rel === TODO_FILE) continue;
486
- parts.push(stampFile(root, rel));
385
+ onReload() {
386
+ this.ui.collection.dismissed.clear();
387
+ this.ui.status = '';
388
+ this.refreshFromRepo({ doneStatus: 'reloaded' });
487
389
  }
488
- return parts.join('\n');
489
- };
490
-
491
- const watchesWorktree = (nav) =>
492
- nav.todoOpen === true ||
493
- nav.pane === 'branches' ||
494
- nav.pane === 'commits' ||
495
- nav.pane === 'npm';
496
-
497
- const currentFileStamp = (api) => {
498
- const { nav } = api;
499
- const root = api.ui.top || api.loadConfig.cwd;
500
- if (watchesWorktree(nav)) return worktreeStamp(api);
501
- const pane = nav.pane;
502
- if (pane !== 'diff' && pane !== 'unit') return '';
503
- const rel = pane === 'unit' ? nav.reviewPath : itemPath(api.ui.current());
504
- if (!rel) return '';
505
- return stampFile(root, rel);
506
- };
507
390
 
508
- const pollCurrentFile = (api) => {
509
- const stamp = currentFileStamp(api);
510
- if (!stamp) {
511
- api.watchStamp = '';
512
- return;
391
+ clearWatchRetry() {
392
+ if (this.watchRetry === null) return;
393
+ clearTimeout(this.watchRetry);
394
+ this.watchRetry = null;
513
395
  }
514
- if (!api.watchStamp) {
515
- api.watchStamp = stamp;
516
- return;
517
- }
518
- if (stamp === api.watchStamp) return;
519
- api.watchStamp = stamp;
520
- onDiskChange(api);
521
- };
522
396
 
523
- const reviewFileStamp = (api) => {
524
- const store = api.review && api.review.store;
525
- if (!store || !store.reviewPath) return '';
526
- try {
527
- const stat = fs.statSync(store.reviewPath);
528
- return `${stat.mtimeMs}:${stat.size}`;
529
- } catch {
530
- return 'missing';
397
+ shouldDeferWatch() {
398
+ const ui = this.ui;
399
+ if (ui.done || !ui.uiOpen || !ui.loader.didLoad) return true;
400
+ if (ui.ops.gitBusy) return true;
401
+ if (ui.mode === 'compose' && ui.composeKind === 'file') return false;
402
+ return ui.mode !== 'review';
531
403
  }
532
- };
533
404
 
534
- const noteReviewStamp = (api) => {
535
- api.reviewStamp = reviewFileStamp(api);
536
- };
405
+ ignoreWatch(ms = WATCH_IGNORE_MS) {
406
+ this.ignoreWatchUntil = Date.now() + ms;
407
+ }
537
408
 
538
- const applyReviewChange = (api) => {
539
- if (!api.ui || api.ui.done) return;
540
- const stamp = reviewFileStamp(api);
541
- if (!stamp || stamp === api.reviewStamp) return;
542
- if (stamp === 'missing') {
543
- api.reviewStamp = stamp;
544
- return;
545
- }
546
- const store = api.review && api.review.store;
547
- if (!store) return;
548
- let disk;
549
- try {
550
- disk = loadReview(store.reviewPath, store.templates);
551
- } catch {
552
- return;
409
+ onDiskChange() {
410
+ if (Date.now() < this.ignoreWatchUntil) return;
411
+ if (!this.shouldDeferWatch()) {
412
+ return void this.refreshFromRepo({ keepView: true, quiet: true });
413
+ }
414
+ if (this.watchRetry !== null) return;
415
+ this.watchRetry = setTimeout(() => {
416
+ this.watchRetry = null;
417
+ this.onDiskChange();
418
+ }, DEBOUNCE_MS);
419
+ this.watchRetry.unref();
553
420
  }
554
- mergeReview(store, disk);
555
- api.reviewStamp = stamp;
556
- if (typeof api.ui.paint === 'function') api.ui.paint();
557
- };
558
421
 
559
- const stopWatch = (api) => {
560
- clearWatchRetry(api);
561
- api.watchStamp = '';
562
- api.reviewStamp = '';
563
- if (api.ui.term) api.ui.term.stopTimer('watch');
564
- if (!api.diskWatcher) return;
565
- api.diskWatcher.close();
566
- api.diskWatcher = null;
567
- };
422
+ catchUpWatch() {
423
+ this.clearWatchRetry();
424
+ if (this.shouldDeferWatch()) return void this.onDiskChange();
425
+ this.refreshFromRepo({ keepView: true, quiet: true });
426
+ }
568
427
 
569
- const startWatch = (api) => {
570
- stopWatch(api);
571
- if (!isWorktree(api.ui)) return;
572
- const root = api.ui.top || api.loadConfig.cwd;
573
- if (!root) return;
574
- api.diskWatcher = createDiskWatcher({
575
- root,
576
- paths: api.loadConfig.paths,
577
- onChange: () => onDiskChange(api),
578
- onReview: () => applyReviewChange(api),
579
- });
580
- syncItemWatches(api);
581
- noteReviewStamp(api);
582
- if (!api.ui.term) return;
583
- api.ui.term.startTimer(
584
- 'watch',
585
- () => {
586
- pollCurrentFile(api);
587
- applyReviewChange(api);
588
- },
589
- POLL_MS,
590
- );
591
- };
428
+ reviewFileStamp() {
429
+ const store = this.ui.review.store;
430
+ if (!store || !store.reviewPath) return '';
431
+ return statStamp(store.reviewPath);
432
+ }
592
433
 
593
- const createLifecycle = (api) => {
594
- api.diskWatcher = null;
595
- api.watchRetry = null;
596
- api.watchStamp = '';
597
- api.reviewStamp = '';
598
- api.ignoreWatchUntil = 0;
599
- api.npmExtras = null;
600
- return {
601
- applyLoaded: (loaded) => applyLoaded(api, loaded),
602
- load: () => load(api),
603
- openLoad: () => openLoad(api),
604
- loadReady: () => loadReady(api),
605
- refreshFromRepo: (extra) => refreshFromRepo(api, extra),
606
- reloadAfterChange: (afterLoad) => reloadAfterChange(api, afterLoad),
607
- onReload: () => onReload(api),
608
- onDiskChange: () => onDiskChange(api),
609
- applyReviewChange: () => applyReviewChange(api),
610
- noteReviewStamp: () => noteReviewStamp(api),
611
- catchUpWatch: () => catchUpWatch(api),
612
- pollCurrentFile: () => pollCurrentFile(api),
613
- startWatch: () => startWatch(api),
614
- stopWatch: () => stopWatch(api),
615
- ignoreWatch: (ms) => ignoreWatch(api, ms),
616
- clearNpmExtras: () => clearNpmExtras(api),
617
- };
618
- };
434
+ noteReviewStamp() {
435
+ this.reviewStamp = this.reviewFileStamp();
436
+ }
619
437
 
620
- module.exports = { createLifecycle };
438
+ applyReviewChange() {
439
+ if (this.ui.done) return;
440
+ const stamp = this.reviewFileStamp();
441
+ if (!stamp || stamp === this.reviewStamp) return;
442
+ if (stamp === 'missing') {
443
+ this.reviewStamp = stamp;
444
+ return;
445
+ }
446
+ const store = this.ui.review.store;
447
+ let disk;
448
+ try {
449
+ disk = loadReview(store.reviewPath, store.templates);
450
+ } catch {
451
+ return;
452
+ }
453
+ mergeReview(store, disk);
454
+ this.reviewStamp = stamp;
455
+ this.ui.paint();
456
+ }
457
+
458
+ stopWatch() {
459
+ this.clearWatchRetry();
460
+ this.reviewStamp = '';
461
+ if (!this.diskWatcher) return;
462
+ this.diskWatcher.close();
463
+ this.diskWatcher = null;
464
+ }
465
+
466
+ startWatch() {
467
+ this.stopWatch();
468
+ const root = this.root;
469
+ if (!isWorktree(this.ui) || !root) return;
470
+ this.diskWatcher = new DiskWatcher({
471
+ root,
472
+ paths: this.ui.paths,
473
+ onChange: () => this.onDiskChange(),
474
+ onReview: () => this.applyReviewChange(),
475
+ onPaths: (paths) => this.ui.dashboard.noteDisk(paths),
476
+ });
477
+ this.syncItemWatches();
478
+ this.noteReviewStamp();
479
+ }
480
+ }
481
+
482
+ module.exports = { Lifecycle };