@ohzw/worktree-command-tui 0.1.3 → 0.1.5

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.
package/README.md CHANGED
@@ -3,10 +3,13 @@
3
3
  `worktree-command-tui` is a terminal UI for managing Git worktrees from inside a repository.
4
4
  It keeps one active runtime session per namespace, lets you switch worktrees with the keyboard, and keeps logs/process cleanup tied to the repo's shared Git state.
5
5
 
6
+ <img width="1023" height="582" alt="image" src="https://github.com/user-attachments/assets/ef33c2aa-0af4-4701-b1ec-fa9289e8ee3a" />
7
+
8
+
6
9
  ## Features
7
10
 
8
11
  - Discover worktrees from the current repository even when launched from a subdirectory
9
- - Start or switch the active worktree session with `Enter`
12
+ - Start, switch, or restart the active worktree session with `Enter`
10
13
  - Stop the active session and clean up recorded orphan processes with `s`
11
14
  - Run an optional per-worktree setup command with `i`
12
15
  - Open the selected worktree in your editor with `e`
@@ -65,7 +68,7 @@ If config is missing, the CLI exits with a message telling you to run `wctui ini
65
68
  Primary shortcuts in the footer:
66
69
 
67
70
  - `↑↓` / `j` `k` — move selection
68
- - `Enter` — start or switch to selected worktree
71
+ - `Enter` — start, switch to, or restart the selected worktree
69
72
  - `i` — run `setupCommand` when configured
70
73
  - `e` — open the selected worktree in the configured editor when `editorCommand` is configured
71
74
  - `o` — open the selected worktree's pull request when GitHub metadata is available
@@ -89,7 +92,7 @@ Additional shortcuts from the help window:
89
92
 
90
93
  `wctui` executes the argv commands stored in `.worktree-command-tui.jsonc` when you press the matching keys. Treat repository config as trusted code:
91
94
 
92
- - `Enter` starts `command` in the selected worktree.
95
+ - `Enter` starts `command` in the selected worktree; pressing it on the active worktree restarts that session.
93
96
  - `i` runs `setupCommand`; package-manager install commands may run dependency lifecycle scripts.
94
97
  - `e` runs `editorCommand` with the selected worktree path appended.
95
98
 
@@ -121,8 +124,8 @@ Example config:
121
124
  // The selected worktree path is appended as the final argv entry.
122
125
  "editorCommand": ["code"],
123
126
 
124
- // TCP port owned by the command, used when stopping stale/orphaned processes.
125
- "port": 3000,
127
+ // TCP ports owned by the command, used when stopping stale/orphaned processes.
128
+ "ports": [3000],
126
129
 
127
130
  // Files that must exist in a worktree before the command can be started there.
128
131
  "requiredFiles": ["package.json"],
@@ -134,8 +137,9 @@ Example config:
134
137
 
135
138
  Notes:
136
139
 
137
- - `setupCommand` is optional and never runs automatically; `i` only appears when it is configured
140
+ - `setupCommand` is optional and never runs automatically; `i` only appears when it is configured. It accepts one argv array or an array of argv arrays executed in order. The built-in `["copy-root-file", "source", "destination"]` copies from the repo root into the selected worktree.
138
141
  - `editorCommand` is optional; when set, the selected worktree path is appended to the argv and `e` becomes available
142
+ - Use `ports` for every backend/frontend/dev-server port the command may bind; legacy single `port` configs still load
139
143
  - The generated default config auto-detects package manager hints from `packageManager` or common lockfiles and chooses a default script such as `dev`, `start`, or `serve`
140
144
  - Session records and logs are stored under the repository's Git common dir, so they are shared across worktrees in the same repo
141
145
 
package/dist/app.js CHANGED
@@ -64,7 +64,7 @@ function getLogPaneHeight(_rootHeight) {
64
64
  return 9;
65
65
  }
66
66
  const ACTIVE_TAG = 'active';
67
- const ALREADY_ACTIVE_MESSAGE = 'already active';
67
+ const REPEATED_ENTER_DEBOUNCE_MS = 750;
68
68
  function syncActiveTags(rows, activePath) {
69
69
  let changed = false;
70
70
  const nextRows = rows.map(row => {
@@ -81,14 +81,25 @@ function syncActiveTags(rows, activePath) {
81
81
  });
82
82
  return changed ? nextRows : rows;
83
83
  }
84
- function shouldRefreshLogs(model) {
85
- if (model.activePath === null) {
86
- return false;
87
- }
88
- if (model.status.kind === 'running' || model.status.kind === 'error') {
84
+ function rowMatchesFilter(row, normalizedQuery) {
85
+ if (normalizedQuery === '') {
89
86
  return true;
90
87
  }
91
- return model.status.message === ALREADY_ACTIVE_MESSAGE;
88
+ const pullRequest = row.pullRequest?.kind === 'found' ? row.pullRequest : null;
89
+ return row.branch.toLowerCase().includes(normalizedQuery)
90
+ || row.path.toLowerCase().includes(normalizedQuery)
91
+ || row.shortPath.toLowerCase().includes(normalizedQuery)
92
+ || (pullRequest !== null && (`${pullRequest.number} ${pullRequest.title}`).toLowerCase().includes(normalizedQuery));
93
+ }
94
+ function filterRows(rows, query) {
95
+ const normalizedQuery = query.trim().toLowerCase();
96
+ if (normalizedQuery === '') {
97
+ return rows;
98
+ }
99
+ return rows.filter(row => rowMatchesFilter(row, normalizedQuery));
100
+ }
101
+ function shouldRefreshLogs(model) {
102
+ return model.activePath !== null && (model.status.kind === 'running' || model.status.kind === 'error');
92
103
  }
93
104
  function getStatusAfterLogRefresh(current, refresh) {
94
105
  if (current.status.kind !== 'running') {
@@ -112,7 +123,10 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
112
123
  const liveWindowSize = useWindowSize();
113
124
  const { columns, rows } = windowSizeOverride ?? liveWindowSize;
114
125
  const [model, setModel] = useState(initialModel);
115
- const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
126
+ const [filterQuery, setFilterQuery] = useState('');
127
+ const [isFilterInputOpen, setIsFilterInputOpen] = useState(false);
128
+ const visibleRows = useMemo(() => filterRows(model.rows, filterQuery), [model.rows, filterQuery]);
129
+ const [selectedPath, setSelectedPath] = useState(visibleRows[0]?.path ?? null);
116
130
  const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
117
131
  const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
118
132
  const [logScrollOffset, setLogScrollOffset] = useState(0);
@@ -124,17 +138,19 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
124
138
  const logRefreshInFlightRef = useRef(false);
125
139
  const actionGenerationRef = useRef(0);
126
140
  const previousStatusRef = useRef(initialModel.status.kind);
141
+ const lastStartRequestRef = useRef({ path: null, atMs: 0 });
127
142
  const alertTimeoutRef = useRef(null);
128
143
  useEffect(() => {
129
- setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
130
- }, [model.rows]);
144
+ setSelectedPath(currentPath => getNextSelectedPath(visibleRows, currentPath));
145
+ }, [visibleRows]);
131
146
  useEffect(() => {
132
147
  setSelectionScrollOffset(0);
133
148
  }, [selectedPath]);
134
149
  useEffect(() => {
135
150
  const becameRunning = previousStatusRef.current === 'starting' && model.status.kind === 'running';
136
151
  if (becameRunning) {
137
- setCompletedAlert(model.activeBranch ? `Switched to ${model.activeBranch}` : 'Worktree switch complete.');
152
+ const switchedAlert = model.activeBranch ? `Switched to ${model.activeBranch}` : 'Worktree switch complete.';
153
+ setCompletedAlert(model.status.message.startsWith('restarted ') && model.activeBranch ? `Restarted ${model.activeBranch}` : switchedAlert);
138
154
  if (alertTimeoutRef.current !== null) {
139
155
  clearTimeout(alertTimeoutRef.current);
140
156
  }
@@ -143,7 +159,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
143
159
  }, 2500);
144
160
  }
145
161
  previousStatusRef.current = model.status.kind;
146
- }, [model.status.kind, model.activeBranch]);
162
+ }, [model.status.kind, model.status.message, model.activeBranch]);
147
163
  useEffect(() => {
148
164
  return () => {
149
165
  if (alertTimeoutRef.current !== null) {
@@ -200,16 +216,16 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
200
216
  setPendingDelete(null);
201
217
  }
202
218
  }, [confirmationOpen, model.rows, pendingDelete]);
203
- const selectedIndex = useMemo(() => getSelectedIndex(model.rows, selectedPath), [model.rows, selectedPath]);
204
- const selected = model.rows[selectedIndex];
219
+ const selectedIndex = useMemo(() => getSelectedIndex(visibleRows, selectedPath), [visibleRows, selectedPath]);
220
+ const selected = visibleRows[selectedIndex];
205
221
  const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
206
222
  const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
207
- const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
208
- const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
209
- const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
210
- const showLogPanel = !stackedLayout && rootHeight >= 34;
223
+ const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, visibleRows.length);
224
+ const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, visibleRows.length);
225
+ const compactDetailPane = !stackedLayout && rootHeight <= 30 && visibleRows.length > 1;
226
+ const showLogPanel = rootHeight >= 34;
211
227
  const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
212
- const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS) / 2));
228
+ const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight) / 2));
213
229
  const paneHeight = stackedLayout
214
230
  ? stackedPaneHeight
215
231
  : Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
@@ -221,11 +237,15 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
221
237
  const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
222
238
  const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
223
239
  function moveSelection(nextIndex) {
224
- const clampedIndex = clampSelectionIndex(nextIndex, model.rows.length);
240
+ const clampedIndex = clampSelectionIndex(nextIndex, visibleRows.length);
225
241
  if (clampedIndex === null) {
226
242
  return;
227
243
  }
228
- setSelectedPath(model.rows[clampedIndex].path);
244
+ setSelectedPath(visibleRows[clampedIndex].path);
245
+ }
246
+ function clearFilter() {
247
+ setFilterQuery('');
248
+ setIsFilterInputOpen(false);
229
249
  }
230
250
  function clearTransientAlert() {
231
251
  if (alertTimeoutRef.current !== null) {
@@ -256,6 +276,39 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
256
276
  userActionInFlightRef.current = false;
257
277
  }
258
278
  }
279
+ function shouldAcceptStartRequest(path) {
280
+ const nowMs = Date.now();
281
+ const last = lastStartRequestRef.current;
282
+ if (last.path === path && nowMs - last.atMs < REPEATED_ENTER_DEBOUNCE_MS) {
283
+ return false;
284
+ }
285
+ last.path = path;
286
+ last.atMs = nowMs;
287
+ return true;
288
+ }
289
+ function startSelectedWorktree() {
290
+ if (userActionInFlightRef.current) {
291
+ return;
292
+ }
293
+ const decision = decideEnterInteraction(selected, model.activePath);
294
+ if (decision.kind === 'ignore') {
295
+ return;
296
+ }
297
+ if (decision.kind === 'set-status') {
298
+ if (decision.suppressesBackgroundRefreshes) {
299
+ invalidateStaleLogRefreshes();
300
+ }
301
+ setModel(current => ({ ...current, status: decision.status }));
302
+ clearTransientAlert();
303
+ return;
304
+ }
305
+ if (!shouldAcceptStartRequest(decision.path)) {
306
+ return;
307
+ }
308
+ setModel(current => ({ ...current, status: decision.status }));
309
+ clearTransientAlert();
310
+ void apply(() => actions.start(decision.path));
311
+ }
259
312
  useInput((input, key) => {
260
313
  if (isHelpOverlayOpen) {
261
314
  if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
@@ -312,6 +365,44 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
312
365
  }
313
366
  return;
314
367
  }
368
+ if (isFilterInputOpen) {
369
+ if (key.escape || input === '\u001B') {
370
+ clearFilter();
371
+ return;
372
+ }
373
+ if (key.upArrow) {
374
+ moveSelection(selectedIndex - 1);
375
+ return;
376
+ }
377
+ if (key.downArrow) {
378
+ moveSelection(selectedIndex + 1);
379
+ return;
380
+ }
381
+ if (key.pageDown) {
382
+ setSelectionScrollOffset(current => current + selectionScrollPageSize);
383
+ return;
384
+ }
385
+ if (key.pageUp) {
386
+ setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
387
+ return;
388
+ }
389
+ if (key.backspace || key.delete) {
390
+ setFilterQuery(current => current.slice(0, -1));
391
+ return;
392
+ }
393
+ if (key.return) {
394
+ startSelectedWorktree();
395
+ return;
396
+ }
397
+ if (input && !key.ctrl && !key.meta) {
398
+ setFilterQuery(current => current + input);
399
+ }
400
+ return;
401
+ }
402
+ if (input === '/') {
403
+ setIsFilterInputOpen(true);
404
+ return;
405
+ }
315
406
  if (key.escape || input === 'q') {
316
407
  exit();
317
408
  return;
@@ -329,7 +420,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
329
420
  return;
330
421
  }
331
422
  if (input === 'G') {
332
- moveSelection(model.rows.length - 1);
423
+ moveSelection(visibleRows.length - 1);
333
424
  return;
334
425
  }
335
426
  if (input === '?') {
@@ -356,25 +447,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
356
447
  setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
357
448
  return;
358
449
  }
359
- if (userActionInFlightRef.current) {
360
- return;
361
- }
362
450
  if (key.return) {
363
- const decision = decideEnterInteraction(selected, model.activePath);
364
- if (decision.kind === 'ignore') {
365
- return;
366
- }
367
- if (decision.kind === 'set-status') {
368
- if (decision.suppressesBackgroundRefreshes) {
369
- invalidateStaleLogRefreshes();
370
- }
371
- setModel(current => ({ ...current, status: decision.status }));
372
- clearTransientAlert();
373
- return;
374
- }
375
- setModel(current => ({ ...current, status: decision.status }));
376
- clearTransientAlert();
377
- void apply(() => actions.start(decision.path));
451
+ startSelectedWorktree();
378
452
  return;
379
453
  }
380
454
  if (input === 's') {
@@ -453,7 +527,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
453
527
  if (listPaneViewportHeight === undefined) {
454
528
  return 0;
455
529
  }
456
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
530
+ const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
457
531
  return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
458
532
  });
459
533
  };
@@ -510,14 +584,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
510
584
  stdout.write(DISABLE_MOUSE_TRACKING);
511
585
  }
512
586
  };
513
- }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
587
+ }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, visibleRows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
514
588
  useEffect(() => {
515
589
  if (listPaneViewportHeight === undefined) {
516
590
  setWorktreeScrollOffset(0);
517
591
  return;
518
592
  }
519
593
  setWorktreeScrollOffset(current => {
520
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
594
+ const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
521
595
  if (selectedIndex < current) {
522
596
  return Math.max(0, selectedIndex);
523
597
  }
@@ -526,7 +600,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
526
600
  }
527
601
  return Math.min(current, max);
528
602
  });
529
- }, [selectedIndex, listPaneViewportHeight, model.rows.length]);
603
+ }, [selectedIndex, listPaneViewportHeight, visibleRows.length]);
530
604
  useEffect(() => {
531
605
  if (!showLogPanel && !isLogOverlayOpen) {
532
606
  setLogScrollOffset(0);
@@ -548,14 +622,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
548
622
  if (minimalLayout) {
549
623
  return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", safeActiveBranch] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", safeSelectedBranch] }) : null, rootHeight >= 3 ? _jsx(Text, { wrap: "truncate-end", children: confirmationOpen ? `D:${safeVisibleStatusMessage}` : `T:${model.status.kind}` }) : null, rootHeight >= 4 ? (confirmationOpen
550
624
  ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "d/y confirm \u00B7 Esc/n/q cancel" })
551
- : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
625
+ : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk/\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
552
626
  }
553
627
  if (compactLayout) {
554
628
  return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", safeActiveBranch] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", safeSelectedBranch] }), safeCompletedAlert
555
629
  ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", safeCompletedAlert] })
556
630
  : model.status.kind === 'setting-up' || model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${safeModelStatusMessage}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", visibleStatus.kind, " \u2014 ", safeVisibleStatusMessage] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: confirmationOpen
557
631
  ? 'Keys: d/y confirm | Esc/n/q cancel'
558
- : `Keys: ↑↓/jk g/G ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
632
+ : `Keys: ↑↓/jk g/G / Filter ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
559
633
  }
560
- return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, setupAvailable: model.setupAvailable, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: visibleStatus, setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, confirmationOpen: confirmationOpen }), safeCompletedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: safeCompletedAlert }) })) : null] }));
634
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: visibleRows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset, filterQuery: filterQuery, isFilterInputOpen: isFilterInputOpen, totalRowCount: model.rows.length }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, setupAvailable: model.setupAvailable, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: visibleStatus, setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, confirmationOpen: confirmationOpen }), safeCompletedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: safeCompletedAlert }) })) : null] }));
561
635
  }
@@ -99,7 +99,7 @@ function getActionMessage(selectedRow, activePath) {
99
99
  return 'Cannot start this worktree.';
100
100
  }
101
101
  if (action.kind === 'active') {
102
- return 'Already active. Press s to stop the current session.';
102
+ return 'Already active. Press Enter to restart, or s to stop.';
103
103
  }
104
104
  return 'Press Enter to start here and switch the active session.';
105
105
  }
@@ -30,6 +30,7 @@ function buildKeyHints(setupAvailable, editorAvailable, confirmationOpen) {
30
30
  { binding: '↑↓/jk', label: 'Move' },
31
31
  { binding: 'Enter', label: 'Switch' },
32
32
  ];
33
+ hints.push({ binding: '/', label: 'Filter' });
33
34
  if (setupAvailable) {
34
35
  hints.push({ binding: 'i', label: 'Setup' });
35
36
  }
@@ -8,7 +8,9 @@ function buildHelpLines(setupAvailable, editorAvailable) {
8
8
  { section: 'Scroll', binding: 'PageUp/PageDn', description: 'selection page' },
9
9
  { section: 'Logs', binding: 'L', description: 'full-screen logs' },
10
10
  { section: 'Logs', binding: '[/]', description: 'scroll log' },
11
- { section: 'Actions', binding: 'Enter', description: 'start/switch worktree' },
11
+ { section: 'Actions', binding: 'Enter', description: 'start/switch/restart worktree' },
12
+ { section: 'Filter', binding: '/', description: 'filter by branch, path, or pull request' },
13
+ { section: 'Filter', binding: 'Esc', description: 'clear filter' },
12
14
  ];
13
15
  if (setupAvailable) {
14
16
  lines.push({ section: 'Actions', binding: 'i', description: 'setup selected worktree' });
@@ -1,9 +1,12 @@
1
1
  import type { AppRow } from '../core/runtime.js';
2
- export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, }: {
2
+ export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, filterQuery, isFilterInputOpen, totalRowCount, }: {
3
3
  rows: AppRow[];
4
4
  selectedIndex: number;
5
5
  width?: number;
6
6
  height?: number;
7
7
  stacked: boolean;
8
8
  scrollOffset?: number;
9
+ filterQuery?: string;
10
+ isFilterInputOpen?: boolean;
11
+ totalRowCount?: number;
9
12
  }): import("react").JSX.Element;
@@ -33,7 +33,7 @@ function truncateLabel(value, width) {
33
33
  }
34
34
  return `${value.slice(0, Math.max(width - 1, 0))}…`;
35
35
  }
36
- export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, }) {
36
+ export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, filterQuery = '', isFilterInputOpen = false, totalRowCount = rows.length, }) {
37
37
  const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
38
38
  const viewport = sliceListViewport(rows, height === undefined ? rows.length : height - 3, scrollOffset);
39
39
  const contentViewportHeight = viewport.viewportHeight;
@@ -41,7 +41,12 @@ export function WorktreeList({ rows, selectedIndex, width, height, stacked, scro
41
41
  const visibleRows = viewport.visibleItems;
42
42
  const showScrollbar = height !== undefined && rows.length > contentViewportHeight;
43
43
  const scrollbarThumbRows = showScrollbar ? getScrollbarThumbRows(rows.length, contentViewportHeight, effectiveScrollOffset) : new Set();
44
- return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), visibleRows.map((row, index) => {
44
+ const hasFilter = filterQuery.trim() !== '';
45
+ const filterText = sanitizeInlineText(filterQuery);
46
+ const title = hasFilter || isFilterInputOpen
47
+ ? `Worktrees /${filterText}${isFilterInputOpen ? '█' : ''} (${rows.length}/${totalRowCount})`
48
+ : 'Worktrees';
49
+ return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: title }), rows.length === 0 ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "No worktrees match filter." })) : null, visibleRows.map((row, index) => {
45
50
  const isSelected = index + effectiveScrollOffset === selectedIndex;
46
51
  const projection = projectWorktreeListRow(row, isSelected);
47
52
  const tagSuffix = projection.isMain ? ' [root]' : '';
@@ -4,8 +4,9 @@ interface CommandLogOptions {
4
4
  logsDir: string;
5
5
  logFileBase: string;
6
6
  errorLabel?: string;
7
+ workspaceRoot?: string;
7
8
  }
8
- export declare function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel, }: CommandLogOptions): Promise<{
9
+ export declare function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel, workspaceRoot, }: CommandLogOptions): Promise<{
9
10
  logPath: string;
10
11
  }>;
11
12
  export declare function startDetachedCommand({ command, cwd, logsDir, logFileBase, }: {
@@ -1,6 +1,55 @@
1
- import { closeSync, mkdirSync, openSync } from 'node:fs';
1
+ import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, realpathSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
+ function writeLogLine(logPath, text) {
5
+ appendFileSync(logPath, text, 'utf8');
6
+ }
7
+ function isContainedPath(root, target, allowEqual) {
8
+ const relativePath = path.relative(root, target);
9
+ return (allowEqual && relativePath === '') || (relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath));
10
+ }
11
+ function resolveSourcePath(root, inputPath) {
12
+ const realRoot = realpathSync(root);
13
+ const source = path.resolve(realRoot, inputPath);
14
+ if (!isContainedPath(realRoot, source, false)) {
15
+ throw new Error(`source must stay under ${realRoot}`);
16
+ }
17
+ const realSource = realpathSync(source);
18
+ if (!isContainedPath(realRoot, realSource, false)) {
19
+ throw new Error(`source must stay under ${realRoot}`);
20
+ }
21
+ return realSource;
22
+ }
23
+ function resolveDestinationPath(root, inputPath) {
24
+ const realRoot = realpathSync(root);
25
+ const destination = path.resolve(realRoot, inputPath);
26
+ const basename = path.basename(destination);
27
+ if (basename === '' || basename === '.' || basename === '..') {
28
+ throw new Error(`destination must stay under ${realRoot}`);
29
+ }
30
+ mkdirSync(path.dirname(destination), { recursive: true });
31
+ const realParent = realpathSync(path.dirname(destination));
32
+ if (!isContainedPath(realRoot, realParent, true)) {
33
+ throw new Error(`destination must stay under ${realRoot}`);
34
+ }
35
+ return path.join(realParent, basename);
36
+ }
37
+ function runBuiltInCommand(input) {
38
+ if (input.command[0] !== 'copy-root-file') {
39
+ return false;
40
+ }
41
+ if (input.command.length !== 3) {
42
+ throw new Error(`${input.errorLabel} requires arguments: source and destination path`);
43
+ }
44
+ if (input.workspaceRoot === undefined) {
45
+ throw new Error(`${input.errorLabel} copy-root-file requires workspaceRoot`);
46
+ }
47
+ const source = resolveSourcePath(input.workspaceRoot, input.command[1] ?? '');
48
+ const destination = resolveDestinationPath(input.cwd, input.command[2] ?? '');
49
+ copyFileSync(source, destination);
50
+ writeLogLine(input.logPath, `copied ${source} -> ${destination}\n`);
51
+ return true;
52
+ }
4
53
  function getLogEnvironment() {
5
54
  return {
6
55
  ...process.env,
@@ -8,7 +57,7 @@ function getLogEnvironment() {
8
57
  CLICOLOR_FORCE: process.env.CLICOLOR_FORCE ?? '1',
9
58
  };
10
59
  }
11
- export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel = 'setup command', }) {
60
+ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel = 'setup command', workspaceRoot, }) {
12
61
  mkdirSync(logsDir, { recursive: true });
13
62
  const logPath = path.join(logsDir, `${logFileBase}.log`);
14
63
  const fd = openSync(logPath, 'a');
@@ -22,6 +71,20 @@ export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel
22
71
  closeSync(fd);
23
72
  return true;
24
73
  };
74
+ try {
75
+ if (runBuiltInCommand({ command, cwd, logPath, workspaceRoot, errorLabel })) {
76
+ if (finalize()) {
77
+ resolve({ logPath });
78
+ }
79
+ return promise;
80
+ }
81
+ }
82
+ catch (error) {
83
+ if (finalize()) {
84
+ reject(new Error(`${errorLabel}: ${error instanceof Error ? error.message : String(error)}; see ${logPath}`));
85
+ }
86
+ return promise;
87
+ }
25
88
  const child = spawn(command[0], command.slice(1), {
26
89
  cwd,
27
90
  env: getLogEnvironment(),
@@ -17,9 +17,9 @@ export declare function isSafeNamespace(value: unknown): value is string;
17
17
  export declare function toSafeNamespace(value: string): string;
18
18
  export declare function validateToolConfig(raw: unknown): ToolConfig;
19
19
  export declare function createDefaultToolConfig(options: DefaultToolConfigOptions): ToolConfig;
20
+ export declare function renderConfigJsonc(config: ToolConfig): string;
20
21
  export declare function loadToolConfig({ repoRoot }: {
21
22
  repoRoot: string;
22
23
  }): Promise<ToolConfig>;
23
- export declare function renderConfigJsonc(config: ToolConfig): string;
24
24
  export declare function findExistingConfigPath(workspaceRoot: string, fileExists: (filePath: string) => Promise<boolean>): Promise<string | null>;
25
25
  export declare function writeToolConfigForRepo({ workspaceRoot, force, config }: ConfigInitOptions, fileExists: (filePath: string) => Promise<boolean>): Promise<ConfigInitResult>;
@@ -37,6 +37,28 @@ function readOrphanMatchers(value) {
37
37
  }
38
38
  return matchers;
39
39
  }
40
+ function isNonEmptyStringArray(value) {
41
+ return Array.isArray(value) && value.length > 0 && value.every(part => isNonEmptyString(part));
42
+ }
43
+ function readOptionalSetupCommands(value) {
44
+ if (value === undefined) {
45
+ return undefined;
46
+ }
47
+ if (!Array.isArray(value) || value.length === 0) {
48
+ throw new Error('setupCommand must be a non-empty string array or an array of non-empty string arrays when set');
49
+ }
50
+ const looksLikeSingleCommand = value.every(part => isNonEmptyString(part));
51
+ if (looksLikeSingleCommand) {
52
+ return [value];
53
+ }
54
+ if (!value.every(part => Array.isArray(part))) {
55
+ throw new Error('setupCommand must be a non-empty string array or an array of non-empty string arrays when set');
56
+ }
57
+ if (!value.every(part => isNonEmptyStringArray(part))) {
58
+ throw new Error('setupCommand must be a non-empty string array or an array of non-empty string arrays when set');
59
+ }
60
+ return value;
61
+ }
40
62
  function readRequiredCommand(value, fieldName) {
41
63
  if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
42
64
  throw new Error(`${fieldName} must be a non-empty string array`);
@@ -52,24 +74,51 @@ function readOptionalCommand(value, fieldName) {
52
74
  }
53
75
  return value;
54
76
  }
55
- function readPort(value) {
77
+ function readPort(value, fieldName) {
56
78
  if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
57
- throw new Error('port must be an integer between 1 and 65535');
79
+ throw new Error(`${fieldName} must be an integer between 1 and 65535`);
80
+ }
81
+ return value;
82
+ }
83
+ function readPorts(value) {
84
+ if (value === undefined) {
85
+ return [];
86
+ }
87
+ if (!Array.isArray(value) || value.length === 0 || value.some(port => typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)) {
88
+ throw new Error('ports must be a non-empty array of integers between 1 and 65535');
58
89
  }
59
90
  return value;
60
91
  }
92
+ function uniquePorts(ports) {
93
+ return [...new Set(ports)];
94
+ }
95
+ function mergeConfiguredPorts(legacyPort, configuredPorts) {
96
+ const merged = configuredPorts.length > 0 ? configuredPorts : legacyPort === undefined ? [] : [legacyPort];
97
+ const resolved = uniquePorts(merged);
98
+ if (resolved.length === 0) {
99
+ throw new Error('port or ports must be configured');
100
+ }
101
+ return resolved;
102
+ }
103
+ function readPortList(port, ports) {
104
+ const legacyPort = port === undefined ? undefined : readPort(port, 'port');
105
+ const configuredPorts = readPorts(ports);
106
+ return mergeConfiguredPorts(legacyPort, configuredPorts);
107
+ }
61
108
  export function validateToolConfig(raw) {
62
109
  const config = (raw ?? {});
63
110
  const command = readRequiredCommand(config.command, 'command');
64
111
  if (!isSafeNamespace(config.namespace)) {
65
112
  throw new Error(`namespace must match ${SAFE_NAMESPACE_DESCRIPTION}`);
66
113
  }
114
+ const ports = readPortList(config.port, config.ports);
67
115
  return {
68
116
  namespace: config.namespace,
69
117
  command,
70
- setupCommand: readOptionalCommand(config.setupCommand, 'setupCommand'),
118
+ setupCommand: readOptionalSetupCommands(config.setupCommand),
71
119
  editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
72
- port: readPort(config.port),
120
+ port: ports[0] ?? 0,
121
+ ports,
73
122
  requiredFiles: readStringList(config.requiredFiles, 'requiredFiles'),
74
123
  orphanMatchers: readOrphanMatchers(config.orphanMatchers),
75
124
  };
@@ -78,38 +127,20 @@ export function createDefaultToolConfig(options) {
78
127
  return validateToolConfig({
79
128
  namespace: toSafeNamespace(options.namespaceSeed),
80
129
  command: [options.packageManager, 'run', options.script],
81
- setupCommand: [options.packageManager, 'install'],
130
+ setupCommand: [[options.packageManager, 'install']],
82
131
  editorCommand: ['code'],
83
- port: 3000,
132
+ ports: [3000],
84
133
  requiredFiles: ['package.json'],
85
134
  orphanMatchers: [],
86
135
  });
87
136
  }
88
- async function readConfigFile(configPath) {
89
- if ((await stat(configPath)).size > MAX_CONFIG_BYTES) {
90
- throw new Error('config file is too large');
91
- }
92
- return readFile(configPath, 'utf8');
93
- }
94
- async function readFirstConfig(repoRoot) {
95
- let firstError;
96
- for (const fileName of CONFIG_FILE_NAMES) {
97
- try {
98
- return await readConfigFile(path.join(repoRoot, fileName));
99
- }
100
- catch (error) {
101
- firstError ??= error;
102
- }
103
- }
104
- throw firstError;
105
- }
106
- export async function loadToolConfig({ repoRoot }) {
107
- return validateToolConfig(parseJsonc(await readFirstConfig(repoRoot)));
108
- }
109
137
  export function renderConfigJsonc(config) {
110
138
  const setupCommandSection = config.setupCommand === undefined ? '' : `
