@runloop/rl-cli 0.1.1 → 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 -52
- 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 +20 -90
- 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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devbox Service - Handles all devbox API calls
|
|
3
|
+
* Returns plain data objects with no SDK reference retention
|
|
4
|
+
*/
|
|
5
|
+
import { getClient } from "../utils/client.js";
|
|
6
|
+
/**
|
|
7
|
+
* Recursively truncate all strings in an object to prevent Yoga crashes
|
|
8
|
+
* CRITICAL: Must be applied to ALL data from API before storing/rendering
|
|
9
|
+
*/
|
|
10
|
+
function truncateStrings(obj, maxLength = 200) {
|
|
11
|
+
if (obj === null || obj === undefined)
|
|
12
|
+
return obj;
|
|
13
|
+
if (typeof obj === "string") {
|
|
14
|
+
return (obj.length > maxLength ? obj.substring(0, maxLength) : obj);
|
|
15
|
+
}
|
|
16
|
+
if (Array.isArray(obj)) {
|
|
17
|
+
return obj.map((item) => truncateStrings(item, maxLength));
|
|
18
|
+
}
|
|
19
|
+
if (typeof obj === "object") {
|
|
20
|
+
const result = {};
|
|
21
|
+
for (const key in obj) {
|
|
22
|
+
if (Object.hasOwn(obj, key)) {
|
|
23
|
+
result[key] = truncateStrings(obj[key], maxLength);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
return obj;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* List devboxes with pagination
|
|
32
|
+
* CRITICAL: Creates defensive copies to break SDK reference chains
|
|
33
|
+
*/
|
|
34
|
+
export async function listDevboxes(options) {
|
|
35
|
+
// Check if aborted before making request
|
|
36
|
+
if (options.signal?.aborted) {
|
|
37
|
+
throw new DOMException("Aborted", "AbortError");
|
|
38
|
+
}
|
|
39
|
+
const client = getClient();
|
|
40
|
+
const queryParams = {
|
|
41
|
+
limit: options.limit,
|
|
42
|
+
};
|
|
43
|
+
if (options.startingAfter) {
|
|
44
|
+
queryParams.starting_after = options.startingAfter;
|
|
45
|
+
}
|
|
46
|
+
if (options.status) {
|
|
47
|
+
queryParams.status = options.status;
|
|
48
|
+
}
|
|
49
|
+
if (options.search) {
|
|
50
|
+
queryParams.name = options.search;
|
|
51
|
+
}
|
|
52
|
+
// Fetch ONE page only - never iterate
|
|
53
|
+
const pagePromise = client.devboxes.list(queryParams);
|
|
54
|
+
// Wrap in Promise.race to support abort
|
|
55
|
+
let page;
|
|
56
|
+
if (options.signal) {
|
|
57
|
+
const abortPromise = new Promise((_, reject) => {
|
|
58
|
+
options.signal.addEventListener("abort", () => {
|
|
59
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
page = (await Promise.race([
|
|
64
|
+
pagePromise,
|
|
65
|
+
abortPromise,
|
|
66
|
+
]));
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// Re-throw abort errors, convert others
|
|
70
|
+
if (err?.name === "AbortError") {
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
page = (await pagePromise);
|
|
78
|
+
}
|
|
79
|
+
// Check again after await (in case abort happened during request)
|
|
80
|
+
if (options.signal?.aborted) {
|
|
81
|
+
throw new DOMException("Aborted", "AbortError");
|
|
82
|
+
}
|
|
83
|
+
// Extract data and create defensive copies immediately
|
|
84
|
+
const devboxes = [];
|
|
85
|
+
if (page.devboxes && Array.isArray(page.devboxes)) {
|
|
86
|
+
page.devboxes.forEach((d) => {
|
|
87
|
+
// CRITICAL: Recursively truncate ALL strings in the object to prevent Yoga crashes
|
|
88
|
+
// This catches nested fields like launch_parameters.user_parameters.username
|
|
89
|
+
const truncated = truncateStrings(d, 200);
|
|
90
|
+
devboxes.push(truncated);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const result = {
|
|
94
|
+
devboxes,
|
|
95
|
+
totalCount: page.total_count || devboxes.length,
|
|
96
|
+
hasMore: page.has_more || false,
|
|
97
|
+
};
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Get a single devbox by ID
|
|
102
|
+
*/
|
|
103
|
+
export async function getDevbox(id) {
|
|
104
|
+
const client = getClient();
|
|
105
|
+
const devbox = await client.devboxes.retrieve(id);
|
|
106
|
+
// CRITICAL: Recursively truncate ALL strings in the object to prevent Yoga crashes
|
|
107
|
+
return truncateStrings(devbox, 200);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Delete a devbox (actually shuts it down)
|
|
111
|
+
*/
|
|
112
|
+
export async function deleteDevbox(id) {
|
|
113
|
+
const client = getClient();
|
|
114
|
+
await client.devboxes.shutdown(id);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Shutdown a devbox
|
|
118
|
+
*/
|
|
119
|
+
export async function shutdownDevbox(id) {
|
|
120
|
+
const client = getClient();
|
|
121
|
+
await client.devboxes.shutdown(id);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Suspend a devbox
|
|
125
|
+
*/
|
|
126
|
+
export async function suspendDevbox(id) {
|
|
127
|
+
const client = getClient();
|
|
128
|
+
await client.devboxes.suspend(id);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resume a devbox
|
|
132
|
+
*/
|
|
133
|
+
export async function resumeDevbox(id) {
|
|
134
|
+
const client = getClient();
|
|
135
|
+
await client.devboxes.resume(id);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Upload file to devbox
|
|
139
|
+
*/
|
|
140
|
+
export async function uploadFile(id, filepath, remotePath) {
|
|
141
|
+
const client = getClient();
|
|
142
|
+
const fs = await import("fs");
|
|
143
|
+
const fileStream = fs.createReadStream(filepath);
|
|
144
|
+
await client.devboxes.uploadFile(id, {
|
|
145
|
+
file: fileStream,
|
|
146
|
+
path: remotePath,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create snapshot of devbox
|
|
151
|
+
*/
|
|
152
|
+
export async function createSnapshot(id, name) {
|
|
153
|
+
const client = getClient();
|
|
154
|
+
const snapshot = await client.devboxes.snapshotDisk(id, { name });
|
|
155
|
+
return {
|
|
156
|
+
id: String(snapshot.id || "").substring(0, 100),
|
|
157
|
+
name: snapshot.name ? String(snapshot.name).substring(0, 200) : undefined,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Create SSH key for devbox
|
|
162
|
+
*/
|
|
163
|
+
export async function createSSHKey(id) {
|
|
164
|
+
const client = getClient();
|
|
165
|
+
const result = await client.devboxes.createSSHKey(id);
|
|
166
|
+
// Truncate keys if they're unexpectedly long (shouldn't happen, but safety)
|
|
167
|
+
return {
|
|
168
|
+
ssh_private_key: String(result.ssh_private_key || "").substring(0, 10000),
|
|
169
|
+
url: String(result.url || "").substring(0, 500),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Create tunnel to devbox
|
|
174
|
+
*/
|
|
175
|
+
export async function createTunnel(id, port) {
|
|
176
|
+
const client = getClient();
|
|
177
|
+
const tunnel = await client.devboxes.createTunnel(id, { port });
|
|
178
|
+
return {
|
|
179
|
+
url: String(tunnel.url || "").substring(0, 500),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Execute command in devbox
|
|
184
|
+
*/
|
|
185
|
+
export async function execCommand(id, command) {
|
|
186
|
+
const client = getClient();
|
|
187
|
+
const result = await client.devboxes.executeSync(id, { command });
|
|
188
|
+
// CRITICAL: Truncate output to prevent Yoga crashes
|
|
189
|
+
const MAX_OUTPUT_LENGTH = 10000; // Allow more for command output
|
|
190
|
+
let stdout = String(result.stdout || "");
|
|
191
|
+
let stderr = String(result.stderr || "");
|
|
192
|
+
if (stdout.length > MAX_OUTPUT_LENGTH) {
|
|
193
|
+
stdout =
|
|
194
|
+
stdout.substring(0, MAX_OUTPUT_LENGTH) + "\n... (output truncated)";
|
|
195
|
+
}
|
|
196
|
+
if (stderr.length > MAX_OUTPUT_LENGTH) {
|
|
197
|
+
stderr =
|
|
198
|
+
stderr.substring(0, MAX_OUTPUT_LENGTH) + "\n... (output truncated)";
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
stdout,
|
|
202
|
+
stderr,
|
|
203
|
+
exit_code: result.exit_code || 0,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Get devbox logs
|
|
208
|
+
* Returns the raw logs array from the API response
|
|
209
|
+
*/
|
|
210
|
+
export async function getDevboxLogs(id) {
|
|
211
|
+
const client = getClient();
|
|
212
|
+
const response = await client.devboxes.logs.list(id);
|
|
213
|
+
// Return the logs array directly - formatting is handled by logFormatter
|
|
214
|
+
return response.logs || [];
|
|
215
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot Service - Handles all snapshot API calls
|
|
3
|
+
*/
|
|
4
|
+
import { getClient } from "../utils/client.js";
|
|
5
|
+
/**
|
|
6
|
+
* List snapshots with pagination
|
|
7
|
+
*/
|
|
8
|
+
export async function listSnapshots(options) {
|
|
9
|
+
const client = getClient();
|
|
10
|
+
const queryParams = {
|
|
11
|
+
limit: options.limit,
|
|
12
|
+
};
|
|
13
|
+
if (options.startingAfter) {
|
|
14
|
+
queryParams.starting_after = options.startingAfter;
|
|
15
|
+
}
|
|
16
|
+
if (options.devboxId) {
|
|
17
|
+
queryParams.devbox_id = options.devboxId;
|
|
18
|
+
}
|
|
19
|
+
const pagePromise = client.devboxes.listDiskSnapshots(queryParams);
|
|
20
|
+
const page = (await pagePromise);
|
|
21
|
+
const snapshots = [];
|
|
22
|
+
if (page.snapshots && Array.isArray(page.snapshots)) {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
page.snapshots.forEach((s) => {
|
|
25
|
+
// CRITICAL: Truncate all strings to prevent Yoga crashes
|
|
26
|
+
const MAX_ID_LENGTH = 100;
|
|
27
|
+
const MAX_NAME_LENGTH = 200;
|
|
28
|
+
const MAX_STATUS_LENGTH = 50;
|
|
29
|
+
// Status is constructed/available in API response but not in type definition
|
|
30
|
+
const snapshotView = s;
|
|
31
|
+
snapshots.push({
|
|
32
|
+
id: String(snapshotView.id || "").substring(0, MAX_ID_LENGTH),
|
|
33
|
+
name: snapshotView.name
|
|
34
|
+
? String(snapshotView.name).substring(0, MAX_NAME_LENGTH)
|
|
35
|
+
: undefined,
|
|
36
|
+
devbox_id: String(snapshotView.source_devbox_id || "").substring(0, MAX_ID_LENGTH),
|
|
37
|
+
status: snapshotView.status
|
|
38
|
+
? String(snapshotView.status).substring(0, MAX_STATUS_LENGTH)
|
|
39
|
+
: "",
|
|
40
|
+
create_time_ms: snapshotView.create_time_ms,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const result = {
|
|
45
|
+
snapshots,
|
|
46
|
+
totalCount: page.total_count || snapshots.length,
|
|
47
|
+
hasMore: page.has_more || false,
|
|
48
|
+
};
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get snapshot status by ID
|
|
53
|
+
*/
|
|
54
|
+
export async function getSnapshotStatus(id) {
|
|
55
|
+
const client = getClient();
|
|
56
|
+
const status = await client.devboxes.diskSnapshots.queryStatus(id);
|
|
57
|
+
return status;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Create a snapshot
|
|
61
|
+
*/
|
|
62
|
+
export async function createSnapshot(devboxId, name) {
|
|
63
|
+
const client = getClient();
|
|
64
|
+
const snapshot = await client.devboxes.snapshotDisk(devboxId, {
|
|
65
|
+
name,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
id: snapshot.id,
|
|
69
|
+
name: snapshot.name || undefined,
|
|
70
|
+
devbox_id: snapshot.devbox_id || devboxId,
|
|
71
|
+
status: snapshot.status || "pending",
|
|
72
|
+
create_time_ms: snapshot.create_time_ms,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Delete a snapshot
|
|
77
|
+
*/
|
|
78
|
+
export async function deleteSnapshot(id) {
|
|
79
|
+
const client = getClient();
|
|
80
|
+
await client.devboxes.diskSnapshots.delete(id);
|
|
81
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blueprint Store - Manages blueprint list state, pagination, and caching
|
|
3
|
+
*/
|
|
4
|
+
import { create } from "zustand";
|
|
5
|
+
const MAX_CACHE_SIZE = 10;
|
|
6
|
+
export const useBlueprintStore = create((set, get) => ({
|
|
7
|
+
blueprints: [],
|
|
8
|
+
loading: false,
|
|
9
|
+
initialLoading: true,
|
|
10
|
+
error: null,
|
|
11
|
+
currentPage: 0,
|
|
12
|
+
pageSize: 10,
|
|
13
|
+
totalCount: 0,
|
|
14
|
+
hasMore: false,
|
|
15
|
+
pageCache: new Map(),
|
|
16
|
+
lastIdCache: new Map(),
|
|
17
|
+
searchQuery: "",
|
|
18
|
+
selectedIndex: 0,
|
|
19
|
+
setBlueprints: (blueprints) => set({ blueprints }),
|
|
20
|
+
setLoading: (loading) => set({ loading }),
|
|
21
|
+
setInitialLoading: (loading) => set({ initialLoading: loading }),
|
|
22
|
+
setError: (error) => set({ error }),
|
|
23
|
+
setCurrentPage: (page) => set({ currentPage: page }),
|
|
24
|
+
setPageSize: (size) => set({ pageSize: size }),
|
|
25
|
+
setTotalCount: (count) => set({ totalCount: count }),
|
|
26
|
+
setHasMore: (hasMore) => set({ hasMore }),
|
|
27
|
+
setSearchQuery: (query) => set({ searchQuery: query }),
|
|
28
|
+
setSelectedIndex: (index) => set({ selectedIndex: index }),
|
|
29
|
+
cachePageData: (page, data, lastId) => {
|
|
30
|
+
const state = get();
|
|
31
|
+
const pageCache = state.pageCache;
|
|
32
|
+
const lastIdCache = state.lastIdCache;
|
|
33
|
+
// Aggressive LRU eviction
|
|
34
|
+
if (pageCache.size >= MAX_CACHE_SIZE) {
|
|
35
|
+
const oldestKey = pageCache.keys().next().value;
|
|
36
|
+
if (oldestKey !== undefined) {
|
|
37
|
+
pageCache.delete(oldestKey);
|
|
38
|
+
lastIdCache.delete(oldestKey);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Create plain data objects to avoid SDK references
|
|
42
|
+
const plainData = data.map((b) => ({
|
|
43
|
+
id: b.id,
|
|
44
|
+
name: b.name,
|
|
45
|
+
status: b.status,
|
|
46
|
+
create_time_ms: b.create_time_ms,
|
|
47
|
+
build_status: b.build_status,
|
|
48
|
+
architecture: b.architecture,
|
|
49
|
+
resources: b.resources,
|
|
50
|
+
}));
|
|
51
|
+
pageCache.set(page, plainData);
|
|
52
|
+
lastIdCache.set(page, lastId);
|
|
53
|
+
set({});
|
|
54
|
+
},
|
|
55
|
+
getCachedPage: (page) => {
|
|
56
|
+
return get().pageCache.get(page);
|
|
57
|
+
},
|
|
58
|
+
clearCache: () => {
|
|
59
|
+
const state = get();
|
|
60
|
+
state.pageCache.clear();
|
|
61
|
+
state.lastIdCache.clear();
|
|
62
|
+
set({
|
|
63
|
+
pageCache: new Map(),
|
|
64
|
+
lastIdCache: new Map(),
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
clearAll: () => {
|
|
68
|
+
const state = get();
|
|
69
|
+
state.pageCache.clear();
|
|
70
|
+
state.lastIdCache.clear();
|
|
71
|
+
set({
|
|
72
|
+
blueprints: [],
|
|
73
|
+
loading: false,
|
|
74
|
+
initialLoading: true,
|
|
75
|
+
error: null,
|
|
76
|
+
currentPage: 0,
|
|
77
|
+
totalCount: 0,
|
|
78
|
+
hasMore: false,
|
|
79
|
+
pageCache: new Map(),
|
|
80
|
+
lastIdCache: new Map(),
|
|
81
|
+
searchQuery: "",
|
|
82
|
+
selectedIndex: 0,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
getSelectedBlueprint: () => {
|
|
86
|
+
const state = get();
|
|
87
|
+
return state.blueprints[state.selectedIndex];
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devbox Store - Manages devbox list state, pagination, and caching
|
|
3
|
+
* Replaces useState/useRef from ListDevboxesUI
|
|
4
|
+
*/
|
|
5
|
+
import { create } from "zustand";
|
|
6
|
+
const MAX_CACHE_SIZE = 10; // Limit cache to 10 pages
|
|
7
|
+
export const useDevboxStore = create((set, get) => ({
|
|
8
|
+
// Initial state
|
|
9
|
+
devboxes: [],
|
|
10
|
+
loading: false,
|
|
11
|
+
initialLoading: true,
|
|
12
|
+
error: null,
|
|
13
|
+
currentPage: 0,
|
|
14
|
+
pageSize: 10,
|
|
15
|
+
totalCount: 0,
|
|
16
|
+
hasMore: false,
|
|
17
|
+
pageCache: new Map(),
|
|
18
|
+
lastIdCache: new Map(),
|
|
19
|
+
searchQuery: "",
|
|
20
|
+
statusFilter: undefined,
|
|
21
|
+
selectedIndex: 0,
|
|
22
|
+
// Actions
|
|
23
|
+
setDevboxes: (devboxes) => {
|
|
24
|
+
const state = get();
|
|
25
|
+
const maxIndex = devboxes.length > 0 ? devboxes.length - 1 : 0;
|
|
26
|
+
const clampedIndex = Math.max(0, Math.min(state.selectedIndex, maxIndex));
|
|
27
|
+
set({ devboxes, selectedIndex: clampedIndex });
|
|
28
|
+
},
|
|
29
|
+
setLoading: (loading) => set({ loading }),
|
|
30
|
+
setInitialLoading: (loading) => set({ initialLoading: loading }),
|
|
31
|
+
setError: (error) => set({ error }),
|
|
32
|
+
setCurrentPage: (page) => set({ currentPage: page }),
|
|
33
|
+
setPageSize: (size) => set({ pageSize: size }),
|
|
34
|
+
setTotalCount: (count) => set({ totalCount: count }),
|
|
35
|
+
setHasMore: (hasMore) => set({ hasMore }),
|
|
36
|
+
setSearchQuery: (query) => set({ searchQuery: query }),
|
|
37
|
+
setStatusFilter: (status) => set({ statusFilter: status }),
|
|
38
|
+
setSelectedIndex: (index) => {
|
|
39
|
+
const state = get();
|
|
40
|
+
const maxIndex = state.devboxes.length > 0 ? state.devboxes.length - 1 : 0;
|
|
41
|
+
const clampedIndex = Math.max(0, Math.min(index, maxIndex));
|
|
42
|
+
set({ selectedIndex: clampedIndex });
|
|
43
|
+
},
|
|
44
|
+
// Cache management with LRU eviction - FIXED: No shallow copies
|
|
45
|
+
cachePageData: (page, data, lastId) => {
|
|
46
|
+
const state = get();
|
|
47
|
+
const pageCache = state.pageCache;
|
|
48
|
+
const lastIdCache = state.lastIdCache;
|
|
49
|
+
// Aggressive LRU eviction: Remove oldest entries if at limit
|
|
50
|
+
if (pageCache.size >= MAX_CACHE_SIZE) {
|
|
51
|
+
const oldestKey = pageCache.keys().next().value;
|
|
52
|
+
if (oldestKey !== undefined) {
|
|
53
|
+
pageCache.delete(oldestKey);
|
|
54
|
+
lastIdCache.delete(oldestKey);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Deep copy all fields to avoid SDK references
|
|
58
|
+
const plainData = data.map((d) => {
|
|
59
|
+
// Create a deep copy using JSON serialization for safety
|
|
60
|
+
return JSON.parse(JSON.stringify(d));
|
|
61
|
+
});
|
|
62
|
+
pageCache.set(page, plainData);
|
|
63
|
+
lastIdCache.set(page, lastId);
|
|
64
|
+
// Trigger update without creating new Map
|
|
65
|
+
set({});
|
|
66
|
+
},
|
|
67
|
+
getCachedPage: (page) => {
|
|
68
|
+
return get().pageCache.get(page);
|
|
69
|
+
},
|
|
70
|
+
clearCache: () => {
|
|
71
|
+
const state = get();
|
|
72
|
+
// Explicitly clear all entries before reassigning
|
|
73
|
+
state.pageCache.clear();
|
|
74
|
+
state.lastIdCache.clear();
|
|
75
|
+
set({
|
|
76
|
+
pageCache: new Map(),
|
|
77
|
+
lastIdCache: new Map(),
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
// Aggressive memory cleanup
|
|
81
|
+
clearAll: () => {
|
|
82
|
+
const state = get();
|
|
83
|
+
// Clear existing structures first to release references
|
|
84
|
+
state.pageCache.clear();
|
|
85
|
+
state.lastIdCache.clear();
|
|
86
|
+
set({
|
|
87
|
+
devboxes: [],
|
|
88
|
+
loading: false,
|
|
89
|
+
initialLoading: true,
|
|
90
|
+
error: null,
|
|
91
|
+
currentPage: 0,
|
|
92
|
+
totalCount: 0,
|
|
93
|
+
hasMore: false,
|
|
94
|
+
pageCache: new Map(),
|
|
95
|
+
lastIdCache: new Map(),
|
|
96
|
+
searchQuery: "",
|
|
97
|
+
selectedIndex: 0,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
// Getters
|
|
101
|
+
getSelectedDevbox: () => {
|
|
102
|
+
const state = get();
|
|
103
|
+
return state.devboxes[state.selectedIndex];
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Store - Exports all stores for easy importing
|
|
3
|
+
*/
|
|
4
|
+
export { useNavigation, useNavigationStore, NavigationProvider, } from "./navigationStore.js";
|
|
5
|
+
export { useDevboxStore } from "./devboxStore.js";
|
|
6
|
+
export { useBlueprintStore } from "./blueprintStore.js";
|
|
7
|
+
export { useSnapshotStore } from "./snapshotStore.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
const NavigationContext = React.createContext(null);
|
|
4
|
+
export function NavigationProvider({ initialScreen = "menu", initialParams = {}, children, }) {
|
|
5
|
+
// Use a single state object to avoid timing issues
|
|
6
|
+
const [state, setState] = React.useState({
|
|
7
|
+
currentScreen: initialScreen,
|
|
8
|
+
params: initialParams,
|
|
9
|
+
history: [],
|
|
10
|
+
});
|
|
11
|
+
const navigate = React.useCallback((screen, newParams = {}) => {
|
|
12
|
+
setState((prev) => ({
|
|
13
|
+
currentScreen: screen,
|
|
14
|
+
params: newParams,
|
|
15
|
+
history: [
|
|
16
|
+
...prev.history,
|
|
17
|
+
{ screen: prev.currentScreen, params: prev.params },
|
|
18
|
+
],
|
|
19
|
+
}));
|
|
20
|
+
}, []);
|
|
21
|
+
const push = React.useCallback((screen, newParams = {}) => {
|
|
22
|
+
setState((prev) => ({
|
|
23
|
+
currentScreen: screen,
|
|
24
|
+
params: newParams,
|
|
25
|
+
history: [
|
|
26
|
+
...prev.history,
|
|
27
|
+
{ screen: prev.currentScreen, params: prev.params },
|
|
28
|
+
],
|
|
29
|
+
}));
|
|
30
|
+
}, []);
|
|
31
|
+
const replace = React.useCallback((screen, newParams = {}) => {
|
|
32
|
+
setState((prev) => ({
|
|
33
|
+
...prev,
|
|
34
|
+
currentScreen: screen,
|
|
35
|
+
params: newParams,
|
|
36
|
+
}));
|
|
37
|
+
}, []);
|
|
38
|
+
const goBack = React.useCallback(() => {
|
|
39
|
+
setState((prev) => {
|
|
40
|
+
if (prev.history.length > 0) {
|
|
41
|
+
const newHistory = [...prev.history];
|
|
42
|
+
const previousScreen = newHistory.pop();
|
|
43
|
+
return {
|
|
44
|
+
currentScreen: previousScreen.screen,
|
|
45
|
+
params: previousScreen.params,
|
|
46
|
+
history: newHistory,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// If no history, go to menu
|
|
51
|
+
return {
|
|
52
|
+
currentScreen: "menu",
|
|
53
|
+
params: {},
|
|
54
|
+
history: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}, []);
|
|
59
|
+
const reset = React.useCallback(() => {
|
|
60
|
+
setState({
|
|
61
|
+
currentScreen: "menu",
|
|
62
|
+
params: {},
|
|
63
|
+
history: [],
|
|
64
|
+
});
|
|
65
|
+
}, []);
|
|
66
|
+
const canGoBack = React.useCallback(() => state.history.length > 0, [state.history.length]);
|
|
67
|
+
const value = React.useMemo(() => ({
|
|
68
|
+
currentScreen: state.currentScreen,
|
|
69
|
+
params: state.params,
|
|
70
|
+
navigate,
|
|
71
|
+
push,
|
|
72
|
+
replace,
|
|
73
|
+
goBack,
|
|
74
|
+
reset,
|
|
75
|
+
canGoBack,
|
|
76
|
+
}), [
|
|
77
|
+
state.currentScreen,
|
|
78
|
+
state.params,
|
|
79
|
+
navigate,
|
|
80
|
+
push,
|
|
81
|
+
replace,
|
|
82
|
+
goBack,
|
|
83
|
+
reset,
|
|
84
|
+
canGoBack,
|
|
85
|
+
]);
|
|
86
|
+
return (_jsx(NavigationContext.Provider, { value: value, children: children }));
|
|
87
|
+
}
|
|
88
|
+
export function useNavigation() {
|
|
89
|
+
const context = React.useContext(NavigationContext);
|
|
90
|
+
if (!context) {
|
|
91
|
+
throw new Error("useNavigation must be used within NavigationProvider");
|
|
92
|
+
}
|
|
93
|
+
return context;
|
|
94
|
+
}
|
|
95
|
+
export function useNavigationStore(selector) {
|
|
96
|
+
const context = useNavigation();
|
|
97
|
+
if (!selector) {
|
|
98
|
+
return context;
|
|
99
|
+
}
|
|
100
|
+
return selector(context);
|
|
101
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot Store - Manages snapshot list state, pagination, and caching
|
|
3
|
+
*/
|
|
4
|
+
import { create } from "zustand";
|
|
5
|
+
const MAX_CACHE_SIZE = 10;
|
|
6
|
+
export const useSnapshotStore = create((set, get) => ({
|
|
7
|
+
snapshots: [],
|
|
8
|
+
loading: false,
|
|
9
|
+
initialLoading: true,
|
|
10
|
+
error: null,
|
|
11
|
+
currentPage: 0,
|
|
12
|
+
pageSize: 10,
|
|
13
|
+
totalCount: 0,
|
|
14
|
+
hasMore: false,
|
|
15
|
+
pageCache: new Map(),
|
|
16
|
+
lastIdCache: new Map(),
|
|
17
|
+
devboxIdFilter: undefined,
|
|
18
|
+
selectedIndex: 0,
|
|
19
|
+
setSnapshots: (snapshots) => set({ snapshots }),
|
|
20
|
+
setLoading: (loading) => set({ loading }),
|
|
21
|
+
setInitialLoading: (loading) => set({ initialLoading: loading }),
|
|
22
|
+
setError: (error) => set({ error }),
|
|
23
|
+
setCurrentPage: (page) => set({ currentPage: page }),
|
|
24
|
+
setPageSize: (size) => set({ pageSize: size }),
|
|
25
|
+
setTotalCount: (count) => set({ totalCount: count }),
|
|
26
|
+
setHasMore: (hasMore) => set({ hasMore }),
|
|
27
|
+
setDevboxIdFilter: (devboxId) => set({ devboxIdFilter: devboxId }),
|
|
28
|
+
setSelectedIndex: (index) => set({ selectedIndex: index }),
|
|
29
|
+
cachePageData: (page, data, lastId) => {
|
|
30
|
+
const state = get();
|
|
31
|
+
const pageCache = state.pageCache;
|
|
32
|
+
const lastIdCache = state.lastIdCache;
|
|
33
|
+
// Aggressive LRU eviction
|
|
34
|
+
if (pageCache.size >= MAX_CACHE_SIZE) {
|
|
35
|
+
const oldestKey = pageCache.keys().next().value;
|
|
36
|
+
if (oldestKey !== undefined) {
|
|
37
|
+
pageCache.delete(oldestKey);
|
|
38
|
+
lastIdCache.delete(oldestKey);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Create plain data objects to avoid SDK references
|
|
42
|
+
const plainData = data.map((s) => ({
|
|
43
|
+
id: s.id,
|
|
44
|
+
name: s.name,
|
|
45
|
+
devbox_id: s.devbox_id,
|
|
46
|
+
status: s.status,
|
|
47
|
+
create_time_ms: s.create_time_ms,
|
|
48
|
+
}));
|
|
49
|
+
pageCache.set(page, plainData);
|
|
50
|
+
lastIdCache.set(page, lastId);
|
|
51
|
+
set({});
|
|
52
|
+
},
|
|
53
|
+
getCachedPage: (page) => {
|
|
54
|
+
return get().pageCache.get(page);
|
|
55
|
+
},
|
|
56
|
+
clearCache: () => {
|
|
57
|
+
const state = get();
|
|
58
|
+
state.pageCache.clear();
|
|
59
|
+
state.lastIdCache.clear();
|
|
60
|
+
set({
|
|
61
|
+
pageCache: new Map(),
|
|
62
|
+
lastIdCache: new Map(),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
clearAll: () => {
|
|
66
|
+
const state = get();
|
|
67
|
+
state.pageCache.clear();
|
|
68
|
+
state.lastIdCache.clear();
|
|
69
|
+
set({
|
|
70
|
+
snapshots: [],
|
|
71
|
+
loading: false,
|
|
72
|
+
initialLoading: true,
|
|
73
|
+
error: null,
|
|
74
|
+
currentPage: 0,
|
|
75
|
+
totalCount: 0,
|
|
76
|
+
hasMore: false,
|
|
77
|
+
pageCache: new Map(),
|
|
78
|
+
lastIdCache: new Map(),
|
|
79
|
+
devboxIdFilter: undefined,
|
|
80
|
+
selectedIndex: 0,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
getSelectedSnapshot: () => {
|
|
84
|
+
const state = get();
|
|
85
|
+
return state.snapshots[state.selectedIndex];
|
|
86
|
+
},
|
|
87
|
+
}));
|