@ohzw/worktree-command-tui 0.1.4 → 0.1.6
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.d.ts +5 -2
- package/dist/app.js +210 -63
- package/dist/components/ActionPanel.js +9 -2
- 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/debounced-resize.d.ts +7 -0
- package/dist/core/debounced-resize.js +39 -0
- package/dist/core/git-metadata.d.ts +4 -0
- package/dist/core/git-metadata.js +9 -0
- 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.d.ts +3 -2
- package/dist/core/runtime.js +15 -8
- package/dist/core/session-store.d.ts +1 -0
- package/dist/core/session-store.js +33 -15
- package/dist/core/tui-interaction.d.ts +1 -0
- package/dist/core/tui-interaction.js +6 -0
- package/dist/core/worktree-projection.d.ts +9 -0
- package/dist/core/worktree-projection.js +13 -0
- package/dist/main.js +8 -16
- 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.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppActions, AppModel } from './core/runtime.js';
|
|
1
|
+
import type { AppActions, AppLogRefresh, AppModel } from './core/runtime.js';
|
|
2
2
|
export interface ShellDimensions {
|
|
3
3
|
rootWidth: number;
|
|
4
4
|
rootHeight: number;
|
|
@@ -14,9 +14,12 @@ export declare function getMouseWheelDelta(input: string): number;
|
|
|
14
14
|
export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
|
|
15
15
|
export declare function shouldUseCompactLayout(_columns: number, _rows: number, _worktreeCount?: number): boolean;
|
|
16
16
|
export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
|
|
17
|
+
export declare const RESIZE_DEBOUNCE_MS = 100;
|
|
17
18
|
export declare function shouldStackPanes(columns: number, rows: number, _worktreeCount?: number): boolean;
|
|
18
|
-
export declare function
|
|
19
|
+
export declare function getModelAfterLogRefresh(current: AppModel, refresh: AppLogRefresh): AppModel;
|
|
20
|
+
export declare function App({ initialModel, actions, windowSizeOverride, resizeDebounceMs, }: {
|
|
19
21
|
initialModel: AppModel;
|
|
20
22
|
actions: AppActions;
|
|
21
23
|
windowSizeOverride?: AppWindowSize;
|
|
24
|
+
resizeDebounceMs?: number;
|
|
22
25
|
}): import("react").JSX.Element;
|
package/dist/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Alert, Spinner } from '@inkjs/ui';
|
|
3
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import { Box, Text, useApp, useInput, useStdin, useStdout
|
|
3
|
+
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
|
|
5
5
|
import { ActionPanel } from './components/ActionPanel.js';
|
|
6
6
|
import { ContextBar } from './components/ContextBar.js';
|
|
7
7
|
import { Header } from './components/Header.js';
|
|
@@ -9,7 +9,7 @@ import { HelpWindow } from './components/HelpWindow.js';
|
|
|
9
9
|
import { FloatingLogWindow } from './components/FloatingLogWindow.js';
|
|
10
10
|
import { LogPanel, buildLogLines } from './components/LogPanel.js';
|
|
11
11
|
import { WorktreeList } from './components/WorktreeList.js';
|
|
12
|
-
import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
|
|
12
|
+
import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex, wrapSelectionIndex } from './core/tui-interaction.js';
|
|
13
13
|
import { sanitizeInlineText } from './core/worktree-projection.js';
|
|
14
14
|
const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
|
|
15
15
|
const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
|
|
@@ -55,6 +55,7 @@ const HEADER_HEIGHT = 5;
|
|
|
55
55
|
const CONTEXT_BAR_HEIGHT = 4;
|
|
56
56
|
const PANE_GAP_WIDTH = 1;
|
|
57
57
|
const MIN_STACKED_PANE_HEIGHT = 9;
|
|
58
|
+
export const RESIZE_DEBOUNCE_MS = 100;
|
|
58
59
|
export function shouldStackPanes(columns, rows, _worktreeCount = 0) {
|
|
59
60
|
// Header + context bar consume the fixed chrome; each stacked pane still keeps ~6 visible content lines at the minimum height.
|
|
60
61
|
return columns < 96 && rows >= STACKED_LAYOUT_FRAME_ROWS + (MIN_STACKED_PANE_HEIGHT * 2);
|
|
@@ -81,6 +82,25 @@ function syncActiveTags(rows, activePath) {
|
|
|
81
82
|
});
|
|
82
83
|
return changed ? nextRows : rows;
|
|
83
84
|
}
|
|
85
|
+
function rowMatchesFilter(row, normalizedQuery) {
|
|
86
|
+
if (normalizedQuery === '') {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const pullRequest = row.pullRequest?.kind === 'found' ? row.pullRequest : null;
|
|
90
|
+
const headCommit = `${row.headSha ?? ''} ${row.headCommit?.message ?? ''}`.toLowerCase();
|
|
91
|
+
return row.branch.toLowerCase().includes(normalizedQuery)
|
|
92
|
+
|| row.path.toLowerCase().includes(normalizedQuery)
|
|
93
|
+
|| row.shortPath.toLowerCase().includes(normalizedQuery)
|
|
94
|
+
|| headCommit.includes(normalizedQuery)
|
|
95
|
+
|| (pullRequest !== null && (`${pullRequest.number} ${pullRequest.title}`).toLowerCase().includes(normalizedQuery));
|
|
96
|
+
}
|
|
97
|
+
function filterRows(rows, query) {
|
|
98
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
99
|
+
if (normalizedQuery === '') {
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
return rows.filter(row => rowMatchesFilter(row, normalizedQuery));
|
|
103
|
+
}
|
|
84
104
|
function shouldRefreshLogs(model) {
|
|
85
105
|
return model.activePath !== null && (model.status.kind === 'running' || model.status.kind === 'error');
|
|
86
106
|
}
|
|
@@ -99,14 +119,104 @@ function getStatusAfterLogRefresh(current, refresh) {
|
|
|
99
119
|
}
|
|
100
120
|
return { kind: 'running', message: 'running' };
|
|
101
121
|
}
|
|
102
|
-
|
|
122
|
+
function areLogsEqual(left, right) {
|
|
123
|
+
if (left.length !== right.length) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return left.every((entry, index) => {
|
|
127
|
+
const other = right[index];
|
|
128
|
+
return other !== undefined
|
|
129
|
+
&& entry.name === other.name
|
|
130
|
+
&& entry.path === other.path
|
|
131
|
+
&& entry.content === other.content;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export function getModelAfterLogRefresh(current, refresh) {
|
|
135
|
+
const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
|
|
136
|
+
const status = getStatusAfterLogRefresh(current, refresh);
|
|
137
|
+
const logsChanged = !areLogsEqual(current.logs, refresh.logs);
|
|
138
|
+
const statusChanged = current.status.kind !== status.kind || current.status.message !== status.message;
|
|
139
|
+
const rows = activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows;
|
|
140
|
+
if (!activeChanged && !logsChanged && !statusChanged) {
|
|
141
|
+
return current;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
...current,
|
|
145
|
+
logs: refresh.logs,
|
|
146
|
+
activePath: refresh.activePath,
|
|
147
|
+
activeBranch: refresh.activeBranch,
|
|
148
|
+
status,
|
|
149
|
+
rows,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function readWindowSize(stdout) {
|
|
153
|
+
return {
|
|
154
|
+
columns: stdout.columns || process.stdout.columns || 80,
|
|
155
|
+
rows: stdout.rows || process.stdout.rows || 24,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function useDebouncedWindowSize(windowSizeOverride, debounceMs) {
|
|
159
|
+
const { stdout } = useStdout();
|
|
160
|
+
const resizeTimerRef = useRef(undefined);
|
|
161
|
+
const [stableWindowSize, setStableWindowSize] = useState(() => windowSizeOverride ?? readWindowSize(stdout));
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
return () => {
|
|
164
|
+
clearTimeout(resizeTimerRef.current);
|
|
165
|
+
};
|
|
166
|
+
}, []);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (windowSizeOverride === undefined) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
clearTimeout(resizeTimerRef.current);
|
|
172
|
+
if (stableWindowSize.columns === windowSizeOverride.columns && stableWindowSize.rows === windowSizeOverride.rows) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
resizeTimerRef.current = setTimeout(() => {
|
|
176
|
+
resizeTimerRef.current = undefined;
|
|
177
|
+
setStableWindowSize({ columns: windowSizeOverride.columns, rows: windowSizeOverride.rows });
|
|
178
|
+
}, debounceMs);
|
|
179
|
+
}, [windowSizeOverride?.columns, windowSizeOverride?.rows, debounceMs, stableWindowSize.columns, stableWindowSize.rows]);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (windowSizeOverride !== undefined) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const scheduleResize = () => {
|
|
185
|
+
const nextWindowSize = readWindowSize(stdout);
|
|
186
|
+
clearTimeout(resizeTimerRef.current);
|
|
187
|
+
if (stableWindowSize.columns === nextWindowSize.columns && stableWindowSize.rows === nextWindowSize.rows) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
resizeTimerRef.current = setTimeout(() => {
|
|
191
|
+
resizeTimerRef.current = undefined;
|
|
192
|
+
setStableWindowSize(nextWindowSize);
|
|
193
|
+
}, debounceMs);
|
|
194
|
+
};
|
|
195
|
+
scheduleResize();
|
|
196
|
+
stdout.on('resize', scheduleResize);
|
|
197
|
+
return () => {
|
|
198
|
+
stdout.off('resize', scheduleResize);
|
|
199
|
+
};
|
|
200
|
+
}, [stdout, windowSizeOverride, debounceMs, stableWindowSize.columns, stableWindowSize.rows]);
|
|
201
|
+
return stableWindowSize;
|
|
202
|
+
}
|
|
203
|
+
export function App({ initialModel, actions, windowSizeOverride, resizeDebounceMs, }) {
|
|
204
|
+
const stableWindowSize = useDebouncedWindowSize(windowSizeOverride, resizeDebounceMs ?? RESIZE_DEBOUNCE_MS);
|
|
205
|
+
return _jsx(MemoizedAppShell, { initialModel: initialModel, actions: actions, windowSize: stableWindowSize });
|
|
206
|
+
}
|
|
207
|
+
function AppShell(props) {
|
|
208
|
+
return _jsx(AppShellContent, { ...props });
|
|
209
|
+
}
|
|
210
|
+
function AppShellContent({ initialModel, actions, windowSize, }) {
|
|
103
211
|
const { exit } = useApp();
|
|
104
212
|
const { stdin } = useStdin();
|
|
105
213
|
const { stdout } = useStdout();
|
|
106
|
-
const
|
|
107
|
-
const { columns, rows } = windowSizeOverride ?? liveWindowSize;
|
|
214
|
+
const { columns, rows } = windowSize;
|
|
108
215
|
const [model, setModel] = useState(initialModel);
|
|
109
|
-
const [
|
|
216
|
+
const [filterQuery, setFilterQuery] = useState('');
|
|
217
|
+
const [isFilterInputOpen, setIsFilterInputOpen] = useState(false);
|
|
218
|
+
const visibleRows = useMemo(() => filterRows(model.rows, filterQuery), [model.rows, filterQuery]);
|
|
219
|
+
const [selectedPath, setSelectedPath] = useState(visibleRows[0]?.path ?? null);
|
|
110
220
|
const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
|
|
111
221
|
const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
|
|
112
222
|
const [logScrollOffset, setLogScrollOffset] = useState(0);
|
|
@@ -121,8 +231,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
121
231
|
const lastStartRequestRef = useRef({ path: null, atMs: 0 });
|
|
122
232
|
const alertTimeoutRef = useRef(null);
|
|
123
233
|
useEffect(() => {
|
|
124
|
-
setSelectedPath(currentPath => getNextSelectedPath(
|
|
125
|
-
}, [
|
|
234
|
+
setSelectedPath(currentPath => getNextSelectedPath(visibleRows, currentPath));
|
|
235
|
+
}, [visibleRows]);
|
|
126
236
|
useEffect(() => {
|
|
127
237
|
setSelectionScrollOffset(0);
|
|
128
238
|
}, [selectedPath]);
|
|
@@ -169,18 +279,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
169
279
|
if (generation !== actionGenerationRef.current || userActionInFlightRef.current) {
|
|
170
280
|
return;
|
|
171
281
|
}
|
|
172
|
-
setModel(current =>
|
|
173
|
-
const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
|
|
174
|
-
const status = getStatusAfterLogRefresh(current, refresh);
|
|
175
|
-
return {
|
|
176
|
-
...current,
|
|
177
|
-
logs: refresh.logs,
|
|
178
|
-
activePath: refresh.activePath,
|
|
179
|
-
activeBranch: refresh.activeBranch,
|
|
180
|
-
status,
|
|
181
|
-
rows: activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows,
|
|
182
|
-
};
|
|
183
|
-
});
|
|
282
|
+
setModel(current => getModelAfterLogRefresh(current, refresh));
|
|
184
283
|
})
|
|
185
284
|
.catch(() => { })
|
|
186
285
|
.finally(() => {
|
|
@@ -196,16 +295,16 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
196
295
|
setPendingDelete(null);
|
|
197
296
|
}
|
|
198
297
|
}, [confirmationOpen, model.rows, pendingDelete]);
|
|
199
|
-
const selectedIndex = useMemo(() => getSelectedIndex(
|
|
200
|
-
const selected =
|
|
298
|
+
const selectedIndex = useMemo(() => getSelectedIndex(visibleRows, selectedPath), [visibleRows, selectedPath]);
|
|
299
|
+
const selected = visibleRows[selectedIndex];
|
|
201
300
|
const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
|
|
202
301
|
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 =
|
|
302
|
+
const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, visibleRows.length);
|
|
303
|
+
const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, visibleRows.length);
|
|
304
|
+
const compactDetailPane = !stackedLayout && rootHeight <= 30 && visibleRows.length > 1;
|
|
305
|
+
const showLogPanel = rootHeight >= 34;
|
|
207
306
|
const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
|
|
208
|
-
const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS) / 2));
|
|
307
|
+
const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight) / 2));
|
|
209
308
|
const paneHeight = stackedLayout
|
|
210
309
|
? stackedPaneHeight
|
|
211
310
|
: Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
|
|
@@ -216,12 +315,18 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
216
315
|
: showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
|
|
217
316
|
const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
|
|
218
317
|
const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
|
|
219
|
-
function moveSelection(nextIndex) {
|
|
220
|
-
const
|
|
221
|
-
|
|
318
|
+
function moveSelection(nextIndex, mode = 'clamp') {
|
|
319
|
+
const nextSelectionIndex = mode === 'wrap'
|
|
320
|
+
? wrapSelectionIndex(nextIndex, visibleRows.length)
|
|
321
|
+
: clampSelectionIndex(nextIndex, visibleRows.length);
|
|
322
|
+
if (nextSelectionIndex === null) {
|
|
222
323
|
return;
|
|
223
324
|
}
|
|
224
|
-
setSelectedPath(
|
|
325
|
+
setSelectedPath(visibleRows[nextSelectionIndex].path);
|
|
326
|
+
}
|
|
327
|
+
function clearFilter() {
|
|
328
|
+
setFilterQuery('');
|
|
329
|
+
setIsFilterInputOpen(false);
|
|
225
330
|
}
|
|
226
331
|
function clearTransientAlert() {
|
|
227
332
|
if (alertTimeoutRef.current !== null) {
|
|
@@ -262,6 +367,29 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
262
367
|
last.atMs = nowMs;
|
|
263
368
|
return true;
|
|
264
369
|
}
|
|
370
|
+
function startSelectedWorktree() {
|
|
371
|
+
if (userActionInFlightRef.current) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const decision = decideEnterInteraction(selected, model.activePath);
|
|
375
|
+
if (decision.kind === 'ignore') {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (decision.kind === 'set-status') {
|
|
379
|
+
if (decision.suppressesBackgroundRefreshes) {
|
|
380
|
+
invalidateStaleLogRefreshes();
|
|
381
|
+
}
|
|
382
|
+
setModel(current => ({ ...current, status: decision.status }));
|
|
383
|
+
clearTransientAlert();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (!shouldAcceptStartRequest(decision.path)) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
setModel(current => ({ ...current, status: decision.status }));
|
|
390
|
+
clearTransientAlert();
|
|
391
|
+
void apply(() => actions.start(decision.path));
|
|
392
|
+
}
|
|
265
393
|
useInput((input, key) => {
|
|
266
394
|
if (isHelpOverlayOpen) {
|
|
267
395
|
if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
|
|
@@ -318,16 +446,54 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
318
446
|
}
|
|
319
447
|
return;
|
|
320
448
|
}
|
|
449
|
+
if (isFilterInputOpen) {
|
|
450
|
+
if (key.escape || input === '\u001B') {
|
|
451
|
+
clearFilter();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (key.upArrow) {
|
|
455
|
+
moveSelection(selectedIndex - 1, 'wrap');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (key.downArrow) {
|
|
459
|
+
moveSelection(selectedIndex + 1, 'wrap');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (key.pageDown) {
|
|
463
|
+
setSelectionScrollOffset(current => current + selectionScrollPageSize);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (key.pageUp) {
|
|
467
|
+
setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (key.backspace || key.delete) {
|
|
471
|
+
setFilterQuery(current => current.slice(0, -1));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (key.return) {
|
|
475
|
+
startSelectedWorktree();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (input && !key.ctrl && !key.meta) {
|
|
479
|
+
setFilterQuery(current => current + input);
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (input === '/') {
|
|
484
|
+
setIsFilterInputOpen(true);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
321
487
|
if (key.escape || input === 'q') {
|
|
322
488
|
exit();
|
|
323
489
|
return;
|
|
324
490
|
}
|
|
325
491
|
if (key.upArrow || input === 'k') {
|
|
326
|
-
moveSelection(selectedIndex - 1);
|
|
492
|
+
moveSelection(selectedIndex - 1, 'wrap');
|
|
327
493
|
return;
|
|
328
494
|
}
|
|
329
495
|
if (key.downArrow || input === 'j') {
|
|
330
|
-
moveSelection(selectedIndex + 1);
|
|
496
|
+
moveSelection(selectedIndex + 1, 'wrap');
|
|
331
497
|
return;
|
|
332
498
|
}
|
|
333
499
|
if (input === 'g') {
|
|
@@ -335,7 +501,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
335
501
|
return;
|
|
336
502
|
}
|
|
337
503
|
if (input === 'G') {
|
|
338
|
-
moveSelection(
|
|
504
|
+
moveSelection(visibleRows.length - 1);
|
|
339
505
|
return;
|
|
340
506
|
}
|
|
341
507
|
if (input === '?') {
|
|
@@ -362,28 +528,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
362
528
|
setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
|
|
363
529
|
return;
|
|
364
530
|
}
|
|
365
|
-
if (userActionInFlightRef.current) {
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
531
|
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));
|
|
532
|
+
startSelectedWorktree();
|
|
387
533
|
return;
|
|
388
534
|
}
|
|
389
535
|
if (input === 's') {
|
|
@@ -462,7 +608,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
462
608
|
if (listPaneViewportHeight === undefined) {
|
|
463
609
|
return 0;
|
|
464
610
|
}
|
|
465
|
-
const max = Math.max(0,
|
|
611
|
+
const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
|
|
466
612
|
return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
|
|
467
613
|
});
|
|
468
614
|
};
|
|
@@ -519,14 +665,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
519
665
|
stdout.write(DISABLE_MOUSE_TRACKING);
|
|
520
666
|
}
|
|
521
667
|
};
|
|
522
|
-
}, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep,
|
|
668
|
+
}, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, visibleRows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
|
|
523
669
|
useEffect(() => {
|
|
524
670
|
if (listPaneViewportHeight === undefined) {
|
|
525
671
|
setWorktreeScrollOffset(0);
|
|
526
672
|
return;
|
|
527
673
|
}
|
|
528
674
|
setWorktreeScrollOffset(current => {
|
|
529
|
-
const max = Math.max(0,
|
|
675
|
+
const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
|
|
530
676
|
if (selectedIndex < current) {
|
|
531
677
|
return Math.max(0, selectedIndex);
|
|
532
678
|
}
|
|
@@ -535,7 +681,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
535
681
|
}
|
|
536
682
|
return Math.min(current, max);
|
|
537
683
|
});
|
|
538
|
-
}, [selectedIndex, listPaneViewportHeight,
|
|
684
|
+
}, [selectedIndex, listPaneViewportHeight, visibleRows.length]);
|
|
539
685
|
useEffect(() => {
|
|
540
686
|
if (!showLogPanel && !isLogOverlayOpen) {
|
|
541
687
|
setLogScrollOffset(0);
|
|
@@ -557,14 +703,15 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
557
703
|
if (minimalLayout) {
|
|
558
704
|
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
705
|
? _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
|
|
706
|
+
: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk/\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
|
|
561
707
|
}
|
|
562
708
|
if (compactLayout) {
|
|
563
709
|
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
710
|
? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", safeCompletedAlert] })
|
|
565
711
|
: 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
712
|
? '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` })] }));
|
|
713
|
+
: `Keys: ↑↓/jk g/G / Filter ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
|
|
568
714
|
}
|
|
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:
|
|
715
|
+
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
716
|
}
|
|
717
|
+
const MemoizedAppShell = memo(AppShell);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import { getOrderedNonActiveTags, projectAction, projectNote, projectPullRequest, projectUpstream, projectWorkingTree, sanitizeInlineText, } from '../core/worktree-projection.js';
|
|
3
|
+
import { getOrderedNonActiveTags, projectAction, projectHeadCommit, projectNote, projectPullRequest, projectUpstream, projectWorkingTree, sanitizeInlineText, } from '../core/worktree-projection.js';
|
|
4
4
|
import { getScrollbarThumbRows, sliceListViewport } from '../terminal/viewport.js';
|
|
5
5
|
export function getActionVariant(selectedRow, activePath) {
|
|
6
6
|
return projectAction(selectedRow, activePath).severity;
|
|
@@ -72,6 +72,13 @@ function formatPullRequest(selectedRow) {
|
|
|
72
72
|
const stateText = pullRequest.state.toLowerCase();
|
|
73
73
|
return `#${pullRequest.number} ${draft}${stateText} → ${pullRequest.baseBranch}`;
|
|
74
74
|
}
|
|
75
|
+
function formatHeadCommit(selectedRow) {
|
|
76
|
+
const headCommit = projectHeadCommit(selectedRow);
|
|
77
|
+
if (headCommit.kind === 'unavailable') {
|
|
78
|
+
return '-';
|
|
79
|
+
}
|
|
80
|
+
return headCommit.label;
|
|
81
|
+
}
|
|
75
82
|
function getPullRequestColorFromProjection(pullRequest) {
|
|
76
83
|
if (pullRequest.kind === 'unavailable') {
|
|
77
84
|
return 'red';
|
|
@@ -150,7 +157,7 @@ function getPanelLines(selectedRow, activePath, setupAvailable, compactDetails)
|
|
|
150
157
|
if (showFullPath) {
|
|
151
158
|
lines.push({ text: `Full Path: ${sanitizeInlineText(selectedRow.path)}` });
|
|
152
159
|
}
|
|
153
|
-
lines.push({ text: `HEAD: ${selectedRow
|
|
160
|
+
lines.push({ text: `HEAD: ${formatHeadCommit(selectedRow)}` });
|
|
154
161
|
lines.push({ text: `Branch Created: ${formatUtcDateTime(selectedRow.branchCreatedAtMs)}` });
|
|
155
162
|
if (showTags) {
|
|
156
163
|
for (const { tag } of getOrderedNonActiveTags(selectedRow.tags)) {
|
|
@@ -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, }: {
|