@ohzw/worktree-command-tui 0.1.0 → 0.1.2
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 +5 -0
- package/dist/app.d.ts +3 -2
- package/dist/app.js +458 -42
- package/dist/components/ActionPanel.d.ts +6 -2
- package/dist/components/ActionPanel.js +141 -82
- package/dist/components/ContextBar.d.ts +4 -1
- package/dist/components/ContextBar.js +37 -4
- package/dist/components/FloatingLogWindow.d.ts +7 -0
- package/dist/components/FloatingLogWindow.js +5 -0
- package/dist/components/HelpWindow.d.ts +7 -0
- package/dist/components/HelpWindow.js +29 -0
- package/dist/components/LogPanel.d.ts +22 -0
- package/dist/components/LogPanel.js +260 -0
- package/dist/components/WorktreeList.d.ts +3 -1
- package/dist/components/WorktreeList.js +25 -30
- package/dist/core/command-runner.d.ts +11 -0
- package/dist/core/command-runner.js +44 -0
- package/dist/core/config-lifecycle.d.ts +25 -0
- package/dist/core/config-lifecycle.js +143 -0
- package/dist/core/config.d.ts +2 -3
- package/dist/core/config.js +0 -48
- package/dist/core/git-metadata.d.ts +25 -0
- package/dist/core/git-metadata.js +84 -0
- package/dist/core/git-worktrees.d.ts +2 -1
- package/dist/core/git-worktrees.js +30 -11
- package/dist/core/github-metadata.d.ts +14 -0
- package/dist/core/github-metadata.js +137 -0
- package/dist/core/init.d.ts +3 -2
- package/dist/core/init.js +9 -57
- package/dist/core/log-reader.d.ts +7 -0
- package/dist/core/log-reader.js +43 -0
- package/dist/core/runtime-state.d.ts +42 -0
- package/dist/core/runtime-state.js +125 -0
- package/dist/core/runtime.d.ts +20 -33
- package/dist/core/runtime.js +116 -173
- package/dist/core/tui-interaction.d.ts +31 -0
- package/dist/core/tui-interaction.js +59 -0
- package/dist/core/worktree-projection.d.ts +76 -0
- package/dist/core/worktree-projection.js +124 -0
- package/dist/main.js +24 -2
- package/dist/render-options.d.ts +1 -0
- package/dist/render-options.js +1 -0
- package/dist/repro.d.ts +1 -0
- package/dist/repro.js +13 -0
- package/dist/terminal/viewport.d.ts +15 -0
- package/dist/terminal/viewport.js +49 -0
- package/dist/ui-theme.d.ts +3 -0
- package/dist/ui-theme.js +38 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -65,6 +65,8 @@ A minimal example of the generated config:
|
|
|
65
65
|
"namespace": "worktree-command-tui",
|
|
66
66
|
// Command executed in each selected worktree
|
|
67
67
|
"command": ["npm", "run", "start"],
|
|
68
|
+
// Optional setup command run manually in the selected worktree
|
|
69
|
+
"setupCommand": ["npm", "install"],
|
|
68
70
|
// Port used for cleanup/monitoring
|
|
69
71
|
"port": 3000,
|
|
70
72
|
// Required files that must exist in a worktree
|
|
@@ -74,6 +76,9 @@ A minimal example of the generated config:
|
|
|
74
76
|
}
|
|
75
77
|
```
|
|
76
78
|
|
|
79
|
+
When `setupCommand` is configured, press `i` in the TUI to run it for the selected worktree.
|
|
80
|
+
It is never run automatically when switching worktrees.
|
|
81
|
+
|
|
77
82
|
## Development
|
|
78
83
|
|
|
79
84
|
```bash
|
package/dist/app.d.ts
CHANGED
|
@@ -10,10 +10,11 @@ export interface AppWindowSize {
|
|
|
10
10
|
columns: number;
|
|
11
11
|
rows: number;
|
|
12
12
|
}
|
|
13
|
+
export declare function getMouseWheelDelta(input: string): number;
|
|
13
14
|
export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
|
|
14
|
-
export declare function shouldUseCompactLayout(
|
|
15
|
+
export declare function shouldUseCompactLayout(_columns: number, _rows: number, _worktreeCount?: number): boolean;
|
|
15
16
|
export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
|
|
16
|
-
export declare function shouldStackPanes(columns: number, rows: number,
|
|
17
|
+
export declare function shouldStackPanes(columns: number, rows: number, _worktreeCount?: number): boolean;
|
|
17
18
|
export declare function App({ initialModel, actions, windowSizeOverride, }: {
|
|
18
19
|
initialModel: AppModel;
|
|
19
20
|
actions: AppActions;
|
package/dist/app.js
CHANGED
|
@@ -1,74 +1,246 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Alert, Spinner } from '@inkjs/ui';
|
|
2
3
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import { Box, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from 'ink';
|
|
4
5
|
import { ActionPanel } from './components/ActionPanel.js';
|
|
5
6
|
import { ContextBar } from './components/ContextBar.js';
|
|
6
7
|
import { Header } from './components/Header.js';
|
|
8
|
+
import { HelpWindow } from './components/HelpWindow.js';
|
|
9
|
+
import { FloatingLogWindow } from './components/FloatingLogWindow.js';
|
|
10
|
+
import { LogPanel, buildLogLines } from './components/LogPanel.js';
|
|
7
11
|
import { WorktreeList } from './components/WorktreeList.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
|
|
13
|
+
const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
|
|
14
|
+
const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
|
|
15
|
+
function parseMouseWheelEvents(input) {
|
|
16
|
+
const events = [];
|
|
17
|
+
const sgrMousePattern = /\u001B\[<(\d+);(\d+);(\d+)[mM]/g;
|
|
18
|
+
for (const match of input.matchAll(sgrMousePattern)) {
|
|
19
|
+
const button = Number(match[1]);
|
|
20
|
+
if (button !== 64 && button !== 65) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const x = Number(match[2]);
|
|
24
|
+
const y = Number(match[3]);
|
|
25
|
+
events.push({
|
|
26
|
+
delta: button === 65 ? 1 : -1,
|
|
27
|
+
x: Number.isFinite(x) ? x : undefined,
|
|
28
|
+
y: Number.isFinite(y) ? y : undefined,
|
|
29
|
+
});
|
|
14
30
|
}
|
|
15
|
-
return
|
|
31
|
+
return events;
|
|
32
|
+
}
|
|
33
|
+
export function getMouseWheelDelta(input) {
|
|
34
|
+
return parseMouseWheelEvents(input).reduce((sum, event) => sum + event.delta, 0);
|
|
16
35
|
}
|
|
17
36
|
export function getShellDimensions(columns, rows) {
|
|
18
37
|
const rootWidth = Math.max(columns, 1);
|
|
19
38
|
const rootHeight = Math.max(rows, 1);
|
|
20
|
-
const bodyWidth =
|
|
39
|
+
const bodyWidth = rootWidth;
|
|
21
40
|
const maxListWidth = Math.max(1, bodyWidth - 21);
|
|
22
41
|
const desiredListWidth = Math.max(1, Math.floor((bodyWidth - 1) * 0.34));
|
|
23
42
|
const listWidth = Math.min(42, desiredListWidth, maxListWidth);
|
|
24
43
|
const actionWidth = Math.max(1, bodyWidth - listWidth - 1);
|
|
25
44
|
return { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth };
|
|
26
45
|
}
|
|
27
|
-
export function shouldUseCompactLayout(
|
|
28
|
-
|
|
29
|
-
return columns < 72 || rows <= contentAwareRowFloor || (columns < 96 && rows < 24);
|
|
46
|
+
export function shouldUseCompactLayout(_columns, _rows, _worktreeCount = 0) {
|
|
47
|
+
return false;
|
|
30
48
|
}
|
|
31
49
|
export function shouldUseMinimalLayout(columns, rows) {
|
|
32
|
-
return columns < 20 || rows <
|
|
50
|
+
return columns < 20 || rows < 10;
|
|
51
|
+
}
|
|
52
|
+
const STACKED_LAYOUT_FRAME_ROWS = 9;
|
|
53
|
+
const HEADER_HEIGHT = 5;
|
|
54
|
+
const CONTEXT_BAR_HEIGHT = 4;
|
|
55
|
+
const PANE_GAP_WIDTH = 1;
|
|
56
|
+
const MIN_STACKED_PANE_HEIGHT = 9;
|
|
57
|
+
export function shouldStackPanes(columns, rows, _worktreeCount = 0) {
|
|
58
|
+
// Header + context bar consume the fixed chrome; each stacked pane still keeps ~6 visible content lines at the minimum height.
|
|
59
|
+
return columns < 96 && rows >= STACKED_LAYOUT_FRAME_ROWS + (MIN_STACKED_PANE_HEIGHT * 2);
|
|
60
|
+
}
|
|
61
|
+
function getLogPaneHeight(_rootHeight) {
|
|
62
|
+
// Each bordered log pane keeps ~6 visible content lines at 9 rows.
|
|
63
|
+
return 9;
|
|
64
|
+
}
|
|
65
|
+
const ACTIVE_TAG = 'active';
|
|
66
|
+
const ALREADY_ACTIVE_MESSAGE = 'already active';
|
|
67
|
+
function syncActiveTags(rows, activePath) {
|
|
68
|
+
let changed = false;
|
|
69
|
+
const nextRows = rows.map(row => {
|
|
70
|
+
const isActive = row.path === activePath;
|
|
71
|
+
const hasActiveTag = row.tags.includes(ACTIVE_TAG);
|
|
72
|
+
if (isActive === hasActiveTag) {
|
|
73
|
+
return row;
|
|
74
|
+
}
|
|
75
|
+
changed = true;
|
|
76
|
+
return {
|
|
77
|
+
...row,
|
|
78
|
+
tags: isActive ? [...row.tags, ACTIVE_TAG] : row.tags.filter(tag => tag !== ACTIVE_TAG),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
return changed ? nextRows : rows;
|
|
82
|
+
}
|
|
83
|
+
function shouldRefreshLogs(model) {
|
|
84
|
+
if (model.activePath === null) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (model.status.kind === 'running' || model.status.kind === 'error') {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return model.status.message === ALREADY_ACTIVE_MESSAGE;
|
|
33
91
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
92
|
+
function getStatusAfterLogRefresh(current, refresh) {
|
|
93
|
+
if (current.status.kind !== 'running') {
|
|
94
|
+
return current.status;
|
|
95
|
+
}
|
|
96
|
+
if (current.activePath === refresh.activePath && current.activeBranch === refresh.activeBranch) {
|
|
97
|
+
return current.status;
|
|
98
|
+
}
|
|
99
|
+
if (refresh.activePath === null) {
|
|
100
|
+
return { kind: 'idle', message: 'session ended' };
|
|
101
|
+
}
|
|
102
|
+
if (refresh.activeBranch) {
|
|
103
|
+
return { kind: 'running', message: `Active: ${refresh.activeBranch}` };
|
|
104
|
+
}
|
|
105
|
+
return { kind: 'running', message: 'running' };
|
|
37
106
|
}
|
|
38
107
|
export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
39
108
|
const { exit } = useApp();
|
|
109
|
+
const { stdin } = useStdin();
|
|
110
|
+
const { stdout } = useStdout();
|
|
40
111
|
const liveWindowSize = useWindowSize();
|
|
41
112
|
const { columns, rows } = windowSizeOverride ?? liveWindowSize;
|
|
42
113
|
const [model, setModel] = useState(initialModel);
|
|
43
114
|
const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
|
|
44
|
-
const
|
|
115
|
+
const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
|
|
116
|
+
const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
|
|
117
|
+
const [logScrollOffset, setLogScrollOffset] = useState(0);
|
|
118
|
+
const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
|
|
119
|
+
const [isHelpOverlayOpen, setIsHelpOverlayOpen] = useState(false);
|
|
120
|
+
const [pendingDelete, setPendingDelete] = useState(null);
|
|
121
|
+
const [completedAlert, setCompletedAlert] = useState(null);
|
|
122
|
+
const userActionInFlightRef = useRef(false);
|
|
123
|
+
const logRefreshInFlightRef = useRef(false);
|
|
124
|
+
const actionGenerationRef = useRef(0);
|
|
125
|
+
const previousStatusRef = useRef(initialModel.status.kind);
|
|
126
|
+
const alertTimeoutRef = useRef(null);
|
|
45
127
|
useEffect(() => {
|
|
46
128
|
setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
|
|
47
129
|
}, [model.rows]);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
setSelectionScrollOffset(0);
|
|
132
|
+
}, [selectedPath]);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const becameRunning = previousStatusRef.current === 'starting' && model.status.kind === 'running';
|
|
135
|
+
if (becameRunning) {
|
|
136
|
+
setCompletedAlert(model.activeBranch ? `Switched to ${model.activeBranch}` : 'Worktree switch complete.');
|
|
137
|
+
if (alertTimeoutRef.current !== null) {
|
|
138
|
+
clearTimeout(alertTimeoutRef.current);
|
|
139
|
+
}
|
|
140
|
+
alertTimeoutRef.current = setTimeout(() => {
|
|
141
|
+
setCompletedAlert(null);
|
|
142
|
+
}, 2500);
|
|
143
|
+
}
|
|
144
|
+
previousStatusRef.current = model.status.kind;
|
|
145
|
+
}, [model.status.kind, model.activeBranch]);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
return () => {
|
|
148
|
+
if (alertTimeoutRef.current !== null) {
|
|
149
|
+
clearTimeout(alertTimeoutRef.current);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}, []);
|
|
153
|
+
const confirmationOpen = pendingDelete !== null;
|
|
154
|
+
const visibleStatus = confirmationOpen
|
|
155
|
+
? { kind: 'idle', message: `Delete ${pendingDelete.branch}? d/y confirm, Esc/n/q cancel` }
|
|
156
|
+
: model.status;
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!shouldRefreshLogs(model)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Only logs and active-session liveness need near-real-time updates.
|
|
162
|
+
// Full worktree metadata includes GitHub PR lookups and is refreshed by
|
|
163
|
+
// explicit user actions instead of a tight polling loop.
|
|
164
|
+
const logRefreshInterval = setInterval(() => {
|
|
165
|
+
if (userActionInFlightRef.current || logRefreshInFlightRef.current) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const generation = actionGenerationRef.current;
|
|
169
|
+
logRefreshInFlightRef.current = true;
|
|
170
|
+
void actions.refreshLogs()
|
|
171
|
+
.then(refresh => {
|
|
172
|
+
if (generation !== actionGenerationRef.current || userActionInFlightRef.current) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
setModel(current => {
|
|
176
|
+
const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
|
|
177
|
+
const status = getStatusAfterLogRefresh(current, refresh);
|
|
178
|
+
return {
|
|
179
|
+
...current,
|
|
180
|
+
logs: refresh.logs,
|
|
181
|
+
activePath: refresh.activePath,
|
|
182
|
+
activeBranch: refresh.activeBranch,
|
|
183
|
+
status,
|
|
184
|
+
rows: activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
})
|
|
188
|
+
.catch(() => { })
|
|
189
|
+
.finally(() => {
|
|
190
|
+
logRefreshInFlightRef.current = false;
|
|
191
|
+
});
|
|
192
|
+
}, 400);
|
|
193
|
+
return () => {
|
|
194
|
+
clearInterval(logRefreshInterval);
|
|
195
|
+
};
|
|
196
|
+
}, [actions, model.activePath, model.status.kind, model.status.message]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (confirmationOpen && !model.rows.some(row => row.path === pendingDelete.path)) {
|
|
199
|
+
setPendingDelete(null);
|
|
51
200
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}, [model.rows, selectedPath]);
|
|
201
|
+
}, [confirmationOpen, model.rows, pendingDelete]);
|
|
202
|
+
const selectedIndex = useMemo(() => getSelectedIndex(model.rows, selectedPath), [model.rows, selectedPath]);
|
|
55
203
|
const selected = model.rows[selectedIndex];
|
|
56
204
|
const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
|
|
57
205
|
const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
|
|
58
206
|
const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
|
|
59
207
|
const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
|
|
60
|
-
const compactDetailPane = !stackedLayout && rootHeight <=
|
|
208
|
+
const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
|
|
209
|
+
const showLogPanel = !stackedLayout && rootHeight >= 34;
|
|
210
|
+
const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
|
|
211
|
+
const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS) / 2));
|
|
212
|
+
const paneHeight = stackedLayout
|
|
213
|
+
? stackedPaneHeight
|
|
214
|
+
: Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
|
|
215
|
+
const selectionScrollPageSize = Math.max(1, Math.floor(paneHeight / 2));
|
|
216
|
+
const logLineCount = useMemo(() => buildLogLines(model.logs).length, [model.logs]);
|
|
217
|
+
const logViewportHeight = isLogOverlayOpen
|
|
218
|
+
? Math.max(1, rootHeight - 3)
|
|
219
|
+
: showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
|
|
220
|
+
const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
|
|
221
|
+
const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
|
|
61
222
|
function moveSelection(nextIndex) {
|
|
62
|
-
|
|
223
|
+
const clampedIndex = clampSelectionIndex(nextIndex, model.rows.length);
|
|
224
|
+
if (clampedIndex === null) {
|
|
63
225
|
return;
|
|
64
226
|
}
|
|
65
|
-
setSelectedPath(model.rows[
|
|
227
|
+
setSelectedPath(model.rows[clampedIndex].path);
|
|
228
|
+
}
|
|
229
|
+
function clearTransientAlert() {
|
|
230
|
+
if (alertTimeoutRef.current !== null) {
|
|
231
|
+
clearTimeout(alertTimeoutRef.current);
|
|
232
|
+
alertTimeoutRef.current = null;
|
|
233
|
+
}
|
|
234
|
+
setCompletedAlert(null);
|
|
235
|
+
}
|
|
236
|
+
function invalidateStaleLogRefreshes() {
|
|
237
|
+
actionGenerationRef.current += 1;
|
|
66
238
|
}
|
|
67
239
|
async function apply(action) {
|
|
68
|
-
|
|
240
|
+
invalidateStaleLogRefreshes();
|
|
241
|
+
userActionInFlightRef.current = true;
|
|
69
242
|
try {
|
|
70
|
-
|
|
71
|
-
setModel(next);
|
|
243
|
+
setModel(await action());
|
|
72
244
|
}
|
|
73
245
|
catch (error) {
|
|
74
246
|
setModel(current => ({
|
|
@@ -80,10 +252,65 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
80
252
|
}));
|
|
81
253
|
}
|
|
82
254
|
finally {
|
|
83
|
-
|
|
255
|
+
userActionInFlightRef.current = false;
|
|
84
256
|
}
|
|
85
257
|
}
|
|
86
258
|
useInput((input, key) => {
|
|
259
|
+
if (isHelpOverlayOpen) {
|
|
260
|
+
if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
|
|
261
|
+
setIsHelpOverlayOpen(false);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (isLogOverlayOpen) {
|
|
266
|
+
if (input === '?') {
|
|
267
|
+
setIsHelpOverlayOpen(true);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (key.escape || input === 'q' || input === 'L') {
|
|
271
|
+
setIsLogOverlayOpen(false);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (key.upArrow || input === 'k') {
|
|
275
|
+
setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + 1));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (key.downArrow || input === 'j') {
|
|
279
|
+
setLogScrollOffset(current => Math.max(0, current - 1));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (input === 'g') {
|
|
283
|
+
setLogScrollOffset(maxLogScrollOffset);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (input === 'G') {
|
|
287
|
+
setLogScrollOffset(0);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (input === '[' || key.pageUp) {
|
|
291
|
+
setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (input === ']' || key.pageDown) {
|
|
295
|
+
setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (confirmationOpen) {
|
|
301
|
+
if (key.escape || input === '\u001B' || input === 'q' || input === 'n') {
|
|
302
|
+
setPendingDelete(null);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (input === 'd' || input === 'y') {
|
|
306
|
+
const { path: worktreePath } = pendingDelete;
|
|
307
|
+
setPendingDelete(null);
|
|
308
|
+
clearTransientAlert();
|
|
309
|
+
void apply(() => actions.deleteWorktree(worktreePath));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
87
314
|
if (key.escape || input === 'q') {
|
|
88
315
|
exit();
|
|
89
316
|
return;
|
|
@@ -104,36 +331,225 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
104
331
|
moveSelection(model.rows.length - 1);
|
|
105
332
|
return;
|
|
106
333
|
}
|
|
107
|
-
if (
|
|
334
|
+
if (input === '?') {
|
|
335
|
+
setIsHelpOverlayOpen(true);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (input === 'L') {
|
|
339
|
+
setIsLogOverlayOpen(true);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (input === ']') {
|
|
343
|
+
setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (input === '[') {
|
|
347
|
+
setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (key.pageDown) {
|
|
351
|
+
setSelectionScrollOffset(current => current + selectionScrollPageSize);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (key.pageUp) {
|
|
355
|
+
setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (userActionInFlightRef.current) {
|
|
108
359
|
return;
|
|
109
360
|
}
|
|
110
|
-
if (key.return
|
|
111
|
-
|
|
112
|
-
|
|
361
|
+
if (key.return) {
|
|
362
|
+
const decision = decideEnterInteraction(selected, model.activePath);
|
|
363
|
+
if (decision.kind === 'ignore') {
|
|
113
364
|
return;
|
|
114
365
|
}
|
|
115
|
-
if (
|
|
116
|
-
|
|
366
|
+
if (decision.kind === 'set-status') {
|
|
367
|
+
if (decision.suppressesBackgroundRefreshes) {
|
|
368
|
+
invalidateStaleLogRefreshes();
|
|
369
|
+
}
|
|
370
|
+
setModel(current => ({ ...current, status: decision.status }));
|
|
371
|
+
clearTransientAlert();
|
|
117
372
|
return;
|
|
118
373
|
}
|
|
119
|
-
setModel(current => ({ ...current, status:
|
|
120
|
-
|
|
374
|
+
setModel(current => ({ ...current, status: decision.status }));
|
|
375
|
+
clearTransientAlert();
|
|
376
|
+
void apply(() => actions.start(decision.path));
|
|
121
377
|
return;
|
|
122
378
|
}
|
|
123
379
|
if (input === 's') {
|
|
124
380
|
setModel(current => ({ ...current, status: { kind: 'stopping', message: 'Stopping active session...' } }));
|
|
381
|
+
clearTransientAlert();
|
|
125
382
|
void apply(() => actions.stop());
|
|
126
383
|
return;
|
|
127
384
|
}
|
|
385
|
+
if (input === 'i') {
|
|
386
|
+
const decision = decideSetupInteraction(selected, model.setupAvailable);
|
|
387
|
+
if (decision.kind === 'ignore') {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
setModel(current => ({ ...current, status: decision.status }));
|
|
391
|
+
clearTransientAlert();
|
|
392
|
+
void apply(() => actions.setup(decision.path));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (input === 'e' && selected && model.editorAvailable) {
|
|
396
|
+
clearTransientAlert();
|
|
397
|
+
void apply(() => actions.openEditor(selected.path));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (input === 'o' && selected) {
|
|
401
|
+
clearTransientAlert();
|
|
402
|
+
void apply(() => actions.openPullRequest(selected.path));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (input === 'd' && selected) {
|
|
406
|
+
clearTransientAlert();
|
|
407
|
+
setPendingDelete({ path: selected.path, branch: selected.branch });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
128
410
|
if (input === 'r') {
|
|
411
|
+
clearTransientAlert();
|
|
129
412
|
void apply(() => actions.refresh());
|
|
130
413
|
}
|
|
131
414
|
});
|
|
415
|
+
const listPaneViewportHeight = paneHeight === undefined ? undefined : Math.max(1, paneHeight - 3);
|
|
416
|
+
const mouseWheelLineStep = 3;
|
|
417
|
+
const paneAreaLeft = 1;
|
|
418
|
+
const worktreePaneRight = !stackedLayout ? paneAreaLeft + listWidth - 1 : undefined;
|
|
419
|
+
const selectionPaneLeft = !stackedLayout && worktreePaneRight !== undefined ? worktreePaneRight + PANE_GAP_WIDTH + 1 : undefined;
|
|
420
|
+
const bodyPaneTop = !stackedLayout && paneHeight !== undefined ? HEADER_HEIGHT + 1 : undefined;
|
|
421
|
+
const bodyPaneBottom = !stackedLayout && bodyPaneTop !== undefined && paneHeight !== undefined ? bodyPaneTop + paneHeight - 1 : undefined;
|
|
422
|
+
const stackedWorktreePaneTop = stackedLayout && paneHeight !== undefined ? HEADER_HEIGHT + 1 : undefined;
|
|
423
|
+
const stackedWorktreePaneBottom = stackedLayout && stackedWorktreePaneTop !== undefined && paneHeight !== undefined ? stackedWorktreePaneTop + paneHeight - 1 : undefined;
|
|
424
|
+
const stackedSelectionPaneTop = stackedLayout && stackedWorktreePaneBottom !== undefined ? stackedWorktreePaneBottom + 1 : undefined;
|
|
425
|
+
const stackedSelectionPaneBottom = stackedLayout && stackedSelectionPaneTop !== undefined && paneHeight !== undefined ? stackedSelectionPaneTop + paneHeight - 1 : undefined;
|
|
426
|
+
const logPaneTop = showLogPanel ? rootHeight - CONTEXT_BAR_HEIGHT - logPaneHeight + 1 : undefined;
|
|
427
|
+
const logPaneBottom = showLogPanel ? rootHeight - CONTEXT_BAR_HEIGHT : undefined;
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
const onData = (data) => {
|
|
430
|
+
if (isHelpOverlayOpen) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const events = parseMouseWheelEvents(typeof data === 'string' ? data : data.toString('utf8'));
|
|
434
|
+
for (const event of events) {
|
|
435
|
+
if (isLogOverlayOpen) {
|
|
436
|
+
setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const isLogPaneEvent = !isLogOverlayOpen
|
|
440
|
+
&& showLogPanel
|
|
441
|
+
&& event.y !== undefined
|
|
442
|
+
&& logPaneTop !== undefined
|
|
443
|
+
&& logPaneBottom !== undefined
|
|
444
|
+
&& event.y >= logPaneTop
|
|
445
|
+
&& event.y <= logPaneBottom;
|
|
446
|
+
if (isLogPaneEvent) {
|
|
447
|
+
setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const scrollWorktrees = () => {
|
|
451
|
+
setWorktreeScrollOffset(current => {
|
|
452
|
+
if (listPaneViewportHeight === undefined) {
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
const max = Math.max(0, model.rows.length - listPaneViewportHeight);
|
|
456
|
+
return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
if (stackedLayout) {
|
|
460
|
+
const isStackedWorktreeEvent = stackedWorktreePaneTop !== undefined
|
|
461
|
+
&& stackedWorktreePaneBottom !== undefined
|
|
462
|
+
&& event.y !== undefined
|
|
463
|
+
&& event.y >= stackedWorktreePaneTop
|
|
464
|
+
&& event.y <= stackedWorktreePaneBottom;
|
|
465
|
+
if (isStackedWorktreeEvent) {
|
|
466
|
+
scrollWorktrees();
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const isStackedSelectionEvent = stackedSelectionPaneTop !== undefined
|
|
470
|
+
&& stackedSelectionPaneBottom !== undefined
|
|
471
|
+
&& event.y !== undefined
|
|
472
|
+
&& event.y >= stackedSelectionPaneTop
|
|
473
|
+
&& event.y <= stackedSelectionPaneBottom;
|
|
474
|
+
if (isStackedSelectionEvent) {
|
|
475
|
+
setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const isBodyPaneEvent = bodyPaneTop !== undefined
|
|
480
|
+
&& bodyPaneBottom !== undefined
|
|
481
|
+
&& event.y !== undefined
|
|
482
|
+
&& event.y >= bodyPaneTop
|
|
483
|
+
&& event.y <= bodyPaneBottom;
|
|
484
|
+
if (!isBodyPaneEvent) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const isSelectionPaneEvent = selectionPaneLeft !== undefined
|
|
488
|
+
&& event.x !== undefined
|
|
489
|
+
&& event.x >= selectionPaneLeft;
|
|
490
|
+
if (isSelectionPaneEvent) {
|
|
491
|
+
setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const isWorktreePaneEvent = event.x === undefined
|
|
495
|
+
|| worktreePaneRight === undefined
|
|
496
|
+
|| event.x <= worktreePaneRight;
|
|
497
|
+
if (isWorktreePaneEvent) {
|
|
498
|
+
scrollWorktrees();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
if (stdout.isTTY) {
|
|
503
|
+
stdout.write(ENABLE_MOUSE_TRACKING);
|
|
504
|
+
}
|
|
505
|
+
stdin.on('data', onData);
|
|
506
|
+
return () => {
|
|
507
|
+
stdin.off('data', onData);
|
|
508
|
+
if (stdout.isTTY) {
|
|
509
|
+
stdout.write(DISABLE_MOUSE_TRACKING);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
if (listPaneViewportHeight === undefined) {
|
|
515
|
+
setWorktreeScrollOffset(0);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
setWorktreeScrollOffset(current => {
|
|
519
|
+
const max = Math.max(0, model.rows.length - listPaneViewportHeight);
|
|
520
|
+
if (selectedIndex < current) {
|
|
521
|
+
return Math.max(0, selectedIndex);
|
|
522
|
+
}
|
|
523
|
+
if (selectedIndex >= current + listPaneViewportHeight) {
|
|
524
|
+
return Math.max(0, Math.min(max, selectedIndex - listPaneViewportHeight + 1));
|
|
525
|
+
}
|
|
526
|
+
return Math.min(current, max);
|
|
527
|
+
});
|
|
528
|
+
}, [selectedIndex, listPaneViewportHeight, model.rows.length]);
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
if (!showLogPanel && !isLogOverlayOpen) {
|
|
531
|
+
setLogScrollOffset(0);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
setLogScrollOffset(current => Math.min(current, maxLogScrollOffset));
|
|
535
|
+
}, [showLogPanel, isLogOverlayOpen, maxLogScrollOffset]);
|
|
536
|
+
if (isHelpOverlayOpen) {
|
|
537
|
+
return (_jsx(HelpWindow, { setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, width: Math.max(1, rootWidth - 1), height: rootHeight }));
|
|
538
|
+
}
|
|
539
|
+
if (isLogOverlayOpen) {
|
|
540
|
+
return (_jsx(FloatingLogWindow, { logs: model.logs, width: Math.max(1, rootWidth - 1), height: rootHeight, scrollOffset: logScrollOffset }));
|
|
541
|
+
}
|
|
132
542
|
if (minimalLayout) {
|
|
133
|
-
return (_jsxs(Box, { width: rootWidth, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ?
|
|
543
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsx(Text, { wrap: "truncate-end", children: confirmationOpen ? `D:${visibleStatus.message}` : `T:${model.status.kind}` }) : null, rootHeight >= 4 ? (confirmationOpen
|
|
544
|
+
? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "d/y confirm \u00B7 Esc/n/q cancel" })
|
|
545
|
+
: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
|
|
134
546
|
}
|
|
135
547
|
if (compactLayout) {
|
|
136
|
-
return (_jsxs(Box, { width: rootWidth,
|
|
548
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), completedAlert
|
|
549
|
+
? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", completedAlert] })
|
|
550
|
+
: model.status.kind === 'setting-up' || model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${model.status.message}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", visibleStatus.kind, " \u2014 ", visibleStatus.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: confirmationOpen
|
|
551
|
+
? 'Keys: d/y confirm | Esc/n/q cancel'
|
|
552
|
+
: `Keys: ↑↓/jk g/G ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
|
|
137
553
|
}
|
|
138
|
-
return (_jsxs(Box, { width: rootWidth, height:
|
|
554
|
+
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 }), completedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: completedAlert }) })) : null] }));
|
|
139
555
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { AppRow } from '../core/runtime.js';
|
|
2
|
+
import { type ProjectionSeverity } from '../core/worktree-projection.js';
|
|
3
|
+
export declare function getActionVariant(selectedRow: AppRow, activePath: string | null): ProjectionSeverity;
|
|
2
4
|
export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }: {
|
|
5
|
+
export declare function ActionPanel({ selectedRow, activePath, setupAvailable, stacked, width, height, compactDetails, scrollOffset, }: {
|
|
5
6
|
selectedRow: AppRow | undefined;
|
|
6
7
|
activePath: string | null;
|
|
8
|
+
setupAvailable: boolean;
|
|
7
9
|
stacked: boolean;
|
|
8
10
|
width?: number;
|
|
11
|
+
height?: number;
|
|
9
12
|
compactDetails?: boolean;
|
|
13
|
+
scrollOffset?: number;
|
|
10
14
|
}): import("react").JSX.Element;
|