Skip to content

Commit ffc011b

Browse files
committed
cp dines
1 parent 541f2c0 commit ffc011b

5 files changed

Lines changed: 350 additions & 40 deletions

File tree

src/commands/devbox/list.tsx

Lines changed: 249 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createExecutor } from '../../utils/CommandExecutor.js';
1616
import { DevboxDetailPage } from '../../components/DevboxDetailPage.js';
1717
import { DevboxCreatePage } from '../../components/DevboxCreatePage.js';
1818
import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js';
19+
import { ActionsPopup } from '../../components/ActionsPopup.js';
1920

2021
// Format time ago in a succinct way
2122
const formatTimeAgo = (timestamp: number): string => {
@@ -58,8 +59,12 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
5859
const [showDetails, setShowDetails] = React.useState(false);
5960
const [showCreate, setShowCreate] = React.useState(false);
6061
const [showActions, setShowActions] = React.useState(false);
62+
const [showPopup, setShowPopup] = React.useState(false);
63+
const [selectedOperation, setSelectedOperation] = React.useState(0);
6164
const [refreshing, setRefreshing] = React.useState(false);
6265
const [refreshIcon, setRefreshIcon] = React.useState(0);
66+
const [searchMode, setSearchMode] = React.useState(false);
67+
const [searchQuery, setSearchQuery] = React.useState('');
6368

6469
// Calculate responsive dimensions
6570
const terminalWidth = stdout?.columns || 120;
@@ -96,6 +101,19 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
96101
nameWidth = Math.max(8, remainingWidth);
97102
}
98103

