@ohzw/worktree-command-tui 0.1.4 → 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,6 +3,9 @@
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
@@ -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
@@ -81,6 +81,23 @@ function syncActiveTags(rows, activePath) {
81
81
  });
82
82
  return changed ? nextRows : rows;
83
83
  }
84
+ function rowMatchesFilter(row, normalizedQuery) {
85
+ if (normalizedQuery === '') {
86
+ return true;
87
+ }
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
+ }
84
101
  function shouldRefreshLogs(model) {
85
102
  return model.activePath !== null && (model.status.kind === 'running' || model.status.kind === 'error');
86
103
  }
@@ -106,7 +123,10 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
106
123
  const liveWindowSize = useWindowSize();
107
124
  const { columns, rows } = windowSizeOverride ?? liveWindowSize;
108
125
  const [model, setModel] = useState(initialModel);
109
- 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);
110
130
  const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
111
131
  const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
112
132
  const [logScrollOffset, setLogScrollOffset] = useState(0);
@@ -121,8 +141,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
121
141
  const lastStartRequestRef = useRef({ path: null, atMs: 0 });
122
142
  const alertTimeoutRef = useRef(null);
123
143
  useEffect(() => {
124
- setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
125
- }, [model.rows]);
144
+ setSelectedPath(currentPath => getNextSelectedPath(visibleRows, currentPath));
145
+ }, [visibleRows]);
126
146
  useEffect(() => {
127
147
  setSelectionScrollOffset(0);
128
148
  }, [selectedPath]);
@@ -196,16 +216,16 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
196
216
  setPendingDelete(null);
197
217
  }
198
218
  }, [confirmationOpen, model.rows, pendingDelete]);
199
- const selectedIndex = useMemo(() => getSelectedIndex(model.rows, selectedPath), [model.rows, selectedPath]);
200
- const selected = model.rows[selectedIndex];
219
+ const selectedIndex = useMemo(() => getSelectedIndex(visibleRows, selectedPath), [visibleRows, selectedPath]);
220
+ const selected = visibleRows[selectedIndex];
201
221
  const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
202
222
  const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
203
- const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
204
- const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
205
- const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
206
- 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;
207
227
  const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
208
- 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));
209
229
  const paneHeight = stackedLayout
210
230
  ? stackedPaneHeight
211
231
  : Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
@@ -217,11 +237,15 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
217
237
  const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
218
238
  const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
219
239
  function moveSelection(nextIndex) {
220
- const clampedIndex = clampSelectionIndex(nextIndex, model.rows.length);
240
+ const clampedIndex = clampSelectionIndex(nextIndex, visibleRows.length);
221
241
  if (clampedIndex === null) {
222
242
  return;
223
243
  }
224
- setSelectedPath(model.rows[clampedIndex].path);
244
+ setSelectedPath(visibleRows[clampedIndex].path);
245
+ }
246
+ function clearFilter() {
247
+ setFilterQuery('');
248
+ setIsFilterInputOpen(false);
225
249
  }
226
250
  function clearTransientAlert() {
227
251
  if (alertTimeoutRef.current !== null) {
@@ -262,6 +286,29 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
262
286
  last.atMs = nowMs;
263
287
  return true;
264
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
+ }
265
312
  useInput((input, key) => {
266
313
  if (isHelpOverlayOpen) {
267
314
  if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
@@ -318,6 +365,44 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
318
365
  }
319
366
  return;
320
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
+ }
321
406
  if (key.escape || input === 'q') {
322
407
  exit();
323
408
  return;
@@ -335,7 +420,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
335
420
  return;
336
421
  }
337
422
  if (input === 'G') {
338
- moveSelection(model.rows.length - 1);
423
+ moveSelection(visibleRows.length - 1);
339
424
  return;
340
425
  }
341
426
  if (input === '?') {
@@ -362,28 +447,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
362
447
  setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
363
448
  return;
364
449
  }
365
- if (userActionInFlightRef.current) {
366
- return;
367
- }
368
450
  if (key.return) {
369
- const decision = decideEnterInteraction(selected, model.activePath);
370
- if (decision.kind === 'ignore') {
371
- return;
372
- }
373
- if (decision.kind === 'set-status') {
374
- if (decision.suppressesBackgroundRefreshes) {
375
- invalidateStaleLogRefreshes();
376
- }
377
- setModel(current => ({ ...current, status: decision.status }));
378
- clearTransientAlert();
379
- return;
380
- }
381
- if (!shouldAcceptStartRequest(decision.path)) {
382
- return;
383
- }
384
- setModel(current => ({ ...current, status: decision.status }));
385
- clearTransientAlert();
386
- void apply(() => actions.start(decision.path));
451
+ startSelectedWorktree();
387
452
  return;
388
453
  }
389
454
  if (input === 's') {
@@ -462,7 +527,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
462
527
  if (listPaneViewportHeight === undefined) {
463
528
  return 0;
464
529
  }
465
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
530
+ const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
466
531
  return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
467
532
  });
468
533
  };
@@ -519,14 +584,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
519
584
  stdout.write(DISABLE_MOUSE_TRACKING);
520
585
  }
521
586
  };
522
- }, [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]);
523
588
  useEffect(() => {
524
589
  if (listPaneViewportHeight === undefined) {
525
590
  setWorktreeScrollOffset(0);
526
591
  return;
527
592
  }
528
593
  setWorktreeScrollOffset(current => {
529
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
594
+ const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
530
595
  if (selectedIndex < current) {
531
596
  return Math.max(0, selectedIndex);
532
597
  }
@@ -535,7 +600,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
535
600
  }
536
601
  return Math.min(current, max);
537
602
  });
538
- }, [selectedIndex, listPaneViewportHeight, model.rows.length]);
603
+ }, [selectedIndex, listPaneViewportHeight, visibleRows.length]);
539
604
  useEffect(() => {
540
605
  if (!showLogPanel && !isLogOverlayOpen) {
541
606
  setLogScrollOffset(0);
@@ -557,14 +622,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
557
622
  if (minimalLayout) {
558
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
559
624
  ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "d/y confirm \u00B7 Esc/n/q cancel" })
560
- : _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] }));
561
626
  }
562
627
  if (compactLayout) {
563
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
564
629
  ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", safeCompletedAlert] })
565
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
566
631
  ? 'Keys: d/y confirm | Esc/n/q cancel'
567
- : `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` })] }));
568
633
  }
569
- 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] }));
570
635
  }
@@ -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
  }
@@ -9,6 +9,8 @@ function buildHelpLines(setupAvailable, editorAvailable) {
9
9
  { section: 'Logs', binding: 'L', description: 'full-screen logs' },
10
10
  { section: 'Logs', binding: '[/]', description: 'scroll log' },
11
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 {
@@ -99,6 +112,7 @@ export function createRuntimeStateActions({ config, paths, adapter }) {
99
112
  pid: started.pid,
100
113
  pgid: started.pgid,
101
114
  port: config.port,
115
+ ports: config.ports,
102
116
  logPath: started.logPath,
103
117
  startedAt: adapter.nowIso(),
104
118
  });
@@ -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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohzw/worktree-command-tui",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A TUI for managing git worktrees",
5
5
  "private": false,
6
6
  "type": "module",