@brainpilot/web 0.0.4 → 0.0.5
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/assets/index-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +187 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +184 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +84 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ChevronRight,
|
|
4
|
+
Download,
|
|
5
|
+
File,
|
|
6
|
+
FileImage,
|
|
7
|
+
FileText,
|
|
8
|
+
Folder,
|
|
9
|
+
Package,
|
|
10
|
+
Maximize2,
|
|
11
|
+
Minimize2,
|
|
12
|
+
RefreshCw,
|
|
13
|
+
X,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { FileContent, FileEntry } from "../../contracts/backend";
|
|
16
|
+
import { useSandbox } from "../../contexts/SandboxContext";
|
|
17
|
+
import { useSessions } from "../../contexts/SessionContext";
|
|
18
|
+
import { runtimeConfig } from "../../config";
|
|
19
|
+
import { useT } from "../../i18n/useT";
|
|
20
|
+
import { api } from "../../utils/api";
|
|
21
|
+
import { downloadBlob } from "../../utils/download";
|
|
22
|
+
import { createZipBlob, type ZipEntry } from "../../utils/zip";
|
|
23
|
+
import { IconButton } from "../primitives/IconButton";
|
|
24
|
+
import { ONE_MB, MAX_BINARY_PREVIEW, formatBytes, formatModified, getPreviewKind, isMarkdown } from "./filePreview";
|
|
25
|
+
import { FilePreviewView, PreviewSource } from "./FilePreviewView";
|
|
26
|
+
|
|
27
|
+
type FileNode = FileEntry & {
|
|
28
|
+
path: string;
|
|
29
|
+
children?: FileNode[];
|
|
30
|
+
loaded?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type FileSidebarProps = {
|
|
34
|
+
isOpen: boolean;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
onResize: (width: number) => void;
|
|
37
|
+
onResizeEnd: () => void;
|
|
38
|
+
onResizeStart: () => void;
|
|
39
|
+
width: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const MIN_FILE_SIDEBAR_WIDTH = 320;
|
|
43
|
+
const MAX_FILE_SIDEBAR_WIDTH = 680;
|
|
44
|
+
const MIN_PREVIEW_WIDTH = 360;
|
|
45
|
+
const MAX_PREVIEW_WIDTH = 900;
|
|
46
|
+
const DEFAULT_PREVIEW_WIDTH = 560;
|
|
47
|
+
|
|
48
|
+
const rootNode: FileNode = {
|
|
49
|
+
name: "workspace",
|
|
50
|
+
path: "/workspace",
|
|
51
|
+
type: "folder",
|
|
52
|
+
size: 0,
|
|
53
|
+
modified: 0,
|
|
54
|
+
permissions: "",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function joinPath(parent: string, name: string) {
|
|
58
|
+
return `${parent.replace(/\/$/, "")}/${name}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function basename(path: string) {
|
|
62
|
+
return path.split("/").filter(Boolean).pop() || "download";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function workspaceRelativePath(path: string) {
|
|
66
|
+
if (path === "/workspace") {
|
|
67
|
+
return "workspace";
|
|
68
|
+
}
|
|
69
|
+
return path.startsWith("/workspace/") ? path.slice("/workspace/".length) : path.replace(/^\/+/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function removeNestedSelections(paths: string[]): string[] {
|
|
73
|
+
return [...paths]
|
|
74
|
+
.sort((a, b) => a.length - b.length)
|
|
75
|
+
.filter((path, index, sorted) => !sorted.slice(0, index).some((parent) => path.startsWith(`${parent}/`)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function FileIcon({ node }: { node: FileNode }) {
|
|
79
|
+
if (node.type === "folder" || node.type === "symlink") {
|
|
80
|
+
return <Folder size={16} />;
|
|
81
|
+
}
|
|
82
|
+
if (/\.(md|txt|py|yaml|yml|csv|json|ts|tsx|js|jsx)$/i.test(node.name)) {
|
|
83
|
+
return <FileText size={16} />;
|
|
84
|
+
}
|
|
85
|
+
if (/\.(svg|png|jpg|jpeg|gif|webp)$/i.test(node.name)) {
|
|
86
|
+
return <FileImage size={16} />;
|
|
87
|
+
}
|
|
88
|
+
return <File size={16} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sortNodes(nodes: FileNode[]): FileNode[] {
|
|
92
|
+
return [...nodes].sort((a, b) => {
|
|
93
|
+
const aIsFolder = a.type === "folder" || a.type === "symlink";
|
|
94
|
+
const bIsFolder = b.type === "folder" || b.type === "symlink";
|
|
95
|
+
if (aIsFolder !== bIsFolder) {
|
|
96
|
+
return aIsFolder ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
return a.name.localeCompare(b.name);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function updateNode(root: FileNode, path: string, updater: (node: FileNode) => FileNode): FileNode {
|
|
103
|
+
if (root.path === path) {
|
|
104
|
+
return updater(root);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
...root,
|
|
108
|
+
children: root.children?.map((child) => updateNode(child, path, updater)),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findNode(root: FileNode, path: string | null): FileNode | null {
|
|
113
|
+
if (!path) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (root.path === path) {
|
|
117
|
+
return root;
|
|
118
|
+
}
|
|
119
|
+
for (const child of root.children ?? []) {
|
|
120
|
+
const found = findNode(child, path);
|
|
121
|
+
if (found) {
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeStart, width }: FileSidebarProps) {
|
|
129
|
+
const { currentSandbox } = useSandbox();
|
|
130
|
+
const { currentSession } = useSessions();
|
|
131
|
+
// In single-user local mode the workspace is addressed by the active session
|
|
132
|
+
// id (workspaces/<sid>/), not a container id. Elsewhere the sandbox id is the
|
|
133
|
+
// addressing key. `currentSandbox.status` still gates whether files are live.
|
|
134
|
+
const sandboxId = runtimeConfig.localMode ? currentSession?.id ?? null : currentSandbox?.id ?? null;
|
|
135
|
+
const t = useT();
|
|
136
|
+
const [tree, setTree] = useState<FileNode>(rootNode);
|
|
137
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["/workspace"]));
|
|
138
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
139
|
+
const [selectedContent, setSelectedContent] = useState<FileContent | null>(null);
|
|
140
|
+
const [selectedDownloadPaths, setSelectedDownloadPaths] = useState<Set<string>>(() => new Set());
|
|
141
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
142
|
+
const [isDownloadingSelection, setIsDownloadingSelection] = useState(false);
|
|
143
|
+
const [error, setError] = useState<string | null>(null);
|
|
144
|
+
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
|
145
|
+
const resizeStartRef = useRef<{ pointerX: number; width: number } | null>(null);
|
|
146
|
+
|
|
147
|
+
const loadDirectory = useCallback(
|
|
148
|
+
async (path: string) => {
|
|
149
|
+
if (!currentSandbox || currentSandbox.status !== "running" || !sandboxId) {
|
|
150
|
+
setError(t("files.error.notRunning"));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setError(null);
|
|
154
|
+
const entries = await api.sandbox.listFiles(sandboxId, path);
|
|
155
|
+
const children = entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) }));
|
|
156
|
+
setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
|
|
157
|
+
},
|
|
158
|
+
[currentSandbox, sandboxId],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (isOpen && currentSandbox?.status === "running") {
|
|
163
|
+
void loadDirectory("/workspace");
|
|
164
|
+
}
|
|
165
|
+
if (!isOpen || currentSandbox?.status !== "running") {
|
|
166
|
+
setSelectedDownloadPaths(new Set());
|
|
167
|
+
setSelectedPath(null);
|
|
168
|
+
setSelectedContent(null);
|
|
169
|
+
setIsPreviewMaximized(false);
|
|
170
|
+
}
|
|
171
|
+
}, [currentSandbox?.status, isOpen, loadDirectory]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
175
|
+
if (!resizeStartRef.current) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const delta = resizeStartRef.current.pointerX - event.clientX;
|
|
179
|
+
const nextWidth = Math.max(
|
|
180
|
+
MIN_FILE_SIDEBAR_WIDTH,
|
|
181
|
+
Math.min(MAX_FILE_SIDEBAR_WIDTH, resizeStartRef.current.width + delta),
|
|
182
|
+
);
|
|
183
|
+
onResize(nextWidth);
|
|
184
|
+
};
|
|
185
|
+
const handlePointerUp = () => {
|
|
186
|
+
if (!resizeStartRef.current) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
resizeStartRef.current = null;
|
|
190
|
+
onResizeEnd();
|
|
191
|
+
};
|
|
192
|
+
window.addEventListener("pointermove", handlePointerMove);
|
|
193
|
+
window.addEventListener("pointerup", handlePointerUp);
|
|
194
|
+
return () => {
|
|
195
|
+
window.removeEventListener("pointermove", handlePointerMove);
|
|
196
|
+
window.removeEventListener("pointerup", handlePointerUp);
|
|
197
|
+
};
|
|
198
|
+
}, [onResize, onResizeEnd]);
|
|
199
|
+
|
|
200
|
+
const selectedNode = useMemo(() => findNode(tree, selectedPath), [selectedPath, tree]);
|
|
201
|
+
const selectedFile = selectedNode?.type === "file" ? selectedNode : null;
|
|
202
|
+
const selectedDownloadCount = selectedDownloadPaths.size;
|
|
203
|
+
|
|
204
|
+
const getNodeForPath = useCallback((path: string) => findNode(tree, path), [tree]);
|
|
205
|
+
|
|
206
|
+
const loadDirectoryEntries = useCallback(
|
|
207
|
+
async (path: string): Promise<FileNode[]> => {
|
|
208
|
+
if (!sandboxId) {
|
|
209
|
+
throw new Error("No active sandbox");
|
|
210
|
+
}
|
|
211
|
+
const cached = findNode(tree, path);
|
|
212
|
+
if (cached?.loaded && cached.children) {
|
|
213
|
+
return cached.children;
|
|
214
|
+
}
|
|
215
|
+
const entries = await api.sandbox.listFiles(sandboxId, path);
|
|
216
|
+
const children = sortNodes(entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) })));
|
|
217
|
+
setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
|
|
218
|
+
return children;
|
|
219
|
+
},
|
|
220
|
+
[sandboxId, tree],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const collectZipEntries = useCallback(
|
|
224
|
+
async (node: FileNode, zipEntries: ZipEntry[]) => {
|
|
225
|
+
if (!sandboxId) {
|
|
226
|
+
throw new Error("No active sandbox");
|
|
227
|
+
}
|
|
228
|
+
if (node.type === "folder" || node.type === "symlink") {
|
|
229
|
+
const children = sortNodes(await loadDirectoryEntries(node.path));
|
|
230
|
+
if (children.length === 0) {
|
|
231
|
+
zipEntries.push({ path: `${workspaceRelativePath(node.path)}/`, data: new Uint8Array() });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const child of children) {
|
|
235
|
+
await collectZipEntries(child, zipEntries);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const blob = await api.sandbox.readRawFile(sandboxId, node.path);
|
|
241
|
+
zipEntries.push({
|
|
242
|
+
path: workspaceRelativePath(node.path),
|
|
243
|
+
data: new Uint8Array(await blob.arrayBuffer()),
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
[sandboxId, loadDirectoryEntries],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const toggleDownloadSelection = useCallback((path: string) => {
|
|
250
|
+
setSelectedDownloadPaths((current) => {
|
|
251
|
+
const next = new Set(current);
|
|
252
|
+
if (next.has(path)) {
|
|
253
|
+
next.delete(path);
|
|
254
|
+
} else {
|
|
255
|
+
next.add(path);
|
|
256
|
+
}
|
|
257
|
+
return next;
|
|
258
|
+
});
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
const clearDownloadSelection = useCallback(() => {
|
|
262
|
+
setSelectedDownloadPaths(new Set());
|
|
263
|
+
}, []);
|
|
264
|
+
|
|
265
|
+
const downloadPaths = useCallback(
|
|
266
|
+
async (paths: string[]) => {
|
|
267
|
+
if (!sandboxId || isDownloadingSelection) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const requestedPaths = removeNestedSelections(paths);
|
|
271
|
+
if (!requestedPaths.length) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
setIsDownloadingSelection(true);
|
|
276
|
+
setError(null);
|
|
277
|
+
try {
|
|
278
|
+
const nodes = requestedPaths.map((path) => {
|
|
279
|
+
const node = getNodeForPath(path);
|
|
280
|
+
if (!node) {
|
|
281
|
+
throw new Error(`Cannot find ${path}`);
|
|
282
|
+
}
|
|
283
|
+
return node;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (nodes.length === 1 && nodes[0].type === "file") {
|
|
287
|
+
const blob = await api.sandbox.readRawFile(sandboxId, nodes[0].path);
|
|
288
|
+
downloadBlob(blob, nodes[0].name);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const zipEntries: ZipEntry[] = [];
|
|
293
|
+
for (const node of nodes) {
|
|
294
|
+
await collectZipEntries(node, zipEntries);
|
|
295
|
+
}
|
|
296
|
+
const filename = nodes.length === 1 ? `${basename(nodes[0].path)}.zip` : "workspace-files.zip";
|
|
297
|
+
downloadBlob(createZipBlob(zipEntries), filename);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
setError(err instanceof Error ? err.message : t("files.error.downloadFailed"));
|
|
300
|
+
} finally {
|
|
301
|
+
setIsDownloadingSelection(false);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
[collectZipEntries, sandboxId, getNodeForPath, isDownloadingSelection],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const refreshFiles = async () => {
|
|
308
|
+
setIsRefreshing(true);
|
|
309
|
+
try {
|
|
310
|
+
const paths = Array.from(expandedPaths);
|
|
311
|
+
for (const path of paths.length ? paths : ["/workspace"]) {
|
|
312
|
+
await loadDirectory(path);
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
setError(err instanceof Error ? err.message : t("files.error.refreshFailed"));
|
|
316
|
+
} finally {
|
|
317
|
+
setIsRefreshing(false);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const toggleFolder = async (node: FileNode) => {
|
|
322
|
+
setExpandedPaths((current) => {
|
|
323
|
+
const next = new Set(current);
|
|
324
|
+
if (next.has(node.path)) {
|
|
325
|
+
next.delete(node.path);
|
|
326
|
+
} else {
|
|
327
|
+
next.add(node.path);
|
|
328
|
+
}
|
|
329
|
+
return next;
|
|
330
|
+
});
|
|
331
|
+
if (!node.loaded) {
|
|
332
|
+
await loadDirectory(node.path);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const selectFile = async (node: FileNode) => {
|
|
337
|
+
setSelectedPath(node.path);
|
|
338
|
+
setSelectedContent(null);
|
|
339
|
+
if (!sandboxId) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Only text files are loaded inline (subject to the 1 MB cap). Binary files
|
|
343
|
+
// (image/pdf/download) are streamed as blobs by FilePreviewPanel regardless
|
|
344
|
+
// of size, so they must NOT short-circuit here.
|
|
345
|
+
if (getPreviewKind(node.name) !== "text" || node.size > ONE_MB) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
setSelectedContent(await api.sandbox.readFile(sandboxId, node.path));
|
|
350
|
+
} catch (err) {
|
|
351
|
+
setSelectedContent({
|
|
352
|
+
path: node.path,
|
|
353
|
+
content: err instanceof Error ? err.message : t("files.error.previewFailed"),
|
|
354
|
+
size: node.size,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const renderNode = (node: FileNode, depth = 0) => {
|
|
360
|
+
const isFolder = node.type === "folder" || node.type === "symlink";
|
|
361
|
+
const isExpanded = expandedPaths.has(node.path);
|
|
362
|
+
const isSelected = selectedPath === node.path;
|
|
363
|
+
const isDownloadSelected = selectedDownloadPaths.has(node.path);
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<div className="file-node" key={node.path}>
|
|
367
|
+
<div className={`file-row ${isSelected ? "is-selected" : ""} ${isDownloadSelected ? "is-download-selected" : ""}`}>
|
|
368
|
+
<label className="file-row__check" style={{ marginLeft: 10 + depth * 16 }}>
|
|
369
|
+
<span className="sr-only">{t("files.selectForDownload", { name: node.name })}</span>
|
|
370
|
+
<input
|
|
371
|
+
checked={isDownloadSelected}
|
|
372
|
+
disabled={isDownloadingSelection}
|
|
373
|
+
onChange={() => toggleDownloadSelection(node.path)}
|
|
374
|
+
type="checkbox"
|
|
375
|
+
/>
|
|
376
|
+
</label>
|
|
377
|
+
<button
|
|
378
|
+
className="file-row__open"
|
|
379
|
+
onClick={() => {
|
|
380
|
+
if (isFolder) {
|
|
381
|
+
void toggleFolder(node);
|
|
382
|
+
} else {
|
|
383
|
+
void selectFile(node);
|
|
384
|
+
}
|
|
385
|
+
}}
|
|
386
|
+
type="button"
|
|
387
|
+
>
|
|
388
|
+
<span className={`file-row__chevron ${isFolder && isExpanded ? "is-expanded" : ""}`}>
|
|
389
|
+
{isFolder ? <ChevronRight size={14} /> : null}
|
|
390
|
+
</span>
|
|
391
|
+
<FileIcon node={node} />
|
|
392
|
+
<span className="file-row__name">{node.name}</span>
|
|
393
|
+
<span className="file-row__size">{formatBytes(node.size)}</span>
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
{isFolder && isExpanded ? node.children?.map((child) => renderNode(child, depth + 1)) : null}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<>
|
|
403
|
+
<aside aria-hidden={!isOpen} aria-label={t("files.aria.sidebar")} className={`file-sidebar ${isOpen ? "is-open" : ""}`}>
|
|
404
|
+
<div
|
|
405
|
+
aria-label={t("files.aria.resizeSidebar")}
|
|
406
|
+
className="file-sidebar__resize-handle"
|
|
407
|
+
onPointerDown={(event) => {
|
|
408
|
+
resizeStartRef.current = { pointerX: event.clientX, width };
|
|
409
|
+
onResizeStart();
|
|
410
|
+
}}
|
|
411
|
+
role="separator"
|
|
412
|
+
/>
|
|
413
|
+
<header className="file-sidebar__header">
|
|
414
|
+
<div>
|
|
415
|
+
<span className="file-sidebar__eyebrow">{t("files.eyebrow.workspace")}</span>
|
|
416
|
+
<h2>{t("files.title")}</h2>
|
|
417
|
+
</div>
|
|
418
|
+
<div className="file-sidebar__actions">
|
|
419
|
+
{selectedDownloadCount > 0 ? (
|
|
420
|
+
<div className="file-sidebar__selection-actions">
|
|
421
|
+
<span>{t("files.selectedCount", { count: selectedDownloadCount })}</span>
|
|
422
|
+
<button
|
|
423
|
+
className="file-sidebar__download-selected"
|
|
424
|
+
disabled={!currentSandbox || isDownloadingSelection}
|
|
425
|
+
onClick={() => void downloadPaths(Array.from(selectedDownloadPaths))}
|
|
426
|
+
type="button"
|
|
427
|
+
>
|
|
428
|
+
<Package size={14} />
|
|
429
|
+
<span>{isDownloadingSelection ? t("files.packing") : t("files.download")}</span>
|
|
430
|
+
</button>
|
|
431
|
+
<IconButton disabled={isDownloadingSelection} label={t("files.aria.clearSelection")} onClick={clearDownloadSelection}>
|
|
432
|
+
<X size={14} />
|
|
433
|
+
</IconButton>
|
|
434
|
+
</div>
|
|
435
|
+
) : null}
|
|
436
|
+
<IconButton className={isRefreshing ? "is-active" : ""} label={t("files.aria.refresh")} onClick={() => void refreshFiles()}>
|
|
437
|
+
<RefreshCw size={15} />
|
|
438
|
+
</IconButton>
|
|
439
|
+
<IconButton label={t("files.aria.close")} onClick={onClose}>
|
|
440
|
+
<X size={15} />
|
|
441
|
+
</IconButton>
|
|
442
|
+
</div>
|
|
443
|
+
</header>
|
|
444
|
+
|
|
445
|
+
<div className="file-sidebar__path">
|
|
446
|
+
<span>/workspace</span>
|
|
447
|
+
<small>{currentSandbox?.status === "running" ? t("files.live") : t("files.offline")}</small>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{error ? <p className="file-sidebar__empty">{error}</p> : null}
|
|
451
|
+
<div className="file-sidebar__tree" aria-label={t("files.aria.tree")}>
|
|
452
|
+
{renderNode(tree)}
|
|
453
|
+
</div>
|
|
454
|
+
</aside>
|
|
455
|
+
|
|
456
|
+
<FilePreviewPanel
|
|
457
|
+
content={selectedContent}
|
|
458
|
+
file={isOpen ? selectedFile : null}
|
|
459
|
+
isMaximized={isPreviewMaximized}
|
|
460
|
+
onClose={() => {
|
|
461
|
+
setSelectedPath(null);
|
|
462
|
+
setSelectedContent(null);
|
|
463
|
+
setIsPreviewMaximized(false);
|
|
464
|
+
}}
|
|
465
|
+
sandboxId={sandboxId}
|
|
466
|
+
onToggleMaximize={() => setIsPreviewMaximized((current) => !current)}
|
|
467
|
+
/>
|
|
468
|
+
</>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function FilePreviewPanel({
|
|
473
|
+
file,
|
|
474
|
+
content,
|
|
475
|
+
isMaximized,
|
|
476
|
+
onClose,
|
|
477
|
+
sandboxId,
|
|
478
|
+
onToggleMaximize,
|
|
479
|
+
}: {
|
|
480
|
+
file: FileNode | null;
|
|
481
|
+
content: FileContent | null;
|
|
482
|
+
isMaximized: boolean;
|
|
483
|
+
onClose: () => void;
|
|
484
|
+
sandboxId: string | null;
|
|
485
|
+
onToggleMaximize: () => void;
|
|
486
|
+
}) {
|
|
487
|
+
const t = useT();
|
|
488
|
+
const [previewWidth, setPreviewWidth] = useState(DEFAULT_PREVIEW_WIDTH);
|
|
489
|
+
const [isResizingPreview, setIsResizingPreview] = useState(false);
|
|
490
|
+
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
491
|
+
const [blobError, setBlobError] = useState<string | null>(null);
|
|
492
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
493
|
+
const previewResizeStartRef = useRef<{ pointerX: number; width: number } | null>(null);
|
|
494
|
+
const previewKind = file ? getPreviewKind(file.name) : "download";
|
|
495
|
+
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (
|
|
498
|
+
!file ||
|
|
499
|
+
!sandboxId ||
|
|
500
|
+
(previewKind !== "image" && previewKind !== "pdf") ||
|
|
501
|
+
file.size > MAX_BINARY_PREVIEW
|
|
502
|
+
) {
|
|
503
|
+
setBlobUrl(null);
|
|
504
|
+
setBlobError(null);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let objectUrl: string | null = null;
|
|
509
|
+
let isCancelled = false;
|
|
510
|
+
|
|
511
|
+
setBlobUrl(null);
|
|
512
|
+
setBlobError(null);
|
|
513
|
+
void api.sandbox
|
|
514
|
+
.readRawFile(sandboxId, file.path)
|
|
515
|
+
.then((blob) => {
|
|
516
|
+
if (isCancelled) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
objectUrl = URL.createObjectURL(blob);
|
|
520
|
+
setBlobUrl(objectUrl);
|
|
521
|
+
})
|
|
522
|
+
.catch((err) => {
|
|
523
|
+
if (!isCancelled) {
|
|
524
|
+
setBlobError(err instanceof Error ? err.message : t("files.error.loadPreviewFailed"));
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
return () => {
|
|
529
|
+
isCancelled = true;
|
|
530
|
+
if (objectUrl) {
|
|
531
|
+
URL.revokeObjectURL(objectUrl);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}, [file?.path, previewKind, sandboxId]);
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
538
|
+
if (!previewResizeStartRef.current) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const availableWidth = window.innerWidth - MIN_FILE_SIDEBAR_WIDTH - 80;
|
|
542
|
+
const maxWidth = Math.max(MIN_PREVIEW_WIDTH, Math.min(MAX_PREVIEW_WIDTH, availableWidth));
|
|
543
|
+
const delta = previewResizeStartRef.current.pointerX - event.clientX;
|
|
544
|
+
const nextWidth = Math.max(MIN_PREVIEW_WIDTH, Math.min(maxWidth, previewResizeStartRef.current.width + delta));
|
|
545
|
+
setPreviewWidth(nextWidth);
|
|
546
|
+
};
|
|
547
|
+
const handlePointerUp = () => {
|
|
548
|
+
previewResizeStartRef.current = null;
|
|
549
|
+
setIsResizingPreview(false);
|
|
550
|
+
};
|
|
551
|
+
window.addEventListener("pointermove", handlePointerMove);
|
|
552
|
+
window.addEventListener("pointerup", handlePointerUp);
|
|
553
|
+
return () => {
|
|
554
|
+
window.removeEventListener("pointermove", handlePointerMove);
|
|
555
|
+
window.removeEventListener("pointerup", handlePointerUp);
|
|
556
|
+
};
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
if (!file) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Size cap is applied PER TYPE: binary (image/pdf) previews tolerate up to
|
|
564
|
+
// MAX_BINARY_PREVIEW (streamed to a blob); text is held inline so it keeps the
|
|
565
|
+
// 1 MB cap. Classifying by previewKind FIRST is what lets a large PDF/image
|
|
566
|
+
// reach its blob branch instead of being force-classified "tooLarge".
|
|
567
|
+
const previewSource: PreviewSource =
|
|
568
|
+
previewKind === "image"
|
|
569
|
+
? file.size > MAX_BINARY_PREVIEW
|
|
570
|
+
? { kind: "tooLarge" }
|
|
571
|
+
: { kind: "image", blobUrl: blobUrl ?? undefined }
|
|
572
|
+
: previewKind === "pdf"
|
|
573
|
+
? file.size > MAX_BINARY_PREVIEW
|
|
574
|
+
? { kind: "tooLarge" }
|
|
575
|
+
: { kind: "pdf", blobUrl: blobUrl ?? undefined }
|
|
576
|
+
: previewKind === "download"
|
|
577
|
+
? { kind: "download" }
|
|
578
|
+
: file.size > ONE_MB
|
|
579
|
+
? { kind: "tooLarge" }
|
|
580
|
+
: { kind: "text", text: content?.content ?? t("files.preview.loading") };
|
|
581
|
+
|
|
582
|
+
const downloadFile = async () => {
|
|
583
|
+
if (!sandboxId) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
setIsDownloading(true);
|
|
587
|
+
try {
|
|
588
|
+
const blob = await api.sandbox.readRawFile(sandboxId, file.path);
|
|
589
|
+
downloadBlob(blob, file.name);
|
|
590
|
+
} finally {
|
|
591
|
+
setIsDownloading(false);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
return (
|
|
596
|
+
<section
|
|
597
|
+
aria-label={t("files.preview.aria")}
|
|
598
|
+
className={`file-preview-panel is-open ${isMaximized ? "is-maximized" : ""} ${
|
|
599
|
+
isResizingPreview ? "is-resizing" : ""
|
|
600
|
+
}`}
|
|
601
|
+
style={{ "--preview-panel-width": `${previewWidth}px` } as React.CSSProperties}
|
|
602
|
+
>
|
|
603
|
+
<div
|
|
604
|
+
aria-label={t("files.preview.ariaResize")}
|
|
605
|
+
className="file-preview-panel__resize-handle"
|
|
606
|
+
onPointerDown={(event) => {
|
|
607
|
+
if (isMaximized) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
previewResizeStartRef.current = { pointerX: event.clientX, width: previewWidth };
|
|
611
|
+
setIsResizingPreview(true);
|
|
612
|
+
}}
|
|
613
|
+
role="separator"
|
|
614
|
+
/>
|
|
615
|
+
<div className="file-preview__header">
|
|
616
|
+
<div>
|
|
617
|
+
<span className="file-sidebar__eyebrow">{t("files.preview.eyebrow")}</span>
|
|
618
|
+
<h3>{file.name}</h3>
|
|
619
|
+
</div>
|
|
620
|
+
<div className="file-preview__actions">
|
|
621
|
+
<button className="file-preview__download" disabled={!sandboxId || isDownloading} onClick={() => void downloadFile()} type="button">
|
|
622
|
+
<Download size={14} />
|
|
623
|
+
<span>{isDownloading ? t("files.preview.downloading") : t("files.preview.download")}</span>
|
|
624
|
+
</button>
|
|
625
|
+
<IconButton label={isMaximized ? t("files.preview.restore") : t("files.preview.maximize")} onClick={onToggleMaximize}>
|
|
626
|
+
{isMaximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
|
627
|
+
</IconButton>
|
|
628
|
+
<IconButton label={t("files.preview.close")} onClick={onClose}>
|
|
629
|
+
<X size={15} />
|
|
630
|
+
</IconButton>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
<dl className="file-preview__meta">
|
|
635
|
+
<div>
|
|
636
|
+
<dt>{t("files.preview.path")}</dt>
|
|
637
|
+
<dd>{file.path}</dd>
|
|
638
|
+
</div>
|
|
639
|
+
<div>
|
|
640
|
+
<dt>{t("files.preview.size")}</dt>
|
|
641
|
+
<dd>{formatBytes(file.size)}</dd>
|
|
642
|
+
</div>
|
|
643
|
+
<div>
|
|
644
|
+
<dt>{t("files.preview.modified")}</dt>
|
|
645
|
+
<dd>{formatModified(file.modified)}</dd>
|
|
646
|
+
</div>
|
|
647
|
+
<div>
|
|
648
|
+
<dt>{t("files.preview.mode")}</dt>
|
|
649
|
+
<dd>{file.permissions || "-"}</dd>
|
|
650
|
+
</div>
|
|
651
|
+
</dl>
|
|
652
|
+
|
|
653
|
+
<FilePreviewView
|
|
654
|
+
name={file.name}
|
|
655
|
+
source={previewSource}
|
|
656
|
+
renderMarkdown={isMarkdown(file.name)}
|
|
657
|
+
error={blobError}
|
|
658
|
+
t={t}
|
|
659
|
+
onDownload={sandboxId ? () => void downloadFile() : undefined}
|
|
660
|
+
isDownloading={isDownloading}
|
|
661
|
+
/>
|
|
662
|
+
</section>
|
|
663
|
+
);
|
|
664
|
+
}
|