111
- // Optional command run manually with the setup key in the selected worktree.
139
+ // Optional command(s) run manually with the setup key in the selected worktree.
112
140
  // Review before running in untrusted worktrees; package installs may run lifecycle scripts.
141
+ // When this is an array of arrays, each entry is executed in order.
142
+ // Built-in helper: ["copy-root-file", ".env", ".env"]
143
+ // copies .env from the repository root to the selected worktree.
113
144
  "setupCommand": ${JSON.stringify(config.setupCommand)},
114
145
  `;
115
146
  const editorCommandSection = config.editorCommand === undefined ? '' : `
@@ -126,8 +157,9 @@ export function renderConfigJsonc(config) {
126
157
  // Treat this config as trusted code. argv form avoids shell metacharacter expansion.
127
158
  "command": ${JSON.stringify(config.command)},
128
159
  ${setupCommandSection}${editorCommandSection}
129
- // TCP port owned by the command, used when stopping stale/orphaned processes.
130
- "port": ${JSON.stringify(config.port)},
160
+ // TCP ports owned by the command, used when stopping stale/orphaned processes.
161
+ // Include all ports your command may bind.
162
+ "ports": ${JSON.stringify(config.ports)},
131
163
 
132
164
  // Files that must exist in a worktree before the command can be started there.
133
165
  "requiredFiles": ${JSON.stringify(config.requiredFiles)},
@@ -139,6 +171,27 @@ ${setupCommandSection}${editorCommandSection}
139
171
  }
