@runloop/rl-cli 0.0.1 → 0.0.3
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/cli.js +59 -13
- package/dist/commands/auth.js +2 -1
- package/dist/commands/blueprint/list.js +18 -7
- package/dist/commands/devbox/create.js +1 -2
- package/dist/commands/devbox/list.js +307 -75
- package/dist/commands/menu.js +70 -0
- package/dist/commands/snapshot/list.js +18 -9
- package/dist/components/ActionsPopup.js +42 -0
- package/dist/components/Banner.js +3 -6
- package/dist/components/Breadcrumb.js +3 -4
- package/dist/components/DevboxActionsMenu.js +481 -0
- package/dist/components/DevboxCreatePage.js +15 -7
- package/dist/components/DevboxDetailPage.js +57 -364
- package/dist/components/MainMenu.js +71 -0
- package/dist/components/StatusBadge.js +15 -12
- package/dist/utils/CommandExecutor.js +12 -0
- package/dist/utils/client.js +17 -0
- package/dist/utils/interactiveCommand.js +14 -0
- package/dist/utils/sshSession.js +25 -0
- package/dist/utils/url.js +39 -0
- package/package.json +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
4
5
|
import figures from 'figures';
|
|
5
6
|
import { getClient } from '../../utils/client.js';
|
|
6
7
|
import { SpinnerComponent } from '../../components/Spinner.js';
|
|
@@ -11,6 +12,10 @@ import { Table, createTextColumn } from '../../components/Table.js';
|
|
|
11
12
|
import { createExecutor } from '../../utils/CommandExecutor.js';
|
|
12
13
|
import { DevboxDetailPage } from '../../components/DevboxDetailPage.js';
|
|
13
14
|
import { DevboxCreatePage } from '../../components/DevboxCreatePage.js';
|
|
15
|
+
import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js';
|
|
16
|
+
import { ActionsPopup } from '../../components/ActionsPopup.js';
|
|
17
|
+
import { getDevboxUrl } from '../../utils/url.js';
|
|
18
|
+
import { runSSHSession } from '../../utils/sshSession.js';
|
|
14
19
|
// Format time ago in a succinct way
|
|
15
20
|
const formatTimeAgo = (timestamp) => {
|
|
16
21
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
@@ -31,10 +36,9 @@ const formatTimeAgo = (timestamp) => {
|
|
|
31
36
|
const years = Math.floor(months / 12);
|
|
32
37
|
return `${years}y ago`;
|
|
33
38
|
};
|
|
34
|
-
const MAX_FETCH = 100;
|
|
35
39
|
const DEFAULT_PAGE_SIZE = 10;
|
|
36
|
-
const ListDevboxesUI = ({ status }) => {
|
|
37
|
-
const { exit } = useApp();
|
|
40
|
+
const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => {
|
|
41
|
+
const { exit: inkExit } = useApp();
|
|
38
42
|
const { stdout } = useStdout();
|
|
39
43
|
const [loading, setLoading] = React.useState(true);
|
|
40
44
|
const [devboxes, setDevboxes] = React.useState([]);
|
|
@@ -43,8 +47,17 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
43
47
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
44
48
|
const [showDetails, setShowDetails] = React.useState(false);
|
|
45
49
|
const [showCreate, setShowCreate] = React.useState(false);
|
|
50
|
+
const [showActions, setShowActions] = React.useState(false);
|
|
51
|
+
const [showPopup, setShowPopup] = React.useState(false);
|
|
52
|
+
const [selectedOperation, setSelectedOperation] = React.useState(0);
|
|
46
53
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
47
54
|
const [refreshIcon, setRefreshIcon] = React.useState(0);
|
|
55
|
+
const [searchMode, setSearchMode] = React.useState(false);
|
|
56
|
+
const [searchQuery, setSearchQuery] = React.useState('');
|
|
57
|
+
const [totalCount, setTotalCount] = React.useState(0);
|
|
58
|
+
const [hasMore, setHasMore] = React.useState(false);
|
|
59
|
+
const pageCache = React.useRef(new Map());
|
|
60
|
+
const lastIdCache = React.useRef(new Map());
|
|
48
61
|
// Calculate responsive dimensions
|
|
49
62
|
const terminalWidth = stdout?.columns || 120;
|
|
50
63
|
const terminalHeight = stdout?.rows || 30;
|
|
@@ -76,6 +89,29 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
76
89
|
const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10;
|
|
77
90
|
nameWidth = Math.max(8, remainingWidth);
|
|
78
91
|
}
|
|
92
|
+
// Define allOperations
|
|
93
|
+
const allOperations = [
|
|
94
|
+
{ key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' },
|
|
95
|
+
{ key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' },
|
|
96
|
+
{ key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' },
|
|
97
|
+
{ key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' },
|
|
98
|
+
{ key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' },
|
|
99
|
+
{ key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' },
|
|
100
|
+
{ key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' },
|
|
101
|
+
{ key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' },
|
|
102
|
+
{ key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' },
|
|
103
|
+
];
|
|
104
|
+
// Check if we need to focus on a specific devbox after returning from SSH
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
if (focusDevboxId && devboxes.length > 0 && !loading) {
|
|
107
|
+
// Find the devbox in the current page
|
|
108
|
+
const devboxIndex = devboxes.findIndex(d => d.id === focusDevboxId);
|
|
109
|
+
if (devboxIndex !== -1) {
|
|
110
|
+
setSelectedIndex(devboxIndex);
|
|
111
|
+
setShowDetails(true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}, [devboxes, loading, focusDevboxId]);
|
|
79
115
|
React.useEffect(() => {
|
|
80
116
|
const list = async (isInitialLoad = false) => {
|
|
81
117
|
try {
|
|
@@ -83,22 +119,53 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
83
119
|
if (isInitialLoad) {
|
|
84
120
|
setRefreshing(true);
|
|
85
121
|
}
|
|
122
|
+
// Check if we have cached data for this page
|
|
123
|
+
if (!isInitialLoad && pageCache.current.has(currentPage)) {
|
|
124
|
+
setDevboxes(pageCache.current.get(currentPage) || []);
|
|
125
|
+
setLoading(false);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
86
128
|
const client = getClient();
|
|
87
|
-
const
|
|
129
|
+
const pageDevboxes = [];
|
|
130
|
+
// Get starting_after cursor from previous page's last ID
|
|
131
|
+
const startingAfter = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined;
|
|
132
|
+
// Build query params
|
|
133
|
+
const queryParams = {
|
|
134
|
+
limit: PAGE_SIZE,
|
|
135
|
+
};
|
|
136
|
+
if (startingAfter) {
|
|
137
|
+
queryParams.starting_after = startingAfter;
|
|
138
|
+
}
|
|
139
|
+
if (status) {
|
|
140
|
+
queryParams.status = status;
|
|
141
|
+
}
|
|
142
|
+
// Fetch only the current page
|
|
143
|
+
const page = await client.devboxes.list(queryParams);
|
|
144
|
+
// Collect items from the page - only get PAGE_SIZE items, don't auto-paginate
|
|
88
145
|
let count = 0;
|
|
89
|
-
for await (const devbox of
|
|
90
|
-
|
|
91
|
-
allDevboxes.push(devbox);
|
|
92
|
-
}
|
|
146
|
+
for await (const devbox of page) {
|
|
147
|
+
pageDevboxes.push(devbox);
|
|
93
148
|
count++;
|
|
94
|
-
|
|
149
|
+
// Break after getting PAGE_SIZE items to prevent auto-pagination
|
|
150
|
+
if (count >= PAGE_SIZE) {
|
|
95
151
|
break;
|
|
96
152
|
}
|
|
97
153
|
}
|
|
98
|
-
//
|
|
154
|
+
// Update pagination metadata from the page object
|
|
155
|
+
// These properties are on the page object itself
|
|
156
|
+
const total = page.total_count || pageDevboxes.length;
|
|
157
|
+
const more = page.has_more || false;
|
|
158
|
+
setTotalCount(total);
|
|
159
|
+
setHasMore(more);
|
|
160
|
+
// Cache the page data and last ID
|
|
161
|
+
if (pageDevboxes.length > 0) {
|
|
162
|
+
pageCache.current.set(currentPage, pageDevboxes);
|
|
163
|
+
lastIdCache.current.set(currentPage, pageDevboxes[pageDevboxes.length - 1].id);
|
|
164
|
+
}
|
|
165
|
+
// Update devboxes for current page
|
|
99
166
|
setDevboxes((prev) => {
|
|
100
|
-
const hasChanged = JSON.stringify(prev) !== JSON.stringify(
|
|
101
|
-
return hasChanged ?
|
|
167
|
+
const hasChanged = JSON.stringify(prev) !== JSON.stringify(pageDevboxes);
|
|
168
|
+
return hasChanged ? pageDevboxes : prev;
|
|
102
169
|
});
|
|
103
170
|
}
|
|
104
171
|
catch (err) {
|
|
@@ -115,24 +182,40 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
115
182
|
list(true);
|
|
116
183
|
// Poll every 3 seconds (increased from 2), but only when in list view
|
|
117
184
|
const interval = setInterval(() => {
|
|
118
|
-
if (!showDetails && !showCreate) {
|
|
185
|
+
if (!showDetails && !showCreate && !showActions) {
|
|
186
|
+
// Clear cache on refresh to get latest data
|
|
187
|
+
pageCache.current.clear();
|
|
188
|
+
lastIdCache.current.clear();
|
|
119
189
|
list(false);
|
|
120
190
|
}
|
|
121
191
|
}, 3000);
|
|
122
192
|
return () => clearInterval(interval);
|
|
123
|
-
}, [showDetails, showCreate]);
|
|
193
|
+
}, [showDetails, showCreate, showActions, currentPage]);
|
|
124
194
|
// Animate refresh icon only when in list view
|
|
125
195
|
React.useEffect(() => {
|
|
126
|
-
if (showDetails || showCreate) {
|
|
196
|
+
if (showDetails || showCreate || showActions) {
|
|
127
197
|
return; // Don't animate when not in list view
|
|
128
198
|
}
|
|
129
199
|
const interval = setInterval(() => {
|
|
130
200
|
setRefreshIcon((prev) => (prev + 1) % 10);
|
|
131
201
|
}, 80);
|
|
132
202
|
return () => clearInterval(interval);
|
|
133
|
-
}, [showDetails, showCreate]);
|
|
203
|
+
}, [showDetails, showCreate, showActions]);
|
|
134
204
|
useInput((input, key) => {
|
|
205
|
+
// Handle Ctrl+C to force exit
|
|
206
|
+
if (key.ctrl && input === 'c') {
|
|
207
|
+
process.stdout.write('\x1b[?1049l'); // Exit alternate screen
|
|
208
|
+
process.exit(130);
|
|
209
|
+
}
|
|
135
210
|
const pageDevboxes = currentDevboxes.length;
|
|
211
|
+
// Skip input handling when in search mode - let TextInput handle it
|
|
212
|
+
if (searchMode) {
|
|
213
|
+
if (key.escape) {
|
|
214
|
+
setSearchMode(false);
|
|
215
|
+
setSearchQuery('');
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
136
219
|
// Skip input handling when in details view - let DevboxDetailPage handle it
|
|
137
220
|
if (showDetails) {
|
|
138
221
|
return;
|
|
@@ -141,6 +224,41 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
141
224
|
if (showCreate) {
|
|
142
225
|
return;
|
|
143
226
|
}
|
|
227
|
+
// Skip input handling when in actions view - let DevboxActionsMenu handle it
|
|
228
|
+
if (showActions) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Handle popup navigation
|
|
232
|
+
if (showPopup) {
|
|
233
|
+
if (key.escape || input === 'q') {
|
|
234
|
+
console.clear();
|
|
235
|
+
setShowPopup(false);
|
|
236
|
+
setSelectedOperation(0);
|
|
237
|
+
}
|
|
238
|
+
else if (key.upArrow && selectedOperation > 0) {
|
|
239
|
+
setSelectedOperation(selectedOperation - 1);
|
|
240
|
+
}
|
|
241
|
+
else if (key.downArrow && selectedOperation < operations.length - 1) {
|
|
242
|
+
setSelectedOperation(selectedOperation + 1);
|
|
243
|
+
}
|
|
244
|
+
else if (key.return) {
|
|
245
|
+
// Execute the selected operation
|
|
246
|
+
console.clear();
|
|
247
|
+
setShowPopup(false);
|
|
248
|
+
setShowActions(true);
|
|
249
|
+
}
|
|
250
|
+
else if (input) {
|
|
251
|
+
// Check for shortcut match
|
|
252
|
+
const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
|
|
253
|
+
if (matchedOpIndex !== -1) {
|
|
254
|
+
setSelectedOperation(matchedOpIndex);
|
|
255
|
+
console.clear();
|
|
256
|
+
setShowPopup(false);
|
|
257
|
+
setShowActions(true);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
144
262
|
// Handle list view
|
|
145
263
|
if (key.upArrow && selectedIndex > 0) {
|
|
146
264
|
setSelectedIndex(selectedIndex - 1);
|
|
@@ -160,13 +278,18 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
160
278
|
console.clear();
|
|
161
279
|
setShowDetails(true);
|
|
162
280
|
}
|
|
281
|
+
else if (input === 'a') {
|
|
282
|
+
console.clear();
|
|
283
|
+
setShowPopup(true);
|
|
284
|
+
setSelectedOperation(0);
|
|
285
|
+
}
|
|
163
286
|
else if (input === 'c') {
|
|
164
287
|
console.clear();
|
|
165
288
|
setShowCreate(true);
|
|
166
289
|
}
|
|
167
290
|
else if (input === 'o' && selectedDevbox) {
|
|
168
291
|
// Open in browser
|
|
169
|
-
const url =
|
|
292
|
+
const url = getDevboxUrl(selectedDevbox.id);
|
|
170
293
|
const openBrowser = async () => {
|
|
171
294
|
const { exec } = await import('child_process');
|
|
172
295
|
const platform = process.platform;
|
|
@@ -184,17 +307,72 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
184
307
|
};
|
|
185
308
|
openBrowser();
|
|
186
309
|
}
|
|
187
|
-
else if (input === '
|
|
188
|
-
|
|
310
|
+
else if (input === '/') {
|
|
311
|
+
setSearchMode(true);
|
|
312
|
+
}
|
|
313
|
+
else if (key.escape) {
|
|
314
|
+
if (searchQuery) {
|
|
315
|
+
// Clear search when Esc is pressed and there's an active search
|
|
316
|
+
setSearchQuery('');
|
|
317
|
+
setCurrentPage(0);
|
|
318
|
+
setSelectedIndex(0);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// Go back to home
|
|
322
|
+
if (onBack) {
|
|
323
|
+
onBack();
|
|
324
|
+
}
|
|
325
|
+
else if (onExit) {
|
|
326
|
+
onExit();
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
inkExit();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
189
332
|
}
|
|
190
333
|
});
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
334
|
+
// Filter devboxes based on search query (client-side only for current page)
|
|
335
|
+
const filteredDevboxes = React.useMemo(() => {
|
|
336
|
+
if (!searchQuery.trim())
|
|
337
|
+
return devboxes;
|
|
338
|
+
const query = searchQuery.toLowerCase();
|
|
339
|
+
return devboxes.filter(devbox => {
|
|
340
|
+
return (devbox.id?.toLowerCase().includes(query) ||
|
|
341
|
+
devbox.name?.toLowerCase().includes(query) ||
|
|
342
|
+
devbox.status?.toLowerCase().includes(query));
|
|
343
|
+
});
|
|
344
|
+
}, [devboxes, searchQuery]);
|
|
345
|
+
// Current page is already fetched, no need to slice
|
|
346
|
+
const currentDevboxes = filteredDevboxes;
|
|
347
|
+
// Ensure selected index is within bounds after filtering
|
|
348
|
+
React.useEffect(() => {
|
|
349
|
+
if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) {
|
|
350
|
+
setSelectedIndex(Math.max(0, currentDevboxes.length - 1));
|
|
351
|
+
}
|
|
352
|
+
}, [currentDevboxes.length, selectedIndex]);
|
|
197
353
|
const selectedDevbox = currentDevboxes[selectedIndex];
|
|
354
|
+
// Calculate pagination info
|
|
355
|
+
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
356
|
+
const startIndex = currentPage * PAGE_SIZE;
|
|
357
|
+
const endIndex = startIndex + currentDevboxes.length;
|
|
358
|
+
// Filter operations based on devbox status
|
|
359
|
+
const operations = selectedDevbox ? allOperations.filter(op => {
|
|
360
|
+
const status = selectedDevbox.status;
|
|
361
|
+
// When suspended: logs and resume
|
|
362
|
+
if (status === 'suspended') {
|
|
363
|
+
return op.key === 'resume' || op.key === 'logs';
|
|
364
|
+
}
|
|
365
|
+
// When not running (shutdown, failure, etc): only logs
|
|
366
|
+
if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') {
|
|
367
|
+
return op.key === 'logs';
|
|
368
|
+
}
|
|
369
|
+
// When running: everything except resume
|
|
370
|
+
if (status === 'running') {
|
|
371
|
+
return op.key !== 'resume';
|
|
372
|
+
}
|
|
373
|
+
// Default for transitional states (provisioning, initializing)
|
|
374
|
+
return op.key === 'logs' || op.key === 'delete';
|
|
375
|
+
}) : allOperations;
|
|
198
376
|
// Create view
|
|
199
377
|
if (showCreate) {
|
|
200
378
|
return (_jsx(DevboxCreatePage, { onBack: () => {
|
|
@@ -205,32 +383,98 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
205
383
|
// The list will auto-refresh via the polling effect
|
|
206
384
|
} }));
|
|
207
385
|
}
|
|
386
|
+
// Actions view
|
|
387
|
+
if (showActions && selectedDevbox) {
|
|
388
|
+
const selectedOp = operations[selectedOperation];
|
|
389
|
+
return (_jsx(DevboxActionsMenu, { devbox: selectedDevbox, onBack: () => {
|
|
390
|
+
setShowActions(false);
|
|
391
|
+
setSelectedOperation(0);
|
|
392
|
+
}, breadcrumbItems: [
|
|
393
|
+
{ label: 'Devboxes' },
|
|
394
|
+
{ label: selectedDevbox.name || selectedDevbox.id, active: true }
|
|
395
|
+
], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
|
|
396
|
+
}
|
|
208
397
|
// Details view
|
|
209
398
|
if (showDetails && selectedDevbox) {
|
|
210
|
-
return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
|
|
399
|
+
return (_jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false), onSSHRequest: onSSHRequest }));
|
|
211
400
|
}
|
|
212
|
-
//
|
|
401
|
+
// Show popup with table in background
|
|
402
|
+
if (showPopup && selectedDevbox) {
|
|
403
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
404
|
+
{ label: 'Devboxes', active: true }
|
|
405
|
+
] }), !loading && !error && devboxes.length > 0 && (_jsx(_Fragment, { children: _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
|
|
406
|
+
{
|
|
407
|
+
key: 'statusIcon',
|
|
408
|
+
label: '',
|
|
409
|
+
width: statusIconWidth,
|
|
410
|
+
render: (devbox, index, isSelected) => {
|
|
411
|
+
const statusDisplay = getStatusDisplay(devbox.status);
|
|
412
|
+
// Truncate icon to fit width and pad
|
|
413
|
+
const icon = statusDisplay.icon.slice(0, statusIconWidth);
|
|
414
|
+
const padded = icon.padEnd(statusIconWidth, ' ');
|
|
415
|
+
return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
|
|
419
|
+
{
|
|
420
|
+
key: 'statusText',
|
|
421
|
+
label: 'Status',
|
|
422
|
+
width: statusTextWidth,
|
|
423
|
+
render: (devbox, index, isSelected) => {
|
|
424
|
+
const statusDisplay = getStatusDisplay(devbox.status);
|
|
425
|
+
const truncated = statusDisplay.text.slice(0, statusTextWidth);
|
|
426
|
+
const padded = truncated.padEnd(statusTextWidth, ' ');
|
|
427
|
+
return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth, dimColor: true }),
|
|
431
|
+
createTextColumn('capabilities', 'Capabilities', (devbox) => {
|
|
432
|
+
const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c) => c !== 'unknown').length > 0;
|
|
433
|
+
return hasCapabilities
|
|
434
|
+
? `[${devbox.capabilities
|
|
435
|
+
.filter((c) => c !== 'unknown')
|
|
436
|
+
.map((c) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
|
|
437
|
+
.join(',')}]`
|
|
438
|
+
: '';
|
|
439
|
+
}, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
|
|
440
|
+
createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
|
|
441
|
+
createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
|
|
442
|
+
] }) })), _jsx(Box, { marginTop: -Math.min(operations.length + 10, PAGE_SIZE + 5), justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedDevbox, operations: operations, selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })] }));
|
|
443
|
+
}
|
|
444
|
+
// If loading or error, show that first
|
|
445
|
+
if (loading) {
|
|
446
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
447
|
+
{ label: 'Devboxes', active: true }
|
|
448
|
+
] }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
|
|
449
|
+
}
|
|
450
|
+
if (error) {
|
|
451
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
452
|
+
{ label: 'Devboxes', active: true }
|
|
453
|
+
] }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
|
|
454
|
+
}
|
|
455
|
+
if (!loading && !error && devboxes.length === 0) {
|
|
456
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
457
|
+
{ label: 'Devboxes', active: true }
|
|
458
|
+
] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No devboxes found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln devbox create" })] })] }));
|
|
459
|
+
}
|
|
460
|
+
// List view with data
|
|
213
461
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
214
462
|
{ label: 'Devboxes', active: true }
|
|
215
|
-
] }),
|
|
463
|
+
] }), currentDevboxes && currentDevboxes.length >= 0 && (_jsxs(_Fragment, { children: [searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search (name, id, status)...", onSubmit: () => {
|
|
464
|
+
setSearchMode(false);
|
|
465
|
+
setCurrentPage(0);
|
|
466
|
+
setSelectedIndex(0);
|
|
467
|
+
} }), _jsx(Text, { color: "gray", dimColor: true, children: " [Esc to cancel]" })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.info, " Searching for: "] }), _jsx(Text, { color: "yellow", bold: true, children: searchQuery }), _jsxs(Text, { color: "gray", dimColor: true, children: [" (", currentDevboxes.length, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${searchQuery ? currentDevboxes.length : totalCount}]`, columns: [
|
|
216
468
|
{
|
|
217
469
|
key: 'statusIcon',
|
|
218
470
|
label: '',
|
|
219
471
|
width: statusIconWidth,
|
|
220
472
|
render: (devbox, index, isSelected) => {
|
|
221
473
|
const statusDisplay = getStatusDisplay(devbox.status);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
else if (status === 'stopped' || status === 'suspended')
|
|
227
|
-
color = 'gray';
|
|
228
|
-
else if (status === 'starting' || status === 'stopping')
|
|
229
|
-
color = 'yellow';
|
|
230
|
-
else if (status === 'failed')
|
|
231
|
-
color = 'red';
|
|
232
|
-
const padded = statusDisplay.icon.padEnd(statusIconWidth, ' ');
|
|
233
|
-
return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
|
|
474
|
+
// Truncate icon to fit width and pad
|
|
475
|
+
const icon = statusDisplay.icon.slice(0, statusIconWidth);
|
|
476
|
+
const padded = icon.padEnd(statusIconWidth, ' ');
|
|
477
|
+
return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
|
|
234
478
|
}
|
|
235
479
|
},
|
|
236
480
|
createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
|
|
@@ -240,19 +484,9 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
240
484
|
width: statusTextWidth,
|
|
241
485
|
render: (devbox, index, isSelected) => {
|
|
242
486
|
const statusDisplay = getStatusDisplay(devbox.status);
|
|
243
|
-
const status = devbox.status;
|
|
244
|
-
let color = 'gray';
|
|
245
|
-
if (status === 'running')
|
|
246
|
-
color = 'green';
|
|
247
|
-
else if (status === 'stopped' || status === 'suspended')
|
|
248
|
-
color = 'gray';
|
|
249
|
-
else if (status === 'starting' || status === 'stopping')
|
|
250
|
-
color = 'yellow';
|
|
251
|
-
else if (status === 'failed')
|
|
252
|
-
color = 'red';
|
|
253
487
|
const truncated = statusDisplay.text.slice(0, statusTextWidth);
|
|
254
488
|
const padded = truncated.padEnd(statusTextWidth, ' ');
|
|
255
|
-
return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
|
|
489
|
+
return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
|
|
256
490
|
}
|
|
257
491
|
},
|
|
258
492
|
createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth }),
|
|
@@ -267,36 +501,34 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
267
501
|
}, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
|
|
268
502
|
createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
|
|
269
503
|
createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
|
|
270
|
-
] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ",
|
|
504
|
+
] }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ", totalCount] }), _jsx(Text, { color: "gray", dimColor: true, children: " total" }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), hasMore && (_jsx(Text, { color: "gray", dimColor: true, children: " (more available)" })), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: "cyan", children: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10] })) : (_jsx(Text, { color: "green", children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 [Enter] Details \u2022 [a] Actions \u2022 [c] Create \u2022 [/] Search \u2022 [o] Browser \u2022 [Esc] Back"] })] })] }))] }));
|
|
271
505
|
};
|
|
272
|
-
|
|
506
|
+
// Export the UI component for use in the main menu
|
|
507
|
+
export { ListDevboxesUI };
|
|
508
|
+
export async function listDevboxes(options, focusDevboxId) {
|
|
273
509
|
const executor = createExecutor(options);
|
|
510
|
+
let sshSessionConfig = null;
|
|
274
511
|
await executor.executeList(async () => {
|
|
275
512
|
const client = executor.getClient();
|
|
276
513
|
return executor.fetchFromIterator(client.devboxes.list(), {
|
|
277
514
|
filter: options.status ? (devbox) => devbox.status === options.status : undefined,
|
|
278
515
|
limit: DEFAULT_PAGE_SIZE,
|
|
279
516
|
});
|
|
280
|
-
}, () => _jsx(ListDevboxesUI, { status: options.status
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
`${sshCommand.sshUser}@${sshCommand.url}`
|
|
297
|
-
], {
|
|
298
|
-
stdio: 'inherit'
|
|
299
|
-
});
|
|
300
|
-
process.exit(result.status || 0);
|
|
517
|
+
}, () => (_jsx(ListDevboxesUI, { status: options.status, focusDevboxId: focusDevboxId, onSSHRequest: (config) => {
|
|
518
|
+
sshSessionConfig = config;
|
|
519
|
+
} })), DEFAULT_PAGE_SIZE);
|
|
520
|
+
// If SSH was requested, handle it now after Ink has exited
|
|
521
|
+
if (sshSessionConfig) {
|
|
522
|
+
const result = await runSSHSession(sshSessionConfig);
|
|
523
|
+
if (result.shouldRestart) {
|
|
524
|
+
console.clear();
|
|
525
|
+
console.log(`\nSSH session ended. Returning to CLI...\n`);
|
|
526
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
527
|
+
// Restart the list view with the devbox ID to focus on
|
|
528
|
+
await listDevboxes(options, result.returnToDevboxId);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
process.exit(result.exitCode);
|
|
532
|
+
}
|
|
301
533
|
}
|
|
302
534
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, useApp } from 'ink';
|
|
4
|
+
import { MainMenu } from '../components/MainMenu.js';
|
|
5
|
+
import { runSSHSession } from '../utils/sshSession.js';
|
|
6
|
+
// Import the UI components directly
|
|
7
|
+
import { ListDevboxesUI } from './devbox/list.js';
|
|
8
|
+
import { ListBlueprintsUI } from './blueprint/list.js';
|
|
9
|
+
import { ListSnapshotsUI } from './snapshot/list.js';
|
|
10
|
+
import { Box } from 'ink';
|
|
11
|
+
const App = ({ onSSHRequest, initialScreen = 'menu', focusDevboxId }) => {
|
|
12
|
+
const { exit } = useApp();
|
|
13
|
+
const [currentScreen, setCurrentScreen] = React.useState(initialScreen);
|
|
14
|
+
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
|
15
|
+
const handleMenuSelect = (key) => {
|
|
16
|
+
setCurrentScreen(key);
|
|
17
|
+
};
|
|
18
|
+
const handleBack = () => {
|
|
19
|
+
setCurrentScreen('menu');
|
|
20
|
+
};
|
|
21
|
+
const handleExit = () => {
|
|
22
|
+
exit();
|
|
23
|
+
};
|
|
24
|
+
// Wrap everything in a full-height container
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", minHeight: process.stdout.rows || 24, children: [currentScreen === 'menu' && _jsx(MainMenu, { onSelect: handleMenuSelect }), currentScreen === 'devboxes' && (_jsx(ListDevboxesUI, { onBack: handleBack, onExit: handleExit, onSSHRequest: onSSHRequest, focusDevboxId: focusDevboxId })), currentScreen === 'blueprints' && _jsx(ListBlueprintsUI, { onBack: handleBack, onExit: handleExit }), currentScreen === 'snapshots' && _jsx(ListSnapshotsUI, { onBack: handleBack, onExit: handleExit })] }));
|
|
26
|
+
};
|
|
27
|
+
export async function runMainMenu(initialScreen = 'menu', focusDevboxId) {
|
|
28
|
+
// Enter alternate screen buffer once at the start
|
|
29
|
+
process.stdout.write('\x1b[?1049h');
|
|
30
|
+
let sshSessionConfig = null;
|
|
31
|
+
let shouldContinue = true;
|
|
32
|
+
let currentInitialScreen = initialScreen;
|
|
33
|
+
let currentFocusDevboxId = focusDevboxId;
|
|
34
|
+
while (shouldContinue) {
|
|
35
|
+
sshSessionConfig = null;
|
|
36
|
+
try {
|
|
37
|
+
const { waitUntilExit } = render(_jsx(App, { onSSHRequest: (config) => {
|
|
38
|
+
sshSessionConfig = config;
|
|
39
|
+
}, initialScreen: currentInitialScreen, focusDevboxId: currentFocusDevboxId }));
|
|
40
|
+
await waitUntilExit();
|
|
41
|
+
shouldContinue = false;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error('Error in menu:', error);
|
|
45
|
+
shouldContinue = false;
|
|
46
|
+
}
|
|
47
|
+
// If SSH was requested, handle it now after Ink has exited
|
|
48
|
+
if (sshSessionConfig) {
|
|
49
|
+
// Exit alternate screen buffer for SSH
|
|
50
|
+
process.stdout.write('\x1b[?1049l');
|
|
51
|
+
const result = await runSSHSession(sshSessionConfig);
|
|
52
|
+
if (result.shouldRestart) {
|
|
53
|
+
console.clear();
|
|
54
|
+
console.log(`\nSSH session ended. Returning to menu...\n`);
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
56
|
+
// Re-enter alternate screen buffer and return to devboxes list
|
|
57
|
+
process.stdout.write('\x1b[?1049h');
|
|
58
|
+
currentInitialScreen = 'devboxes';
|
|
59
|
+
currentFocusDevboxId = result.returnToDevboxId;
|
|
60
|
+
shouldContinue = true;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
shouldContinue = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Exit alternate screen buffer once at the end
|
|
68
|
+
process.stdout.write('\x1b[?1049l');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|