reslop 0.3.0 → 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 (74) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +166 -370
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -13
  7. package/lib/agents/models.js +250 -376
  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/deps.js +29 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/git.js +1 -1
  21. package/lib/git/linked.js +152 -0
  22. package/lib/git/local.js +179 -0
  23. package/lib/git/status.js +126 -0
  24. package/lib/git/worktree.js +137 -31
  25. package/lib/input/actions.js +83 -2
  26. package/lib/input/editor.js +9 -0
  27. package/lib/input/keys.js +39 -3
  28. package/lib/input/line-edit.js +8 -3
  29. package/lib/render/agents.js +64 -36
  30. package/lib/render/branches.js +55 -27
  31. package/lib/render/field.js +19 -5
  32. package/lib/render/footer.js +14 -4
  33. package/lib/render/frame.js +16 -2
  34. package/lib/render/log.js +5 -4
  35. package/lib/render/menu.js +103 -6
  36. package/lib/render/name-edit.js +31 -0
  37. package/lib/render/repos.js +52 -8
  38. package/lib/render/status.js +22 -4
  39. package/lib/report/render.js +4 -7
  40. package/lib/report/run.js +16 -23
  41. package/lib/review/agent-instructions.md +13 -8
  42. package/lib/review/format.js +0 -6
  43. package/lib/runs/process.js +3 -3
  44. package/lib/runs/runs.js +27 -4
  45. package/lib/session/agents/agents.js +227 -194
  46. package/lib/session/agents/away.js +48 -0
  47. package/lib/session/agents/job.js +23 -86
  48. package/lib/session/agents/jobs.js +14 -27
  49. package/lib/session/agents/options.js +45 -101
  50. package/lib/session/agents/plans.js +53 -0
  51. package/lib/session/agents/view.js +34 -0
  52. package/lib/session/branches.js +18 -2
  53. package/lib/session/compose.js +20 -6
  54. package/lib/session/dispatch.js +43 -3
  55. package/lib/session/editor-pointer.js +98 -0
  56. package/lib/session/files-pane.js +3 -0
  57. package/lib/session/import.js +12 -1
  58. package/lib/session/lifecycle.js +70 -10
  59. package/lib/session/navigation.js +5 -0
  60. package/lib/session/npm/history.js +3 -1
  61. package/lib/session/npm/npm.js +111 -13
  62. package/lib/session/ops.js +3 -0
  63. package/lib/session/pointer.js +304 -46
  64. package/lib/session/session.js +17 -2
  65. package/lib/session/tasks.js +1 -1
  66. package/lib/session/terminal.js +2 -2
  67. package/lib/session/text-menu.js +50 -0
  68. package/lib/session/view.js +19 -2
  69. package/lib/session/workspace.js +179 -38
  70. package/lib/session/worktrees.js +280 -0
  71. package/lib/term/select.js +2 -1
  72. package/lib/term/themes/dark.json +4 -0
  73. package/lib/term/themes/light.json +4 -0
  74. 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;
