@runloop/rl-cli 1.5.0 → 1.7.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/dist/commands/blueprint/list.js +30 -5
- package/dist/commands/devbox/create.js +18 -0
- package/dist/commands/devbox/list.js +30 -34
- package/dist/commands/devbox/tunnel.js +25 -0
- package/dist/commands/network-policy/list.js +30 -5
- package/dist/commands/object/list.js +30 -5
- package/dist/commands/snapshot/list.js +30 -5
- package/dist/components/DevboxActionsMenu.js +356 -50
- package/dist/components/ExecViewer.js +439 -0
- package/dist/components/LogsViewer.js +5 -2
- package/dist/components/ResourceDetailPage.js +2 -2
- package/dist/components/SearchBar.js +24 -0
- package/dist/components/StreamingLogsViewer.js +276 -0
- package/dist/hooks/useListSearch.js +54 -0
- package/dist/router/Router.js +3 -1
- package/dist/screens/DevboxExecScreen.js +51 -0
- package/dist/screens/SnapshotDetailScreen.js +20 -0
- package/dist/services/devboxService.js +42 -5
- package/dist/services/snapshotService.js +17 -4
- package/dist/utils/commands.js +2 -0
- package/dist/utils/output.js +8 -1
- package/package.json +3 -3
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ExecViewer - Unified execution viewer for both sync and async modes
|
|
4
|
+
* Supports kill, leave-early, and run-another for both modes
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text, useInput } from "ink";
|
|
8
|
+
import Spinner from "ink-spinner";
|
|
9
|
+
import figures from "figures";
|
|
10
|
+
import { Breadcrumb } from "./Breadcrumb.js";
|
|
11
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
12
|
+
import { colors } from "../utils/theme.js";
|
|
13
|
+
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
14
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
15
|
+
import { execCommandAsync, getExecution, killExecution, } from "../services/devboxService.js";
|
|
16
|
+
import { getClient } from "../utils/client.js";
|
|
17
|
+
export const ExecViewer = ({ devboxId, command, breadcrumbItems, onBack, onRunAnother, existingExecutionId, onExecutionStart, }) => {
|
|
18
|
+
// State
|
|
19
|
+
const [status, setStatus] = React.useState(existingExecutionId ? "running" : "starting");
|
|
20
|
+
const [executionId, setExecutionId] = React.useState(existingExecutionId || null);
|
|
21
|
+
const [stdout, setStdout] = React.useState("");
|
|
22
|
+
const [stderr, setStderr] = React.useState("");
|
|
23
|
+
const [exitCode, setExitCode] = React.useState(null);
|
|
24
|
+
const [error, setError] = React.useState(null);
|
|
25
|
+
const [scroll, setScroll] = React.useState(0);
|
|
26
|
+
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
27
|
+
const [elapsedTime, setElapsedTime] = React.useState(0);
|
|
28
|
+
const [finalDuration, setFinalDuration] = React.useState(null);
|
|
29
|
+
const [autoScroll, setAutoScroll] = React.useState(true);
|
|
30
|
+
// Use ref for start time so we can set it when execution actually starts
|
|
31
|
+
const startTimeRef = React.useRef(Date.now());
|
|
32
|
+
// Refs for cleanup
|
|
33
|
+
const abortControllerRef = React.useRef(null);
|
|
34
|
+
const pollIntervalRef = React.useRef(null);
|
|
35
|
+
const streamCleanupRef = React.useRef(null);
|
|
36
|
+
// Ref for callback to avoid stale closures
|
|
37
|
+
const onExecutionStartRef = React.useRef(onExecutionStart);
|
|
38
|
+
onExecutionStartRef.current = onExecutionStart;
|
|
39
|
+
// Viewport calculation
|
|
40
|
+
const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 });
|
|
41
|
+
// Handle Ctrl+C
|
|
42
|
+
useExitOnCtrlC();
|
|
43
|
+
// Elapsed time updater (in milliseconds)
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
if (status === "running" || status === "starting") {
|
|
46
|
+
const timer = setInterval(() => {
|
|
47
|
+
setElapsedTime(Date.now() - startTimeRef.current);
|
|
48
|
+
}, 100);
|
|
49
|
+
return () => clearInterval(timer);
|
|
50
|
+
}
|
|
51
|
+
}, [status]);
|
|
52
|
+
// Track if execution has started to prevent re-execution on re-renders
|
|
53
|
+
const executionStartedRef = React.useRef(false);
|
|
54
|
+
// Store the initial existingExecutionId to detect true remounts vs just prop updates
|
|
55
|
+
const initialExistingExecutionIdRef = React.useRef(existingExecutionId);
|
|
56
|
+
// Start execution on mount (only once), or resume if existingExecutionId provided
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
// Prevent re-execution if already started (e.g., during resize re-renders)
|
|
59
|
+
// Also ignore if existingExecutionId changed from null to a value (that's us reporting back)
|
|
60
|
+
if (executionStartedRef.current) {
|
|
61
|
+
// Only resume if this is a true remount (component was unmounted and remounted with an ID)
|
|
62
|
+
// Not if we just reported the ID back to parent
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
executionStartedRef.current = true;
|
|
66
|
+
const startOrResumeExecution = async () => {
|
|
67
|
+
// If we have an existing execution ID on initial mount, resume
|
|
68
|
+
if (initialExistingExecutionIdRef.current) {
|
|
69
|
+
try {
|
|
70
|
+
// Fetch current output state before resuming
|
|
71
|
+
const currentState = await getExecution(devboxId, initialExistingExecutionIdRef.current);
|
|
72
|
+
setStdout(currentState.stdout ?? "");
|
|
73
|
+
setStderr(currentState.stderr ?? "");
|
|
74
|
+
if (currentState.status === "completed") {
|
|
75
|
+
setFinalDuration(Date.now() - startTimeRef.current);
|
|
76
|
+
setStatus("completed");
|
|
77
|
+
setExitCode(currentState.exit_status ?? 0);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// If fetch fails, just continue with polling
|
|
83
|
+
}
|
|
84
|
+
// Reconnect to streams - they'll replay from offset 0
|
|
85
|
+
startStreaming(initialExistingExecutionIdRef.current);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Start a new execution
|
|
89
|
+
try {
|
|
90
|
+
const result = await execCommandAsync(devboxId, command);
|
|
91
|
+
// Reset start time to when execution actually begins
|
|
92
|
+
startTimeRef.current = Date.now();
|
|
93
|
+
setExecutionId(result.executionId);
|
|
94
|
+
setStatus("running");
|
|
95
|
+
// Report execution ID to parent so it survives remounts
|
|
96
|
+
if (onExecutionStartRef.current) {
|
|
97
|
+
onExecutionStartRef.current(result.executionId);
|
|
98
|
+
}
|
|
99
|
+
// Always use streaming for live output
|
|
100
|
+
startStreaming(result.executionId);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
setError(err.message);
|
|
104
|
+
setStatus("failed");
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
startOrResumeExecution();
|
|
108
|
+
// Cleanup on unmount only - not on dep changes since executionStartedRef prevents re-run
|
|
109
|
+
return () => {
|
|
110
|
+
if (pollIntervalRef.current) {
|
|
111
|
+
clearInterval(pollIntervalRef.current);
|
|
112
|
+
}
|
|
113
|
+
if (streamCleanupRef.current) {
|
|
114
|
+
streamCleanupRef.current();
|
|
115
|
+
}
|
|
116
|
+
if (abortControllerRef.current) {
|
|
117
|
+
abortControllerRef.current.abort();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}, [devboxId, command]);
|
|
121
|
+
// Start streaming for live output
|
|
122
|
+
const startStreaming = async (execId) => {
|
|
123
|
+
const client = getClient();
|
|
124
|
+
abortControllerRef.current = new AbortController();
|
|
125
|
+
let stdoutOffset = 0;
|
|
126
|
+
let stderrOffset = 0;
|
|
127
|
+
let isCompleted = false;
|
|
128
|
+
// Wait for execution to complete using long-polling
|
|
129
|
+
const waitForCompletion = async () => {
|
|
130
|
+
try {
|
|
131
|
+
const result = await client.devboxes.executions.awaitCompleted(devboxId, execId);
|
|
132
|
+
isCompleted = true;
|
|
133
|
+
// Get final output to ensure we have everything
|
|
134
|
+
setStdout(result.stdout ?? "");
|
|
135
|
+
setStderr(result.stderr ?? "");
|
|
136
|
+
setExitCode(result.exit_status ?? 0);
|
|
137
|
+
setFinalDuration(Date.now() - startTimeRef.current);
|
|
138
|
+
setStatus("completed");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Ignore errors - execution may have been killed
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
// Start waiting for completion
|
|
145
|
+
waitForCompletion();
|
|
146
|
+
// Stream stdout
|
|
147
|
+
const streamStdout = async () => {
|
|
148
|
+
try {
|
|
149
|
+
const stream = await client.devboxes.executions.streamStdoutUpdates(devboxId, execId, { offset: stdoutOffset.toString() });
|
|
150
|
+
for await (const chunk of stream) {
|
|
151
|
+
if (abortControllerRef.current?.signal.aborted || isCompleted)
|
|
152
|
+
break;
|
|
153
|
+
if (chunk.output) {
|
|
154
|
+
setStdout((prev) => {
|
|
155
|
+
const newOutput = prev + chunk.output;
|
|
156
|
+
// Truncate if too long
|
|
157
|
+
if (newOutput.length > 10000) {
|
|
158
|
+
return newOutput.substring(newOutput.length - 10000);
|
|
159
|
+
}
|
|
160
|
+
return newOutput;
|
|
161
|
+
});
|
|
162
|
+
if (chunk.offset !== undefined) {
|
|
163
|
+
stdoutOffset = chunk.offset;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Stream ended or error - fall back to polling if not completed
|
|
170
|
+
// Note: Don't check status here as it may be stale due to closure
|
|
171
|
+
if (!isCompleted) {
|
|
172
|
+
startPolling(execId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
// Stream stderr
|
|
177
|
+
const streamStderr = async () => {
|
|
178
|
+
try {
|
|
179
|
+
const stream = await client.devboxes.executions.streamStderrUpdates(devboxId, execId, { offset: stderrOffset.toString() });
|
|
180
|
+
for await (const chunk of stream) {
|
|
181
|
+
if (abortControllerRef.current?.signal.aborted || isCompleted)
|
|
182
|
+
break;
|
|
183
|
+
if (chunk.output) {
|
|
184
|
+
setStderr((prev) => {
|
|
185
|
+
const newOutput = prev + chunk.output;
|
|
186
|
+
// Truncate if too long
|
|
187
|
+
if (newOutput.length > 10000) {
|
|
188
|
+
return newOutput.substring(newOutput.length - 10000);
|
|
189
|
+
}
|
|
190
|
+
return newOutput;
|
|
191
|
+
});
|
|
192
|
+
if (chunk.offset !== undefined) {
|
|
193
|
+
stderrOffset = chunk.offset;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Stream ended or error - ignore, awaitCompleted will handle completion
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
// Start both streams
|
|
203
|
+
streamStdout();
|
|
204
|
+
streamStderr();
|
|
205
|
+
streamCleanupRef.current = () => {
|
|
206
|
+
abortControllerRef.current?.abort();
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
// Start polling (sync mode or fallback)
|
|
210
|
+
const startPolling = (execId) => {
|
|
211
|
+
const poll = async () => {
|
|
212
|
+
try {
|
|
213
|
+
const result = await getExecution(devboxId, execId);
|
|
214
|
+
setStdout(result.stdout ?? "");
|
|
215
|
+
setStderr(result.stderr ?? "");
|
|
216
|
+
if (result.status === "completed") {
|
|
217
|
+
setExitCode(result.exit_status ?? 0);
|
|
218
|
+
setFinalDuration(Date.now() - startTimeRef.current);
|
|
219
|
+
setStatus("completed");
|
|
220
|
+
if (pollIntervalRef.current) {
|
|
221
|
+
clearInterval(pollIntervalRef.current);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
setError(err.message);
|
|
227
|
+
setStatus("failed");
|
|
228
|
+
if (pollIntervalRef.current) {
|
|
229
|
+
clearInterval(pollIntervalRef.current);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
// Initial poll
|
|
234
|
+
poll();
|
|
235
|
+
// Continue polling every 500ms
|
|
236
|
+
pollIntervalRef.current = setInterval(poll, 500);
|
|
237
|
+
};
|
|
238
|
+
// Kill execution
|
|
239
|
+
const handleKill = async () => {
|
|
240
|
+
if (!executionId || status !== "running")
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
await killExecution(devboxId, executionId);
|
|
244
|
+
setFinalDuration(Date.now() - startTimeRef.current);
|
|
245
|
+
setStatus("killed");
|
|
246
|
+
if (pollIntervalRef.current) {
|
|
247
|
+
clearInterval(pollIntervalRef.current);
|
|
248
|
+
}
|
|
249
|
+
if (streamCleanupRef.current) {
|
|
250
|
+
streamCleanupRef.current();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
setError(`Failed to kill: ${err.message}`);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
// Copy output to clipboard
|
|
258
|
+
const handleCopy = async () => {
|
|
259
|
+
const output = stdout + stderr;
|
|
260
|
+
const { spawn } = await import("child_process");
|
|
261
|
+
const platform = process.platform;
|
|
262
|
+
let cmd;
|
|
263
|
+
let args;
|
|
264
|
+
if (platform === "darwin") {
|
|
265
|
+
cmd = "pbcopy";
|
|
266
|
+
args = [];
|
|
267
|
+
}
|
|
268
|
+
else if (platform === "win32") {
|
|
269
|
+
cmd = "clip";
|
|
270
|
+
args = [];
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
cmd = "xclip";
|
|
274
|
+
args = ["-selection", "clipboard"];
|
|
275
|
+
}
|
|
276
|
+
const proc = spawn(cmd, args);
|
|
277
|
+
proc.stdin.write(output);
|
|
278
|
+
proc.stdin.end();
|
|
279
|
+
proc.on("exit", (code) => {
|
|
280
|
+
if (code === 0) {
|
|
281
|
+
setCopyStatus("Copied!");
|
|
282
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
setCopyStatus("Failed");
|
|
286
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
proc.on("error", () => {
|
|
290
|
+
setCopyStatus("Not supported");
|
|
291
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
// Handle input
|
|
295
|
+
useInput((input, key) => {
|
|
296
|
+
const isRunning = status === "running" || status === "starting";
|
|
297
|
+
const isComplete = status === "completed" || status === "killed" || status === "failed";
|
|
298
|
+
// Kill command
|
|
299
|
+
if (input === "k" && isRunning) {
|
|
300
|
+
handleKill();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Run another (after completion)
|
|
304
|
+
if (input === "r" && isComplete) {
|
|
305
|
+
onRunAnother();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Copy output (ignore if Ctrl+C for quit)
|
|
309
|
+
if (input === "c" && !key.ctrl) {
|
|
310
|
+
handleCopy();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// Back/leave
|
|
314
|
+
if (input === "q" || key.escape) {
|
|
315
|
+
onBack();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Return after completion
|
|
319
|
+
if (key.return && isComplete) {
|
|
320
|
+
onBack();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Scrolling
|
|
324
|
+
const allLines = [...stdout.split("\n"), ...stderr.split("\n")].filter((line) => line !== "");
|
|
325
|
+
const maxScroll = Math.max(0, allLines.length - execViewport.viewportHeight);
|
|
326
|
+
if (key.upArrow || input === "k") {
|
|
327
|
+
setScroll(Math.max(0, scroll - 1));
|
|
328
|
+
setAutoScroll(false);
|
|
329
|
+
}
|
|
330
|
+
else if (key.downArrow || input === "j") {
|
|
331
|
+
const newScroll = Math.min(maxScroll, scroll + 1);
|
|
332
|
+
setScroll(newScroll);
|
|
333
|
+
setAutoScroll(newScroll >= maxScroll);
|
|
334
|
+
}
|
|
335
|
+
else if (key.pageUp) {
|
|
336
|
+
setScroll(Math.max(0, scroll - 10));
|
|
337
|
+
setAutoScroll(false);
|
|
338
|
+
}
|
|
339
|
+
else if (key.pageDown) {
|
|
340
|
+
const newScroll = Math.min(maxScroll, scroll + 10);
|
|
341
|
+
setScroll(newScroll);
|
|
342
|
+
setAutoScroll(newScroll >= maxScroll);
|
|
343
|
+
}
|
|
344
|
+
else if (input === "g") {
|
|
345
|
+
setScroll(0);
|
|
346
|
+
setAutoScroll(false);
|
|
347
|
+
}
|
|
348
|
+
else if (input === "G") {
|
|
349
|
+
setScroll(maxScroll);
|
|
350
|
+
setAutoScroll(true);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Auto-scroll to bottom when new output arrives (for both modes)
|
|
354
|
+
// Simple approach: if autoScroll is enabled, always go to bottom
|
|
355
|
+
// User actions (scroll up) disable autoScroll, scroll to bottom re-enables it
|
|
356
|
+
React.useEffect(() => {
|
|
357
|
+
if (!autoScroll)
|
|
358
|
+
return;
|
|
359
|
+
const allLines = [...stdout.split("\n"), ...stderr.split("\n")].filter((line) => line !== "");
|
|
360
|
+
const maxScroll = Math.max(0, allLines.length - execViewport.viewportHeight);
|
|
361
|
+
setScroll(maxScroll);
|
|
362
|
+
}, [stdout, stderr, autoScroll, execViewport.viewportHeight]);
|
|
363
|
+
// Format elapsed time (input is milliseconds)
|
|
364
|
+
const formatTime = (ms) => {
|
|
365
|
+
if (ms < 1000)
|
|
366
|
+
return `${ms}ms`;
|
|
367
|
+
const seconds = Math.floor(ms / 1000);
|
|
368
|
+
if (seconds < 60)
|
|
369
|
+
return `${seconds}s`;
|
|
370
|
+
const mins = Math.floor(seconds / 60);
|
|
371
|
+
const secs = seconds % 60;
|
|
372
|
+
return `${mins}m ${secs}s`;
|
|
373
|
+
};
|
|
374
|
+
// Get status display
|
|
375
|
+
const getStatusDisplay = () => {
|
|
376
|
+
switch (status) {
|
|
377
|
+
case "starting":
|
|
378
|
+
return { text: "Starting...", color: colors.info, icon: "●" };
|
|
379
|
+
case "running":
|
|
380
|
+
return {
|
|
381
|
+
text: `Running (${formatTime(elapsedTime)})`,
|
|
382
|
+
color: colors.warning,
|
|
383
|
+
icon: "●",
|
|
384
|
+
};
|
|
385
|
+
case "completed":
|
|
386
|
+
return {
|
|
387
|
+
text: `Completed (exit: ${exitCode}) in ${formatTime(finalDuration ?? elapsedTime)}`,
|
|
388
|
+
color: exitCode === 0 ? colors.success : colors.error,
|
|
389
|
+
icon: exitCode === 0 ? figures.tick : figures.cross,
|
|
390
|
+
};
|
|
391
|
+
case "killed":
|
|
392
|
+
return {
|
|
393
|
+
text: `Killed after ${formatTime(finalDuration ?? elapsedTime)}`,
|
|
394
|
+
color: colors.warning,
|
|
395
|
+
icon: figures.cross,
|
|
396
|
+
};
|
|
397
|
+
case "failed":
|
|
398
|
+
return { text: "Failed", color: colors.error, icon: figures.cross };
|
|
399
|
+
default:
|
|
400
|
+
return { text: "Unknown", color: colors.textDim, icon: "?" };
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const statusDisplay = getStatusDisplay();
|
|
404
|
+
const isRunning = status === "running" || status === "starting";
|
|
405
|
+
// Prepare output lines
|
|
406
|
+
const stdoutLines = stdout ? stdout.split("\n") : [];
|
|
407
|
+
const stderrLines = stderr ? stderr.split("\n") : [];
|
|
408
|
+
const allLines = [...stdoutLines, ...stderrLines].filter((line) => line !== "");
|
|
409
|
+
const viewportHeight = execViewport.viewportHeight;
|
|
410
|
+
const maxScroll = Math.max(0, allLines.length - viewportHeight);
|
|
411
|
+
const actualScroll = Math.min(scroll, maxScroll);
|
|
412
|
+
const visibleLines = allLines.slice(actualScroll, actualScroll + viewportHeight);
|
|
413
|
+
const hasMore = actualScroll + viewportHeight < allLines.length;
|
|
414
|
+
const hasLess = actualScroll > 0;
|
|
415
|
+
// Get navigation tips based on state
|
|
416
|
+
const getNavigationTips = () => {
|
|
417
|
+
if (isRunning) {
|
|
418
|
+
return [
|
|
419
|
+
{ key: "↑↓", label: "Scroll" },
|
|
420
|
+
{ key: "k", label: "Kill" },
|
|
421
|
+
{ key: "q/esc", label: "Leave Running" },
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
return [
|
|
426
|
+
{ key: "↑↓", label: "Scroll" },
|
|
427
|
+
{ key: "r", label: "Run Another" },
|
|
428
|
+
{ key: "c", label: "Copy" },
|
|
429
|
+
{ key: "q/esc/Enter", label: "Back" },
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Command:"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.text, children: command.length > 80 ? command.substring(0, 80) + "..." : command })] }), _jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Status:", " "] }), isRunning && (_jsxs(Text, { color: statusDisplay.color, children: [_jsx(Spinner, { type: "dots" }), " "] })), _jsxs(Text, { color: statusDisplay.color, children: [!isRunning && `${statusDisplay.icon} `, statusDisplay.text] })] })] }), error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Error: ", error] }) })), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight + 2, children: allLines.length === 0 && isRunning ? (_jsxs(Box, { children: [_jsx(Text, { color: colors.info, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: colors.textDim, children: " Waiting for output..." })] })) : allLines.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No output" })) : (visibleLines.map((line, index) => {
|
|
434
|
+
const actualIndex = actualScroll + index;
|
|
435
|
+
const isStderr = actualIndex >= stdoutLines.length;
|
|
436
|
+
const lineColor = isStderr ? colors.error : colors.text;
|
|
437
|
+
return (_jsx(Box, { children: _jsx(Text, { color: lineColor, children: line }) }, index));
|
|
438
|
+
})) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }))] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(NavigationTips, { tips: getNavigationTips() })] }));
|
|
439
|
+
};
|
|
@@ -10,11 +10,14 @@ import { Breadcrumb } from "./Breadcrumb.js";
|
|
|
10
10
|
import { NavigationTips } from "./NavigationTips.js";
|
|
11
11
|
import { colors } from "../utils/theme.js";
|
|
12
12
|
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
13
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
13
14
|
import { parseAnyLogEntry } from "../utils/logFormatter.js";
|
|
14
15
|
export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: true }], onBack, title = "Logs", }) => {
|
|
15
16
|
const [logsWrapMode, setLogsWrapMode] = React.useState(false);
|
|
16
17
|
const [logsScroll, setLogsScroll] = React.useState(0);
|
|
17
18
|
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
19
|
+
// Handle Ctrl+C to exit
|
|
20
|
+
useExitOnCtrlC();
|
|
18
21
|
// Calculate viewport for logs output:
|
|
19
22
|
// - Breadcrumb (border top + content + border bottom + marginBottom): 4 lines
|
|
20
23
|
// - Log box borders: 2 lines (added to height by Ink)
|
|
@@ -57,8 +60,8 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
|
|
|
57
60
|
else if (input === "w") {
|
|
58
61
|
setLogsWrapMode(!logsWrapMode);
|
|
59
62
|
}
|
|
60
|
-
else if (input === "c") {
|
|
61
|
-
// Copy logs to clipboard
|
|
63
|
+
else if (input === "c" && !key.ctrl) {
|
|
64
|
+
// Copy logs to clipboard (ignore if Ctrl+C for quit)
|
|
62
65
|
const logsText = logs
|
|
63
66
|
.map((log) => {
|
|
64
67
|
const parts = parseAnyLogEntry(log);
|
|
@@ -143,8 +143,8 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
143
143
|
if (input === "q" || key.escape) {
|
|
144
144
|
onBack();
|
|
145
145
|
}
|
|
146
|
-
else if (input === "c") {
|
|
147
|
-
// Copy resource ID to clipboard
|
|
146
|
+
else if (input === "c" && !key.ctrl) {
|
|
147
|
+
// Copy resource ID to clipboard (ignore if Ctrl+C for quit)
|
|
148
148
|
copyToClipboard(getId(currentResource));
|
|
149
149
|
}
|
|
150
150
|
else if (input === "i" && buildDetailLines) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import figures from "figures";
|
|
5
|
+
import { colors } from "../utils/theme.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reusable search bar component for list views.
|
|
8
|
+
* Displays either an input mode or the active search with result count.
|
|
9
|
+
*/
|
|
10
|
+
export function SearchBar({ searchMode, searchQuery, submittedSearchQuery, resultCount, onSearchChange, onSearchSubmit, placeholder = "Type to search...", maxDisplayLength = 50, }) {
|
|
11
|
+
// Don't render if no search is active
|
|
12
|
+
if (!searchMode && !submittedSearchQuery) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// Search input mode
|
|
16
|
+
if (searchMode) {
|
|
17
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: onSearchChange, placeholder: placeholder, onSubmit: onSearchSubmit }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to search, Esc to cancel]"] })] }));
|
|
18
|
+
}
|
|
19
|
+
// Display active search with results
|
|
20
|
+
const displayQuery = submittedSearchQuery.length > maxDisplayLength
|
|
21
|
+
? submittedSearchQuery.substring(0, maxDisplayLength) + "..."
|
|
22
|
+
: submittedSearchQuery;
|
|
23
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, children: [figures.info, " Searching for: "] }), _jsx(Text, { color: colors.warning, bold: true, children: displayQuery }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", resultCount, " results) [/ to edit, Esc to clear]"] })] }));
|
|
24
|
+
}
|