reslop 0.3.1 → 0.3.2

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 (65) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +163 -436
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -14
  7. package/lib/agents/models.js +250 -383
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/dir.js +50 -0
  19. package/lib/git/linked.js +152 -0
  20. package/lib/git/local.js +179 -0
  21. package/lib/git/status.js +126 -0
  22. package/lib/git/worktree.js +113 -36
  23. package/lib/input/actions.js +83 -5
  24. package/lib/input/keys.js +30 -1
  25. package/lib/input/line-edit.js +5 -1
  26. package/lib/render/agents.js +50 -24
  27. package/lib/render/branches.js +55 -27
  28. package/lib/render/field.js +19 -5
  29. package/lib/render/footer.js +14 -6
  30. package/lib/render/frame.js +16 -2
  31. package/lib/render/menu.js +103 -6
  32. package/lib/render/name-edit.js +31 -0
  33. package/lib/render/repos.js +52 -8
  34. package/lib/render/status.js +22 -4
  35. package/lib/report/run.js +16 -23
  36. package/lib/review/agent-instructions.md +2 -2
  37. package/lib/runs/process.js +3 -3
  38. package/lib/runs/runs.js +27 -4
  39. package/lib/session/agents/agents.js +220 -205
  40. package/lib/session/agents/away.js +48 -0
  41. package/lib/session/agents/job.js +23 -86
  42. package/lib/session/agents/jobs.js +14 -27
  43. package/lib/session/agents/options.js +45 -101
  44. package/lib/session/agents/plans.js +53 -0
  45. package/lib/session/agents/view.js +34 -0
  46. package/lib/session/branches.js +18 -1
  47. package/lib/session/compose.js +13 -5
  48. package/lib/session/dispatch.js +43 -4
  49. package/lib/session/editor-pointer.js +98 -0
  50. package/lib/session/files-pane.js +3 -0
  51. package/lib/session/lifecycle.js +67 -10
  52. package/lib/session/navigation.js +5 -0
  53. package/lib/session/npm/history.js +2 -1
  54. package/lib/session/npm/npm.js +89 -12
  55. package/lib/session/ops.js +3 -0
  56. package/lib/session/pointer.js +304 -46
  57. package/lib/session/session.js +15 -2
  58. package/lib/session/terminal.js +2 -2
  59. package/lib/session/text-menu.js +50 -0
  60. package/lib/session/view.js +19 -2
  61. package/lib/session/workspace.js +179 -38
  62. package/lib/session/worktrees.js +280 -0
  63. package/lib/term/themes/dark.json +4 -0
  64. package/lib/term/themes/light.json +4 -0
  65. package/package.json +2 -2
@@ -47,7 +47,7 @@ class BranchController {
47
47
  ui.refreshFromRepo({ keepEmpty: true, ...extra });
48
48
  }
49
49
 
