diff --git a/package-lock.json b/package-lock.json index 207b68f..b8ce643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@inkjs/ui": "^2.0.0", "@sentry/node": "^9.29.0", "@tanstack/react-query": "^5.76.1", + "chalk": "^5.4.1", + "date-fns": "^4.1.0", "ink": "^5.2.1", "isbinaryfile": "^5.0.4", "isomorphic-ws": "^5.0.0", @@ -793,18 +795,6 @@ "ink": ">=5" } }, - "node_modules/@inkjs/ui/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@inkjs/ui/node_modules/cli-spinners": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.2.0.tgz", @@ -2304,6 +2294,23 @@ "x256": ">=0.0.1" } }, + "node_modules/blessed-contrib/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/blessed-contrib/node_modules/strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -2590,33 +2597,15 @@ } }, "node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", - "dev": true, + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "license": "MIT", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/chalk/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^2.0.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/charm": { @@ -3087,6 +3076,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -4099,18 +4097,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ink/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/ink/node_modules/cli-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", @@ -4528,18 +4514,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/log-symbols/node_modules/is-unicode-supported": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", @@ -4626,19 +4600,6 @@ "marked": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" } }, - "node_modules/marked-terminal/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5061,18 +5022,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/ora/node_modules/emoji-regex": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", diff --git a/package.json b/package.json index 96a62e8..ca7b386 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,8 @@ "@inkjs/ui": "^2.0.0", "@sentry/node": "^9.29.0", "@tanstack/react-query": "^5.76.1", + "chalk": "^5.4.1", + "date-fns": "^4.1.0", "ink": "^5.2.1", "isbinaryfile": "^5.0.4", "isomorphic-ws": "^5.0.0", diff --git a/src/bin/main.tsx b/src/bin/main.tsx index 0595e3c..944de98 100644 --- a/src/bin/main.tsx +++ b/src/bin/main.tsx @@ -6,13 +6,14 @@ import { buildCommand } from "./commands/build"; import { sandboxesCommand } from "./commands/sandbox"; import { previewHostsCommand } from "./commands/previewHosts"; import { hostTokensCommand } from "./commands/hostTokens"; -import { Dashboard } from "./ui/Dashboard"; +import { App } from "./ui/App"; import React from "react"; import { SDKProvider } from "./ui/sdkContext"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ViewProvider } from "./ui/viewContext"; if (process.argv.length === 2) { - // Clear the screen before rendering the dashboard + // Clear the screen before rendering the App process.stdout.write("\x1Bc"); const queryClient = new QueryClient(); @@ -20,7 +21,9 @@ if (process.argv.length === 2) { render( - + + + , { diff --git a/src/bin/ui/App.tsx b/src/bin/ui/App.tsx new file mode 100644 index 0000000..fb0a43e --- /dev/null +++ b/src/bin/ui/App.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { Dashboard } from "./views/Dashboard"; +import { useView } from "./viewContext"; +import { Sandbox } from "./views/Sandbox"; +import { useTerminalSize } from "./hooks/useTerminalSize"; + +export function App() { + const [stdoutWidth, stdoutHeight] = useTerminalSize(); + const { view } = useView(); + + return ( + + + □ CodeSandbox SDK + + {view.name === "dashboard" && } + {view.name === "sandbox" && } + + ); +} diff --git a/src/bin/ui/Dashboard.tsx b/src/bin/ui/Dashboard.tsx deleted file mode 100644 index c8f5ad8..0000000 --- a/src/bin/ui/Dashboard.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React, { memo, useEffect, useRef, useState } from "react"; -import { Box, Text, useInput, useStdout } from "ink"; -import { useSDK } from "./sdkContext"; -import { useQuery } from "@tanstack/react-query"; -import { getSandbox, getRunningVms } from "./api"; - -// Custom hook to get terminal size -function useTerminalSize() { - const { stdout } = useStdout(); - const [size, setSize] = useState([stdout?.columns || 80, stdout?.rows || 24]); - useEffect(() => { - if (!stdout) return undefined; - const handler = () => setSize([stdout.columns, stdout.rows]); - stdout.on("resize", handler); - return () => { - stdout.off("resize", handler); - }; - }, [stdout]); - return size; -} - -// Component to open a sandbox by ID -export function Dashboard() { - const { apiClient } = useSDK(); - - // Poll getRunningVms API every 2 seconds - const runningVmsQuery = useQuery({ - queryKey: ["runningVms"], - queryFn: () => getRunningVms(apiClient), - }); - - const [sandboxId, setSandboxId] = useState(""); - const [showSandbox, setShowSandbox] = useState(false); - const [isFocused, setIsFocused] = useState(true); - const [stdoutWidth, stdoutHeight] = useTerminalSize(); - - useEffect(() => { - // have to manually do this because of environment - const interval = setInterval(() => { - runningVmsQuery.refetch(); - }, 2000); - - return () => { - clearInterval(interval); - }; - }, []); - - useInput((input, key) => { - if (!showSandbox) { - if (key.return) { - if (sandboxId.trim()) { - setShowSandbox(true); - } - } else if (key.backspace || key.delete) { - setSandboxId((prev) => prev.slice(0, -1)); - } else if (input && !key.ctrl && !key.meta && !key.shift) { - // Only add printable characters - setSandboxId((prev) => prev + input); - } - } - }); - - if (showSandbox) { - const state = runningVmsQuery.isLoading - ? "PENDING" - : runningVmsQuery.data?.vms.find((vm) => vm.id === sandboxId) - ? "RUNNING" - : "IDLE"; - - return ( - setShowSandbox(false)} - /> - ); - } - - return ( - - - CodeSandbox - - - Enter Sandbox ID: - {sandboxId || "_"} - - - Type to input ID, press ENTER to open - - - ); -} - -// Component to display a sandbox -const Sandbox = memo( - ({ - id, - runningState, - onBack, - }: { - id: string; - runningState: "RUNNING" | "IDLE" | "PENDING"; - onBack: () => void; - }) => { - const sandboxQuery = useQuery({ - queryKey: ["sandbox", id], - queryFn: () => getSandbox(apiClient, id), - }); - const runningStateRef = useRef(runningState); - - const { sdk, apiClient } = useSDK(); - - // Only two states: RUNNING or IDLE - const [sandboxState, setSandboxState] = useState< - "RUNNING" | "IDLE" | "PENDING" - >(runningState); - const [selectedOption, setSelectedOption] = useState(0); - const [stdoutWidth, stdoutHeight] = useTerminalSize(); - - // We only want to update the state when the - // running state has ACTUALLY changed (Reconciliation sucks) - useEffect(() => { - if ( - sandboxState !== "PENDING" && - runningStateRef.current !== runningState - ) { - runningStateRef.current = runningState; - setSandboxState(runningState); - } - }, [runningState, sandboxState]); - - // Define menu options based on state - const getMenuOptions = () => { - switch (sandboxState) { - case "RUNNING": - return ["Hibernate", "Shutdown", "Restart"]; - case "IDLE": - return ["Start"]; - default: - return []; - } - }; - - const menuOptions = getMenuOptions(); - - // Handle menu options - const handleAction = async (action: string) => { - switch (action) { - case "Hibernate": - case "Shutdown": - setSandboxState("PENDING"); - await sdk.sandboxes.shutdown(id); - setSandboxState("IDLE"); - setSelectedOption(0); - break; - case "Restart": - setSandboxState("PENDING"); - await sdk.sandboxes.restart(id); - setSandboxState("RUNNING"); - setSelectedOption(0); - break; - case "Start": - setSandboxState("PENDING"); - await sdk.sandboxes.resume(id); - setSandboxState("RUNNING"); - setSelectedOption(0); - break; - } - }; - - // Handle keyboard navigation - useInput((input, key) => { - if (key.escape) { - onBack(); - } else if (menuOptions.length > 0) { - if (key.upArrow) { - setSelectedOption((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (key.downArrow) { - setSelectedOption((prev) => - prev < menuOptions.length - 1 ? prev + 1 : prev - ); - } else if (key.return) { - handleAction(menuOptions[selectedOption]); - } - } - }); - - return ( - - {/* Handle query states */} - {sandboxQuery.isLoading && ( - - Loading sandbox information... - - )} - - {sandboxQuery.error && ( - - - Error loading sandbox: {(sandboxQuery.error as Error).message} - - - )} - - {sandboxQuery.data && ( - - - {sandboxQuery.data.title} - {id} - - - {sandboxQuery.data.description && ( - - {sandboxQuery.data.description} - - )} - - )} - - {/* Status display - moved above title and description */} - - Status: - - {sandboxState} - - - - {menuOptions.length > 0 && ( - - Actions: - {menuOptions.map((option, index) => ( - - - {selectedOption === index ? "> " : " "} - {option} - - - ))} - - )} - - - - {menuOptions.length > 0 - ? "Use arrow keys to navigate, Enter to select, ESC to go back" - : "Press ESC to go back"} - - - - ); - } -); diff --git a/src/bin/ui/components/Table.tsx b/src/bin/ui/components/Table.tsx new file mode 100644 index 0000000..0a0788f --- /dev/null +++ b/src/bin/ui/components/Table.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { Box, Text } from "ink"; + +interface TableProps { + marginTop?: number; + renderHeader: () => React.ReactNode; + renderBody: (totalWidth: number) => React.ReactNode; +} + +export const Table = ({ renderHeader, renderBody }: TableProps) => { + // Calculate total width from TableHeader children + const calculateTotalWidth = () => { + let totalWidth = 0; + + const headerElement = renderHeader(); + + if ( + React.isValidElement(headerElement) && + headerElement.type === TableHeader + ) { + React.Children.forEach( + (headerElement.props as any).children, + (headerChild) => { + if ( + React.isValidElement(headerChild) && + headerChild.type === TableColumn + ) { + totalWidth += (headerChild.props as any).width || 0; + } + } + ); + } + + return totalWidth; + }; + + const totalWidth = calculateTotalWidth(); + + return ( + + {renderHeader()} + {renderBody(totalWidth)} + + ); +}; + +interface TableHeaderProps { + children: React.ReactNode; +} + +export const TableHeader = ({ children }: TableHeaderProps) => ( + {children} +); + +interface TableBodyProps { + children: React.ReactNode; + totalWidth: number; +} + +export const TableBody = ({ children, totalWidth }: TableBodyProps) => ( + + + {"─".repeat(Math.max(totalWidth, 50))} + + {children} + +); + +interface TableRowProps { + children: React.ReactNode; + isSelected?: boolean; +} + +export const TableRow = ({ children, isSelected = false }: TableRowProps) => ( + + {React.Children.map(children, (child) => { + if (React.isValidElement(child) && child.type === TableColumn) { + return React.cloneElement(child, { inverse: isSelected } as any); + } + return child; + })} + +); + +interface TableColumnProps { + children: React.ReactNode; + width?: number; + bold?: boolean; + inverse?: boolean; +} + +export const TableColumn = ({ + children, + width, + bold = false, + inverse = false, +}: TableColumnProps) => ( + + + {children} + + +); diff --git a/src/bin/ui/components/TextInput.tsx b/src/bin/ui/components/TextInput.tsx new file mode 100644 index 0000000..7da9777 --- /dev/null +++ b/src/bin/ui/components/TextInput.tsx @@ -0,0 +1,130 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import chalk from "chalk"; + +interface TextInputProps { + value: string; + onChange: (value: string) => void; + onSubmit?: () => void; + isFocused?: boolean; + showCursor?: boolean; +} + +// Helper function to check if a key is a regular printable character +const isRegularKey = (key: Key) => { + return ( + !key.ctrl && + !key.meta && + !key.shift && + !key.upArrow && + !key.downArrow && + !key.return && + !key.backspace && + !key.delete && + !key.tab && + !key.escape && + !key.leftArrow && + !key.rightArrow + ); +}; + +export const TextInput: React.FC = ({ + value, + onChange, + onSubmit, + isFocused = true, + showCursor = true, +}) => { + const [cursorPosition, setCursorPosition] = useState(value.length); + const isInternalChange = useRef(false); + + // Only update cursor position when value changes externally + useEffect(() => { + if (!isInternalChange.current) { + setCursorPosition(value.length); + } + isInternalChange.current = false; + }, [value]); + + useInput( + (input, key) => { + if (key.return && onSubmit) { + onSubmit(); + } else if (key.leftArrow) { + setCursorPosition(Math.max(0, cursorPosition - 1)); + } else if (key.rightArrow) { + setCursorPosition(Math.min(value.length, cursorPosition + 1)); + } else if (key.backspace || key.delete) { + // Both backspace and delete remove the character before the cursor + if (cursorPosition > 0) { + const newValue = + value.slice(0, cursorPosition - 1) + value.slice(cursorPosition); + const newCursorPosition = cursorPosition - 1; + isInternalChange.current = true; + + onChange(newValue); + setCursorPosition(newCursorPosition); + } + } else if (input && isRegularKey(key)) { + // Insert character(s) at cursor position + const newValue = + value.slice(0, cursorPosition) + input + value.slice(cursorPosition); + let newCursorPosition = cursorPosition + input.length; + + // Ensure cursor stays within bounds + if (newCursorPosition < 0) { + newCursorPosition = 0; + } + if (newCursorPosition > newValue.length) { + newCursorPosition = newValue.length; + } + + isInternalChange.current = true; + onChange(newValue); + setCursorPosition(newCursorPosition); + } + }, + { isActive: isFocused } + ); + + const renderText = () => { + const displayValue = value || ""; + + if (!showCursor || !isFocused) { + // When cursor is hidden, just show the text + return {displayValue}; + } + + let renderedValue = ""; + + if (displayValue.length === 0) { + // Show cursor when there's no text + renderedValue = chalk.inverse(" "); + } else { + // Show value with cursor + renderedValue = ""; + + for (let i = 0; i < displayValue.length; i++) { + const char = displayValue[i]; + if (i === cursorPosition) { + renderedValue += chalk.inverse(char); + } else { + renderedValue += char; + } + } + + // Add cursor at the end if position is at the end + if (cursorPosition === displayValue.length) { + renderedValue += chalk.inverse(" "); + } + } + + return {renderedValue}; + }; + + return ( + + {renderText()} + + ); +}; diff --git a/src/bin/ui/components/VmTable.tsx b/src/bin/ui/components/VmTable.tsx new file mode 100644 index 0000000..6ff3f4e --- /dev/null +++ b/src/bin/ui/components/VmTable.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { Table, TableHeader, TableBody, TableRow, TableColumn } from "./Table"; +import { format, parseISO } from "date-fns"; + +const formatDate = (dateString: string | undefined): string => { + if (!dateString) return "N/A"; + + try { + const date = parseISO(dateString); + return format(date, "d MMMM yyyy 'at' HH:mm 'UTC'"); + } catch (error) { + return "Invalid date"; + } +}; + +const calculateRuntime = (startedAt: string | undefined, lastActiveAt: string | undefined): string => { + if (!startedAt || !lastActiveAt) { + return "N/A" + }; + + try { + const startDate = parseISO(startedAt); + const lastActiveDate = parseISO(lastActiveAt); + + // Calculate difference in milliseconds + const diffMs = lastActiveDate.getTime() - startDate.getTime(); + + if (diffMs < 0) return "N/A"; + + // Convert to seconds, minutes, hours + const totalSeconds = Math.floor(diffMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + // Format output + let result = ""; + + if (hours > 0) { + result += `${hours}h `; + } + + if (minutes > 0) { + result += `${minutes}m `; + } + + if (seconds > 0 || result === "") { + result += `${seconds}s`; + } + + return result.trim(); + } catch (error) { + return "N/A"; + } +}; + +interface VmData { + id?: string; + credit_basis?: string; + last_active_at?: string; + session_started_at?: string; + specs?: { + cpu?: number; + memory?: number; + storage?: number; + }; +} + +interface VmTableProps { + vms: VmData[]; + selectedIndex: number; + onSelect: (index: number, vmId: string) => void; +} + +export const VmTable = ({ vms, selectedIndex, onSelect }: VmTableProps) => { + const columnWidths = { + id: 20, + lastActive: 28, + startedAt: 28, + runtime: 14, + creditBasis: 20, + }; + + if (vms.length === 0) { + return ( + + Running VMs + No running VMs found. + + ); + } + + return ( + + Running VMs + ( + + VM ID + Last Active + Started At + Runtime + Credit Basis + + )} + renderBody={(totalWidth) => ( + + {vms.map((vm, index) => ( + + {vm.id || "N/A"} + {formatDate(vm.last_active_at)} + {formatDate(vm.session_started_at)} + {calculateRuntime(vm.session_started_at, vm.last_active_at)} + {vm.credit_basis || "N/A"} credits / hour + + ))} + + )} + /> + + ); +}; \ No newline at end of file diff --git a/src/bin/ui/hooks/useTerminalSize.ts b/src/bin/ui/hooks/useTerminalSize.ts new file mode 100644 index 0000000..27e9ba3 --- /dev/null +++ b/src/bin/ui/hooks/useTerminalSize.ts @@ -0,0 +1,16 @@ +import { useStdout } from "ink"; +import { useEffect, useState } from "react"; + +export function useTerminalSize() { + const { stdout } = useStdout(); + const [size, setSize] = useState([stdout?.columns || 80, stdout?.rows || 24]); + useEffect(() => { + if (!stdout) return undefined; + const handler = () => setSize([stdout.columns, stdout.rows]); + stdout.on("resize", handler); + return () => { + stdout.off("resize", handler); + }; + }, [stdout]); + return size; +} diff --git a/src/bin/ui/hooks/useVmInput.ts b/src/bin/ui/hooks/useVmInput.ts new file mode 100644 index 0000000..0a24981 --- /dev/null +++ b/src/bin/ui/hooks/useVmInput.ts @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { useInput } from "ink"; + +interface VmData { + id?: string; + [key: string]: any; +} + +interface UseVmInputOptions { + vms?: VmData[]; + onSubmit: (id: string) => void; +} + +export const useVmInput = ({ vms, onSubmit }: UseVmInputOptions) => { + const [sandboxId, setSandboxId] = useState(""); + const [selectedVm, setSelectedVm] = useState(null); + const [selectedVmIndex, setSelectedVmIndex] = useState(-1); + + const handleInputChange = (value: string) => { + setSandboxId(value); + + // Clear VM selection when user types manually + if (selectedVm) { + setSelectedVm(null); + setSelectedVmIndex(-1); + } + }; + + const handleInputSubmit = () => { + if (selectedVm) { + onSubmit(selectedVm); + } else if (sandboxId.trim()) { + onSubmit(sandboxId); + } + }; + + const handleVmSelect = (index: number, vmId: string) => { + setSelectedVmIndex(index); + setSelectedVm(vmId); + }; + + useInput((_input, key) => { + if (key.upArrow || key.downArrow) { + if (vms && vms.length > 0) { + let newIndex = selectedVmIndex; + + if (key.upArrow) { + newIndex = selectedVmIndex <= 0 ? vms.length - 1 : selectedVmIndex - 1; + } else if (key.downArrow) { + newIndex = selectedVmIndex >= vms.length - 1 ? 0 : selectedVmIndex + 1; + } + + setSelectedVmIndex(newIndex); + const vmId = vms[newIndex]?.id || null; + setSelectedVm(vmId); + + // Set the selected VM ID in the text input + if (vmId) { + setSandboxId(vmId); + } + } + } + }); + + return { + sandboxId, + selectedVm, + selectedVmIndex, + handleInputChange, + handleInputSubmit, + handleVmSelect, + }; +}; \ No newline at end of file diff --git a/src/bin/ui/viewContext.tsx b/src/bin/ui/viewContext.tsx new file mode 100644 index 0000000..7283766 --- /dev/null +++ b/src/bin/ui/viewContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useState } from "react"; + +type ViewState = + | { name: "dashboard" } + | { name: "sandbox"; params: { id: string } }; + +export const ViewContext = createContext<{ + view: ViewState; + setView: (view: ViewState) => void; +}>({ + view: { name: "dashboard" }, + setView: () => {}, +}); + +export const ViewProvider = ({ children }: { children: React.ReactNode }) => { + const [view, setView] = useState({ + name: "dashboard", + }); + + return ( + + {children} + + ); +}; + +export const useView = () => { + const { view, setView } = useContext(ViewContext); + const typedView = view as Extract; + + return { + view: typedView, + setView, + }; +}; diff --git a/src/bin/ui/views/Dashboard.tsx b/src/bin/ui/views/Dashboard.tsx new file mode 100644 index 0000000..d7c9b10 --- /dev/null +++ b/src/bin/ui/views/Dashboard.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { useView } from "../viewContext"; +import { getRunningVms } from "../api"; +import { useQuery } from "@tanstack/react-query"; +import { useSDK } from "../sdkContext"; +import { TextInput } from "../components/TextInput"; +import { VmTable } from "../components/VmTable"; +import { useVmInput } from "../hooks/useVmInput"; + +export const Dashboard = () => { + const { apiClient } = useSDK(); + + const { data, isLoading } = useQuery({ + queryKey: ["runningVms"], + queryFn: () => getRunningVms(apiClient), + }); + + const { setView } = useView(); + + const { + sandboxId, + selectedVm, + selectedVmIndex, + handleInputChange, + handleInputSubmit, + handleVmSelect, + } = useVmInput({ + vms: data?.vms, + onSubmit: (id: string) => { + setView({ name: "sandbox", params: { id } }); + }, + }); + + // Cursor is shown when no VM is selected (user typed manually) + const showCursor = selectedVm === null; + + const renderVmSection = () => { + if (isLoading) { + return ( + + Running VMs + + Loading VM data... + + + ); + } + + if (data?.vms && data.vms.length > 0) { + // TODO: Fix type mismatch - API types are being updated + return ( + + ); + } + + return ( + + Running VMs + + No running VMs found. + + + ); + }; + + return ( + + + Start typing to input an ID or use ↑/↓ arrows to select from running VMs. Press ENTER to view VM details. + + + + Sandbox ID + + + + + + {renderVmSection()} + + ); +}; diff --git a/src/bin/ui/views/Sandbox.tsx b/src/bin/ui/views/Sandbox.tsx new file mode 100644 index 0000000..62e48fd --- /dev/null +++ b/src/bin/ui/views/Sandbox.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { useView } from "../viewContext"; +import { useQuery } from "@tanstack/react-query"; +import { useSDK } from "../sdkContext"; +import { getRunningVms, getSandbox } from "../api"; + +export const Sandbox = () => { + const { view, setView } = useView<"sandbox">(); + + // Poll getRunningVms API every 2 seconds + const runningVmsQuery = useQuery({ + queryKey: ["runningVms"], + queryFn: () => getRunningVms(apiClient), + }); + + useEffect(() => { + // have to manually do this because of environment + const interval = setInterval(() => { + runningVmsQuery.refetch(); + }, 2000); + + return () => { + clearInterval(interval); + }; + }, []); + + const sandboxQuery = useQuery({ + queryKey: ["sandbox", view.params.id], + queryFn: () => getSandbox(apiClient, view.params.id), + enabled: !!view.params.id, + }); + + const runningState = runningVmsQuery.isLoading + ? "PENDING" + : runningVmsQuery.data?.vms.find((vm) => vm.id === view.params.id) + ? "RUNNING" + : "IDLE"; + + const runningStateRef = useRef(runningState); + + const { sdk, apiClient } = useSDK(); + + // Only two states: RUNNING or IDLE + const [sandboxState, setSandboxState] = useState< + "RUNNING" | "IDLE" | "PENDING" + >(runningState); + const [selectedOption, setSelectedOption] = useState(0); + + // We only want to update the state when the + // running state has ACTUALLY changed (Reconciliation sucks) + useEffect(() => { + if ( + sandboxState !== "PENDING" && + runningStateRef.current !== runningState + ) { + runningStateRef.current = runningState; + setSandboxState(runningState); + } + }, [runningState, sandboxState]); + + // Define menu options based on state + const getMenuOptions = () => { + switch (sandboxState) { + case "RUNNING": + return ["Hibernate", "Shutdown", "Restart"]; + case "IDLE": + return ["Start"]; + default: + return []; + } + }; + + const menuOptions = getMenuOptions(); + + // Handle menu options + const handleAction = async (action: string) => { + switch (action) { + case "Hibernate": + case "Shutdown": + setSandboxState("PENDING"); + await sdk.sandboxes.shutdown(view.params.id); + setSandboxState("IDLE"); + setSelectedOption(0); + break; + case "Restart": + setSandboxState("PENDING"); + await sdk.sandboxes.restart(view.params.id); + setSandboxState("RUNNING"); + setSelectedOption(0); + break; + case "Start": + setSandboxState("PENDING"); + await sdk.sandboxes.resume(view.params.id); + setSandboxState("RUNNING"); + setSelectedOption(0); + break; + } + }; + + // Handle keyboard navigation + useInput((input, key) => { + if (key.escape) { + setView({ name: "dashboard" }); + } else if (menuOptions.length > 0) { + if (key.upArrow) { + setSelectedOption((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (key.downArrow) { + setSelectedOption((prev) => + prev < menuOptions.length - 1 ? prev + 1 : prev + ); + } else if (key.return) { + handleAction(menuOptions[selectedOption]); + } + } + }); + + if (!view.params.id) { + return No sandbox ID provided. Press escape to go back.; + } + + return ( + + {/* Handle query states */} + {sandboxQuery.isLoading && ( + + Loading sandbox information... + + )} + + {sandboxQuery.error && ( + + + Error loading sandbox: {(sandboxQuery.error as Error).message} + + + )} + + {sandboxQuery.data && ( + + + {sandboxQuery.data.title} - {view.params.id} + + + {sandboxQuery.data.description && ( + + {sandboxQuery.data.description} + + )} + + )} + + {/* Status display - moved above title and description */} + + Status: + + {sandboxState} + + + + {menuOptions.length > 0 && ( + + Actions: + {menuOptions.map((option, index) => ( + + + {selectedOption === index ? "> " : " "} + {option} + + + ))} + + )} + + + + {menuOptions.length > 0 + ? "Use arrow keys to navigate, Enter to select, ESC to go back" + : "Press ESC to go back"} + + + + ); +};