104+
// Define allOperations
105+
const allOperations = [
106+
{ key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' },
107+
{ key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' },
108+
{ key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' },
109+
{ key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' },
110+
{ key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' },
111+
{ key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' },
112+
{ key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' },
113+
{ key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' },
114+
{ key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' },
115+
];
116+
99117
React.useEffect(() => {
100118
const list = async (isInitialLoad: boolean = false) => {
101119
try {
@@ -160,6 +178,15 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
160178
useInput((input, key) => {
161179
const pageDevboxes = currentDevboxes.length;
162180

181+
// Skip input handling when in search mode - let TextInput handle it
182+
if (searchMode) {
183+
if (key.escape) {
184+
setSearchMode(false);
185+
setSearchQuery('');
186+
}
187+
return;
188+
}
189+
163190
// Skip input handling when in details view - let DevboxDetailPage handle it
164191
if (showDetails) {
165192
return;
@@ -175,6 +202,34 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
175202
return;
176203
}
177204

205+
// Handle popup navigation
206+
if (showPopup) {
207+
if (key.escape || input === 'q') {
208+
console.clear();
209+
setShowPopup(false);
210+
setSelectedOperation(0);
211+
} else if (key.upArrow && selectedOperation > 0) {
212+
setSelectedOperation(selectedOperation - 1);
213+
} else if (key.downArrow && selectedOperation < operations.length - 1) {
214+
setSelectedOperation(selectedOperation + 1);
215+
} else if (key.return) {
216+
// Execute the selected operation
217+
console.clear();
218+
setShowPopup(false);
219+
setShowActions(true);
220+
} else if (input) {
221+
// Check for shortcut match
222+
const matchedOpIndex = operations.findIndex(op => op.shortcut === input);
223+
if (matchedOpIndex !== -1) {
224+
setSelectedOperation(matchedOpIndex);
225+
console.clear();
226+
setShowPopup(false);
227+
setShowActions(true);
228+
}
229+
}
230+
return;
231+
}
232+
178233
// Handle list view
179234
if (key.upArrow && selectedIndex > 0) {
180235
setSelectedIndex(selectedIndex - 1);
@@ -191,7 +246,8 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
191246
setShowDetails(true);
192247
} else if (input === 'a') {
193248
console.clear();
194-
setShowActions(true);
249+
setShowPopup(true);
250+
setSelectedOperation(0);
195251
} else if (input === 'c') {
196252
console.clear();
197253
setShowCreate(true);
@@ -214,22 +270,66 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
214270
exec(openCommand);
215271
};
216272
openBrowser();
273+
} else if (input === '/') {
274+
setSearchMode(true);
275+
} else if (key.escape && searchQuery) {
276+
// Clear search when Esc is pressed and there's an active search
277+
setSearchQuery('');
278+
setCurrentPage(0);
279+
setSelectedIndex(0);
217280
} else if (input === 'q') {
218281
process.exit(0);
219282
}
220283
});
221284

222-
const running = devboxes.filter((d) => d.status === 'running').length;
223-
const stopped = devboxes.filter((d) =>
285+
// Filter devboxes based on search query
286+
const filteredDevboxes = React.useMemo(() => {
287+
if (!searchQuery.trim()) return devboxes;
288+
289+
const query = searchQuery.toLowerCase();
290+
return devboxes.filter(devbox => {
291+
return (
292+
devbox.id?.toLowerCase().includes(query) ||
293+
devbox.name?.toLowerCase().includes(query) ||
294+
devbox.status?.toLowerCase().includes(query)
295+
);
296+
});
297+
}, [devboxes, searchQuery]);
298+
299+
const running = filteredDevboxes.filter((d) => d.status === 'running').length;
300+
const stopped = filteredDevboxes.filter((d) =>
224301
['stopped', 'suspended'].includes(d.status)
225302
).length;
226303

227-
const totalPages = Math.ceil(devboxes.length / PAGE_SIZE);
304+
const totalPages = Math.ceil(filteredDevboxes.length / PAGE_SIZE);
228305
const startIndex = currentPage * PAGE_SIZE;
229-
const endIndex = Math.min(startIndex + PAGE_SIZE, devboxes.length);
230-
const currentDevboxes = devboxes.slice(startIndex, endIndex);
306+
const endIndex = Math.min(startIndex + PAGE_SIZE, filteredDevboxes.length);
307+
const currentDevboxes = filteredDevboxes.slice(startIndex, endIndex);
231308
const selectedDevbox = currentDevboxes[selectedIndex];
232309

310+
// Filter operations based on devbox status
311+
const operations = selectedDevbox ? allOperations.filter(op => {
312+
const status = selectedDevbox.status;
313+
314+
// When suspended: logs and resume
315+
if (status === 'suspended') {
316+
return op.key === 'resume' || op.key === 'logs';
317+
}
318+
319+
// When not running (shutdown, failure, etc): only logs
320+
if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') {
321+
return op.key === 'logs';
322+
}
323+
324+
// When running: everything except resume
325+
if (status === 'running') {
326+
return op.key !== 'resume';
327+
}
328+
329+
// Default for transitional states (provisioning, initializing)
330+
return op.key === 'logs' || op.key === 'delete';
331+
}) : allOperations;
332+
233333
// Create view
234334
if (showCreate) {
235335
return (
@@ -248,14 +348,20 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
248348

249349
// Actions view
250350
if (showActions && selectedDevbox) {
351+
const selectedOp = operations[selectedOperation];
251352
return (
252353
<DevboxActionsMenu
253354
devbox={selectedDevbox}
254-
onBack={() => setShowActions(false)}
355+
onBack={() => {
356+
setShowActions(false);
357+
setSelectedOperation(0);
358+
}}
255359
breadcrumbItems={[
256360
{ label: 'Devboxes' },
257361
{ label: selectedDevbox.name || selectedDevbox.id, active: true }
258362
]}
363+
initialOperation={selectedOp?.key}
364+
initialOperationIndex={selectedOperation}
259365
/>
260366
);
261367
}
@@ -265,6 +371,112 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
265371
return <DevboxDetailPage devbox={selectedDevbox} onBack={() => setShowDetails(false)} />;
266372
}
267373

374+
// Show popup with table in background
375+
if (showPopup && selectedDevbox) {
376+
return (
377+
<>
378+
<Breadcrumb items={[
379+
{ label: 'Devboxes', active: true }
380+
]} />
381+
{!loading && !error && devboxes.length > 0 && (
382+
<>
383+
<Table
384+
data={currentDevboxes}
385+
keyExtractor={(devbox: any) => devbox.id}
386+
selectedIndex={selectedIndex}
387+
title={`devboxes[${devboxes.length}]`}
388+
columns={[
389+
{
390+
key: 'statusIcon',
391+
label: '',
392+
width: statusIconWidth,
393+
render: (devbox: any, index: number, isSelected: boolean) => {
394+
const statusDisplay = getStatusDisplay(devbox.status);
395+
396+
// Truncate icon to fit width and pad
397+
const icon = statusDisplay.icon.slice(0, statusIconWidth);
398+
const padded = icon.padEnd(statusIconWidth, ' ');
399+
400+
return (
401+
<Text color={isSelected ? 'white' : statusDisplay.color} bold={true} inverse={isSelected}>
402+
{padded}
403+
</Text>
404+
);
405+
}
406+
},
407+
createTextColumn(
408+
'id',
409+
'ID',
410+
(devbox: any) => devbox.id,
411+
{ width: idWidth, color: 'gray', dimColor: true, bold: false }
412+
),
413+
{
414+
key: 'statusText',
415+
label: 'Status',
416+
width: statusTextWidth,
417+
render: (devbox: any, index: number, isSelected: boolean) => {
418+
const statusDisplay = getStatusDisplay(devbox.status);
419+
420+
const truncated = statusDisplay.text.slice(0, statusTextWidth);
421+
const padded = truncated.padEnd(statusTextWidth, ' ');
422+
423+
return (
424+
<Text color={isSelected ? 'white' : statusDisplay.color} bold={true} inverse={isSelected}>
425+
{padded}
426+
</Text>
427+
);
428+
}
429+
},
430+
createTextColumn(
431+
'name',
432+
'Name',
433+
(devbox: any) => devbox.name || '',
434+
{ width: nameWidth, dimColor: true }
435+
),
436+
createTextColumn(
437+
'capabilities',
438+
'Capabilities',
439+
(devbox: any) => {
440+
const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c: string) => c !== 'unknown').length > 0;
441+
return hasCapabilities
442+
? `[${devbox.capabilities
443+
.filter((c: string) => c !== 'unknown')
444+
.map((c: string) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
445+
.join(',')}]`
446+
: '';
447+
},
448+
{ width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }
449+
),
450+
createTextColumn(
451+
'tags',
452+
'Tags',
453+
(devbox: any) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '',
454+
{ width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }
455+
),
456+
createTextColumn(
457+
'created',
458+
'Created',
459+
(devbox: any) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '',
460+
{ width: timeWidth, color: 'gray', dimColor: true, bold: false }
461+
),
462+
]}
463+
/>
464+
</>
465+
)}
466+
467+
{/* Popup overlaying - use negative margin to pull it up over the table */}
468+
<Box marginTop={-Math.min(operations.length + 10, PAGE_SIZE + 5)} justifyContent="center">
469+
<ActionsPopup
470+
devbox={selectedDevbox}
471+
operations={operations}
472+
selectedOperation={selectedOperation}
473+
onClose={() => setShowPopup(false)}
474+
/>
475+
</Box>
476+
</>
477+
);
478+
}
479+
268480
// List view
269481
return (
270482
<>
@@ -285,6 +497,29 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
285497
)}
286498
{!loading && !error && devboxes.length > 0 && (
287499
<>
500+
{searchMode && (
501+
<Box marginBottom={1}>
502+
<Text color="cyan">{figures.pointerSmall} Search: </Text>
503+
<TextInput
504+
value={searchQuery}
505+
onChange={setSearchQuery}
506+
placeholder="Type to search (name, id, status)..."
507+
onSubmit={() => {
508+
setSearchMode(false);
509+
setCurrentPage(0);
510+
setSelectedIndex(0);
511+
}}
512+
/>
513+
<Text color="gray" dimColor> [Esc to cancel]</Text>
514+
</Box>
515+
)}
516+
{searchQuery && !searchMode && (
517+
<Box marginBottom={1}>
518+
<Text color="cyan">{figures.info} Searching for: </Text>
519+
<Text color="yellow" bold>{searchQuery}</Text>
520+
<Text color="gray" dimColor> ({filteredDevboxes.length} results) [/ to edit, Esc to clear]</Text>
521+
</Box>
522+
)}
288523
<Table
289524
data={currentDevboxes}
290525
keyExtractor={(devbox: any) => devbox.id}
@@ -297,17 +532,13 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
297532
width: statusIconWidth,
298533
render: (devbox: any, index: number, isSelected: boolean) => {
299534
const statusDisplay = getStatusDisplay(devbox.status);
300-
const status = devbox.status;
301-
let color: string = 'gray';
302-
if (status === 'running') color = 'green';
303-
else if (status === 'stopped' || status === 'suspended') color = 'gray';
304-
else if (status === 'starting' || status === 'stopping') color = 'yellow';
305-
else if (status === 'failed') color = 'red';
306535

307-
const padded = statusDisplay.icon.padEnd(statusIconWidth, ' ');
536+
// Truncate icon to fit width and pad
537+
const icon = statusDisplay.icon.slice(0, statusIconWidth);
538+
const padded = icon.padEnd(statusIconWidth, ' ');
308539

309540
return (
310-
<Text color={isSelected ? 'white' : color} bold={true} inverse={isSelected}>
541+
<Text color={isSelected ? 'white' : statusDisplay.color} bold={true} inverse={isSelected}>
311542
{padded}
312543
</Text>
313544
);
@@ -325,18 +556,12 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
325556
width: statusTextWidth,
326557
render: (devbox: any, index: number, isSelected: boolean) => {
327558
const statusDisplay = getStatusDisplay(devbox.status);
328-
const status = devbox.status;
329-
let color: string = 'gray';
330-
if (status === 'running') color = 'green';
331-
else if (status === 'stopped' || status === 'suspended') color = 'gray';
332-
else if (status === 'starting' || status === 'stopping') color = 'yellow';
333-
else if (status === 'failed') color = 'red';
334559

335560
const truncated = statusDisplay.text.slice(0, statusTextWidth);
336561
const padded = truncated.padEnd(statusTextWidth, ' ');
337562

338563
return (
339-
<Text color={isSelected ? 'white' : color} bold={true} inverse={isSelected}>
564+
<Text color={isSelected ? 'white' : statusDisplay.color} bold={true} inverse={isSelected}>
340565
{padded}
341566
</Text>
342567
);
@@ -420,9 +645,10 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
420645
</Text>
421646
)}
422647
<Text color="gray" dimColor>
423-
{' '}• [Enter] Details • [a] Actions • [c] Create • [o] Browser • [q] Quit
648+
{' '}• [Enter] Details • [a] Actions • [c] Create • [/] Search • [o] Browser • [q] Quit
424649
</Text>
425650
</Box>
651+
426652
</>
427653
)}
428654
{error && <ErrorMessage message="Failed to list devboxes" error={error} />}

0 commit comments

Comments
 (0)