@runloop/rl-cli 0.0.2 → 0.1.0
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 +64 -29
- package/dist/cli.js +420 -76
- package/dist/commands/auth.js +12 -10
- package/dist/commands/blueprint/create.js +108 -0
- package/dist/commands/blueprint/get.js +37 -0
- package/dist/commands/blueprint/list.js +303 -224
- package/dist/commands/blueprint/logs.js +40 -0
- package/dist/commands/blueprint/preview.js +45 -0
- package/dist/commands/devbox/create.js +10 -9
- package/dist/commands/devbox/delete.js +8 -8
- package/dist/commands/devbox/download.js +49 -0
- package/dist/commands/devbox/exec.js +23 -13
- package/dist/commands/devbox/execAsync.js +43 -0
- package/dist/commands/devbox/get.js +37 -0
- package/dist/commands/devbox/getAsync.js +37 -0
- package/dist/commands/devbox/list.js +390 -205
- package/dist/commands/devbox/logs.js +40 -0
- package/dist/commands/devbox/read.js +49 -0
- package/dist/commands/devbox/resume.js +37 -0
- package/dist/commands/devbox/rsync.js +118 -0
- package/dist/commands/devbox/scp.js +122 -0
- package/dist/commands/devbox/shutdown.js +37 -0
- package/dist/commands/devbox/ssh.js +104 -0
- package/dist/commands/devbox/suspend.js +37 -0
- package/dist/commands/devbox/tunnel.js +120 -0
- package/dist/commands/devbox/upload.js +10 -10
- package/dist/commands/devbox/write.js +51 -0
- package/dist/commands/mcp-http.js +37 -0
- package/dist/commands/mcp-install.js +120 -0
- package/dist/commands/mcp.js +30 -0
- package/dist/commands/menu.js +70 -0
- package/dist/commands/object/delete.js +37 -0
- package/dist/commands/object/download.js +88 -0
- package/dist/commands/object/get.js +37 -0
- package/dist/commands/object/list.js +112 -0
- package/dist/commands/object/upload.js +130 -0
- package/dist/commands/snapshot/create.js +12 -11
- package/dist/commands/snapshot/delete.js +8 -8
- package/dist/commands/snapshot/list.js +59 -91
- package/dist/commands/snapshot/status.js +37 -0
- package/dist/components/ActionsPopup.js +16 -13
- package/dist/components/Banner.js +5 -8
- package/dist/components/Breadcrumb.js +6 -6
- package/dist/components/DetailView.js +7 -4
- package/dist/components/DevboxActionsMenu.js +347 -189
- package/dist/components/DevboxCard.js +15 -14
- package/dist/components/DevboxCreatePage.js +147 -113
- package/dist/components/DevboxDetailPage.js +182 -103
- package/dist/components/ErrorMessage.js +5 -4
- package/dist/components/Header.js +4 -3
- package/dist/components/MainMenu.js +72 -0
- package/dist/components/MetadataDisplay.js +17 -9
- package/dist/components/OperationsMenu.js +6 -5
- package/dist/components/ResourceActionsMenu.js +117 -0
- package/dist/components/ResourceListView.js +213 -0
- package/dist/components/Spinner.js +5 -4
- package/dist/components/StatusBadge.js +81 -31
- package/dist/components/SuccessMessage.js +4 -3
- package/dist/components/Table.example.js +53 -23
- package/dist/components/Table.js +19 -11
- package/dist/hooks/useCursorPagination.js +125 -0
- package/dist/mcp/server-http.js +416 -0
- package/dist/mcp/server.js +397 -0
- package/dist/utils/CommandExecutor.js +22 -6
- package/dist/utils/client.js +20 -3
- package/dist/utils/config.js +40 -4
- package/dist/utils/interactiveCommand.js +14 -0
- package/dist/utils/output.js +17 -17
- package/dist/utils/ssh.js +160 -0
- package/dist/utils/sshSession.js +29 -0
- package/dist/utils/theme.js +22 -0
- package/dist/utils/url.js +39 -0
- package/package.json +29 -4
|
@@ -1,44 +1,28 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
2
|
-
import React from
|
|
3
|
-
import { Box, Text, useInput, useApp, useStdout } from
|
|
4
|
-
import TextInput from
|
|
5
|
-
import figures from
|
|
6
|
-
import { getClient } from
|
|
7
|
-
import { SpinnerComponent } from
|
|
8
|
-
import { ErrorMessage } from
|
|
9
|
-
import { getStatusDisplay } from
|
|
10
|
-
import { Breadcrumb } from
|
|
11
|
-
import { Table, createTextColumn } from
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return `${seconds}s ago`;
|
|
22
|
-
const minutes = Math.floor(seconds / 60);
|
|
23
|
-
if (minutes < 60)
|
|
24
|
-
return `${minutes}m ago`;
|
|
25
|
-
const hours = Math.floor(minutes / 60);
|
|
26
|
-
if (hours < 24)
|
|
27
|
-
return `${hours}h ago`;
|
|
28
|
-
const days = Math.floor(hours / 24);
|
|
29
|
-
if (days < 30)
|
|
30
|
-
return `${days}d ago`;
|
|
31
|
-
const months = Math.floor(days / 30);
|
|
32
|
-
if (months < 12)
|
|
33
|
-
return `${months}mo ago`;
|
|
34
|
-
const years = Math.floor(months / 12);
|
|
35
|
-
return `${years}y ago`;
|
|
36
|
-
};
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text, useInput, useApp, useStdout } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import figures from "figures";
|
|
6
|
+
import { getClient } from "../../utils/client.js";
|
|
7
|
+
import { SpinnerComponent } from "../../components/Spinner.js";
|
|
8
|
+
import { ErrorMessage } from "../../components/ErrorMessage.js";
|
|
9
|
+
import { getStatusDisplay } from "../../components/StatusBadge.js";
|
|
10
|
+
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
|
11
|
+
import { Table, createTextColumn } from "../../components/Table.js";
|
|
12
|
+
import { formatTimeAgo } from "../../components/ResourceListView.js";
|
|
13
|
+
import { createExecutor } from "../../utils/CommandExecutor.js";
|
|
14
|
+
import { DevboxDetailPage } from "../../components/DevboxDetailPage.js";
|
|
15
|
+
import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
|
|
16
|
+
import { ResourceActionsMenu } from "../../components/ResourceActionsMenu.js";
|
|
17
|
+
import { ActionsPopup } from "../../components/ActionsPopup.js";
|
|
18
|
+
import { getDevboxUrl } from "../../utils/url.js";
|
|
19
|
+
import { runSSHSession, } from "../../utils/sshSession.js";
|
|
20
|
+
import { colors } from "../../utils/theme.js";
|
|
37
21
|
const DEFAULT_PAGE_SIZE = 10;
|
|
38
|
-
const ListDevboxesUI = ({ status }) => {
|
|
39
|
-
const { exit } = useApp();
|
|
22
|
+
const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => {
|
|
23
|
+
const { exit: inkExit } = useApp();
|
|
40
24
|
const { stdout } = useStdout();
|
|
41
|
-
const [
|
|
25
|
+
const [initialLoading, setInitialLoading] = React.useState(true);
|
|
42
26
|
const [devboxes, setDevboxes] = React.useState([]);
|
|
43
27
|
const [error, setError] = React.useState(null);
|
|
44
28
|
const [currentPage, setCurrentPage] = React.useState(0);
|
|
@@ -50,8 +34,9 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
50
34
|
const [selectedOperation, setSelectedOperation] = React.useState(0);
|
|
51
35
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
52
36
|
const [refreshIcon, setRefreshIcon] = React.useState(0);
|
|
37
|
+
const isNavigating = React.useRef(false);
|
|
53
38
|
const [searchMode, setSearchMode] = React.useState(false);
|
|
54
|
-
const [searchQuery, setSearchQuery] = React.useState(
|
|
39
|
+
const [searchQuery, setSearchQuery] = React.useState("");
|
|
55
40
|
const [totalCount, setTotalCount] = React.useState(0);
|
|
56
41
|
const [hasMore, setHasMore] = React.useState(false);
|
|
57
42
|
const pageCache = React.useRef(new Map());
|
|
@@ -67,55 +52,155 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
67
52
|
const statusTextWidth = 10;
|
|
68
53
|
const timeWidth = 20;
|
|
69
54
|
const capabilitiesWidth = 18;
|
|
70
|
-
const
|
|
55
|
+
const sourceWidth = 26;
|
|
71
56
|
// ID is always full width (25 chars for dbx_31CYd5LLFbBxst8mqnUjO format)
|
|
72
57
|
const idWidth = 26;
|
|
73
58
|
// Responsive layout based on terminal width
|
|
74
|
-
const showCapabilities = terminalWidth >=
|
|
75
|
-
const
|
|
59
|
+
const showCapabilities = terminalWidth >= 140;
|
|
60
|
+
const showSource = terminalWidth >= 120;
|
|
76
61
|
// Name width is flexible and uses remaining space
|
|
77
62
|
let nameWidth = 15;
|
|
78
63
|
if (terminalWidth >= 120) {
|
|
79
|
-
const remainingWidth = terminalWidth -
|
|
64
|
+
const remainingWidth = terminalWidth -
|
|
65
|
+
fixedWidth -
|
|
66
|
+
statusIconWidth -
|
|
67
|
+
idWidth -
|
|
68
|
+
statusTextWidth -
|
|
69
|
+
timeWidth -
|
|
70
|
+
capabilitiesWidth -
|
|
71
|
+
sourceWidth -
|
|
72
|
+
12;
|
|
80
73
|
nameWidth = Math.max(15, remainingWidth);
|
|
81
74
|
}
|
|
82
75
|
else if (terminalWidth >= 110) {
|
|
83
|
-
const remainingWidth = terminalWidth -
|
|
76
|
+
const remainingWidth = terminalWidth -
|
|
77
|
+
fixedWidth -
|
|
78
|
+
statusIconWidth -
|
|
79
|
+
idWidth -
|
|
80
|
+
statusTextWidth -
|
|
81
|
+
timeWidth -
|
|
82
|
+
sourceWidth -
|
|
83
|
+
10;
|
|
84
84
|
nameWidth = Math.max(12, remainingWidth);
|
|
85
85
|
}
|
|
86
86
|
else {
|
|
87
|
-
const remainingWidth = terminalWidth -
|
|
87
|
+
const remainingWidth = terminalWidth -
|
|
88
|
+
fixedWidth -
|
|
89
|
+
statusIconWidth -
|
|
90
|
+
idWidth -
|
|
91
|
+
statusTextWidth -
|
|
92
|
+
timeWidth -
|
|
93
|
+
10;
|
|
88
94
|
nameWidth = Math.max(8, remainingWidth);
|
|
89
95
|
}
|
|
90
96
|
// Define allOperations
|
|
91
97
|
const allOperations = [
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
|
|
98
|
+
{
|
|
99
|
+
key: "logs",
|
|
100
|
+
label: "View Logs",
|
|
101
|
+
color: colors.info,
|
|
102
|
+
icon: figures.info,
|
|
103
|
+
shortcut: "l",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "exec",
|
|
107
|
+
label: "Execute Command",
|
|
108
|
+
color: colors.success,
|
|
109
|
+
icon: figures.play,
|
|
110
|
+
shortcut: "e",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: "upload",
|
|
114
|
+
label: "Upload File",
|
|
115
|
+
color: colors.success,
|
|
116
|
+
icon: figures.arrowUp,
|
|
117
|
+
shortcut: "u",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "snapshot",
|
|
121
|
+
label: "Create Snapshot",
|
|
122
|
+
color: colors.warning,
|
|
123
|
+
icon: figures.circleFilled,
|
|
124
|
+
shortcut: "n",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: "ssh",
|
|
128
|
+
label: "SSH onto the box",
|
|
129
|
+
color: colors.primary,
|
|
130
|
+
icon: figures.arrowRight,
|
|
131
|
+
shortcut: "s",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "tunnel",
|
|
135
|
+
label: "Open Tunnel",
|
|
136
|
+
color: colors.secondary,
|
|
137
|
+
icon: figures.pointerSmall,
|
|
138
|
+
shortcut: "t",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: "suspend",
|
|
142
|
+
label: "Suspend Devbox",
|
|
143
|
+
color: colors.warning,
|
|
144
|
+
icon: figures.squareSmallFilled,
|
|
145
|
+
shortcut: "p",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: "resume",
|
|
149
|
+
label: "Resume Devbox",
|
|
150
|
+
color: colors.success,
|
|
151
|
+
icon: figures.play,
|
|
152
|
+
shortcut: "r",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
key: "delete",
|
|
156
|
+
label: "Shutdown Devbox",
|
|
157
|
+
color: colors.error,
|
|
158
|
+
icon: figures.cross,
|
|
159
|
+
shortcut: "d",
|
|
160
|
+
},
|
|
101
161
|
];
|
|
162
|
+
// Check if we need to focus on a specific devbox after returning from SSH
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
if (focusDevboxId && devboxes.length > 0 && !initialLoading) {
|
|
165
|
+
// Find the devbox in the current page
|
|
166
|
+
const devboxIndex = devboxes.findIndex((d) => d.id === focusDevboxId);
|
|
167
|
+
if (devboxIndex !== -1) {
|
|
168
|
+
setSelectedIndex(devboxIndex);
|
|
169
|
+
setShowDetails(true);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}, [devboxes, initialLoading, focusDevboxId]);
|
|
173
|
+
// Clear cache when search query changes
|
|
102
174
|
React.useEffect(() => {
|
|
103
|
-
|
|
175
|
+
pageCache.current.clear();
|
|
176
|
+
lastIdCache.current.clear();
|
|
177
|
+
setCurrentPage(0);
|
|
178
|
+
}, [searchQuery]);
|
|
179
|
+
React.useEffect(() => {
|
|
180
|
+
const list = async (isInitialLoad = false, isBackgroundRefresh = false) => {
|
|
104
181
|
try {
|
|
182
|
+
// Set navigating flag at the start (but not for background refresh)
|
|
183
|
+
if (!isBackgroundRefresh) {
|
|
184
|
+
isNavigating.current = true;
|
|
185
|
+
}
|
|
105
186
|
// Only show refreshing indicator on initial load
|
|
106
187
|
if (isInitialLoad) {
|
|
107
188
|
setRefreshing(true);
|
|
108
189
|
}
|
|
109
190
|
// Check if we have cached data for this page
|
|
110
|
-
if (!isInitialLoad &&
|
|
191
|
+
if (!isInitialLoad &&
|
|
192
|
+
!isBackgroundRefresh &&
|
|
193
|
+
pageCache.current.has(currentPage)) {
|
|
111
194
|
setDevboxes(pageCache.current.get(currentPage) || []);
|
|
112
|
-
|
|
195
|
+
isNavigating.current = false;
|
|
113
196
|
return;
|
|
114
197
|
}
|
|
115
198
|
const client = getClient();
|
|
116
199
|
const pageDevboxes = [];
|
|
117
200
|
// Get starting_after cursor from previous page's last ID
|
|
118
|
-
const startingAfter = currentPage > 0
|
|
201
|
+
const startingAfter = currentPage > 0
|
|
202
|
+
? lastIdCache.current.get(currentPage - 1)
|
|
203
|
+
: undefined;
|
|
119
204
|
// Build query params
|
|
120
205
|
const queryParams = {
|
|
121
206
|
limit: PAGE_SIZE,
|
|
@@ -126,6 +211,9 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
126
211
|
if (status) {
|
|
127
212
|
queryParams.status = status;
|
|
128
213
|
}
|
|
214
|
+
if (searchQuery) {
|
|
215
|
+
queryParams.search = searchQuery;
|
|
216
|
+
}
|
|
129
217
|
// Fetch only the current page
|
|
130
218
|
const page = await client.devboxes.list(queryParams);
|
|
131
219
|
// Collect items from the page - only get PAGE_SIZE items, don't auto-paginate
|
|
@@ -159,25 +247,31 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
159
247
|
setError(err);
|
|
160
248
|
}
|
|
161
249
|
finally {
|
|
162
|
-
|
|
163
|
-
|
|
250
|
+
if (!isBackgroundRefresh) {
|
|
251
|
+
isNavigating.current = false;
|
|
252
|
+
}
|
|
253
|
+
// Only set initialLoading to false after first successful load
|
|
164
254
|
if (isInitialLoad) {
|
|
255
|
+
setInitialLoading(false);
|
|
165
256
|
setTimeout(() => setRefreshing(false), 300);
|
|
166
257
|
}
|
|
167
258
|
}
|
|
168
259
|
};
|
|
169
|
-
|
|
170
|
-
|
|
260
|
+
// Only treat as initial load on first mount
|
|
261
|
+
const isFirstMount = initialLoading;
|
|
262
|
+
list(isFirstMount, false);
|
|
263
|
+
// Poll every 3 seconds (increased from 2), but only when in list view and not navigating
|
|
171
264
|
const interval = setInterval(() => {
|
|
172
|
-
if (!showDetails &&
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
265
|
+
if (!showDetails &&
|
|
266
|
+
!showCreate &&
|
|
267
|
+
!showActions &&
|
|
268
|
+
!isNavigating.current) {
|
|
269
|
+
// Don't clear cache on background refresh - just update the current page
|
|
270
|
+
list(false, true);
|
|
177
271
|
}
|
|
178
272
|
}, 3000);
|
|
179
273
|
return () => clearInterval(interval);
|
|
180
|
-
}, [showDetails, showCreate, showActions, currentPage]);
|
|
274
|
+
}, [showDetails, showCreate, showActions, currentPage, searchQuery]);
|
|
181
275
|
// Animate refresh icon only when in list view
|
|
182
276
|
React.useEffect(() => {
|
|
183
277
|
if (showDetails || showCreate || showActions) {
|
|
@@ -189,12 +283,17 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
189
283
|
return () => clearInterval(interval);
|
|
190
284
|
}, [showDetails, showCreate, showActions]);
|
|
191
285
|
useInput((input, key) => {
|
|
286
|
+
// Handle Ctrl+C to force exit
|
|
287
|
+
if (key.ctrl && input === "c") {
|
|
288
|
+
process.stdout.write("\x1b[?1049l"); // Exit alternate screen
|
|
289
|
+
process.exit(130);
|
|
290
|
+
}
|
|
192
291
|
const pageDevboxes = currentDevboxes.length;
|
|
193
292
|
// Skip input handling when in search mode - let TextInput handle it
|
|
194
293
|
if (searchMode) {
|
|
195
294
|
if (key.escape) {
|
|
196
295
|
setSearchMode(false);
|
|
197
|
-
setSearchQuery(
|
|
296
|
+
setSearchQuery("");
|
|
198
297
|
}
|
|
199
298
|
return;
|
|
200
299
|
}
|
|
@@ -212,7 +311,7 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
212
311
|
}
|
|
213
312
|
// Handle popup navigation
|
|
214
313
|
if (showPopup) {
|
|
215
|
-
if (key.escape || input ===
|
|
314
|
+
if (key.escape || input === "q") {
|
|
216
315
|
console.clear();
|
|
217
316
|
setShowPopup(false);
|
|
218
317
|
setSelectedOperation(0);
|
|
@@ -231,7 +330,7 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
231
330
|
}
|
|
232
331
|
else if (input) {
|
|
233
332
|
// Check for shortcut match
|
|
234
|
-
const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
|
|
333
|
+
const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
|
|
235
334
|
if (matchedOpIndex !== -1) {
|
|
236
335
|
setSelectedOperation(matchedOpIndex);
|
|
237
336
|
console.clear();
|
|
@@ -248,11 +347,15 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
248
347
|
else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
|
|
249
348
|
setSelectedIndex(selectedIndex + 1);
|
|
250
349
|
}
|
|
251
|
-
else if ((input ===
|
|
350
|
+
else if ((input === "n" || key.rightArrow) &&
|
|
351
|
+
!isNavigating.current &&
|
|
352
|
+
currentPage < totalPages - 1) {
|
|
252
353
|
setCurrentPage(currentPage + 1);
|
|
253
354
|
setSelectedIndex(0);
|
|
254
355
|
}
|
|
255
|
-
else if ((input ===
|
|
356
|
+
else if ((input === "p" || key.leftArrow) &&
|
|
357
|
+
!isNavigating.current &&
|
|
358
|
+
currentPage > 0) {
|
|
256
359
|
setCurrentPage(currentPage - 1);
|
|
257
360
|
setSelectedIndex(0);
|
|
258
361
|
}
|
|
@@ -260,26 +363,26 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
260
363
|
console.clear();
|
|
261
364
|
setShowDetails(true);
|
|
262
365
|
}
|
|
263
|
-
else if (input ===
|
|
366
|
+
else if (input === "a") {
|
|
264
367
|
console.clear();
|
|
265
368
|
setShowPopup(true);
|
|
266
369
|
setSelectedOperation(0);
|
|
267
370
|
}
|
|
268
|
-
else if (input ===
|
|
371
|
+
else if (input === "c") {
|
|
269
372
|
console.clear();
|
|
270
373
|
setShowCreate(true);
|
|
271
374
|
}
|
|
272
|
-
else if (input ===
|
|
375
|
+
else if (input === "o" && selectedDevbox) {
|
|
273
376
|
// Open in browser
|
|
274
|
-
const url =
|
|
377
|
+
const url = getDevboxUrl(selectedDevbox.id);
|
|
275
378
|
const openBrowser = async () => {
|
|
276
|
-
const { exec } = await import(
|
|
379
|
+
const { exec } = await import("child_process");
|
|
277
380
|
const platform = process.platform;
|
|
278
381
|
let openCommand;
|
|
279
|
-
if (platform ===
|
|
382
|
+
if (platform === "darwin") {
|
|
280
383
|
openCommand = `open "${url}"`;
|
|
281
384
|
}
|
|
282
|
-
else if (platform ===
|
|
385
|
+
else if (platform === "win32") {
|
|
283
386
|
openCommand = `start "${url}"`;
|
|
284
387
|
}
|
|
285
388
|
else {
|
|
@@ -289,57 +392,65 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
289
392
|
};
|
|
290
393
|
openBrowser();
|
|
291
394
|
}
|
|
292
|
-
else if (input ===
|
|
395
|
+
else if (input === "/") {
|
|
293
396
|
setSearchMode(true);
|
|
294
397
|
}
|
|
295
|
-
else if (key.escape
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
398
|
+
else if (key.escape) {
|
|
399
|
+
if (searchQuery) {
|
|
400
|
+
// Clear search when Esc is pressed and there's an active search
|
|
401
|
+
setSearchQuery("");
|
|
402
|
+
setCurrentPage(0);
|
|
403
|
+
setSelectedIndex(0);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// Go back to home
|
|
407
|
+
if (onBack) {
|
|
408
|
+
onBack();
|
|
409
|
+
}
|
|
410
|
+
else if (onExit) {
|
|
411
|
+
onExit();
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
inkExit();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
303
417
|
}
|
|
304
418
|
});
|
|
305
|
-
//
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
devbox.status?.toLowerCase().includes(query));
|
|
314
|
-
});
|
|
315
|
-
}, [devboxes, searchQuery]);
|
|
316
|
-
const running = filteredDevboxes.filter((d) => d.status === 'running').length;
|
|
317
|
-
const stopped = filteredDevboxes.filter((d) => ['stopped', 'suspended'].includes(d.status)).length;
|
|
318
|
-
// Current page is already fetched, no need to slice
|
|
319
|
-
const currentDevboxes = filteredDevboxes;
|
|
419
|
+
// No client-side filtering - search is handled server-side
|
|
420
|
+
const currentDevboxes = devboxes;
|
|
421
|
+
// Ensure selected index is within bounds after filtering
|
|
422
|
+
React.useEffect(() => {
|
|
423
|
+
if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) {
|
|
424
|
+
setSelectedIndex(Math.max(0, currentDevboxes.length - 1));
|
|
425
|
+
}
|
|
426
|
+
}, [currentDevboxes.length, selectedIndex]);
|
|
320
427
|
const selectedDevbox = currentDevboxes[selectedIndex];
|
|
321
428
|
// Calculate pagination info
|
|
322
429
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
323
430
|
const startIndex = currentPage * PAGE_SIZE;
|
|
324
431
|
const endIndex = startIndex + currentDevboxes.length;
|
|
325
432
|
// Filter operations based on devbox status
|
|
326
|
-
const operations = selectedDevbox
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
433
|
+
const operations = selectedDevbox
|
|
434
|
+
? allOperations.filter((op) => {
|
|
435
|
+
const status = selectedDevbox.status;
|
|
436
|
+
// When suspended: logs and resume
|
|
437
|
+
if (status === "suspended") {
|
|
438
|
+
return op.key === "resume" || op.key === "logs";
|
|
439
|
+
}
|
|
440
|
+
// When not running (shutdown, failure, etc): only logs
|
|
441
|
+
if (status !== "running" &&
|
|
442
|
+
status !== "provisioning" &&
|
|
443
|
+
status !== "initializing") {
|
|
444
|
+
return op.key === "logs";
|
|
445
|
+
}
|
|
446
|
+
// When running: everything except resume
|
|
447
|
+
if (status === "running") {
|
|
448
|
+
return op.key !== "resume";
|
|
449
|
+
}
|
|
450
|
+
// Default for transitional states (provisioning, initializing)
|
|
451
|
+
return op.key === "logs" || op.key === "delete";
|
|
452
|
+
})
|
|
453
|
+
: allOperations;
|
|
343
454
|
// Create view
|
|
344
455
|
if (showCreate) {
|
|
345
456
|
return (_jsx(DevboxCreatePage, { onBack: () => {
|
|
@@ -353,135 +464,209 @@ const ListDevboxesUI = ({ status }) => {
|
|
|
353
464
|
// Actions view
|
|
354
465
|
if (showActions && selectedDevbox) {
|
|
355
466
|
const selectedOp = operations[selectedOperation];
|
|
356
|
-
return (_jsx(
|
|
467
|
+
return (_jsx(ResourceActionsMenu, { resourceType: "devbox", resource: selectedDevbox, onBack: () => {
|
|
357
468
|
setShowActions(false);
|
|
358
469
|
setSelectedOperation(0);
|
|
359
470
|
}, breadcrumbItems: [
|
|
360
|
-
{ label:
|
|
361
|
-
{ label: selectedDevbox.name || selectedDevbox.id, active: true }
|
|
362
|
-
], initialOperation: selectedOp?.key,
|
|
471
|
+
{ label: "Devboxes" },
|
|
472
|
+
{ label: selectedDevbox.name || selectedDevbox.id, active: true },
|
|
473
|
+
], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
|
|
363
474
|
}
|
|
364
475
|
// Details view
|
|
365
476
|
if (showDetails && selectedDevbox) {
|
|
366
|
-
return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
|
|
477
|
+
return (_jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false), onSSHRequest: onSSHRequest }));
|
|
367
478
|
}
|
|
368
479
|
// Show popup with table in background
|
|
369
480
|
if (showPopup && selectedDevbox) {
|
|
370
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
371
|
-
{ label: 'Devboxes', active: true }
|
|
372
|
-
] }), !loading && !error && devboxes.length > 0 && (_jsx(_Fragment, { children: _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
|
|
481
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), !initialLoading && !error && devboxes.length > 0 && (_jsx(_Fragment, { children: _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
|
|
373
482
|
{
|
|
374
|
-
key:
|
|
375
|
-
label:
|
|
483
|
+
key: "statusIcon",
|
|
484
|
+
label: "",
|
|
376
485
|
width: statusIconWidth,
|
|
377
486
|
render: (devbox, index, isSelected) => {
|
|
378
487
|
const statusDisplay = getStatusDisplay(devbox.status);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const padded = icon.padEnd(statusIconWidth, ' ');
|
|
382
|
-
return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
|
|
383
|
-
}
|
|
488
|
+
return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
|
|
489
|
+
},
|
|
384
490
|
},
|
|
385
|
-
createTextColumn(
|
|
491
|
+
createTextColumn("id", "ID", (devbox) => devbox.id, {
|
|
492
|
+
width: idWidth,
|
|
493
|
+
color: colors.textDim,
|
|
494
|
+
dimColor: false,
|
|
495
|
+
bold: false,
|
|
496
|
+
}),
|
|
386
497
|
{
|
|
387
|
-
key:
|
|
388
|
-
label:
|
|
498
|
+
key: "statusText",
|
|
499
|
+
label: "Status",
|
|
389
500
|
width: statusTextWidth,
|
|
390
501
|
render: (devbox, index, isSelected) => {
|
|
391
502
|
const statusDisplay = getStatusDisplay(devbox.status);
|
|
392
503
|
const truncated = statusDisplay.text.slice(0, statusTextWidth);
|
|
393
|
-
const padded = truncated.padEnd(statusTextWidth,
|
|
394
|
-
return (_jsx(Text, { color: isSelected ?
|
|
395
|
-
}
|
|
504
|
+
const padded = truncated.padEnd(statusTextWidth, " ");
|
|
505
|
+
return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: padded }));
|
|
506
|
+
},
|
|
396
507
|
},
|
|
397
|
-
createTextColumn(
|
|
398
|
-
|
|
399
|
-
|
|
508
|
+
createTextColumn("name", "Name", (devbox) => devbox.name || "", {
|
|
509
|
+
width: nameWidth,
|
|
510
|
+
dimColor: false,
|
|
511
|
+
}),
|
|
512
|
+
createTextColumn("capabilities", "Capabilities", (devbox) => {
|
|
513
|
+
const hasCapabilities = devbox.capabilities &&
|
|
514
|
+
devbox.capabilities.filter((c) => c !== "unknown")
|
|
515
|
+
.length > 0;
|
|
400
516
|
return hasCapabilities
|
|
401
517
|
? `[${devbox.capabilities
|
|
402
|
-
.filter((c) => c !==
|
|
403
|
-
.map((c) => c ===
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
518
|
+
.filter((c) => c !== "unknown")
|
|
519
|
+
.map((c) => c === "computer_usage"
|
|
520
|
+
? "comp"
|
|
521
|
+
: c === "browser_usage"
|
|
522
|
+
? "browser"
|
|
523
|
+
: c === "docker_in_docker"
|
|
524
|
+
? "docker"
|
|
525
|
+
: c)
|
|
526
|
+
.join(",")}]`
|
|
527
|
+
: "";
|
|
528
|
+
}, {
|
|
529
|
+
width: capabilitiesWidth,
|
|
530
|
+
color: colors.info,
|
|
531
|
+
dimColor: false,
|
|
532
|
+
bold: false,
|
|
533
|
+
visible: showCapabilities,
|
|
534
|
+
}),
|
|
535
|
+
createTextColumn("source", "Source", (devbox) => devbox.blueprint_id
|
|
536
|
+
? devbox.blueprint_id
|
|
537
|
+
: devbox.snapshot_id
|
|
538
|
+
? devbox.snapshot_id
|
|
539
|
+
: "", {
|
|
540
|
+
width: sourceWidth,
|
|
541
|
+
color: colors.info,
|
|
542
|
+
dimColor: false,
|
|
543
|
+
bold: false,
|
|
544
|
+
visible: showSource,
|
|
545
|
+
}),
|
|
546
|
+
createTextColumn("created", "Created", (devbox) => devbox.create_time_ms
|
|
547
|
+
? formatTimeAgo(devbox.create_time_ms)
|
|
548
|
+
: "", {
|
|
549
|
+
width: timeWidth,
|
|
550
|
+
color: colors.textDim,
|
|
551
|
+
dimColor: false,
|
|
552
|
+
bold: false,
|
|
553
|
+
}),
|
|
409
554
|
] }) })), _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) }) })] }));
|
|
410
555
|
}
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
556
|
+
// If initial loading or error, show that first
|
|
557
|
+
if (initialLoading) {
|
|
558
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
|
|
559
|
+
}
|
|
560
|
+
if (error) {
|
|
561
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
|
|
562
|
+
}
|
|
563
|
+
// List view with data (always show, even if empty)
|
|
564
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes", active: true }] }), currentDevboxes && currentDevboxes.length >= 0 && (_jsxs(_Fragment, { children: [searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.pointerSmall, " Search:", " "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search (name, id, status)...", onSubmit: () => {
|
|
415
565
|
setSearchMode(false);
|
|
416
566
|
setCurrentPage(0);
|
|
417
567
|
setSelectedIndex(0);
|
|
418
|
-
} }),
|
|
568
|
+
} }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Esc to cancel]"] })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: searchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", totalCount, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
|
|
419
569
|
{
|
|
420
|
-
key:
|
|
421
|
-
label:
|
|
570
|
+
key: "statusIcon",
|
|
571
|
+
label: "",
|
|
422
572
|
width: statusIconWidth,
|
|
423
573
|
render: (devbox, index, isSelected) => {
|
|
424
574
|
const statusDisplay = getStatusDisplay(devbox.status);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const padded = icon.padEnd(statusIconWidth, ' ');
|
|
428
|
-
return (_jsx(Text, { color: isSelected ? 'white' : statusDisplay.color, bold: true, inverse: isSelected, children: padded }));
|
|
429
|
-
}
|
|
575
|
+
return (_jsxs(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
|
|
576
|
+
},
|
|
430
577
|
},
|
|
431
|
-
createTextColumn(
|
|
578
|
+
createTextColumn("id", "ID", (devbox) => devbox.id, {
|
|
579
|
+
width: idWidth,
|
|
580
|
+
color: colors.textDim,
|
|
581
|
+
dimColor: false,
|
|
582
|
+
bold: false,
|
|
583
|
+
}),
|
|
432
584
|
{
|
|
433
|
-
key:
|
|
434
|
-
label:
|
|
585
|
+
key: "statusText",
|
|
586
|
+
label: "Status",
|
|
435
587
|
width: statusTextWidth,
|
|
436
588
|
render: (devbox, index, isSelected) => {
|
|
437
589
|
const statusDisplay = getStatusDisplay(devbox.status);
|
|
438
590
|
const truncated = statusDisplay.text.slice(0, statusTextWidth);
|
|
439
|
-
const padded = truncated.padEnd(statusTextWidth,
|
|
440
|
-
return (_jsx(Text, { color: isSelected ?
|
|
441
|
-
}
|
|
591
|
+
const padded = truncated.padEnd(statusTextWidth, " ");
|
|
592
|
+
return (_jsx(Text, { color: isSelected ? "white" : statusDisplay.color, bold: true, inverse: isSelected, wrap: "truncate", children: padded }));
|
|
593
|
+
},
|
|
442
594
|
},
|
|
443
|
-
createTextColumn(
|
|
444
|
-
|
|
445
|
-
|
|
595
|
+
createTextColumn("name", "Name", (devbox) => devbox.name || "", {
|
|
596
|
+
width: nameWidth,
|
|
597
|
+
}),
|
|
598
|
+
createTextColumn("capabilities", "Capabilities", (devbox) => {
|
|
599
|
+
const hasCapabilities = devbox.capabilities &&
|
|
600
|
+
devbox.capabilities.filter((c) => c !== "unknown")
|
|
601
|
+
.length > 0;
|
|
446
602
|
return hasCapabilities
|
|
447
603
|
? `[${devbox.capabilities
|
|
448
|
-
.filter((c) => c !==
|
|
449
|
-
.map((c) => c ===
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
604
|
+
.filter((c) => c !== "unknown")
|
|
605
|
+
.map((c) => c === "computer_usage"
|
|
606
|
+
? "comp"
|
|
607
|
+
: c === "browser_usage"
|
|
608
|
+
? "browser"
|
|
609
|
+
: c === "docker_in_docker"
|
|
610
|
+
? "docker"
|
|
611
|
+
: c)
|
|
612
|
+
.join(",")}]`
|
|
613
|
+
: "";
|
|
614
|
+
}, {
|
|
615
|
+
width: capabilitiesWidth,
|
|
616
|
+
color: colors.info,
|
|
617
|
+
dimColor: false,
|
|
618
|
+
bold: false,
|
|
619
|
+
visible: showCapabilities,
|
|
620
|
+
}),
|
|
621
|
+
createTextColumn("source", "Source", (devbox) => devbox.blueprint_id
|
|
622
|
+
? devbox.blueprint_id
|
|
623
|
+
: devbox.snapshot_id
|
|
624
|
+
? devbox.snapshot_id
|
|
625
|
+
: "", {
|
|
626
|
+
width: sourceWidth,
|
|
627
|
+
color: colors.info,
|
|
628
|
+
dimColor: false,
|
|
629
|
+
bold: false,
|
|
630
|
+
visible: showSource,
|
|
631
|
+
}),
|
|
632
|
+
createTextColumn("created", "Created", (devbox) => devbox.create_time_ms
|
|
633
|
+
? formatTimeAgo(devbox.create_time_ms)
|
|
634
|
+
: "", {
|
|
635
|
+
width: timeWidth,
|
|
636
|
+
color: colors.textDim,
|
|
637
|
+
dimColor: false,
|
|
638
|
+
bold: false,
|
|
639
|
+
}),
|
|
640
|
+
] }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), hasMore && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(more available)"] })), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: colors.primary, children: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][refreshIcon % 10] })) : (_jsx(Text, { color: colors.success, children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Enter] Details \u2022 [a] Actions \u2022 [c] Create \u2022 [/] Search \u2022 [o] Browser \u2022 [Esc] Back"] })] })] }))] }));
|
|
456
641
|
};
|
|
457
|
-
|
|
642
|
+
// Export the UI component for use in the main menu
|
|
643
|
+
export { ListDevboxesUI };
|
|
644
|
+
export async function listDevboxes(options, focusDevboxId) {
|
|
458
645
|
const executor = createExecutor(options);
|
|
646
|
+
let sshSessionConfig = null;
|
|
459
647
|
await executor.executeList(async () => {
|
|
460
648
|
const client = executor.getClient();
|
|
461
649
|
return executor.fetchFromIterator(client.devboxes.list(), {
|
|
462
|
-
filter: options.status
|
|
650
|
+
filter: options.status
|
|
651
|
+
? (devbox) => devbox.status === options.status
|
|
652
|
+
: undefined,
|
|
463
653
|
limit: DEFAULT_PAGE_SIZE,
|
|
464
654
|
});
|
|
465
|
-
}, () => _jsx(ListDevboxesUI, { status: options.status
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
`${sshCommand.sshUser}@${sshCommand.url}`
|
|
482
|
-
], {
|
|
483
|
-
stdio: 'inherit'
|
|
484
|
-
});
|
|
485
|
-
process.exit(result.status || 0);
|
|
655
|
+
}, () => (_jsx(ListDevboxesUI, { status: options.status, focusDevboxId: focusDevboxId, onSSHRequest: (config) => {
|
|
656
|
+
sshSessionConfig = config;
|
|
657
|
+
} })), DEFAULT_PAGE_SIZE);
|
|
658
|
+
// If SSH was requested, handle it now after Ink has exited
|
|
659
|
+
if (sshSessionConfig) {
|
|
660
|
+
const result = await runSSHSession(sshSessionConfig);
|
|
661
|
+
if (result.shouldRestart) {
|
|
662
|
+
console.clear();
|
|
663
|
+
console.log(`\nSSH session ended. Returning to CLI...\n`);
|
|
664
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
665
|
+
// Restart the list view with the devbox ID to focus on
|
|
666
|
+
await listDevboxes(options, result.returnToDevboxId);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
process.exit(result.exitCode);
|
|
670
|
+
}
|
|
486
671
|
}
|
|
487
672
|
}
|