140
172
  `;
141
173
  }
174
+ async function readConfigFile(configPath) {
175
+ if ((await stat(configPath)).size > MAX_CONFIG_BYTES) {
176
+ throw new Error('config file is too large');
177
+ }
178
+ return readFile(configPath, 'utf8');
179
+ }
180
+ async function readFirstConfig(repoRoot) {
181
+ let firstError;
182
+ for (const fileName of CONFIG_FILE_NAMES) {
183
+ try {
184
+ return await readConfigFile(path.join(repoRoot, fileName));
185
+ }
186
+ catch (error) {
187
+ firstError ??= error;
188
+ }
189
+ }
190
+ throw firstError;
191
+ }
192
+ export async function loadToolConfig({ repoRoot }) {
193
+ return validateToolConfig(parseJsonc(await readFirstConfig(repoRoot)));
194
+ }
142
195
  export async function findExistingConfigPath(workspaceRoot, fileExists) {
143
196
  for (const fileName of CONFIG_FILE_NAMES) {
144
197
  const configPath = path.join(workspaceRoot, fileName);
@@ -4,9 +4,10 @@ export declare const CONFIG_FILE_NAMES: readonly [".worktree-command-tui.jsonc",
4
4
  export interface ToolConfig {
5
5
  namespace: string;
6
6
  command: string[];
7
- setupCommand?: string[];
7
+ setupCommand?: string[][];
8
8
  editorCommand?: string[];
9
9
  port: number;
10
+ ports: number[];
10
11
  requiredFiles: string[];
11
12
  orphanMatchers: string[];
12
13
  }
@@ -6,6 +6,6 @@ export interface CleanupDeps {
6
6
  }
7
7
  export declare function stopSessionWithFallback(input: {
8
8
  pgid: number;
9
- port: number;
9
+ ports: number[];
10
10
  orphanMatchers: string[];
11
11
  }, deps: CleanupDeps): Promise<boolean>;
@@ -6,7 +6,9 @@ export async function stopSessionWithFallback(input, deps) {
6
6
  if (!(await deps.isSessionAlive(input.pgid))) {
7
7
  return true;
8
8
  }
9
- await deps.killPortOwner(input.port, input.pgid);
9
+ for (const port of input.ports) {
10
+ await deps.killPortOwner(port, input.pgid);
11
+ }
10
12
  for (const matcher of input.orphanMatchers) {
11
13
  await deps.killOrphans(matcher, input.pgid);
12
14
  }
@@ -17,6 +17,7 @@ export interface RuntimeStateAdapter {
17
17
  cwd: string;
18
18
  logsDir: string;
19
19
  logFileBase: string;
20
+ workspaceRoot: string | undefined;
20
21
  }) => Promise<{
21
22
  logPath: string;
22
23
  }>;
@@ -37,6 +38,7 @@ export interface RuntimeStateAdapter {
37
38
  export interface RuntimeStateOptions {
38
39
  config: ToolConfig;
39
40
  paths: SessionPaths;
41
+ workspaceRoot?: string;
40
42
  adapter: RuntimeStateAdapter;
41
43
  }
42
- export declare function createRuntimeStateActions({ config, paths, adapter }: RuntimeStateOptions): AppActions;
44
+ export declare function createRuntimeStateActions({ config, paths, workspaceRoot, adapter }: RuntimeStateOptions): AppActions;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  function toLogFileBase(branch) {
3
3
  return branch.replace(/[\\/]/g, '-');
4
4
  }
5
- export function createRuntimeStateActions({ config, paths, adapter }) {
5
+ export function createRuntimeStateActions({ config, paths, workspaceRoot, adapter }) {
6
6
  const refreshLogs = async () => {
7
7
  const active = await adapter.readActive();
8
8
  return {
@@ -40,8 +40,8 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
40
40
  };
41
41
  };
42
42
  const setup = async (worktreePath) => {
43
- const setupCommand = config.setupCommand;
44
- if (setupCommand === undefined) {
43
+ const setupCommands = config.setupCommand;
44
+ if (setupCommands === undefined) {
45
45
  const model = await adapter.refresh();
46
46
  return {
47
47
  ...model,
@@ -51,21 +51,34 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
51
51
  const branch = await adapter.readWorktreeBranch(worktreePath);
52
52
  const logFileBase = `${toLogFileBase(branch)}.setup`;
53
53
  const setupLogPath = path.join(paths.logsDir, `${logFileBase}.log`);
54
- try {
55
- await adapter.runSetup({
56
- command: setupCommand,
57
- cwd: worktreePath,
58
- logsDir: paths.logsDir,
59
- logFileBase,
60
- });
61
- }
62
- catch (error) {
63
- const model = await adapter.refresh();
64
- return {
65
- ...model,
66
- status: { kind: 'error', message: error instanceof Error ? error.message : String(error) },
67
- logs: await adapter.readLogs(setupLogPath),
68
- };
54
+ for (let index = 0; index < setupCommands.length; index += 1) {
55
+ const setupCommand = setupCommands[index];
56
+ if (setupCommand === undefined) {
57
+ const model = await adapter.refresh();
58
+ return {
59
+ ...model,
60
+ status: { kind: 'error', message: `setup command ${index + 1} is not configured` },
61
+ logs: await adapter.readLogs(setupLogPath),
62
+ };
63
+ }
64
+ try {
65
+ await adapter.runSetup({
66
+ command: setupCommand,
67
+ cwd: worktreePath,
68
+ logsDir: paths.logsDir,
69
+ logFileBase,
70
+ workspaceRoot,
71
+ });
72
+ }
73
+ catch (error) {
74
+ const model = await adapter.refresh();
75
+ const commandLabel = setupCommands.length > 1 ? `setup command ${index + 1}/${setupCommands.length} ` : '';
76
+ return {
77
+ ...model,
78
+ status: { kind: 'error', message: `${commandLabel}${error instanceof Error ? error.message : String(error)}`.trim() },
79
+ logs: await adapter.readLogs(setupLogPath),
80
+ };
81
+ }
69
82
  }
70
83
  const model = await adapter.refresh();
71
84
  return {
@@ -76,19 +89,11 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
76
89
  };
77
90
  const start = async (worktreePath) => {
78
91
  const current = await adapter.readActive();
79
- if (current?.worktreePath === worktreePath) {
80
- const model = await adapter.refresh();
81
- return {
82
- ...model,
83
- activePath: current.worktreePath,
84
- activeBranch: current.branch,
85
- status: { kind: 'idle', message: 'already active' },
86
- };
87
- }
88
92
  const invalidReason = await adapter.getInvalidReason(worktreePath);
89
93
  if (invalidReason) {
90
94
  throw new Error(invalidReason);
91
95
  }
96
+ const isRestart = current?.worktreePath === worktreePath;
92
97
  if (current) {
93
98
  await adapter.stopSession(current);
94
99
  await adapter.clearSession();
@@ -107,6 +112,7 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
107
112
  pid: started.pid,
108
113
  pgid: started.pgid,
109
114
  port: config.port,
115
+ ports: config.ports,
110
116
  logPath: started.logPath,
111
117
  startedAt: adapter.nowIso(),
112
118
  });
@@ -115,7 +121,7 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
115
121
  ...model,
116
122
  activePath: worktreePath,
117
123
  activeBranch: branch,
118
- status: { kind: 'running', message: `started ${branch}` },
124
+ status: { kind: 'running', message: `${isRestart ? 'restarted' : 'started'} ${branch}` },
119
125
  };
120
126
  };
121
127
  const openEditor = async (worktreePath) => refreshWithStatus(() => adapter.openEditor(worktreePath), true);
@@ -70,8 +70,8 @@ async function buildRows(mainWorktreePath, workspaceRoot, activePath, requiredFi
70
70
  }));
71
71
  return rows;
72
72
  }
73
- async function stopRecordedSession(pgid, port, orphanMatchers) {
74
- const stopped = await stopSessionWithFallback({ pgid, port, orphanMatchers }, {
73
+ async function stopRecordedSession(pgid, ports, orphanMatchers) {
74
+ const stopped = await stopSessionWithFallback({ pgid, ports, orphanMatchers }, {
75
75
  killProcessGroup,
76
76
  killPortOwner,
77
77
  killOrphans,
@@ -81,6 +81,9 @@ async function stopRecordedSession(pgid, port, orphanMatchers) {
81
81
  throw new Error(`Failed to stop existing session pgid=${pgid}`);
82
82
  }
83
83
  }
84
+ function sessionCleanupPorts(active, configuredPorts) {
85
+ return [...new Set([...(active.ports ?? [active.port]), ...configuredPorts])];
86
+ }
84
87
  async function launchDetachedCommand(command, cwd) {
85
88
  const { promise, resolve, reject } = Promise.withResolvers();
86
89
  let settled = false;
@@ -141,6 +144,7 @@ export async function buildActions(cwd) {
141
144
  return createRuntimeStateActions({
142
145
  config,
143
146
  paths,
147
+ workspaceRoot,
144
148
  adapter: {
145
149
  refresh: async () => buildInitialModel(cwd),
146
150
  readActive: async () => readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive }),
@@ -153,9 +157,9 @@ export async function buildActions(cwd) {
153
157
  return selected.branch;
154
158
  },
155
159
  getInvalidReason: async (worktreePath) => getInvalidReason(worktreePath, config.requiredFiles),
156
- runSetup: runCommandToLog,
160
+ runSetup: async (input) => runCommandToLog({ ...input, workspaceRoot }),
157
161
  startCommand: startDetachedCommand,
158
- stopSession: async (active) => stopRecordedSession(active.pgid, active.port, config.orphanMatchers),
162
+ stopSession: async (active) => stopRecordedSession(active.pgid, sessionCleanupPorts(active, config.ports), config.orphanMatchers),
159
163
  clearSession: async () => clearSessionRecord(paths),
160
164
  writeSession: async (record) => writeSessionRecord(paths, record),
161
165
  openEditor: async (worktreePath) => {
@@ -179,7 +183,7 @@ export async function buildActions(cwd) {
179
183
  return { kind: 'idle', message: `no pull request found for ${selected.branch}` };
180
184
  }
181
185
  if (pullRequest.kind === 'unavailable') {
182
- return { kind: 'error', message: `pull request metadata is unavailable for ${selected.branch}` };
186
+ return { kind: 'idle', message: `pull request metadata unavailable for ${selected.branch}` };
183
187
  }
184
188
  await launchDetachedCommand(getBrowserOpenCommand(pullRequest.url), worktreePath);
185
189
  return { kind: 'idle', message: `opened pull request #${pullRequest.number} for ${selected.branch}` };
