reslop 0.2.0 → 0.2.3

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 (43) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +13 -2
  3. package/lib/ansi.js +263 -6
  4. package/lib/api.js +2 -0
  5. package/lib/capabilities.js +6 -0
  6. package/lib/cli.js +13 -2
  7. package/lib/deps.js +43 -3
  8. package/lib/git-branches.js +228 -17
  9. package/lib/git-reword-todo.js +20 -0
  10. package/lib/git.js +20 -3
  11. package/lib/keys.js +10 -4
  12. package/lib/npm-commands.js +513 -0
  13. package/lib/render/branches.js +23 -58
  14. package/lib/render/chrome.js +156 -99
  15. package/lib/render/commits.js +643 -31
  16. package/lib/render/diff.js +4 -4
  17. package/lib/render/frame.js +51 -5
  18. package/lib/render/notes.js +22 -5
  19. package/lib/render/npm.js +228 -0
  20. package/lib/render/primitives.js +48 -34
  21. package/lib/render/render.js +3 -3
  22. package/lib/report-model.js +127 -0
  23. package/lib/report-parse.js +94 -0
  24. package/lib/report-render.js +87 -0
  25. package/lib/report-run.js +164 -0
  26. package/lib/report-test.js +506 -0
  27. package/lib/report-tools.js +311 -0
  28. package/lib/review-merge.js +270 -0
  29. package/lib/review.js +38 -6
  30. package/lib/session/actions.js +81 -5
  31. package/lib/session/commit.js +187 -19
  32. package/lib/session/compose.js +96 -32
  33. package/lib/session/dispatch.js +36 -2
  34. package/lib/session/files-pane.js +36 -6
  35. package/lib/session/lifecycle.js +116 -16
  36. package/lib/session/navigation.js +1 -0
  37. package/lib/session/npm.js +335 -0
  38. package/lib/session/pointer.js +12 -1
  39. package/lib/session/todos.js +29 -1
  40. package/lib/session/view.js +14 -3
  41. package/lib/session/watch.js +120 -8
  42. package/lib/session.js +11 -2
  43. package/package.json +7 -7
@@ -4,12 +4,14 @@ const wrap = require('../wrap.js');
4
4
  const { cursorInWrap, wrapDoc } = wrap;
5
5
  const render = require('../render/render.js');
6
6
  const { noteInnerWidth, codeInnerWidth, todoBodyWidth } = render;
7
+ const { commitMessageWidth } = require('../render/commits.js');
8
+ const { listedCommits } = require('./commit.js');
7
9
  const { Editor } = require('../editor.js');
8
10
  const files = require('../files.js');
9
11
  const { itemPath, isTodoItem, listPath, isReadOnlyOrigin } = files;
10
12
  const { blockAddText } = require('../diff/diff.js');
11
13
  const items = require('./items.js');
12
- const { itemFeedKey, canEditCode, blockLinesKey } = items;
14
+ const { itemFeedKey, canEditCode } = items;
13
15
  const review = require('../review.js');
14
16
  const { setFeedback, setCode, checkLabel, rememberTemplate } = review;
15
17
  const { createTemplatePick } = require('./templates.js');
@@ -68,12 +70,21 @@ const KIND = {
68
70
  command: 'branch',
69
71
  view: 'note',
70
72
  },
73
+ npm: {
74
+ enter: 'save',
75
+ tab: 'insert-tab',
76
+ escape: 'cancel',
77
+ move: 'note',
78
+ command: 'npm',
79
+ view: 'note',
80
+ },
71
81
  };
72
82
 
