Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
425 changes: 247 additions & 178 deletions src/commands/blueprint/list.tsx

Large diffs are not rendered by default.

244 changes: 135 additions & 109 deletions src/commands/devbox/list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Box, Text, useInput, useApp } from "ink";
import { Box, Text, useApp } from "ink";
import figures from "figures";
import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination";
import { getClient } from "../../utils/client.js";
Expand All @@ -22,6 +22,11 @@ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
import { useListSearch } from "../../hooks/useListSearch.js";
import { openInBrowser } from "../../utils/browser.js";
import {
useInputHandler,
type InputMode,
} from "../../hooks/useInputHandler.js";
import { colors } from "../../utils/theme.js";
import { useDevboxStore, type Devbox } from "../../store/devboxStore.js";

Expand Down Expand Up @@ -447,122 +452,143 @@ const ListDevboxesUI = ({
})
: allOperations;

useInput((input, key) => {
const pageDevboxes = devboxes.length;

// Skip input handling when in search mode - let TextInput handle it
if (search.searchMode) {
if (key.escape) {
search.cancelSearch();
}
return;
}

// Skip input handling when in details view
if (showDetails) {
return;
}

// Skip input handling when in create view
if (showCreate) {
return;
const closePopup = React.useCallback(() => {
setShowPopup(false);
setSelectedOperation(0);
}, []);

const handleListEscape = React.useCallback(() => {
if (search.handleEscape()) return;
if (onBack) {
onBack();
} else if (onExit) {
onExit();
} else {
inkExit();
}
}, [search, onBack, onExit, inkExit]);

// Skip input handling when in actions view
if (showActions) {
return;
}

// Handle popup navigation
if (showPopup) {
if (key.escape || input === "q") {
setShowPopup(false);
setSelectedOperation(0);
} else if (key.upArrow && selectedOperation > 0) {
setSelectedOperation(selectedOperation - 1);
} else if (key.downArrow && selectedOperation < operations.length - 1) {
setSelectedOperation(selectedOperation + 1);
} else if (key.return) {
setShowPopup(false);
setShowActions(true);
} else if (input) {
const matchedOpIndex = operations.findIndex(
(op) => op.shortcut === input,
);
if (matchedOpIndex !== -1) {
setSelectedOperation(matchedOpIndex);
setShowPopup(false);
setShowActions(true);
}
}
return;
}
const handleOpenInBrowser = React.useCallback(() => {
if (!selectedDevbox) return;
openInBrowser(getDevboxUrl(selectedDevbox.id));
}, [selectedDevbox]);

// Handle list view
if (key.upArrow && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
setSelectedIndex(selectedIndex + 1);
} else if (
(input === "n" || key.rightArrow) &&
!loading &&
!navigating &&
hasMore
) {
const goToNextPage = React.useCallback(() => {
if (!loading && !navigating && hasMore) {
nextPage();
setSelectedIndex(0);
} else if (
(input === "p" || key.leftArrow) &&
!loading &&
!navigating &&
hasPrev
) {
}
}, [loading, navigating, hasMore, nextPage]);

const goToPrevPage = React.useCallback(() => {
if (!loading && !navigating && hasPrev) {
prevPage();
setSelectedIndex(0);
} else if (key.return) {
if (onNavigateToDetail && selectedDevbox) {
onNavigateToDetail(selectedDevbox.id);
} else {
setShowDetails(true);
}
} else if (input === "a") {
setShowPopup(true);
setSelectedOperation(0);
} else if (input === "c") {
setShowCreate(true);
} else if (input === "o" && selectedDevbox) {
const url = getDevboxUrl(selectedDevbox.id);
const openBrowser = async () => {
const { exec } = await import("child_process");
const platform = process.platform;

let openCommand: string;
if (platform === "darwin") {
openCommand = `open "${url}"`;
} else if (platform === "win32") {
openCommand = `start "${url}"`;
} else {
openCommand = `xdg-open "${url}"`;
}

exec(openCommand);
};
openBrowser();
} else if (input === "/") {
search.enterSearchMode();
} else if (key.escape) {
if (search.handleEscape()) {
return;
}
if (onBack) {
onBack();
} else if (onExit) {
onExit();
} else {
inkExit();
}
}
});
}, [loading, navigating, hasPrev, prevPage]);

const inputModes: InputMode[] = React.useMemo(
() => [
// Search mode: only escape to cancel, swallow everything else
{
name: "search",
active: () => search.searchMode,
bindings: {
escape: () => search.cancelSearch(),
},
captureAll: true,
},
// Subview guards: swallow all input when a child view is active
{
name: "subviews",
active: () => showDetails || showCreate || showActions,
bindings: {},
captureAll: true,
},
// Popup navigation
{
name: "popup",
active: () => showPopup,
bindings: {
escape: closePopup,
q: closePopup,
up: () => {
if (selectedOperation > 0)
setSelectedOperation(selectedOperation - 1);
},
down: () => {
if (selectedOperation < operations.length - 1)
setSelectedOperation(selectedOperation + 1);
},
enter: () => {
setShowPopup(false);
setShowActions(true);
},
},
onUnmatched: (input) => {
const idx = operations.findIndex((op) => op.shortcut === input);
if (idx !== -1) {
setSelectedOperation(idx);
setShowPopup(false);
setShowActions(true);
}
},
},
// List navigation (default mode)
{
name: "list",
active: () => true,
bindings: {
up: () => {
if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1);
},
down: () => {
if (selectedIndex < devboxes.length - 1)
setSelectedIndex(selectedIndex + 1);
},
n: goToNextPage,
right: goToNextPage,
p: goToPrevPage,
left: goToPrevPage,
enter: () => {
if (onNavigateToDetail && selectedDevbox) {
onNavigateToDetail(selectedDevbox.id);
} else {
setShowDetails(true);
}
},
a: () => {
setShowPopup(true);
setSelectedOperation(0);
},
c: () => setShowCreate(true),
o: handleOpenInBrowser,
"/": () => search.enterSearchMode(),
escape: handleListEscape,
},
},
],
[
search,
showDetails,
showCreate,
showActions,
showPopup,
closePopup,
selectedOperation,
operations,
selectedIndex,
devboxes.length,
goToNextPage,
goToPrevPage,
onNavigateToDetail,
selectedDevbox,
handleOpenInBrowser,
handleListEscape,
],
);

useInputHandler(inputModes);

// Create view
if (showCreate) {
Expand Down
23 changes: 4 additions & 19 deletions src/commands/devbox/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getClient } from "../../utils/client.js";
import { output, outputError } from "../../utils/output.js";
import { processUtils } from "../../utils/processUtils.js";
import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
import { openInBrowser } from "../../utils/browser.js";

interface TunnelOptions {
ports: string;
Expand Down Expand Up @@ -83,25 +84,9 @@ export async function createTunnel(devboxId: string, options: TunnelOptions) {
// Open browser if --open flag is set
if (options.open) {
// Small delay to let the tunnel establish
setTimeout(async () => {
const { exec } = await import("child_process");
const platform = process.platform;

let openCommand: string;
if (platform === "darwin") {
openCommand = `open "${tunnelUrl}"`;
} else if (platform === "win32") {
openCommand = `start "${tunnelUrl}"`;
} else {
openCommand = `xdg-open "${tunnelUrl}"`;
}

exec(openCommand, (error) => {
if (error) {
console.log(`\nCould not open browser: ${error.message}`);
}
});
}, 1000);
setTimeout(() => {
openInBrowser(tunnelUrl);
}, 1000); // TODO: Not going to need this soon with tunnels v2
}

tunnelProcess.on("close", (code) => {
Expand Down
Loading