@@ -111,7 +128,6 @@ class BranchController {
111
128
  const after = (extra) => this.refreshAfterWrite(extra);
112
129
  const leave = () => {
113
130
  ui.composer.closeCompose();
114
- ui.showFiles();
115
131
  };
116
132
  const done = `created ${name}`;
117
133
  ui.ops.runWrite(
@@ -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;
@@ -20,7 +20,13 @@ const unit = require('./unit.js');
20
20
  const clipboard = require('../term/clipboard.js');
21
21
  const { EDIT_OP, copyLine, cutLine } = require('../input/line-edit.js');
22
22
 
23
- const COMPOSE_EDITS = ['backspace', 'delete', 'ctrl-z', 'ctrl-y'];
23
+ const COMPOSE_EDITS = [
24
+ 'backspace',
25
+ 'ctrl-backspace',
26
+ 'delete',
27
+ 'ctrl-z',
28
+ 'ctrl-y',
29
+ ];
24
30
  const COMPOSE_CLIP = ['ctrl-x', 'ctrl-v'];
25
31
  const COMPOSE_LEAVE = ['next', 'prev', 'tasks'];
26
32
 
@@ -33,10 +39,18 @@ const KIND = {
33
39
  branch: { enter: 'save', tab: 'template', escape: 'cancel', move: 'note' },
34
40
  npm: { enter: 'save', tab: 'insert-tab', escape: 'cancel', move: 'note' },
35
41
  package: { enter: 'save', tab: 'insert-tab', escape: 'cancel', move: 'note' },
42
+ worktree: { enter: 'save', tab: 'template', escape: 'cancel', move: 'note' },
36
43
  };
37
44
 
38
- const COMMAND_KINDS = ['commit', 'branch', 'npm', 'package'];
39
- 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
+ ];
40
54
 
41
55
  const itemNote = (item, text) => {
42
56
  const hunk = item.hunk;
@@ -668,8 +682,8 @@ class Composer {
668
682
  }
669
683
 
670
684
  handleKey(key) {
671
- if (key === 'enter') {
672
- const result = this.enter();
685
+ if (key === 'enter' || isNewlineKey(key)) {
686
+ const result = isNewlineKey(key) ? this.insertNewline() : this.enter();
673
687
  this.syncCodeScroll();
674
688
  return result;
675
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,9 +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'),
120
+ agentInstall: (ui) => ui.agents.proposeInstall(),
121
+ agentStart: (ui) => ui.agents.startSelected(),
119
122
  agentStop: (ui) => ui.agents.stop(),
120
123
  agentRerun: (ui) => ui.agents.rerun(),
121
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(),
122
130
  repoLeft: (ui) => ui.workspace.move(-1, 0),
123
131
  repoRight: (ui) => ui.workspace.move(1, 0),
124
132
  repoUp: (ui) => ui.workspace.move(0, -1),
@@ -126,7 +134,22 @@ const HANDLERS = {
126
134
  ...DASH_HANDLERS,
127
135
  };
128
136
 
129
- 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;
130
153
  if (CONFIRM_MODE.includes(ui.mode)) return;
131
154
  if (ui.mode === 'import') stopImport(ui);
132
155
  if (action === 'lines' && (ui.pane === 'diff' || ui.pane === 'unit')) {
@@ -158,6 +181,10 @@ const CONFIRMATIONS = {
158
181
  accept: (ui) => ui.updater.accept(),
159
182
  decline: (ui) => ui.updater.decline(),
160
183
  },
184
+ confirmAgentInstall: {
185
+ confirm: (ui) => ui.agents.confirmInstall(),
186
+ cancel: (ui) => ui.agents.cancelInstall(),
187
+ },
161
188
  confirmDrop: {
162
189
  confirm: (ui) => {
163
190
  if (ui.packages.dropName) return void ui.packages.confirmDrop();
@@ -172,6 +199,14 @@ const CONFIRMATIONS = {
172
199
  ui.gitBranches.cancelDropBranch();
173
200
  },
174
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
+ },
175
210
  confirmPush: {
176
211
  force: (ui) => ui.gitBranches.onForcePush(),
177
212
  cancel: (ui) => ui.gitBranches.cancelPush(),
@@ -193,7 +228,10 @@ const confirmChoice = (ui, key) => {
193
228
 
194
229
  const NPM_VIEW_KEYS = {
195
230
  escape: (ui) => ui.npm.closeView(),
196
- s: (ui) => ui.npm.stop(),
231
+ s: (ui) => {
232
+ if (ui.npm.viewedRunning()) ui.npm.stop();
233
+ else ui.npm.solve();
234
+ },
197
235
  r: (ui) => ui.npm.rerun(),
198
236
  v: (ui) => ui.npm.toggleVerbose(),
199
237
  e: () => {},
@@ -253,7 +291,9 @@ const planMenuKey = (ui, key) => {
253
291
  };
254
292
 
255
293
  const handleKey = (ui, key) => {
294
+ ui.nav.editDrag = null;
256
295
  if (CONFIRM_MODE.includes(ui.mode)) return void confirmChoice(ui, key);
296
+ if (handleContextKey(ui, key)) return;
257
297
  if (planMenuKey(ui, key)) return;
258
298
  if (ui.pane === 'tasks' && (key === 'ctrl-up' || key === 'ctrl-down')) {
259
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
  }
@@ -3,11 +3,22 @@
3
3
  const { remoteKind, selectChangeSource } = require('../source/source.js');
4
4
  const { lineKey, lineField } = require('../input/line-edit.js');
5
5
 
6
+ const changeRepositoryUrl = (change) => {
7
+ if (!change?.url || !change.repository) return '';
8
+ try {
9
+ const url = new URL(change.url);
10
+ return `${url.origin}/${change.repository}`;
11
+ } catch {
12
+ return '';
13
+ }
14
+ };
15
+
6
16
  const startImport = (ui) => {
7
17
  if (ui.mode !== 'review' || ui.pane !== 'tasks') return;
8
18
  ui.composer.tasks.closePlanMenu();
9
19
  ui.mode = 'import';
10
- ui.nav.import = lineField('url');
20
+ const defaultUrl = ui.repositoryUrl || changeRepositoryUrl(ui.change);
21
+ ui.nav.import = lineField('url', defaultUrl);
11
22
  ui.status = '';
12
23
  };
13
24
 
@@ -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 {
@@ -169,6 +184,9 @@ class Lifecycle {
169
184
 
170
185
  applySource(loaded) {
171
186
  const ui = this.ui;
187
+ if (loaded.repositoryUrl !== undefined) {
188
+ ui.repositoryUrl = loaded.repositoryUrl || '';
189
+ }
172
190
  if (loaded.rev) ui.rev = loaded.rev;
173
191
  if (loaded.revShort) ui.revShort = loaded.revShort;
174
192
  else if (ui.rev && !ui.revShort) ui.revShort = ui.rev.slice(0, 7);
@@ -191,7 +209,9 @@ class Lifecycle {
191
209
  const here = ui.current();
192
210
  ui.top = loaded.top ?? ui.cwd;
193
211
  this.applyPrefs();
194
- 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);
195
215
  stampItemDates(ui.top, collection.items);
196
216
  this.applySource(loaded);
197
217
  if (!ui.review.store) ui.initReview();
@@ -239,7 +259,7 @@ class Lifecycle {
239
259
  }
240
260
 
241
261
  clearNpmExtras() {
242
- this.cachedExtras = null;
262
+ this.extras.clear();
243
263
  }
244
264
 
245
265
  npmExtras() {
@@ -276,7 +296,8 @@ class Lifecycle {
276
296
  const next = outcome.value;
277
297
  if (!this.isWanted(gen)) return;
278
298
  if (next) {
279
- this.applyLoaded(tagLoaded(next, gen));
299
+ const seenEpoch = loaded.seenEpoch;
300
+ this.applyLoaded(tagLoaded({ ...next, seenEpoch }, gen));
280
301
  this.cacheNpmExtras(next);
281
302
  } else {
282
303
  ui.loader.pendingExtras = false;
@@ -313,6 +334,17 @@ class Lifecycle {
313
334
  if (extra.afterLoad) extra.afterLoad();
314
335
  }
315
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
+
316
348
  async runSnapshot(gen, kind, extra = {}) {
317
349
  const ui = this.ui;
318
350
  if (extra.quiet !== true) {
@@ -323,8 +355,7 @@ class Lifecycle {
323
355
  let loaded = null;
324
356
  const side = ui.reads.read();
325
357
  try {
326
- const opts = this.snapshotOpts({ signal: ui.loader.signal });
327
- loaded = await fetchSnapshot(ui.repo, ui.cwd, ui.paths, opts);
358
+ loaded = await this.fetchSettled();
328
359
  } catch (error) {
329
360
  if (ui.loader.isCurrent(gen)) this.failOpen(error);
330
361
  }
@@ -474,21 +505,46 @@ class Lifecycle {
474
505
  stopWatch() {
475
506
  this.clearWatchRetry();
476
507
  this.reviewStamp = '';
508
+ this.stopTreeWatch();
477
509
  if (!this.diskWatcher) return;
478
510
  this.diskWatcher.close();
479
511
  this.diskWatcher = null;
480
512
  }
481
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
+
482
538
  startWatch() {
483
539
  this.stopWatch();
484
540
  const ui = this.ui;
485
541
  if (ui.nav.pane === 'repos') {
486
- const root = ui.workspaceRoot;
487
- if (!root) return;
542
+ if (!ui.workspaceRoot) return;
543
+ const root = dirKey(ui.workspaceRoot);
488
544
  const info = ui.workspaceInfo;
489
545
  const repos = info ? info.repos : [];
490
546
  const paths = ['.'];
491
- for (const repo of repos) paths.push(path.join(repo.name, '.git'));
547
+ for (const repo of repos) paths.push(...watchedBy(root, repo));
492
548
  const matchReview = (rel) => rel.includes(`/${REVIEW_DIR}/`);
493
549
  const onPaths = (rels) => ui.workspace.notePaths(rels);
494
550
  const options = { root, paths, matchReview, onPaths };
@@ -502,10 +558,14 @@ class Lifecycle {
502
558
  paths: this.ui.paths,
503
559
  onChange: () => this.onDiskChange(),
504
560
  onReview: () => this.applyReviewChange(),
505
- onPaths: (paths) => this.ui.dashboard.noteDisk(paths),
561
+ onPaths: (paths) => {
562
+ this.ui.dashboard.noteDisk(paths);
563
+ this.ui.trees.notePaths(paths);
564
+ },
506
565
  });
507
566
  this.syncItemWatches();
508
567
  this.noteReviewStamp();
568
+ this.watchTrees();
509
569
  }
510
570
  }
511
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() {
@@ -189,6 +189,7 @@ const recordedItem = (record) => {
189
189
  return {
190
190
  name,
191
191
  kind: script ? 'script' : 'command',
192
+ script,
192
193
  command,
193
194
  status: externalStatus(record),
194
195
  exit: externalExit(record),
@@ -220,7 +221,8 @@ const whenLabel = (ms) => {
220
221
  };
221
222
 
222
223
  const elapsedText = (run, now) => {
223
- const end = run.endedAt || now;
224
+ const end =
225
+ run.status === 'running' ? now : run.endedAt || run.startedAt || 0;
224
226
  const ms = Math.max(0, end - (run.startedAt || end));
225
227
  const sec = Math.round(ms / 1000);
226
228
  if (sec < 60) return `${sec}s`;