@runloop/rl-cli 1.8.0 → 1.10.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 +21 -7
- package/dist/cli.js +0 -0
- package/dist/commands/blueprint/delete.js +21 -0
- package/dist/commands/blueprint/list.js +226 -174
- package/dist/commands/blueprint/prune.js +13 -28
- package/dist/commands/devbox/create.js +41 -0
- package/dist/commands/devbox/list.js +142 -110
- package/dist/commands/devbox/rsync.js +69 -41
- package/dist/commands/devbox/scp.js +180 -39
- package/dist/commands/devbox/tunnel.js +4 -19
- package/dist/commands/gateway-config/create.js +53 -0
- package/dist/commands/gateway-config/delete.js +21 -0
- package/dist/commands/gateway-config/get.js +18 -0
- package/dist/commands/gateway-config/list.js +493 -0
- package/dist/commands/gateway-config/update.js +70 -0
- package/dist/commands/snapshot/list.js +11 -2
- package/dist/commands/snapshot/prune.js +265 -0
- package/dist/components/BenchmarkMenu.js +23 -3
- package/dist/components/DetailedInfoView.js +20 -0
- package/dist/components/DevboxActionsMenu.js +26 -62
- package/dist/components/DevboxCreatePage.js +763 -15
- package/dist/components/DevboxDetailPage.js +73 -24
- package/dist/components/GatewayConfigCreatePage.js +272 -0
- package/dist/components/LogsViewer.js +6 -40
- package/dist/components/ResourceDetailPage.js +143 -160
- package/dist/components/ResourceListView.js +3 -33
- package/dist/components/ResourcePicker.js +234 -0
- package/dist/components/SecretCreatePage.js +71 -27
- package/dist/components/SettingsMenu.js +12 -2
- package/dist/components/StateHistory.js +1 -20
- package/dist/components/StatusBadge.js +9 -2
- package/dist/components/StreamingLogsViewer.js +8 -42
- package/dist/components/form/FormTextInput.js +4 -2
- package/dist/components/resourceDetailTypes.js +18 -0
- package/dist/hooks/useInputHandler.js +103 -0
- package/dist/router/Router.js +79 -2
- package/dist/screens/BenchmarkDetailScreen.js +163 -0
- package/dist/screens/BenchmarkJobCreateScreen.js +524 -0
- package/dist/screens/BenchmarkJobDetailScreen.js +614 -0
- package/dist/screens/BenchmarkJobListScreen.js +479 -0
- package/dist/screens/BenchmarkListScreen.js +266 -0
- package/dist/screens/BenchmarkMenuScreen.js +6 -0
- package/dist/screens/BenchmarkRunDetailScreen.js +258 -22
- package/dist/screens/BenchmarkRunListScreen.js +21 -1
- package/dist/screens/BlueprintDetailScreen.js +5 -1
- package/dist/screens/DevboxCreateScreen.js +2 -2
- package/dist/screens/GatewayConfigDetailScreen.js +236 -0
- package/dist/screens/GatewayConfigListScreen.js +7 -0
- package/dist/screens/ScenarioRunDetailScreen.js +6 -0
- package/dist/screens/SecretDetailScreen.js +26 -2
- package/dist/screens/SettingsMenuScreen.js +3 -0
- package/dist/screens/SnapshotDetailScreen.js +6 -0
- package/dist/services/agentService.js +42 -0
- package/dist/services/benchmarkJobService.js +122 -0
- package/dist/services/benchmarkService.js +47 -0
- package/dist/services/gatewayConfigService.js +153 -0
- package/dist/services/scenarioService.js +34 -0
- package/dist/store/benchmarkJobStore.js +66 -0
- package/dist/store/benchmarkStore.js +63 -0
- package/dist/store/gatewayConfigStore.js +83 -0
- package/dist/utils/browser.js +22 -0
- package/dist/utils/clipboard.js +41 -0
- package/dist/utils/commands.js +105 -9
- package/dist/utils/gatewayConfigValidation.js +58 -0
- package/dist/utils/time.js +121 -0
- package/package.json +43 -43
|
@@ -4,35 +4,21 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
* Can be used for devboxes, blueprints, snapshots, etc.
|
|
5
5
|
*/
|
|
6
6
|
import React from "react";
|
|
7
|
-
import { Box, Text
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
8
|
import figures from "figures";
|
|
9
|
-
import { Header } from "./Header.js";
|
|
10
9
|
import { StatusBadge } from "./StatusBadge.js";
|
|
11
10
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
11
|
+
import { DetailedInfoView } from "./DetailedInfoView.js";
|
|
12
12
|
import { NavigationTips } from "./NavigationTips.js";
|
|
13
13
|
import { colors } from "../utils/theme.js";
|
|
14
14
|
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
15
15
|
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (minutes < 60)
|
|
23
|
-
return `${minutes}m ago`;
|
|
24
|
-
const hours = Math.floor(minutes / 60);
|
|
25
|
-
if (hours < 24)
|
|
26
|
-
return `${hours}h ago`;
|
|
27
|
-
const days = Math.floor(hours / 24);
|
|
28
|
-
if (days < 30)
|
|
29
|
-
return `${days}d ago`;
|
|
30
|
-
const months = Math.floor(days / 30);
|
|
31
|
-
if (months < 12)
|
|
32
|
-
return `${months}mo ago`;
|
|
33
|
-
const years = Math.floor(months / 12);
|
|
34
|
-
return `${years}y ago`;
|
|
35
|
-
};
|
|
16
|
+
import { useInputHandler, scrollBindings, } from "../hooks/useInputHandler.js";
|
|
17
|
+
import { useNavigation } from "../store/navigationStore.js";
|
|
18
|
+
import { openInBrowser as openUrlInBrowser } from "../utils/browser.js";
|
|
19
|
+
import { copyToClipboard } from "../utils/clipboard.js";
|
|
20
|
+
import { collectActionableFields, } from "./resourceDetailTypes.js";
|
|
21
|
+
export { collectActionableFields } from "./resourceDetailTypes.js";
|
|
36
22
|
// Truncate long strings to prevent layout issues
|
|
37
23
|
const truncateString = (str, maxLength) => {
|
|
38
24
|
if (str.length <= maxLength)
|
|
@@ -41,6 +27,7 @@ const truncateString = (str, maxLength) => {
|
|
|
41
27
|
};
|
|
42
28
|
export function ResourceDetailPage({ resource: initialResource, resourceType, getDisplayName, getId, getStatus, getUrl, breadcrumbPrefix = [], detailSections, operations, onOperation, onBack, buildDetailLines, additionalContent, pollResource, pollInterval = 3000, }) {
|
|
43
29
|
const isMounted = React.useRef(true);
|
|
30
|
+
const { navigate } = useNavigation();
|
|
44
31
|
// Track mounted state
|
|
45
32
|
React.useEffect(() => {
|
|
46
33
|
isMounted.current = true;
|
|
@@ -51,45 +38,27 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
51
38
|
// Local state for resource data (updated by polling)
|
|
52
39
|
const [currentResource, setCurrentResource] = React.useState(initialResource);
|
|
53
40
|
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
54
|
-
// Copy to clipboard
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let args;
|
|
60
|
-
if (platform === "darwin") {
|
|
61
|
-
command = "pbcopy";
|
|
62
|
-
args = [];
|
|
63
|
-
}
|
|
64
|
-
else if (platform === "win32") {
|
|
65
|
-
command = "clip";
|
|
66
|
-
args = [];
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
command = "xclip";
|
|
70
|
-
args = ["-selection", "clipboard"];
|
|
71
|
-
}
|
|
72
|
-
const proc = spawn(command, args);
|
|
73
|
-
proc.stdin.write(text);
|
|
74
|
-
proc.stdin.end();
|
|
75
|
-
proc.on("exit", (code) => {
|
|
76
|
-
if (code === 0) {
|
|
77
|
-
setCopyStatus("Copied ID to clipboard!");
|
|
78
|
-
setTimeout(() => setCopyStatus(null), 2000);
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
setCopyStatus("Failed to copy");
|
|
82
|
-
setTimeout(() => setCopyStatus(null), 2000);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
proc.on("error", () => {
|
|
86
|
-
setCopyStatus("Copy not supported");
|
|
87
|
-
setTimeout(() => setCopyStatus(null), 2000);
|
|
88
|
-
});
|
|
41
|
+
// Copy to clipboard with status feedback
|
|
42
|
+
const handleCopy = React.useCallback(async (text) => {
|
|
43
|
+
const status = await copyToClipboard(text);
|
|
44
|
+
setCopyStatus(status);
|
|
45
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
89
46
|
}, []);
|
|
90
47
|
const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
|
|
91
48
|
const [detailScroll, setDetailScroll] = React.useState(0);
|
|
92
|
-
|
|
49
|
+
// Unified selectable items: actionable detail fields followed by operations.
|
|
50
|
+
// Arrow keys move through the entire list seamlessly.
|
|
51
|
+
const actionableFields = React.useMemo(() => collectActionableFields(detailSections), [detailSections]);
|
|
52
|
+
const totalSelectableItems = actionableFields.length + operations.length;
|
|
53
|
+
// Default selection is the first operation (skip links)
|
|
54
|
+
const [selectedIndex, setSelectedIndex] = React.useState(actionableFields.length);
|
|
55
|
+
// Clamp selectedIndex when the number of selectable items shrinks
|
|
56
|
+
// (e.g. operations list changes due to a status change from polling)
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (totalSelectableItems > 0 && selectedIndex >= totalSelectableItems) {
|
|
59
|
+
setSelectedIndex(totalSelectableItems - 1);
|
|
60
|
+
}
|
|
61
|
+
}, [totalSelectableItems, selectedIndex]);
|
|
93
62
|
// Background polling for resource details
|
|
94
63
|
React.useEffect(() => {
|
|
95
64
|
if (!pollResource || showDetailedInfo)
|
|
@@ -114,98 +83,118 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
114
83
|
const displayName = getDisplayName(currentResource);
|
|
115
84
|
const resourceId = getId(currentResource);
|
|
116
85
|
const status = getStatus(currentResource);
|
|
86
|
+
// Execute a field action
|
|
87
|
+
const executeFieldAction = React.useCallback((action) => {
|
|
88
|
+
if (action.type === "navigate" && action.screen) {
|
|
89
|
+
navigate(action.screen, action.params || {});
|
|
90
|
+
}
|
|
91
|
+
else if (action.type === "callback" && action.handler) {
|
|
92
|
+
action.handler();
|
|
93
|
+
}
|
|
94
|
+
}, [navigate]);
|
|
117
95
|
// Handle Ctrl+C to exit
|
|
118
96
|
useExitOnCtrlC();
|
|
119
|
-
|
|
120
|
-
|
|
97
|
+
// Helper: is the current selection on a link or an operation?
|
|
98
|
+
const isOnLink = selectedIndex < actionableFields.length;
|
|
99
|
+
const operationIndex = selectedIndex - actionableFields.length;
|
|
100
|
+
const handleOpenInBrowser = React.useCallback(() => {
|
|
101
|
+
if (!getUrl)
|
|
121
102
|
return;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
else if (key.pageDown) {
|
|
135
|
-
setDetailScroll(detailScroll + 10);
|
|
136
|
-
}
|
|
137
|
-
else if (key.pageUp) {
|
|
138
|
-
setDetailScroll(Math.max(0, detailScroll - 10));
|
|
103
|
+
openUrlInBrowser(getUrl(currentResource));
|
|
104
|
+
}, [getUrl, currentResource]);
|
|
105
|
+
const exitDetailedInfo = React.useCallback(() => {
|
|
106
|
+
setShowDetailedInfo(false);
|
|
107
|
+
setDetailScroll(0);
|
|
108
|
+
}, []);
|
|
109
|
+
const handleEnter = React.useCallback(() => {
|
|
110
|
+
if (isOnLink) {
|
|
111
|
+
const ref = actionableFields[selectedIndex];
|
|
112
|
+
if (ref) {
|
|
113
|
+
executeFieldAction(ref.action);
|
|
139
114
|
}
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
// Main view input handling
|
|
143
|
-
if (input === "q" || key.escape) {
|
|
144
|
-
onBack();
|
|
145
|
-
}
|
|
146
|
-
else if (input === "c" && !key.ctrl) {
|
|
147
|
-
// Copy resource ID to clipboard (ignore if Ctrl+C for quit)
|
|
148
|
-
copyToClipboard(getId(currentResource));
|
|
149
|
-
}
|
|
150
|
-
else if (input === "i" && buildDetailLines) {
|
|
151
|
-
setShowDetailedInfo(true);
|
|
152
|
-
setDetailScroll(0);
|
|
153
115
|
}
|
|
154
|
-
else
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
else if (key.downArrow && selectedOperation < operations.length - 1) {
|
|
158
|
-
setSelectedOperation(selectedOperation + 1);
|
|
159
|
-
}
|
|
160
|
-
else if (key.return) {
|
|
161
|
-
const op = operations[selectedOperation];
|
|
116
|
+
else {
|
|
117
|
+
const op = operations[operationIndex];
|
|
162
118
|
if (op) {
|
|
163
119
|
onOperation(op.key, currentResource);
|
|
164
120
|
}
|
|
165
121
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
122
|
+
}, [
|
|
123
|
+
isOnLink,
|
|
124
|
+
actionableFields,
|
|
125
|
+
selectedIndex,
|
|
126
|
+
operationIndex,
|
|
127
|
+
operations,
|
|
128
|
+
currentResource,
|
|
129
|
+
executeFieldAction,
|
|
130
|
+
onOperation,
|
|
131
|
+
]);
|
|
132
|
+
const inputModes = React.useMemo(() => [
|
|
133
|
+
{
|
|
134
|
+
name: "detailedInfo",
|
|
135
|
+
active: () => showDetailedInfo,
|
|
136
|
+
bindings: {
|
|
137
|
+
...scrollBindings(() => detailScroll, setDetailScroll),
|
|
138
|
+
q: exitDetailedInfo,
|
|
139
|
+
escape: exitDetailedInfo,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "mainView",
|
|
144
|
+
active: () => true,
|
|
145
|
+
bindings: {
|
|
146
|
+
q: onBack,
|
|
147
|
+
escape: onBack,
|
|
148
|
+
c: () => handleCopy(getId(currentResource)),
|
|
149
|
+
...(buildDetailLines
|
|
150
|
+
? {
|
|
151
|
+
i: () => {
|
|
152
|
+
setShowDetailedInfo(true);
|
|
153
|
+
setDetailScroll(0);
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
: {}),
|
|
157
|
+
up: () => {
|
|
158
|
+
if (selectedIndex > 0)
|
|
159
|
+
setSelectedIndex(selectedIndex - 1);
|
|
160
|
+
},
|
|
161
|
+
down: () => {
|
|
162
|
+
if (selectedIndex < totalSelectableItems - 1)
|
|
163
|
+
setSelectedIndex(selectedIndex + 1);
|
|
164
|
+
},
|
|
165
|
+
enter: handleEnter,
|
|
166
|
+
...(getUrl ? { o: handleOpenInBrowser } : {}),
|
|
167
|
+
},
|
|
168
|
+
onUnmatched: (input) => {
|
|
169
|
+
// Operation shortcuts work from anywhere
|
|
170
|
+
const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
|
|
171
|
+
if (matchedOpIndex !== -1) {
|
|
172
|
+
setSelectedIndex(actionableFields.length + matchedOpIndex);
|
|
173
|
+
onOperation(operations[matchedOpIndex].key, currentResource);
|
|
188
174
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
], [
|
|
178
|
+
showDetailedInfo,
|
|
179
|
+
detailScroll,
|
|
180
|
+
exitDetailedInfo,
|
|
181
|
+
onBack,
|
|
182
|
+
currentResource,
|
|
183
|
+
buildDetailLines,
|
|
184
|
+
selectedIndex,
|
|
185
|
+
totalSelectableItems,
|
|
186
|
+
handleEnter,
|
|
187
|
+
getUrl,
|
|
188
|
+
handleOpenInBrowser,
|
|
189
|
+
operations,
|
|
190
|
+
actionableFields,
|
|
191
|
+
onOperation,
|
|
192
|
+
getId,
|
|
193
|
+
]);
|
|
194
|
+
useInputHandler(inputModes, { isActive: isMounted.current });
|
|
194
195
|
// Detailed info mode - full screen
|
|
195
196
|
if (showDetailedInfo && buildDetailLines) {
|
|
196
|
-
|
|
197
|
-
const viewportHeight = detailViewport.viewportHeight;
|
|
198
|
-
const maxScroll = Math.max(0, detailLines.length - viewportHeight);
|
|
199
|
-
const actualScroll = Math.min(detailScroll, maxScroll);
|
|
200
|
-
const visibleLines = detailLines.slice(actualScroll, actualScroll + viewportHeight);
|
|
201
|
-
const hasMore = actualScroll + viewportHeight < detailLines.length;
|
|
202
|
-
const hasLess = actualScroll > 0;
|
|
203
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
204
|
-
...breadcrumbPrefix,
|
|
205
|
-
{ label: resourceType },
|
|
206
|
-
{ label: displayName },
|
|
207
|
-
{ label: "Full Details", active: true },
|
|
208
|
-
] }), _jsx(Header, { title: `${displayName} - Complete Information` }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Box, { marginBottom: 1, children: [_jsx(StatusBadge, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.idColor, children: resourceId })] }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, borderStyle: "round", borderColor: colors.border, paddingX: 2, paddingY: 1, children: _jsx(Box, { flexDirection: "column", children: visibleLines }) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Scroll \u2022 Line ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, detailLines.length), " of", " ", detailLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [q or esc] Back to Details"] })] })] }));
|
|
197
|
+
return (_jsx(DetailedInfoView, { detailLines: buildDetailLines(currentResource), scrollOffset: detailScroll, viewportHeight: detailViewport.viewportHeight, displayName: displayName, resourceId: resourceId, status: status, resourceType: resourceType, breadcrumbPrefix: breadcrumbPrefix }));
|
|
209
198
|
}
|
|
210
199
|
// Main detail view
|
|
211
200
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
@@ -214,33 +203,27 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
214
203
|
{ label: displayName, active: true },
|
|
215
204
|
] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [_jsx(Text, { color: colors.primary, bold: true, children: truncateString(displayName, Math.max(20, detailViewport.terminalWidth - 35)) }), displayName !== resourceId && (_jsxs(Text, { color: colors.idColor, children: [" \u2022 ", resourceId] }))] }), _jsx(Box, { children: _jsx(StatusBadge, { status: status, fullText: true }) })] }), detailSections.map((section, sectionIndex) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: section.color || colors.warning, bold: true, children: [section.icon || figures.squareSmallFilled, " ", section.title] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: section.fields
|
|
216
205
|
.filter((field) => field.value !== undefined && field.value !== null)
|
|
217
|
-
.map((field, fieldIndex) =>
|
|
218
|
-
|
|
206
|
+
.map((field, fieldIndex) => {
|
|
207
|
+
// Check if this field is an actionable field and whether it's selected
|
|
208
|
+
const isActionable = !!field.action;
|
|
209
|
+
const actionableIdx = isActionable
|
|
210
|
+
? actionableFields.findIndex((ref) => ref.sectionIndex === sectionIndex &&
|
|
211
|
+
ref.fieldIndex === fieldIndex)
|
|
212
|
+
: -1;
|
|
213
|
+
const isFieldSelected = isActionable && actionableIdx === selectedIndex;
|
|
214
|
+
return (_jsxs(Box, { children: [isActionable ? (_jsxs(Text, { color: isFieldSelected ? colors.primary : colors.textDim, children: [isFieldSelected ? figures.pointer : " ", " "] })) : null, _jsxs(Text, { color: isFieldSelected ? colors.primary : colors.textDim, bold: isFieldSelected, children: [field.label, field.label ? " " : ""] }), typeof field.value === "string" ? (_jsx(Text, { color: isFieldSelected
|
|
215
|
+
? colors.primary
|
|
216
|
+
: field.color || undefined, dimColor: !isFieldSelected && !field.color, bold: isFieldSelected, children: field.value })) : (field.value), isFieldSelected && field.action?.hint && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter: ", field.action.hint, "]"] }))] }, fieldIndex));
|
|
217
|
+
}) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
|
|
218
|
+
const isSelected = index + actionableFields.length === selectedIndex;
|
|
219
219
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
|
|
220
220
|
}) })] })), copyStatus && (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: colors.success, bold: true, children: copyStatus }) })), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
221
|
-
{ key: "Enter", label: "Execute" },
|
|
221
|
+
{ key: "Enter", label: isOnLink ? "Open Link" : "Execute" },
|
|
222
222
|
{ key: "c", label: "Copy ID" },
|
|
223
223
|
{ key: "i", label: "Full Details", condition: !!buildDetailLines },
|
|
224
224
|
{ key: "o", label: "Browser", condition: !!getUrl },
|
|
225
225
|
{ key: "q/Ctrl+C", label: "Back/Quit" },
|
|
226
226
|
] })] }));
|
|
227
227
|
}
|
|
228
|
-
//
|
|
229
|
-
export
|
|
230
|
-
if (!timestamp)
|
|
231
|
-
return undefined;
|
|
232
|
-
const formatted = new Date(timestamp).toLocaleString();
|
|
233
|
-
const ago = formatTimeAgo(timestamp);
|
|
234
|
-
return `${formatted} (${ago})`;
|
|
235
|
-
}
|
|
236
|
-
// Helper to format create time with arrow to end time
|
|
237
|
-
export function formatTimeRange(createTime, endTime) {
|
|
238
|
-
if (!createTime)
|
|
239
|
-
return undefined;
|
|
240
|
-
const start = new Date(createTime).toLocaleString();
|
|
241
|
-
if (endTime) {
|
|
242
|
-
const end = new Date(endTime).toLocaleString();
|
|
243
|
-
return `${start} → ${end}`;
|
|
244
|
-
}
|
|
245
|
-
return `${start} (${formatTimeAgo(createTime)})`;
|
|
246
|
-
}
|
|
228
|
+
// Re-export format helpers from utils/time for backward compatibility
|
|
229
|
+
export { formatTimestamp, formatTimeRange } from "../utils/time.js";
|
|
@@ -11,39 +11,9 @@ import { Table } from "./Table.js";
|
|
|
11
11
|
import { colors } from "../utils/theme.js";
|
|
12
12
|
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
13
13
|
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
const date = new Date(timestamp);
|
|
18
|
-
const now = new Date();
|
|
19
|
-
// Format time as HH:MM:SS (24h)
|
|
20
|
-
const time = date.toTimeString().slice(0, 8);
|
|
21
|
-
// Less than 1 minute
|
|
22
|
-
if (seconds < 60)
|
|
23
|
-
return `${time} (${seconds}s ago)`;
|
|
24
|
-
const minutes = Math.floor(seconds / 60);
|
|
25
|
-
// Less than 1 hour
|
|
26
|
-
if (minutes < 60)
|
|
27
|
-
return `${time} (${minutes}m ago)`;
|
|
28
|
-
const hours = Math.floor(minutes / 60);
|
|
29
|
-
// Less than 24 hours - show time + relative
|
|
30
|
-
if (hours < 24)
|
|
31
|
-
return `${time} (${hours}hr ago)`;
|
|
32
|
-
const days = Math.floor(hours / 24);
|
|
33
|
-
const sameYear = date.getFullYear() === now.getFullYear();
|
|
34
|
-
// Format date parts
|
|
35
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
36
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
37
|
-
const year = date.getFullYear();
|
|
38
|
-
// Date format: MM-DD or YYYY-MM-DD if different year
|
|
39
|
-
const dateStr = `${month}-${day}`;
|
|
40
|
-
// 1-7 days - show date + time + relative
|
|
41
|
-
if (days <= 7) {
|
|
42
|
-
return `${dateStr} ${time} (${days}d)`;
|
|
43
|
-
}
|
|
44
|
-
// More than 7 days - just date + time, no relative
|
|
45
|
-
return `${dateStr} ${time}`;
|
|
46
|
-
};
|
|
14
|
+
import { formatTimeAgoRich } from "../utils/time.js";
|
|
15
|
+
// Re-export for backwards compatibility with existing imports
|
|
16
|
+
export const formatTimeAgo = formatTimeAgoRich;
|
|
47
17
|
export function ResourceListView({ config }) {
|
|
48
18
|
const { exit: inkExit } = useApp();
|
|
49
19
|
const isMounted = React.useRef(true);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ResourcePicker - Reusable component for selecting resources
|
|
4
|
+
* Supports single-select and multi-select modes with search and pagination
|
|
5
|
+
* Uses Table component for consistent styling with resource list views
|
|
6
|
+
*/
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { Box, Text, useInput } from "ink";
|
|
9
|
+
import figures from "figures";
|
|
10
|
+
import { Breadcrumb } from "./Breadcrumb.js";
|
|
11
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
12
|
+
import { SpinnerComponent } from "./Spinner.js";
|
|
13
|
+
import { ErrorMessage } from "./ErrorMessage.js";
|
|
14
|
+
import { SearchBar } from "./SearchBar.js";
|
|
15
|
+
import { Table, createTextColumn, createComponentColumn, } from "./Table.js";
|
|
16
|
+
export { createTextColumn, createComponentColumn };
|
|
17
|
+
import { colors } from "../utils/theme.js";
|
|
18
|
+
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
19
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
20
|
+
import { useCursorPagination } from "../hooks/useCursorPagination.js";
|
|
21
|
+
import { useListSearch } from "../hooks/useListSearch.js";
|
|
22
|
+
/**
|
|
23
|
+
* ResourcePicker component for selecting resources from a list
|
|
24
|
+
*/
|
|
25
|
+
export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [], }) {
|
|
26
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
27
|
+
const [selectedIds, setSelectedIds] = React.useState(new Set(initialSelected));
|
|
28
|
+
// Search state
|
|
29
|
+
const search = useListSearch({
|
|
30
|
+
onSearchSubmit: () => setSelectedIndex(0),
|
|
31
|
+
onSearchClear: () => setSelectedIndex(0),
|
|
32
|
+
});
|
|
33
|
+
// Calculate overhead for viewport height
|
|
34
|
+
// Matches list pages: breadcrumb(4) + table chrome(4) + stats(2) + nav tips(2) + buffer(1) = 13
|
|
35
|
+
const overhead = 13 + search.getSearchOverhead();
|
|
36
|
+
const { viewportHeight, terminalWidth } = useViewportHeight({
|
|
37
|
+
overhead,
|
|
38
|
+
minHeight: 5,
|
|
39
|
+
});
|
|
40
|
+
const PAGE_SIZE = viewportHeight;
|
|
41
|
+
// Resolve columns - support both static array and function that receives terminalWidth
|
|
42
|
+
const resolvedColumns = React.useMemo(() => {
|
|
43
|
+
if (!config.columns)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (typeof config.columns === "function") {
|
|
46
|
+
return config.columns(terminalWidth);
|
|
47
|
+
}
|
|
48
|
+
return config.columns;
|
|
49
|
+
}, [config.columns, terminalWidth]);
|
|
50
|
+
// Store fetchPage in a ref to avoid dependency issues
|
|
51
|
+
const fetchPageRef = React.useRef(config.fetchPage);
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
fetchPageRef.current = config.fetchPage;
|
|
54
|
+
}, [config.fetchPage]);
|
|
55
|
+
// Fetch function for pagination hook
|
|
56
|
+
const fetchPage = React.useCallback(async (params) => {
|
|
57
|
+
return fetchPageRef.current({
|
|
58
|
+
limit: params.limit,
|
|
59
|
+
startingAt: params.startingAt,
|
|
60
|
+
search: search.submittedSearchQuery || undefined,
|
|
61
|
+
});
|
|
62
|
+
}, [search.submittedSearchQuery]);
|
|
63
|
+
// Use the shared pagination hook
|
|
64
|
+
const { items, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, } = useCursorPagination({
|
|
65
|
+
fetchPage,
|
|
66
|
+
pageSize: PAGE_SIZE,
|
|
67
|
+
getItemId: config.getItemId,
|
|
68
|
+
pollInterval: 0, // No polling for picker
|
|
69
|
+
pollingEnabled: false,
|
|
70
|
+
deps: [PAGE_SIZE, search.submittedSearchQuery],
|
|
71
|
+
});
|
|
72
|
+
// Handle Ctrl+C to exit
|
|
73
|
+
useExitOnCtrlC();
|
|
74
|
+
// Ensure selected index is within bounds
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
if (items.length > 0 && selectedIndex >= items.length) {
|
|
77
|
+
setSelectedIndex(Math.max(0, items.length - 1));
|
|
78
|
+
}
|
|
79
|
+
}, [items.length, selectedIndex]);
|
|
80
|
+
const selectedItem = items[selectedIndex];
|
|
81
|
+
const minSelection = config.minSelection ?? 1;
|
|
82
|
+
const canConfirm = config.mode === "single"
|
|
83
|
+
? selectedItem !== undefined
|
|
84
|
+
: selectedIds.size >= minSelection;
|
|
85
|
+
// Toggle selection for multi-select
|
|
86
|
+
const toggleSelection = React.useCallback((item) => {
|
|
87
|
+
const id = config.getItemId(item);
|
|
88
|
+
setSelectedIds((prev) => {
|
|
89
|
+
const next = new Set(prev);
|
|
90
|
+
if (next.has(id)) {
|
|
91
|
+
next.delete(id);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (config.maxSelection && next.size >= config.maxSelection) {
|
|
95
|
+
return prev; // Don't add if at max
|
|
96
|
+
}
|
|
97
|
+
next.add(id);
|
|
98
|
+
}
|
|
99
|
+
return next;
|
|
100
|
+
});
|
|
101
|
+
}, [config.getItemId, config.maxSelection]);
|
|
102
|
+
// Handle confirmation
|
|
103
|
+
const handleConfirm = React.useCallback(() => {
|
|
104
|
+
if (config.mode === "single") {
|
|
105
|
+
if (selectedItem) {
|
|
106
|
+
onSelect([selectedItem]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const selectedItems = items.filter((item) => selectedIds.has(config.getItemId(item)));
|
|
111
|
+
// Also include items from previous pages that were selected
|
|
112
|
+
// For now, we only return items from current view
|
|
113
|
+
// A more complete implementation would track full item objects
|
|
114
|
+
if (selectedItems.length >= minSelection) {
|
|
115
|
+
onSelect(selectedItems);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}, [config, selectedItem, selectedIds, items, minSelection, onSelect]);
|
|
119
|
+
// Calculate pagination info for display
|
|
120
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
|
121
|
+
useInput((input, key) => {
|
|
122
|
+
// Handle search mode input
|
|
123
|
+
if (search.searchMode) {
|
|
124
|
+
if (key.escape) {
|
|
125
|
+
search.cancelSearch();
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const pageItems = items.length;
|
|
130
|
+
// Navigation
|
|
131
|
+
if (key.upArrow && selectedIndex > 0) {
|
|
132
|
+
setSelectedIndex(selectedIndex - 1);
|
|
133
|
+
}
|
|
134
|
+
else if (key.downArrow && selectedIndex < pageItems - 1) {
|
|
135
|
+
setSelectedIndex(selectedIndex + 1);
|
|
136
|
+
}
|
|
137
|
+
else if ((input === "n" || key.rightArrow) &&
|
|
138
|
+
!loading &&
|
|
139
|
+
!navigating &&
|
|
140
|
+
hasMore) {
|
|
141
|
+
nextPage();
|
|
142
|
+
setSelectedIndex(0);
|
|
143
|
+
}
|
|
144
|
+
else if ((input === "p" || key.leftArrow) &&
|
|
145
|
+
!loading &&
|
|
146
|
+
!navigating &&
|
|
147
|
+
hasPrev) {
|
|
148
|
+
prevPage();
|
|
149
|
+
setSelectedIndex(0);
|
|
150
|
+
}
|
|
151
|
+
else if (input === " " && config.mode === "multi" && selectedItem) {
|
|
152
|
+
// Space toggles selection in multi mode
|
|
153
|
+
toggleSelection(selectedItem);
|
|
154
|
+
}
|
|
155
|
+
else if (key.return) {
|
|
156
|
+
if (config.mode === "single" && selectedItem) {
|
|
157
|
+
// Enter selects in single mode
|
|
158
|
+
onSelect([selectedItem]);
|
|
159
|
+
}
|
|
160
|
+
else if (config.mode === "multi" && canConfirm) {
|
|
161
|
+
// Enter confirms in multi mode
|
|
162
|
+
handleConfirm();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (input === "c" && config.onCreateNew) {
|
|
166
|
+
config.onCreateNew();
|
|
167
|
+
}
|
|
168
|
+
else if (input === "/") {
|
|
169
|
+
search.enterSearchMode();
|
|
170
|
+
}
|
|
171
|
+
else if (key.escape) {
|
|
172
|
+
if (search.handleEscape()) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
onCancel();
|
|
176
|
+
}
|
|
177
|
+
else if (input === "q") {
|
|
178
|
+
onCancel();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
// Loading state
|
|
182
|
+
if (loading && items.length === 0) {
|
|
183
|
+
return (_jsxs(_Fragment, { children: [config.breadcrumbItems && (_jsx(Breadcrumb, { items: config.breadcrumbItems })), _jsx(SpinnerComponent, { message: `Loading ${config.title.toLowerCase()}...` })] }));
|
|
184
|
+
}
|
|
185
|
+
// Error state
|
|
186
|
+
if (error) {
|
|
187
|
+
return (_jsxs(_Fragment, { children: [config.breadcrumbItems && (_jsx(Breadcrumb, { items: config.breadcrumbItems })), _jsx(ErrorMessage, { message: `Failed to load resources`, error: error }), _jsx(NavigationTips, { tips: [{ key: "Esc", label: "Cancel" }] })] }));
|
|
188
|
+
}
|
|
189
|
+
// Calculate pagination info for display
|
|
190
|
+
const startIndex = currentPage * PAGE_SIZE;
|
|
191
|
+
const endIndex = Math.min(startIndex + PAGE_SIZE, totalCount);
|
|
192
|
+
return (_jsxs(_Fragment, { children: [config.breadcrumbItems && _jsx(Breadcrumb, { items: config.breadcrumbItems }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: config.searchPlaceholder || "Search..." }), resolvedColumns ? (_jsx(Table, { data: items, keyExtractor: config.getItemId, selectedIndex: selectedIndex, title: `${config.title.toLowerCase()}[${totalCount}]${config.mode === "multi" ? ` (${selectedIds.size} selected)` : ""}`, columns: config.mode === "multi"
|
|
193
|
+
? [
|
|
194
|
+
// Prepend checkbox column for multi-select mode
|
|
195
|
+
createComponentColumn("_selection", "", (row) => {
|
|
196
|
+
const isChecked = selectedIds.has(config.getItemId(row));
|
|
197
|
+
return (_jsxs(Text, { color: isChecked ? colors.success : colors.textDim, children: [isChecked
|
|
198
|
+
? figures.checkboxOn
|
|
199
|
+
: figures.checkboxOff, " "] }));
|
|
200
|
+
}, { width: 3 }),
|
|
201
|
+
...resolvedColumns,
|
|
202
|
+
]
|
|
203
|
+
: resolvedColumns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found", config.onCreateNew ? " Press [c] to create one." : ""] }) })) : (
|
|
204
|
+
// Fallback simple list view if no columns provided
|
|
205
|
+
_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, paddingY: 0, children: items.length === 0 ? (_jsx(Box, { paddingY: 1, children: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found", config.onCreateNew ? " Press [c] to create one." : ""] }) })) : (items.map((item, index) => {
|
|
206
|
+
const isHighlighted = index === selectedIndex;
|
|
207
|
+
const id = config.getItemId(item);
|
|
208
|
+
const label = config.getItemLabel(item);
|
|
209
|
+
const status = config.getItemStatus?.(item);
|
|
210
|
+
const isChecked = selectedIds.has(id);
|
|
211
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? colors.primary : colors.textDim, children: isHighlighted ? figures.pointer : " " }), _jsx(Text, { children: " " }), config.mode === "multi" && (_jsxs(Text, { color: isChecked ? colors.success : colors.textDim, children: [isChecked
|
|
212
|
+
? figures.checkboxOn
|
|
213
|
+
: figures.checkboxOff, " "] })), _jsx(Text, { color: colors.text, bold: isHighlighted, inverse: isHighlighted, children: label }), status && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: status })] }))] }, id));
|
|
214
|
+
})) })), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_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 ", totalCount] })] }), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
215
|
+
{
|
|
216
|
+
icon: `${figures.arrowLeft}${figures.arrowRight}`,
|
|
217
|
+
label: "Page",
|
|
218
|
+
condition: hasMore || hasPrev,
|
|
219
|
+
},
|
|
220
|
+
...(config.mode === "multi"
|
|
221
|
+
? [{ key: "Space", label: "Toggle" }]
|
|
222
|
+
: []),
|
|
223
|
+
{
|
|
224
|
+
key: "Enter",
|
|
225
|
+
label: config.mode === "single" ? "Select" : "Confirm",
|
|
226
|
+
condition: canConfirm,
|
|
227
|
+
},
|
|
228
|
+
...(config.onCreateNew
|
|
229
|
+
? [{ key: "c", label: config.createNewLabel || "Create new" }]
|
|
230
|
+
: []),
|
|
231
|
+
{ key: "/", label: "Search" },
|
|
232
|
+
{ key: "Esc", label: "Cancel" },
|
|
233
|
+
] })] }));
|
|
234
|
+
}
|