@@ -194,7 +198,7 @@ export async function buildActions(cwd) {
194
198
  }
195
199
  const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
196
200
  if (active?.worktreePath === worktreePath) {
197
- await stopRecordedSession(active.pgid, active.port, config.orphanMatchers);
201
+ await stopRecordedSession(active.pgid, sessionCleanupPorts(active, config.ports), config.orphanMatchers);
198
202
  await clearSessionRecord(paths);
199
203
  }
200
204
  await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: workspaceRoot });
@@ -5,6 +5,7 @@ export interface SessionRecord {
5
5
  pid: number;
6
6
  pgid: number;
7
7
  port: number;
8
+ ports?: number[];
8
9
  logPath: string;
9
10
  startedAt: string;
10
11
  }
@@ -1,26 +1,41 @@
1
1
  import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- const MAX_SESSION_BYTES = 16 * 1024;
4
3
  function isSafeProcessId(value) {
5
4
  return typeof value === 'number' && Number.isInteger(value) && value > 1;
6
5
  }
7
- function isValidPort(value) {
6
+ function isSafePort(value) {
8
7
  return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535;
9
8
  }
10
- function isSessionRecord(value) {
9
+ function normalizeSessionRecordPorts(port, ports) {
10
+ if (ports !== undefined && ports.length > 0) {
11
+ return ports;
12
+ }
13
+ if (port !== undefined) {
14
+ return [port];
15
+ }
16
+ return [];
17
+ }
18
+ function parseSessionRecord(value) {
11
19
  if (typeof value !== 'object' || value === null) {
12
- return false;
20
+ return null;
13
21
  }
14
22
  const record = value;
15
- return (typeof record.namespace === 'string' &&
16
- typeof record.worktreePath === 'string' &&
17
- typeof record.branch === 'string' &&
18
- isSafeProcessId(record.pid) &&
19
- isSafeProcessId(record.pgid) &&
20
- isValidPort(record.port) &&
21
- typeof record.logPath === 'string' &&
22
- typeof record.startedAt === 'string');
23
+ const parsedPorts = normalizeSessionRecordPorts(record.port, Array.isArray(record.ports) ? record.ports : undefined);
24
+ if (typeof record.namespace !== 'string' ||
25
+ typeof record.worktreePath !== 'string' ||
26
+ typeof record.branch !== 'string' ||
27
+ !isSafeProcessId(record.pgid) ||
28
+ !isSafeProcessId(record.pid) ||
29
+ !isSafePort(record.port) ||
30
+ !parsedPorts.every(isSafePort) ||
31
+ parsedPorts.length === 0 ||
32
+ typeof record.logPath !== 'string' ||
33
+ typeof record.startedAt !== 'string') {
34
+ return null;
35
+ }
36
+ return { ...record, ports: parsedPorts };
23
37
  }
38
+ const MAX_SESSION_BYTES = 16 * 1024;
24
39
  export function getSessionPaths(gitCommonDir, namespace) {
25
40
  const baseDir = path.join(gitCommonDir, 'worktree-command-tui');
26
41
  return {
@@ -42,8 +57,8 @@ export async function readSessionRecord(paths, { isSessionAlive }) {
42
57
  if (source === null) {
43
58
  return null;
44
59
  }
45
- const parsed = JSON.parse(source);
46
- if (!isSessionRecord(parsed)) {
60
+ const parsed = parseSessionRecord(JSON.parse(source));
61
+ if (parsed === null) {
47
62
  await rm(paths.sessionFile, { force: true });
48
63
  return null;
49
64
  }
@@ -59,7 +74,10 @@ export async function readSessionRecord(paths, { isSessionAlive }) {
59
74
  }
60
75
  export async function writeSessionRecord(paths, record) {
61
76
  await mkdir(paths.baseDir, { recursive: true });
62
- await writeFile(paths.sessionFile, JSON.stringify(record, null, 2));
77
+ await writeFile(paths.sessionFile, JSON.stringify({
78
+ ...record,
79
+ ports: record.ports ?? [record.port],
80
+ }, null, 2));
63
81
  }
64
82
  export async function clearSessionRecord(paths) {
65
83
  await rm(paths.sessionFile, { force: true });
@@ -33,9 +33,9 @@ export function decideEnterInteraction(selected, activePath) {
33
33
  }
34
34
  if (selected.path === activePath) {
35
35
  return {
36
- kind: 'set-status',
37
- status: { kind: 'idle', message: 'already active' },
38
- suppressesBackgroundRefreshes: true,
36
+ kind: 'start',
37
+ path: selected.path,
38
+ status: { kind: 'starting', message: `Restarting ${selected.branch}...` },
39
39
  };
40
40
  }
41
41
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohzw/worktree-command-tui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A TUI for managing git worktrees",
5
5
  "private": false,
6
6
  "type": "module",
package/dist/repro.d.ts DELETED
@@ -1 +0,0 @@
1
- export {};
package/dist/repro.js DELETED
@@ -1,13 +0,0 @@
1
- const model = {
2
- repoName: "reclaim-the-forest",
3
- namespace: "rojo-serve",
4
- rows: Array.from({ length: 10 }, (_, index) => ({
5
- path: `/repo/.worktree/feat-${index}`,
6
- shortPath: `.worktree/feat-${index}`,
7
- branch: `feat/${index}`,
8
- tags: (index === 0 ? [active] : []),
9
- pullRequest: index === 0 ? { kind: found, number: 2125, title: Selection }
10
- :
11
- }))
12
- };
13
- export {};