reslop 0.3.1 → 0.3.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 (66) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +30 -4
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +274 -438
  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/script.js +93 -0
  11. package/lib/agents/transcript.js +143 -0
  12. package/lib/cli.js +2 -1
  13. package/lib/common/process.js +26 -1
  14. package/lib/dashboard/git.js +11 -1
  15. package/lib/dashboard/reports.js +80 -0
  16. package/lib/dashboard/watch.js +43 -8
  17. package/lib/dashboard/workspace.js +19 -20
  18. package/lib/dashboard/worktrees.js +145 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/linked.js +152 -0
  21. package/lib/git/local.js +179 -0
  22. package/lib/git/status.js +126 -0
  23. package/lib/git/worktree.js +113 -36
  24. package/lib/input/actions.js +83 -5
  25. package/lib/input/keys.js +30 -1
  26. package/lib/input/line-edit.js +5 -1
  27. package/lib/render/agents.js +84 -34
  28. package/lib/render/branches.js +55 -27
  29. package/lib/render/field.js +19 -5
  30. package/lib/render/footer.js +14 -6
  31. package/lib/render/frame.js +16 -2
  32. package/lib/render/menu.js +103 -6
  33. package/lib/render/name-edit.js +31 -0
  34. package/lib/render/repos.js +52 -8
  35. package/lib/render/status.js +22 -4
  36. package/lib/report/run.js +16 -23
  37. package/lib/review/agent-instructions.md +2 -2
  38. package/lib/runs/process.js +3 -3
  39. package/lib/runs/runs.js +27 -4
  40. package/lib/session/agents/agents.js +220 -205
  41. package/lib/session/agents/away.js +48 -0
  42. package/lib/session/agents/job.js +23 -86
  43. package/lib/session/agents/jobs.js +14 -27
  44. package/lib/session/agents/options.js +45 -101
  45. package/lib/session/agents/plans.js +53 -0
  46. package/lib/session/agents/view.js +34 -0
  47. package/lib/session/branches.js +18 -1
  48. package/lib/session/compose.js +13 -5
  49. package/lib/session/dispatch.js +43 -4
  50. package/lib/session/editor-pointer.js +98 -0
  51. package/lib/session/files-pane.js +3 -0
  52. package/lib/session/lifecycle.js +67 -10
  53. package/lib/session/navigation.js +5 -0
  54. package/lib/session/npm/history.js +2 -1
  55. package/lib/session/npm/npm.js +89 -12
  56. package/lib/session/ops.js +3 -0
  57. package/lib/session/pointer.js +304 -46
  58. package/lib/session/session.js +15 -2
  59. package/lib/session/terminal.js +2 -2
  60. package/lib/session/text-menu.js +50 -0
  61. package/lib/session/view.js +19 -2
  62. package/lib/session/workspace.js +179 -38
  63. package/lib/session/worktrees.js +280 -0
  64. package/lib/term/themes/dark.json +4 -0
  65. package/lib/term/themes/light.json +4 -0
  66. package/package.json +2 -2
@@ -1,20 +1,24 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const path = require('node:path');
4
5
  const { REVIEW_DIR } = require('../common/files.js');
5
6
  const { FileIndex, HEAT_MS } = require('../dashboard/tree.js');
6
7
  const { UNKNOWN_PATH } = require('../dashboard/watch.js');
7
8
  const workspace = require('../dashboard/workspace.js');
8
9
  const { emptyDiff, countCommits, readDiffStat, taskStats, pickRun } = workspace;
10
+ const { currentBranchName } = workspace;
9
11
  const { readNpmSummary } = require('../dashboard/npm.js');
10
- const { collectReport } = require('../git/deps.js');
12
+ const { reportCounts } = require('../dashboard/reports.js');
13
+ const { dirKey, changedFiles } = require('../git/linked.js');
14
+ const { resolveGitDirs } = require('../git/dir.js');
11
15
 
12
16
  const TICK_ID = 'repos';
