@ohzw/worktree-command-tui 0.1.0 → 0.1.1
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/dist/app.d.ts +2 -1
- package/dist/app.js +318 -24
- package/dist/components/ActionPanel.d.ts +4 -2
- package/dist/components/ActionPanel.js +133 -26
- package/dist/components/ContextBar.js +12 -3
- package/dist/components/FloatingLogWindow.d.ts +7 -0
- package/dist/components/FloatingLogWindow.js +5 -0
- package/dist/components/LogPanel.d.ts +15 -0
- package/dist/components/LogPanel.js +54 -0
- package/dist/components/WorktreeList.d.ts +3 -1
- package/dist/components/WorktreeList.js +19 -4
- package/dist/core/runtime.d.ts +7 -0
- package/dist/core/runtime.js +48 -1
- 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/ui-theme.d.ts +3 -0
- package/dist/ui-theme.js +38 -0
- package/package.json +2 -1
package/dist/app.d.ts
CHANGED
|
@@ -10,8 +10,9 @@ 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
17
|
export declare function shouldStackPanes(columns: number, rows: number, worktreeCount?: number): boolean;
|
|
17
18
|
export declare function App({ initialModel, actions, windowSizeOverride, }: {
|
package/dist/app.js
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
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 { FloatingLogWindow } from './components/FloatingLogWindow.js';
|
|
9
|
+
import { LogPanel, buildLogLines } from './components/LogPanel.js';
|
|
7
10
|
import { WorktreeList } from './components/WorktreeList.js';
|
|
11
|
+
const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
|
|
12
|
+
const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
|
|
13
|
+
function parseMouseWheelEvents(input) {
|
|
14
|
+
const events = [];
|
|
15
|
+
const sgrMousePattern = /\u001B\[<(\d+);(\d+);(\d+)[mM]/g;
|
|
16
|
+
for (const match of input.matchAll(sgrMousePattern)) {
|
|
17
|
+
const button = Number(match[1]);
|
|
18
|
+
if (button !== 64 && button !== 65) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const x = Number(match[2]);
|
|
22
|
+
const y = Number(match[3]);
|
|
23
|
+
events.push({
|
|
24
|
+
delta: button === 65 ? 1 : -1,
|
|
25
|
+
x: Number.isFinite(x) ? x : undefined,
|
|
26
|
+
y: Number.isFinite(y) ? y : undefined,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return events;
|
|
30
|
+
}
|
|
31
|
+
export function getMouseWheelDelta(input) {
|
|
32
|
+
return parseMouseWheelEvents(input).reduce((sum, event) => sum + event.delta, 0);
|
|
33
|
+
}
|
|
8
34
|
function getNextSelectedPath(rows, currentPath) {
|
|
9
35
|
if (rows.length === 0) {
|
|
10
36
|
return null;
|
|
@@ -24,27 +50,102 @@ export function getShellDimensions(columns, rows) {
|
|
|
24
50
|
const actionWidth = Math.max(1, bodyWidth - listWidth - 1);
|
|
25
51
|
return { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth };
|
|
26
52
|
}
|
|
27
|
-
export function shouldUseCompactLayout(
|
|
28
|
-
|
|
29
|
-
return columns < 72 || rows <= contentAwareRowFloor || (columns < 96 && rows < 24);
|
|
53
|
+
export function shouldUseCompactLayout(_columns, _rows, _worktreeCount = 0) {
|
|
54
|
+
return false;
|
|
30
55
|
}
|
|
31
56
|
export function shouldUseMinimalLayout(columns, rows) {
|
|
32
|
-
return columns < 20 || rows <
|
|
57
|
+
return columns < 20 || rows < 10;
|
|
33
58
|
}
|
|
34
59
|
export function shouldStackPanes(columns, rows, worktreeCount = 0) {
|
|
35
|
-
|
|
60
|
+
// Stacked panes are taller than split panes. Only use them when the full frame can fit the viewport.
|
|
61
|
+
const minimumRows = Math.max(36, worktreeCount + 34);
|
|
36
62
|
return columns < 96 && rows >= minimumRows;
|
|
37
63
|
}
|
|
64
|
+
function getLogPaneHeight(_rootHeight) {
|
|
65
|
+
// Outer pane height. With border + title, 9 rows gives ~6 visible log lines.
|
|
66
|
+
return 9;
|
|
67
|
+
}
|
|
38
68
|
export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
39
69
|
const { exit } = useApp();
|
|
70
|
+
const { stdin } = useStdin();
|
|
71
|
+
const { stdout } = useStdout();
|
|
40
72
|
const liveWindowSize = useWindowSize();
|
|
41
73
|
const { columns, rows } = windowSizeOverride ?? liveWindowSize;
|
|
42
74
|
const [model, setModel] = useState(initialModel);
|
|
43
75
|
const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
|
|
44
|
-
const
|
|
76
|
+
const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
|
|
77
|
+
const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
|
|
78
|
+
const [logScrollOffset, setLogScrollOffset] = useState(0);
|
|
79
|
+
const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
|
|
80
|
+
const [completedAlert, setCompletedAlert] = useState(null);
|
|
81
|
+
const userActionInFlightRef = useRef(false);
|
|
82
|
+
const backgroundRefreshInFlightRef = useRef(false);
|
|
83
|
+
const actionGenerationRef = useRef(0);
|
|
84
|
+
const logRefreshInFlightRef = useRef(false);
|
|
85
|
+
const previousStatusRef = useRef(initialModel.status.kind);
|
|
86
|
+
const alertTimeoutRef = useRef(null);
|
|
45
87
|
useEffect(() => {
|
|
46
88
|
setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
|
|
47
89
|
}, [model.rows]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setSelectionScrollOffset(0);
|
|
92
|
+
}, [selectedPath]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const becameRunning = previousStatusRef.current === 'starting' && model.status.kind === 'running';
|
|
95
|
+
if (becameRunning) {
|
|
96
|
+
setCompletedAlert(model.activeBranch ? `Switched to ${model.activeBranch}` : 'Worktree switch complete.');
|
|
97
|
+
if (alertTimeoutRef.current !== null) {
|
|
98
|
+
clearTimeout(alertTimeoutRef.current);
|
|
99
|
+
}
|
|
100
|
+
alertTimeoutRef.current = setTimeout(() => {
|
|
101
|
+
setCompletedAlert(null);
|
|
102
|
+
}, 2500);
|
|
103
|
+
}
|
|
104
|
+
previousStatusRef.current = model.status.kind;
|
|
105
|
+
}, [model.status.kind, model.activeBranch]);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
return () => {
|
|
108
|
+
if (alertTimeoutRef.current !== null) {
|
|
109
|
+
clearTimeout(alertTimeoutRef.current);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
return () => {
|
|
115
|
+
if (alertTimeoutRef.current !== null) {
|
|
116
|
+
clearTimeout(alertTimeoutRef.current);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}, []);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (model.status.kind !== 'running') {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const fullRefreshInterval = setInterval(() => {
|
|
125
|
+
if (userActionInFlightRef.current || backgroundRefreshInFlightRef.current) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
void apply(() => actions.refresh(), { blocksInput: false });
|
|
129
|
+
}, 1500);
|
|
130
|
+
const logRefreshInterval = setInterval(() => {
|
|
131
|
+
if (userActionInFlightRef.current || backgroundRefreshInFlightRef.current || logRefreshInFlightRef.current) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
logRefreshInFlightRef.current = true;
|
|
135
|
+
void actions.refreshLogs()
|
|
136
|
+
.then(logs => {
|
|
137
|
+
setModel(current => ({ ...current, logs }));
|
|
138
|
+
})
|
|
139
|
+
.catch(() => { })
|
|
140
|
+
.finally(() => {
|
|
141
|
+
logRefreshInFlightRef.current = false;
|
|
142
|
+
});
|
|
143
|
+
}, 400);
|
|
144
|
+
return () => {
|
|
145
|
+
clearInterval(fullRefreshInterval);
|
|
146
|
+
clearInterval(logRefreshInterval);
|
|
147
|
+
};
|
|
148
|
+
}, [actions, model.status.kind]);
|
|
48
149
|
const selectedIndex = useMemo(() => {
|
|
49
150
|
if (selectedPath === null) {
|
|
50
151
|
return 0;
|
|
@@ -57,33 +158,103 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
57
158
|
const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
|
|
58
159
|
const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
|
|
59
160
|
const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
|
|
60
|
-
const compactDetailPane = !stackedLayout && rootHeight <=
|
|
161
|
+
const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
|
|
162
|
+
const showLogPanel = !stackedLayout && rootHeight >= 34;
|
|
163
|
+
const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
|
|
164
|
+
const paneHeight = stackedLayout
|
|
165
|
+
? undefined
|
|
166
|
+
: Math.max(3, rootHeight - 11 - logPaneHeight);
|
|
167
|
+
const selectionScrollPageSize = Math.max(1, Math.floor((paneHeight ?? rootHeight) / 2));
|
|
168
|
+
const logLineCount = useMemo(() => buildLogLines(model.logs).length, [model.logs]);
|
|
169
|
+
const logViewportHeight = isLogOverlayOpen
|
|
170
|
+
? Math.max(1, rootHeight - 3)
|
|
171
|
+
: showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
|
|
172
|
+
const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
|
|
173
|
+
const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
|
|
61
174
|
function moveSelection(nextIndex) {
|
|
62
175
|
if (model.rows.length === 0) {
|
|
63
176
|
return;
|
|
64
177
|
}
|
|
65
178
|
setSelectedPath(model.rows[Math.min(Math.max(nextIndex, 0), model.rows.length - 1)].path);
|
|
66
179
|
}
|
|
67
|
-
|
|
68
|
-
|
|
180
|
+
function clearTransientAlert() {
|
|
181
|
+
if (alertTimeoutRef.current !== null) {
|
|
182
|
+
clearTimeout(alertTimeoutRef.current);
|
|
183
|
+
alertTimeoutRef.current = null;
|
|
184
|
+
}
|
|
185
|
+
setCompletedAlert(null);
|
|
186
|
+
}
|
|
187
|
+
function invalidateBackgroundRefreshes() {
|
|
188
|
+
actionGenerationRef.current += 1;
|
|
189
|
+
}
|
|
190
|
+
async function apply(action, options = {}) {
|
|
191
|
+
const blocksInput = options.blocksInput ?? true;
|
|
192
|
+
const generation = blocksInput ? actionGenerationRef.current + 1 : actionGenerationRef.current;
|
|
193
|
+
if (blocksInput) {
|
|
194
|
+
actionGenerationRef.current = generation;
|
|
195
|
+
userActionInFlightRef.current = true;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
backgroundRefreshInFlightRef.current = true;
|
|
199
|
+
}
|
|
69
200
|
try {
|
|
70
201
|
const next = await action();
|
|
71
|
-
|
|
202
|
+
if (blocksInput || (generation === actionGenerationRef.current && !userActionInFlightRef.current)) {
|
|
203
|
+
setModel(next);
|
|
204
|
+
}
|
|
72
205
|
}
|
|
73
206
|
catch (error) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
207
|
+
if (blocksInput || (generation === actionGenerationRef.current && !userActionInFlightRef.current)) {
|
|
208
|
+
setModel(current => ({
|
|
209
|
+
...current,
|
|
210
|
+
status: {
|
|
211
|
+
kind: 'error',
|
|
212
|
+
message: error instanceof Error ? error.message : String(error),
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
81
216
|
}
|
|
82
217
|
finally {
|
|
83
|
-
|
|
218
|
+
if (blocksInput) {
|
|
219
|
+
userActionInFlightRef.current = false;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
backgroundRefreshInFlightRef.current = false;
|
|
223
|
+
}
|
|
84
224
|
}
|
|
85
225
|
}
|
|
86
226
|
useInput((input, key) => {
|
|
227
|
+
if (isLogOverlayOpen) {
|
|
228
|
+
if (key.escape || input === 'q' || input === 'L') {
|
|
229
|
+
setIsLogOverlayOpen(false);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (key.upArrow || input === 'k') {
|
|
233
|
+
setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + 1));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (key.downArrow || input === 'j') {
|
|
237
|
+
setLogScrollOffset(current => Math.max(0, current - 1));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (input === 'g') {
|
|
241
|
+
setLogScrollOffset(maxLogScrollOffset);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (input === 'G') {
|
|
245
|
+
setLogScrollOffset(0);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (input === '[' || key.pageUp) {
|
|
249
|
+
setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (input === ']' || key.pageDown) {
|
|
253
|
+
setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
87
258
|
if (key.escape || input === 'q') {
|
|
88
259
|
exit();
|
|
89
260
|
return;
|
|
@@ -104,36 +275,159 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
|
104
275
|
moveSelection(model.rows.length - 1);
|
|
105
276
|
return;
|
|
106
277
|
}
|
|
107
|
-
if (
|
|
278
|
+
if (input === 'L') {
|
|
279
|
+
setIsLogOverlayOpen(true);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (input === ']') {
|
|
283
|
+
setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (input === '[') {
|
|
287
|
+
setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (key.pageDown) {
|
|
291
|
+
setSelectionScrollOffset(current => current + selectionScrollPageSize);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (key.pageUp) {
|
|
295
|
+
setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (userActionInFlightRef.current) {
|
|
108
299
|
return;
|
|
109
300
|
}
|
|
110
301
|
if (key.return && selected) {
|
|
111
302
|
if (selected.invalidReason) {
|
|
303
|
+
invalidateBackgroundRefreshes();
|
|
112
304
|
setModel(current => ({ ...current, status: { kind: 'error', message: selected.invalidReason } }));
|
|
305
|
+
clearTransientAlert();
|
|
113
306
|
return;
|
|
114
307
|
}
|
|
115
308
|
if (selected.path === model.activePath) {
|
|
309
|
+
invalidateBackgroundRefreshes();
|
|
116
310
|
setModel(current => ({ ...current, status: { kind: 'idle', message: 'already active' } }));
|
|
311
|
+
clearTransientAlert();
|
|
117
312
|
return;
|
|
118
313
|
}
|
|
119
314
|
setModel(current => ({ ...current, status: { kind: 'starting', message: `Starting ${selected.branch}...` } }));
|
|
315
|
+
clearTransientAlert();
|
|
120
316
|
void apply(() => actions.start(selected.path));
|
|
121
317
|
return;
|
|
122
318
|
}
|
|
123
319
|
if (input === 's') {
|
|
124
320
|
setModel(current => ({ ...current, status: { kind: 'stopping', message: 'Stopping active session...' } }));
|
|
321
|
+
clearTransientAlert();
|
|
125
322
|
void apply(() => actions.stop());
|
|
126
323
|
return;
|
|
127
324
|
}
|
|
128
325
|
if (input === 'r') {
|
|
326
|
+
clearTransientAlert();
|
|
129
327
|
void apply(() => actions.refresh());
|
|
130
328
|
}
|
|
131
329
|
});
|
|
330
|
+
const listPaneViewportHeight = paneHeight === undefined ? undefined : Math.max(1, paneHeight - 3);
|
|
331
|
+
const mouseWheelLineStep = 3;
|
|
332
|
+
const paneAreaLeft = 3;
|
|
333
|
+
const worktreePaneRight = !stackedLayout ? paneAreaLeft + listWidth - 1 : undefined;
|
|
334
|
+
const selectionPaneLeft = !stackedLayout && worktreePaneRight !== undefined ? worktreePaneRight + 2 : undefined;
|
|
335
|
+
const bodyPaneTop = !stackedLayout && paneHeight !== undefined ? 7 : undefined;
|
|
336
|
+
const bodyPaneBottom = !stackedLayout && bodyPaneTop !== undefined && paneHeight !== undefined ? bodyPaneTop + paneHeight - 1 : undefined;
|
|
337
|
+
const logPaneTop = showLogPanel ? rootHeight - 5 - logPaneHeight + 1 : undefined;
|
|
338
|
+
const logPaneBottom = showLogPanel ? rootHeight - 5 : undefined;
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
const onData = (data) => {
|
|
341
|
+
const events = parseMouseWheelEvents(typeof data === 'string' ? data : data.toString('utf8'));
|
|
342
|
+
for (const event of events) {
|
|
343
|
+
if (isLogOverlayOpen) {
|
|
344
|
+
setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const isLogPaneEvent = !isLogOverlayOpen
|
|
348
|
+
&& showLogPanel
|
|
349
|
+
&& event.y !== undefined
|
|
350
|
+
&& logPaneTop !== undefined
|
|
351
|
+
&& logPaneBottom !== undefined
|
|
352
|
+
&& event.y >= logPaneTop
|
|
353
|
+
&& event.y <= logPaneBottom;
|
|
354
|
+
if (isLogPaneEvent) {
|
|
355
|
+
setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const isBodyPaneEvent = bodyPaneTop !== undefined
|
|
359
|
+
&& bodyPaneBottom !== undefined
|
|
360
|
+
&& event.y !== undefined
|
|
361
|
+
&& event.y >= bodyPaneTop
|
|
362
|
+
&& event.y <= bodyPaneBottom;
|
|
363
|
+
const isSelectionPaneEvent = isBodyPaneEvent
|
|
364
|
+
&& selectionPaneLeft !== undefined
|
|
365
|
+
&& event.x !== undefined
|
|
366
|
+
&& event.x >= selectionPaneLeft;
|
|
367
|
+
if (isSelectionPaneEvent) {
|
|
368
|
+
setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const shouldScrollWorktrees = !stackedLayout
|
|
372
|
+
&& (event.x === undefined || worktreePaneRight === undefined || event.x <= worktreePaneRight);
|
|
373
|
+
if (shouldScrollWorktrees) {
|
|
374
|
+
setWorktreeScrollOffset(current => {
|
|
375
|
+
if (listPaneViewportHeight === undefined) {
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
const max = Math.max(0, model.rows.length - listPaneViewportHeight);
|
|
379
|
+
return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
if (stdout.isTTY) {
|
|
388
|
+
stdout.write(ENABLE_MOUSE_TRACKING);
|
|
389
|
+
}
|
|
390
|
+
stdin.on('data', onData);
|
|
391
|
+
return () => {
|
|
392
|
+
stdin.off('data', onData);
|
|
393
|
+
if (stdout.isTTY) {
|
|
394
|
+
stdout.write(DISABLE_MOUSE_TRACKING);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, isLogOverlayOpen]);
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (listPaneViewportHeight === undefined) {
|
|
400
|
+
setWorktreeScrollOffset(0);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
setWorktreeScrollOffset(current => {
|
|
404
|
+
const max = Math.max(0, model.rows.length - listPaneViewportHeight);
|
|
405
|
+
if (selectedIndex < current) {
|
|
406
|
+
return Math.max(0, selectedIndex);
|
|
407
|
+
}
|
|
408
|
+
if (selectedIndex >= current + listPaneViewportHeight) {
|
|
409
|
+
return Math.max(0, Math.min(max, selectedIndex - listPaneViewportHeight + 1));
|
|
410
|
+
}
|
|
411
|
+
return Math.min(current, max);
|
|
412
|
+
});
|
|
413
|
+
}, [selectedIndex, listPaneViewportHeight, model.rows.length]);
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (!showLogPanel && !isLogOverlayOpen) {
|
|
416
|
+
setLogScrollOffset(0);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
setLogScrollOffset(current => Math.min(current, maxLogScrollOffset));
|
|
420
|
+
}, [showLogPanel, isLogOverlayOpen, maxLogScrollOffset]);
|
|
421
|
+
if (isLogOverlayOpen) {
|
|
422
|
+
return (_jsx(FloatingLogWindow, { logs: model.logs, width: Math.max(1, rootWidth - 1), height: rootHeight, scrollOffset: logScrollOffset }));
|
|
423
|
+
}
|
|
132
424
|
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 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\
|
|
425
|
+
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 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\u21B5Lq" }) : null] }));
|
|
134
426
|
}
|
|
135
427
|
if (compactLayout) {
|
|
136
|
-
return (_jsxs(Box, { width: rootWidth, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }),
|
|
428
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), completedAlert
|
|
429
|
+
? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", completedAlert] })
|
|
430
|
+
: 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: ", model.status.kind, " \u2014 ", model.status.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk g/G \u21B5 L s r q \u00B7 Resize terminal for split view" })] }));
|
|
137
431
|
}
|
|
138
|
-
return (_jsxs(Box, { width: rootWidth, height:
|
|
432
|
+
return (_jsxs(Box, { width: rootWidth, height: rootHeight, borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, 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, 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: model.status }), completedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: completedAlert }) })) : null] }));
|
|
139
433
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { AppRow } from '../core/runtime.js';
|
|
2
|
+
export declare function getActionVariant(selectedRow: AppRow, activePath: string | null): 'success' | 'error' | 'info';
|
|
2
3
|
export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }: {
|
|
4
|
+
export declare function ActionPanel({ selectedRow, activePath, stacked, width, height, compactDetails, scrollOffset, }: {
|
|
5
5
|
selectedRow: AppRow | undefined;
|
|
6
6
|
activePath: string | null;
|
|
7
7
|
stacked: boolean;
|
|
8
8
|
width?: number;
|
|
9
|
+
height?: number;
|
|
9
10
|
compactDetails?: boolean;
|
|
11
|
+
scrollOffset?: number;
|
|
10
12
|
}): import("react").JSX.Element;
|
|
@@ -1,7 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
function
|
|
4
|
-
|
|
3
|
+
function getTagColor(tag) {
|
|
4
|
+
if (tag === 'active') {
|
|
5
|
+
return 'green';
|
|
6
|
+
}
|
|
7
|
+
if (tag === 'external') {
|
|
8
|
+
return 'yellow';
|
|
9
|
+
}
|
|
10
|
+
if (tag === 'main') {
|
|
11
|
+
return 'blue';
|
|
12
|
+
}
|
|
13
|
+
if (tag === 'invalid') {
|
|
14
|
+
return 'red';
|
|
15
|
+
}
|
|
16
|
+
return 'magenta';
|
|
17
|
+
}
|
|
18
|
+
export function getActionVariant(selectedRow, activePath) {
|
|
19
|
+
if (selectedRow.invalidReason) {
|
|
20
|
+
return 'error';
|
|
21
|
+
}
|
|
22
|
+
if (selectedRow.path === activePath) {
|
|
23
|
+
return 'success';
|
|
24
|
+
}
|
|
25
|
+
if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
|
|
26
|
+
return 'error';
|
|
27
|
+
}
|
|
28
|
+
if ((selectedRow.workingTree?.staged ?? 0) > 0
|
|
29
|
+
|| (selectedRow.workingTree?.unstaged ?? 0) > 0
|
|
30
|
+
|| (selectedRow.workingTree?.untracked ?? 0) > 0) {
|
|
31
|
+
return 'info';
|
|
32
|
+
}
|
|
33
|
+
return 'info';
|
|
34
|
+
}
|
|
35
|
+
function getNoteVariant(selectedRow) {
|
|
36
|
+
if (selectedRow.invalidReason || selectedRow.tags.includes('external')) {
|
|
37
|
+
return selectedRow.invalidReason ? 'error' : 'info';
|
|
38
|
+
}
|
|
39
|
+
if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
|
|
40
|
+
return 'error';
|
|
41
|
+
}
|
|
42
|
+
if ((selectedRow.workingTree?.staged ?? 0) > 0
|
|
43
|
+
|| (selectedRow.workingTree?.unstaged ?? 0) > 0
|
|
44
|
+
|| (selectedRow.workingTree?.untracked ?? 0) > 0) {
|
|
45
|
+
return 'info';
|
|
46
|
+
}
|
|
47
|
+
return 'info';
|
|
5
48
|
}
|
|
6
49
|
function sanitizeInlineText(value) {
|
|
7
50
|
return value
|
|
@@ -86,20 +129,6 @@ function getActionMessage(selectedRow, activePath) {
|
|
|
86
129
|
}
|
|
87
130
|
return 'Press Enter to start here and switch the active session.';
|
|
88
131
|
}
|
|
89
|
-
export function getActionColor(selectedRow) {
|
|
90
|
-
if (selectedRow.invalidReason) {
|
|
91
|
-
return 'red';
|
|
92
|
-
}
|
|
93
|
-
if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
|
|
94
|
-
return 'red';
|
|
95
|
-
}
|
|
96
|
-
if ((selectedRow.workingTree?.staged ?? 0) > 0
|
|
97
|
-
|| (selectedRow.workingTree?.unstaged ?? 0) > 0
|
|
98
|
-
|| (selectedRow.workingTree?.untracked ?? 0) > 0) {
|
|
99
|
-
return 'yellow';
|
|
100
|
-
}
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
132
|
function getNotes(selectedRow) {
|
|
104
133
|
if (selectedRow.invalidReason) {
|
|
105
134
|
return selectedRow.invalidReason;
|
|
@@ -107,23 +136,101 @@ function getNotes(selectedRow) {
|
|
|
107
136
|
if (selectedRow.tags.includes('external')) {
|
|
108
137
|
return 'External worktree managed outside the main checkout path.';
|
|
109
138
|
}
|
|
110
|
-
if (selectedRow.tags.includes('active')) {
|
|
111
|
-
return 'This worktree currently owns the running command session.';
|
|
112
|
-
}
|
|
113
139
|
return 'Ready to launch with the configured command in this worktree.';
|
|
114
140
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
141
|
+
function getOrderedTags(tags) {
|
|
142
|
+
const tagPriority = {
|
|
143
|
+
active: 0,
|
|
144
|
+
main: 1,
|
|
145
|
+
external: 2,
|
|
146
|
+
invalid: 3,
|
|
147
|
+
};
|
|
148
|
+
return [...tags].sort((a, b) => {
|
|
149
|
+
const aPriority = tagPriority[a] ?? 10;
|
|
150
|
+
const bPriority = tagPriority[b] ?? 10;
|
|
151
|
+
if (aPriority === bPriority) {
|
|
152
|
+
return a.localeCompare(b);
|
|
153
|
+
}
|
|
154
|
+
return aPriority - bPriority;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function getVariantColor(variant) {
|
|
158
|
+
if (variant === 'success') {
|
|
159
|
+
return 'green';
|
|
160
|
+
}
|
|
161
|
+
if (variant === 'error') {
|
|
162
|
+
return 'red';
|
|
163
|
+
}
|
|
164
|
+
return 'blue';
|
|
165
|
+
}
|
|
166
|
+
function getVariantIcon(variant) {
|
|
167
|
+
if (variant === 'success') {
|
|
168
|
+
return '✓';
|
|
169
|
+
}
|
|
170
|
+
if (variant === 'error') {
|
|
171
|
+
return '✘';
|
|
172
|
+
}
|
|
173
|
+
return 'ℹ';
|
|
174
|
+
}
|
|
175
|
+
function section(label) {
|
|
176
|
+
return { text: `[${label}]`, color: 'cyan', bold: true };
|
|
117
177
|
}
|
|
118
|
-
|
|
178
|
+
function divider() {
|
|
179
|
+
return { text: ' ', dimColor: true };
|
|
180
|
+
}
|
|
181
|
+
function getPanelLines(selectedRow, activePath, compactDetails) {
|
|
119
182
|
if (!selectedRow) {
|
|
120
|
-
return
|
|
183
|
+
return [{ text: 'No worktrees found.', dimColor: true }];
|
|
121
184
|
}
|
|
122
|
-
const
|
|
185
|
+
const lines = [section('Identity')];
|
|
123
186
|
const showFullPath = !compactDetails && selectedRow.shortPath !== selectedRow.path;
|
|
124
187
|
const showTags = !compactDetails;
|
|
125
188
|
const pullRequestTitle = selectedRow.pullRequest?.kind === 'found' && !compactDetails
|
|
126
189
|
? sanitizeInlineText(selectedRow.pullRequest.title)
|
|
127
190
|
: null;
|
|
128
|
-
|
|
191
|
+
lines.push({ text: `Branch: ${sanitizeInlineText(selectedRow.branch)}`, bold: true }, { text: `Path: ${sanitizeInlineText(selectedRow.shortPath)}` });
|
|
192
|
+
if (showFullPath) {
|
|
193
|
+
lines.push({ text: `Full Path: ${sanitizeInlineText(selectedRow.path)}` });
|
|
194
|
+
}
|
|
195
|
+
lines.push({ text: `HEAD: ${selectedRow.headSha || '-'}` });
|
|
196
|
+
if (showTags) {
|
|
197
|
+
for (const tag of getOrderedTags(selectedRow.tags.filter(tag => tag !== 'active'))) {
|
|
198
|
+
lines.push({ text: tag.toUpperCase(), color: getTagColor(tag) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
lines.push(divider(), section('Git / PR'), { text: `Upstream: ${formatUpstream(selectedRow)}` }, { text: `Status: ${formatWorkingTree(selectedRow)}` }, {
|
|
202
|
+
text: `${getPullRequestLabel(selectedRow)}: ${formatPullRequest(selectedRow)}`,
|
|
203
|
+
color: getPullRequestColor(selectedRow),
|
|
204
|
+
dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN',
|
|
205
|
+
});
|
|
206
|
+
if (pullRequestTitle) {
|
|
207
|
+
lines.push({ text: `${getPullRequestTitleLabel(selectedRow)}: ${pullRequestTitle}`, dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN' });
|
|
208
|
+
}
|
|
209
|
+
const actionVariant = getActionVariant(selectedRow, activePath);
|
|
210
|
+
const noteVariant = getNoteVariant(selectedRow);
|
|
211
|
+
lines.push(divider(), section('Action'), { text: `${getVariantIcon(actionVariant)} ${getActionMessage(selectedRow, activePath)}`, color: getVariantColor(actionVariant) }, section('Notes'), { text: `${getVariantIcon(noteVariant)} ${getNotes(selectedRow)}`, color: getVariantColor(noteVariant) });
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
|
|
215
|
+
if (totalLines <= viewportHeight) {
|
|
216
|
+
return new Set();
|
|
217
|
+
}
|
|
218
|
+
const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
|
|
219
|
+
const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
|
|
220
|
+
const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
|
|
221
|
+
return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
|
|
222
|
+
}
|
|
223
|
+
export function ActionPanel({ selectedRow, activePath, stacked, width, height, compactDetails, scrollOffset = 0, }) {
|
|
224
|
+
const lines = getPanelLines(selectedRow, activePath, compactDetails ?? false);
|
|
225
|
+
const contentViewportHeight = height === undefined ? undefined : Math.max(1, height - 3);
|
|
226
|
+
const maxScrollOffset = contentViewportHeight === undefined ? 0 : Math.max(0, lines.length - contentViewportHeight);
|
|
227
|
+
const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
|
|
228
|
+
const visibleLines = contentViewportHeight === undefined
|
|
229
|
+
? lines
|
|
230
|
+
: lines.slice(effectiveScrollOffset, effectiveScrollOffset + contentViewportHeight);
|
|
231
|
+
const showScrollbar = contentViewportHeight !== undefined && lines.length > contentViewportHeight;
|
|
232
|
+
const scrollbarThumbRows = showScrollbar
|
|
233
|
+
? getScrollbarThumbRows(lines.length, contentViewportHeight, effectiveScrollOffset)
|
|
234
|
+
: new Set();
|
|
235
|
+
return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "magenta", wrap: "truncate-end", children: "Selection / Action" }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: line.color, dimColor: line.dimColor, bold: line.bold, wrap: "truncate-end", children: line.text }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'magenta' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${effectiveScrollOffset + index}-${line.text}`))) })] }));
|
|
129
236
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
|
|
3
|
+
import { Spinner } from '@inkjs/ui';
|
|
4
|
+
const KIND_TO_ICON = {
|
|
5
|
+
idle: 'ℹ',
|
|
6
|
+
starting: '⚠',
|
|
7
|
+
running: '✓',
|
|
8
|
+
stopping: '⚠',
|
|
9
|
+
error: '✘',
|
|
10
|
+
};
|
|
11
|
+
const KIND_TO_COLOR = {
|
|
4
12
|
idle: 'blue',
|
|
5
13
|
starting: 'yellow',
|
|
6
14
|
running: 'green',
|
|
@@ -8,5 +16,6 @@ const COLOR_BY_KIND = {
|
|
|
8
16
|
error: 'red',
|
|
9
17
|
};
|
|
10
18
|
export function ContextBar({ status }) {
|
|
11
|
-
|
|
19
|
+
const isBusy = status.kind === 'starting' || status.kind === 'stopping';
|
|
20
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: KIND_TO_COLOR[status.kind], flexDirection: "column", paddingX: 1, children: [isBusy ? (_jsx(Spinner, { label: `Status: ${status.kind} — ${status.message}` })) : (_jsxs(Text, { color: KIND_TO_COLOR[status.kind], wrap: "truncate-end", children: [KIND_TO_ICON[status.kind], " Status: ", status.kind, " \u2014 ", status.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk move g/G first/last Wheel/PgUp/PgDn list & selection scroll [/] log scroll L full-screen logs Enter start/switch s stop r refresh q quit" })] }));
|
|
12
21
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { LogPanel } from './LogPanel.js';
|
|
3
|
+
export function FloatingLogWindow({ logs, width, height, scrollOffset, }) {
|
|
4
|
+
return (_jsx(LogPanel, { logs: logs, width: width, height: height, scrollOffset: scrollOffset, title: "Logs (*.log \u00B7 tail 120 \u00B7 full screen)" }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AppLogEntry } from '../core/runtime.js';
|
|
2
|
+
type LineSpec = {
|
|
3
|
+
text: string;
|
|
4
|
+
color?: 'cyan';
|
|
5
|
+
dimColor?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function buildLogLines(logs: AppLogEntry[]): LineSpec[];
|
|
8
|
+
export declare function LogPanel({ logs, width, height, scrollOffset, title, }: {
|
|
9
|
+
logs: AppLogEntry[];
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
scrollOffset?: number;
|
|
13
|
+
title?: string;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const ANSI_ESCAPE_PATTERN = /(?:\u001B|\u009B)\[[0-?]*[ -/]*[@-~]/gu;
|
|
4
|
+
function sanitizeLogLine(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replace(ANSI_ESCAPE_PATTERN, '')
|
|
7
|
+
.replace(/[\r\t\u2028\u2029]+/g, ' ')
|
|
8
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
|
9
|
+
.replace(/\p{Cf}/gu, '')
|
|
10
|
+
.trimEnd();
|
|
11
|
+
}
|
|
12
|
+
export function buildLogLines(logs) {
|
|
13
|
+
if (logs.length === 0) {
|
|
14
|
+
return [{ text: 'No *.log files yet.', dimColor: true }];
|
|
15
|
+
}
|
|
16
|
+
const lines = [];
|
|
17
|
+
for (const [index, log] of logs.entries()) {
|
|
18
|
+
if (index > 0) {
|
|
19
|
+
lines.push({ text: ' ', dimColor: true });
|
|
20
|
+
}
|
|
21
|
+
lines.push({ text: `[${log.name}]`, color: 'cyan' });
|
|
22
|
+
const contentLines = log.content.length > 0 ? log.content.split('\n') : ['(empty)'];
|
|
23
|
+
for (const line of contentLines) {
|
|
24
|
+
lines.push({ text: sanitizeLogLine(line) || ' ' });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
|
|
30
|
+
if (totalLines <= viewportHeight) {
|
|
31
|
+
return new Set();
|
|
32
|
+
}
|
|
33
|
+
const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
|
|
34
|
+
const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
|
|
35
|
+
const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
|
|
36
|
+
return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
|
|
37
|
+
}
|
|
38
|
+
export function LogPanel({ logs, width, height, scrollOffset = 0, title = 'Logs (*.log · tail 120)', }) {
|
|
39
|
+
const lines = buildLogLines(logs);
|
|
40
|
+
const contentViewportHeight = height === undefined ? lines.length : Math.max(1, height - 3);
|
|
41
|
+
const maxScrollOffset = contentViewportHeight === undefined ? 0 : Math.max(0, lines.length - contentViewportHeight);
|
|
42
|
+
const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
|
|
43
|
+
const startIndex = contentViewportHeight === undefined
|
|
44
|
+
? 0
|
|
45
|
+
: Math.max(0, lines.length - contentViewportHeight - effectiveScrollOffset);
|
|
46
|
+
const visibleLines = contentViewportHeight === undefined
|
|
47
|
+
? lines
|
|
48
|
+
: lines.slice(startIndex, startIndex + contentViewportHeight);
|
|
49
|
+
const showScrollbar = contentViewportHeight !== undefined && lines.length > contentViewportHeight;
|
|
50
|
+
const scrollbarThumbRows = showScrollbar
|
|
51
|
+
? getScrollbarThumbRows(lines.length, contentViewportHeight, maxScrollOffset - effectiveScrollOffset)
|
|
52
|
+
: new Set();
|
|
53
|
+
return (_jsxs(Box, { width: width, height: height, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "yellow", wrap: "truncate-end", children: title }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: line.color, dimColor: line.dimColor, wrap: "truncate-end", children: line.text }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'yellow' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${startIndex + index}-${line.text}`))) })] }));
|
|
54
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { AppRow } from '../core/runtime.js';
|
|
2
|
-
export declare function WorktreeList({ rows, selectedIndex, width, stacked, }: {
|
|
2
|
+
export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, }: {
|
|
3
3
|
rows: AppRow[];
|
|
4
4
|
selectedIndex: number;
|
|
5
5
|
width?: number;
|
|
6
|
+
height?: number;
|
|
6
7
|
stacked: boolean;
|
|
8
|
+
scrollOffset?: number;
|
|
7
9
|
}): import("react").JSX.Element;
|
|
@@ -48,11 +48,26 @@ function truncateLabel(value, width) {
|
|
|
48
48
|
}
|
|
49
49
|
return `${value.slice(0, Math.max(width - 1, 0))}…`;
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
|
|
52
|
+
if (totalLines <= viewportHeight) {
|
|
53
|
+
return new Set();
|
|
54
|
+
}
|
|
55
|
+
const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
|
|
56
|
+
const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
|
|
57
|
+
const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
|
|
58
|
+
return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
|
|
59
|
+
}
|
|
60
|
+
export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, }) {
|
|
52
61
|
const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
const contentViewportHeight = height === undefined ? rows.length : Math.max(1, height - 3);
|
|
63
|
+
const maxScrollOffset = Math.max(0, rows.length - contentViewportHeight);
|
|
64
|
+
const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
|
|
65
|
+
const visibleRows = rows.slice(effectiveScrollOffset, effectiveScrollOffset + contentViewportHeight);
|
|
66
|
+
const showScrollbar = height !== undefined && rows.length > contentViewportHeight;
|
|
67
|
+
const scrollbarThumbRows = showScrollbar ? getScrollbarThumbRows(rows.length, contentViewportHeight, effectiveScrollOffset) : new Set();
|
|
68
|
+
return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), visibleRows.map((row, index) => {
|
|
69
|
+
const isSelected = index + effectiveScrollOffset === selectedIndex;
|
|
55
70
|
const line = `${isSelected ? '>' : ' '} ${getIndicator(row)} ${truncateLabel(sanitizeInlineText(row.branch), branchWidth)}`;
|
|
56
|
-
return (_jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path));
|
|
71
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'cyan' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, row.path));
|
|
57
72
|
})] }));
|
|
58
73
|
}
|
package/dist/core/runtime.d.ts
CHANGED
|
@@ -40,6 +40,11 @@ export interface AppStatus {
|
|
|
40
40
|
kind: 'idle' | 'starting' | 'running' | 'stopping' | 'error';
|
|
41
41
|
message: string;
|
|
42
42
|
}
|
|
43
|
+
export interface AppLogEntry {
|
|
44
|
+
name: string;
|
|
45
|
+
path: string;
|
|
46
|
+
content: string;
|
|
47
|
+
}
|
|
43
48
|
export interface AppModel {
|
|
44
49
|
repoName: string;
|
|
45
50
|
namespace: string;
|
|
@@ -47,11 +52,13 @@ export interface AppModel {
|
|
|
47
52
|
activePath: string | null;
|
|
48
53
|
activeBranch: string | null;
|
|
49
54
|
status: AppStatus;
|
|
55
|
+
logs: AppLogEntry[];
|
|
50
56
|
}
|
|
51
57
|
export interface AppActions {
|
|
52
58
|
start: (worktreePath: string) => Promise<AppModel>;
|
|
53
59
|
stop: () => Promise<AppModel>;
|
|
54
60
|
refresh: () => Promise<AppModel>;
|
|
61
|
+
refreshLogs: () => Promise<AppLogEntry[]>;
|
|
55
62
|
}
|
|
56
63
|
interface GitStatusSummary {
|
|
57
64
|
upstream?: UpstreamInfo;
|
package/dist/core/runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
4
|
import { promisify } from 'node:util';
|
|
4
5
|
import { loadToolConfig } from './config.js';
|
|
5
6
|
import { readWorktrees, sortWorktrees, toShortPath } from './git-worktrees.js';
|
|
@@ -11,6 +12,8 @@ import { isProcessGroupAlive, killProcessGroup, killPortOwner, killOrphans } fro
|
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
12
13
|
const SHORT_SHA_LENGTH = 8;
|
|
13
14
|
const GH_TIMEOUT_MS = 2500;
|
|
15
|
+
const MAX_LOG_BYTES = 16 * 1024;
|
|
16
|
+
const MAX_LOG_LINES = 120;
|
|
14
17
|
function shortenSha(headSha) {
|
|
15
18
|
return headSha.slice(0, SHORT_SHA_LENGTH);
|
|
16
19
|
}
|
|
@@ -177,6 +180,45 @@ async function stopRecordedSession(pgid, port, orphanMatchers) {
|
|
|
177
180
|
throw new Error(`Failed to stop existing session pgid=${pgid}`);
|
|
178
181
|
}
|
|
179
182
|
}
|
|
183
|
+
function tailLogContent(content) {
|
|
184
|
+
const byteTrimmed = content.length > MAX_LOG_BYTES ? content.slice(-MAX_LOG_BYTES) : content;
|
|
185
|
+
const lines = byteTrimmed.replace(/\r\n/g, '\n').split('\n');
|
|
186
|
+
const tailLines = lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines;
|
|
187
|
+
return tailLines.join('\n').trimEnd();
|
|
188
|
+
}
|
|
189
|
+
async function readLogs(logsDir, activeLogPath) {
|
|
190
|
+
try {
|
|
191
|
+
const entries = (await readdir(logsDir, { withFileTypes: true }))
|
|
192
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.log'))
|
|
193
|
+
.map(entry => ({ name: entry.name, path: path.join(logsDir, entry.name) }));
|
|
194
|
+
if (entries.length === 0) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
let selectedEntries = entries;
|
|
198
|
+
if (activeLogPath !== null) {
|
|
199
|
+
const activeEntry = entries.find(entry => entry.path === activeLogPath);
|
|
200
|
+
if (activeEntry) {
|
|
201
|
+
selectedEntries = [activeEntry];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const withStats = await Promise.all(entries.map(async (entry) => ({
|
|
206
|
+
...entry,
|
|
207
|
+
mtimeMs: (await stat(entry.path)).mtimeMs,
|
|
208
|
+
})));
|
|
209
|
+
withStats.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
210
|
+
selectedEntries = [withStats[0]];
|
|
211
|
+
}
|
|
212
|
+
return await Promise.all(selectedEntries.map(async (entry) => ({
|
|
213
|
+
name: entry.name,
|
|
214
|
+
path: entry.path,
|
|
215
|
+
content: tailLogContent(await readFile(entry.path, 'utf8')),
|
|
216
|
+
})));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
180
222
|
export async function buildInitialModel(cwd) {
|
|
181
223
|
const { workspaceRoot, mainWorktreePath, gitCommonDir } = await resolveRepoContext(cwd);
|
|
182
224
|
const config = await loadToolConfig({ repoRoot: workspaceRoot });
|
|
@@ -189,6 +231,7 @@ export async function buildInitialModel(cwd) {
|
|
|
189
231
|
activePath: active?.worktreePath ?? null,
|
|
190
232
|
activeBranch: active?.branch ?? null,
|
|
191
233
|
status: active ? { kind: 'running', message: `Active: ${active.branch}` } : { kind: 'idle', message: 'ready' },
|
|
234
|
+
logs: await readLogs(paths.logsDir, active?.logPath ?? null),
|
|
192
235
|
};
|
|
193
236
|
}
|
|
194
237
|
export async function buildActions(cwd) {
|
|
@@ -197,6 +240,10 @@ export async function buildActions(cwd) {
|
|
|
197
240
|
const paths = getSessionPaths(gitCommonDir, config.namespace);
|
|
198
241
|
const mainWorktreePath = path.dirname(gitCommonDir);
|
|
199
242
|
const refresh = async () => buildInitialModel(cwd);
|
|
243
|
+
const refreshLogs = async () => {
|
|
244
|
+
const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
|
|
245
|
+
return readLogs(paths.logsDir, active?.logPath ?? null);
|
|
246
|
+
};
|
|
200
247
|
const stop = async () => {
|
|
201
248
|
const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
|
|
202
249
|
if (active) {
|
|
@@ -259,5 +306,5 @@ export async function buildActions(cwd) {
|
|
|
259
306
|
status: { kind: 'running', message: `started ${selected.branch}` },
|
|
260
307
|
};
|
|
261
308
|
};
|
|
262
|
-
return { start, stop, refresh };
|
|
309
|
+
return { start, stop, refresh, refreshLogs };
|
|
263
310
|
}
|
package/dist/main.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createConfigForRepo, parseInitArgs } from './core/init.js';
|
|
4
4
|
import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES } from './core/config.js';
|
|
5
|
+
import { ThemeProvider } from '@inkjs/ui';
|
|
5
6
|
import { render } from 'ink';
|
|
7
|
+
import { APP_RENDER_OPTIONS } from './render-options.js';
|
|
6
8
|
import { App } from './app.js';
|
|
7
9
|
import { buildActions, buildInitialModel } from './core/runtime.js';
|
|
8
|
-
import {
|
|
10
|
+
import { appTheme } from './ui-theme.js';
|
|
9
11
|
const cwd = process.cwd();
|
|
10
12
|
const args = process.argv.slice(2);
|
|
11
13
|
const [, , subcommand] = process.argv;
|
|
@@ -65,7 +67,27 @@ if (subcommand !== undefined) {
|
|
|
65
67
|
}
|
|
66
68
|
try {
|
|
67
69
|
const [initialModel, actions] = await Promise.all([buildInitialModel(cwd), buildActions(cwd)]);
|
|
68
|
-
|
|
70
|
+
const createApp = () => (_jsx(ThemeProvider, { theme: appTheme, children: _jsx(App, { initialModel: initialModel, actions: actions }) }));
|
|
71
|
+
const instance = render(createApp(), APP_RENDER_OPTIONS);
|
|
72
|
+
let repaintTimer;
|
|
73
|
+
const repaintAfterResize = () => {
|
|
74
|
+
// Give Ink/useWindowSize one tick to observe the new size, then force a fresh root render.
|
|
75
|
+
if (repaintTimer) {
|
|
76
|
+
clearTimeout(repaintTimer);
|
|
77
|
+
}
|
|
78
|
+
repaintTimer = setTimeout(() => {
|
|
79
|
+
instance.rerender(createApp());
|
|
80
|
+
}, 25);
|
|
81
|
+
};
|
|
82
|
+
if (process.stdout.isTTY) {
|
|
83
|
+
process.stdout.on('resize', repaintAfterResize);
|
|
84
|
+
void instance.waitUntilExit().finally(() => {
|
|
85
|
+
if (repaintTimer) {
|
|
86
|
+
clearTimeout(repaintTimer);
|
|
87
|
+
}
|
|
88
|
+
process.stdout.off('resize', repaintAfterResize);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
69
91
|
}
|
|
70
92
|
catch (error) {
|
|
71
93
|
console.error(describeError(error));
|
package/dist/render-options.d.ts
CHANGED
package/dist/render-options.js
CHANGED
package/dist/repro.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/repro.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const model = {
|
|
2
|
+
repoName: "reclaim-the-forest",
|
|
3
|
+
namespace: "rojo-serve",
|
|
4
|
+
rows: Array.from({ length: 10 }, (_, index) => ({
|
|
5
|
+
path: `/repo/.worktree/feat-${index}`,
|
|
6
|
+
shortPath: `.worktree/feat-${index}`,
|
|
7
|
+
branch: `feat/${index}`,
|
|
8
|
+
tags: (index === 0 ? [active] : []),
|
|
9
|
+
pullRequest: index === 0 ? { kind: found, number: 2125, title: Selection }
|
|
10
|
+
:
|
|
11
|
+
}))
|
|
12
|
+
};
|
|
13
|
+
export {};
|
package/dist/ui-theme.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defaultTheme, extendTheme } from '@inkjs/ui';
|
|
2
|
+
const variantColor = {
|
|
3
|
+
info: 'blue',
|
|
4
|
+
success: 'green',
|
|
5
|
+
error: 'red',
|
|
6
|
+
warning: 'yellow',
|
|
7
|
+
};
|
|
8
|
+
export const appTheme = extendTheme(defaultTheme, {
|
|
9
|
+
components: {
|
|
10
|
+
StatusMessage: {
|
|
11
|
+
styles: {
|
|
12
|
+
icon: ({ variant }) => ({
|
|
13
|
+
color: variantColor[variant],
|
|
14
|
+
bold: true,
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
Alert: {
|
|
19
|
+
styles: {
|
|
20
|
+
container: ({ variant }) => ({
|
|
21
|
+
flexGrow: 0,
|
|
22
|
+
flexShrink: 0,
|
|
23
|
+
borderStyle: 'round',
|
|
24
|
+
borderColor: variantColor[variant],
|
|
25
|
+
gap: 1,
|
|
26
|
+
paddingX: 1,
|
|
27
|
+
}),
|
|
28
|
+
icon: ({ variant }) => ({
|
|
29
|
+
color: variantColor[variant],
|
|
30
|
+
bold: true,
|
|
31
|
+
}),
|
|
32
|
+
title: () => ({
|
|
33
|
+
bold: true,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ohzw/worktree-command-tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A TUI for managing git worktrees",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"access": "public"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"@inkjs/ui": "^2.0.0",
|
|
57
58
|
"ink": "^7.0.4",
|
|
58
59
|
"react": "^19.2.0"
|
|
59
60
|
},
|