73
83
  const initialState = () => ({
74
84
  editor: null,
75
85
  composeKind: null,
76
86
  commitKind: null,
87
+ commitBody: '',
77
88
  composeTodoId: null,
78
89
  composeBaseline: '',
79
90
  templateIndex: -1,
@@ -81,6 +92,7 @@ const initialState = () => ({
81
92
  blinkOn: true,
82
93
  fileDirty: false,
83
94
  fileEditRows: null,
95
+ fileOpened: '',
84
96
  });
85
97
 
86
98
  const reset = (state) => {
@@ -124,6 +136,7 @@ const openCompose = (api, kind, text, todoId) => {
124
136
  state.composeKind = kind;
125
137
  state.composeTodoId = todoId ?? null;
126
138
  state.composeBaseline = text;
139
+ state.fileOpened = kind === 'file' ? text : '';
127
140
  state.editor = new Editor(text);
128
141
  state.templateIndex = -1;
129
142
  state.templateFocus = false;
@@ -138,8 +151,10 @@ const closeCompose = (api) => {
138
151
  state.editor = null;
139
152
  state.composeKind = null;
140
153
  state.commitKind = null;
154
+ state.commitBody = '';
141
155
  state.composeTodoId = null;
142
156
  state.composeBaseline = '';
157
+ state.fileOpened = '';
143
158
  state.templateIndex = -1;
144
159
  state.templateFocus = false;
145
160
  state.fileEditRows = null;
@@ -240,60 +255,66 @@ const saveUnitFile = (api, text) => {
240
255
  return true;
241
256
  };
242
257
 
243
- const snapshotPathKeys = (list, rel) => {
244
- const keys = new Set();
245
- for (const item of list) {
246
- if (listPath(item) !== rel) continue;
247
- keys.add(blockLinesKey(item));
248
- }
249
- return keys;
250
- };
251
-
252
- const stageNewFileHunks = (api, rel, beforeKeys) => {
258
+ const stageEditedFile = (api, rel) => {
259
+ if (!rel) return false;
253
260
  const repo = api.ui.repo;
254
- if (!rel || !repo || typeof repo.add !== 'function') return 0;
261
+ if (!repo) return false;
255
262
  const caps = api.ui.capabilities;
256
- if (!caps || caps.changes !== true) return 0;
257
- const list = api.ui.items ?? [];
263
+ if (!caps || caps.changes !== true) return false;
264
+ if (typeof repo.stagePath === 'function') {
265
+ try {
266
+ repo.stagePath(api.ui.top, rel);
267
+ } catch (error) {
268
+ api.ui.status = error.message;
269
+ return false;
270
+ }
271
+ if (typeof api.ui.ignoreWatch === 'function') api.ui.ignoreWatch();
272
+ return true;
273
+ }
274
+ if (typeof repo.add !== 'function') return false;
258
275
  let staged = 0;
259
- for (const item of list) {
276
+ for (const item of api.ui.items ?? []) {
260
277
  if (listPath(item) !== rel) continue;
261
278
  if (isReadOnlyOrigin(item.origin)) continue;
262
279
  if (item.origin === 'staged') continue;
263
- if (beforeKeys.has(blockLinesKey(item))) continue;
264
280
  try {
265
281
  repo.add(api.ui.top, item);
266
282
  } catch (error) {
267
283
  api.ui.status = error.message;
268
- return staged;
284
+ return staged > 0;
269
285
  }
270
286
  if (api.ui.collection && api.ui.collection.keepOrigin) {
271
287
  api.ui.collection.keepOrigin(item, 'staged');
272
288
  }
273
- if (typeof api.ui.ignoreWatch === 'function') api.ui.ignoreWatch();
274
289
  staged += 1;
275
290
  }
276
- return staged;
291
+ if (staged && typeof api.ui.ignoreWatch === 'function') {
292
+ api.ui.ignoreWatch();
293
+ }
294
+ return staged > 0;
277
295
  };
278
296
 
279
297
  const saveFileCompose = (api) => {
280
298
  const rel = api.nav.reviewPath;
281
- const beforeKeys = snapshotPathKeys(api.ui.items ?? [], rel);
299
+ const opened = api.state.fileOpened;
300
+ const text = api.state.editor ? api.state.editor.text : '';
282
301
  if (api.state.editor) {
283
302
  api.nav.unitLine = api.state.editor.linePos().line;
284
303
  }
285
- const result = saveUnitFile(api, api.state.editor.text);
304
+ const result = saveUnitFile(api, text);
286
305
  if (result === false) return { kind: 'error' };
306
+ const changed = text !== opened;
287
307
  const reload = api.state.fileDirty === true;
308
+ if (changed) stageEditedFile(api, rel);
288
309
  api.ui.flushReview();
289
310
  closeCompose(api);
311
+ const doneStatus = changed ? 'staged' : 'saved';
290
312
  if (!reload || typeof api.ui.reloadAfterChange !== 'function') {
291
- api.ui.status = 'saved';
313
+ if (!api.ui.status) api.ui.status = doneStatus;
292
314
  return { kind: 'saved' };
293
315
  }
294
316
  api.ui.reloadAfterChange(() => {
295
- const n = stageNewFileHunks(api, rel, beforeKeys);
296
- api.ui.status = n > 0 ? 'staged' : 'saved';
317
+ if (!api.ui.status) api.ui.status = doneStatus;
297
318
  });
298
319
  return { kind: 'saved' };
299
320
  };
@@ -321,6 +342,13 @@ const saveCompose = (api, extra) => {
321
342
  if (spec && spec.command) return { kind: spec.command };
322
343
  const kind = api.state.composeKind;
323
344
  if (kind === 'file') return saveFileCompose(api);
345
+ const item = api.ui.current ? api.ui.current() : null;
346
+ const shouldStage =
347
+ kind === 'code' &&
348
+ writesCodeFile(api, item) &&
349
+ item &&
350
+ item.origin !== 'staged';
351
+ const rel = itemPath(item);
324
352
  const baseline = api.state.composeBaseline;
325
353
  const result = commitCompose(api);
326
354
  if (result === false) return { kind: 'error' };
@@ -331,12 +359,17 @@ const saveCompose = (api, extra) => {
331
359
  const next = editor ? editor.text : '';
332
360
  if (notes) rememberTemplate(notes, baseline, next);
333
361
  }
362
+ if (shouldStage && reload) stageEditedFile(api, rel);
334
363
  api.ui.flushReview();
335
364
  closeCompose(api);
365
+ const doneStatus = shouldStage && reload ? 'staged' : 'saved';
336
366
  if (reload && typeof api.ui.reloadAfterChange === 'function') {
337
- api.ui.reloadAfterChange();
367
+ api.ui.reloadAfterChange(() => {
368
+ if (!api.ui.status) api.ui.status = doneStatus;
369
+ });
370
+ } else {
371
+ api.ui.status = doneStatus;
338
372
  }
339
- api.ui.status = 'saved';
340
373
  if (opts.editNext === true) api.todos.editNextTodo();
341
374
  return { kind: 'saved' };
342
375
  };
@@ -369,7 +402,7 @@ const composeView = (api) => {
369
402
  const kind = state.composeKind;
370
403
  const text = state.editor.text;
371
404
  const cursor = state.editor.cursor;
372
- return { kind, text, cursor };
405
+ return { kind, text, cursor, commitKind: state.commitKind };
373
406
  };
374
407
 
375
408
  const liveCodeText = (api) => {
@@ -453,6 +486,22 @@ const moveNoteLine = (api, delta) => {
453
486
  api.state.editor.moveLine(delta, width);
454
487
  };
455
488
 
489
+ const fullCommitEdit = (api) =>
490
+ api.state.composeKind === 'commit' && api.ui.commits.commitView === 'full';
491
+
492
+ const editedCommitEntry = (api) => {
493
+ const kind = api.state.commitKind;
494
+ if (kind !== 'amend' && kind !== 'reword') return null;
495
+ const list = listedCommits(api.ui.commits.commits);
496
+ return list[api.nav.commitCursor] ?? null;
497
+ };
498
+
499
+ const moveCommitLine = (api, delta) => {
500
+ const size = viewSize(api);
501
+ const width = commitMessageWidth(editedCommitEntry(api), size.width);
502
+ api.state.editor.moveLine(delta, width);
503
+ };
504
+
456
505
  const moveComposeLine = (api, delta) => {
457
506
  const spec = KIND[api.state.composeKind];
458
507
  const move = spec ? spec.move : 'note';
@@ -466,6 +515,7 @@ const moveComposeLine = (api, delta) => {
466
515
  const inner = codeInnerWidth(size.width, api.ui.layout);
467
516
  return void api.state.editor.moveLine(delta, inner);
468
517
  }
518
+ if (fullCommitEdit(api)) return void moveCommitLine(api, delta);
469
519
  moveNoteLine(api, delta);
470
520
  };
471
521
 
@@ -554,17 +604,31 @@ const onCode = (api) => {
554
604
  openCompose(api, 'code', text);
555
605
  };
556
606
 
607
+ const insertNewline = (api) => {
608
+ api.state.editor.insert('\n');
609
+ resetBlink(api);
610
+ followFileEdit(api);
611
+ return null;
612
+ };
613
+
614
+ const onEmptyLastLine = (editor) => {
615
+ if (!editor) return false;
616
+ const text = editor.text;
617
+ const lastBreak = text.lastIndexOf('\n');
618
+ if (editor.cursor < lastBreak + 1) return false;
619
+ return text.slice(lastBreak + 1).trim() === '';
620
+ };
621
+
557
622
  const enterCompose = (api) => {
558
623
  const { state } = api;
559
624
  if (state.templateFocus && state.composeKind === 'feedback') {
560
625
  return api.templates.selectTemplate(state.templateIndex);
561
626
  }
562
627
  const spec = KIND[state.composeKind];
563
- if (spec && spec.enter === 'newline') {
564
- state.editor.insert('\n');
565
- resetBlink(api);
566
- followFileEdit(api);
567
- return null;
628
+ if (spec && spec.enter === 'newline') return insertNewline(api);
629
+ if (fullCommitEdit(api)) {
630
+ if (onEmptyLastLine(state.editor)) return saveCompose(api);
631
+ return insertNewline(api);
568
632
  }
569
633
  if (spec && spec.enter === 'edit-next') {
570
634
  return saveCompose(api, { editNext: true });
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const compose = require('./compose.js');
4
+ const { listedCommits } = require('./commit.js');
4
5
  const { COMPOSE_LEAVE } = compose;
5
6
  const actions = require('./actions.js');
6
7
  const { CONFIRM_MODE, disabledActions, actionFromKey, applyConfirm } = actions;
@@ -16,7 +17,7 @@ const currentTarget = (ui) => {
16
17
  return ui.branches[ui.branchCursor];
17
18
  }
18
19
  if (ui.pane === 'commits') {
19
- const entry = ui.commits.commits[ui.commitCursor];
20
+ const entry = listedCommits(ui.commits.commits)[ui.commitCursor];
20
21
  const staged = targetHasStaged(ui);
21
22
  if (!entry) return { canCommit: staged };
22
23
  return { ...entry, canCommit: staged };
@@ -32,6 +33,7 @@ const createDispatcher = (ui) => {
32
33
  next: ui.filesPane.onNext,
33
34
  prev: ui.filesPane.onPrev,
34
35
  layout: ui.filesPane.onLayout,
36
+ theme: ui.filesPane.onTheme,
35
37
  files: ui.filesPane.onFiles,
36
38
  feedback: ui.composer.onFeedback,
37
39
  todo: ui.composer.onTodo,
@@ -39,7 +41,10 @@ const createDispatcher = (ui) => {
39
41
  reload: ui.lifecycle.onReload,
40
42
  commit: ui.commits.onCommit,
41
43
  amend: ui.commits.onAmend,
44
+ reword: ui.commits.onReword,
45
+ apply: ui.commits.onApply,
42
46
  fixup: ui.commits.onFixup,
47
+ view: () => ui.commits.onView(),
43
48
  branch: ui.gitBranches.onBranch,
44
49
  pull: ui.gitBranches.onPull,
45
50
  push: ui.gitBranches.onPush,
@@ -54,7 +59,16 @@ const createDispatcher = (ui) => {
54
59
  open: ui.filesPane.onOpenFile,
55
60
  file: ui.filesPane.onFileScope,
56
61
  diff: ui.filesPane.onDiffScope,
57
- removeTodo: ui.composer.removeFocusedTodo,
62
+ npm: ui.npm.open,
63
+ npmEdit: ui.npm.edit,
64
+ npmNew: ui.npm.create,
65
+ npmStop: () => ui.npm.closeView(),
66
+ npmRerun: () => ui.npm.rerun(),
67
+ check: () => ui.composer.toggleFocusedTodo(),
68
+ removeTodo: () => {
69
+ if (ui.pane === 'npm') return void ui.npm.askDrop();
70
+ ui.composer.removeFocusedTodo();
71
+ },
58
72
  quit: () => ui.onQuit(),
59
73
  scrollUp: () => ui.filesPane.onScroll(-1),
60
74
  scrollDown: () => ui.filesPane.onScroll(1),
@@ -86,6 +100,10 @@ const createDispatcher = (ui) => {
86
100
  if (action === 'next') return void ui.commits.onCommitMove(1);
87
101
  if (action === 'prev') return void ui.commits.onCommitMove(-1);
88
102
  }
103
+ if (pane === 'npm') {
104
+ if (action === 'next') return void ui.npm.move(1);
105
+ if (action === 'prev') return void ui.npm.move(-1);
106
+ }
89
107
  const hidden = disabledActions(pane, currentTarget(ui));
90
108
  if (hidden.includes(action)) return;
91
109
  const handler = handlers[action];
@@ -109,12 +127,14 @@ const createInput = (ui) => {
109
127
  },
110
128
  confirmDrop: {
111
129
  confirm: () => {
130
+ if (ui.npm.dropName) return void ui.npm.confirmDrop();
112
131
  if (ui.commits.dropName) {
113
132
  return void ui.commits.confirmDropCommit();
114
133
  }
115
134
  ui.gitBranches.confirmDropBranch();
116
135
  },
117
136
  cancel: () => {
137
+ if (ui.npm.dropName) return void ui.npm.cancelDrop();
118
138
  if (ui.commits.dropName) {
119
139
  return void ui.commits.cancelDropCommit();
120
140
  }
@@ -141,7 +161,21 @@ const createInput = (ui) => {
141
161
  const command = ui.composer.handleKey(key);
142
162
  return void ui.runComposeCommand(command);
143
163
  }
164
+ if (ui.pane === 'npm' && ui.npm.viewing) {
165
+ if (key === 'escape') return void ui.npm.closeView();
166
+ if (key === 'r') return void ui.npm.rerun();
167
+ if (key === 'e' || key === 'n') return;
168
+ }
169
+ if (ui.pane === 'npm' && key === 'ctrl-up') return void ui.npm.reorder(-1);
170
+ if (ui.pane === 'npm' && key === 'ctrl-down') {
171
+ return void ui.npm.reorder(1);
172
+ }
173
+ if (ui.pane === 'npm' && (key === 'up' || key === 'down')) {
174
+ return void ui.npm.move(key === 'up' ? -1 : 1);
175
+ }
144
176
  if (key === 'escape') return void ui.filesPane.onEscape();
177
+ const toggle = key === 'x' || key === ' ';
178
+ if (toggle && ui.composer.toggleFocusedTodo()) return;
145
179
  if (ui.composer.typeIntoTodo(key)) return;
146
180
  const action = actionFromKey(key, ui.pane, ui.fileScope);
147
181
  if (action) ui.dispatch(action);
@@ -1,7 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const ansi = require('../ansi.js');
3
4
  const files = require('../files.js');
5
+ const { THEME_NAMES, setTheme, themeName } = ansi;
4
6
  const { isTodosEntry, TODO_FILE } = files;
7
+ const { logViewRows } = require('../render/npm.js');
5
8
  const unit = require('./unit.js');
6
9
  const { setFileScope, openUnitFile, moveUnitBlock } = unit;
7
10
 
@@ -24,14 +27,25 @@ const syncFileCursor = (ui) => {
24
27
  };
25
28
 
26
29
  const showFiles = (ui) => {
27
- ui.nav.pane = 'files';
28
- ui.nav.clearSelection();
30
+ const nav = ui.nav;
31
+ const held =
32
+ nav.todoOpen === true ||
33
+ nav.pane === 'branches' ||
34
+ nav.pane === 'commits' ||
35
+ nav.pane === 'npm';
36
+ nav.pane = 'files';
37
+ nav.clearSelection();
29
38
  followReviewPath(ui);
30
- ui.nav.todoOpen = false;
39
+ nav.todoOpen = false;
40
+ if (held) ui.lifecycle.catchUpWatch();
31
41
  };
32
42
 
33
43
  const onEscape = (ui) => {
34
- if (ui.nav.pane === 'branches' || ui.nav.pane === 'commits') {
44
+ if (
45
+ ui.nav.pane === 'branches' ||
46
+ ui.nav.pane === 'commits' ||
47
+ ui.nav.pane === 'npm'
48
+ ) {
35
49
  return void showFiles(ui);
36
50
  }
37
51
  if (ui.nav.pane === 'files') return void ui.onQuit();
@@ -59,7 +73,8 @@ const onOpenFile = (ui) => {
59
73
  if (ui.nav.pane === 'branches') {
60
74
  return void ui.gitBranches.onCheckoutBranch();
61
75
  }
62
- if (ui.nav.pane === 'commits') return;
76
+ if (ui.nav.pane === 'commits') return void ui.commits.onShowDiff();
77
+ if (ui.nav.pane === 'npm') return void ui.npm.run();
63
78
  if (ui.nav.pane !== 'files') {
64
79
  if (ui.nav.todoOpen) ui.composer.editFocusedTodo();
65
80
  return;
@@ -146,7 +161,9 @@ const scrollStep = (ui, fraction) => {
146
161
  const rows = size.height ?? 24;
147
162
  const fallback = Math.max(1, rows - 4);
148
163
  const bodyH = frame?.bodyH ?? fallback;
149
- const height = Math.max(1, bodyH);
164
+ const viewing = ui.nav.pane === 'npm' && ui.npm && ui.npm.viewing;
165
+ const viewRows = viewing ? logViewRows(bodyH) : bodyH;
166
+ const height = Math.max(1, viewRows);
150
167
  const span = Math.floor(height * Math.abs(fraction));
151
168
  const n = Math.max(1, span);
152
169
  if (fraction < 0) return -n;
@@ -161,6 +178,7 @@ const onScroll = (ui, delta) => {
161
178
  if (ui.nav.pane === 'commits') {
162
179
  return void ui.commits.onCommitMove(delta);
163
180
  }
181
+ if (ui.nav.pane === 'npm') return void ui.npm.move(delta);
164
182
  if (ui.nav.todoOpen && ui.composer.todoRowCount() > 1) {
165
183
  return void ui.composer.moveTodoFocus(delta);
166
184
  }
@@ -192,6 +210,10 @@ const onJump = (ui, toEnd) => {
192
210
  const delta = toEnd ? Number.MAX_SAFE_INTEGER : -Number.MAX_SAFE_INTEGER;
193
211
  return void ui.commits.onCommitMove(delta);
194
212
  }
213
+ if (ui.nav.pane === 'npm') {
214
+ const delta = toEnd ? Number.MAX_SAFE_INTEGER : -Number.MAX_SAFE_INTEGER;
215
+ return void ui.npm.move(delta);
216
+ }
195
217
  if (ui.nav.todoOpen && ui.composer.todoRowCount() > 0) {
196
218
  const last = ui.composer.todoRowCount() - 1;
197
219
  const target = toEnd ? last : 0;
@@ -214,6 +236,13 @@ const onLayout = (ui) => {
214
236
  ui.nav.clearSelection();
215
237
  };
216
238
 
239
+ const onTheme = (ui) => {
240
+ const i = THEME_NAMES.indexOf(themeName());
241
+ const next = THEME_NAMES[(i + 1) % THEME_NAMES.length];
242
+ setTheme(next);
243
+ ui.status = next;
244
+ };
245
+
217
246
  const createFilesPane = (ui) => ({
218
247
  clampIndex: () => clampIndex(ui),
219
248
  syncReviewPath: () => syncReviewPath(ui),
@@ -230,6 +259,7 @@ const createFilesPane = (ui) => ({
230
259
  onHome: () => onJump(ui, false),
231
260
  onEnd: () => onJump(ui, true),
232
261
  onLayout: () => onLayout(ui),
262
+ onTheme: () => onTheme(ui),
233
263
  onFileScope: () => setFileScope(ui, 'file'),
234
264
  onDiffScope: () => setFileScope(ui, 'diff'),
235
265
  });
@@ -6,10 +6,11 @@ const path = require('node:path');
6
6
  const { isAbortError } = require('./load.js');
7
7
  const { createDiskWatcher, POLL_MS, DEBOUNCE_MS } = require('./watch.js');
8
8
  const files = require('../files.js');
9
- const { itemPath, relativeAge } = files;
10
- const { MANIFEST, LOCKFILE } = require('../deps.js');
9
+ const { itemPath, relativeAge, TODO_FILE } = files;
10
+ const { MANIFEST, LOCKFILE, depFileMeta } = require('../deps.js');
11
11
  const review = require('../review.js');
12
- const { hasNotes } = review;
12
+ const { hasNotes, loadReview } = review;
13
+ const { mergeReview } = require('../review-merge.js');
13
14
 
14
15
  const loadOptions = (api, extra = {}) => {
15
16
  const { loadConfig, ui } = api;
@@ -196,11 +197,32 @@ const fileStamp = (top, rel) => {
196
197
  }
197
198
  };
198
199
 
200
+ const npmRels = (items) => {
201
+ const rels = [MANIFEST, LOCKFILE];
202
+ for (const item of items) {
203
+ const rel = itemPath(item);
204
+ if (!depFileMeta(rel)) continue;
205
+ if (rels.includes(rel)) continue;
206
+ rels.push(rel);
207
+ }
208
+ rels.sort();
209
+ return rels;
210
+ };
211
+
199
212
  const npmStamp = (api) => {
200
213
  const top = api.ui.top || api.loadConfig.cwd;
201
- const pkg = fileStamp(top, MANIFEST);
202
- const lock = fileStamp(top, LOCKFILE);
203
- return `${pkg}|${lock}`;
214
+ const rels = npmRels(api.collection.items);
215
+ const parts = new Array(rels.length);
216
+ for (let i = 0; i < rels.length; i++) {
217
+ parts[i] = `${rels[i]}:${fileStamp(top, rels[i])}`;
218
+ }
219
+ return parts.join('|');
220
+ };
221
+
222
+ const npmExtrasStale = (api) => {
223
+ const cached = api.npmExtras;
224
+ if (!cached || !api.loadConfig.audit) return false;
225
+ return cached.stamp !== npmStamp(api);
204
226
  };
205
227
 
206
228
  const cacheNpmExtras = (api, loaded) => {
@@ -251,7 +273,6 @@ const followExtras = async (api, loaded, gen, kind) => {
251
273
  const npmReuseOpts = (api, extra = {}) => {
252
274
  const opts = {};
253
275
  if (extra.signal) opts.signal = extra.signal;
254
- if (extra.keepView !== true) return opts;
255
276
  const cached = api.npmExtras;
256
277
  if (!cached) return opts;
257
278
  if (cached.stamp !== npmStamp(api)) return opts;
@@ -314,7 +335,7 @@ const runSnapshot = async (api, gen, kind, extra = {}) => {
314
335
  }
315
336
  applyLoaded(api, api.loader.tag(loaded, gen));
316
337
  afterSnapshot(api, kind, extra);
317
- if (loaded.pending) {
338
+ if (loaded.pending === true || npmExtrasStale(api)) {
318
339
  api.ops.setBusy('checking npm');
319
340
  if (kind === 'open' || extra.quiet === true) api.ui.paint();
320
341
  await followExtras(api, loaded, gen, kind);
@@ -360,7 +381,7 @@ const refreshFromRepo = (api, extra = {}) => {
360
381
  );
361
382
  applyLoaded(api, api.loader.tag(loaded, gen));
362
383
  afterSnapshot(api, kind, extra);
363
- if (loaded.pending) {
384
+ if (loaded.pending === true || npmExtrasStale(api)) {
364
385
  api.ops.setBusy('checking npm');
365
386
  return void followExtras(api, loaded, gen, kind);
366
387
  }
@@ -436,12 +457,14 @@ const onDiskChange = (api) => {
436
457
  refreshFromRepo(api, { keepView: true, quiet: true });
437
458
  };
438
459
 
439
- const currentFileStamp = (api) => {
440
- const pane = api.nav.pane;
441
- if (pane !== 'diff' && pane !== 'unit') return '';
442
- const rel = pane === 'unit' ? api.nav.reviewPath : itemPath(api.ui.current());
443
- if (!rel) return '';
444
- const full = path.join(api.ui.top || api.loadConfig.cwd, rel);
460
+ const catchUpWatch = (api) => {
461
+ clearWatchRetry(api);
462
+ if (shouldDeferWatch(api)) return void onDiskChange(api);
463
+ refreshFromRepo(api, { keepView: true, quiet: true });
464
+ };
465
+
466
+ const stampFile = (root, rel) => {
467
+ const full = path.join(root, rel);
445
468
  let stat;
446
469
  try {
447
470
  stat = fs.statSync(full);
@@ -451,6 +474,33 @@ const currentFileStamp = (api) => {
451
474
  return `${rel}:${stat.mtimeMs}:${stat.size}`;
452
475
  };
453
476
 
477
+ const worktreeStamp = (api) => {
478
+ const root = api.ui.top || api.loadConfig.cwd;
479
+ const parts = [];
480
+ for (const rel of itemRels(api.collection)) {
481
+ if (!rel || rel === TODO_FILE) continue;
482
+ parts.push(stampFile(root, rel));
483
+ }
484
+ return parts.join('\n');
485
+ };
486
+
487
+ const watchesWorktree = (nav) =>
488
+ nav.todoOpen === true ||
489
+ nav.pane === 'branches' ||
490
+ nav.pane === 'commits' ||
491
+ nav.pane === 'npm';
492
+
493
+ const currentFileStamp = (api) => {
494
+ const { nav } = api;
495
+ const root = api.ui.top || api.loadConfig.cwd;
496
+ if (watchesWorktree(nav)) return worktreeStamp(api);
497
+ const pane = nav.pane;
498
+ if (pane !== 'diff' && pane !== 'unit') return '';
499
+ const rel = pane === 'unit' ? nav.reviewPath : itemPath(api.ui.current());
500
+ if (!rel) return '';
501
+ return stampFile(root, rel);
502
+ };
503
+
454
504
  const pollCurrentFile = (api) => {
455
505
  const stamp = currentFileStamp(api);
456
506
  if (!stamp) {
@@ -466,9 +516,46 @@ const pollCurrentFile = (api) => {
466
516
  onDiskChange(api);
467
517
  };
468
518
 
519
+ const reviewFileStamp = (api) => {
520
+ const store = api.review && api.review.store;
521
+ if (!store || !store.reviewPath) return '';
522
+ try {
523
+ const stat = fs.statSync(store.reviewPath);
524
+ return `${stat.mtimeMs}:${stat.size}`;
525
+ } catch {
526
+ return 'missing';
527
+ }
528
+ };
529
+
530
+ const noteReviewStamp = (api) => {
531
+ api.reviewStamp = reviewFileStamp(api);
532
+ };
533
+
534
+ const applyReviewChange = (api) => {
535
+ if (!api.ui || api.ui.done) return;
536
+ const stamp = reviewFileStamp(api);
537
+ if (!stamp || stamp === api.reviewStamp) return;
538
+ if (stamp === 'missing') {
539
+ api.reviewStamp = stamp;
540
+ return;
541
+ }
542
+ const store = api.review && api.review.store;
543
+ if (!store) return;
544
+ let disk;
545
+ try {
546
+ disk = loadReview(store.reviewPath, store.templates);
547
+ } catch {
548
+ return;
549
+ }
550
+ mergeReview(store, disk);
551
+ api.reviewStamp = stamp;
552
+ if (typeof api.ui.paint === 'function') api.ui.paint();
553
+ };
554
+
469
555
  const stopWatch = (api) => {
470
556
  clearWatchRetry(api);
471
557
  api.watchStamp = '';
558
+ api.reviewStamp = '';
472
559
  if (api.ui.term) api.ui.term.stopTimer('watch');
473
560
  if (!api.diskWatcher) return;
474
561
  api.diskWatcher.close();
@@ -484,16 +571,26 @@ const startWatch = (api) => {
484
571
  root,
485
572
  paths: api.loadConfig.paths,
486
573
  onChange: () => onDiskChange(api),
574
+ onReview: () => applyReviewChange(api),
487
575
  });
488
576
  syncItemWatches(api);
577
+ noteReviewStamp(api);
489
578
  if (!api.ui.term) return;
490
- api.ui.term.startTimer('watch', () => pollCurrentFile(api), POLL_MS);
579
+ api.ui.term.startTimer(
580
+ 'watch',
581
+ () => {
582
+ pollCurrentFile(api);
583
+ applyReviewChange(api);
584
+ },
585
+ POLL_MS,
586
+ );
491
587
  };
492
588
 
493
589
  const createLifecycle = (api) => {
494
590
  api.diskWatcher = null;
495
591
  api.watchRetry = null;
496
592
  api.watchStamp = '';
593
+ api.reviewStamp = '';
497
594
  api.ignoreWatchUntil = 0;
498
595
  api.npmExtras = null;
499
596
  return {
@@ -505,6 +602,9 @@ const createLifecycle = (api) => {
505
602
  reloadAfterChange: (afterLoad) => reloadAfterChange(api, afterLoad),
506
603
  onReload: () => onReload(api),
507
604
  onDiskChange: () => onDiskChange(api),
605
+ applyReviewChange: () => applyReviewChange(api),
606
+ noteReviewStamp: () => noteReviewStamp(api),
607
+ catchUpWatch: () => catchUpWatch(api),
508
608
  pollCurrentFile: () => pollCurrentFile(api),
509
609
  startWatch: () => startWatch(api),
510
610
  stopWatch: () => stopWatch(api),
@@ -9,6 +9,7 @@ const initialState = (options = {}) => ({
9
9
  fileCursor: 0,
10
10
  branchCursor: 0,
11
11
  commitCursor: 0,
12
+ npmCursor: 0,
12
13
  scroll: 0,
13
14
  listScroll: 0,
14
15
  reviewPath: null,