13
- const HEAT_TIMER = 'repos-heat';
14
17
 
15
18
  const freshBag = () => ({
16
19
  files: [],
17
20
  git: false,
21
+ diff: false,
18
22
  runs: false,
19
23
  tasks: false,
20
24
  npm: false,
@@ -24,6 +28,7 @@ const freshBag = () => ({
24
28
  const fullBag = () => ({
25
29
  files: [UNKNOWN_PATH],
26
30
  git: true,
31
+ diff: true,
27
32
  runs: true,
28
33
  tasks: true,
29
34
  npm: true,
@@ -35,6 +40,7 @@ const mergeBag = (left, right) => {
35
40
  return {
36
41
  files: [...base.files, ...right.files],
37
42
  git: base.git || right.git,
43
+ diff: base.diff || right.diff,
38
44
  runs: base.runs || right.runs,
39
45
  tasks: base.tasks || right.tasks,
40
46
  npm: base.npm || right.npm,
@@ -42,9 +48,37 @@ const mergeBag = (left, right) => {
42
48
  };
43
49
  };
44
50
 
45
- const blankRow = (repo) => ({
51
+ // The path inside of a directory, or null for one outside of it
52
+ const insideOf = (base, full) => {
53
+ const rel = path.relative(base, full);
54
+ const out = rel === '..' || rel.startsWith(`..${path.sep}`);
55
+ if (out || path.isAbsolute(rel)) return null;
56
+ return rel.split(path.sep).join('/');
57
+ };
58
+
59
+ const gitDirKey = (dir) => {
60
+ const dirs = resolveGitDirs(dir);
61
+ return dirs ? dirKey(dirs.gitDir) : '';
62
+ };
63
+
64
+ // What a list of worktrees says of a repository
65
+ const rowMeta = (repo) => ({
46
66
  name: repo.name,
67
+ group: repo.group ?? repo.name,
68
+ main: repo.main ?? true,
69
+ linked: repo.linked ?? false,
70
+ branch: repo.branch ?? '',
71
+ head: repo.head ?? '',
72
+ detached: repo.detached ?? false,
73
+ locked: repo.locked ?? false,
74
+ });
75
+
76
+ const blankRow = (repo) => ({
77
+ ...rowMeta(repo),
47
78
  dir: repo.dir,
79
+ key: dirKey(repo.dir),
80
+ gitDir: gitDirKey(repo.dir),
81
+ dirty: false,
48
82
  index: new FileIndex(repo.dir),
49
83
  bytes: 0,
50
84
  exts: [],
@@ -78,12 +112,23 @@ const readFiles = async (row, bag) => {
78
112
  };
79
113
 
80
114
  const readCommits = async (row) => {
81
- row.commits = await countCommits(row.dir);
115
+ const [commits, branch] = await Promise.all([
116
+ countCommits(row.dir),
117
+ currentBranchName(row.dir),
118
+ ]);
119
+ row.commits = commits;
120
+ row.branch = branch;
121
+ row.detached = !branch;
82
122
  row.commitsReady = true;
83
123
  };
84
124
 
85
125
  const readDiff = async (row) => {
86
- row.diff = await readDiffStat(row.dir);
126
+ const [diff, changed] = await Promise.all([
127
+ readDiffStat(row.dir),
128
+ changedFiles(row.dir).catch(() => []),
129
+ ]);
130
+ row.diff = diff;
131
+ row.dirty = changed.length > 0;
87
132
  row.diffReady = true;
88
133
  };
89
134
 
@@ -99,24 +144,26 @@ const readRuns = (row) => {
99
144
  row.runsReady = true;
100
145
  };
101
146
 
102
- const reportCount = async (dir, kind) => {
103
- const report = await collectReport(dir, kind);
104
- return report ? report.size : 0;
105
- };
106
-
107
147
  const readNpm = async (row) => {
108
- const summary = await readNpmSummary(row.dir, row.npm);
109
- const [audit, outdated] = await Promise.all([
110
- reportCount(row.dir, 'audit'),
111
- reportCount(row.dir, 'outdated'),
148
+ const [summary, counts] = await Promise.all([
149
+ readNpmSummary(row.dir, row.npm),
150
+ reportCounts(row.dir),
112
151
  ]);
113
- row.npm = { ...summary, audit, outdated };
152
+ row.npm = { ...summary, ...counts };
114
153
  row.npmReady = true;
115
154
  };
116
155
 
117
- const toTile = (row) => ({
156
+ const toTile = (row, live) => ({
118
157
  id: row.name,
119
158
  name: row.name,
159
+ group: row.group,
160
+ linked: row.linked,
161
+ branch: row.branch,
162
+ detached: row.detached,
163
+ head: row.head,
164
+ locked: row.locked,
165
+ dirty: row.dirty,
166
+ agents: live.get(row.key) ?? 0,
120
167
  bytes: row.bytes,
121
168
  exts: row.exts,
122
169
  tasks: row.tasks,
@@ -171,13 +218,20 @@ const classifyRest = (rest) => {
171
218
  return bag;
172
219
  }
173
220
  bag.files.push(rest);
221
+ bag.diff = true;
174
222
  bag.activity = true;
175
223
  return bag;
176
224
  };
177
225
 
226
+ // The tiles of a folder on the repos screen, and the ones of the worktrees
227
+ // of a repository above the branches
178
228
  class Workspace {
179
- constructor(ui) {
229
+ constructor(ui, options = {}) {
180
230
  this.ui = ui;
231
+ this.pane = options.pane ?? 'repos';
232
+ this.tick = this.pane === 'repos' ? TICK_ID : `${TICK_ID}-${this.pane}`;
233
+ this.heat = `${this.tick}-heat`;
234
+ this.infoOf = options.infoOf ?? (() => this.ui.workspaceInfo);
181
235
  this.epoch = 0;
182
236
  this.reset();
183
237
  }
@@ -190,14 +244,46 @@ class Workspace {
190
244
  this.cols = 1;
191
245
  }
192
246
 
247
+ // Whether the tiles are on the screen
248
+ shown() {
249
+ return this.ui.uiOpen && this.ui.nav.pane === this.pane;
250
+ }
251
+
193
252
  seed() {
194
- const info = this.ui.workspaceInfo;
253
+ const info = this.infoOf();
195
254
  const repos = info ? info.repos : [];
196
255
  this.rows = repos.map(blankRow);
197
256
  this.byName = new Map(this.rows.map((row) => [row.name, row]));
198
257
  if (this.cursor >= this.rows.length) this.cursor = 0;
199
258
  }
200
259
 
260
+ // The list of worktrees changed: what is known of the rest is kept
261
+ adopt(repos) {
262
+ const focus = this.rows[this.cursor] ? this.rows[this.cursor].key : '';
263
+ const known = new Map(this.rows.map((row) => [row.key, row]));
264
+ const fresh = [];
265
+ this.rows = repos.map((repo) => {
266
+ const prev = known.get(dirKey(repo.dir));
267
+ if (!prev) {
268
+ const row = blankRow(repo);
269
+ fresh.push(row);
270
+ return row;
271
+ }
272
+ return Object.assign(prev, rowMeta(repo));
273
+ });
274
+ this.byName = new Map(this.rows.map((row) => [row.name, row]));
275
+ const at = this.rows.findIndex((row) => row.key === focus);
276
+ this.cursor = at < 0 ? Math.min(this.cursor, this.rows.length - 1) : at;
277
+ this.cursor = Math.max(0, this.cursor);
278
+ for (const row of fresh) this.kick(row, fullBag());
279
+ }
280
+
281
+ focusDir(dir) {
282
+ const key = dirKey(dir);
283
+ const at = this.rows.findIndex((row) => row.key === key);
284
+ if (at >= 0) this.cursor = at;
285
+ }
286
+
201
287
  stale(epoch) {
202
288
  return epoch !== this.epoch || !this.active;
203
289
  }
@@ -205,7 +291,7 @@ class Workspace {
205
291
  publish(epoch) {
206
292
  if (this.stale(epoch)) return;
207
293
  this.afterDraw();
208
- if (this.ui.uiOpen && this.ui.nav.pane === 'repos') this.ui.paint();
294
+ if (this.shown()) this.ui.paint();
209
295
  }
210
296
 
211
297
  async settle(epoch, read) {
@@ -219,8 +305,8 @@ class Workspace {
219
305
  if (bag.files.length) {
220
306
  jobs.push(this.settle(epoch, () => readFiles(row, bag)));
221
307
  }
222
- if (bag.git) {
223
- jobs.push(this.settle(epoch, () => readCommits(row)));
308
+ if (bag.git) jobs.push(this.settle(epoch, () => readCommits(row)));
309
+ if (bag.git || bag.diff) {
224
310
  jobs.push(this.settle(epoch, () => readDiff(row)));
225
311
  }
226
312
  if (bag.tasks) jobs.push(this.settle(epoch, () => readTasks(row)));
@@ -242,7 +328,7 @@ class Workspace {
242
328
  }
243
329
  if (this.stale(epoch)) break;
244
330
  this.afterDraw();
245
- if (this.ui.uiOpen && this.ui.nav.pane === 'repos') this.ui.paint();
331
+ if (this.shown()) this.ui.paint();
246
332
  }
247
333
  row.running = false;
248
334
  if (row.pending && this.active) {
@@ -250,7 +336,7 @@ class Workspace {
250
336
  return;
251
337
  }
252
338
  this.afterDraw();
253
- if (this.ui.uiOpen && this.ui.nav.pane === 'repos') this.ui.paint();
339
+ if (this.shown()) this.ui.paint();
254
340
  }
255
341
 
256
342
  kick(row, bag) {
@@ -265,24 +351,65 @@ class Workspace {
265
351
  for (const row of this.rows) this.kick(row, fullBag());
266
352
  }
267
353
 
354
+ // Everything is read again, but npm only where there is no answer yet:
355
+ // the lists of packages change when their files do, and the watcher says so
356
+ refresh() {
357
+ for (const row of this.rows) {
358
+ this.kick(row, { ...fullBag(), npm: !row.npmReady });
359
+ }
360
+ }
361
+
362
+ // The worktree that owns a path of the watcher, and the path inside it.
363
+ // A worktree can be anywhere, and the state of git of a linked one is
364
+ // in the directory of the main one
365
+ placeOf(rel, root) {
366
+ const full = path.resolve(root, rel);
367
+ let best = null;
368
+ for (const row of this.rows) {
369
+ for (const [base, lead] of [
370
+ [row.key, ''],
371
+ [row.gitDir, '.git/'],
372
+ ]) {
373
+ const inside = base ? insideOf(base, full) : null;
374
+ if (inside === null) continue;
375
+ if (best && base.length <= best.size) continue;
376
+ best = { row, rest: `${lead}${inside}`, size: base.length };
377
+ }
378
+ }
379
+ return best ?? this.placeByName(rel);
380
+ }
381
+
382
+ placeByName(rel) {
383
+ const slash = rel.indexOf('/');
384
+ const name = slash < 0 ? rel : rel.slice(0, slash);
385
+ const row = this.byName.get(name);
386
+ return row ? { row, rest: slash < 0 ? '' : rel.slice(slash + 1) } : null;
387
+ }
388
+
268
389
  notePaths(paths) {
269
390
  if (!this.active) return;
391
+ const missing = this.rows.some((row) => !fs.existsSync(row.dir));
392
+ if (missing) {
393
+ this.ui.ops.hold(this.ui.worktrees.reload());
394
+ return;
395
+ }
270
396
  for (const rel of paths) {
271
397
  if (rel === UNKNOWN_PATH) return void this.requestAll();
272
398
  }
273
399
  const found = new Map();
400
+ const info = this.infoOf();
401
+ const root = dirKey(info ? info.root : '.');
274
402
  for (const rel of paths) {
275
- const slash = rel.indexOf('/');
276
- const name = slash < 0 ? rel : rel.slice(0, slash);
277
- const rest = slash < 0 ? '' : rel.slice(slash + 1);
278
- const row = this.byName.get(name);
279
- if (!row) continue;
403
+ const place = this.placeOf(rel, root);
404
+ if (!place) continue;
405
+ const { row, rest } = place;
406
+ const name = row.name;
280
407
  found.set(name, mergeBag(found.get(name), classifyRest(rest)));
281
408
  }
282
409
  for (const [name, bag] of found) this.kick(this.byName.get(name), bag);
283
410
  if (found.size) {
284
411
  this.afterDraw();
285
- if (this.ui.uiOpen) this.ui.paint();
412
+ if (this.shown()) this.ui.paint();
286
413
  }
287
414
  }
288
415
 
@@ -302,8 +429,8 @@ class Workspace {
302
429
  cancelTimers() {
303
430
  const { progress, term } = this.ui;
304
431
  if (!progress || !term) return;
305
- progress.stop(TICK_ID);
306
- term.cancel(HEAT_TIMER);
432
+ progress.stop(this.tick);
433
+ term.cancel(this.heat);
307
434
  }
308
435
 
309
436
  hotUntil() {
@@ -316,32 +443,46 @@ class Workspace {
316
443
 
317
444
  afterDraw() {
318
445
  if (!this.active) return;
319
- const visible = this.ui.uiOpen && this.ui.nav.pane === 'repos';
320
- if (!visible) return void this.cancelTimers();
446
+ if (!this.shown()) return void this.cancelTimers();
321
447
  const now = Date.now();
322
448
  const until = this.hotUntil();
323
449
  const hot = now < until;
324
450
  const running = this.rows.some((row) => row.run?.status === 'running');
325
451
  const loading = this.rows.some((row) => rowBusy(row));
326
452
  const { progress, term } = this.ui;
327
- if (running || hot || loading) progress.start(TICK_ID);
328
- else progress.stop(TICK_ID);
453
+ if (running || hot || loading) progress.start(this.tick);
454
+ else progress.stop(this.tick);
329
455
  if (!hot) return;
330
456
  term.later(
331
- HEAT_TIMER,
457
+ this.heat,
332
458
  () => {
333
- if (this.ui.nav.pane === 'repos') this.ui.paint();
459
+ if (this.ui.nav.pane === this.pane) this.ui.paint();
334
460
  },
335
461
  until - now + 20,
336
462
  );
337
463
  }
338
464
 
465
+ liveAgents() {
466
+ const live = new Map();
467
+ const running = this.ui.agents ? this.ui.agents.liveByRoot() : new Map();
468
+ for (const [root, count] of running) live.set(dirKey(root), count);
469
+ return live;
470
+ }
471
+
472
+ // The index of the worktree that is shown, -1 for none of the tiles
473
+ currentIndex() {
474
+ const key = dirKey(this.ui.top || this.ui.cwd || '.');
475
+ return this.rows.findIndex((row) => row.key === key);
476
+ }
477
+
339
478
  view() {
479
+ const live = this.liveAgents();
340
480
  return {
341
481
  now: Date.now(),
342
482
  frame: this.ui.progressFrame,
343
483
  cursor: this.cursor,
344
- tiles: this.rows.map(toTile),
484
+ current: this.currentIndex(),
485
+ tiles: this.rows.map((row) => toTile(row, live)),
345
486
  };
346
487
  }
347
488
 
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+
3
+ const wt = require('../dashboard/worktrees.js');
4
+ const { readWorkspace, worktreesOf, worktreeTarget } = wt;
5
+ const linked = require('../git/linked.js');
6
+ const { addWorktree, removeWorktree, dirKey } = linked;
7
+
8
+ // Create, switch and remove the worktrees of a repository. They are tiles
9
+ // above the branches, and on the repos screen when a folder is opened. Each
10
+ // worktree is a repository of its own for everything else
11
+ class WorktreeController {
12
+ constructor(ui) {
13
+ this.ui = ui;
14
+ this.reset();
15
+ }
16
+
17
+ reset() {
18
+ this.dropName = '';
19
+ this.dropRow = null;
20
+ this.branchName = '';
21
+ this.branchRow = null;
22
+ this.branchTarget = '';
23
+ this.busy = false;
24
+ }
25
+
26
+ // The tiles in use: the worktrees above the branches or the folder
27
+ panel() {
28
+ const { ui } = this;
29
+ return ui.nav.pane === 'branches' ? ui.trees : ui.workspace;
30
+ }
31
+
32
+ // The tiles show only when there is more than one worktree
33
+ hasPanels() {
34
+ return this.ui.trees.rows.length > 1;
35
+ }
36
+
37
+ // One element is selected: a tile of the worktrees or a branch
38
+ focused() {
39
+ const { ui } = this;
40
+ return ui.nav.pane === 'branches' && ui.nav.treeFocus && this.hasPanels();
41
+ }
42
+
43
+ selected() {
44
+ const { rows, cursor } = this.panel();
45
+ return rows[cursor] ?? null;
46
+ }
47
+
48
+ mainOf(row) {
49
+ const rows = this.panel().rows.filter((it) => it.group === row.group);
50
+ return rows.find((it) => it.main) ?? rows[0];
51
+ }
52
+
53
+ canWrite() {
54
+ const { ui } = this;
55
+ const inList = ui.nav.pane === 'repos' || ui.nav.pane === 'branches';
56
+ if (!inList || ui.mode !== 'review') return false;
57
+ if (!ui.capabilities.branches) {
58
+ ui.status = 'read only';
59
+ return false;
60
+ }
61
+ if (this.busy) {
62
+ ui.status = 'busy';
63
+ return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ // Runs in work in a worktree keep it from being removed
69
+ workingIn(row) {
70
+ const { agents, npm } = this.ui;
71
+ const key = dirKey(row.dir);
72
+ const sources = [agents.liveByRoot(), npm.liveByRoot()];
73
+ return sources.some((live) => {
74
+ for (const [root, count] of live) {
75
+ if (count && dirKey(root) === key) return true;
76
+ }
77
+ return false;
78
+ });
79
+ }
80
+
81
+ // The worktrees of the repository that is shown
82
+ async refreshTrees() {
83
+ const { ui } = this;
84
+ const top = ui.top;
85
+ if (!top) return;
86
+ try {
87
+ const seed = { ...worktreesOf(top), root: top };
88
+ const info = await readWorkspace(seed, ui.trees.rows);
89
+ if (ui.top !== top) return;
90
+ ui.treesInfo = { ...info, root: top };
91
+ } catch {
92
+ return;
93
+ }
94
+ if (ui.trees.active) ui.trees.adopt(ui.treesInfo.repos);
95
+ else ui.trees.start();
96
+ ui.trees.refresh();
97
+ ui.trees.focusDir(top);
98
+ if (!this.hasPanels()) ui.nav.treeFocus = false;
99
+ ui.lifecycle.watchTrees();
100
+ if (ui.uiOpen && ui.nav.pane === 'branches') ui.paint();
101
+ }
102
+
103
+ moveTree(dx) {
104
+ if (this.focused()) this.ui.trees.move(dx, 0);
105
+ }
106
+
107
+ // A click on a tile selects it and shows that worktree
108
+ pickTile(name) {
109
+ const { ui } = this;
110
+ const at = ui.trees.rows.findIndex((row) => row.name === name);
111
+ if (at < 0 || ui.mode !== 'review') return;
112
+ ui.trees.cursor = at;
113
+ ui.nav.treeFocus = true;
114
+ ui.ops.hold(this.enterSelected());
115
+ }
116
+
117
+ // Selecting a tile shows that worktree: its branches, files, diffs, tasks
118
+ async enterSelected() {
119
+ const { ui } = this;
120
+ const row = this.selected();
121
+ if (!row || ui.mode !== 'review' || this.busy) return;
122
+ if (dirKey(row.dir) === dirKey(ui.top)) return;
123
+ ui.flushReview();
124
+ const home = ui.dashboardHome;
125
+ ui.retarget({ cwd: row.dir, repoName: row.name, pane: 'branches' });
126
+ ui.dashboardHome = home;
127
+ try {
128
+ await ui.ensureRepo();
129
+ } catch (error) {
130
+ ui.status = error instanceof Error ? error.message : `${error}`;
131
+ return;
132
+ }
133
+ const loading = ui.openLoad();
134
+ await ui.gitBranches.open(true);
135
+ await this.refreshTrees();
136
+ if (ui.uiOpen) {
137
+ ui.lifecycle.startWatch();
138
+ if (home) ui.dashboard.start();
139
+ ui.paint();
140
+ }
141
+ await loading;
142
+ }
143
+
144
+ onAdd() {
145
+ if (!this.canWrite() || !this.selected()) return;
146
+ this.ui.composer.openCompose('worktree', '');
147
+ this.ui.status = 'new worktree: branch name';
148
+ }
149
+
150
+ // Make a worktree for a branch that already exists in the branch list
151
+ onBranch() {
152
+ const ui = this.ui;
153
+ if (this.focused() || !this.canWrite()) return;
154
+ const branch = ui.gitBranches.selected();
155
+ const row = this.selected();
156
+ if (!branch || !row) return;
157
+ const main = this.mainOf(row);
158
+ this.branchName = branch.name;
159
+ this.branchRow = main;
160
+ this.branchTarget = worktreeTarget(main, branch.name);
161
+ ui.mode = 'confirmCreateWorktree';
162
+ ui.status = '';
163
+ }
164
+
165
+ cancelBranch() {
166
+ this.ui.mode = 'review';
167
+ this.branchName = '';
168
+ this.branchRow = null;
169
+ this.branchTarget = '';
170
+ this.ui.status = '';
171
+ }
172
+
173
+ confirmBranch() {
174
+ const row = this.branchRow;
175
+ const name = this.branchName;
176
+ this.cancelBranch();
177
+ if (row && name) this.ui.ops.hold(this.create(row, name));
178
+ }
179
+
180
+ finishAdd() {
181
+ const ui = this.ui;
182
+ const editor = ui.composer.editor;
183
+ const name = editor ? editor.text.trim() : '';
184
+ if (!name) {
185
+ ui.status = 'empty branch name';
186
+ return;
187
+ }
188
+ const row = this.selected();
189
+ ui.composer.closeCompose();
190
+ if (!row) return;
191
+ ui.ops.hold(this.create(row, name));
192
+ }
193
+
194
+ async reload() {
195
+ const { ui } = this;
196
+ if (ui.nav.pane === 'branches') {
197
+ await this.refreshTrees();
198
+ return;
199
+ }
200
+ const ws = ui.workspace;
201
+ const info = await readWorkspace(ui.workspaceInfo, ws.rows);
202
+ ui.workspaceInfo = info;
203
+ ws.adopt(info.repos);
204
+ if (ui.nav.pane === 'repos' && ui.uiOpen) ui.lifecycle.startWatch();
205
+ }
206
+
207
+ async create(row, name) {
208
+ const ui = this.ui;
209
+ const main = this.mainOf(row);
210
+ this.busy = true;
211
+ ui.status = 'creating worktree';
212
+ ui.paint();
213
+ try {
214
+ const dir = await addWorktree(main.dir, worktreeTarget(main, name), name);
215
+ await this.reload();
216
+ this.panel().focusDir(dir);
217
+ ui.status = `created ${name}`;
218
+ } catch (error) {
219
+ ui.status = error.message;
220
+ } finally {
221
+ this.busy = false;
222
+ ui.paint();
223
+ }
224
+ }
225
+
226
+ onDrop() {
227
+ const ui = this.ui;
228
+ const row = this.selected();
229
+ if (!this.canWrite() || !row) return;
230
+ if (row.main) {
231
+ ui.status = 'the main worktree stays';
232
+ return;
233
+ }
234
+ if (ui.nav.pane === 'branches' && dirKey(row.dir) === dirKey(ui.top)) {
235
+ ui.status = 'this worktree is shown';
236
+ return;
237
+ }
238
+ if (this.workingIn(row)) {
239
+ ui.status = 'runs are working in this worktree';
240
+ return;
241
+ }
242
+ this.dropRow = row;
243
+ this.dropName = `worktree ${row.name}`;
244
+ ui.mode = 'confirmWorktree';
245
+ ui.status = '';
246
+ }
247
+
248
+ cancelDrop() {
249
+ this.ui.mode = 'review';
250
+ this.reset();
251
+ this.ui.status = '';
252
+ }
253
+
254
+ confirmDrop() {
255
+ const row = this.dropRow;
256
+ this.ui.mode = 'review';
257
+ this.reset();
258
+ if (row) this.ui.ops.hold(this.remove(row));
259
+ }
260
+
261
+ // Git refuses a dirty or locked worktree and so does this, nothing is forced
262
+ async remove(row) {
263
+ const ui = this.ui;
264
+ this.busy = true;
265
+ ui.status = 'removing worktree';
266
+ ui.paint();
267
+ try {
268
+ await removeWorktree(this.mainOf(row).dir, row.dir);
269
+ await this.reload();
270
+ ui.status = `removed ${row.name}`;
271
+ } catch (error) {
272
+ ui.status = error.message;
273
+ } finally {
274
+ this.busy = false;
275
+ ui.paint();
276
+ }
277
+ }
278
+ }
279
+
280
+ module.exports = { WorktreeController };
@@ -24,6 +24,10 @@
24
24
  "chromeFg": "#d0d0e0",
25
25
  "chromeBg": "#181824",
26
26
  "noteBg": "#242436",
27
+ "popupBg": "#34445c",
28
+ "popupFg": "#f2f4ff",
29
+ "popupHotBg": "#566d90",
30
+ "popupDisabledFg": "#66748a",
27
31
  "buttonFg": "#6a6a7e",
28
32
  "buttonBg": "#2a2a40",
29
33
  "buttonHotFg": "#ffffff",
@@ -24,6 +24,10 @@
24
24
  "chromeFg": "#24292f",
25
25
  "chromeBg": "#f2f4f8",
26
26
  "noteBg": "#f8fbff",
27
+ "popupBg": "#d0daea",
28
+ "popupFg": "#202c40",
29
+ "popupHotBg": "#9fb6da",
30
+ "popupDisabledFg": "#94a0b2",
27
31
  "buttonFg": "#57606a",
28
32
  "buttonBg": "#f7f9fc",
29
33
  "buttonHotFg": "#0f1419",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reslop",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Interactive code review and repair planning for AI-assisted changes",
5
5
  "keywords": [
6
6
  "code-review",
@@ -49,7 +49,7 @@
49
49
  "node": ">=18.15.0"
50
50
  },
51
51
  "scripts": {
52
- "test": "reslop t -- npm run -s lint && reslop t -- node --test",
52
+ "test": "reslop t -- npm run -s lint && reslop t -- node scripts/run-tests.js",
53
53
  "lint": "reslop t -- eslint . && reslop t -- prettier -c \"**/*.js\" \"**/*.json\" \"**/*.md\"",
54
54
  "fix": "reslop t -- eslint . --fix && reslop t -- prettier --write \"**/*.js\" \"**/*.json\" \"**/*.md\"",
55
55
  "enable": "node scripts/enable.js",