diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 358072a5..7a50e627 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text, useInput, useApp } from "ink"; +import { Box, Text, useApp } from "ink"; import TextInput from "ink-text-input"; import figures from "figures"; import type { BlueprintsCursorIDPage } from "@runloop/api-client/pagination"; @@ -24,6 +24,11 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.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 { useNavigation } from "../../store/navigationStore.js"; import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; @@ -426,194 +431,258 @@ const ListBlueprintsUI = ({ }) : allOperations; - // Handle input for all views - useInput((input, key) => { - // Handle search mode input - if (search.searchMode) { - if (key.escape) { - search.cancelSearch(); - } - return; - } + // --- Callbacks for input modes --- - // Handle operation input mode - if (executingOperation && !operationResult && !operationError) { - // Allow escape/q to cancel any operation, even during loading - if (input === "q" || key.escape) { - setExecutingOperation(null); - setOperationInput(""); - setOperationLoading(false); - return; - } + const cancelOperation = React.useCallback(() => { + setExecutingOperation(null); + setOperationInput(""); + setOperationLoading(false); + }, []); - const currentOp = allOperations.find( - (op) => op.key === executingOperation, - ); - if (currentOp?.needsInput) { - if (key.return) { - executeOperation(); - } - } - return; - } + const dismissResult = React.useCallback(() => { + setOperationResult(null); + setOperationError(null); + setExecutingOperation(null); + setOperationInput(""); + }, []); - // Handle operation result display - if (operationResult || operationError) { - if (input === "q" || key.escape || key.return) { - setOperationResult(null); - setOperationError(null); - setExecutingOperation(null); - setOperationInput(""); - } - return; - } + const closePopup = React.useCallback(() => { + setShowPopup(false); + setSelectedOperation(0); + }, []); - // Handle create devbox view - if (showCreateDevbox) { - return; - } + const executePopupSelection = React.useCallback(() => { + setShowPopup(false); + const operationKey = allOperations[selectedOperation].key; - // Handle actions popup overlay - if (showPopup) { - if (key.upArrow && selectedOperation > 0) { - setSelectedOperation(selectedOperation - 1); - } else if ( - key.downArrow && - selectedOperation < allOperations.length - 1 - ) { - setSelectedOperation(selectedOperation + 1); - } else if (key.return) { - setShowPopup(false); - const operationKey = allOperations[selectedOperation].key; - - if (operationKey === "view_details") { - navigate("blueprint-detail", { - blueprintId: selectedBlueprintItem.id, - }); - } else if (operationKey === "create_devbox") { - setSelectedBlueprint(selectedBlueprintItem); - setShowCreateDevbox(true); - } else if (operationKey === "delete") { - // Show delete confirmation - setSelectedBlueprint(selectedBlueprintItem); - setShowDeleteConfirm(true); - } else { - setSelectedBlueprint(selectedBlueprintItem); - setExecutingOperation(operationKey as OperationType); - executeOperation( - selectedBlueprintItem, - operationKey as OperationType, - ); - } - } else if (input === "v" && selectedBlueprintItem) { - // View details hotkey - setShowPopup(false); - navigate("blueprint-detail", { - blueprintId: selectedBlueprintItem.id, - }); - } else if (key.escape || input === "q") { - setShowPopup(false); - setSelectedOperation(0); - } else if (input === "c") { - if ( - selectedBlueprintItem && - (selectedBlueprintItem.status === "build_complete" || - selectedBlueprintItem.status === "building_complete") - ) { - setShowPopup(false); - setSelectedBlueprint(selectedBlueprintItem); - setShowCreateDevbox(true); - } - } else if (input === "d") { - const deleteIndex = allOperations.findIndex( - (op) => op.key === "delete", - ); - if (deleteIndex >= 0) { - // Show delete confirmation - setShowPopup(false); - setSelectedBlueprint(selectedBlueprintItem); - setShowDeleteConfirm(true); - } - } else if (input === "l") { - const logsIndex = allOperations.findIndex( - (op) => op.key === "view_logs", - ); - if (logsIndex >= 0) { - setShowPopup(false); - setSelectedBlueprint(selectedBlueprintItem); - setExecutingOperation("view_logs"); - executeOperation(selectedBlueprintItem, "view_logs"); - } - } - return; + if (operationKey === "view_details") { + navigate("blueprint-detail", { + blueprintId: selectedBlueprintItem.id, + }); + } else if (operationKey === "create_devbox") { + setSelectedBlueprint(selectedBlueprintItem); + setShowCreateDevbox(true); + } else if (operationKey === "delete") { + setSelectedBlueprint(selectedBlueprintItem); + setShowDeleteConfirm(true); + } else { + setSelectedBlueprint(selectedBlueprintItem); + setExecutingOperation(operationKey as OperationType); + executeOperation(selectedBlueprintItem, operationKey as OperationType); } - - // Handle list navigation - const pageBlueprints = blueprints.length; - - if (key.upArrow && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < pageBlueprints - 1) { - setSelectedIndex(selectedIndex + 1); - } else if ( - (input === "n" || key.rightArrow) && - !loading && - !navigating && - hasMore - ) { + }, [ + allOperations, + selectedOperation, + selectedBlueprintItem, + navigate, + executeOperation, + ]); + + 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 && selectedBlueprintItem) { - // Enter key navigates to detail view - navigate("blueprint-detail", { - blueprintId: selectedBlueprintItem.id, - }); - } else if (input === "a") { - setShowPopup(true); - setSelectedOperation(0); - } else if (input === "l" && selectedBlueprintItem) { - setSelectedBlueprint(selectedBlueprintItem); - setExecutingOperation("view_logs"); - executeOperation(selectedBlueprintItem, "view_logs"); - } else if (input === "o" && blueprints[selectedIndex]) { - const url = getBlueprintUrl(blueprints[selectedIndex].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 handleListEscape = React.useCallback(() => { + if (search.handleEscape()) return; + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + }, [search, onBack, onExit, inkExit]); + + const handleOpenInBrowser = React.useCallback(() => { + const bp = blueprints[selectedIndex]; + if (!bp) return; + openInBrowser(getBlueprintUrl(bp.id)); + }, [blueprints, selectedIndex]); + + // --- Declarative input modes --- + + 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, + }, + // Operation input mode: escape/q to cancel, enter to submit + { + name: "operationInput", + active: () => + !!executingOperation && !operationResult && !operationError, + bindings: { + q: cancelOperation, + escape: cancelOperation, + enter: () => { + const currentOp = allOperations.find( + (op) => op.key === executingOperation, + ); + if (currentOp?.needsInput) { + executeOperation(); + } + }, + }, + captureAll: true, + }, + // Operation result display: any dismiss key + { + name: "operationResult", + active: () => !!operationResult || !!operationError, + bindings: { + q: dismissResult, + escape: dismissResult, + enter: dismissResult, + }, + captureAll: true, + }, + // Create devbox subview: swallow all input + { + name: "createDevbox", + active: () => showCreateDevbox, + bindings: {}, + captureAll: true, + }, + // Actions popup overlay + { + name: "popup", + active: () => showPopup, + bindings: { + up: () => { + if (selectedOperation > 0) + setSelectedOperation(selectedOperation - 1); + }, + down: () => { + if (selectedOperation < allOperations.length - 1) + setSelectedOperation(selectedOperation + 1); + }, + enter: executePopupSelection, + escape: closePopup, + q: closePopup, + v: () => { + if (selectedBlueprintItem) { + setShowPopup(false); + navigate("blueprint-detail", { + blueprintId: selectedBlueprintItem.id, + }); + } + }, + c: () => { + if ( + selectedBlueprintItem && + (selectedBlueprintItem.status === "build_complete" || + selectedBlueprintItem.status === "building_complete") + ) { + setShowPopup(false); + setSelectedBlueprint(selectedBlueprintItem); + setShowCreateDevbox(true); + } + }, + d: () => { + const deleteIndex = allOperations.findIndex( + (op) => op.key === "delete", + ); + if (deleteIndex >= 0) { + setShowPopup(false); + setSelectedBlueprint(selectedBlueprintItem); + setShowDeleteConfirm(true); + } + }, + l: () => { + const logsIndex = allOperations.findIndex( + (op) => op.key === "view_logs", + ); + if (logsIndex >= 0) { + setShowPopup(false); + setSelectedBlueprint(selectedBlueprintItem); + setExecutingOperation("view_logs"); + executeOperation(selectedBlueprintItem, "view_logs"); + } + }, + }, + }, + // List navigation (default mode) + { + name: "list", + active: () => true, + bindings: { + up: () => { + if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1); + }, + down: () => { + if (selectedIndex < blueprints.length - 1) + setSelectedIndex(selectedIndex + 1); + }, + n: goToNextPage, + right: goToNextPage, + p: goToPrevPage, + left: goToPrevPage, + enter: () => { + if (selectedBlueprintItem) { + navigate("blueprint-detail", { + blueprintId: selectedBlueprintItem.id, + }); + } + }, + a: () => { + setShowPopup(true); + setSelectedOperation(0); + }, + l: () => { + if (selectedBlueprintItem) { + setSelectedBlueprint(selectedBlueprintItem); + setExecutingOperation("view_logs"); + executeOperation(selectedBlueprintItem, "view_logs"); + } + }, + o: handleOpenInBrowser, + "/": () => search.enterSearchMode(), + escape: handleListEscape, + }, + }, + ], + [ + search, + executingOperation, + operationResult, + operationError, + cancelOperation, + allOperations, + executeOperation, + dismissResult, + showCreateDevbox, + showPopup, + selectedOperation, + executePopupSelection, + closePopup, + selectedBlueprintItem, + navigate, + selectedIndex, + blueprints.length, + goToNextPage, + goToPrevPage, + handleOpenInBrowser, + handleListEscape, + ], + ); + + useInputHandler(inputModes); // Delete confirmation if (showDeleteConfirm && selectedBlueprint) { diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 1fd6bf52..c6b5a5bf 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -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"; @@ -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"; @@ -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) { diff --git a/src/commands/devbox/tunnel.ts b/src/commands/devbox/tunnel.ts index 0641adac..1a24eca2 100644 --- a/src/commands/devbox/tunnel.ts +++ b/src/commands/devbox/tunnel.ts @@ -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; @@ -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) => { diff --git a/src/components/DetailedInfoView.tsx b/src/components/DetailedInfoView.tsx new file mode 100644 index 00000000..e839bac3 --- /dev/null +++ b/src/components/DetailedInfoView.tsx @@ -0,0 +1,100 @@ +/** + * DetailedInfoView - Full-screen scrollable detail view for resources. + * + * Extracted from ResourceDetailPage to reduce component size. + * Displays all resource information in a scrollable, bordered container. + */ +import React from "react"; +import { Box, Text } from "ink"; +import figures from "figures"; +import { Header } from "./Header.js"; +import { StatusBadge } from "./StatusBadge.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +interface DetailedInfoViewProps { + /** Lines of React elements to display */ + detailLines: React.ReactElement[]; + /** Current scroll offset */ + scrollOffset: number; + /** Maximum visible lines */ + viewportHeight: number; + /** Display name of the resource */ + displayName: string; + /** Resource ID */ + resourceId: string; + /** Resource status string */ + status: string; + /** Resource type for breadcrumbs (e.g. "Devboxes") */ + resourceType: string; + /** Optional breadcrumb prefix items */ + breadcrumbPrefix?: Array<{ label: string; active?: boolean }>; +} + +export function DetailedInfoView({ + detailLines, + scrollOffset, + viewportHeight, + displayName, + resourceId, + status, + resourceType, + breadcrumbPrefix = [], +}: DetailedInfoViewProps) { + const maxScroll = Math.max(0, detailLines.length - viewportHeight); + const actualScroll = Math.min(scrollOffset, maxScroll); + const visibleLines = detailLines.slice( + actualScroll, + actualScroll + viewportHeight, + ); + const hasMore = actualScroll + viewportHeight < detailLines.length; + const hasLess = actualScroll > 0; + + return ( + <> + +
+ + + + + {resourceId} + + + + + {visibleLines} + + + + + {figures.arrowUp} + {figures.arrowDown} Scroll • Line {actualScroll + 1}- + {Math.min(actualScroll + viewportHeight, detailLines.length)} of{" "} + {detailLines.length} + + {hasLess && {figures.arrowUp}} + {hasMore && {figures.arrowDown}} + + {" "} + • [q or esc] Back to Details + + + + ); +} diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index d4d8fb51..e7b33b5c 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -10,6 +10,8 @@ import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { ConfirmationPrompt } from "./ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; +import { openInBrowser } from "../utils/browser.js"; +import { copyToClipboard } from "../utils/clipboard.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useNavigation } from "../store/navigationStore.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -476,30 +478,9 @@ export const DevboxActionsMenu = ({ // Open tunnel URL in browser const tunnelUrl = (operationResult as any).__tunnelUrl; if (tunnelUrl) { - const openBrowser = 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) { - setCopyStatus("Could not open browser"); - setTimeout(() => setCopyStatus(null), 2000); - } else { - setCopyStatus("Opened in browser!"); - setTimeout(() => setCopyStatus(null), 2000); - } - }); - }; - openBrowser(); + openInBrowser(tunnelUrl); + setCopyStatus("Opened in browser!"); + setTimeout(() => setCopyStatus(null), 2000); } } else if ( (key.upArrow || input === "k") && @@ -563,45 +544,10 @@ export const DevboxActionsMenu = ({ ((operationResult as any).stdout || "") + ((operationResult as any).stderr || ""); - const copyToClipboard = async (text: string) => { - const { spawn } = await import("child_process"); - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === "darwin") { - command = "pbcopy"; - args = []; - } else if (platform === "win32") { - command = "clip"; - args = []; - } else { - command = "xclip"; - args = ["-selection", "clipboard"]; - } - - const proc = spawn(command, args); - proc.stdin.write(text); - proc.stdin.end(); - - proc.on("exit", (code) => { - if (code === 0) { - setCopyStatus("Copied to clipboard!"); - setTimeout(() => setCopyStatus(null), 2000); - } else { - setCopyStatus("Failed to copy"); - setTimeout(() => setCopyStatus(null), 2000); - } - }); - - proc.on("error", () => { - setCopyStatus("Copy not supported"); - setTimeout(() => setCopyStatus(null), 2000); - }); - }; - - copyToClipboard(output); + copyToClipboard(output).then((status) => { + setCopyStatus(status); + setTimeout(() => setCopyStatus(null), 2000); + }); } return; } diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 4465f94a..d9dd531d 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -225,14 +225,27 @@ export const DevboxDetailPage = ({ } // Source - if (devbox.blueprint_id || devbox.snapshot_id) { + if (devbox.blueprint_id) { + detailFields.push({ + label: "Source", + value: {devbox.blueprint_id}, + action: { + type: "navigate" as const, + screen: "blueprint-detail" as const, + params: { blueprintId: devbox.blueprint_id }, + hint: "View Blueprint", + }, + }); + } else if (devbox.snapshot_id) { detailFields.push({ label: "Source", - value: ( - - {devbox.blueprint_id || devbox.snapshot_id} - - ), + value: {devbox.snapshot_id}, + action: { + type: "navigate" as const, + screen: "snapshot-detail" as const, + params: { snapshotId: devbox.snapshot_id }, + hint: "View Snapshot", + }, }); } @@ -241,6 +254,12 @@ export const DevboxDetailPage = ({ detailFields.push({ label: "Network Policy", value: {lp.network_policy_id}, + action: { + type: "navigate" as const, + screen: "network-policy-detail" as const, + params: { networkPolicyId: lp.network_policy_id }, + hint: "View Policy", + }, }); } diff --git a/src/components/LogsViewer.tsx b/src/components/LogsViewer.tsx index 534e576f..494f5ef9 100644 --- a/src/components/LogsViewer.tsx +++ b/src/components/LogsViewer.tsx @@ -8,6 +8,7 @@ import figures from "figures"; import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; +import { copyToClipboard } from "../utils/clipboard.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { parseAnyLogEntry, type AnyLog } from "../utils/logFormatter.js"; @@ -82,45 +83,10 @@ export const LogsViewer = ({ }) .join("\n"); - const copyToClipboard = async (text: string) => { - const { spawn } = await import("child_process"); - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === "darwin") { - command = "pbcopy"; - args = []; - } else if (platform === "win32") { - command = "clip"; - args = []; - } else { - command = "xclip"; - args = ["-selection", "clipboard"]; - } - - const proc = spawn(command, args); - proc.stdin.write(text); - proc.stdin.end(); - - proc.on("exit", (code) => { - if (code === 0) { - setCopyStatus("Copied to clipboard!"); - setTimeout(() => setCopyStatus(null), 2000); - } else { - setCopyStatus("Failed to copy"); - setTimeout(() => setCopyStatus(null), 2000); - } - }); - - proc.on("error", () => { - setCopyStatus("Copy not supported"); - setTimeout(() => setCopyStatus(null), 2000); - }); - }; - - copyToClipboard(logsText); + copyToClipboard(logsText).then((status) => { + setCopyStatus(status); + setTimeout(() => setCopyStatus(null), 2000); + }); } else if (input === "q" || key.escape || key.return) { onBack(); } diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx index 81eb3f66..f83659a7 100644 --- a/src/components/ResourceDetailPage.tsx +++ b/src/components/ResourceDetailPage.tsx @@ -3,71 +3,38 @@ * Can be used for devboxes, blueprints, snapshots, etc. */ import React from "react"; -import { Box, Text, useInput } from "ink"; +import { Box, Text } from "ink"; import figures from "figures"; -import { Header } from "./Header.js"; import { StatusBadge } from "./StatusBadge.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { DetailedInfoView } from "./DetailedInfoView.js"; import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; -import { formatTimeAgo } from "../utils/time.js"; - -// Types for configurable detail sections -export interface DetailField { - label: string; - value: string | React.ReactNode | undefined | null; - color?: string; -} - -export interface DetailSection { - title: string; - icon?: string; - color?: string; - fields: DetailField[]; -} - -export interface ResourceOperation { - key: string; - label: string; - color: string; - icon: string; - shortcut: string; -} - -export interface ResourceDetailPageProps { - /** The resource being displayed */ - resource: T; - /** Resource type name for breadcrumbs (e.g., "Blueprints", "Snapshots") */ - resourceType: string; - /** Get display name for the resource */ - getDisplayName: (resource: T) => string; - /** Get resource ID */ - getId: (resource: T) => string; - /** Get resource status */ - getStatus: (resource: T) => string; - /** Optional: Get URL to open in browser */ - getUrl?: (resource: T) => string; - /** Breadcrumb items before the resource name */ - breadcrumbPrefix?: Array<{ label: string; active?: boolean }>; - /** Detail sections to display in main view */ - detailSections: DetailSection[]; - /** Available operations/actions */ - operations: ResourceOperation[]; - /** Callback when operation is selected */ - onOperation: (operation: string, resource: T) => void; - /** Callback to go back */ - onBack: () => void; - /** Optional: Build detailed info lines for full details view */ - buildDetailLines?: (resource: T) => React.ReactElement[]; - /** Optional: Additional content to render after details section */ - additionalContent?: React.ReactNode; - /** Optional: Polling function to refresh resource data */ - pollResource?: () => Promise; - /** Polling interval in ms (default: 3000) */ - pollInterval?: number; -} +import { + useInputHandler, + scrollBindings, + type InputMode, +} from "../hooks/useInputHandler.js"; +import { useNavigation } from "../store/navigationStore.js"; +import { openInBrowser as openUrlInBrowser } from "../utils/browser.js"; +import { copyToClipboard } from "../utils/clipboard.js"; +import { + collectActionableFields, + type DetailFieldAction, + type ResourceDetailPageProps, +} from "./resourceDetailTypes.js"; + +// Re-export all types so existing consumers don't need to change their imports +export type { + DetailFieldAction, + DetailField, + DetailSection, + ResourceOperation, + ResourceDetailPageProps, +} from "./resourceDetailTypes.js"; +export { collectActionableFields } from "./resourceDetailTypes.js"; // Truncate long strings to prevent layout issues const truncateString = (str: string, maxLength: number): string => { @@ -93,6 +60,7 @@ export function ResourceDetailPage({ pollInterval = 3000, }: ResourceDetailPageProps) { const isMounted = React.useRef(true); + const { navigate } = useNavigation(); // Track mounted state React.useEffect(() => { @@ -106,48 +74,35 @@ export function ResourceDetailPage({ const [currentResource, setCurrentResource] = React.useState(initialResource); const [copyStatus, setCopyStatus] = React.useState(null); - // Copy to clipboard helper - const copyToClipboard = React.useCallback(async (text: string) => { - const { spawn } = await import("child_process"); - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === "darwin") { - command = "pbcopy"; - args = []; - } else if (platform === "win32") { - command = "clip"; - args = []; - } else { - command = "xclip"; - args = ["-selection", "clipboard"]; - } - - const proc = spawn(command, args); - proc.stdin.write(text); - proc.stdin.end(); - - proc.on("exit", (code) => { - if (code === 0) { - setCopyStatus("Copied ID to clipboard!"); - setTimeout(() => setCopyStatus(null), 2000); - } else { - setCopyStatus("Failed to copy"); - setTimeout(() => setCopyStatus(null), 2000); - } - }); - - proc.on("error", () => { - setCopyStatus("Copy not supported"); - setTimeout(() => setCopyStatus(null), 2000); - }); + // Copy to clipboard with status feedback + const handleCopy = React.useCallback(async (text: string) => { + const status = await copyToClipboard(text); + setCopyStatus(status); + setTimeout(() => setCopyStatus(null), 2000); }, []); const [showDetailedInfo, setShowDetailedInfo] = React.useState(false); const [detailScroll, setDetailScroll] = React.useState(0); - const [selectedOperation, setSelectedOperation] = React.useState(0); + + // Unified selectable items: actionable detail fields followed by operations. + // Arrow keys move through the entire list seamlessly. + const actionableFields = React.useMemo( + () => collectActionableFields(detailSections), + [detailSections], + ); + const totalSelectableItems = actionableFields.length + operations.length; + // Default selection is the first operation (skip links) + const [selectedIndex, setSelectedIndex] = React.useState( + actionableFields.length, + ); + + // Clamp selectedIndex when the number of selectable items shrinks + // (e.g. operations list changes due to a status change from polling) + React.useEffect(() => { + if (totalSelectableItems > 0 && selectedIndex >= totalSelectableItems) { + setSelectedIndex(totalSelectableItems - 1); + } + }, [totalSelectableItems, selectedIndex]); // Background polling for resource details React.useEffect(() => { @@ -176,138 +131,140 @@ export function ResourceDetailPage({ const resourceId = getId(currentResource); const status = getStatus(currentResource); + // Execute a field action + const executeFieldAction = React.useCallback( + (action: DetailFieldAction) => { + if (action.type === "navigate" && action.screen) { + navigate(action.screen, action.params || {}); + } else if (action.type === "callback" && action.handler) { + action.handler(); + } + }, + [navigate], + ); + // Handle Ctrl+C to exit useExitOnCtrlC(); - useInput((input, key) => { - if (!isMounted.current) return; - - // Handle detailed info mode - if (showDetailedInfo) { - if (input === "q" || key.escape) { - setShowDetailedInfo(false); - setDetailScroll(0); - } else if (input === "j" || input === "s" || key.downArrow) { - setDetailScroll(detailScroll + 1); - } else if (input === "k" || input === "w" || key.upArrow) { - setDetailScroll(Math.max(0, detailScroll - 1)); - } else if (key.pageDown) { - setDetailScroll(detailScroll + 10); - } else if (key.pageUp) { - setDetailScroll(Math.max(0, detailScroll - 10)); - } - return; - } + // Helper: is the current selection on a link or an operation? + const isOnLink = selectedIndex < actionableFields.length; + const operationIndex = selectedIndex - actionableFields.length; + + const handleOpenInBrowser = React.useCallback(() => { + if (!getUrl) return; + openUrlInBrowser(getUrl(currentResource)); + }, [getUrl, currentResource]); - // Main view input handling - if (input === "q" || key.escape) { - onBack(); - } else if (input === "c" && !key.ctrl) { - // Copy resource ID to clipboard (ignore if Ctrl+C for quit) - copyToClipboard(getId(currentResource)); - } else if (input === "i" && buildDetailLines) { - setShowDetailedInfo(true); - setDetailScroll(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) { - const op = operations[selectedOperation]; + const exitDetailedInfo = React.useCallback(() => { + setShowDetailedInfo(false); + setDetailScroll(0); + }, []); + + const handleEnter = React.useCallback(() => { + if (isOnLink) { + const ref = actionableFields[selectedIndex]; + if (ref) { + executeFieldAction(ref.action); + } + } else { + const op = operations[operationIndex]; if (op) { onOperation(op.key, currentResource); } - } else if (input) { - // Check if input matches any operation shortcut - const matchedOpIndex = operations.findIndex( - (op) => op.shortcut === input, - ); - if (matchedOpIndex !== -1) { - setSelectedOperation(matchedOpIndex); - onOperation(operations[matchedOpIndex].key, currentResource); - } } + }, [ + isOnLink, + actionableFields, + selectedIndex, + operationIndex, + operations, + currentResource, + executeFieldAction, + onOperation, + ]); + + const inputModes: InputMode[] = React.useMemo( + () => [ + { + name: "detailedInfo", + active: () => showDetailedInfo, + bindings: { + ...scrollBindings(() => detailScroll, setDetailScroll), + q: exitDetailedInfo, + escape: exitDetailedInfo, + }, + }, + { + name: "mainView", + active: () => true, + bindings: { + q: onBack, + escape: onBack, + c: () => handleCopy(getId(currentResource)), + ...(buildDetailLines + ? { + i: () => { + setShowDetailedInfo(true); + setDetailScroll(0); + }, + } + : {}), + up: () => { + if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1); + }, + down: () => { + if (selectedIndex < totalSelectableItems - 1) + setSelectedIndex(selectedIndex + 1); + }, + enter: handleEnter, + ...(getUrl ? { o: handleOpenInBrowser } : {}), + }, + onUnmatched: (input) => { + // Operation shortcuts work from anywhere + const matchedOpIndex = operations.findIndex( + (op) => op.shortcut === input, + ); + if (matchedOpIndex !== -1) { + setSelectedIndex(actionableFields.length + matchedOpIndex); + onOperation(operations[matchedOpIndex].key, currentResource); + } + }, + }, + ], + [ + showDetailedInfo, + detailScroll, + exitDetailedInfo, + onBack, + currentResource, + buildDetailLines, + selectedIndex, + totalSelectableItems, + handleEnter, + getUrl, + handleOpenInBrowser, + operations, + actionableFields, + onOperation, + getId, + ], + ); - if (input === "o" && getUrl) { - const url = getUrl(currentResource); - 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(); - } - }); + useInputHandler(inputModes, { isActive: isMounted.current }); // Detailed info mode - full screen if (showDetailedInfo && buildDetailLines) { - const detailLines = buildDetailLines(currentResource); - const viewportHeight = detailViewport.viewportHeight; - const maxScroll = Math.max(0, detailLines.length - viewportHeight); - const actualScroll = Math.min(detailScroll, maxScroll); - const visibleLines = detailLines.slice( - actualScroll, - actualScroll + viewportHeight, - ); - const hasMore = actualScroll + viewportHeight < detailLines.length; - const hasLess = actualScroll > 0; - return ( - <> - -
- - - - - {resourceId} - - - - - {visibleLines} - - - - - {figures.arrowUp} - {figures.arrowDown} Scroll • Line {actualScroll + 1}- - {Math.min(actualScroll + viewportHeight, detailLines.length)} of{" "} - {detailLines.length} - - {hasLess && {figures.arrowUp}} - {hasMore && {figures.arrowDown}} - - {" "} - • [q or esc] Back to Details - - - + ); } @@ -352,18 +309,61 @@ export function ResourceDetailPage({ .filter( (field) => field.value !== undefined && field.value !== null, ) - .map((field, fieldIndex) => ( - - {field.label} - {typeof field.value === "string" ? ( - - {field.value} + .map((field, fieldIndex) => { + // Check if this field is an actionable field and whether it's selected + const isActionable = !!field.action; + const actionableIdx = isActionable + ? actionableFields.findIndex( + (ref) => + ref.sectionIndex === sectionIndex && + ref.fieldIndex === fieldIndex, + ) + : -1; + const isFieldSelected = + isActionable && actionableIdx === selectedIndex; + + return ( + + {isActionable ? ( + + {isFieldSelected ? figures.pointer : " "}{" "} + + ) : null} + + {field.label} + {field.label ? " " : ""} - ) : ( - field.value - )} - - ))} + {typeof field.value === "string" ? ( + + {field.value} + + ) : ( + field.value + )} + {isFieldSelected && field.action?.hint && ( + + {" "} + [Enter: {field.action.hint}] + + )} + + ); + })} ))} @@ -379,7 +379,8 @@ export function ResourceDetailPage({ {operations.map((op, index) => { - const isSelected = index === selectedOperation; + const isSelected = + index + actionableFields.length === selectedIndex; return ( @@ -413,7 +414,7 @@ export function ResourceDetailPage({ ({ ); } -// Helper to format timestamp as "time (ago)" -export function formatTimestamp( - timestamp: number | undefined, -): string | undefined { - if (!timestamp) return undefined; - const formatted = new Date(timestamp).toLocaleString(); - const ago = formatTimeAgo(timestamp); - return `${formatted} (${ago})`; -} - -// Helper to format create time with arrow to end time -export function formatTimeRange( - createTime: number | undefined, - endTime: number | undefined, -): string | undefined { - if (!createTime) return undefined; - const start = new Date(createTime).toLocaleString(); - if (endTime) { - const end = new Date(endTime).toLocaleString(); - return `${start} → ${end}`; - } - return `${start} (${formatTimeAgo(createTime)})`; -} +// Re-export format helpers from utils/time for backward compatibility +export { formatTimestamp, formatTimeRange } from "../utils/time.js"; diff --git a/src/components/StreamingLogsViewer.tsx b/src/components/StreamingLogsViewer.tsx index 64a59a5f..78711dd8 100644 --- a/src/components/StreamingLogsViewer.tsx +++ b/src/components/StreamingLogsViewer.tsx @@ -8,6 +8,7 @@ import figures from "figures"; import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; +import { copyToClipboard } from "../utils/clipboard.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { parseAnyLogEntry, type AnyLog } from "../utils/logFormatter.js"; @@ -184,45 +185,10 @@ export const StreamingLogsViewer = ({ }) .join("\n"); - const copyToClipboard = async (text: string) => { - const { spawn } = await import("child_process"); - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === "darwin") { - command = "pbcopy"; - args = []; - } else if (platform === "win32") { - command = "clip"; - args = []; - } else { - command = "xclip"; - args = ["-selection", "clipboard"]; - } - - const proc = spawn(command, args); - proc.stdin.write(text); - proc.stdin.end(); - - proc.on("exit", (code) => { - if (code === 0) { - setCopyStatus("Copied!"); - setTimeout(() => setCopyStatus(null), 2000); - } else { - setCopyStatus("Failed"); - setTimeout(() => setCopyStatus(null), 2000); - } - }); - - proc.on("error", () => { - setCopyStatus("Not supported"); - setTimeout(() => setCopyStatus(null), 2000); - }); - }; - - copyToClipboard(logsText); + copyToClipboard(logsText).then((status) => { + setCopyStatus(status); + setTimeout(() => setCopyStatus(null), 2000); + }); } else if (input === "q" || key.escape || key.return) { onBack(); } diff --git a/src/components/resourceDetailTypes.ts b/src/components/resourceDetailTypes.ts new file mode 100644 index 00000000..dbff0944 --- /dev/null +++ b/src/components/resourceDetailTypes.ts @@ -0,0 +1,118 @@ +/** + * Shared types for the ResourceDetailPage component and its consumers. + */ +import React from "react"; +import type { ScreenName, RouteParams } from "../store/navigationStore.js"; + +// --------------------------------------------------------------------------- +// Detail field types +// --------------------------------------------------------------------------- + +/** Action that can be triggered from an actionable detail field. */ +export interface DetailFieldAction { + /** Type of action */ + type: "navigate" | "callback"; + /** For navigate: screen name to navigate to */ + screen?: ScreenName; + /** For navigate: params to pass */ + params?: RouteParams; + /** For callback: custom function to execute */ + handler?: () => void; + /** Hint text shown next to field, e.g. "View Blueprint" */ + hint?: string; +} + +/** A single field within a detail section. */ +export interface DetailField { + label: string; + value: string | React.ReactNode | undefined | null; + color?: string; + /** Optional action to trigger when this field is selected and Enter is pressed */ + action?: DetailFieldAction; +} + +/** A group of related fields displayed under a section heading. */ +export interface DetailSection { + title: string; + icon?: string; + color?: string; + fields: DetailField[]; +} + +/** An operation/action available for the resource (shown in the Actions menu). */ +export interface ResourceOperation { + key: string; + label: string; + color: string; + icon: string; + shortcut: string; +} + +// --------------------------------------------------------------------------- +// Actionable field helpers +// --------------------------------------------------------------------------- + +/** Reference to an actionable field by section/field index plus its action. */ +export interface ActionableFieldRef { + sectionIndex: number; + fieldIndex: number; + action: DetailFieldAction; +} + +/** + * Walk all sections and collect fields that have an action defined. + * Returns a flat list of references preserving section/field indices + * so the component can map selections back to the right field. + */ +export function collectActionableFields( + sections: DetailSection[], +): ActionableFieldRef[] { + const refs: ActionableFieldRef[] = []; + sections.forEach((section, sectionIndex) => { + section.fields + .filter((field) => field.value !== undefined && field.value !== null) + .forEach((field, fieldIndex) => { + if (field.action) { + refs.push({ sectionIndex, fieldIndex, action: field.action }); + } + }); + }); + return refs; +} + +// --------------------------------------------------------------------------- +// Component props +// --------------------------------------------------------------------------- + +export interface ResourceDetailPageProps { + /** The resource being displayed */ + resource: T; + /** Resource type name for breadcrumbs (e.g., "Blueprints", "Snapshots") */ + resourceType: string; + /** Get display name for the resource */ + getDisplayName: (resource: T) => string; + /** Get resource ID */ + getId: (resource: T) => string; + /** Get resource status */ + getStatus: (resource: T) => string; + /** Optional: Get URL to open in browser */ + getUrl?: (resource: T) => string; + /** Breadcrumb items before the resource name */ + breadcrumbPrefix?: Array<{ label: string; active?: boolean }>; + /** Detail sections to display in main view */ + detailSections: DetailSection[]; + /** Available operations/actions */ + operations: ResourceOperation[]; + /** Callback when operation is selected */ + onOperation: (operation: string, resource: T) => void; + /** Callback to go back */ + onBack: () => void; + /** Optional: Build detailed info lines for full details view */ + buildDetailLines?: (resource: T) => React.ReactElement[]; + /** Optional: Additional content to render after details section */ + additionalContent?: React.ReactNode; + /** Optional: Polling function to refresh resource data */ + pollResource?: () => Promise; + /** Polling interval in ms (default: 3000) */ + pollInterval?: number; +} diff --git a/src/hooks/useInputHandler.ts b/src/hooks/useInputHandler.ts new file mode 100644 index 00000000..54917672 --- /dev/null +++ b/src/hooks/useInputHandler.ts @@ -0,0 +1,172 @@ +/** + * useInputHandler - Declarative, mode-based input handling for Ink components. + * + * Replaces long imperative if/else chains in useInput callbacks with a + * structured system of ordered modes, each with a key-binding map. + * The first active mode wins; bindings are looked up by canonical key name. + */ +import { useInput, type Key } from "ink"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Canonical key names that can appear in a bindings map. + * + * Special keys use short names ("up", "enter", etc.). + * Printable characters use the character itself ("a", "/", etc.). + * Ctrl combinations use "ctrl+" (e.g. "ctrl+c"). + */ +export type KeyName = + | "up" + | "down" + | "left" + | "right" + | "enter" + | "escape" + | "tab" + | "backspace" + | "delete" + | "pageUp" + | "pageDown" + | (string & {}); // any single-char or "ctrl+x" string + +/** + * A single input mode. Modes are evaluated in order; the first whose + * `active()` returns true handles the key event. + */ +export interface InputMode { + /** Human-readable name (useful for debugging). */ + name: string; + + /** Return true when this mode should handle input. */ + active: () => boolean; + + /** Map of canonical key name -> handler. */ + bindings: Partial void>>; + + /** + * If true, keys that don't match any binding are silently swallowed + * (the event does not fall through to subsequent modes). + * Useful for modal overlays like search-input where you want to + * prevent the underlying list from reacting. + */ + captureAll?: boolean; + + /** + * Called when no binding matched and captureAll is not set. + * Receives the raw Ink `(input, key)` arguments so you can do + * dynamic matching (e.g. operation shortcuts). + * If provided, the event is consumed and does not fall through. + */ + onUnmatched?: (input: string, key: Key) => void; +} + +export interface UseInputHandlerOptions { + /** Forwarded to Ink's useInput `isActive` option. */ + isActive?: boolean; +} + +// --------------------------------------------------------------------------- +// Key resolution +// --------------------------------------------------------------------------- + +/** + * Normalise Ink's (input, key) pair into a single canonical key name. + * + * Priority order (first match wins): + * 1. Special keys (arrows, enter, escape, etc.) + * 2. Ctrl+ combinations + * 3. The raw `input` string (printable character) + */ +export function resolveKeyName(input: string, key: Key): KeyName { + // Special keys + if (key.upArrow) return "up"; + if (key.downArrow) return "down"; + if (key.leftArrow) return "left"; + if (key.rightArrow) return "right"; + if (key.return) return "enter"; + if (key.escape) return "escape"; + if (key.tab) return "tab"; + if (key.backspace) return "backspace"; + if (key.delete) return "delete"; + if (key.pageUp) return "pageUp"; + if (key.pageDown) return "pageDown"; + + // Ctrl combinations (e.g. ctrl+c) + if (key.ctrl && input) return `ctrl+${input}`; + + // Printable character + return input; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Declarative input handler. + * + * @param modes Ordered array of input modes. The first mode whose `active()` + * returns `true` gets to handle the key event. + * @param options Optional settings forwarded to Ink's useInput. + */ +export function useInputHandler( + modes: InputMode[], + options?: UseInputHandlerOptions, +): void { + useInput( + (input, key) => { + const keyName = resolveKeyName(input, key); + + for (const mode of modes) { + if (!mode.active()) continue; + + // Try an exact binding match + const handler = mode.bindings[keyName]; + if (handler) { + handler(); + return; + } + + // No binding matched — try the dynamic fallback + if (mode.onUnmatched) { + mode.onUnmatched(input, key); + return; + } + + // captureAll: swallow the event silently + if (mode.captureAll) return; + + // Default: first active mode consumes the event even if nothing matched + return; + } + }, + { isActive: options?.isActive ?? true }, + ); +} + +// --------------------------------------------------------------------------- +// Preset binding helpers +// --------------------------------------------------------------------------- + +/** + * Common scroll bindings (j/k, arrows, page up/down). + * Spread into a mode's `bindings` to get standard scrolling behaviour. + */ +export function scrollBindings( + getScroll: () => number, + setScroll: (value: number) => void, +): Partial void>> { + return { + down: () => setScroll(getScroll() + 1), + up: () => setScroll(Math.max(0, getScroll() - 1)), + j: () => setScroll(getScroll() + 1), + k: () => setScroll(Math.max(0, getScroll() - 1)), + s: () => setScroll(getScroll() + 1), + w: () => setScroll(Math.max(0, getScroll() - 1)), + pageDown: () => setScroll(getScroll() + 10), + pageUp: () => setScroll(Math.max(0, getScroll() - 10)), + }; +} diff --git a/src/screens/BenchmarkJobDetailScreen.tsx b/src/screens/BenchmarkJobDetailScreen.tsx index add0243d..6823b6be 100644 --- a/src/screens/BenchmarkJobDetailScreen.tsx +++ b/src/screens/BenchmarkJobDetailScreen.tsx @@ -447,6 +447,16 @@ export function BenchmarkJobDetailScreen({ return { label: run.agentName, value: {parts}, + ...(run.benchmarkRunId + ? { + action: { + type: "navigate" as const, + screen: "benchmark-run-detail" as const, + params: { benchmarkRunId: run.benchmarkRunId }, + hint: "View Run", + }, + } + : {}), }; }); @@ -527,6 +537,12 @@ export function BenchmarkJobDetailScreen({ sourceFields.push({ label: "Benchmark ID", value: {source.benchmark_id}, + action: { + type: "navigate" as const, + screen: "benchmark-detail" as const, + params: { benchmarkId: source.benchmark_id as string }, + hint: "View Benchmark", + }, }); } diff --git a/src/screens/BenchmarkRunDetailScreen.tsx b/src/screens/BenchmarkRunDetailScreen.tsx index 3ac57931..159d3ac0 100644 --- a/src/screens/BenchmarkRunDetailScreen.tsx +++ b/src/screens/BenchmarkRunDetailScreen.tsx @@ -279,6 +279,12 @@ export function BenchmarkRunDetailScreen({ basicFields.push({ label: "Benchmark ID", value: {run.benchmark_id}, + action: { + type: "navigate" as const, + screen: "benchmark-detail" as const, + params: { benchmarkId: run.benchmark_id }, + hint: "View Benchmark", + }, }); } if (run.purpose) { @@ -441,6 +447,12 @@ export function BenchmarkRunDetailScreen({ ([envVar, secretName]) => ({ label: envVar, value: {secretName} (secret), + action: { + type: "navigate" as const, + screen: "secret-detail" as const, + params: { secretId: secretName }, + hint: "View Secret", + }, }), ); diff --git a/src/screens/DevboxCreateScreen.tsx b/src/screens/DevboxCreateScreen.tsx index 1ed0b693..4ea1d0cf 100644 --- a/src/screens/DevboxCreateScreen.tsx +++ b/src/screens/DevboxCreateScreen.tsx @@ -8,12 +8,19 @@ import { useNavigation } from "../store/navigationStore.js"; import { DevboxCreatePage } from "../components/DevboxCreatePage.js"; export function DevboxCreateScreen() { - const { goBack, navigate } = useNavigation(); + const { goBack, navigate, params } = useNavigation(); const handleCreate = (devbox: DevboxView) => { // After creation, navigate to the devbox detail page navigate("devbox-detail", { devboxId: devbox.id }); }; - return ; + return ( + + ); } diff --git a/src/screens/ScenarioRunDetailScreen.tsx b/src/screens/ScenarioRunDetailScreen.tsx index e81c95da..5ba67217 100644 --- a/src/screens/ScenarioRunDetailScreen.tsx +++ b/src/screens/ScenarioRunDetailScreen.tsx @@ -152,6 +152,12 @@ export function ScenarioRunDetailScreen({ basicFields.push({ label: "Benchmark Run ID", value: {run.benchmark_run_id}, + action: { + type: "navigate" as const, + screen: "benchmark-run-detail" as const, + params: { benchmarkRunId: run.benchmark_run_id }, + hint: "View Run", + }, }); } diff --git a/src/screens/SnapshotDetailScreen.tsx b/src/screens/SnapshotDetailScreen.tsx index 006a1200..40a8fd8b 100644 --- a/src/screens/SnapshotDetailScreen.tsx +++ b/src/screens/SnapshotDetailScreen.tsx @@ -126,6 +126,12 @@ export function SnapshotDetailScreen({ basicFields.push({ label: "Source Devbox", value: {snapshot.devbox_id}, + action: { + type: "navigate" as const, + screen: "devbox-detail" as const, + params: { devboxId: snapshot.devbox_id }, + hint: "View Devbox", + }, }); } if (snapshot.disk_size_bytes) { diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 00000000..e165ef4b --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,23 @@ +/** + * Cross-platform browser-opening utility. + */ + +/** + * Open a URL in the system's default browser. + * Works on macOS (open), Windows (start), and Linux (xdg-open). + */ +export async function openInBrowser(url: string): Promise { + 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); +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 00000000..c97ee226 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,44 @@ +/** + * Cross-platform clipboard utility. + */ + +/** + * Copy text to the system clipboard. + * Returns a promise that resolves with a status message. + */ +export async function copyToClipboard(text: string): Promise { + const { spawn } = await import("child_process"); + const platform = process.platform; + + let command: string; + let args: string[]; + + if (platform === "darwin") { + command = "pbcopy"; + args = []; + } else if (platform === "win32") { + command = "clip"; + args = []; + } else { + command = "xclip"; + args = ["-selection", "clipboard"]; + } + + return new Promise((resolve) => { + const proc = spawn(command, args); + proc.stdin.write(text); + proc.stdin.end(); + + proc.on("exit", (code) => { + if (code === 0) { + resolve("Copied to clipboard!"); + } else { + resolve("Failed to copy"); + } + }); + + proc.on("error", () => { + resolve("Copy not supported"); + }); + }); +} diff --git a/src/utils/time.ts b/src/utils/time.ts index f21511fd..ee47cfe8 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -102,3 +102,36 @@ export function formatTimeAgoRich(timestampMs: number): string { // More than 7 days - just date + time return `${dateStr} ${time}`; } + +/** + * Format a timestamp as "locale string (relative ago)". + * Example: "1/15/2025, 3:30:00 PM (2h ago)" + * + * Returns undefined if timestamp is falsy. + */ +export function formatTimestamp( + timestamp: number | undefined, +): string | undefined { + if (!timestamp) return undefined; + const formatted = new Date(timestamp).toLocaleString(); + const ago = formatTimeAgo(timestamp); + return `${formatted} (${ago})`; +} + +/** + * Format a time range as "start → end" or "start (relative ago)" if no end. + * + * Returns undefined if createTime is falsy. + */ +export function formatTimeRange( + createTime: number | undefined, + endTime: number | undefined, +): string | undefined { + if (!createTime) return undefined; + const start = new Date(createTime).toLocaleString(); + if (endTime) { + const end = new Date(endTime).toLocaleString(); + return `${start} → ${end}`; + } + return `${start} (${formatTimeAgo(createTime)})`; +} diff --git a/tests/__tests__/components/DevboxDetailPage.test.tsx b/tests/__tests__/components/DevboxDetailPage.test.tsx index 5d65ba94..cc5e71d4 100644 --- a/tests/__tests__/components/DevboxDetailPage.test.tsx +++ b/tests/__tests__/components/DevboxDetailPage.test.tsx @@ -4,6 +4,10 @@ import React from 'react'; import { render } from 'ink-testing-library'; import { DevboxDetailPage } from '../../../src/components/DevboxDetailPage.js'; +import { NavigationProvider } from '../../../src/store/navigationStore.js'; + +const renderWithNav = (ui: React.ReactElement) => + render({ui}); describe('DevboxDetailPage', () => { const mockDevbox = { @@ -19,7 +23,7 @@ describe('DevboxDetailPage', () => { }; it('renders without crashing', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -29,7 +33,7 @@ describe('DevboxDetailPage', () => { }); it('displays devbox name', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -39,7 +43,7 @@ describe('DevboxDetailPage', () => { }); it('displays devbox id', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -49,7 +53,7 @@ describe('DevboxDetailPage', () => { }); it('shows status badge', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -59,7 +63,7 @@ describe('DevboxDetailPage', () => { }); it('shows Actions section', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -69,7 +73,7 @@ describe('DevboxDetailPage', () => { }); it('shows available operations', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -82,7 +86,7 @@ describe('DevboxDetailPage', () => { }); it('shows navigation help', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -96,7 +100,7 @@ describe('DevboxDetailPage', () => { }); it('displays resource information', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} @@ -110,7 +114,7 @@ describe('DevboxDetailPage', () => { }); it('displays capabilities', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithNav( {}} diff --git a/tests/__tests__/components/ResourceDetailPage.test.tsx b/tests/__tests__/components/ResourceDetailPage.test.tsx new file mode 100644 index 00000000..ccfc4d0b --- /dev/null +++ b/tests/__tests__/components/ResourceDetailPage.test.tsx @@ -0,0 +1,387 @@ +/** + * Tests for ResourceDetailPage component + */ +import React from "react"; +import { Text } from "ink"; +import { jest } from "@jest/globals"; +import { render } from "ink-testing-library"; +import { + ResourceDetailPage, + type DetailSection, + type ResourceOperation, +} from "../../../src/components/ResourceDetailPage.js"; +import { NavigationProvider } from "../../../src/store/navigationStore.js"; + +// Helper to wrap components with NavigationProvider +const renderWithNav = (ui: React.ReactElement) => + render({ui}); + +// Shared test data +interface TestResource { + id: string; + name: string; + status: string; +} + +const mockResource: TestResource = { + id: "res_test_123", + name: "test-resource", + status: "running", +}; + +const mockOperations: ResourceOperation[] = [ + { + key: "view-logs", + label: "View Logs", + color: "#00ff00", + icon: "ℹ", + shortcut: "l", + }, + { + key: "delete", + label: "Delete Resource", + color: "#ff0000", + icon: "✖", + shortcut: "d", + }, +]; + +const mockSections: DetailSection[] = [ + { + title: "Details", + icon: "■", + color: "#ffaa00", + fields: [ + { label: "Created", value: "2024-01-01" }, + { label: "Region", value: "us-east-1" }, + ], + }, +]; + +const createDefaultProps = () => ({ + resource: mockResource, + resourceType: "Resources", + getDisplayName: (r: TestResource) => r.name, + getId: (r: TestResource) => r.id, + getStatus: (r: TestResource) => r.status, + detailSections: mockSections, + operations: mockOperations, + onOperation: jest.fn(), + onBack: jest.fn(), +}); + +describe("ResourceDetailPage", () => { + // --- Rendering --- + + it("renders without crashing", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toBeTruthy(); + }); + + it("displays the resource name", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("test-resource"); + }); + + it("displays the resource ID when different from name", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("res_test_123"); + }); + + it("does not show separate ID when name equals ID", () => { + const props = { + ...createDefaultProps(), + resource: { ...mockResource, name: "res_test_123" }, + getDisplayName: (r: TestResource) => r.id, + }; + const { lastFrame } = renderWithNav(); + const frame = lastFrame() || ""; + // Should NOT contain the "• res_test_123" separator pattern (i.e. ID shown separately from name) + expect(frame).not.toContain("• res_test_123"); + }); + + it("displays the status badge", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("Running"); + }); + + // --- Detail Sections --- + + it("renders section titles", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("Details"); + }); + + it("renders field labels and values", () => { + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Created"); + expect(frame).toContain("2024-01-01"); + expect(frame).toContain("Region"); + expect(frame).toContain("us-east-1"); + }); + + it("filters out fields with undefined values", () => { + const sections: DetailSection[] = [ + { + title: "Info", + fields: [ + { label: "Present", value: "yes" }, + { label: "Missing", value: undefined }, + { label: "Null", value: null }, + ], + }, + ]; + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Present"); + expect(frame).not.toContain("Missing"); + expect(frame).not.toContain("Null"); + }); + + it("renders multiple sections", () => { + const sections: DetailSection[] = [ + { title: "Section A", fields: [{ label: "A1", value: "val1" }] }, + { title: "Section B", fields: [{ label: "B1", value: "val2" }] }, + ]; + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Section A"); + expect(frame).toContain("Section B"); + }); + + // --- Operations --- + + it("renders the Actions section", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("Actions"); + }); + + it("displays all operations with labels", () => { + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("View Logs"); + expect(frame).toContain("Delete Resource"); + }); + + it("displays operation shortcuts", () => { + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("[l]"); + expect(frame).toContain("[d]"); + }); + + it("does not render Actions section when operations is empty", () => { + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).not.toContain("Actions"); + }); + + // --- Actionable Fields --- + + it("renders actionable fields with label and value", () => { + const sections: DetailSection[] = [ + { + title: "Info", + fields: [ + { + label: "Blueprint", + value: "bpt_123", + action: { + type: "navigate" as const, + screen: "blueprint-detail" as const, + params: { blueprintId: "bpt_123" }, + hint: "View Blueprint", + }, + }, + ], + }, + ]; + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Blueprint"); + expect(frame).toContain("bpt_123"); + }); + + // --- Navigation Tips --- + + it("shows navigation tips", () => { + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Execute"); + expect(frame).toContain("Copy ID"); + expect(frame).toContain("Back"); + }); + + it("shows Full Details tip when buildDetailLines is provided", () => { + const { lastFrame } = renderWithNav( + []} + />, + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Full Details"); + }); + + it("shows Browser tip when getUrl is provided", () => { + const { lastFrame } = renderWithNav( + "https://example.com"} + />, + ); + const frame = lastFrame() || ""; + expect(frame).toContain("Browser"); + }); + + // --- Breadcrumbs --- + + it("shows resource type in breadcrumbs", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("Resources"); + }); + + it("shows custom breadcrumb prefix", () => { + const { lastFrame } = renderWithNav( + , + ); + expect(lastFrame()).toContain("Home"); + }); + + // --- Additional Content --- + + it("renders additional content", () => { + const { lastFrame } = renderWithNav( + Extra content here} + />, + ); + expect(lastFrame()).toContain("Extra content here"); + }); + + // --- Selection state --- + + it("defaults selection to first operation (not links)", () => { + const sections: DetailSection[] = [ + { + title: "Info", + fields: [ + { + label: "Source", + value: "bpt_123", + action: { + type: "navigate" as const, + screen: "blueprint-detail" as const, + params: { blueprintId: "bpt_123" }, + hint: "View Blueprint", + }, + }, + ], + }, + ]; + const { lastFrame } = renderWithNav( + , + ); + const frame = lastFrame() || ""; + // The link hint should NOT be visible since it's not selected + expect(frame).not.toContain("View Blueprint"); + // The first operation should be rendered + expect(frame).toContain("View Logs"); + }); + + // --- Keyboard interaction --- + + it("calls onBack when escape is pressed", () => { + const props = createDefaultProps(); + const { stdin } = renderWithNav( + , + ); + stdin.write("\u001B"); // escape + expect(props.onBack).toHaveBeenCalled(); + }); + + it("calls onBack when q is pressed", () => { + const props = createDefaultProps(); + const { stdin } = renderWithNav( + , + ); + stdin.write("q"); + expect(props.onBack).toHaveBeenCalled(); + }); + + it("calls onOperation with correct key when Enter is pressed on an operation", () => { + const props = createDefaultProps(); + const { stdin } = renderWithNav( + , + ); + // Default selection is the first operation, press Enter + stdin.write("\r"); + expect(props.onOperation).toHaveBeenCalledWith("view-logs", mockResource); + }); + + it("calls onOperation via shortcut key", () => { + const props = createDefaultProps(); + const { stdin } = renderWithNav( + , + ); + // Press 'd' shortcut for delete + stdin.write("d"); + expect(props.onOperation).toHaveBeenCalledWith("delete", mockResource); + }); + + it("triggers different operations via different shortcuts", () => { + const props = createDefaultProps(); + const { stdin } = renderWithNav( + , + ); + stdin.write("l"); + expect(props.onOperation).toHaveBeenCalledWith("view-logs", mockResource); + }); + + it("does not trigger operations for non-shortcut keys", () => { + const props = createDefaultProps(); + const { stdin } = renderWithNav( + , + ); + stdin.write("x"); // not a shortcut + expect(props.onOperation).not.toHaveBeenCalled(); + }); +});