50
- async open() {
50
+ async open(focusTrees = false) {
51
51
  const ui = this.ui;
52
52
  const loaded = await this.loadBranches();
53
53
  if (!loaded) return;
@@ -55,9 +55,11 @@ class BranchController {
55
55
  const nav = ui.nav;
56
56
  nav.branchCursor = current < 0 ? 0 : current;
57
57
  nav.pane = 'branches';
58
+ nav.treeFocus = focusTrees;
58
59
  nav.resetListScroll('branches');
59
60
  ui.status = '';
60
61
  nav.clearSelection();
62
+ void ui.worktrees.refreshTrees();
61
63
  }
62
64
 
63
65
  runWrite(name, args, kind, done) {
@@ -76,8 +78,23 @@ class BranchController {
76
78
  ui.ops.hold(this.open());
77
79
  }
78
80
 
81
+ // The worktrees above the branches share the cursor keys with them: one
82
+ // element is selected, up from the first branch reaches the worktrees
83
+ moveBetweenTrees(delta) {
84
+ const nav = this.ui.nav;
85
+ if (!this.ui.worktrees.hasPanels()) return false;
86
+ if (nav.treeFocus) {
87
+ if (delta > 0) nav.treeFocus = false;
88
+ return true;
89
+ }
90
+ if (delta >= 0 || nav.branchCursor > 0) return false;
91
+ nav.treeFocus = true;
92
+ return true;
93
+ }
94
+
79
95
  onBranchMove(delta) {
80
96
  const nav = this.ui.nav;
97
+ if (this.moveBetweenTrees(delta)) return void (this.ui.status = '');
81
98
  if (!this.branches.length) {
82
99
  this.ui.status = 'nothing to review';
83
100
  return;
@@ -6,7 +6,7 @@ const { noteInnerWidth, codeInnerWidth, todoBodyWidth, activeDigits } = render;
6
6
  const { commitMessageWidth } = require('../render/commits.js');
7
7
  const { listedCommits } = require('./commit.js');
8
8
  const { Editor } = require('../input/editor.js');
9
- const { isTextKey } = require('../input/keys.js');
9
+ const { isTextKey, isNewlineKey } = require('../input/keys.js');
10
10
  const files = require('../common/files.js');
11
11
  const { itemPath, isTaskItem, isTasksEntry, listPath } = files;
12
12
  const { isReadOnlyOrigin } = files;
@@ -39,10 +39,18 @@ const KIND = {
39
39
  branch: { enter: 'save', tab: 'template', escape: 'cancel', move: 'note' },
40
40
  npm: { enter: 'save', tab: 'insert-tab', escape: 'cancel', move: 'note' },
41
41
  package: { enter: 'save', tab: 'insert-tab', escape: 'cancel', move: 'note' },
42
+ worktree: { enter: 'save', tab: 'template', escape: 'cancel', move: 'note' },
42
43
  };
43
44
 
44
- const COMMAND_KINDS = ['commit', 'branch', 'npm', 'package'];
45
- const NOTE_VIEWS = ['feedback', 'commit', 'branch', 'npm', 'package'];
45
+ const COMMAND_KINDS = ['commit', 'branch', 'npm', 'package', 'worktree'];
46
+ const NOTE_VIEWS = [
47
+ 'feedback',
48
+ 'commit',
49
+ 'branch',
50
+ 'npm',
51
+ 'package',
52
+ 'worktree',
53
+ ];
46
54
 
47
55
  const itemNote = (item, text) => {
48
56
  const hunk = item.hunk;
@@ -674,8 +682,8 @@ class Composer {
674
682
  }
675
683
 
676
684
  handleKey(key) {
677
- if (key === 'enter') {
678
- const result = this.enter();
685
+ if (key === 'enter' || isNewlineKey(key)) {
686
+ const result = isNewlineKey(key) ? this.insertNewline() : this.enter();
679
687
  this.syncCodeScroll();
680
688
  return result;
681
689
  }
@@ -8,7 +8,7 @@ const { REPOS_ALLOWED } = actions;
8
8
  const { disabledActions, actionFromKey, confirmKey, lineHasDiff } = actions;
9
9
  const { onCommand } = require('./change-actions.js');
10
10
  const filesPane = require('./files-pane.js');
11
- const { handlePointer } = require('./pointer.js');
11
+ const { handlePointer, handleContextKey } = require('./pointer.js');
12
12
  const { FIND_PANES, startFind, onFindKey } = require('./find.js');
13
13
  const { startImport, stopImport, onImportKey } = require('./import.js');
14
14
  const { unitBodyLines } = require('./view.js');
@@ -109,6 +109,7 @@ const HANDLERS = {
109
109
  packageLatest: (ui) => ui.packages.updateLatest(),
110
110
  npmLogs: (ui) => ui.npm.askLogs(),
111
111
  npmStop: (ui) => ui.npm.stop(),
112
+ npmSolve: (ui) => ui.npm.solve(),
112
113
  npmVerbose: (ui) => ui.npm.toggleVerbose(),
113
114
  npmRerun: (ui) => ui.npm.rerun(),
114
115
  agentModel: (ui) => ui.agents.openPick('model'),
@@ -116,10 +117,16 @@ const HANDLERS = {
116
117
  agentFast: (ui) => ui.agents.toggleFast(),
117
118
  agentContext: (ui) => ui.agents.openPick('context'),
118
119
  agentReview: (ui) => ui.agents.openPick('plan'),
119
- agentContinue: (ui) => ui.agents.continueSession(),
120
+ agentInstall: (ui) => ui.agents.proposeInstall(),
121
+ agentStart: (ui) => ui.agents.startSelected(),
120
122
  agentStop: (ui) => ui.agents.stop(),
121
123
  agentRerun: (ui) => ui.agents.rerun(),
122
124
  quit: (ui) => ui.onQuit(),
125
+ treeLeft: (ui) => ui.worktrees.moveTree(-1),
126
+ treeRight: (ui) => ui.worktrees.moveTree(1),
127
+ worktreeNew: (ui) => ui.worktrees.onAdd(),
128
+ worktreeDrop: (ui) => ui.worktrees.onDrop(),
129
+ worktreeBranch: (ui) => ui.worktrees.onBranch(),
123
130
  repoLeft: (ui) => ui.workspace.move(-1, 0),
124
131
  repoRight: (ui) => ui.workspace.move(1, 0),
125
132
  repoUp: (ui) => ui.workspace.move(0, -1),
@@ -127,7 +134,22 @@ const HANDLERS = {
127
134
  ...DASH_HANDLERS,
128
135
  };
129
136
 
130
- const dispatchAction = (ui, action) => {
137
+ // The keys of a branch do the same to the selected tile of the worktrees
138
+ const TREE_ACTIONS = {
139
+ newBranch: 'worktreeNew',
140
+ drop: 'worktreeDrop',
141
+ };
142
+ const TREE_OFF = ['rebase', 'commit'];
143
+
144
+ const forSelection = (ui, action) => {
145
+ if (!ui.worktrees.focused()) return action;
146
+ if (TREE_OFF.includes(action)) return null;
147
+ return TREE_ACTIONS[action] ?? action;
148
+ };
149
+
150
+ const dispatchAction = (ui, picked) => {
151
+ const action = forSelection(ui, picked);
152
+ if (!action) return;
131
153
  if (CONFIRM_MODE.includes(ui.mode)) return;
132
154
  if (ui.mode === 'import') stopImport(ui);
133
155
  if (action === 'lines' && (ui.pane === 'diff' || ui.pane === 'unit')) {
@@ -159,6 +181,10 @@ const CONFIRMATIONS = {
159
181
  accept: (ui) => ui.updater.accept(),
160
182
  decline: (ui) => ui.updater.decline(),
161
183
  },
184
+ confirmAgentInstall: {
185
+ confirm: (ui) => ui.agents.confirmInstall(),
186
+ cancel: (ui) => ui.agents.cancelInstall(),
187
+ },
162
188
  confirmDrop: {
163
189
  confirm: (ui) => {
164
190
  if (ui.packages.dropName) return void ui.packages.confirmDrop();
@@ -173,6 +199,14 @@ const CONFIRMATIONS = {
173
199
  ui.gitBranches.cancelDropBranch();
174
200
  },
175
201
  },
202
+ confirmWorktree: {
203
+ confirm: (ui) => ui.worktrees.confirmDrop(),
204
+ cancel: (ui) => ui.worktrees.cancelDrop(),
205
+ },
206
+ confirmCreateWorktree: {
207
+ confirm: (ui) => ui.worktrees.confirmBranch(),
208
+ cancel: (ui) => ui.worktrees.cancelBranch(),
209
+ },
176
210
  confirmPush: {
177
211
  force: (ui) => ui.gitBranches.onForcePush(),
178
212
  cancel: (ui) => ui.gitBranches.cancelPush(),
@@ -194,7 +228,10 @@ const confirmChoice = (ui, key) => {
194
228
 
195
229
  const NPM_VIEW_KEYS = {
196
230
  escape: (ui) => ui.npm.closeView(),
197
- s: (ui) => ui.npm.stop(),
231
+ s: (ui) => {
232
+ if (ui.npm.viewedRunning()) ui.npm.stop();
233
+ else ui.npm.solve();
234
+ },
198
235
  r: (ui) => ui.npm.rerun(),
199
236
  v: (ui) => ui.npm.toggleVerbose(),
200
237
  e: () => {},
@@ -254,7 +291,9 @@ const planMenuKey = (ui, key) => {
254
291
  };
255
292
 
256
293
  const handleKey = (ui, key) => {
294
+ ui.nav.editDrag = null;
257
295
  if (CONFIRM_MODE.includes(ui.mode)) return void confirmChoice(ui, key);
296
+ if (handleContextKey(ui, key)) return;
258
297
  if (planMenuKey(ui, key)) return;
259
298
  if (ui.pane === 'tasks' && (key === 'ctrl-up' || key === 'ctrl-down')) {
260
299
  if (ui.mode === 'import') stopImport(ui);
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const { indexAtRow } = require('../term/wrap.js');
4
+
5
+ const hitAt = (hits, cell) =>
6
+ hits.find((hit) => {
7
+ if (hit.y !== cell.y) return false;
8
+ const x0 = hit.x0 ?? hit.textX;
9
+ return cell.x >= x0 && (hit.x1 === undefined || cell.x < hit.x1);
10
+ });
11
+
12
+ const offsetOf = (hit, cellX) => {
13
+ const col = Math.max(0, cellX - hit.textX) + (hit.scroll || 0);
14
+ const at = indexAtRow(hit.start || 0, hit.text, col);
15
+ return Math.min(at, hit.limit ?? Infinity);
16
+ };
17
+
18
+ const focusField = (ui, hit) => {
19
+ if (ui.composeKind !== 'npm' || !hit.field) return true;
20
+ if (hit.field === ui.npm.editField) return true;
21
+ return ui.npm.focusField(hit.field);
22
+ };
23
+
24
+ const activeEditor = (ui) => {
25
+ if (ui.pane === 'agents' && ui.agents.pick) return ui.agents.pick.editor;
26
+ if (ui.pane === 'tasks' && ui.composer.tasks.planOpen) {
27
+ return ui.composer.tasks.planPick.editor;
28
+ }
29
+ if (ui.mode === 'find') return ui.nav.find?.editor;
30
+ if (ui.mode === 'import') return ui.nav.import?.editor;
31
+ return ui.mode === 'compose' ? ui.editor : null;
32
+ };
33
+
34
+ const editorHits = (ui, editor) =>
35
+ (ui.lastFrame?.editHits ?? []).filter(
36
+ (hit) => (hit.editor ?? ui.editor) === editor,
37
+ );
38
+
39
+ const revealEditor = (ui) => {
40
+ if (ui.mode === 'compose') ui.composer.afterEdit();
41
+ ui.paint();
42
+ };
43
+
44
+ const beginEditDrag = (ui, cell, extend = false) => {
45
+ const editor = activeEditor(ui);
46
+ if (!editor) return false;
47
+ const hit = hitAt(editorHits(ui, editor), cell);
48
+ if (!hit) return false;
49
+ if (!focusField(ui, hit)) {
50
+ ui.paint();
51
+ return true;
52
+ }
53
+ const previous = editor.anchor ?? editor.cursor;
54
+ const anchor = extend ? previous : offsetOf(hit, cell.x);
55
+ editor.place(offsetOf(hit, cell.x));
56
+ if (extend && anchor !== editor.cursor) editor.anchor = anchor;
57
+ ui.nav.clearSelection();
58
+ ui.nav.editDrag = { editor, anchor, field: hit.field, cell };
59
+ revealEditor(ui);
60
+ return true;
61
+ };
62
+
63
+ const moveEditDrag = (ui, cell, release = false) => {
64
+ const drag = ui.nav.editDrag;
65
+ if (!drag) return false;
66
+ if (release && drag.cell.x === cell.x && drag.cell.y === cell.y) {
67
+ ui.nav.editDrag = null;
68
+ return true;
69
+ }
70
+ if (activeEditor(ui) === drag.editor) {
71
+ const hits = editorHits(ui, drag.editor).filter(
72
+ (hit) => hit.field === drag.field,
73
+ );
74
+ const hit = hits.reduce((nearest, candidate) => {
75
+ if (!nearest) return candidate;
76
+ return Math.abs(candidate.y - cell.y) < Math.abs(nearest.y - cell.y)
77
+ ? candidate
78
+ : nearest;
79
+ }, null);
80
+ if (hit) {
81
+ const x = Math.min(cell.x, (hit.x1 ?? cell.x + 1) - 1);
82
+ drag.editor.place(offsetOf(hit, x));
83
+ if (drag.anchor !== drag.editor.cursor) drag.editor.anchor = drag.anchor;
84
+ drag.cell = cell;
85
+ revealEditor(ui);
86
+ }
87
+ }
88
+ if (release) ui.nav.editDrag = null;
89
+ return true;
90
+ };
91
+
92
+ module.exports = {
93
+ hitAt,
94
+ offsetOf,
95
+ focusField,
96
+ beginEditDrag,
97
+ moveEditDrag,
98
+ };
@@ -107,6 +107,9 @@ const onFiles = (ui) => {
107
107
 
108
108
  const onOpenFile = (ui) => {
109
109
  if (ui.nav.pane === 'packages') return;
110
+ if (ui.nav.pane === 'branches' && ui.worktrees.focused()) {
111
+ return void ui.ops.hold(ui.worktrees.enterSelected());
112
+ }
110
113
  if (ui.nav.pane === 'branches') {
111
114
  return void ui.gitBranches.onCheckoutBranch();
112
115
  }
@@ -6,6 +6,9 @@ const path = require('node:path');
6
6
  const { isAbort } = require('../common/process.js');
7
7
  const { tagLoaded, fetchSnapshot, fetchExtras } = require('./load.js');
8
8
  const { DiskWatcher, DEBOUNCE_MS } = require('../dashboard/watch.js');
9
+ const { watchedBy } = require('../dashboard/worktrees.js');
10
+ const { dirKey } = require('../git/linked.js');
11
+ const { resolveGitDirs } = require('../git/dir.js');
9
12
  const { itemPath, REVIEW_DIR } = require('../common/files.js');
10
13
  const { relativeAge } = require('../common/format.js');
11
14
  const { MANIFEST, LOCKFILE, depFileMeta } = require('../deps/deps.js');
@@ -73,16 +76,28 @@ class Lifecycle {
73
76
  constructor(ui) {
74
77
  this.ui = ui;
75
78
  this.diskWatcher = null;
79
+ this.treeWatcher = null;
76
80
  this.watchRetry = null;
77
81
  this.reviewStamp = '';
78
82
  this.ignoreWatchUntil = 0;
79
- this.cachedExtras = null;
83
+ this.extras = new Map();
80
84
  }
81
85
 
82
86
  get root() {
83
87
  return this.ui.top || this.ui.cwd;
84
88
  }
85
89
 
90
+ // The reports of npm are kept for each worktree: showing another one does
91
+ // not ask npm again, and the reports of one are never taken for another
92
+ get cachedExtras() {
93
+ return this.extras.get(this.root) ?? null;
94
+ }
95
+
96
+ set cachedExtras(entry) {
97
+ if (entry) this.extras.set(this.root, entry);
98
+ else this.extras.delete(this.root);
99
+ }
100
+
86
101
  loadOptions(extra = {}) {
87
102
  const ui = this.ui;
88
103
  return {
@@ -194,7 +209,9 @@ class Lifecycle {
194
209
  const here = ui.current();
195
210
  ui.top = loaded.top ?? ui.cwd;
196
211
  this.applyPrefs();
197
- collection.replace(loaded.items);
212
+ const seen = loaded.seenEpoch;
213
+ const fresh = seen === undefined || seen === ui.ops.writeEpoch;
214
+ if (fresh) collection.replace(loaded.items);
198
215
  stampItemDates(ui.top, collection.items);
199
216
  this.applySource(loaded);
200
217
  if (!ui.review.store) ui.initReview();
@@ -242,7 +259,7 @@ class Lifecycle {
242
259
  }
243
260
 
244
261
  clearNpmExtras() {
245
- this.cachedExtras = null;
262
+ this.extras.clear();
246
263
  }
247
264
 
248
265
  npmExtras() {
@@ -279,7 +296,8 @@ class Lifecycle {
279
296
  const next = outcome.value;
280
297
  if (!this.isWanted(gen)) return;
281
298
  if (next) {
282
- this.applyLoaded(tagLoaded(next, gen));
299
+ const seenEpoch = loaded.seenEpoch;
300
+ this.applyLoaded(tagLoaded({ ...next, seenEpoch }, gen));
283
301
  this.cacheNpmExtras(next);
284
302
  } else {
285
303
  ui.loader.pendingExtras = false;
@@ -316,6 +334,17 @@ class Lifecycle {
316
334
  if (extra.afterLoad) extra.afterLoad();
317
335
  }
318
336
 
337
+ // Remember the write epoch so a snapshot that started before stage or
338
+ // unstage does not paint that older index over the optimistic items.
339
+ async fetchSettled() {
340
+ const ui = this.ui;
341
+ const seenEpoch = ui.ops.writeEpoch;
342
+ const opts = this.snapshotOpts({ signal: ui.loader.signal });
343
+ const loaded = await fetchSnapshot(ui.repo, ui.cwd, ui.paths, opts);
344
+ if (!loaded) return loaded;
345
+ return { ...loaded, seenEpoch };
346
+ }
347
+
319
348
  async runSnapshot(gen, kind, extra = {}) {
320
349
  const ui = this.ui;
321
350
  if (extra.quiet !== true) {
@@ -326,8 +355,7 @@ class Lifecycle {
326
355
  let loaded = null;
327
356
  const side = ui.reads.read();
328
357
  try {
329
- const opts = this.snapshotOpts({ signal: ui.loader.signal });
330
- loaded = await fetchSnapshot(ui.repo, ui.cwd, ui.paths, opts);
358
+ loaded = await this.fetchSettled();
331
359
  } catch (error) {
332
360
  if (ui.loader.isCurrent(gen)) this.failOpen(error);
333
361
  }
@@ -477,21 +505,46 @@ class Lifecycle {
477
505
  stopWatch() {
478
506
  this.clearWatchRetry();
479
507
  this.reviewStamp = '';
508
+ this.stopTreeWatch();
480
509
  if (!this.diskWatcher) return;
481
510
  this.diskWatcher.close();
482
511
  this.diskWatcher = null;
483
512
  }
484
513
 
514
+ stopTreeWatch() {
515
+ if (!this.treeWatcher) return;
516
+ this.treeWatcher.close();
517
+ this.treeWatcher = null;
518
+ }
519
+
520
+ // The other worktrees of the repository are watched for their tiles; the
521
+ // one that is shown is watched with everything else
522
+ watchTrees() {
523
+ this.stopTreeWatch();
524
+ const ui = this.ui;
525
+ const info = ui.treesInfo;
526
+ if (!info || ui.trees.rows.length < 2 || !ui.top) return;
527
+ const root = dirKey(info.root);
528
+ const paths = [];
529
+ for (const row of ui.trees.rows) {
530
+ const dirs = resolveGitDirs(row.key);
531
+ if (dirs) paths.push(dirKey(dirs.gitDir));
532
+ if (row.key !== dirKey(root)) paths.push(row.key);
533
+ }
534
+ const onPaths = (rels) => ui.trees.notePaths(rels);
535
+ this.treeWatcher = new DiskWatcher({ root, paths, onPaths });
536
+ }
537
+
485
538
  startWatch() {
486
539
  this.stopWatch();
487
540
  const ui = this.ui;
488
541
  if (ui.nav.pane === 'repos') {
489
- const root = ui.workspaceRoot;
490
- if (!root) return;
542
+ if (!ui.workspaceRoot) return;
543
+ const root = dirKey(ui.workspaceRoot);
491
544
  const info = ui.workspaceInfo;
492
545
  const repos = info ? info.repos : [];
493
546
  const paths = ['.'];
494
- for (const repo of repos) paths.push(path.join(repo.name, '.git'));
547
+ for (const repo of repos) paths.push(...watchedBy(root, repo));
495
548
  const matchReview = (rel) => rel.includes(`/${REVIEW_DIR}/`);
496
549
  const onPaths = (rels) => ui.workspace.notePaths(rels);
497
550
  const options = { root, paths, matchReview, onPaths };
@@ -505,10 +558,14 @@ class Lifecycle {
505
558
  paths: this.ui.paths,
506
559
  onChange: () => this.onDiskChange(),
507
560
  onReview: () => this.applyReviewChange(),
508
- onPaths: (paths) => this.ui.dashboard.noteDisk(paths),
561
+ onPaths: (paths) => {
562
+ this.ui.dashboard.noteDisk(paths);
563
+ this.ui.trees.notePaths(paths);
564
+ },
509
565
  });
510
566
  this.syncItemWatches();
511
567
  this.noteReviewStamp();
568
+ this.watchTrees();
512
569
  }
513
570
  }
514
571
 
@@ -17,6 +17,7 @@ class Navigation {
17
17
  this.index = 0;
18
18
  this.fileCursor = 0;
19
19
  this.branchCursor = 0;
20
+ this.treeFocus = false;
20
21
  this.commitCursor = 0;
21
22
  this.npmCursor = 0;
22
23
  this.packagesCursor = 0;
@@ -28,9 +29,11 @@ class Navigation {
28
29
  this.unitLine = 0;
29
30
  this.selection = null;
30
31
  this.mouseAnchor = null;
32
+ this.editDrag = null;
31
33
  this.pendingClick = null;
32
34
  this.scrollDrag = null;
33
35
  this.lastClick = null;
36
+ this.contextMenu = null;
34
37
  this.tasksOpen = false;
35
38
  this.tasksFocus = 0;
36
39
  this.find = null;
@@ -99,8 +102,10 @@ class Navigation {
99
102
  clearSelection() {
100
103
  this.selection = null;
101
104
  this.mouseAnchor = null;
105
+ this.editDrag = null;
102
106
  this.pendingClick = null;
103
107
  this.scrollDrag = null;
108
+ this.contextMenu = null;
104
109
  }
105
110
 
106
111
  get listScroll() {
@@ -221,7 +221,8 @@ const whenLabel = (ms) => {
221
221
  };
222
222
 
223
223
  const elapsedText = (run, now) => {
224
- const end = run.endedAt || now;
224
+ const end =
225
+ run.status === 'running' ? now : run.endedAt || run.startedAt || 0;
225
226
  const ms = Math.max(0, end - (run.startedAt || end));
226
227
  const sec = Math.round(ms / 1000);
227
228
  if (sec < 60) return `${sec}s`;
@@ -30,6 +30,8 @@ const { historyName, historyMark, countText, elapsedText } = historyPart;
30
30
  const { whenLabel, runLabel, logBase, applyExternal } = historyPart;
31
31
  const { recordedItem, sameWindow, noteTests, filteredOutput } = historyPart;
32
32
  const { size } = require('../../common/format.js');
33
+ const { TASKS_FILE } = require('../../common/files.js');
34
+ const { stripAnsi } = require('../../term/ansi.js');
33
35
 
34
36
  const NAME_RE = /^[\w:.-]+$/;
35
37
 
@@ -54,11 +56,30 @@ class NpmController extends RunPanel {
54
56
  this.reset();
55
57
  }
56
58
 
59
+ // Runs belong to the worktree they started in and go on while another
60
+ // one is shown
61
+ get runs() {
62
+ let list = this.byTop.get(this.ui.top);
63
+ if (!list) {
64
+ list = [];
65
+ this.byTop.set(this.ui.top, list);
66
+ }
67
+ return list;
68
+ }
69
+
70
+ set runs(list) {
71
+ this.byTop.set(this.ui.top, list);
72
+ }
73
+
74
+ everyRun() {
75
+ return [...this.byTop.values()].flat();
76
+ }
77
+
57
78
  reset() {
58
- for (const run of this.runs ?? []) {
79
+ for (const run of this.byTop ? this.everyRun() : []) {
59
80
  if (run.status === 'running' && run.child) run.child.kill();
60
81
  }
61
- this.runs = [];
82
+ this.byTop = new Map();
62
83
  this.nextId = 1;
63
84
  this.viewId = 0;
64
85
  this.focus = 'commands';
@@ -83,7 +104,28 @@ class NpmController extends RunPanel {
83
104
  }
84
105
 
85
106
  get running() {
86
- return this.runs.some((run) => run.status === 'running');
107
+ return this.everyRun().some((run) => run.status === 'running');
108
+ }
109
+
110
+ // The count of runs in work by the directory they started in
111
+ liveByRoot() {
112
+ const live = new Map();
113
+ for (const run of this.everyRun()) {
114
+ if (run.status !== 'running') continue;
115
+ live.set(run.top, (live.get(run.top) ?? 0) + 1);
116
+ }
117
+ return live;
118
+ }
119
+
120
+ // Another worktree is shown: its commands and runs replace these
121
+ retarget() {
122
+ this.viewing = false;
123
+ this.verbose = false;
124
+ this.viewId = 0;
125
+ this.runCursor = 0;
126
+ this.commands = [];
127
+ this.dropName = '';
128
+ this.logBytes = 0;
87
129
  }
88
130
 
89
131
  view(active) {
@@ -103,6 +145,7 @@ class NpmController extends RunPanel {
103
145
  npmDraftName: this.draftName,
104
146
  npmDraftCommand: this.draftCommand,
105
147
  npmRunning: this.viewedRunning(),
148
+ npmCanSolve: this.canSolve(),
106
149
  npmLogs: active ? this.logLabel : '',
107
150
  };
108
151
  }
@@ -173,6 +216,39 @@ class NpmController extends RunPanel {
173
216
  return Boolean(run && run.status === 'running');
174
217
  }
175
218
 
219
+ canSolve() {
220
+ const run = this.viewedRun();
221
+ if (!this.viewing || !run || run.status !== 'exited') return false;
222
+ return run.exit !== 0 && run.exit !== '0' && run.exit !== '';
223
+ }
224
+
225
+ solve() {
226
+ const ui = this.ui;
227
+ if (!this.canSolve() || !ui.review.store) return;
228
+ const run = this.viewedRun();
229
+ this.hydrate(run);
230
+ this.syncOutput(run);
231
+ const root = run.top ?? ui.top;
232
+ const output = stripAnsi(
233
+ run.raw
234
+ ? filteredOutput(run.raw, root, run.exit).output
235
+ : reduceOutput(run.output, root, ''),
236
+ ).trim();
237
+ const limit = 6000;
238
+ const shortened =
239
+ output.length > limit
240
+ ? `${output.slice(0, limit)}\n… (truncated)`
241
+ : output;
242
+ const command = run.command || run.label || runLabel(run);
243
+ const text = `${command} failed (exit ${run.exit})\n\n${shortened}`;
244
+ const task = ui.review.store.addTask(TASKS_FILE, text, false, 'bugs');
245
+ this.closeView();
246
+ const tasks = ui.composer.tasks;
247
+ tasks.openTasksPage();
248
+ tasks.followRow({ task });
249
+ ui.flushReview(true);
250
+ }
251
+
176
252
  historyRuns() {
177
253
  const runs = this.runs.filter((run) => !launchedReslop(run));
178
254
  return foldSteps(runs).slice(0, RUN_KEEP);
@@ -249,6 +325,7 @@ class NpmController extends RunPanel {
249
325
  logName: item.logName,
250
326
  rawName: item.rawName,
251
327
  externalId: item.externalId || '',
328
+ top: this.ui.top,
252
329
  result: item.result ?? null,
253
330
  progress: item.progress ?? null,
254
331
  saved: true,
@@ -508,8 +585,9 @@ class NpmController extends RunPanel {
508
585
  if (!run.stale) return false;
509
586
  run.stale = false;
510
587
  run.shownAt = Date.now();
511
- noteTests(run, this.runs, run.raw);
512
- run.output = filteredOutput(run.raw, this.ui.top, '').output;
588
+ const top = run.top ?? this.ui.top;
589
+ noteTests(run, this.byTop.get(top) ?? this.runs, run.raw);
590
+ run.output = filteredOutput(run.raw, top, '').output;
513
591
  return true;
514
592
  }
515
593
 
@@ -521,25 +599,23 @@ class NpmController extends RunPanel {
521
599
 
522
600
  async finishRun(run, entry, result) {
523
601
  const ui = this.ui;
602
+ const top = run.top ?? ui.top;
524
603
  const stopped = run.stopping === true;
525
604
  const raw = stopped ? withNotice(run.raw, 'terminated') : result.text;
526
605
  let slot = null;
527
606
  try {
528
- slot = nextLogFile(ui.top, entry.name);
607
+ slot = nextLogFile(top, entry.name);
529
608
  } catch (error) {
530
609
  ui.status = error.message;
531
610
  }
532
611
  const rawFile = slot ? slot.rawName : '';
533
612
  const stoppedOutput = () => {
534
- const output = withNotice(
535
- reduceOutput(run.raw, ui.top, ''),
536
- 'terminated',
537
- );
613
+ const output = withNotice(reduceOutput(run.raw, top, ''), 'terminated');
538
614
  return { output, log: output };
539
615
  };
540
616
  const filtered = stopped
541
617
  ? stoppedOutput()
542
- : filteredOutput(result.text, ui.top, result.status, rawFile);
618
+ : filteredOutput(result.text, top, result.status, rawFile);
543
619
  run.raw = raw;
544
620
  run.stale = false;
545
621
  run.exit = stopped ? '' : result.status;
@@ -576,6 +652,7 @@ class NpmController extends RunPanel {
576
652
  exit: '',
577
653
  startedAt: Date.now(),
578
654
  endedAt: 0,
655
+ top: ui.top,
579
656
  });
580
657
  this.nextId += 1;
581
658
  this.runs.push(run);
@@ -659,7 +736,7 @@ class NpmController extends RunPanel {
659
736
  }
660
737
 
661
738
  stopAll() {
662
- for (const run of this.runs) this.stop(run);
739
+ for (const run of this.everyRun()) this.stop(run);
663
740
  }
664
741
 
665
742
  toggleVerbose() {