@runloop/rl-cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/cli.js +73 -60
- package/dist/commands/auth.js +0 -1
- package/dist/commands/blueprint/create.js +31 -83
- package/dist/commands/blueprint/get.js +29 -34
- package/dist/commands/blueprint/list.js +215 -213
- package/dist/commands/blueprint/logs.js +133 -37
- package/dist/commands/blueprint/preview.js +42 -38
- package/dist/commands/config.js +117 -0
- package/dist/commands/devbox/create.js +120 -40
- package/dist/commands/devbox/delete.js +17 -33
- package/dist/commands/devbox/download.js +29 -43
- package/dist/commands/devbox/exec.js +22 -39
- package/dist/commands/devbox/execAsync.js +20 -37
- package/dist/commands/devbox/get.js +13 -35
- package/dist/commands/devbox/getAsync.js +12 -34
- package/dist/commands/devbox/list.js +241 -402
- package/dist/commands/devbox/logs.js +20 -38
- package/dist/commands/devbox/read.js +29 -43
- package/dist/commands/devbox/resume.js +13 -35
- package/dist/commands/devbox/rsync.js +26 -78
- package/dist/commands/devbox/scp.js +25 -79
- package/dist/commands/devbox/sendStdin.js +41 -0
- package/dist/commands/devbox/shutdown.js +13 -35
- package/dist/commands/devbox/ssh.js +45 -78
- package/dist/commands/devbox/suspend.js +13 -35
- package/dist/commands/devbox/tunnel.js +36 -88
- package/dist/commands/devbox/upload.js +28 -36
- package/dist/commands/devbox/write.js +29 -44
- package/dist/commands/mcp-install.js +4 -3
- package/dist/commands/menu.js +24 -66
- package/dist/commands/object/delete.js +12 -34
- package/dist/commands/object/download.js +26 -74
- package/dist/commands/object/get.js +12 -34
- package/dist/commands/object/list.js +15 -93
- package/dist/commands/object/upload.js +35 -96
- package/dist/commands/snapshot/create.js +23 -39
- package/dist/commands/snapshot/delete.js +17 -33
- package/dist/commands/snapshot/get.js +16 -0
- package/dist/commands/snapshot/list.js +309 -80
- package/dist/commands/snapshot/status.js +12 -34
- package/dist/components/ActionsPopup.js +63 -39
- package/dist/components/Breadcrumb.js +10 -48
- package/dist/components/DevboxActionsMenu.js +182 -110
- package/dist/components/DevboxCreatePage.js +12 -7
- package/dist/components/DevboxDetailPage.js +76 -28
- package/dist/components/ErrorBoundary.js +29 -0
- package/dist/components/ErrorMessage.js +10 -2
- package/dist/components/Header.js +12 -4
- package/dist/components/InteractiveSpawn.js +94 -0
- package/dist/components/MainMenu.js +36 -32
- package/dist/components/MetadataDisplay.js +4 -4
- package/dist/components/OperationsMenu.js +1 -1
- package/dist/components/ResourceActionsMenu.js +4 -4
- package/dist/components/ResourceListView.js +46 -34
- package/dist/components/Spinner.js +7 -2
- package/dist/components/StatusBadge.js +1 -1
- package/dist/components/SuccessMessage.js +12 -2
- package/dist/components/Table.js +16 -6
- package/dist/hooks/useCursorPagination.js +125 -85
- package/dist/hooks/useExitOnCtrlC.js +14 -0
- package/dist/hooks/useViewportHeight.js +47 -0
- package/dist/mcp/server.js +65 -6
- package/dist/router/Router.js +68 -0
- package/dist/router/types.js +1 -0
- package/dist/screens/BlueprintListScreen.js +7 -0
- package/dist/screens/DevboxActionsScreen.js +25 -0
- package/dist/screens/DevboxCreateScreen.js +11 -0
- package/dist/screens/DevboxDetailScreen.js +60 -0
- package/dist/screens/DevboxListScreen.js +23 -0
- package/dist/screens/LogsSessionScreen.js +49 -0
- package/dist/screens/MenuScreen.js +23 -0
- package/dist/screens/SSHSessionScreen.js +55 -0
- package/dist/screens/SnapshotListScreen.js +7 -0
- package/dist/services/blueprintService.js +105 -0
- package/dist/services/devboxService.js +215 -0
- package/dist/services/snapshotService.js +81 -0
- package/dist/store/blueprintStore.js +89 -0
- package/dist/store/devboxStore.js +105 -0
- package/dist/store/index.js +7 -0
- package/dist/store/navigationStore.js +101 -0
- package/dist/store/snapshotStore.js +87 -0
- package/dist/utils/CommandExecutor.js +53 -24
- package/dist/utils/client.js +0 -2
- package/dist/utils/config.js +22 -111
- package/dist/utils/interactiveCommand.js +3 -2
- package/dist/utils/logFormatter.js +162 -0
- package/dist/utils/memoryMonitor.js +85 -0
- package/dist/utils/output.js +150 -59
- package/dist/utils/screen.js +23 -0
- package/dist/utils/ssh.js +3 -1
- package/dist/utils/sshSession.js +5 -29
- package/dist/utils/terminalDetection.js +97 -0
- package/dist/utils/terminalSync.js +39 -0
- package/dist/utils/theme.js +147 -13
- package/package.json +16 -13
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { Box, Text, useInput,
|
|
3
|
+
import { Box, Text, useInput, useApp } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import figures from "figures";
|
|
6
6
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
@@ -8,6 +8,8 @@ import { SpinnerComponent } from "./Spinner.js";
|
|
|
8
8
|
import { ErrorMessage } from "./ErrorMessage.js";
|
|
9
9
|
import { Table } from "./Table.js";
|
|
10
10
|
import { colors } from "../utils/theme.js";
|
|
11
|
+
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
12
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
11
13
|
// Format time ago in a succinct way
|
|
12
14
|
export const formatTimeAgo = (timestamp) => {
|
|
13
15
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
@@ -29,8 +31,15 @@ export const formatTimeAgo = (timestamp) => {
|
|
|
29
31
|
return `${years}y ago`;
|
|
30
32
|
};
|
|
31
33
|
export function ResourceListView({ config }) {
|
|
32
|
-
const { stdout } = useStdout();
|
|
33
34
|
const { exit: inkExit } = useApp();
|
|
35
|
+
const isMounted = React.useRef(true);
|
|
36
|
+
// Track mounted state
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
isMounted.current = true;
|
|
39
|
+
return () => {
|
|
40
|
+
isMounted.current = false;
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
34
43
|
const [loading, setLoading] = React.useState(true);
|
|
35
44
|
const [resources, setResources] = React.useState([]);
|
|
36
45
|
const [error, setError] = React.useState(null);
|
|
@@ -38,29 +47,39 @@ export function ResourceListView({ config }) {
|
|
|
38
47
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
39
48
|
const [searchMode, setSearchMode] = React.useState(false);
|
|
40
49
|
const [searchQuery, setSearchQuery] = React.useState("");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// Calculate overhead for viewport height:
|
|
51
|
+
// - Breadcrumb (3 lines + marginBottom): 4 lines
|
|
52
|
+
// - Search bar (if visible, 1 line + marginBottom): 2 lines
|
|
53
|
+
// - Table (title + top border + header + bottom border): 4 lines
|
|
54
|
+
// - Stats bar (marginTop + content): 2 lines
|
|
55
|
+
// - Help bar (marginTop + content): 2 lines
|
|
56
|
+
// - Safety buffer for edge cases: 1 line
|
|
57
|
+
// Total: 13 lines base + 2 if searching
|
|
58
|
+
const overhead = 13 + (searchMode || searchQuery ? 2 : 0);
|
|
59
|
+
const { viewportHeight } = useViewportHeight({
|
|
60
|
+
overhead,
|
|
61
|
+
minHeight: 5,
|
|
62
|
+
});
|
|
63
|
+
// Use viewport height for dynamic page size, or fall back to config
|
|
64
|
+
const pageSize = config.pageSize || viewportHeight;
|
|
48
65
|
// Fetch resources
|
|
49
|
-
const fetchData = React.useCallback(async (
|
|
66
|
+
const fetchData = React.useCallback(async (_isInitialLoad = false) => {
|
|
67
|
+
if (!isMounted.current)
|
|
68
|
+
return;
|
|
50
69
|
try {
|
|
51
|
-
if (isInitialLoad) {
|
|
52
|
-
setRefreshing(true);
|
|
53
|
-
}
|
|
54
70
|
const data = await config.fetchResources();
|
|
55
|
-
|
|
71
|
+
if (isMounted.current) {
|
|
72
|
+
setResources(data);
|
|
73
|
+
}
|
|
56
74
|
}
|
|
57
75
|
catch (err) {
|
|
58
|
-
|
|
76
|
+
if (isMounted.current) {
|
|
77
|
+
setError(err);
|
|
78
|
+
}
|
|
59
79
|
}
|
|
60
80
|
finally {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
setTimeout(() => setRefreshing(false), 300);
|
|
81
|
+
if (isMounted.current) {
|
|
82
|
+
setLoading(false);
|
|
64
83
|
}
|
|
65
84
|
}
|
|
66
85
|
}, [config.fetchResources]);
|
|
@@ -77,13 +96,7 @@ export function ResourceListView({ config }) {
|
|
|
77
96
|
return () => clearInterval(interval);
|
|
78
97
|
}
|
|
79
98
|
}, [config.autoRefresh, fetchData]);
|
|
80
|
-
//
|
|
81
|
-
React.useEffect(() => {
|
|
82
|
-
const interval = setInterval(() => {
|
|
83
|
-
setRefreshIcon((prev) => (prev + 1) % 10);
|
|
84
|
-
}, 80);
|
|
85
|
-
return () => clearInterval(interval);
|
|
86
|
-
}, []);
|
|
99
|
+
// Removed refresh icon animation to prevent constant re-renders and flashing
|
|
87
100
|
// Filter resources based on search query
|
|
88
101
|
const filteredResources = React.useMemo(() => {
|
|
89
102
|
if (!config.searchConfig?.enabled || !searchQuery.trim()) {
|
|
@@ -108,13 +121,13 @@ export function ResourceListView({ config }) {
|
|
|
108
121
|
}
|
|
109
122
|
}, [currentResources.length, selectedIndex]);
|
|
110
123
|
const selectedResource = currentResources[selectedIndex];
|
|
124
|
+
// Handle Ctrl+C to exit
|
|
125
|
+
useExitOnCtrlC();
|
|
111
126
|
// Input handling
|
|
112
127
|
useInput((input, key) => {
|
|
113
|
-
//
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
process.exit(130);
|
|
117
|
-
}
|
|
128
|
+
// Don't process input if unmounting
|
|
129
|
+
if (!isMounted.current)
|
|
130
|
+
return;
|
|
118
131
|
const pageResourcesCount = currentResources.length;
|
|
119
132
|
// Skip input handling when in search mode
|
|
120
133
|
if (searchMode) {
|
|
@@ -141,7 +154,6 @@ export function ResourceListView({ config }) {
|
|
|
141
154
|
setSelectedIndex(0);
|
|
142
155
|
}
|
|
143
156
|
else if (key.return && selectedResource && config.onSelect) {
|
|
144
|
-
console.clear();
|
|
145
157
|
config.onSelect(selectedResource);
|
|
146
158
|
}
|
|
147
159
|
else if (input === "/" && config.searchConfig?.enabled) {
|
|
@@ -173,8 +185,8 @@ export function ResourceListView({ config }) {
|
|
|
173
185
|
}
|
|
174
186
|
}
|
|
175
187
|
});
|
|
176
|
-
// Calculate stats
|
|
177
|
-
const
|
|
188
|
+
// Calculate stats (computed for potential future use)
|
|
189
|
+
const _stats = React.useMemo(() => {
|
|
178
190
|
if (!config.statusConfig || !config.getStatus) {
|
|
179
191
|
return null;
|
|
180
192
|
}
|
|
@@ -208,6 +220,6 @@ export function ResourceListView({ config }) {
|
|
|
208
220
|
setSearchMode(false);
|
|
209
221
|
setCurrentPage(0);
|
|
210
222
|
setSelectedIndex(0);
|
|
211
|
-
} }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Esc to cancel]"] })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: searchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", currentResources.length, " results) [/ to edit, Esc to clear]"] })] }))] })), _jsx(Table, { data: currentResources, keyExtractor: config.keyExtractor, selectedIndex: selectedIndex, title: `${config.resourceNamePlural.toLowerCase()}[${searchQuery ? currentResources.length : resources.length}]`, columns: config.columns }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", resources.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", filteredResources.length] }), _jsx(Text, { children: " " }),
|
|
223
|
+
} }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Esc to cancel]"] })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: searchQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", currentResources.length, " results) [/ to edit, Esc to clear]"] })] }))] })), _jsx(Table, { data: currentResources, keyExtractor: config.keyExtractor, selectedIndex: selectedIndex, title: `${config.resourceNamePlural.toLowerCase()}[${searchQuery ? currentResources.length : resources.length}]`, columns: config.columns }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", resources.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", filteredResources.length] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.success, children: figures.circleFilled })] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), config.onSelect && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Enter] Details"] })), config.searchConfig?.enabled && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [/] Search"] })), config.additionalShortcuts &&
|
|
212
224
|
config.additionalShortcuts.map((shortcut) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [", shortcut.key, "] ", shortcut.label] }, shortcut.key))), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
|
|
213
225
|
}
|
|
@@ -2,6 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import { colors } from "../utils/theme.js";
|
|
5
|
-
export const SpinnerComponent = ({ message
|
|
6
|
-
|
|
5
|
+
export const SpinnerComponent = ({ message }) => {
|
|
6
|
+
// Limit message length to prevent Yoga layout engine errors
|
|
7
|
+
const MAX_LENGTH = 200;
|
|
8
|
+
const truncatedMessage = message.length > MAX_LENGTH
|
|
9
|
+
? message.substring(0, MAX_LENGTH) + "..."
|
|
10
|
+
: message;
|
|
11
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: colors.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", truncatedMessage] })] }));
|
|
7
12
|
};
|
|
@@ -88,7 +88,7 @@ export const getStatusDisplay = (status) => {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
};
|
|
91
|
-
export const StatusBadge = ({ status, showText = true
|
|
91
|
+
export const StatusBadge = ({ status, showText = true }) => {
|
|
92
92
|
const statusDisplay = getStatusDisplay(status);
|
|
93
93
|
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: statusDisplay.color, children: statusDisplay.icon }), showText && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: statusDisplay.color, children: statusDisplay.text })] }))] }));
|
|
94
94
|
};
|
|
@@ -2,6 +2,16 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import figures from "figures";
|
|
4
4
|
import { colors } from "../utils/theme.js";
|
|
5
|
-
export const SuccessMessage = ({ message, details
|
|
6
|
-
|
|
5
|
+
export const SuccessMessage = ({ message, details }) => {
|
|
6
|
+
// Limit message length to prevent Yoga layout engine errors
|
|
7
|
+
const MAX_LENGTH = 500;
|
|
8
|
+
const truncatedMessage = message.length > MAX_LENGTH
|
|
9
|
+
? message.substring(0, MAX_LENGTH) + "..."
|
|
10
|
+
: message;
|
|
11
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: colors.success, bold: true, children: [figures.tick, " ", truncatedMessage] }) }), details && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: details.split("\n").map((line, i) => {
|
|
12
|
+
const truncatedLine = line.length > MAX_LENGTH
|
|
13
|
+
? line.substring(0, MAX_LENGTH) + "..."
|
|
14
|
+
: line;
|
|
15
|
+
return (_jsx(Text, { color: colors.textDim, dimColor: true, children: truncatedLine }, i));
|
|
16
|
+
}) }))] }));
|
|
7
17
|
};
|
package/dist/components/Table.js
CHANGED
|
@@ -2,21 +2,29 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
4
|
import figures from "figures";
|
|
5
|
-
import { colors } from "../utils/theme.js";
|
|
5
|
+
import { colors, sanitizeWidth } from "../utils/theme.js";
|
|
6
6
|
/**
|
|
7
7
|
* Reusable table component for displaying lists of data with optional selection
|
|
8
8
|
* Designed to be responsive and work across devboxes, blueprints, and snapshots
|
|
9
9
|
*/
|
|
10
10
|
export function Table({ data, columns, selectedIndex = -1, showSelection = true, emptyState, keyExtractor, title, }) {
|
|
11
|
+
// Safety: Handle null/undefined data
|
|
12
|
+
if (!data || !Array.isArray(data)) {
|
|
13
|
+
return emptyState ? _jsx(_Fragment, { children: emptyState }) : null;
|
|
14
|
+
}
|
|
11
15
|
if (data.length === 0 && emptyState) {
|
|
12
16
|
return _jsx(_Fragment, { children: emptyState });
|
|
13
17
|
}
|
|
14
18
|
// Filter visible columns
|
|
15
19
|
const visibleColumns = columns.filter((col) => col.visible !== false);
|
|
16
|
-
return (_jsxs(Box, { flexDirection: "column", children: [title && (_jsx(Box, { paddingX: 1, marginBottom: 0, children: _jsxs(Text, { color: colors.primary, bold: true, children: ["\u256D\u2500 ", title, " ", "─".repeat(
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", children: [title && (_jsx(Box, { paddingX: 1, marginBottom: 0, children: _jsxs(Text, { color: colors.primary, bold: true, children: ["\u256D\u2500 ", title.length > 50 ? title.substring(0, 50) + "..." : title, " ", "─".repeat(10), "\u256E"] }) })), _jsxs(Box, { flexDirection: "column", borderStyle: title ? "single" : "round", borderColor: colors.border, paddingX: 1, children: [_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column) => {
|
|
21
|
+
// Cap column width to prevent Yoga crashes from padEnd creating massive strings
|
|
22
|
+
const safeWidth = sanitizeWidth(column.width, 1, 100);
|
|
23
|
+
return (_jsx(Text, { bold: true, dimColor: true, children: column.label.slice(0, safeWidth).padEnd(safeWidth, " ") }, `header-${column.key}`));
|
|
24
|
+
})] }), data.map((row, index) => {
|
|
17
25
|
const isSelected = index === selectedIndex;
|
|
18
26
|
const rowKey = keyExtractor(row);
|
|
19
|
-
return (_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { color: isSelected ? colors.primary : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column, colIndex) => (_jsx(React.Fragment, { children: column.render(row, index, isSelected) }, `${rowKey}-${column.key}`)))] }, rowKey));
|
|
27
|
+
return (_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { color: isSelected ? colors.primary : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column, colIndex) => (_jsx(React.Fragment, { children: column.render(row, index, isSelected) }, `${rowKey}-${column.key}-${colIndex}`)))] }, rowKey));
|
|
20
28
|
})] })] }));
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
@@ -29,8 +37,10 @@ export function createTextColumn(key, label, getValue, options) {
|
|
|
29
37
|
width: options?.width || 20,
|
|
30
38
|
visible: options?.visible,
|
|
31
39
|
render: (row, index, isSelected) => {
|
|
32
|
-
const value = getValue(row);
|
|
33
|
-
const
|
|
40
|
+
const value = String(getValue(row) || "");
|
|
41
|
+
const rawWidth = options?.width || 20;
|
|
42
|
+
// CRITICAL: Sanitize width to prevent padEnd from creating invalid strings that crash Yoga
|
|
43
|
+
const width = sanitizeWidth(rawWidth, 1, 100);
|
|
34
44
|
const color = options?.color || (isSelected ? colors.text : colors.text);
|
|
35
45
|
const bold = options?.bold !== undefined ? options.bold : isSelected;
|
|
36
46
|
const dimColor = options?.dimColor || false;
|
|
@@ -38,7 +48,7 @@ export function createTextColumn(key, label, getValue, options) {
|
|
|
38
48
|
let truncated;
|
|
39
49
|
if (value.length > width) {
|
|
40
50
|
// Reserve space for ellipsis if truncating
|
|
41
|
-
truncated = value.slice(0, width - 1) + "…";
|
|
51
|
+
truncated = value.slice(0, Math.max(1, width - 1)) + "…";
|
|
42
52
|
}
|
|
43
53
|
else {
|
|
44
54
|
truncated = value;
|
|
@@ -1,125 +1,165 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Hook for cursor-based pagination with polling.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - No caching: always fetches fresh data on navigation or poll
|
|
7
|
+
* - Cursor history: tracks the last item ID of each visited page
|
|
8
|
+
* - cursorHistory[N] = last item ID of page N (used as startingAt for page N+1)
|
|
9
|
+
* - Polling: refreshes current page every pollInterval ms
|
|
10
|
+
*
|
|
11
|
+
* Navigation:
|
|
12
|
+
* - Page 0: startingAt = undefined
|
|
13
|
+
* - Page N: startingAt = cursorHistory[N-1]
|
|
14
|
+
* - Going back uses known cursor from history
|
|
15
|
+
*/
|
|
2
16
|
export function useCursorPagination(config) {
|
|
17
|
+
const { fetchPage, pageSize, getItemId, pollInterval = 2000, deps = [], pollingEnabled = true, } = config;
|
|
18
|
+
// State
|
|
3
19
|
const [items, setItems] = React.useState([]);
|
|
4
20
|
const [loading, setLoading] = React.useState(true);
|
|
21
|
+
const [navigating, setNavigating] = React.useState(false);
|
|
5
22
|
const [error, setError] = React.useState(null);
|
|
6
23
|
const [currentPage, setCurrentPage] = React.useState(0);
|
|
7
|
-
const [totalCount, setTotalCount] = React.useState(0);
|
|
8
24
|
const [hasMore, setHasMore] = React.useState(false);
|
|
9
|
-
const [
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
25
|
+
const [totalCount, setTotalCount] = React.useState(0);
|
|
26
|
+
// Cursor history: cursorHistory[N] = last item ID of page N
|
|
27
|
+
// Used to determine startingAt for page N+1
|
|
28
|
+
const cursorHistoryRef = React.useRef([]);
|
|
29
|
+
// Track if component is mounted
|
|
30
|
+
const isMountedRef = React.useRef(true);
|
|
31
|
+
// Track if we're currently fetching (to prevent concurrent fetches)
|
|
32
|
+
const isFetchingRef = React.useRef(false);
|
|
33
|
+
// Store stable references to config
|
|
34
|
+
const fetchPageRef = React.useRef(fetchPage);
|
|
35
|
+
const getItemIdRef = React.useRef(getItemId);
|
|
36
|
+
const pageSizeRef = React.useRef(pageSize);
|
|
37
|
+
// Keep refs in sync
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
fetchPageRef.current = fetchPage;
|
|
40
|
+
}, [fetchPage]);
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
getItemIdRef.current = getItemId;
|
|
43
|
+
}, [getItemId]);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
pageSizeRef.current = pageSize;
|
|
46
|
+
}, [pageSize]);
|
|
47
|
+
// Cleanup on unmount
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
isMountedRef.current = true;
|
|
50
|
+
return () => {
|
|
51
|
+
isMountedRef.current = false;
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
/**
|
|
55
|
+
* Fetch a specific page
|
|
56
|
+
* @param page - Page number to fetch (0-indexed)
|
|
57
|
+
* @param isInitialLoad - Whether this is the initial load (shows loading state)
|
|
58
|
+
* @param isNavigation - Whether this is a page navigation (shows navigating state)
|
|
59
|
+
*/
|
|
60
|
+
const fetchPageData = React.useCallback(async (page, isInitialLoad = false, isNavigation = false) => {
|
|
61
|
+
if (!isMountedRef.current)
|
|
62
|
+
return;
|
|
63
|
+
if (isFetchingRef.current)
|
|
64
|
+
return;
|
|
65
|
+
isFetchingRef.current = true;
|
|
14
66
|
try {
|
|
15
67
|
if (isInitialLoad) {
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
setLoading(true);
|
|
19
|
-
// Check cache first (skip on refresh)
|
|
20
|
-
if (!isInitialLoad && pageCache.current.has(currentPage)) {
|
|
21
|
-
setItems(pageCache.current.get(currentPage) || []);
|
|
22
|
-
setLoading(false);
|
|
23
|
-
return;
|
|
68
|
+
setLoading(true);
|
|
24
69
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const startingAt = currentPage > 0
|
|
28
|
-
? lastIdCache.current.get(currentPage - 1)
|
|
29
|
-
: undefined;
|
|
30
|
-
// Build query params
|
|
31
|
-
const queryParams = {
|
|
32
|
-
limit: config.pageSize,
|
|
33
|
-
...config.queryParams,
|
|
34
|
-
};
|
|
35
|
-
if (startingAt) {
|
|
36
|
-
queryParams.starting_at = startingAt;
|
|
70
|
+
if (isNavigation) {
|
|
71
|
+
setNavigating(true);
|
|
37
72
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
setError(null);
|
|
74
|
+
// Determine startingAt cursor:
|
|
75
|
+
// - Page 0: undefined
|
|
76
|
+
// - Page N: cursorHistory[N-1] (last item ID from previous page)
|
|
77
|
+
const startingAt = page > 0 ? cursorHistoryRef.current[page - 1] : undefined;
|
|
78
|
+
const result = await fetchPageRef.current({
|
|
79
|
+
limit: pageSizeRef.current,
|
|
80
|
+
startingAt,
|
|
81
|
+
});
|
|
82
|
+
if (!isMountedRef.current)
|
|
83
|
+
return;
|
|
84
|
+
// Update items
|
|
85
|
+
setItems(result.items);
|
|
86
|
+
// Update cursor history for this page
|
|
87
|
+
if (result.items.length > 0) {
|
|
88
|
+
const lastItemId = getItemIdRef.current(result.items[result.items.length - 1]);
|
|
89
|
+
cursorHistoryRef.current[page] = lastItemId;
|
|
51
90
|
}
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
91
|
+
// Update pagination state
|
|
92
|
+
setHasMore(result.hasMore);
|
|
93
|
+
if (result.totalCount !== undefined) {
|
|
94
|
+
setTotalCount(result.totalCount);
|
|
56
95
|
}
|
|
57
|
-
// Update items for current page
|
|
58
|
-
setItems(pageItems);
|
|
59
96
|
}
|
|
60
97
|
catch (err) {
|
|
98
|
+
if (!isMountedRef.current)
|
|
99
|
+
return;
|
|
61
100
|
setError(err);
|
|
62
101
|
}
|
|
63
102
|
finally {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
103
|
+
if (isMountedRef.current) {
|
|
104
|
+
setLoading(false);
|
|
105
|
+
setNavigating(false);
|
|
67
106
|
}
|
|
107
|
+
isFetchingRef.current = false;
|
|
68
108
|
}
|
|
69
|
-
}, [
|
|
70
|
-
//
|
|
109
|
+
}, []);
|
|
110
|
+
// Reset when deps change (e.g., filters, search)
|
|
111
|
+
const depsKey = JSON.stringify(deps);
|
|
71
112
|
React.useEffect(() => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
113
|
+
// Clear cursor history when deps change
|
|
114
|
+
cursorHistoryRef.current = [];
|
|
115
|
+
setCurrentPage(0);
|
|
116
|
+
setItems([]);
|
|
117
|
+
setHasMore(false);
|
|
118
|
+
setTotalCount(0);
|
|
119
|
+
// Fetch page 0
|
|
120
|
+
fetchPageData(0, true);
|
|
121
|
+
}, [depsKey, fetchPageData]);
|
|
122
|
+
// Polling effect
|
|
75
123
|
React.useEffect(() => {
|
|
76
|
-
if (!
|
|
124
|
+
if (!pollInterval || pollInterval <= 0 || !pollingEnabled) {
|
|
77
125
|
return;
|
|
78
126
|
}
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
127
|
+
const timer = setInterval(() => {
|
|
128
|
+
if (isMountedRef.current && !isFetchingRef.current) {
|
|
129
|
+
fetchPageData(currentPage, false);
|
|
130
|
+
}
|
|
131
|
+
}, pollInterval);
|
|
132
|
+
return () => clearInterval(timer);
|
|
133
|
+
}, [pollInterval, pollingEnabled, currentPage, fetchPageData]);
|
|
134
|
+
// Navigation functions
|
|
87
135
|
const nextPage = React.useCallback(() => {
|
|
88
|
-
if (!loading && hasMore) {
|
|
89
|
-
|
|
136
|
+
if (!loading && !navigating && hasMore) {
|
|
137
|
+
const newPage = currentPage + 1;
|
|
138
|
+
setCurrentPage(newPage);
|
|
139
|
+
fetchPageData(newPage, false, true);
|
|
90
140
|
}
|
|
91
|
-
}, [loading, hasMore]);
|
|
141
|
+
}, [loading, navigating, hasMore, currentPage, fetchPageData]);
|
|
92
142
|
const prevPage = React.useCallback(() => {
|
|
93
|
-
if (!loading && currentPage > 0) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const goToPage = React.useCallback((page) => {
|
|
98
|
-
if (!loading && page >= 0) {
|
|
99
|
-
setCurrentPage(page);
|
|
143
|
+
if (!loading && !navigating && currentPage > 0) {
|
|
144
|
+
const newPage = currentPage - 1;
|
|
145
|
+
setCurrentPage(newPage);
|
|
146
|
+
fetchPageData(newPage, false, true);
|
|
100
147
|
}
|
|
101
|
-
}, [loading]);
|
|
148
|
+
}, [loading, navigating, currentPage, fetchPageData]);
|
|
102
149
|
const refresh = React.useCallback(() => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
fetchData(true);
|
|
106
|
-
}, [fetchData]);
|
|
107
|
-
const clearCache = React.useCallback(() => {
|
|
108
|
-
pageCache.current.clear();
|
|
109
|
-
lastIdCache.current.clear();
|
|
110
|
-
}, []);
|
|
150
|
+
fetchPageData(currentPage, false);
|
|
151
|
+
}, [currentPage, fetchPageData]);
|
|
111
152
|
return {
|
|
112
153
|
items,
|
|
113
154
|
loading,
|
|
155
|
+
navigating,
|
|
114
156
|
error,
|
|
115
157
|
currentPage,
|
|
116
|
-
totalCount,
|
|
117
158
|
hasMore,
|
|
118
|
-
|
|
159
|
+
hasPrev: currentPage > 0,
|
|
160
|
+
totalCount,
|
|
119
161
|
nextPage,
|
|
120
162
|
prevPage,
|
|
121
|
-
goToPage,
|
|
122
163
|
refresh,
|
|
123
|
-
clearCache,
|
|
124
164
|
};
|
|
125
165
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to handle Ctrl+C (SIGINT) consistently across all screens
|
|
3
|
+
* Exits the program with proper cleanup of alternate screen buffer
|
|
4
|
+
*/
|
|
5
|
+
import { useInput } from "ink";
|
|
6
|
+
import { exitAlternateScreenBuffer } from "../utils/screen.js";
|
|
7
|
+
export function useExitOnCtrlC() {
|
|
8
|
+
useInput((input, key) => {
|
|
9
|
+
if (key.ctrl && input === "c") {
|
|
10
|
+
exitAlternateScreenBuffer();
|
|
11
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
3
|
+
/**
|
|
4
|
+
* Custom hook to calculate available viewport height for content rendering.
|
|
5
|
+
* Ensures consistent layout calculations across all CLI screens and prevents overflow.
|
|
6
|
+
*
|
|
7
|
+
* @param options Configuration for viewport calculation
|
|
8
|
+
* @returns Viewport dimensions including available height for content
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const { viewportHeight } = useViewportHeight({ overhead: 10 });
|
|
13
|
+
* const pageSize = viewportHeight; // Use for dynamic page sizing
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function useViewportHeight(options = {}) {
|
|
17
|
+
const { overhead = 0, minHeight = 5, maxHeight = 100 } = options;
|
|
18
|
+
const { stdout } = useStdout();
|
|
19
|
+
// Sample terminal dimensions ONCE and use fixed values - no reactive dependencies
|
|
20
|
+
// This prevents re-renders and Yoga WASM crashes from dynamic resizing
|
|
21
|
+
// CRITICAL: Initialize with safe fallback values to prevent null/undefined
|
|
22
|
+
const dimensions = React.useRef({
|
|
23
|
+
width: 120,
|
|
24
|
+
height: 30,
|
|
25
|
+
});
|
|
26
|
+
// Only sample on first call when still at default values
|
|
27
|
+
if (dimensions.current.width === 120 && dimensions.current.height === 30) {
|
|
28
|
+
// Only sample if stdout has valid dimensions
|
|
29
|
+
const sampledWidth = stdout?.columns && stdout.columns > 0 ? stdout.columns : 120;
|
|
30
|
+
const sampledHeight = stdout?.rows && stdout.rows > 0 ? stdout.rows : 30;
|
|
31
|
+
// Always enforce safe bounds to prevent Yoga crashes
|
|
32
|
+
dimensions.current = {
|
|
33
|
+
width: Math.max(80, Math.min(200, sampledWidth)),
|
|
34
|
+
height: Math.max(20, Math.min(100, sampledHeight)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const terminalHeight = dimensions.current.height;
|
|
38
|
+
const terminalWidth = dimensions.current.width;
|
|
39
|
+
// Calculate viewport height with bounds
|
|
40
|
+
const viewportHeight = Math.max(minHeight, Math.min(maxHeight, terminalHeight - overhead));
|
|
41
|
+
// Removed console.logs to prevent rendering interference
|
|
42
|
+
return {
|
|
43
|
+
viewportHeight,
|
|
44
|
+
terminalHeight,
|
|
45
|
+
terminalWidth,
|
|
46
|
+
};
|
|
47
|
+
}
|