@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 +7 -3
- package/dist/app.js +106 -41
- package/dist/components/ContextBar.js +1 -0
- package/dist/components/HelpWindow.js +2 -0
- package/dist/components/WorktreeList.d.ts +4 -1
- package/dist/components/WorktreeList.js +7 -2
- package/dist/core/command-runner.d.ts +2 -1
- package/dist/core/command-runner.js +65 -2
- package/dist/core/config-lifecycle.d.ts +1 -1
- package/dist/core/config-lifecycle.js +83 -30
- package/dist/core/config.d.ts +2 -1
- package/dist/core/process-control.d.ts +1 -1
- package/dist/core/process-control.js +3 -1
- package/dist/core/runtime-state.d.ts +3 -1
- package/dist/core/runtime-state.js +32 -18
- package/dist/core/runtime.js +10 -6
- package/dist/core/session-store.d.ts +1 -0
- package/dist/core/session-store.js +33 -15
- package/package.json +1 -1
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
|
|
125
|
-
"
|
|
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 [
|
|
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(
|
|
125
|
-
}, [
|
|
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(
|
|
200
|
-
const selected =
|
|
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,
|
|
204
|
-
const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight,
|
|
205
|
-
const compactDetailPane = !stackedLayout && rootHeight <= 30 &&
|
|
206
|
-
const showLogPanel =
|
|
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,
|
|
240
|
+
const clampedIndex = clampSelectionIndex(nextIndex, visibleRows.length);
|
|
221
241
|
if (clampedIndex === null) {
|
|
222
242
|
return;
|
|
223
243
|
}
|
|
224
|
-
setSelectedPath(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
118
|
+
setupCommand: readOptionalSetupCommands(config.setupCommand),
|
|
71
119
|
editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
|
|
72
|
-
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
|
-
|
|
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
|
|
130
|
-
|
|
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);
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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,7 +6,9 @@ export async function stopSessionWithFallback(input, deps) {
|
|
|
6
6
|
if (!(await deps.isSessionAlive(input.pgid))) {
|
|
7
7
|
return true;
|
|
8
8
|
}
|
|
9
|
-
|
|
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
|
|
44
|
-
if (
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
});
|
package/dist/core/runtime.js
CHANGED
|
@@ -70,8 +70,8 @@ async function buildRows(mainWorktreePath, workspaceRoot, activePath, requiredFi
|
|
|
70
70
|
}));
|
|
71
71
|
return rows;
|
|
72
72
|
}
|
|
73
|
-
async function stopRecordedSession(pgid,
|
|
74
|
-
const stopped = await stopSessionWithFallback({ pgid,
|
|
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.
|
|
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: '
|
|
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.
|
|
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 });
|
|
@@ -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
|
|
6
|
+
function isSafePort(value) {
|
|
8
7
|
return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535;
|
|
9
8
|
}
|
|
10
|
-
function
|
|
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
|
|
20
|
+
return null;
|
|
13
21
|
}
|
|
14
22
|
const record = value;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
typeof record.
|
|
18
|
-
|
|
19
|
-
isSafeProcessId(record.pgid)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 (
|
|
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(
|
|
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 });
|