Skip to content

Commit 62fa6dc

Browse files
authored
feat: adding links to detail pages IE: allowing you to view the source of a devbox (#112)
<img width="657" height="385" alt="image" src="https://github.com/user-attachments/assets/1a70e8c7-b8bb-4d60-b2cf-bc2337303a3f" />
1 parent b3479fe commit 62fa6dc

21 files changed

Lines changed: 1600 additions & 715 deletions

src/commands/blueprint/list.tsx

Lines changed: 247 additions & 178 deletions
Large diffs are not rendered by default.

src/commands/devbox/list.tsx

Lines changed: 135 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { Box, Text, useInput, useApp } from "ink";
2+
import { Box, Text, useApp } from "ink";
33
import figures from "figures";
44
import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination";
55
import { getClient } from "../../utils/client.js";
@@ -22,6 +22,11 @@ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
2222
import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
2323
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
2424
import { useListSearch } from "../../hooks/useListSearch.js";
25+
import { openInBrowser } from "../../utils/browser.js";
26+
import {
27+
useInputHandler,
28+
type InputMode,
29+
} from "../../hooks/useInputHandler.js";
2530
import { colors } from "../../utils/theme.js";
2631
import { useDevboxStore, type Devbox } from "../../store/devboxStore.js";
2732

@@ -447,122 +452,143 @@ const ListDevboxesUI = ({
447452
})
448453
: allOperations;
449454

450-
useInput((input, key) => {
451-
const pageDevboxes = devboxes.length;
452-
453-
// Skip input handling when in search mode - let TextInput handle it
454-
if (search.searchMode) {
455-
if (key.escape) {
456-
search.cancelSearch();
457-
}
458-
return;
459-
}
460-
461-
// Skip input handling when in details view
462-
if (showDetails) {
463-
return;
464-
}
465-
466-
// Skip input handling when in create view
467-
if (showCreate) {
468-
return;
455+
const closePopup = React.useCallback(() => {
456+
setShowPopup(false);
457+
setSelectedOperation(0);
458+
}, []);
459+
460+
const handleListEscape = React.useCallback(() => {
461+
if (search.handleEscape()) return;
462+
if (onBack) {
463+
onBack();
464+
} else if (onExit) {
465+
onExit();
466+
} else {
467+
inkExit();
469468
}
469+
}, [search, onBack, onExit, inkExit]);
470470

471-
// Skip input handling when in actions view
472-
if (showActions) {
473-
return;
474-
}
475-
476-
// Handle popup navigation
477-
if (showPopup) {
478-
if (key.escape || input === "q") {
479-
setShowPopup(false);
480-
setSelectedOperation(0);
481-
} else if (key.upArrow && selectedOperation > 0) {
482-
setSelectedOperation(selectedOperation - 1);
483-
} else if (key.downArrow && selectedOperation < operations.length - 1) {
484-
setSelectedOperation(selectedOperation + 1);
485-
} else if (key.return) {
486-
setShowPopup(false);
487-
setShowActions(true);
488-
} else if (input) {
489-
const matchedOpIndex = operations.findIndex(
490-
(op) => op.shortcut === input,
491-
);
492-
if (matchedOpIndex !== -1) {
493-
setSelectedOperation(matchedOpIndex);
494-
setShowPopup(false);
495-
setShowActions(true);
496-
}
497-
}
498-
return;
499-
}
471+
const handleOpenInBrowser = React.useCallback(() => {
472+
if (!selectedDevbox) return;
473+
openInBrowser(getDevboxUrl(selectedDevbox.id));
474+
}, [selectedDevbox]);
500475

501-
// Handle list view
502-
if (key.upArrow && selectedIndex > 0) {
503-
setSelectedIndex(selectedIndex - 1);
504-
} else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
505-
setSelectedIndex(selectedIndex + 1);
506-
} else if (
507-
(input === "n" || key.rightArrow) &&
508-
!loading &&
509-
!navigating &&
510-
hasMore
511-
) {
476+
const goToNextPage = React.useCallback(() => {
477+
if (!loading && !navigating && hasMore) {
512478
nextPage();
513479
setSelectedIndex(0);
514-
} else if (
515-
(input === "p" || key.leftArrow) &&
516-
!loading &&
517-
!navigating &&
518-
hasPrev
519-
) {
480+
}
481+
}, [loading, navigating, hasMore, nextPage]);
482+
483+
const goToPrevPage = React.useCallback(() => {
484+
if (!loading && !navigating && hasPrev) {
520485
prevPage();
521486
setSelectedIndex(0);
522-
} else if (key.return) {
523-
if (onNavigateToDetail && selectedDevbox) {
524-
onNavigateToDetail(selectedDevbox.id);
525-
} else {
526-
setShowDetails(true);
527-
}
528-
} else if (input === "a") {
529-
setShowPopup(true);
530-
setSelectedOperation(0);
531-
} else if (input === "c") {
532-
setShowCreate(true);
533-
} else if (input === "o" && selectedDevbox) {
534-
const url = getDevboxUrl(selectedDevbox.id);
535-
const openBrowser = async () => {
536-
const { exec } = await import("child_process");
537-
const platform = process.platform;
538-
539-
let openCommand: string;
540-
if (platform === "darwin") {
541-
openCommand = `open "${url}"`;
542-
} else if (platform === "win32") {
543-
openCommand = `start "${url}"`;
544-
} else {
545-
openCommand = `xdg-open "${url}"`;
546-
}
547-
548-
exec(openCommand);
549-
};
550-
openBrowser();
551-
} else if (input === "/") {
552-
search.enterSearchMode();
553-
} else if (key.escape) {
554-
if (search.handleEscape()) {
555-
return;
556-
}
557-
if (onBack) {
558-
onBack();
559-
} else if (onExit) {
560-
onExit();
561-
} else {
562-
inkExit();
563-
}
564487
}
565-
});
488+
}, [loading, navigating, hasPrev, prevPage]);
489+
490+
const inputModes: InputMode[] = React.useMemo(
491+
() => [
492+
// Search mode: only escape to cancel, swallow everything else
493+
{
494+
name: "search",
495+
active: () => search.searchMode,
496+
bindings: {
497+
escape: () => search.cancelSearch(),
498+
},
499+
captureAll: true,
500+
},
501+
// Subview guards: swallow all input when a child view is active
502+
{
503+
name: "subviews",
504+
active: () => showDetails || showCreate || showActions,
505+
bindings: {},
506+
captureAll: true,
507+
},
508+
// Popup navigation
509+
{
510+
name: "popup",
511+
active: () => showPopup,
512+
bindings: {
513+
escape: closePopup,
514+
q: closePopup,
515+
up: () => {
516+
if (selectedOperation > 0)
517+
setSelectedOperation(selectedOperation - 1);
518+
},
519+
down: () => {
520+
if (selectedOperation < operations.length - 1)
521+
setSelectedOperation(selectedOperation + 1);
522+
},
523+
enter: () => {
524+
setShowPopup(false);
525+
setShowActions(true);
526+
},
527+
},
528+
onUnmatched: (input) => {
529+
const idx = operations.findIndex((op) => op.shortcut === input);
530+
if (idx !== -1) {
531+
setSelectedOperation(idx);
532+
setShowPopup(false);
533+
setShowActions(true);
534+
}
535+
},
536+
},
537+
// List navigation (default mode)
538+
{
539+
name: "list",
540+
active: () => true,
541+
bindings: {
542+
up: () => {
543+
if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1);
544+
},
545+
down: () => {
546+
if (selectedIndex < devboxes.length - 1)
547+
setSelectedIndex(selectedIndex + 1);
548+
},
549+
n: goToNextPage,
550+
right: goToNextPage,
551+
p: goToPrevPage,
552+
left: goToPrevPage,
553+
enter: () => {
554+
if (onNavigateToDetail && selectedDevbox) {
555+
onNavigateToDetail(selectedDevbox.id);
556+
} else {
557+
setShowDetails(true);
558+
}
559+
},
560+
a: () => {
561+
setShowPopup(true);
562+
setSelectedOperation(0);
563+
},
564+
c: () => setShowCreate(true),
565+
o: handleOpenInBrowser,
566+
"/": () => search.enterSearchMode(),
567+
escape: handleListEscape,
568+
},
569+
},
570+
],
571+
[
572+
search,
573+
showDetails,
574+
showCreate,
575+
showActions,
576+
showPopup,
577+
closePopup,
578+
selectedOperation,
579+
operations,
580+
selectedIndex,
581+
devboxes.length,
582+
goToNextPage,
583+
goToPrevPage,
584+
onNavigateToDetail,
585+
selectedDevbox,
586+
handleOpenInBrowser,
587+
handleListEscape,
588+
],
589+
);
590+
591+
useInputHandler(inputModes);
566592

567593
// Create view
568594
if (showCreate) {

src/commands/devbox/tunnel.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getClient } from "../../utils/client.js";
77
import { output, outputError } from "../../utils/output.js";
88
import { processUtils } from "../../utils/processUtils.js";
99
import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
10+
import { openInBrowser } from "../../utils/browser.js";
1011

1112
interface TunnelOptions {
1213
ports: string;
@@ -83,25 +84,9 @@ export async function createTunnel(devboxId: string, options: TunnelOptions) {
8384
// Open browser if --open flag is set
8485
if (options.open) {
8586
// Small delay to let the tunnel establish
86-
setTimeout(async () => {
87-
const { exec } = await import("child_process");
88-
const platform = process.platform;
89-
90-
let openCommand: string;
91-
if (platform === "darwin") {
92-
openCommand = `open "${tunnelUrl}"`;
93-
} else if (platform === "win32") {
94-
openCommand = `start "${tunnelUrl}"`;
95-
} else {
96-
openCommand = `xdg-open "${tunnelUrl}"`;
97-
}
98-
99-
exec(openCommand, (error) => {
100-
if (error) {
101-
console.log(`\nCould not open browser: ${error.message}`);
102-
}
103-
});
104-
}, 1000);
87+
setTimeout(() => {
88+
openInBrowser(tunnelUrl);
89+
}, 1000); // TODO: Not going to need this soon with tunnels v2
10590
}
10691

10792
tunnelProcess.on("close", (code) => {

0 commit comments

Comments
 (0)