-
- {loadError}
+ const codeStyle = {
+ "--hb-filesystem-code-font-family": theme.editor.fontFamily,
+ "--hb-filesystem-code-font-size": `${theme.editor.fontSize}px`,
+ "--hb-filesystem-code-line-height": String(theme.editor.lineHeight),
+ } as CSSProperties;
+ const previewMetaItems = [document.contentType, formatFileSize(document.size)].filter(
+ (value): value is string => Boolean(value)
+ );
+ const previewMeta = previewMetaItems.join(" • ");
+
+ switch (document.kind) {
+ case "text": {
+ const lineCount = toLineCount(document.contents);
+
+ return (
+
+ {document.readOnlyReason ? (
+
+ {document.readOnlyReason}
+
+ ) : null}
+
+
+
{Array.from({ length: lineCount }, (_, index) => String(index + 1)).join("\n")}
+
{document.contents}
+
-
- );
- }
-
- return (
-
- {loadState === "loading" ? (
-
-
Loading editor…
+ );
+ }
+ case "image":
+ return (
+
+
+

+ {previewMeta ?
{previewMeta}
: null}
+
- ) : null}
- {document.readOnlyReason ? (
-
- {document.readOnlyReason}
+ );
+ case "audio":
+ return (
+
+
+
+ {previewMeta ?
{previewMeta}
: null}
+
- ) : null}
-
-
- );
+ );
+ case "video":
+ return (
+
+
+
+ {previewMeta ?
{previewMeta}
: null}
+
+
+ );
+ case "pdf":
+ return (
+
+
+
+ {previewMeta ?
{previewMeta}
: null}
+
+
+ );
+ case "binary":
+ default:
+ return (
+
+
+
+
+
PREVIEW UNAVAILABLE
+
+
+
+ );
+ }
}
diff --git a/src/components/filesystem/FileTree.tsx b/src/components/filesystem/FileTree.tsx
index 56d7c2e..7ca33eb 100644
--- a/src/components/filesystem/FileTree.tsx
+++ b/src/components/filesystem/FileTree.tsx
@@ -1,4 +1,4 @@
-import { useRef } from "react";
+import { useRef, type KeyboardEvent } from "react";
import { getDirName, isRootPath, normalizeFilePath } from "./filePath";
import type { FileEntry } from "./types";
@@ -10,6 +10,7 @@ type FileTreeProps = {
onOpenFile: (path: string) => void;
onSelectPath: (path: string) => void;
onToggleDirectory: (path: string) => void;
+ rootPath: string;
selectedPath: string | null;
};
@@ -20,18 +21,10 @@ type VisibleTreeItem = {
function buildVisibleItems(
directoryChildren: Record
,
- expandedPaths: Set
+ expandedPaths: Set,
+ rootPath: string
): VisibleTreeItem[] {
- const items: VisibleTreeItem[] = [
- {
- depth: 0,
- entry: {
- name: "/",
- path: "/",
- type: "directory",
- },
- },
- ];
+ const items: VisibleTreeItem[] = [];
function visit(path: string, depth: number) {
const children = directoryChildren[path] ?? [];
@@ -46,7 +39,7 @@ function buildVisibleItems(
}
}
- visit("/", 1);
+ visit(rootPath, 0);
return items;
}
@@ -57,6 +50,111 @@ function toEntryLabel(entry: FileEntry): string {
return entry.name || normalizeFilePath(entry.path);
}
+function ChevronIcon({ expanded }: { expanded: boolean }) {
+ return (
+
+ );
+}
+
+function SpinnerIcon() {
+ return (
+
+ );
+}
+
+function FolderIcon({ open }: { open: boolean }) {
+ return (
+
+ );
+}
+
+function FileIcon() {
+ return (
+
+ );
+}
+
+function LinkIcon() {
+ return (
+
+ );
+}
+
export function FileTree({
activeFilePath,
directoryChildren,
@@ -65,19 +163,28 @@ export function FileTree({
onOpenFile,
onSelectPath,
onToggleDirectory,
+ rootPath,
selectedPath,
}: FileTreeProps) {
const expandedPathSet = new Set(expandedPaths);
const loadingPathSet = new Set(loadingPaths);
const rowRefs = useRef>({});
- const visibleItems = buildVisibleItems(directoryChildren, expandedPathSet);
+ const visibleItems = buildVisibleItems(
+ directoryChildren,
+ expandedPathSet,
+ normalizeFilePath(rootPath)
+ );
+ const selectedPathVisible = selectedPath
+ ? visibleItems.some((item) => item.entry.path === selectedPath)
+ : false;
+ const fallbackFocusablePath = visibleItems[0]?.entry.path ?? null;
function focusPath(path: string) {
rowRefs.current[path]?.focus();
}
function handleKeyDown(
- event: React.KeyboardEvent,
+ event: KeyboardEvent,
item: VisibleTreeItem,
itemIndex: number
) {
@@ -121,7 +228,11 @@ export function FileTree({
}
case "ArrowLeft": {
event.preventDefault();
- if (item.entry.type === "directory" && expandedPathSet.has(item.entry.path) && !isRootPath(item.entry.path)) {
+ if (
+ item.entry.type === "directory" &&
+ expandedPathSet.has(item.entry.path) &&
+ !isRootPath(item.entry.path)
+ ) {
onToggleDirectory(item.entry.path);
return;
}
@@ -147,11 +258,7 @@ export function FileTree({
}
return (
-
+
{visibleItems.map((item, itemIndex) => {
const isDirectory = item.entry.type === "directory";
const isExpanded = isDirectory
@@ -169,17 +276,18 @@ export function FileTree({
key={item.entry.path}
role="treeitem"
style={{
- paddingInlineStart: `${item.depth * 14}px`,
+ paddingInlineStart: `${item.depth * 11}px`,
}}
>
{isDirectory ? (
) : (
@@ -191,12 +299,6 @@ export function FileTree({
data-active={isActiveFile ? "true" : undefined}
data-selected={isSelected ? "true" : undefined}
onClick={() => {
- onSelectPath(item.entry.path);
- if (!isDirectory) {
- onOpenFile(item.entry.path);
- }
- }}
- onDoubleClick={() => {
onSelectPath(item.entry.path);
if (isDirectory) {
onToggleDirectory(item.entry.path);
@@ -208,21 +310,24 @@ export function FileTree({
ref={(element) => {
rowRefs.current[item.entry.path] = element;
}}
- tabIndex={selectedPath === item.entry.path || (selectedPath === null && item.entry.path === "/") ? 0 : -1}
+ tabIndex={
+ selectedPath === item.entry.path ||
+ (!selectedPathVisible && fallbackFocusablePath === item.entry.path)
+ ? 0
+ : -1
+ }
type="button"
>
-
- {isDirectory ? "DIR" : "FILE"}
+
+ {isDirectory ? : }
{toEntryLabel(item.entry)}
{item.entry.symlinkTarget ? (
- LINK {normalizeFilePath(item.entry.symlinkTarget)}
+
+ {normalizeFilePath(item.entry.symlinkTarget)}
) : null}
- {isLoading ? (
- Loading…
- ) : null}
);
diff --git a/src/components/filesystem/FileWorkspace.tsx b/src/components/filesystem/FileWorkspace.tsx
index 2997bef..64cb6e0 100644
--- a/src/components/filesystem/FileWorkspace.tsx
+++ b/src/components/filesystem/FileWorkspace.tsx
@@ -8,26 +8,10 @@ import {
getBaseName,
getDirName,
isPathWithin,
- isRootPath,
- joinFilePath,
normalizeFilePath,
} from "./filePath";
import { resolveFileWorkspaceTheme } from "./fileWorkspaceThemes";
-import type { FileDocument, FileEntry, FileWorkspaceProps } from "./types";
-
-type WorkspaceDocument = FileDocument & {
- savedContents: string;
-};
-
-type PendingAction =
- | {
- directoryPath: string;
- type: "create-directory" | "create-file";
- }
- | {
- path: string;
- type: "rename";
- };
+import type { FileEntry, FilePreview, FileWorkspaceProps } from "./types";
function toThemeStyle(
theme: ReturnType
@@ -44,27 +28,12 @@ function toThemeStyle(
"--hb-filesystem-row-active": theme.chrome.rowActive,
"--hb-filesystem-row-hover": theme.chrome.rowHover,
"--hb-filesystem-shadow": theme.chrome.shadow,
- "--hb-filesystem-tab-active": theme.chrome.tabActive,
- "--hb-filesystem-tab-inactive": theme.chrome.tabInactive,
"--hb-filesystem-text": theme.chrome.text,
"--hb-filesystem-text-muted": theme.chrome.textMuted,
"--hb-filesystem-warning": theme.chrome.warning,
} as CSSProperties;
}
-function toStatusLabel(document: WorkspaceDocument | null): string {
- if (!document) {
- return "No file open";
- }
- if (document.readOnly) {
- return "Read only";
- }
- if (document.contents !== document.savedContents) {
- return "Unsaved changes";
- }
- return "Synced";
-}
-
function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
@@ -72,6 +41,20 @@ function toErrorMessage(error: unknown): string {
return "An unexpected filesystem error occurred.";
}
+function isPathNotFoundError(error: unknown): boolean {
+ if (!(error instanceof Error)) {
+ return false;
+ }
+
+ const message = error.message.toLowerCase();
+ return (
+ message.includes("not found") ||
+ message.includes("no such file") ||
+ message.includes("no such directory") ||
+ message.includes("enoent")
+ );
+}
+
function sortEntries(entries: FileEntry[]): FileEntry[] {
return [...entries].sort((left, right) => {
if (left.type !== right.type) {
@@ -81,25 +64,179 @@ function sortEntries(entries: FileEntry[]): FileEntry[] {
});
}
+function CopyIcon() {
+ return (
+
+ );
+}
+
+function CheckIcon() {
+ return (
+
+ );
+}
+
+type WorkspacePickerOption = {
+ isParent?: boolean;
+ path: string;
+};
+
+function WorkspaceIcon() {
+ return (
+
+ );
+}
+
+function ChevronDownIcon({ open }: { open: boolean }) {
+ return (
+
+ );
+}
+
+function ParentDirectoryIcon() {
+ return (
+
+ );
+}
+
+function toDirectoryInputValue(path: string): string {
+ const normalizedPath = normalizeFilePath(path);
+ if (normalizedPath === "/") {
+ return "/";
+ }
+ return `${normalizedPath}/`;
+}
+
+function toWorkspaceLabel(path: string): string {
+ const normalizedPath = normalizeFilePath(path);
+ if (normalizedPath === "/") {
+ return "/";
+ }
+ return getBaseName(normalizedPath);
+}
+
+function getExpandedWorkspacePaths(
+ workspacePath: string,
+ activeDocumentPath: string | null
+): string[] {
+ const nextPaths = new Set(getAncestorPaths(workspacePath));
+
+ if (!activeDocumentPath || !isPathWithin(workspacePath, activeDocumentPath)) {
+ return Array.from(nextPaths);
+ }
+
+ for (const ancestor of getAncestorPaths(getDirName(activeDocumentPath))) {
+ if (ancestor === "/" || isPathWithin(workspacePath, ancestor)) {
+ nextPaths.add(ancestor);
+ }
+ }
+
+ return Array.from(nextPaths);
+}
+
+type DirectoryLoadOptions = {
+ reportError?: boolean;
+ throwOnError?: boolean;
+};
+
export function FileWorkspace({
adapter,
+ appearance,
className,
- initialPath = "/",
- onCreateDirectory,
- onCreateFile,
- onDelete,
+ chromeTheme,
+ editorTheme,
onError,
onOpenFile,
- onRename,
- onSaveFile,
- readOnly = false,
+ onWorkspacePathChange,
+ preset,
style,
theme,
- title = "Filesystem Workspace",
+ title = "Filesystem Browser",
+ workspacePath,
}: FileWorkspaceProps) {
- const resolvedTheme = resolveFileWorkspaceTheme(theme);
+ const resolvedTheme = resolveFileWorkspaceTheme(
+ appearance !== undefined ||
+ chromeTheme !== undefined ||
+ editorTheme !== undefined ||
+ preset !== undefined
+ ? {
+ appearance,
+ chromeTheme,
+ editorTheme,
+ preset,
+ }
+ : theme
+ );
+ const controlledWorkspacePath =
+ workspacePath == null ? null : normalizeFilePath(workspacePath);
const adapterRef = useRef(adapter);
const mountedRef = useRef(true);
+ const workspacePickerRef = useRef(null);
+ const workspacePickerRequestRef = useRef(0);
+ const documentRequestRef = useRef(0);
+ const copyResetTimeoutRef = useRef(null);
+ const activeDocumentRef = useRef(null);
+ const activeDocumentPathRef = useRef(null);
const [activeDocumentPath, setActiveDocumentPath] = useState(null);
const [directoryChildren, setDirectoryChildren] = useState>({});
const [entryIndex, setEntryIndex] = useState>({
@@ -110,38 +247,76 @@ export function FileWorkspace({
},
});
const [expandedPaths, setExpandedPaths] = useState(["/"]);
- const [loadingDirectories, setLoadingDirectories] = useState(["/"]);
- const [openDocuments, setOpenDocuments] = useState([]);
- const [pendingAction, setPendingAction] = useState(null);
- const [pendingActionValue, setPendingActionValue] = useState("");
+ const [loadingDirectories, setLoadingDirectories] = useState([]);
+ const [activeDocument, setActiveDocument] = useState(null);
const [selectedPath, setSelectedPath] = useState(null);
- const [statusMessage, setStatusMessage] = useState("Loading sandbox filesystem…");
- const [errorMessage, setErrorMessage] = useState(null);
+ const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
+ const [uncontrolledWorkspacePath, setUncontrolledWorkspacePath] = useState(
+ controlledWorkspacePath ?? "/"
+ );
+ const [isWorkspacePickerOpen, setIsWorkspacePickerOpen] = useState(false);
+ const [workspaceDraftPath, setWorkspaceDraftPath] = useState(
+ toDirectoryInputValue(controlledWorkspacePath ?? "/")
+ );
+ const [workspaceBrowserPath, setWorkspaceBrowserPath] = useState(controlledWorkspacePath ?? "/");
+ const [workspacePickerOptions, setWorkspacePickerOptions] = useState<
+ WorkspacePickerOption[]
+ >([]);
+ const [workspacePickerError, setWorkspacePickerError] = useState(null);
+ const [workspacePickerLoading, setWorkspacePickerLoading] = useState(false);
+ const resolvedWorkspacePath = controlledWorkspacePath ?? uncontrolledWorkspacePath;
adapterRef.current = adapter;
+ activeDocumentRef.current = activeDocument;
+ activeDocumentPathRef.current = activeDocumentPath;
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
+ if (copyResetTimeoutRef.current !== null) {
+ window.clearTimeout(copyResetTimeoutRef.current);
+ }
};
}, []);
+ useEffect(() => {
+ if (controlledWorkspacePath !== null) {
+ setUncontrolledWorkspacePath(controlledWorkspacePath);
+ }
+ }, [controlledWorkspacePath]);
+
function pushError(message: string) {
- setErrorMessage(message);
- setStatusMessage(message);
onError?.(message);
}
- function mergeEntries(entries: FileEntry[]) {
- const nextEntries = { ...entryIndex };
- for (const entry of entries) {
- nextEntries[entry.path] = entry;
+ async function statPath(path: string): Promise {
+ const normalizedPath = normalizeFilePath(path);
+ if (normalizedPath === "/") {
+ return {
+ name: "/",
+ path: "/",
+ type: "directory",
+ };
}
- setEntryIndex(nextEntries);
+
+ return await adapterRef.current.stat(normalizedPath);
}
- async function loadDirectory(path: string): Promise {
+ function mergeEntries(entries: FileEntry[]) {
+ setEntryIndex((current) => {
+ const nextEntries = { ...current };
+ for (const entry of entries) {
+ nextEntries[entry.path] = entry;
+ }
+ return nextEntries;
+ });
+ }
+
+ async function loadDirectory(
+ path: string,
+ options?: DirectoryLoadOptions
+ ): Promise {
const normalizedPath = normalizeFilePath(path);
setLoadingDirectories((current) =>
current.includes(normalizedPath) ? current : [...current, normalizedPath]
@@ -159,11 +334,13 @@ export function FileWorkspace({
language: entry.language ?? inferLanguageFromPath(entry.path),
}))
);
+
setDirectoryChildren((current) => ({
...current,
[normalizedPath]: sortedEntries,
}));
mergeEntries(sortedEntries);
+
if (normalizedPath !== "/") {
setEntryIndex((current) => ({
...current,
@@ -176,9 +353,15 @@ export function FileWorkspace({
} satisfies FileEntry),
}));
}
+
return sortedEntries;
} catch (error) {
- pushError(toErrorMessage(error));
+ if (options?.reportError !== false) {
+ pushError(toErrorMessage(error));
+ }
+ if (options?.throwOnError) {
+ throw error;
+ }
return [];
} finally {
if (mountedRef.current) {
@@ -189,58 +372,155 @@ export function FileWorkspace({
}
}
- async function ensureDirectoryLoaded(path: string): Promise {
+ async function ensureDirectoryLoaded(
+ path: string,
+ options?: DirectoryLoadOptions
+ ): Promise {
const normalizedPath = normalizeFilePath(path);
if (directoryChildren[normalizedPath]) {
return;
}
- await loadDirectory(normalizedPath);
+ await loadDirectory(normalizedPath, options);
}
- async function expandDirectoryChain(path: string): Promise {
- const ancestors = getAncestorPaths(path);
- for (const ancestor of ancestors) {
- setExpandedPaths((current) =>
- current.includes(ancestor) ? current : [...current, ancestor]
- );
- if (entryIndex[ancestor]?.type === "directory" || ancestor === "/") {
- await ensureDirectoryLoaded(ancestor);
+ async function listWorkspacePickerOptions(path: string): Promise {
+ const normalizedPath = normalizeFilePath(path);
+ const entries =
+ directoryChildren[normalizedPath] ??
+ (await loadDirectory(normalizedPath, {
+ reportError: false,
+ throwOnError: true,
+ }));
+ const nextOptions: WorkspacePickerOption[] = [];
+
+ if (normalizedPath !== "/") {
+ nextOptions.push({
+ isParent: true,
+ path: getDirName(normalizedPath),
+ });
+ }
+
+ for (const entry of entries) {
+ if (entry.type !== "directory") {
+ continue;
}
+ nextOptions.push({
+ path: entry.path,
+ });
}
+
+ return nextOptions;
}
- async function openFile(path: string): Promise {
+ async function showWorkspacePickerDirectory(path: string): Promise {
+ const requestId = ++workspacePickerRequestRef.current;
const normalizedPath = normalizeFilePath(path);
+
+ setWorkspacePickerLoading(true);
+
+ try {
+ const nextOptions = await listWorkspacePickerOptions(normalizedPath);
+
+ if (requestId !== workspacePickerRequestRef.current || !mountedRef.current) {
+ return;
+ }
+
+ setWorkspaceBrowserPath(normalizedPath);
+ setWorkspacePickerOptions(nextOptions);
+ } catch (error) {
+ if (requestId !== workspacePickerRequestRef.current || !mountedRef.current) {
+ return;
+ }
+ setWorkspacePickerError(toErrorMessage(error));
+ }
+
+ if (requestId === workspacePickerRequestRef.current && mountedRef.current) {
+ setWorkspacePickerLoading(false);
+ }
+ }
+
+ async function validateWorkspacePickerPath(inputPath: string): Promise {
+ const requestId = ++workspacePickerRequestRef.current;
+ const trimmedPath = inputPath.trim();
+ const normalizedInput = trimmedPath ? normalizeFilePath(trimmedPath) : resolvedWorkspacePath;
+
+ setWorkspacePickerLoading(true);
+
+ let exactDirectory = false;
+ let nextError: string | null = null;
+
+ try {
+ if (!trimmedPath || !trimmedPath.startsWith("/")) {
+ nextError = "Enter an absolute folder path.";
+ } else {
+ const entry = await statPath(normalizedInput);
+ if (entry.type === "directory") {
+ const nextOptions = await listWorkspacePickerOptions(normalizedInput);
+
+ if (requestId !== workspacePickerRequestRef.current || !mountedRef.current) {
+ return exactDirectory;
+ }
+
+ setWorkspaceBrowserPath(normalizedInput);
+ setWorkspacePickerOptions(nextOptions);
+ exactDirectory = true;
+ } else if (entry.type === "file") {
+ nextError = "Please enter a folder path.";
+ }
+ }
+ } catch (error) {
+ nextError = isPathNotFoundError(error)
+ ? "Please enter a path that exists."
+ : toErrorMessage(error);
+ }
+
+ if (requestId !== workspacePickerRequestRef.current || !mountedRef.current) {
+ return exactDirectory;
+ }
+
+ setWorkspacePickerError(nextError);
+ setWorkspacePickerLoading(false);
+ return exactDirectory;
+ }
+
+ async function loadDocument(path: string, options?: { force?: boolean }) {
+ const normalizedPath = normalizeFilePath(path);
+ const requestId = ++documentRequestRef.current;
+
setSelectedPath(normalizedPath);
setActiveDocumentPath(normalizedPath);
onOpenFile?.(normalizedPath);
- const existingDocument = openDocuments.find(
- (document) => document.path === normalizedPath
- );
- if (existingDocument) {
+ if (
+ activeDocumentPathRef.current === normalizedPath &&
+ activeDocumentRef.current &&
+ !options?.force
+ ) {
return;
}
try {
- const document = await adapterRef.current.readFile(normalizedPath);
- if (!mountedRef.current) {
+ const document = await adapterRef.current.previewFile(normalizedPath);
+ if (requestId !== documentRequestRef.current || !mountedRef.current) {
return;
}
- const nextDocument: WorkspaceDocument = {
- ...document,
- language:
- document.language ??
- entryIndex[normalizedPath]?.language ??
- inferLanguageFromPath(normalizedPath),
- path: normalizedPath,
- savedContents: document.contents,
- };
- setOpenDocuments((current) => [...current, nextDocument]);
- setStatusMessage(`Opened ${normalizedPath}`);
- setErrorMessage(null);
+ if (document.kind === "text") {
+ setActiveDocument({
+ ...document,
+ language:
+ document.language ??
+ entryIndex[normalizedPath]?.language ??
+ inferLanguageFromPath(normalizedPath),
+ });
+ return;
+ }
+
+ setActiveDocument(document);
} catch (error) {
+ if (requestId !== documentRequestRef.current || !mountedRef.current) {
+ return;
+ }
pushError(toErrorMessage(error));
}
}
@@ -248,25 +528,31 @@ export function FileWorkspace({
useEffect(() => {
let active = true;
- async function initializeWorkspace() {
- await loadDirectory("/");
- if (!active || !mountedRef.current) {
- return;
- }
-
- const normalizedInitialPath = normalizeFilePath(initialPath);
- if (normalizedInitialPath === "/") {
- setSelectedPath("/");
- setStatusMessage("Ready");
- return;
- }
+ workspacePickerRequestRef.current += 1;
+ documentRequestRef.current += 1;
+ setWorkspaceDraftPath(toDirectoryInputValue(resolvedWorkspacePath));
+ setWorkspaceBrowserPath(resolvedWorkspacePath);
+ setWorkspacePickerError(null);
+ setWorkspacePickerLoading(false);
+ setWorkspacePickerOptions([]);
+ setIsWorkspacePickerOpen(false);
+ async function initializeWorkspace() {
try {
- const entry = await adapterRef.current.stat(normalizedInitialPath);
+ const entry = await statPath(resolvedWorkspacePath);
if (!active || !mountedRef.current) {
return;
}
+ if (entry.type !== "directory") {
+ pushError("Workspace path must point to a folder.");
+ setActiveDocument(null);
+ setActiveDocumentPath(null);
+ setSelectedPath(null);
+ setExpandedPaths(["/"]);
+ return;
+ }
+
setEntryIndex((current) => ({
...current,
[entry.path]: {
@@ -275,23 +561,47 @@ export function FileWorkspace({
},
}));
- if (entry.type === "directory") {
- await expandDirectoryChain(entry.path);
- if (!active || !mountedRef.current) {
- return;
- }
- setSelectedPath(entry.path);
- } else {
- const parentPath = getDirName(entry.path);
- await expandDirectoryChain(parentPath);
+ const nextExpandedPaths = getExpandedWorkspacePaths(
+ resolvedWorkspacePath,
+ activeDocumentRef.current ? activeDocumentPathRef.current : null
+ );
+ setExpandedPaths(nextExpandedPaths);
+
+ for (const ancestor of nextExpandedPaths) {
+ await ensureDirectoryLoaded(ancestor, {
+ reportError: false,
+ throwOnError: true,
+ });
if (!active || !mountedRef.current) {
return;
}
- await openFile(entry.path);
}
- setStatusMessage("Ready");
+
+ if (
+ activeDocumentRef.current &&
+ activeDocumentPathRef.current &&
+ isPathWithin(resolvedWorkspacePath, activeDocumentPathRef.current)
+ ) {
+ setSelectedPath(activeDocumentPathRef.current);
+ return;
+ }
+
+ setActiveDocument(null);
+ setActiveDocumentPath(null);
+ setSelectedPath(null);
} catch (error) {
- pushError(toErrorMessage(error));
+ if (!active || !mountedRef.current) {
+ return;
+ }
+ pushError(
+ isPathNotFoundError(error)
+ ? `Workspace path does not exist: ${resolvedWorkspacePath}`
+ : toErrorMessage(error)
+ );
+ setActiveDocument(null);
+ setActiveDocumentPath(null);
+ setSelectedPath(null);
+ setExpandedPaths(["/"]);
}
}
@@ -300,32 +610,43 @@ export function FileWorkspace({
return () => {
active = false;
};
- }, [initialPath]);
+ }, [resolvedWorkspacePath]);
useEffect(() => {
- function handleWindowSave(event: KeyboardEvent) {
- if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
+ if (!isWorkspacePickerOpen) {
+ return;
+ }
+
+ function handlePointerDown(event: MouseEvent) {
+ const target = event.target;
+ if (!(target instanceof Node)) {
return;
}
+ if (workspacePickerRef.current?.contains(target)) {
+ return;
+ }
+ setIsWorkspacePickerOpen(false);
+ }
- event.preventDefault();
- void saveActiveDocument();
+ function handleEscape(event: KeyboardEvent) {
+ if (event.key !== "Escape") {
+ return;
+ }
+ setIsWorkspacePickerOpen(false);
}
- window.addEventListener("keydown", handleWindowSave);
+ window.addEventListener("mousedown", handlePointerDown);
+ window.addEventListener("keydown", handleEscape);
+
return () => {
- window.removeEventListener("keydown", handleWindowSave);
+ window.removeEventListener("mousedown", handlePointerDown);
+ window.removeEventListener("keydown", handleEscape);
};
- });
-
- const activeDocument =
- openDocuments.find((document) => document.path === activeDocumentPath) ?? null;
- const selectedEntry = selectedPath ? entryIndex[selectedPath] ?? null : null;
- const isWorkspaceReadOnly = readOnly === true;
+ }, [isWorkspacePickerOpen]);
async function handleToggleDirectory(path: string) {
const normalizedPath = normalizeFilePath(path);
- if (isRootPath(normalizedPath)) {
+ if (normalizedPath === "/") {
await ensureDirectoryLoaded("/");
return;
}
@@ -342,238 +663,64 @@ export function FileWorkspace({
);
}
- function updateDocument(path: string, patch: Partial) {
- const normalizedPath = normalizeFilePath(path);
- setOpenDocuments((current) =>
- current.map((document) =>
- document.path === normalizedPath ? { ...document, ...patch } : document
- )
- );
- }
-
- async function saveActiveDocument() {
- if (!activeDocument) {
- return;
- }
- if (isWorkspaceReadOnly || activeDocument.readOnly) {
- setStatusMessage("This file is read-only and cannot be saved.");
- return;
- }
- if (activeDocument.contents === activeDocument.savedContents) {
- setStatusMessage("No changes to save.");
+ async function copyToClipboard(value: string) {
+ if (!navigator?.clipboard?.writeText) {
return;
}
try {
- await adapterRef.current.writeFile(activeDocument.path, activeDocument.contents);
- if (!mountedRef.current) {
- return;
+ await navigator.clipboard.writeText(value);
+ if (copyResetTimeoutRef.current !== null) {
+ window.clearTimeout(copyResetTimeoutRef.current);
}
-
- updateDocument(activeDocument.path, {
- savedContents: activeDocument.contents,
- });
- setStatusMessage(`Saved ${activeDocument.path}`);
- setErrorMessage(null);
- onSaveFile?.(activeDocument.path);
- await ensureDirectoryLoaded(getDirName(activeDocument.path));
- } catch (error) {
- pushError(toErrorMessage(error));
+ setCopyState("copied");
+ copyResetTimeoutRef.current = window.setTimeout(() => {
+ setCopyState("idle");
+ copyResetTimeoutRef.current = null;
+ }, 1200);
+ } catch {
+ setCopyState("idle");
}
}
- function startCreateAction(type: "create-directory" | "create-file") {
- const baseDirectory =
- selectedEntry?.type === "directory"
- ? selectedEntry.path
- : selectedPath
- ? getDirName(selectedPath)
- : "/";
- setPendingAction({
- directoryPath: baseDirectory,
- type,
- });
- setPendingActionValue("");
- }
+ useEffect(() => {
+ if (copyResetTimeoutRef.current !== null) {
+ window.clearTimeout(copyResetTimeoutRef.current);
+ copyResetTimeoutRef.current = null;
+ }
+ setCopyState("idle");
+ }, [activeDocumentPath]);
- function startRenameAction() {
- if (!selectedPath || isRootPath(selectedPath)) {
- return;
+ function setWorkspacePathValue(path: string) {
+ const normalizedPath = normalizeFilePath(path);
+ if (controlledWorkspacePath === null) {
+ setUncontrolledWorkspacePath(normalizedPath);
}
- setPendingAction({
- path: selectedPath,
- type: "rename",
- });
- setPendingActionValue(getBaseName(selectedPath));
+ onWorkspacePathChange?.(normalizedPath);
}
- function cancelPendingAction() {
- setPendingAction(null);
- setPendingActionValue("");
+ function openWorkspacePicker() {
+ setWorkspaceDraftPath(toDirectoryInputValue(resolvedWorkspacePath));
+ setWorkspaceBrowserPath(resolvedWorkspacePath);
+ setWorkspacePickerError(null);
+ setIsWorkspacePickerOpen(true);
+ void showWorkspacePickerDirectory(resolvedWorkspacePath);
}
- async function submitPendingAction() {
- if (!pendingAction) {
- return;
- }
-
- const currentPendingAction = pendingAction;
- const nextName = pendingActionValue.trim();
- if (!nextName) {
- setStatusMessage("Name is required.");
- return;
- }
-
- try {
- if (currentPendingAction.type === "create-file") {
- const nextPath = joinFilePath(currentPendingAction.directoryPath, nextName);
- await adapterRef.current.createFile(nextPath, "");
- if (!mountedRef.current) {
- return;
- }
-
- await ensureDirectoryLoaded(currentPendingAction.directoryPath);
- await loadDirectory(currentPendingAction.directoryPath);
- setSelectedPath(nextPath);
- setActiveDocumentPath(nextPath);
- setOpenDocuments((current) => {
- if (current.some((document) => document.path === nextPath)) {
- return current;
- }
- return [
- ...current,
- {
- contents: "",
- language: inferLanguageFromPath(nextPath),
- path: nextPath,
- savedContents: "",
- },
- ];
- });
- setStatusMessage(`Created ${nextPath}`);
- onCreateFile?.(nextPath);
- } else if (currentPendingAction.type === "create-directory") {
- const nextPath = joinFilePath(currentPendingAction.directoryPath, nextName);
- await adapterRef.current.createDirectory(nextPath);
- if (!mountedRef.current) {
- return;
- }
-
- await ensureDirectoryLoaded(currentPendingAction.directoryPath);
- await loadDirectory(currentPendingAction.directoryPath);
- setExpandedPaths((current) =>
- current.includes(nextPath) ? current : [...current, nextPath]
- );
- setSelectedPath(nextPath);
- setStatusMessage(`Created ${nextPath}`);
- onCreateDirectory?.(nextPath);
- } else if (currentPendingAction.type === "rename") {
- const currentPath = currentPendingAction.path;
- const nextPath = joinFilePath(getDirName(currentPath), nextName);
- await adapterRef.current.rename(currentPath, nextPath);
- if (!mountedRef.current) {
- return;
- }
-
- await loadDirectory(getDirName(currentPath));
- if (getDirName(nextPath) !== getDirName(currentPath)) {
- await loadDirectory(getDirName(nextPath));
- }
-
- setEntryIndex((current) => {
- const nextIndex = { ...current };
- const movingEntry = nextIndex[currentPath];
- delete nextIndex[currentPath];
- if (movingEntry) {
- nextIndex[nextPath] = {
- ...movingEntry,
- name: getBaseName(nextPath),
- path: nextPath,
- };
- }
- return nextIndex;
- });
- setOpenDocuments((current) =>
- current.map((document) =>
- document.path === currentPath
- ? {
- ...document,
- language: inferLanguageFromPath(nextPath),
- path: nextPath,
- }
- : document
- )
- );
- setExpandedPaths((current) =>
- current.map((path) => (path === currentPath ? nextPath : path))
- );
- if (selectedPath === currentPath) {
- setSelectedPath(nextPath);
- }
- if (activeDocumentPath === currentPath) {
- setActiveDocumentPath(nextPath);
- }
- setStatusMessage(`Renamed to ${nextPath}`);
- onRename?.(currentPath, nextPath);
- }
-
- setErrorMessage(null);
- cancelPendingAction();
- } catch (error) {
- pushError(toErrorMessage(error));
- }
+ function handleWorkspaceOptionClick(option: WorkspacePickerOption) {
+ setWorkspaceDraftPath(toDirectoryInputValue(option.path));
+ setWorkspacePickerError(null);
+ void showWorkspacePickerDirectory(option.path);
}
- async function deleteSelectedPath() {
- if (!selectedPath || isRootPath(selectedPath)) {
- return;
- }
-
- const isDirectory = entryIndex[selectedPath]?.type === "directory";
- const confirmationMessage = isDirectory
- ? `Delete ${selectedPath} and all nested files?`
- : `Delete ${selectedPath}?`;
- if (!window.confirm(confirmationMessage)) {
+ async function commitWorkspacePath() {
+ const isValidDirectory = await validateWorkspacePickerPath(workspaceDraftPath);
+ if (!isValidDirectory) {
return;
}
- try {
- await adapterRef.current.delete(selectedPath, {
- recursive: isDirectory,
- });
- if (!mountedRef.current) {
- return;
- }
-
- const parentPath = getDirName(selectedPath);
- await loadDirectory(parentPath);
- setSelectedPath(parentPath);
- setExpandedPaths((current) =>
- current.filter((path) => !isPathWithin(selectedPath, path))
- );
- setOpenDocuments((current) =>
- current.filter((document) => !isPathWithin(selectedPath, document.path))
- );
- setActiveDocumentPath((current) =>
- current && isPathWithin(selectedPath, current) ? null : current
- );
- setStatusMessage(`Deleted ${selectedPath}`);
- setErrorMessage(null);
- onDelete?.(selectedPath);
- } catch (error) {
- pushError(toErrorMessage(error));
- }
- }
-
- async function refreshExplorer() {
- const targetPath =
- selectedEntry?.type === "directory"
- ? selectedEntry.path
- : selectedPath
- ? getDirName(selectedPath)
- : "/";
- await loadDirectory(targetPath);
- setStatusMessage(`Refreshed ${targetPath}`);
+ setWorkspacePathValue(workspaceDraftPath);
+ setIsWorkspacePickerOpen(false);
}
const rootClassName = ["hb-filesystem", className].filter(Boolean).join(" ");
@@ -587,231 +734,173 @@ export function FileWorkspace({
...style,
}}
>
-
-
-
-
+
-
- {openDocuments.length === 0 ? (
-
- No file open
-
- ) : (
- openDocuments.map((document) => {
- const isActive = document.path === activeDocumentPath;
- const isDirty = document.contents !== document.savedContents;
- return (
-
-
-
-
- );
- })
- )}
-
-
-
{
- if (!activeDocumentPath) {
- return;
- }
- updateDocument(activeDocumentPath, {
- contents: nextContents,
- });
- }}
- onSave={() => {
- void saveActiveDocument();
- }}
- theme={resolvedTheme}
- />
+
-
-
);
}
diff --git a/src/components/filesystem/filePreview.ts b/src/components/filesystem/filePreview.ts
new file mode 100644
index 0000000..2a4ef1b
--- /dev/null
+++ b/src/components/filesystem/filePreview.ts
@@ -0,0 +1,21 @@
+export function formatFileSize(size?: number): string | null {
+ if (typeof size !== "number" || !Number.isFinite(size) || size < 0) {
+ return null;
+ }
+
+ if (size < 1024) {
+ return `${size} B`;
+ }
+
+ const units = ["KB", "MB", "GB", "TB"];
+ let value = size;
+ let unitIndex = -1;
+
+ do {
+ value /= 1024;
+ unitIndex += 1;
+ } while (value >= 1024 && unitIndex < units.length - 1);
+
+ const formatted = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
+ return `${formatted} ${units[unitIndex]}`;
+}
diff --git a/src/components/filesystem/fileWorkspaceThemes.ts b/src/components/filesystem/fileWorkspaceThemes.ts
index 1fccab7..0b01d05 100644
--- a/src/components/filesystem/fileWorkspaceThemes.ts
+++ b/src/components/filesystem/fileWorkspaceThemes.ts
@@ -1,92 +1,307 @@
import type {
+ FileWorkspaceAppearance,
+ FileWorkspaceChromeTheme,
+ FileWorkspaceEditorTheme,
+ FileWorkspaceSurfaceTheme,
+ LegacyFileWorkspaceTheme,
+ FileWorkspacePreset,
+ FileWorkspacePresetName,
FileWorkspaceTheme,
FileWorkspaceThemeName,
ResolvedFileWorkspaceTheme,
} from "./types";
-export const fileWorkspaceThemePresets: Record = {
- atlas: {
+type NormalizedThemeInput = {
+ appearance?: FileWorkspaceAppearance;
+ chromeTheme?: Partial;
+ editorTheme?: Partial;
+ preset?: FileWorkspacePresetName;
+};
+
+const DEFAULT_FILE_WORKSPACE_APPEARANCE: FileWorkspaceAppearance = "light";
+
+const baseFileWorkspaceEditorTheme = {
+ fontFamily:
+ '"IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace',
+ fontSize: 14,
+ lineHeight: 1.5,
+} as const;
+
+const legacyThemeAliases: Record<
+ string,
+ { appearance: FileWorkspaceAppearance; preset: FileWorkspacePresetName }
+> = {
+ "basic-dark": {
+ appearance: "dark",
+ preset: "basic",
+ },
+ "atlas-dark": {
+ appearance: "dark",
+ preset: "atlas",
+ },
+ "ledger-dark": {
+ appearance: "dark",
+ preset: "ledger",
+ },
+};
+
+function definePreset(preset: FileWorkspacePreset): FileWorkspacePreset {
+ return preset;
+}
+
+function mergeChromeTheme(
+ base: FileWorkspaceChromeTheme,
+ override: Partial | undefined
+): FileWorkspaceChromeTheme {
+ return {
+ accent: override?.accent ?? base.accent,
+ background: override?.background ?? base.background,
+ border: override?.border ?? base.border,
+ danger: override?.danger ?? base.danger,
+ divider: override?.divider ?? base.divider,
+ editorBackground: override?.editorBackground ?? base.editorBackground,
+ panel: override?.panel ?? base.panel,
+ panelMuted: override?.panelMuted ?? base.panelMuted,
+ rowActive: override?.rowActive ?? base.rowActive,
+ rowHover: override?.rowHover ?? base.rowHover,
+ shadow: override?.shadow ?? base.shadow,
+ text: override?.text ?? base.text,
+ textMuted: override?.textMuted ?? base.textMuted,
+ warning: override?.warning ?? base.warning,
+ };
+}
+
+function mergeEditorTheme(
+ base: FileWorkspaceEditorTheme,
+ override: Partial | undefined
+): FileWorkspaceEditorTheme {
+ return {
+ fontFamily: override?.fontFamily ?? base.fontFamily,
+ fontSize: override?.fontSize ?? base.fontSize,
+ lineHeight: override?.lineHeight ?? base.lineHeight,
+ };
+}
+
+function isLegacyThemeInput(
+ input: FileWorkspaceTheme
+): input is LegacyFileWorkspaceTheme {
+ return "chrome" in input || "editor" in input;
+}
+
+function normalizeThemeInput(input: FileWorkspaceTheme | undefined): NormalizedThemeInput {
+ if (!input) {
+ return {};
+ }
+
+ if (isLegacyThemeInput(input)) {
+ return {
+ appearance: input.appearance,
+ chromeTheme: input.chrome,
+ editorTheme: input.editor,
+ preset: input.preset,
+ };
+ }
+
+ const normalizedInput = input as FileWorkspaceSurfaceTheme;
+ return {
+ appearance: normalizedInput.appearance,
+ chromeTheme: normalizedInput.chromeTheme,
+ editorTheme: normalizedInput.editorTheme,
+ preset: normalizedInput.preset,
+ };
+}
+
+function normalizeThemeName(
+ input: FileWorkspaceThemeName | undefined
+): NormalizedThemeInput {
+ if (!input) {
+ return {};
+ }
+
+ const legacyAlias = legacyThemeAliases[input];
+ if (legacyAlias) {
+ return legacyAlias;
+ }
+
+ return {
+ preset: input as FileWorkspacePresetName,
+ };
+}
+
+export const defaultFileWorkspaceAppearance = DEFAULT_FILE_WORKSPACE_APPEARANCE;
+
+export const fileWorkspacePresets = {
+ basic: definePreset({
+ id: "basic",
+ label: "Basic",
+ chrome: {
+ dark: {
+ accent: "#ffffff",
+ background: "#0a0a0a",
+ border: "rgba(255, 255, 255, 0.12)",
+ danger: "#ff7b72",
+ divider: "rgba(255, 255, 255, 0.1)",
+ editorBackground: "#050505",
+ panel: "rgba(12, 12, 12, 0.96)",
+ panelMuted: "rgba(20, 20, 20, 0.94)",
+ rowActive: "rgba(255, 255, 255, 0.1)",
+ rowHover: "rgba(255, 255, 255, 0.06)",
+ shadow: "0 24px 48px rgba(0, 0, 0, 0.42)",
+ text: "#f5f5f5",
+ textMuted: "#9ca3af",
+ warning: "#f3b35b",
+ },
+ light: {
+ accent: "#111111",
+ background: "#f3f4f6",
+ border: "rgba(17, 17, 17, 0.1)",
+ danger: "#b91c1c",
+ divider: "rgba(17, 17, 17, 0.08)",
+ editorBackground: "#ffffff",
+ panel: "rgba(255, 255, 255, 0.96)",
+ panelMuted: "rgba(243, 244, 246, 0.96)",
+ rowActive: "#e5e7eb",
+ rowHover: "#f3f4f6",
+ shadow: "0 24px 48px rgba(17, 17, 17, 0.08)",
+ text: "#111111",
+ textMuted: "#6b7280",
+ warning: "#b45309",
+ },
+ },
+ editor: {
+ dark: baseFileWorkspaceEditorTheme,
+ light: baseFileWorkspaceEditorTheme,
+ },
+ }),
+ atlas: definePreset({
id: "atlas",
label: "Atlas",
chrome: {
- accent: "#1267d6",
- background: "#eef3f7",
- border: "#d7e0ea",
- danger: "#b42318",
- divider: "#d7e0ea",
- editorBackground: "#fbfdff",
- panel: "#ffffff",
- panelMuted: "#f5f8fb",
- rowActive: "#dfeeff",
- rowHover: "#eef5ff",
- shadow: "0 24px 48px rgba(15, 23, 42, 0.08)",
- tabActive: "#ffffff",
- tabInactive: "#edf3f8",
- text: "#10243b",
- textMuted: "#62748a",
- warning: "#b54708",
+ dark: {
+ accent: "#3d80e8",
+ background: "#0a121d",
+ border: "#233347",
+ danger: "#f97066",
+ divider: "#1e3044",
+ editorBackground: "#08111a",
+ panel: "#0f1926",
+ panelMuted: "#0b1420",
+ rowActive: "#15314d",
+ rowHover: "#11263b",
+ shadow: "0 24px 48px rgba(2, 6, 23, 0.52)",
+ text: "#edf4ff",
+ textMuted: "#92a7be",
+ warning: "#f0a63d",
+ },
+ light: {
+ accent: "#1267d6",
+ background: "#eef3f7",
+ border: "#d7e0ea",
+ danger: "#b42318",
+ divider: "#d7e0ea",
+ editorBackground: "#fbfdff",
+ panel: "#ffffff",
+ panelMuted: "#f5f8fb",
+ rowActive: "#dfeeff",
+ rowHover: "#eef5ff",
+ shadow: "0 24px 48px rgba(15, 23, 42, 0.08)",
+ text: "#10243b",
+ textMuted: "#62748a",
+ warning: "#b54708",
+ },
},
editor: {
- fontFamily:
- '"IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace',
- fontSize: 14,
- lineHeight: 1.5,
+ dark: baseFileWorkspaceEditorTheme,
+ light: baseFileWorkspaceEditorTheme,
},
- },
- ledger: {
+ }),
+ ledger: definePreset({
id: "ledger",
label: "Ledger",
chrome: {
- accent: "#0a8b67",
- background: "#f4f1ea",
- border: "#ddd5c7",
- danger: "#ab1f2f",
- divider: "#ddd5c7",
- editorBackground: "#fffdf9",
- panel: "#fffdf8",
- panelMuted: "#f8f4ec",
- rowActive: "#e8f6ef",
- rowHover: "#f4faf7",
- shadow: "0 22px 44px rgba(55, 48, 35, 0.09)",
- tabActive: "#fffdf8",
- tabInactive: "#efe7d8",
- text: "#21312c",
- textMuted: "#6a7169",
- warning: "#9c5d00",
+ dark: {
+ accent: "#1f9c74",
+ background: "#17120d",
+ border: "#3a2f23",
+ danger: "#ff8a7a",
+ divider: "#34291f",
+ editorBackground: "#120e09",
+ panel: "#211913",
+ panelMuted: "#19130e",
+ rowActive: "#14342a",
+ rowHover: "#11261f",
+ shadow: "0 24px 48px rgba(7, 5, 3, 0.5)",
+ text: "#f3ece2",
+ textMuted: "#b4a695",
+ warning: "#e2a13c",
+ },
+ light: {
+ accent: "#0a8b67",
+ background: "#f4f1ea",
+ border: "#ddd5c7",
+ danger: "#ab1f2f",
+ divider: "#ddd5c7",
+ editorBackground: "#fffdf9",
+ panel: "#fffdf8",
+ panelMuted: "#f8f4ec",
+ rowActive: "#e8f6ef",
+ rowHover: "#f4faf7",
+ shadow: "0 22px 44px rgba(55, 48, 35, 0.09)",
+ text: "#21312c",
+ textMuted: "#6a7169",
+ warning: "#9c5d00",
+ },
},
editor: {
- fontFamily:
- '"IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace',
- fontSize: 14,
- lineHeight: 1.5,
+ dark: baseFileWorkspaceEditorTheme,
+ light: baseFileWorkspaceEditorTheme,
},
- },
-};
+ }),
+} satisfies Record;
+
+export const fileWorkspaceThemePresets = fileWorkspacePresets;
+
+export const defaultFileWorkspacePreset = fileWorkspacePresets.atlas;
-export const defaultFileWorkspaceTheme = fileWorkspaceThemePresets.atlas;
+export function createFileWorkspaceTheme(
+ presetOrTheme: FileWorkspacePresetName | FileWorkspaceSurfaceTheme,
+ overrides: Omit = {}
+): FileWorkspaceSurfaceTheme {
+ if (typeof presetOrTheme === "string") {
+ return {
+ preset: presetOrTheme,
+ ...overrides,
+ };
+ }
+
+ return presetOrTheme;
+}
export function resolveFileWorkspaceTheme(
theme?: FileWorkspaceTheme | FileWorkspaceThemeName
): ResolvedFileWorkspaceTheme {
- const preset =
- typeof theme === "string"
- ? fileWorkspaceThemePresets[theme] ?? defaultFileWorkspaceTheme
- : defaultFileWorkspaceTheme;
-
- if (!theme || typeof theme === "string") {
- return preset;
- }
+ const normalizedInput =
+ typeof theme === "string" ? normalizeThemeName(theme) : normalizeThemeInput(theme);
+ const appearance =
+ normalizedInput.appearance ?? defaultFileWorkspaceAppearance;
+ const basePreset =
+ (normalizedInput.preset &&
+ fileWorkspacePresets[normalizedInput.preset as FileWorkspacePresetName]) ||
+ defaultFileWorkspacePreset;
return {
- id: theme.id ?? preset.id,
- label: theme.label ?? preset.label,
- chrome: {
- ...preset.chrome,
- ...theme.chrome,
- },
- editor: {
- ...preset.editor,
- ...theme.editor,
- },
+ appearance,
+ chrome: mergeChromeTheme(
+ basePreset.chrome[appearance],
+ normalizedInput.chromeTheme
+ ),
+ editor: mergeEditorTheme(
+ basePreset.editor[appearance],
+ normalizedInput.editorTheme
+ ),
+ id: basePreset.id,
+ label: basePreset.label,
};
}
+
+export const defaultFileWorkspaceTheme = resolveFileWorkspaceTheme();
diff --git a/src/components/filesystem/monaco-loader.ts b/src/components/filesystem/monaco-loader.ts
deleted file mode 100644
index 0c360ed..0000000
--- a/src/components/filesystem/monaco-loader.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type * as MonacoEditor from "monaco-editor";
-
-type MonacoNamespace = typeof MonacoEditor;
-type MonacoWindow = Window & {
- monaco?: MonacoNamespace;
- require?: AMDRequire;
-};
-
-type AMDRequire = ((modules: string[], onLoad: () => void, onError?: (error: unknown) => void) => void) & {
- config: (config: { paths: Record }) => void;
-};
-
-export type MonacoLoaderOptions = {
- vsPath?: string;
-};
-
-const DEFAULT_MONACO_VS_PATH = "/dist/monaco/vs";
-const SCRIPT_ATTRIBUTE = "data-hb-monaco-loader";
-
-let configuredVsPath = DEFAULT_MONACO_VS_PATH;
-let loaderPromise: Promise | null = null;
-
-function normalizeVsPath(path: string): string {
- return path.replace(/\/+$/, "");
-}
-
-function loadAmdLoader(vsPath: string): Promise {
- if (typeof window === "undefined") {
- return Promise.reject(
- new Error("Monaco can only be loaded in a browser environment.")
- );
- }
-
- const monacoWindow = window as MonacoWindow;
- if (monacoWindow.require) {
- return Promise.resolve();
- }
-
- const existingScript = document.querySelector(
- `script[${SCRIPT_ATTRIBUTE}="true"]`
- );
- if (existingScript) {
- return new Promise((resolve, reject) => {
- existingScript.addEventListener("load", () => resolve(), { once: true });
- existingScript.addEventListener(
- "error",
- () => reject(new Error("Failed to load Monaco AMD loader.")),
- { once: true }
- );
- });
- }
-
- return new Promise((resolve, reject) => {
- const script = document.createElement("script");
- script.async = true;
- script.src = `${vsPath}/loader.js`;
- script.setAttribute(SCRIPT_ATTRIBUTE, "true");
- script.addEventListener("load", () => resolve(), { once: true });
- script.addEventListener(
- "error",
- () => reject(new Error(`Failed to load Monaco from ${vsPath}.`)),
- { once: true }
- );
- document.head.appendChild(script);
- });
-}
-
-export function configureMonacoLoader(options: MonacoLoaderOptions): void {
- if (!options.vsPath) {
- return;
- }
-
- configuredVsPath = normalizeVsPath(options.vsPath);
- loaderPromise = null;
-}
-
-export function getConfiguredMonacoVsPath(): string {
- return configuredVsPath;
-}
-
-export async function loadMonaco(): Promise {
- if (typeof window === "undefined") {
- throw new Error("Monaco can only be loaded in a browser environment.");
- }
-
- const monacoWindow = window as MonacoWindow;
-
- if (monacoWindow.monaco) {
- return monacoWindow.monaco;
- }
-
- if (!loaderPromise) {
- loaderPromise = (async () => {
- const vsPath = normalizeVsPath(configuredVsPath);
- await loadAmdLoader(vsPath);
-
- const runtimeWindow = window as MonacoWindow;
- if (typeof runtimeWindow.require !== "function") {
- throw new Error("Monaco AMD loader did not expose window.require.");
- }
-
- window.MonacoEnvironment = {
- baseUrl: `${vsPath}/`,
- };
- runtimeWindow.require.config({
- paths: {
- vs: vsPath,
- },
- });
-
- return new Promise((resolve, reject) => {
- runtimeWindow.require?.(
- ["vs/editor/editor.main"],
- () => {
- const loadedWindow = window as MonacoWindow;
- if (!loadedWindow.monaco) {
- reject(new Error("Monaco loaded without exposing window.monaco."));
- return;
- }
- resolve(loadedWindow.monaco);
- },
- (error) => {
- reject(
- error instanceof Error
- ? error
- : new Error("Failed to load Monaco editor.")
- );
- }
- );
- });
- })();
- }
-
- return loaderPromise;
-}
diff --git a/src/components/filesystem/types.ts b/src/components/filesystem/types.ts
index 8c7d449..b8c0e78 100644
--- a/src/components/filesystem/types.ts
+++ b/src/components/filesystem/types.ts
@@ -21,7 +21,16 @@ export type FileDirectoryListing = {
entries: FileEntry[];
};
-export type FileDocument = {
+export type FilePreviewKind =
+ | "text"
+ | "image"
+ | "audio"
+ | "video"
+ | "pdf"
+ | "binary";
+
+export type FileTextPreview = {
+ kind: "text";
path: string;
contents: string;
contentType?: string;
@@ -29,18 +38,41 @@ export type FileDocument = {
language?: string;
readOnly?: boolean;
readOnlyReason?: string;
+ size?: number;
truncated?: boolean;
};
+export type FileAssetPreview = {
+ kind: "image" | "audio" | "video" | "pdf";
+ path: string;
+ contentType?: string;
+ expiresAt?: number;
+ name?: string;
+ size?: number;
+ url: string;
+};
+
+export type FileBinaryPreview = {
+ kind: "binary";
+ path: string;
+ contentType?: string;
+ name?: string;
+ readOnlyReason?: string;
+ reason?: string;
+ size?: number;
+};
+
+export type FilePreview = FileTextPreview | FileAssetPreview | FileBinaryPreview;
+
export type FileWorkspaceAdapter = {
listDirectory(path: string): Promise;
+ previewFile(path: string): Promise;
stat(path: string): Promise;
- readFile(path: string): Promise;
- writeFile(path: string, contents: string): Promise;
- createFile(path: string, contents?: string): Promise;
- createDirectory(path: string): Promise;
- rename(path: string, nextPath: string): Promise;
- delete(path: string, options?: { recursive?: boolean }): Promise;
+ writeFile?: (path: string, contents: string) => Promise;
+ createFile?: (path: string, contents?: string) => Promise;
+ createDirectory?: (path: string) => Promise;
+ rename?: (path: string, nextPath: string) => Promise;
+ delete?: (path: string, options?: { recursive?: boolean }) => Promise;
};
export type FileWorkspaceChromeTheme = {
@@ -55,8 +87,6 @@ export type FileWorkspaceChromeTheme = {
rowActive: string;
rowHover: string;
shadow: string;
- tabActive: string;
- tabInactive: string;
text: string;
textMuted: string;
warning: string;
@@ -68,26 +98,52 @@ export type FileWorkspaceEditorTheme = {
lineHeight: number;
};
+export type FileWorkspaceAppearance = "dark" | "light";
+
+export type FileWorkspacePresetName = "basic" | "atlas" | "ledger";
+
+export type FileWorkspacePreset = {
+ chrome: Record;
+ editor: Record;
+ id: FileWorkspacePresetName;
+ label: string;
+};
+
export type ResolvedFileWorkspaceTheme = {
+ appearance: FileWorkspaceAppearance;
chrome: FileWorkspaceChromeTheme;
editor: FileWorkspaceEditorTheme;
id: string;
label: string;
};
-export type FileWorkspaceTheme = Partial & {
+export type FileWorkspaceSurfaceTheme = {
+ appearance?: FileWorkspaceAppearance;
+ chromeTheme?: Partial;
+ editorTheme?: Partial;
+ preset?: FileWorkspacePresetName;
+};
+
+export type LegacyFileWorkspaceTheme = Partial & {
chrome?: Partial;
editor?: Partial;
id?: string;
label?: string;
+ preset?: FileWorkspacePresetName;
};
-export type FileWorkspaceThemeName = string;
+export type FileWorkspaceTheme =
+ | FileWorkspaceSurfaceTheme
+ | LegacyFileWorkspaceTheme;
+
+export type FileWorkspaceThemeName = FileWorkspacePresetName | string;
export type FileWorkspaceProps = {
adapter: FileWorkspaceAdapter;
+ appearance?: FileWorkspaceAppearance;
className?: string;
- initialPath?: string;
+ chromeTheme?: Partial;
+ editorTheme?: Partial;
onCreateDirectory?: (path: string) => void;
onCreateFile?: (path: string) => void;
onDelete?: (path: string) => void;
@@ -95,8 +151,11 @@ export type FileWorkspaceProps = {
onOpenFile?: (path: string) => void;
onRename?: (path: string, nextPath: string) => void;
onSaveFile?: (path: string) => void;
+ onWorkspacePathChange?: (path: string) => void;
+ preset?: FileWorkspacePresetName;
readOnly?: boolean;
style?: CSSProperties;
theme?: FileWorkspaceTheme | FileWorkspaceThemeName;
title?: string;
+ workspacePath?: string;
};
diff --git a/src/components/hyperbrowser/HyperbrowserFileWorkspace.tsx b/src/components/hyperbrowser/HyperbrowserFileWorkspace.tsx
index a8f7e1c..deba976 100644
--- a/src/components/hyperbrowser/HyperbrowserFileWorkspace.tsx
+++ b/src/components/hyperbrowser/HyperbrowserFileWorkspace.tsx
@@ -1,89 +1,52 @@
-import { useRef } from "react";
+import { useMemo } from "react";
import { FileWorkspace } from "../filesystem/FileWorkspace";
import type { FileWorkspaceProps } from "../filesystem/types";
import {
createHyperbrowserFilesystemAdapter,
type HyperbrowserFilesystemAdapterOptions,
- type HyperbrowserFilesystemBrowserAuthResolver,
- type HyperbrowserRuntimeBrowserAuth,
} from "./hyperbrowser-filesystem-adapter";
+import { useHyperbrowserRuntime } from "./HyperbrowserRuntimeProvider";
-export type HyperbrowserFileWorkspaceProps = Omit &
- HyperbrowserFilesystemAdapterOptions;
-
-type StableAdapterFactory = {
- adapter: ReturnType;
-};
-
-function serializeValue(value: unknown): string {
- if (!value) {
- return "";
- }
- if (Array.isArray(value)) {
- return JSON.stringify(value);
- }
- if (value instanceof Headers) {
- return JSON.stringify(Array.from(value.entries()));
- }
- if (typeof value === "object") {
- return JSON.stringify(value, Object.keys(value as Record).sort());
- }
- return String(value);
-}
-
-function createAdapterKey(props: HyperbrowserFileWorkspaceProps): string {
- return [
- props.apiBaseUrl ?? "",
- props.bootstrapUrl ?? "",
- props.browserAuthPath ?? "",
- serializeValue(props.apiHeaders),
- props.runtimeBaseUrl ?? "",
- props.sandboxId ?? "",
- ].join("|");
-}
+export type HyperbrowserFileWorkspaceProps = Omit<
+ FileWorkspaceProps,
+ "adapter"
+> &
+ Pick;
function createAdapterOptions(
- props: HyperbrowserFileWorkspaceProps
+ props: HyperbrowserFileWorkspaceProps,
+ getRuntimeAccess: HyperbrowserFilesystemAdapterOptions["getRuntimeAccess"],
): HyperbrowserFilesystemAdapterOptions {
return {
- apiBaseUrl: props.apiBaseUrl,
- apiCredentials: props.apiCredentials,
- apiHeaders: props.apiHeaders,
- bootstrapUrl: props.bootstrapUrl,
- browserAuthPath: props.browserAuthPath,
fetch: props.fetch,
- getRuntimeBrowserAuth: props.getRuntimeBrowserAuth,
- runtimeBaseUrl: props.runtimeBaseUrl,
- sandboxId: props.sandboxId,
+ getRuntimeAccess,
};
}
export {
createHyperbrowserFilesystemAdapter,
type HyperbrowserFilesystemAdapterOptions,
- type HyperbrowserFilesystemBrowserAuthResolver,
- type HyperbrowserRuntimeBrowserAuth,
};
export function HyperbrowserFileWorkspace(
- props: HyperbrowserFileWorkspaceProps
+ props: HyperbrowserFileWorkspaceProps,
) {
- const adapterFactoryRef = useRef(null);
- const adapterKeyRef = useRef("");
- const adapterKey = createAdapterKey(props);
-
- if (!adapterFactoryRef.current || adapterKeyRef.current !== adapterKey) {
- adapterFactoryRef.current = {
- adapter: createHyperbrowserFilesystemAdapter(createAdapterOptions(props)),
- };
- adapterKeyRef.current = adapterKey;
- }
+ const { ensureRuntimeAccess } = useHyperbrowserRuntime();
+ const adapter = useMemo(
+ () =>
+ createHyperbrowserFilesystemAdapter(
+ createAdapterOptions(props, ensureRuntimeAccess),
+ ),
+ [props.fetch, ensureRuntimeAccess],
+ );
return (
);
}
diff --git a/src/components/hyperbrowser/HyperbrowserRuntimeProvider.tsx b/src/components/hyperbrowser/HyperbrowserRuntimeProvider.tsx
new file mode 100644
index 0000000..1edfc68
--- /dev/null
+++ b/src/components/hyperbrowser/HyperbrowserRuntimeProvider.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ type ReactNode,
+} from "react";
+import type {
+ HyperbrowserRuntimeAccess,
+ HyperbrowserRuntimeAccessResolver,
+ HyperbrowserRuntimeLoader,
+} from "./hyperbrowser-runtime";
+
+const RUNTIME_REFRESH_BUFFER_MS = 15_000;
+
+type HyperbrowserRuntimeContextValue = {
+ ensureRuntimeAccess: HyperbrowserRuntimeAccessResolver;
+ invalidateRuntimeAccess: () => void;
+ sandboxId: string;
+};
+
+type HyperbrowserRuntimeProviderProps = {
+ children: ReactNode;
+ loadRuntimeAccess: HyperbrowserRuntimeLoader;
+ sandboxId: string;
+};
+
+type RuntimeProviderState = {
+ cachedAccess: HyperbrowserRuntimeAccess | null;
+ inFlightAccess: Promise | null;
+ sandboxId: string;
+};
+
+const HyperbrowserRuntimeContext =
+ createContext(null);
+
+function createAbortError(): DOMException {
+ return new DOMException("Request aborted", "AbortError");
+}
+
+function withAbortSignal(
+ promise: Promise,
+ signal?: AbortSignal,
+): Promise {
+ if (!signal) {
+ return promise;
+ }
+
+ if (signal.aborted) {
+ return Promise.reject(createAbortError());
+ }
+
+ return new Promise((resolve, reject) => {
+ const onAbort = () => {
+ signal.removeEventListener("abort", onAbort);
+ reject(createAbortError());
+ };
+
+ signal.addEventListener("abort", onAbort, { once: true });
+
+ promise.then(
+ (value) => {
+ signal.removeEventListener("abort", onAbort);
+ resolve(value);
+ },
+ (error) => {
+ signal.removeEventListener("abort", onAbort);
+ reject(error);
+ },
+ );
+ });
+}
+
+function isRuntimeAccessFresh(
+ runtimeAccess: HyperbrowserRuntimeAccess | null,
+): runtimeAccess is HyperbrowserRuntimeAccess {
+ if (!runtimeAccess) {
+ return false;
+ }
+
+ const expiresAtMs = runtimeAccess.expiresAt
+ ? Date.parse(runtimeAccess.expiresAt)
+ : Number.NaN;
+ if (!Number.isFinite(expiresAtMs)) {
+ return true;
+ }
+
+ return expiresAtMs - RUNTIME_REFRESH_BUFFER_MS > Date.now();
+}
+
+export function HyperbrowserRuntimeProvider({
+ children,
+ loadRuntimeAccess,
+ sandboxId,
+}: HyperbrowserRuntimeProviderProps) {
+ const stateRef = useRef({
+ cachedAccess: null,
+ inFlightAccess: null,
+ sandboxId,
+ });
+
+ if (stateRef.current.sandboxId !== sandboxId) {
+ stateRef.current = {
+ cachedAccess: null,
+ inFlightAccess: null,
+ sandboxId,
+ };
+ }
+
+ const invalidateRuntimeAccess = useCallback(() => {
+ stateRef.current.cachedAccess = null;
+ stateRef.current.inFlightAccess = null;
+ }, []);
+
+ const ensureRuntimeAccess = useCallback(
+ async ({ forceRefresh = false, signal }) => {
+ if (
+ !forceRefresh &&
+ isRuntimeAccessFresh(stateRef.current.cachedAccess)
+ ) {
+ return stateRef.current.cachedAccess;
+ }
+
+ if (!forceRefresh && stateRef.current.inFlightAccess) {
+ return withAbortSignal(stateRef.current.inFlightAccess, signal);
+ }
+
+ const requestSandboxId = sandboxId;
+ const controller = new AbortController();
+ const runtimeAccessPromise = loadRuntimeAccess({
+ sandboxId: requestSandboxId,
+ signal: controller.signal,
+ })
+ .then((runtimeAccess) => {
+ if (stateRef.current.sandboxId === requestSandboxId) {
+ stateRef.current.cachedAccess = runtimeAccess;
+ }
+ return runtimeAccess;
+ })
+ .catch((error) => {
+ if (stateRef.current.sandboxId === requestSandboxId) {
+ stateRef.current.cachedAccess = null;
+ stateRef.current.inFlightAccess = null;
+ }
+ throw error;
+ })
+ .finally(() => {
+ if (
+ stateRef.current.sandboxId === requestSandboxId &&
+ stateRef.current.inFlightAccess === runtimeAccessPromise
+ ) {
+ stateRef.current.inFlightAccess = null;
+ }
+ });
+
+ stateRef.current.inFlightAccess = runtimeAccessPromise;
+ return withAbortSignal(runtimeAccessPromise, signal);
+ },
+ [loadRuntimeAccess, sandboxId],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ ensureRuntimeAccess,
+ invalidateRuntimeAccess,
+ sandboxId,
+ }),
+ [ensureRuntimeAccess, invalidateRuntimeAccess, sandboxId],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useHyperbrowserRuntime(): HyperbrowserRuntimeContextValue {
+ const value = useContext(HyperbrowserRuntimeContext);
+ if (!value) {
+ throw new Error(
+ "Hyperbrowser runtime context missing. Wrap this subtree in HyperbrowserRuntimeProvider.",
+ );
+ }
+ return value;
+}
diff --git a/src/components/hyperbrowser/HyperbrowserTerminal.tsx b/src/components/hyperbrowser/HyperbrowserTerminal.tsx
index da3389b..d23da16 100644
--- a/src/components/hyperbrowser/HyperbrowserTerminal.tsx
+++ b/src/components/hyperbrowser/HyperbrowserTerminal.tsx
@@ -3,12 +3,10 @@ import { TerminalSurface } from "../terminal/TerminalSurface";
import type { TerminalSurfaceProps } from "../terminal/types";
import {
createHyperbrowserPtyConnection,
- type HyperbrowserPtyBrowserAuthParams,
- type HyperbrowserPtyBrowserAuthResolver,
type HyperbrowserPtyConnectionOptions,
type HyperbrowserPtyStatus,
- type HyperbrowserRuntimeBrowserAuth,
} from "./hyperbrowser-pty-connection";
+import { useHyperbrowserRuntime } from "./HyperbrowserRuntimeProvider";
import {
useSandboxTerminalConnection,
getSandboxTerminalConnectionIdentity,
@@ -19,20 +17,18 @@ export type HyperbrowserTerminalProps = Omit<
TerminalSurfaceProps,
"connection"
> &
- UseSandboxTerminalConnectionOptions;
+ Omit;
export {
createHyperbrowserPtyConnection,
- type HyperbrowserPtyBrowserAuthParams,
- type HyperbrowserPtyBrowserAuthResolver,
type HyperbrowserPtyConnectionOptions,
- type HyperbrowserRuntimeBrowserAuth,
type HyperbrowserPtyStatus,
useSandboxTerminalConnection,
type UseSandboxTerminalConnectionOptions,
};
-function HyperbrowserTerminalSession(props: HyperbrowserTerminalProps) {
+export function HyperbrowserTerminal(props: HyperbrowserTerminalProps) {
+ const { ensureRuntimeAccess } = useHyperbrowserRuntime();
const {
appearance,
autoFocus,
@@ -50,10 +46,16 @@ function HyperbrowserTerminalSession(props: HyperbrowserTerminalProps) {
title,
} = props;
- const connection = useSandboxTerminalConnection(props);
+ const connectionOptions: UseSandboxTerminalConnectionOptions = {
+ ...props,
+ getRuntimeAccess: ensureRuntimeAccess,
+ };
+ const terminalKey = getSandboxTerminalConnectionIdentity(connectionOptions);
+ const connection = useSandboxTerminalConnection(connectionOptions);
return (
);
}
-
-export function HyperbrowserTerminal(props: HyperbrowserTerminalProps) {
- const terminalKey = getSandboxTerminalConnectionIdentity(props);
-
- return ;
-}
diff --git a/src/components/hyperbrowser/hyperbrowser-filesystem-adapter.ts b/src/components/hyperbrowser/hyperbrowser-filesystem-adapter.ts
index 077cdad..6992cbc 100644
--- a/src/components/hyperbrowser/hyperbrowser-filesystem-adapter.ts
+++ b/src/components/hyperbrowser/hyperbrowser-filesystem-adapter.ts
@@ -1,40 +1,19 @@
-import { inferLanguageFromPath, isTextLikeContentType } from "../filesystem/fileLanguage";
+import { inferLanguageFromPath } from "../filesystem/fileLanguage";
import { normalizeFilePath } from "../filesystem/filePath";
import type {
FileDirectoryListing,
- FileDocument,
FileEntry,
+ FilePreview,
FileWorkspaceAdapter,
} from "../filesystem/types";
-
-type HyperbrowserRuntimeTarget = {
- baseUrl: string;
- host?: string;
- transport?: string;
-};
-
-export type HyperbrowserRuntimeBrowserAuth = {
- allowedOrigin?: string;
- bootstrapUrl: string;
- bootstrapUrlExpiresAt?: string | null;
- capabilities?: string[];
- runtime: HyperbrowserRuntimeTarget;
-};
-
-export type HyperbrowserFilesystemBrowserAuthResolver = (params: {
- signal: AbortSignal;
-}) => Promise;
+import type {
+ HyperbrowserRuntimeAccess,
+ HyperbrowserRuntimeAccessResolver,
+} from "./hyperbrowser-runtime";
export type HyperbrowserFilesystemAdapterOptions = {
- apiBaseUrl?: string;
- apiCredentials?: RequestCredentials;
- apiHeaders?: HeadersInit | (() => HeadersInit | Promise);
- bootstrapUrl?: string;
- browserAuthPath?: string;
fetch?: typeof fetch;
- getRuntimeBrowserAuth?: HyperbrowserFilesystemBrowserAuthResolver;
- runtimeBaseUrl?: string;
- sandboxId?: string;
+ getRuntimeAccess: HyperbrowserRuntimeAccessResolver;
};
type HyperbrowserFileInfoWire = {
@@ -59,12 +38,21 @@ type HyperbrowserStatWireResponse = {
file: HyperbrowserFileInfoWire;
};
-type HyperbrowserReadWireResponse = {
- bytesRead: number;
- content: string;
+type HyperbrowserPreviewWirePayload = {
+ content?: string;
contentType?: string;
encoding?: string;
- truncated?: boolean;
+ expiresAt?: number;
+ kind: string;
+ name?: string;
+ path: string;
+ reason?: string;
+ size?: number;
+ url?: string;
+};
+
+type HyperbrowserPreviewWireResponse = {
+ preview: HyperbrowserPreviewWirePayload;
};
class HyperbrowserRequestError extends Error {
@@ -78,12 +66,12 @@ class HyperbrowserRequestError extends Error {
}
function resolveFetchImplementation(
- fetchImpl: typeof fetch | undefined
+ fetchImpl: typeof fetch | undefined,
): typeof fetch {
const resolved = fetchImpl ?? globalThis.fetch;
if (typeof resolved !== "function") {
throw new Error(
- "Hyperbrowser filesystem transport requires a global fetch implementation."
+ "Hyperbrowser filesystem transport requires a global fetch implementation.",
);
}
return resolved;
@@ -97,7 +85,9 @@ function resolveUrl(baseUrl: string, path: string): URL {
return new URL(path.replace(/^\/+/, ""), normalizedBaseUrl);
}
-function toQueryString(query: Record): string {
+function toQueryString(
+ query: Record,
+): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined) {
@@ -131,9 +121,88 @@ function toEntry(entry: HyperbrowserFileInfoWire): FileEntry {
};
}
+function parsePreviewKind(kind: string): FilePreview["kind"] {
+ switch (kind) {
+ case "text":
+ case "image":
+ case "audio":
+ case "video":
+ case "pdf":
+ case "binary":
+ return kind;
+ default:
+ throw new HyperbrowserRequestError(`Unsupported preview kind: ${kind}`);
+ }
+}
+
+function toPreview(
+ preview: HyperbrowserPreviewWirePayload,
+ fallbackPath: string,
+): FilePreview {
+ const normalizedPath = normalizeFilePath(preview.path || fallbackPath);
+ const kind = parsePreviewKind(preview.kind);
+
+ if (kind === "text") {
+ if (typeof preview.content !== "string") {
+ throw new HyperbrowserRequestError(
+ `Text preview for ${normalizedPath} did not include content.`,
+ );
+ }
+
+ return {
+ contentType: preview.contentType,
+ contents: preview.content,
+ encoding: preview.encoding,
+ kind,
+ language: inferLanguageFromPath(normalizedPath),
+ path: normalizedPath,
+ size: preview.size,
+ };
+ }
+
+ if (
+ kind === "image" ||
+ kind === "audio" ||
+ kind === "video" ||
+ kind === "pdf"
+ ) {
+ if (typeof preview.url !== "string" || preview.url.length === 0) {
+ throw new HyperbrowserRequestError(
+ `${kind} preview for ${normalizedPath} did not include a URL.`,
+ );
+ }
+ try {
+ new URL(preview.url);
+ } catch {
+ throw new HyperbrowserRequestError(
+ `${kind} preview for ${normalizedPath} did not include an absolute URL.`,
+ );
+ }
+
+ return {
+ contentType: preview.contentType,
+ expiresAt: preview.expiresAt,
+ kind,
+ name: preview.name,
+ path: normalizedPath,
+ size: preview.size,
+ url: preview.url,
+ };
+ }
+
+ return {
+ contentType: preview.contentType,
+ kind: "binary",
+ name: preview.name,
+ path: normalizedPath,
+ reason: preview.reason,
+ size: preview.size,
+ };
+}
+
async function readJsonResponse(
response: Response,
- fallbackMessage: string
+ fallbackMessage: string,
): Promise {
const text = await response.text();
let payload: Record = {};
@@ -146,7 +215,7 @@ async function readJsonResponse(
}
throw new HyperbrowserRequestError(
`Invalid JSON response from server. ${fallbackMessage}`,
- response.status
+ response.status,
);
}
}
@@ -161,102 +230,39 @@ async function readJsonResponse(
return payload as T;
}
-function resolveHeaders(
- input: HyperbrowserFilesystemAdapterOptions["apiHeaders"]
-): Promise {
- return Promise.resolve(typeof input === "function" ? input() : input).then(
- (value) => new Headers(value)
- );
-}
-
-async function fetchRuntimeBrowserAuth(
- options: HyperbrowserFilesystemAdapterOptions,
- signal: AbortSignal
-): Promise {
- if (options.getRuntimeBrowserAuth) {
- return options.getRuntimeBrowserAuth({ signal });
- }
-
- if (options.runtimeBaseUrl && options.bootstrapUrl) {
- return {
- bootstrapUrl: options.bootstrapUrl,
- runtime: {
- baseUrl: options.runtimeBaseUrl,
- },
- };
- }
-
- if (!options.apiBaseUrl || !options.sandboxId) {
- throw new Error(
- "Hyperbrowser filesystem transport requires either getRuntimeBrowserAuth, runtimeBaseUrl + bootstrapUrl, or apiBaseUrl + sandboxId."
- );
- }
-
- const fetchImpl = resolveFetchImplementation(options.fetch);
- const headers = await resolveHeaders(options.apiHeaders);
- const endpoint = resolveUrl(
- options.apiBaseUrl,
- options.browserAuthPath ??
- `sandbox/${encodeURIComponent(options.sandboxId)}/runtime/browser-auth`
- );
- const response = await fetchImpl(endpoint.toString(), {
- credentials: options.apiCredentials ?? "include",
- headers,
- method: "POST",
- signal,
- });
-
- return readJsonResponse(
- response,
- "Failed to issue runtime browser auth."
- );
-}
-
export function createHyperbrowserFilesystemAdapter(
- options: HyperbrowserFilesystemAdapterOptions
+ options: HyperbrowserFilesystemAdapterOptions,
): FileWorkspaceAdapter {
const fetchImpl = resolveFetchImplementation(options.fetch);
- let runtimeBaseUrlPromise: Promise | null = null;
-
- async function ensureRuntimeBaseUrl(): Promise {
- if (!runtimeBaseUrlPromise) {
- runtimeBaseUrlPromise = (async () => {
- const controller = new AbortController();
- const runtimeAuth = await fetchRuntimeBrowserAuth(options, controller.signal);
- if (!runtimeAuth.runtime?.baseUrl) {
- throw new Error(
- "Runtime browser auth response did not include a runtime base URL."
- );
- }
-
- const bootstrapResponse = await fetchImpl(runtimeAuth.bootstrapUrl, {
- credentials: "include",
- method: "GET",
+ let runtimeAccessPromise: Promise | null = null;
+
+ async function ensureRuntimeAccess(
+ forceRefresh = false,
+ ): Promise {
+ if (!runtimeAccessPromise || forceRefresh) {
+ const controller = new AbortController();
+ runtimeAccessPromise = options
+ .getRuntimeAccess({
+ forceRefresh,
signal: controller.signal,
+ })
+ .catch((error) => {
+ runtimeAccessPromise = null;
+ throw error;
});
- if (!bootstrapResponse.ok) {
- throw new HyperbrowserRequestError(
- "Failed to bootstrap runtime browser auth.",
- bootstrapResponse.status
- );
- }
- return runtimeAuth.runtime.baseUrl;
- })().catch((error) => {
- runtimeBaseUrlPromise = null;
- throw error;
- });
}
- return runtimeBaseUrlPromise;
+ return runtimeAccessPromise;
}
async function requestJson(
path: string,
init: RequestInit,
- query?: Record
+ query?: Record,
+ allowRetry = true,
): Promise {
- const runtimeBaseUrl = await ensureRuntimeBaseUrl();
- const url = resolveUrl(runtimeBaseUrl, path);
+ const runtimeAccess = await ensureRuntimeAccess();
+ const url = resolveUrl(runtimeAccess.runtimeBaseUrl, path);
if (query) {
url.search = toQueryString(query);
}
@@ -265,10 +271,14 @@ export function createHyperbrowserFilesystemAdapter(
credentials: "include",
...init,
});
+ if (!response.ok && response.status === 401 && allowRetry) {
+ await ensureRuntimeAccess(true);
+ return requestJson(path, init, query, false);
+ }
return readJsonResponse(response, `Request failed for ${path}.`);
}
- return {
+ const adapter: FileWorkspaceAdapter = {
async createDirectory(path: string): Promise {
await requestJson("/sandbox/files/mkdir", {
body: JSON.stringify({
@@ -317,7 +327,7 @@ export function createHyperbrowserFilesystemAdapter(
{
depth: 1,
path: normalizeFilePath(path),
- }
+ },
);
return {
entries: response.entries.map(toEntry),
@@ -325,48 +335,22 @@ export function createHyperbrowserFilesystemAdapter(
};
},
- async readFile(path: string): Promise {
+ async previewFile(path: string): Promise {
const normalizedPath = normalizeFilePath(path);
- const response = await requestJson(
- "/sandbox/files/read",
+ const response = await requestJson(
+ "/sandbox/files/preview",
{
body: JSON.stringify({
- encoding: "utf8",
path: normalizedPath,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
- }
+ },
);
- const isTextDocument = isTextLikeContentType(response.contentType);
- if (!isTextDocument) {
- return {
- contentType: response.contentType,
- contents: "",
- encoding: response.encoding,
- language: inferLanguageFromPath(normalizedPath),
- path: normalizedPath,
- readOnly: true,
- readOnlyReason: "Binary file preview is not available in v1.",
- truncated: Boolean(response.truncated),
- };
- }
-
- return {
- contentType: response.contentType,
- contents: response.content,
- encoding: response.encoding,
- language: inferLanguageFromPath(normalizedPath),
- path: normalizedPath,
- readOnly: Boolean(response.truncated),
- readOnlyReason: response.truncated
- ? "This file exceeded the runtime read limit and is read-only in v1."
- : undefined,
- truncated: Boolean(response.truncated),
- };
+ return toPreview(response.preview, normalizedPath);
},
async rename(path: string, nextPath: string): Promise {
@@ -390,7 +374,7 @@ export function createHyperbrowserFilesystemAdapter(
},
{
path: normalizeFilePath(path),
- }
+ },
);
return toEntry(response.file);
},
@@ -409,4 +393,6 @@ export function createHyperbrowserFilesystemAdapter(
});
},
};
+
+ return adapter;
}
diff --git a/src/components/hyperbrowser/hyperbrowser-pty-connection.ts b/src/components/hyperbrowser/hyperbrowser-pty-connection.ts
index ac9040c..c72ecbf 100644
--- a/src/components/hyperbrowser/hyperbrowser-pty-connection.ts
+++ b/src/components/hyperbrowser/hyperbrowser-pty-connection.ts
@@ -6,6 +6,10 @@ import type {
TerminalSize,
TerminalUnsubscribe,
} from "../terminal/types";
+import type {
+ HyperbrowserRuntimeAccess,
+ HyperbrowserRuntimeAccessResolver,
+} from "./hyperbrowser-runtime";
const DEFAULT_CREATE_RETRY_COUNT = 4;
const DEFAULT_CREATE_RETRY_DELAY_MS = 700;
@@ -13,20 +17,6 @@ const DEFAULT_RECONNECT_RETRY_DELAY_MS = 1000;
const DEFAULT_INPUT_BATCH_DELAY_MS = 16;
const DEFAULT_INPUT_BATCH_MAX_BYTES = 8192;
const DEFAULT_TERMINATE_CLEANUP_TIMEOUT_MS = 3000;
-const DEFAULT_HYPERBROWSER_API_BASE_URL = "https://api.hyperbrowser.ai/api";
-
-export type HyperbrowserRuntimeBrowserAuth = {
- allowedOrigin?: string;
- bootstrapUrl: string;
- bootstrapUrlExpiresAt?: string | null;
- capabilities?: string[];
-};
-
-export type HyperbrowserPtyBrowserAuthParams = {
- browserAuthEndpoint?: string;
- sandboxId?: string;
- signal: AbortSignal;
-};
export type HyperbrowserPtyStatus = {
cols: number;
@@ -43,16 +33,8 @@ export type HyperbrowserPtyStatus = {
timedOut?: boolean;
};
-export type HyperbrowserPtyBrowserAuthResolver = (
- params: HyperbrowserPtyBrowserAuthParams
-) => Promise;
-
export type HyperbrowserPtyConnectionOptions = {
- apiBaseUrl?: string;
- apiCredentials?: RequestCredentials;
- apiHeaders?: HeadersInit | (() => HeadersInit | Promise);
args?: string[];
- bootstrapUrl?: string;
closeBehavior?: "disconnect" | "terminate";
command?: string;
createRetryCount?: number;
@@ -61,20 +43,17 @@ export type HyperbrowserPtyConnectionOptions = {
env?: Record;
existingPtyId?: string;
fetch?: typeof fetch;
- getRuntimeBrowserAuth?: HyperbrowserPtyBrowserAuthResolver;
+ getRuntimeAccess: HyperbrowserRuntimeAccessResolver;
inputBatchDelayMs?: number;
inputBatchMaxBytes?: number;
killSignal?: string;
maxReconnectAttempts?: number;
reconnectRetryDelayMs?: number;
- sandboxId?: string;
timeoutMs?: number;
useShell?: boolean;
webSocketFactory?: (url: string) => WebSocket;
};
-type HyperbrowserBrowserAuthResponse = HyperbrowserRuntimeBrowserAuth;
-
type HyperbrowserPtyApiEnvelope = {
pty: HyperbrowserPtyStatus;
success?: boolean;
@@ -96,11 +75,6 @@ type HyperbrowserPtyServerEvent =
| HyperbrowserPtyOutputEvent
| HyperbrowserPtyExitEvent;
-type HyperbrowserResolvedRuntimeAccess = {
- bootstrapUrl: string;
- runtimeBaseUrl: string;
-};
-
type HyperbrowserPtyCloseBehavior = NonNullable<
HyperbrowserPtyConnectionOptions["closeBehavior"]
>;
@@ -122,36 +96,32 @@ class HyperbrowserRequestError extends Error {
}
function resolveFetchImplementation(
- fetchImpl: typeof fetch | undefined
+ fetchImpl: typeof fetch | undefined,
): typeof fetch {
const resolved = fetchImpl ?? globalThis.fetch;
if (typeof resolved !== "function") {
- throw new Error("Hyperbrowser PTY transport requires a global fetch implementation.");
+ throw new Error(
+ "Hyperbrowser PTY transport requires a global fetch implementation.",
+ );
}
return ((input: RequestInfo | URL, init?: RequestInit) =>
Reflect.apply(resolved, globalThis, [input, init])) as typeof fetch;
}
function resolveWebSocketFactory(
- webSocketFactory: HyperbrowserPtyConnectionOptions["webSocketFactory"]
+ webSocketFactory: HyperbrowserPtyConnectionOptions["webSocketFactory"],
): (url: string) => WebSocket {
if (webSocketFactory) {
return webSocketFactory;
}
if (typeof WebSocket !== "function") {
- throw new Error("Hyperbrowser PTY transport requires a global WebSocket implementation.");
+ throw new Error(
+ "Hyperbrowser PTY transport requires a global WebSocket implementation.",
+ );
}
return (url) => new WebSocket(url);
}
-function resolveHeaders(
- input: HyperbrowserPtyConnectionOptions["apiHeaders"]
-): Promise {
- return Promise.resolve(typeof input === "function" ? input() : input).then(
- (value) => new Headers(value)
- );
-}
-
function resolveUrl(baseUrl: string, path: string): URL {
const normalizedBaseUrl = new URL(baseUrl);
if (!normalizedBaseUrl.pathname.endsWith("/")) {
@@ -160,47 +130,11 @@ function resolveUrl(baseUrl: string, path: string): URL {
return new URL(path.replace(/^\/+/, ""), normalizedBaseUrl);
}
-function resolveAbsoluteUrl(input: string): URL {
- try {
- return new URL(input);
- } catch {
- if (typeof window !== "undefined" && window.location) {
- return new URL(input, window.location.href);
- }
-
- throw new Error("Hyperbrowser PTY bootstrap URL must be absolute outside the browser.");
- }
-}
-
-function deriveRuntimeBaseUrl(bootstrapUrl: string): string {
- return resolveAbsoluteUrl(bootstrapUrl).origin;
-}
-
-function resolveBrowserAuthEndpoint(
- options: HyperbrowserPtyConnectionOptions
-): string | undefined {
- if (!options.sandboxId) {
- return undefined;
- }
-
- if (options.getRuntimeBrowserAuth) {
- return resolveUrl(
- options.apiBaseUrl ?? DEFAULT_HYPERBROWSER_API_BASE_URL,
- `sandbox/${encodeURIComponent(options.sandboxId)}/runtime/browser-auth`
- ).toString();
- }
-
- if (!options.apiBaseUrl) {
- return undefined;
- }
-
- return resolveUrl(
- options.apiBaseUrl,
- `sandbox/${encodeURIComponent(options.sandboxId)}/runtime/browser-auth`
- ).toString();
-}
-
-function toWebSocketUrl(baseUrl: string, path: string, query?: URLSearchParams): string {
+function toWebSocketUrl(
+ baseUrl: string,
+ path: string,
+ query?: URLSearchParams,
+): string {
const url = resolveUrl(baseUrl, path);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.search = query?.toString() ?? "";
@@ -251,8 +185,7 @@ function shouldRetryInitialCreate(error: unknown): boolean {
function toTerminalExitEvent(status: HyperbrowserPtyStatus): TerminalExitEvent {
return {
error: status.error || undefined,
- exitCode:
- typeof status.exitCode === "number" ? status.exitCode : undefined,
+ exitCode: typeof status.exitCode === "number" ? status.exitCode : undefined,
};
}
@@ -267,7 +200,10 @@ function encodeBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
- const chunk = bytes.subarray(index, Math.min(index + chunkSize, bytes.length));
+ const chunk = bytes.subarray(
+ index,
+ Math.min(index + chunkSize, bytes.length),
+ );
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
@@ -284,7 +220,7 @@ function decodeBase64(value: string): Uint8Array {
async function readJsonResponse(
response: Response,
- fallbackMessage: string
+ fallbackMessage: string,
): Promise {
const text = await response.text();
let payload: Record = {};
@@ -297,7 +233,7 @@ async function readJsonResponse(
}
throw new HyperbrowserRequestError(
`Invalid JSON response from server. ${fallbackMessage}`,
- response.status
+ response.status,
);
}
}
@@ -311,49 +247,6 @@ async function readJsonResponse(
return payload as T;
}
-async function fetchRuntimeBrowserAuth(
- options: HyperbrowserPtyConnectionOptions,
- signal: AbortSignal,
- keepalive = false
-): Promise {
- const browserAuthEndpoint = resolveBrowserAuthEndpoint(options);
-
- if (options.getRuntimeBrowserAuth) {
- return options.getRuntimeBrowserAuth({
- browserAuthEndpoint,
- sandboxId: options.sandboxId,
- signal,
- });
- }
-
- if (options.bootstrapUrl) {
- return {
- bootstrapUrl: options.bootstrapUrl,
- };
- }
-
- if (!browserAuthEndpoint) {
- throw new Error(
- "Hyperbrowser PTY transport requires either getRuntimeBrowserAuth, bootstrapUrl, or apiBaseUrl + sandboxId."
- );
- }
-
- const fetchImpl = resolveFetchImplementation(options.fetch);
- const headers = await resolveHeaders(options.apiHeaders);
- const response = await fetchImpl(browserAuthEndpoint, {
- credentials: options.apiCredentials ?? "include",
- headers,
- keepalive,
- method: "POST",
- signal,
- });
-
- return readJsonResponse(
- response,
- "Failed to issue runtime browser auth."
- );
-}
-
class HyperbrowserPtySession implements TerminalSession {
private readonly abortSignal: AbortSignal;
private readonly closeBehavior: HyperbrowserPtyCloseBehavior;
@@ -366,7 +259,9 @@ class HyperbrowserPtySession implements TerminalSession {
private readonly inputBatchMaxBytes: number;
private readonly options: HyperbrowserPtyConnectionOptions;
private readonly outputListeners = new Set<(data: Uint8Array) => void>();
- private readonly exitListeners = new Set<(event: TerminalExitEvent) => void>();
+ private readonly exitListeners = new Set<
+ (event: TerminalExitEvent) => void
+ >();
private readonly reconnectRetryDelayMs: number;
private readonly webSocketFactory: (url: string) => WebSocket;
private explicitClose = false;
@@ -378,18 +273,19 @@ class HyperbrowserPtySession implements TerminalSession {
private ptyId: string | null;
private reconnectAttempts = 0;
private reconnectTimerId: number | null = null;
- private runtimeAccess: HyperbrowserResolvedRuntimeAccess | null = null;
+ private runtimeAccess: HyperbrowserRuntimeAccess | null = null;
private size: TerminalSize;
private socket: WebSocket | null = null;
private terminated = false;
constructor(
options: HyperbrowserPtyConnectionOptions,
- connectParams: TerminalConnectParams
+ connectParams: TerminalConnectParams,
) {
this.abortSignal = connectParams.signal;
this.closeBehavior = options.closeBehavior ?? "disconnect";
- this.createRetryCount = options.createRetryCount ?? DEFAULT_CREATE_RETRY_COUNT;
+ this.createRetryCount =
+ options.createRetryCount ?? DEFAULT_CREATE_RETRY_COUNT;
this.createRetryDelayMs =
options.createRetryDelayMs ?? DEFAULT_CREATE_RETRY_DELAY_MS;
this.fetchImpl = resolveFetchImplementation(options.fetch);
@@ -410,7 +306,7 @@ class HyperbrowserPtySession implements TerminalSession {
if (!this.ptyId) {
await this.createPtyWithRetry();
} else {
- await this.bootstrapRuntimeAuth();
+ await this.ensureRuntimeAccess();
}
await this.openSocket(this.lastSeq);
@@ -469,7 +365,7 @@ class HyperbrowserPtySession implements TerminalSession {
cols: size.cols,
rows: size.rows,
type: "resize",
- })
+ }),
);
}
@@ -541,7 +437,7 @@ class HyperbrowserPtySession implements TerminalSession {
while (true) {
try {
- await this.bootstrapRuntimeAuth();
+ await this.ensureRuntimeAccess();
const response = await this.runtimeFetch(
resolveUrl(this.getRuntimeBaseUrl(), "sandbox/pty"),
{
@@ -559,7 +455,7 @@ class HyperbrowserPtySession implements TerminalSession {
"Content-Type": "application/json",
},
method: "POST",
- }
+ },
);
this.ptyId = response.pty.id;
this.createdPtyDuringStart = true;
@@ -578,7 +474,9 @@ class HyperbrowserPtySession implements TerminalSession {
}
}
- private async killPty(requestOptions: RuntimeRequestOptions = {}): Promise {
+ private async killPty(
+ requestOptions: RuntimeRequestOptions = {},
+ ): Promise {
if (!this.ptyId) {
return;
}
@@ -586,7 +484,7 @@ class HyperbrowserPtySession implements TerminalSession {
await this.runtimeFetch(
resolveUrl(
this.getRuntimeBaseUrl(),
- `sandbox/pty/${encodeURIComponent(this.ptyId)}/kill`
+ `sandbox/pty/${encodeURIComponent(this.ptyId)}/kill`,
),
{
body: JSON.stringify({
@@ -597,7 +495,7 @@ class HyperbrowserPtySession implements TerminalSession {
},
method: "POST",
},
- requestOptions
+ requestOptions,
);
}
@@ -628,7 +526,7 @@ class HyperbrowserPtySession implements TerminalSession {
data: encodeBase64(combined),
encoding: "base64",
type: "input",
- })
+ }),
);
}
@@ -645,7 +543,9 @@ class HyperbrowserPtySession implements TerminalSession {
payload = JSON.parse(event.data) as HyperbrowserPtyServerEvent;
} catch (error) {
this.failSession(
- error instanceof Error ? error : new Error("Invalid PTY websocket payload.")
+ error instanceof Error
+ ? error
+ : new Error("Invalid PTY websocket payload."),
);
return;
}
@@ -692,11 +592,11 @@ class HyperbrowserPtySession implements TerminalSession {
const response = await this.runtimeFetch(
resolveUrl(
this.getRuntimeBaseUrl(),
- `sandbox/pty/${encodeURIComponent(this.ptyId)}`
+ `sandbox/pty/${encodeURIComponent(this.ptyId)}`,
),
{
method: "GET",
- }
+ },
);
return response.pty;
@@ -720,14 +620,14 @@ class HyperbrowserPtySession implements TerminalSession {
query.set("cursor", String(cursor));
}
+ await this.ensureRuntimeAccess();
+
const url = toWebSocketUrl(
this.getRuntimeBaseUrl(),
`sandbox/pty/${encodeURIComponent(this.ptyId)}/ws`,
- query
+ query,
);
- await this.bootstrapRuntimeAuth();
-
await new Promise((resolve, reject) => {
const socket = this.webSocketFactory(url);
let settled = false;
@@ -784,7 +684,9 @@ class HyperbrowserPtySession implements TerminalSession {
socket.addEventListener("close", () => {
if (!settled) {
- rejectOnce(new Error("PTY websocket connection closed before it opened."));
+ rejectOnce(
+ new Error("PTY websocket connection closed before it opened."),
+ );
return;
}
this.handleSocketClosed();
@@ -799,31 +701,21 @@ class HyperbrowserPtySession implements TerminalSession {
});
}
- private async bootstrapRuntimeAuth(
+ private async ensureRuntimeAccess(
signal: AbortSignal = this.abortSignal,
- keepalive = false
+ forceRefresh = false,
): Promise {
- const runtimeAuth = await fetchRuntimeBrowserAuth(this.options, signal, keepalive);
- const runtimeBaseUrl = deriveRuntimeBaseUrl(runtimeAuth.bootstrapUrl);
-
- this.runtimeAccess = {
- bootstrapUrl: runtimeAuth.bootstrapUrl,
- runtimeBaseUrl,
- };
-
- const response = await this.fetchImpl(runtimeAuth.bootstrapUrl, {
- credentials: "include",
- keepalive,
- method: "GET",
+ const runtimeAccess = await this.options.getRuntimeAccess({
+ forceRefresh,
signal,
});
- await readJsonResponse>(response, "Runtime auth bootstrap failed.");
+ this.runtimeAccess = runtimeAccess;
}
private async runtimeFetch>(
url: URL,
init: RequestInit,
- requestOptions: RuntimeRequestOptions = {}
+ requestOptions: RuntimeRequestOptions = {},
): Promise {
const {
allowRetry = true,
@@ -838,7 +730,7 @@ class HyperbrowserPtySession implements TerminalSession {
});
if (!response.ok && response.status === 401 && allowRetry) {
- await this.bootstrapRuntimeAuth(signal, keepalive);
+ await this.ensureRuntimeAccess(signal, true);
return this.runtimeFetch(url, init, {
allowRetry: false,
keepalive,
@@ -856,7 +748,7 @@ class HyperbrowserPtySession implements TerminalSession {
}
if (!this.runtimeAccess) {
- await this.bootstrapRuntimeAuth(signal, true);
+ await this.ensureRuntimeAccess(signal, true);
}
await this.killPty({
@@ -876,11 +768,13 @@ class HyperbrowserPtySession implements TerminalSession {
}
private shouldTerminateOnClose(): boolean {
- return this.closeBehavior === "terminate" && !!this.ptyId && !this.hasEmittedExit;
+ return (
+ this.closeBehavior === "terminate" && !!this.ptyId && !this.hasEmittedExit
+ );
}
private async withTerminateCleanupSignal(
- callback: (signal: AbortSignal) => Promise
+ callback: (signal: AbortSignal) => Promise,
): Promise {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => {
@@ -895,7 +789,11 @@ class HyperbrowserPtySession implements TerminalSession {
}
private scheduleReconnect(): void {
- if (this.reconnectTimerId !== null || this.terminated || this.hasEmittedExit) {
+ if (
+ this.reconnectTimerId !== null ||
+ this.terminated ||
+ this.hasEmittedExit
+ ) {
return;
}
@@ -921,7 +819,7 @@ class HyperbrowserPtySession implements TerminalSession {
this.reconnectAttempts += 1;
try {
- await this.bootstrapRuntimeAuth();
+ await this.ensureRuntimeAccess();
const status = await this.fetchPtyStatus();
if (!status.running) {
this.emitExit(toTerminalExitEvent(status));
@@ -943,7 +841,7 @@ class HyperbrowserPtySession implements TerminalSession {
}
function normalizeConnectionOptions(
- options: HyperbrowserPtyConnectionOptions
+ options: HyperbrowserPtyConnectionOptions,
): HyperbrowserPtyConnectionOptions {
return {
...options,
@@ -953,13 +851,18 @@ function normalizeConnectionOptions(
}
export function createHyperbrowserPtyConnection(
- options: HyperbrowserPtyConnectionOptions
+ options: HyperbrowserPtyConnectionOptions,
): TerminalConnection {
const normalizedOptions = normalizeConnectionOptions(options);
return {
- async connect(connectParams: TerminalConnectParams): Promise {
- const session = new HyperbrowserPtySession(normalizedOptions, connectParams);
+ async connect(
+ connectParams: TerminalConnectParams,
+ ): Promise {
+ const session = new HyperbrowserPtySession(
+ normalizedOptions,
+ connectParams,
+ );
await session.start();
return session;
},
diff --git a/src/components/hyperbrowser/hyperbrowser-runtime.ts b/src/components/hyperbrowser/hyperbrowser-runtime.ts
new file mode 100644
index 0000000..80f9a44
--- /dev/null
+++ b/src/components/hyperbrowser/hyperbrowser-runtime.ts
@@ -0,0 +1,22 @@
+export type HyperbrowserRuntimeAccess = {
+ expiresAt?: string | null;
+ runtimeBaseUrl: string;
+};
+
+export type HyperbrowserRuntimeAccessParams = {
+ forceRefresh?: boolean;
+ signal: AbortSignal;
+};
+
+export type HyperbrowserRuntimeAccessResolver = (
+ params: HyperbrowserRuntimeAccessParams,
+) => Promise;
+
+export type HyperbrowserRuntimeLoaderParams = {
+ sandboxId: string;
+ signal: AbortSignal;
+};
+
+export type HyperbrowserRuntimeLoader = (
+ params: HyperbrowserRuntimeLoaderParams,
+) => Promise;
diff --git a/src/components/hyperbrowser/useSandboxTerminalConnection.ts b/src/components/hyperbrowser/useSandboxTerminalConnection.ts
index 9eac4d2..113d2ff 100644
--- a/src/components/hyperbrowser/useSandboxTerminalConnection.ts
+++ b/src/components/hyperbrowser/useSandboxTerminalConnection.ts
@@ -3,13 +3,10 @@ import type { TerminalConnection } from "../terminal/types";
import {
createHyperbrowserPtyConnection,
type HyperbrowserPtyConnectionOptions,
- type HyperbrowserRuntimeBrowserAuth,
} from "./hyperbrowser-pty-connection";
export type UseSandboxTerminalConnectionOptions =
- HyperbrowserPtyConnectionOptions & {
- browserAuth?: HyperbrowserRuntimeBrowserAuth;
- };
+ HyperbrowserPtyConnectionOptions;
const DEFAULT_CLOSE_BEHAVIOR = "disconnect";
const DEFAULT_COMMAND = "bash";
@@ -18,7 +15,6 @@ const DEFAULT_CREATE_RETRY_DELAY_MS = 700;
const DEFAULT_INPUT_BATCH_DELAY_MS = 16;
const DEFAULT_INPUT_BATCH_MAX_BYTES = 8192;
const DEFAULT_RECONNECT_RETRY_DELAY_MS = 1000;
-const DEFAULT_HYPERBROWSER_API_BASE_URL = "https://api.hyperbrowser.ai/api";
const functionIdentityMap = new WeakMap();
let nextFunctionIdentity = 1;
@@ -61,95 +57,11 @@ function serializeStringRecord(
);
}
-function serializeHeaders(
- value: HyperbrowserPtyConnectionOptions["apiHeaders"],
-): string {
- if (!value) {
- return "";
- }
- if (typeof value === "function") {
- return getFunctionIdentity(value);
- }
-
- return JSON.stringify(
- Array.from(new Headers(value).entries()).sort(
- ([leftKey, leftValue], [rightKey, rightValue]) => {
- if (leftKey === rightKey) {
- return leftValue.localeCompare(rightValue);
- }
- return leftKey.localeCompare(rightKey);
- },
- ),
- );
-}
-
-function resolveBootstrapUrl(
- options: UseSandboxTerminalConnectionOptions,
-): string | undefined {
- if (options.bootstrapUrl) {
- return options.bootstrapUrl;
- }
- if (options.getRuntimeBrowserAuth) {
- return undefined;
- }
- return options.browserAuth?.bootstrapUrl;
-}
-
-function resolveBrowserAuthEndpoint(
- options: UseSandboxTerminalConnectionOptions,
-): string | undefined {
- if (!options.sandboxId) {
- return undefined;
- }
-
- if (options.getRuntimeBrowserAuth) {
- return new URL(
- `sandbox/${encodeURIComponent(options.sandboxId)}/runtime/browser-auth`,
- `${options.apiBaseUrl ?? DEFAULT_HYPERBROWSER_API_BASE_URL}/`,
- ).toString();
- }
-
- if (!options.apiBaseUrl) {
- return undefined;
- }
-
- return new URL(
- `sandbox/${encodeURIComponent(options.sandboxId)}/runtime/browser-auth`,
- options.apiBaseUrl.endsWith("/")
- ? options.apiBaseUrl
- : `${options.apiBaseUrl}/`,
- ).toString();
-}
-
-function getAuthSourceIdentity(
- options: UseSandboxTerminalConnectionOptions,
-): string {
- if (options.getRuntimeBrowserAuth) {
- return [
- "resolver",
- getFunctionIdentity(options.getRuntimeBrowserAuth),
- resolveBrowserAuthEndpoint(options) ?? "",
- ].join("|");
- }
-
- const bootstrapUrl = resolveBootstrapUrl(options);
- if (bootstrapUrl) {
- return ["direct", bootstrapUrl].join("|");
- }
-
- return [
- "api",
- resolveBrowserAuthEndpoint(options) ?? "",
- options.apiCredentials ?? "",
- serializeHeaders(options.apiHeaders),
- ].join("|");
-}
-
export function getSandboxTerminalConnectionIdentity(
options: UseSandboxTerminalConnectionOptions,
): string {
const identityParts = [
- getAuthSourceIdentity(options),
+ getFunctionIdentity(options.getRuntimeAccess),
options.existingPtyId ?? "",
options.closeBehavior ?? DEFAULT_CLOSE_BEHAVIOR,
options.killSignal ?? "",
@@ -179,17 +91,6 @@ export function getSandboxTerminalConnectionIdentity(
return identityParts.join("|");
}
-function toPtyConnectionOptions(
- options: UseSandboxTerminalConnectionOptions,
-): HyperbrowserPtyConnectionOptions {
- const { browserAuth, ...connectionOptions } = options;
-
- return {
- ...connectionOptions,
- bootstrapUrl: resolveBootstrapUrl(options),
- };
-}
-
export function useSandboxTerminalConnection(
options: UseSandboxTerminalConnectionOptions,
): TerminalConnection {
@@ -201,9 +102,7 @@ export function useSandboxTerminalConnection(
if (!recordRef.current || recordRef.current.identity !== identity) {
recordRef.current = {
- connection: createHyperbrowserPtyConnection(
- toPtyConnectionOptions(options),
- ),
+ connection: createHyperbrowserPtyConnection(options),
identity,
};
}
diff --git a/src/index.ts b/src/index.ts
index cef6fed..065615e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,15 +11,17 @@ export {
HyperbrowserTerminal,
createHyperbrowserPtyConnection,
useSandboxTerminalConnection,
- type HyperbrowserPtyBrowserAuthParams,
- type HyperbrowserPtyBrowserAuthResolver,
type HyperbrowserPtyConnectionOptions,
type HyperbrowserPtyStatus,
- type HyperbrowserRuntimeBrowserAuth,
type HyperbrowserTerminalProps,
type UseSandboxTerminalConnectionOptions,
} from "./components/hyperbrowser/HyperbrowserTerminal";
+export {
+ HyperbrowserRuntimeProvider,
+ useHyperbrowserRuntime,
+} from "./components/hyperbrowser/HyperbrowserRuntimeProvider";
+
export {
useHyperbrowserHlsPlayback,
type HyperbrowserVideoSourceType,
@@ -27,6 +29,25 @@ export {
type UseHyperbrowserHlsPlaybackResult,
} from "./hooks/useHyperbrowserHlsPlayback";
+export { FileWorkspace } from "./components/filesystem/FileWorkspace";
+
+export {
+ createFileWorkspaceTheme,
+ defaultFileWorkspaceAppearance,
+ defaultFileWorkspacePreset,
+ defaultFileWorkspaceTheme,
+ fileWorkspacePresets,
+ fileWorkspaceThemePresets,
+ resolveFileWorkspaceTheme,
+} from "./components/filesystem/fileWorkspaceThemes";
+
+export {
+ HyperbrowserFileWorkspace,
+ createHyperbrowserFilesystemAdapter,
+ type HyperbrowserFileWorkspaceProps,
+ type HyperbrowserFilesystemAdapterOptions,
+} from "./components/hyperbrowser/HyperbrowserFileWorkspace";
+
export { BaseTerminal } from "./components/terminal/BaseTerminal";
export { TerminalSurface } from "./components/terminal/TerminalSurface";
export { useTerminal } from "./components/terminal/useTerminal";
@@ -41,6 +62,33 @@ export {
terminalPresets,
} from "./components/terminal/terminalThemes";
+export type {
+ FileWorkspaceAppearance,
+ FileDirectoryListing,
+ FileEntry,
+ FileEntryType,
+ FilePreview,
+ FilePreviewKind,
+ FileWorkspacePreset,
+ FileWorkspacePresetName,
+ FileWorkspaceAdapter,
+ FileWorkspaceChromeTheme,
+ FileWorkspaceEditorTheme,
+ FileWorkspaceProps,
+ FileWorkspaceSurfaceTheme,
+ FileWorkspaceTheme,
+ FileWorkspaceThemeName,
+ ResolvedFileWorkspaceTheme,
+} from "./components/filesystem/types";
+
+export type {
+ HyperbrowserRuntimeAccess,
+ HyperbrowserRuntimeAccessParams,
+ HyperbrowserRuntimeAccessResolver,
+ HyperbrowserRuntimeLoader,
+ HyperbrowserRuntimeLoaderParams,
+} from "./components/hyperbrowser/hyperbrowser-runtime";
+
export type {
ResolvedTerminalTheme,
TerminalAppearance,
diff --git a/src/styles/filesystem.css b/src/styles/filesystem.css
index 117327d..2d8ee95 100644
--- a/src/styles/filesystem.css
+++ b/src/styles/filesystem.css
@@ -1,54 +1,89 @@
.hb-filesystem {
- --hb-filesystem-accent: #1267d6;
- --hb-filesystem-background: #eef3f7;
- --hb-filesystem-border: #d7e0ea;
+ --hb-filesystem-accent: #2563eb;
+ --hb-filesystem-accent-border: color-mix(
+ in srgb,
+ var(--hb-filesystem-accent) 24%,
+ var(--hb-filesystem-border)
+ );
+ --hb-filesystem-accent-glow: color-mix(
+ in srgb,
+ var(--hb-filesystem-accent) 7%,
+ transparent
+ );
+ --hb-filesystem-accent-ring: color-mix(
+ in srgb,
+ var(--hb-filesystem-accent) 18%,
+ transparent
+ );
+ --hb-filesystem-accent-soft: color-mix(
+ in srgb,
+ var(--hb-filesystem-accent) 10%,
+ transparent
+ );
+ --hb-filesystem-background: #f8fafc;
+ --hb-filesystem-border: #e2e8f0;
--hb-filesystem-danger: #b42318;
- --hb-filesystem-divider: #d7e0ea;
- --hb-filesystem-editor-background: #fbfdff;
- --hb-filesystem-panel: #ffffff;
- --hb-filesystem-panel-muted: #f5f8fb;
- --hb-filesystem-row-active: #dfeeff;
- --hb-filesystem-row-hover: #eef5ff;
- --hb-filesystem-shadow: 0 24px 48px rgba(15, 23, 42, 0.08);
- --hb-filesystem-tab-active: #ffffff;
- --hb-filesystem-tab-inactive: #edf3f8;
- --hb-filesystem-text: #10243b;
- --hb-filesystem-text-muted: #62748a;
+ --hb-filesystem-divider: #e2e8f0;
+ --hb-filesystem-editor-background: #ffffff;
+ --hb-filesystem-gutter-text: color-mix(
+ in srgb,
+ var(--hb-filesystem-text-muted) 68%,
+ var(--hb-filesystem-divider) 32%
+ );
+ --hb-filesystem-icon-muted: color-mix(
+ in srgb,
+ var(--hb-filesystem-text-muted) 92%,
+ transparent
+ );
+ --hb-filesystem-panel: #fcfdff;
+ --hb-filesystem-panel-muted: #f8fafc;
+ --hb-filesystem-row-active: #eaf2ff;
+ --hb-filesystem-row-hover: #f3f7ff;
+ --hb-filesystem-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
+ --hb-filesystem-soft-shadow: var(--hb-filesystem-shadow);
+ --hb-filesystem-strong-shadow: var(--hb-filesystem-shadow);
+ --hb-filesystem-surface-elevated: color-mix(
+ in srgb,
+ var(--hb-filesystem-panel) 92%,
+ var(--hb-filesystem-background) 8%
+ );
+ --hb-filesystem-surface-muted: color-mix(
+ in srgb,
+ var(--hb-filesystem-panel-muted) 55%,
+ var(--hb-filesystem-panel) 45%
+ );
+ --hb-filesystem-text: #0f172a;
+ --hb-filesystem-text-muted: #64748b;
+ --hb-filesystem-warning-soft: color-mix(
+ in srgb,
+ var(--hb-filesystem-warning) 10%,
+ transparent
+ );
--hb-filesystem-warning: #b54708;
- background:
- radial-gradient(circle at top left, rgba(18, 103, 214, 0.14), transparent 34%),
- linear-gradient(180deg, var(--hb-filesystem-background) 0%, #f7fafc 100%);
+ background: linear-gradient(
+ 180deg,
+ var(--hb-filesystem-background) 0%,
+ var(--hb-filesystem-panel) 100%
+ );
border: 1px solid var(--hb-filesystem-border);
- border-radius: 24px;
+ border-radius: 16px;
box-shadow: var(--hb-filesystem-shadow);
color: var(--hb-filesystem-text);
display: grid;
gap: 0;
- min-height: 720px;
+ grid-template-rows: minmax(0, 1fr);
+ min-height: 680px;
overflow: hidden;
- position: relative;
-}
-
-.hb-filesystem__glow {
- background: radial-gradient(circle, rgba(18, 103, 214, 0.18) 0%, transparent 72%);
- inset: -10% auto auto -10%;
- pointer-events: none;
- position: absolute;
- width: 320px;
- height: 320px;
}
.hb-filesystem__header,
.hb-filesystem__footer {
align-items: center;
- background: rgba(255, 255, 255, 0.8);
- backdrop-filter: blur(12px);
+ background: var(--hb-filesystem-surface-elevated);
display: flex;
gap: 1rem;
justify-content: space-between;
- padding: 1rem 1.25rem;
- position: relative;
- z-index: 1;
+ padding: 0.9rem 1rem;
}
.hb-filesystem__header {
@@ -58,18 +93,18 @@
.hb-filesystem__footer {
border-top: 1px solid var(--hb-filesystem-divider);
color: var(--hb-filesystem-text-muted);
- font-size: 0.92rem;
+ font-size: 0.86rem;
}
.hb-filesystem__titleBlock {
display: grid;
- gap: 0.18rem;
+ gap: 0.15rem;
}
.hb-filesystem__eyebrow,
-.hb-filesystem__sidebarEyebrow {
+.hb-filesystem__previewEyebrow {
color: var(--hb-filesystem-accent);
- font-size: 0.74rem;
+ font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
margin: 0;
@@ -77,20 +112,17 @@
}
.hb-filesystem__title {
- font-size: 1.15rem;
+ font-size: 1rem;
+ font-weight: 650;
margin: 0;
}
-.hb-filesystem__headerActions,
-.hb-filesystem__sidebarActions,
-.hb-filesystem__inlineFormActions {
+.hb-filesystem__headerActions {
display: flex;
- flex-wrap: wrap;
gap: 0.5rem;
}
.hb-filesystem__actionButton,
-.hb-filesystem__miniButton,
.hb-filesystem__tabButton,
.hb-filesystem__tabClose,
.hb-filesystem__treeToggle,
@@ -101,127 +133,337 @@
font: inherit;
}
-.hb-filesystem__actionButton,
-.hb-filesystem__miniButton {
+.hb-filesystem__actionButton {
+ align-items: center;
background: var(--hb-filesystem-panel);
border: 1px solid var(--hb-filesystem-border);
- border-radius: 999px;
+ border-radius: 9px;
cursor: pointer;
- padding: 0.58rem 0.95rem;
- transition: transform 140ms ease, background-color 140ms ease;
+ display: inline-flex;
+ gap: 0.45rem;
+ padding: 0.5rem 0.72rem;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ color 140ms ease;
}
-.hb-filesystem__actionButton:hover,
-.hb-filesystem__miniButton:hover {
+.hb-filesystem__actionButton:hover {
background: var(--hb-filesystem-row-hover);
- transform: translateY(-1px);
+ border-color: var(--hb-filesystem-accent-border);
}
-.hb-filesystem__miniButton[data-tone="danger"] {
- color: var(--hb-filesystem-danger);
-}
-
-.hb-filesystem__actionButton:disabled,
-.hb-filesystem__miniButton:disabled {
- cursor: not-allowed;
- opacity: 0.5;
- transform: none;
+.hb-filesystem__actionButton svg,
+.hb-filesystem__tabClose svg {
+ display: block;
+ height: 0.95rem;
+ width: 0.95rem;
}
.hb-filesystem__body {
display: grid;
gap: 0;
- grid-template-columns: minmax(280px, 340px) 1fr;
+ grid-template-columns: minmax(250px, 290px) 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ height: 100%;
min-height: 0;
- position: relative;
- z-index: 1;
+ overflow: hidden;
+}
+
+.hb-filesystem__bodyHeader {
+ align-items: center;
+ background: var(--hb-filesystem-surface-elevated);
+ border-bottom: 1px solid var(--hb-filesystem-divider);
+ display: flex;
+ min-height: 2.75rem;
+}
+
+.hb-filesystem__bodyHeader--sidebar {
+ border-right: 1px solid var(--hb-filesystem-divider);
+ grid-column: 1;
+ grid-row: 1;
+ padding: 0.35rem 0.8rem;
+}
+
+.hb-filesystem__bodyHeader--workspace {
+ gap: 0.6rem;
+ grid-column: 2;
+ grid-row: 1;
+ padding: 0.35rem 0.8rem;
}
.hb-filesystem__sidebar {
- background: linear-gradient(180deg, var(--hb-filesystem-panel) 0%, var(--hb-filesystem-panel-muted) 100%);
+ background: linear-gradient(
+ 180deg,
+ var(--hb-filesystem-panel) 0%,
+ var(--hb-filesystem-panel-muted) 100%
+ );
border-right: 1px solid var(--hb-filesystem-divider);
display: grid;
- gap: 0.9rem;
+ grid-column: 1;
+ grid-row: 2;
min-height: 0;
- padding: 1rem;
+ overflow: hidden;
}
-.hb-filesystem__sidebarHeader {
- display: grid;
- gap: 0.8rem;
+.hb-filesystem__workspaceSwitcher {
+ flex: 1 1 auto;
+ min-width: 0;
+ position: relative;
}
-.hb-filesystem__sidebarPath {
- color: var(--hb-filesystem-text-muted);
- font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
- font-size: 0.82rem;
- margin: 0.15rem 0 0;
- overflow-wrap: anywhere;
+.hb-filesystem__workspaceTrigger {
+ align-items: center;
+ background: var(--hb-filesystem-panel);
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: var(--hb-filesystem-text);
+ cursor: pointer;
+ display: inline-flex;
+ gap: 0.45rem;
+ justify-content: flex-start;
+ min-width: 0;
+ padding: 0.32rem 0.5rem;
+ text-align: left;
+ width: 100%;
+}
+
+.hb-filesystem__workspaceTrigger:hover,
+.hb-filesystem__workspaceTrigger:focus-visible {
+ background: var(--hb-filesystem-row-hover);
+ border-color: var(--hb-filesystem-accent-border);
+ outline: none;
+}
+
+.hb-filesystem__workspaceTriggerIcon,
+.hb-filesystem__workspaceOptionIcon {
+ color: var(--hb-filesystem-icon-muted);
+ display: inline-flex;
+ flex: 0 0 auto;
+}
+
+.hb-filesystem__workspaceTriggerIcon svg,
+.hb-filesystem__workspaceOptionIcon svg {
+ display: block;
+ height: 0.9rem;
+ width: 0.9rem;
+}
+
+.hb-filesystem__workspaceTriggerText {
+ flex: 1 1 auto;
+ font-size: 0.88rem;
+ font-weight: 600;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hb-filesystem__workspaceTriggerChevron {
+ color: var(--hb-filesystem-icon-muted);
+ display: block;
+ flex: 0 0 auto;
+ height: 0.85rem;
+ transition: transform 140ms ease;
+ width: 0.85rem;
+}
+
+.hb-filesystem__workspaceTriggerChevron[data-open="true"] {
+ transform: rotate(180deg);
}
-.hb-filesystem__inlineForm {
- background: rgba(255, 255, 255, 0.8);
+.hb-filesystem__workspaceMenu {
+ background: var(--hb-filesystem-surface-elevated);
border: 1px solid var(--hb-filesystem-border);
- border-radius: 16px;
+ border-radius: 12px;
+ box-shadow: var(--hb-filesystem-soft-shadow);
display: grid;
- gap: 0.75rem;
- padding: 0.85rem;
+ gap: 0.5rem;
+ left: 0;
+ padding: 0.55rem;
+ position: absolute;
+ top: calc(100% + 0.35rem);
+ width: min(28rem, calc(100vw - 2rem));
+ z-index: 30;
}
-.hb-filesystem__inlineFormLabel {
+.hb-filesystem__workspaceMenuHeader {
+ align-items: center;
+ display: flex;
+ gap: 0.6rem;
+ justify-content: space-between;
+}
+
+.hb-filesystem__workspaceMenuTitle {
color: var(--hb-filesystem-text-muted);
- display: grid;
- gap: 0.45rem;
- font-size: 0.82rem;
+ font-size: 0.8rem;
+ font-weight: 650;
+}
+
+.hb-filesystem__workspaceMenuConfirm {
+ align-items: center;
+ background: var(--hb-filesystem-accent);
+ border: 0;
+ border-radius: 8px;
+ color: #ffffff;
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 0.76rem;
font-weight: 600;
+ height: 1.9rem;
+ justify-content: center;
+ min-width: 2.6rem;
+ padding: 0 0.72rem;
}
-.hb-filesystem__input {
+.hb-filesystem__workspaceMenuConfirm:hover,
+.hb-filesystem__workspaceMenuConfirm:focus-visible {
+ background: color-mix(
+ in srgb,
+ var(--hb-filesystem-accent) 86%,
+ var(--hb-filesystem-text) 14%
+ );
+ outline: none;
+}
+
+.hb-filesystem__workspaceInput {
background: var(--hb-filesystem-panel);
border: 1px solid var(--hb-filesystem-border);
- border-radius: 12px;
+ border-radius: 8px;
color: var(--hb-filesystem-text);
font: inherit;
- padding: 0.7rem 0.8rem;
+ min-width: 0;
+ padding: 0.55rem 0.65rem;
+}
+
+.hb-filesystem__workspaceInput:focus {
+ border-color: var(--hb-filesystem-accent);
+ box-shadow: 0 0 0 1px var(--hb-filesystem-accent-ring);
+ outline: none;
+}
+
+.hb-filesystem__workspaceError,
+.hb-filesystem__workspacePlaceholder {
+ color: var(--hb-filesystem-text-muted);
+ font-size: 0.78rem;
+ margin: 0;
+}
+
+.hb-filesystem__workspaceError {
+ color: var(--hb-filesystem-danger);
+}
+
+.hb-filesystem__workspaceOptions {
+ display: grid;
+ gap: 0.15rem;
+ max-height: 15rem;
+ overflow: auto;
+}
+
+.hb-filesystem__workspaceOption {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: 8px;
+ color: var(--hb-filesystem-text);
+ cursor: pointer;
+ display: inline-flex;
+ gap: 0.45rem;
+ justify-content: flex-start;
+ padding: 0.42rem 0.45rem;
+ text-align: left;
+ width: 100%;
+}
+
+.hb-filesystem__workspaceOption:hover,
+.hb-filesystem__workspaceOption:focus-visible {
+ background: var(--hb-filesystem-row-hover);
+ outline: none;
+}
+
+.hb-filesystem__workspaceOptionText {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.hb-filesystem__tree {
display: grid;
align-content: start;
- gap: 0.15rem;
+ gap: 0.05rem;
+ height: 100%;
min-height: 0;
overflow: auto;
- padding-inline-end: 0.35rem;
+ padding: 0.5rem 0.35rem 0.5rem 0.35rem;
}
.hb-filesystem__treeRow {
align-items: center;
display: grid;
- grid-template-columns: 1.2rem 1fr;
+ grid-template-columns: 1rem 1fr;
+ min-height: 1.75rem;
}
.hb-filesystem__treeToggle,
.hb-filesystem__treeTogglePlaceholder {
color: var(--hb-filesystem-text-muted);
display: inline-flex;
- height: 1.4rem;
+ height: 1rem;
justify-content: center;
- width: 1.1rem;
+ width: 1rem;
}
.hb-filesystem__treeToggle {
+ align-items: center;
cursor: pointer;
+ padding: 0;
+}
+
+.hb-filesystem__treeToggle:hover {
+ color: var(--hb-filesystem-text);
+}
+
+.hb-filesystem__treeTogglePlaceholder {
+ visibility: hidden;
+}
+
+.hb-filesystem__treeChevron {
+ display: block;
+ height: 0.9rem;
+ transition: transform 140ms ease;
+ width: 0.9rem;
+}
+
+.hb-filesystem__treeChevron[data-expanded="true"] {
+ transform: rotate(90deg);
+}
+
+.hb-filesystem__treeSpinner {
+ animation: hb-filesystem-tree-spinner 720ms linear infinite;
+ display: block;
+ height: 0.9rem;
+ width: 0.9rem;
+}
+
+.hb-filesystem__treeSpinnerTrack {
+ opacity: 0.22;
+}
+
+.hb-filesystem__treeSpinnerArc {
+ color: var(--hb-filesystem-accent);
}
.hb-filesystem__treeLabel {
align-items: center;
- border-radius: 12px;
+ border-radius: 6px;
cursor: pointer;
display: inline-flex;
- gap: 0.5rem;
+ font-size: 0.9rem;
+ gap: 0.35rem;
justify-content: flex-start;
min-width: 0;
- padding: 0.48rem 0.6rem;
+ padding: 0.24rem 0.4rem;
text-align: left;
}
@@ -236,24 +478,20 @@
}
.hb-filesystem__treeLabel[data-active="true"] {
- box-shadow: inset 0 0 0 1px rgba(18, 103, 214, 0.24);
+ color: var(--hb-filesystem-text);
}
-.hb-filesystem__treeBadge,
-.hb-filesystem__dirtyDot {
- background: rgba(18, 103, 214, 0.12);
- border-radius: 999px;
- color: var(--hb-filesystem-accent);
- font-size: 0.66rem;
- font-weight: 700;
- letter-spacing: 0.08em;
- padding: 0.2rem 0.42rem;
- text-transform: uppercase;
+.hb-filesystem__treeIcon,
+.hb-filesystem__treeMetaIcon {
+ color: var(--hb-filesystem-icon-muted);
+ display: inline-flex;
+ flex: 0 0 auto;
}
-.hb-filesystem__dirtyDot {
- background: rgba(181, 71, 8, 0.12);
- color: var(--hb-filesystem-warning);
+.hb-filesystem__treeGlyph {
+ display: block;
+ height: 0.88rem;
+ width: 0.88rem;
}
.hb-filesystem__treeText {
@@ -264,142 +502,339 @@
}
.hb-filesystem__treeMeta {
+ align-items: center;
color: var(--hb-filesystem-text-muted);
- font-size: 0.74rem;
+ display: inline-flex;
+ font-size: 0.72rem;
+ gap: 0.25rem;
margin-inline-start: auto;
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+.hb-filesystem__treeMetaIcon {
+ height: 0.8rem;
+ width: 0.8rem;
+}
+
+@keyframes hb-filesystem-tree-spinner {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.hb-filesystem__workspace {
- background: linear-gradient(180deg, rgba(251, 253, 255, 0.92) 0%, var(--hb-filesystem-editor-background) 100%);
+ background: var(--hb-filesystem-editor-background);
display: grid;
- grid-template-rows: auto 1fr;
+ grid-template-rows: 1fr;
+ grid-column: 2;
+ grid-row: 2;
min-height: 0;
+ min-width: 0;
+ overflow: auto;
}
-.hb-filesystem__tabs {
- align-items: end;
- background: rgba(255, 255, 255, 0.72);
- border-bottom: 1px solid var(--hb-filesystem-divider);
- display: flex;
- flex-wrap: wrap;
- gap: 0.35rem;
- padding: 0.8rem 0.9rem 0;
+.hb-filesystem__preview {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ min-height: 100%;
}
-.hb-filesystem__tab {
- align-items: stretch;
- background: var(--hb-filesystem-tab-inactive);
- border: 1px solid var(--hb-filesystem-border);
- border-bottom: 0;
- border-top-left-radius: 14px;
- border-top-right-radius: 14px;
- display: inline-flex;
- max-width: 280px;
- min-width: 0;
+.hb-filesystem__empty {
+ display: grid;
+ min-height: 100%;
}
-.hb-filesystem__tab[data-active="true"] {
- background: var(--hb-filesystem-tab-active);
+.hb-filesystem__assetFrame {
+ align-content: center;
+ background:
+ linear-gradient(
+ 180deg,
+ var(--hb-filesystem-surface-elevated),
+ var(--hb-filesystem-editor-background)
+ ),
+ var(--hb-filesystem-editor-background);
+ display: grid;
+ gap: 0.85rem;
+ justify-items: center;
+ min-height: 100%;
+ padding: 1.25rem;
}
-.hb-filesystem__tabButton {
- align-items: center;
- cursor: pointer;
- display: inline-flex;
- gap: 0.5rem;
- min-width: 0;
+.hb-filesystem__assetFrame--pdf {
+ align-content: stretch;
+ grid-template-rows: minmax(0, 1fr) auto;
+}
+
+.hb-filesystem__assetMeta {
+ color: var(--hb-filesystem-text-muted);
+ font-size: 0.78rem;
+ line-height: 1.5;
+ margin: 0;
+ text-align: center;
+}
+
+.hb-filesystem__previewPathBar {
+ color: var(--hb-filesystem-text-muted);
+ display: block;
+ flex: 1 1 auto;
+ font-family:
+ "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ font-size: 0.76rem;
overflow: hidden;
- padding: 0.68rem 0.9rem;
+ padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
-.hb-filesystem__tabClose {
+.hb-filesystem__previewPathBar[data-empty="true"] {
+ min-height: 1rem;
+ opacity: 0;
+}
+
+.hb-filesystem__copyButton {
+ align-items: center;
+ background: var(--hb-filesystem-panel);
+ border: 1px solid var(--hb-filesystem-border);
+ border-radius: 8px;
color: var(--hb-filesystem-text-muted);
cursor: pointer;
- padding: 0 0.8rem 0 0.2rem;
+ display: inline-flex;
+ flex: 0 0 auto;
+ height: 2rem;
+ justify-content: center;
+ position: relative;
+ transition:
+ background-color 140ms ease,
+ border-color 180ms ease,
+ color 180ms ease,
+ transform 180ms ease;
+ width: 2rem;
}
-.hb-filesystem__editorSurface,
-.hb-filesystem__editorFallback,
-.hb-filesystem__empty {
+.hb-filesystem__copyButton:hover {
+ background: var(--hb-filesystem-row-hover);
+ color: var(--hb-filesystem-text);
+}
+
+.hb-filesystem__copyButton[data-state="copied"] {
+ background: var(--hb-filesystem-accent-soft);
+ border-color: var(--hb-filesystem-accent-border);
+ color: var(--hb-filesystem-accent);
+ transform: translateY(-1px);
+}
+
+.hb-filesystem__copyIconStack {
display: grid;
- grid-template-rows: auto 1fr;
- min-height: 0;
- position: relative;
+ height: 0.95rem;
+ place-items: center;
+ width: 0.95rem;
}
-.hb-filesystem__editorHost,
-.hb-filesystem__editorTextarea,
-.hb-filesystem__empty {
- min-height: 0;
+.hb-filesystem__copyIcon {
+ grid-area: 1 / 1;
+ transition:
+ opacity 160ms ease,
+ transform 180ms ease;
}
-.hb-filesystem__editorHost {
- height: 100%;
- min-height: 540px;
+.hb-filesystem__copyButton[data-state="idle"] .hb-filesystem__copyIcon--copy,
+.hb-filesystem__copyButton[data-state="copied"]
+ .hb-filesystem__copyIcon--check {
+ opacity: 1;
+ transform: scale(1);
}
-.hb-filesystem__editorTextarea {
- background: var(--hb-filesystem-editor-background);
- border: 0;
- color: var(--hb-filesystem-text);
- font: 14px/1.6 "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
- min-height: 540px;
- outline: none;
- padding: 1.1rem 1.25rem 1.35rem;
- resize: none;
+.hb-filesystem__copyButton[data-state="idle"] .hb-filesystem__copyIcon--check {
+ opacity: 0;
+ transform: scale(0.72);
+}
+
+.hb-filesystem__copyButton[data-state="copied"] .hb-filesystem__copyIcon--copy {
+ opacity: 0;
+ transform: scale(0.72);
+}
+
+.hb-filesystem__copyButton svg {
+ display: block;
+ height: 0.95rem;
+ width: 0.95rem;
}
.hb-filesystem__editorBanner {
+ background: var(--hb-filesystem-warning-soft);
border-bottom: 1px solid var(--hb-filesystem-divider);
color: var(--hb-filesystem-warning);
- font-size: 0.86rem;
- padding: 0.78rem 1rem;
+ font-size: 0.82rem;
+ padding: 0.72rem 1rem;
}
-.hb-filesystem__editorBanner[data-tone="warning"] {
- background: rgba(181, 71, 8, 0.08);
+.hb-filesystem__codeFrame {
+ background: linear-gradient(
+ 180deg,
+ var(--hb-filesystem-panel) 0%,
+ var(--hb-filesystem-editor-background) 100%
+ );
+ display: grid;
+ grid-template-columns: auto 1fr;
+ min-height: 100%;
}
-.hb-filesystem__overlay {
+.hb-filesystem__codeGutter,
+.hb-filesystem__codeContent {
+ font-family: var(--hb-filesystem-code-font-family);
+ font-size: var(--hb-filesystem-code-font-size);
+ line-height: var(--hb-filesystem-code-line-height);
+ margin: 0;
+ min-height: 100%;
+ overflow: visible;
+ padding: 1rem 0;
+ tab-size: 2;
+}
+
+.hb-filesystem__codeGutter {
+ background: transparent;
+ border-right: 1px solid var(--hb-filesystem-divider);
+ color: var(--hb-filesystem-gutter-text);
+ min-width: 3.2rem;
+ padding-inline: 0.85rem 0.7rem;
+ text-align: right;
+ user-select: none;
+}
+
+.hb-filesystem__codeContent {
+ color: var(--hb-filesystem-text);
+ padding-inline: 1rem 1.25rem;
+ white-space: pre;
+}
+
+.hb-filesystem__imagePreview {
+ border-radius: 14px;
+ box-shadow: var(--hb-filesystem-soft-shadow);
+ display: block;
+ max-height: min(70vh, 720px);
+ max-width: 100%;
+ object-fit: contain;
+}
+
+.hb-filesystem__mediaPlayer,
+.hb-filesystem__videoPlayer {
+ display: block;
+ max-width: min(100%, 920px);
+ width: min(100%, 920px);
+}
+
+.hb-filesystem__videoPlayer {
+ background: #020617;
+ border-radius: 14px;
+ box-shadow: var(--hb-filesystem-soft-shadow);
+ max-height: min(72vh, 780px);
+}
+
+.hb-filesystem__pdfFrame {
+ background: var(--hb-filesystem-panel);
+ border: 1px solid var(--hb-filesystem-divider);
+ border-radius: 14px;
+ display: block;
+ min-height: min(72vh, 780px);
+ width: 100%;
+}
+
+.hb-filesystem__binaryState {
+ align-content: center;
+ background:
+ linear-gradient(
+ 180deg,
+ var(--hb-filesystem-panel),
+ var(--hb-filesystem-surface-muted)
+ ),
+ var(--hb-filesystem-panel);
+ border: 1px solid var(--hb-filesystem-border);
+ border-radius: 20px;
+ box-shadow:
+ var(--hb-filesystem-strong-shadow),
+ inset 0 1px 0
+ color-mix(
+ in srgb,
+ var(--hb-filesystem-panel) 76%,
+ var(--hb-filesystem-background) 24%
+ );
+ display: grid;
+ gap: 1rem;
+ justify-items: center;
+ max-width: 24rem;
+ padding: 2rem 1.6rem;
+ text-align: center;
+ width: min(100%, 24rem);
+}
+
+.hb-filesystem__binaryShell {
+ align-content: center;
+ background:
+ radial-gradient(
+ circle at top,
+ var(--hb-filesystem-accent-glow),
+ transparent 34%
+ ),
+ linear-gradient(
+ 180deg,
+ var(--hb-filesystem-surface-muted),
+ var(--hb-filesystem-editor-background)
+ );
+ display: grid;
+ justify-items: center;
+ min-height: 100%;
+ padding: 1.5rem;
+}
+
+.hb-filesystem__binaryIcon {
align-items: center;
- background: rgba(251, 253, 255, 0.72);
- color: var(--hb-filesystem-text-muted);
+ background: var(--hb-filesystem-accent-soft);
+ border: 1px solid var(--hb-filesystem-accent-border);
+ border-radius: 14px;
+ color: var(--hb-filesystem-accent);
display: inline-flex;
- inset: 0;
+ height: 3.2rem;
justify-content: center;
- pointer-events: none;
- position: absolute;
- z-index: 2;
+ width: 3.2rem;
+}
+
+.hb-filesystem__binaryIcon svg {
+ display: block;
+ height: 1.5rem;
+ width: 1.5rem;
+}
+
+.hb-filesystem__binaryHeadline {
+ color: var(--hb-filesystem-text);
+ font-size: clamp(1.125rem, 0.95rem + 0.8vw, 1.5rem);
+ font-weight: 760;
+ letter-spacing: 0.08em;
+ line-height: 1.15;
+ text-transform: uppercase;
}
.hb-filesystem__empty {
align-content: center;
- background: radial-gradient(circle at top, rgba(18, 103, 214, 0.08), transparent 44%),
- var(--hb-filesystem-editor-background);
+ background: linear-gradient(
+ 180deg,
+ var(--hb-filesystem-panel) 0%,
+ var(--hb-filesystem-editor-background) 100%
+ );
justify-items: center;
padding: 2.5rem;
text-align: center;
}
-.hb-filesystem__emptyTitle {
- font-size: 1.08rem;
- margin: 0;
-}
-
-.hb-filesystem__emptyMeta {
- color: var(--hb-filesystem-text-muted);
- margin: 0.35rem 0 0;
- max-width: 34rem;
-}
-
.hb-filesystem__footerLabel {
color: var(--hb-filesystem-text);
- font-weight: 700;
+ font-weight: 650;
}
.hb-filesystem__footerMeta {
@@ -411,15 +846,62 @@
@media (max-width: 960px) {
.hb-filesystem__body {
grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(14rem, 18rem) auto minmax(0, 1fr);
}
- .hb-filesystem__sidebar {
+ .hb-filesystem__bodyHeader--sidebar,
+ .hb-filesystem__sidebar,
+ .hb-filesystem__bodyHeader--workspace,
+ .hb-filesystem__workspace {
+ grid-column: 1;
+ }
+
+ .hb-filesystem__bodyHeader--sidebar {
border-right: 0;
+ grid-row: 1;
+ }
+
+ .hb-filesystem__sidebar {
border-bottom: 1px solid var(--hb-filesystem-divider);
+ border-right: 0;
+ grid-row: 2;
+ }
+
+ .hb-filesystem__bodyHeader--workspace {
+ grid-row: 3;
+ padding: 0.35rem 0.7rem;
+ }
+
+ .hb-filesystem__workspaceMenu {
+ width: min(24rem, calc(100vw - 1.4rem));
+ }
+
+ .hb-filesystem__workspace {
+ grid-row: 4;
+ }
+
+ .hb-filesystem__codeFrame {
+ grid-template-columns: 1fr;
+ }
+
+ .hb-filesystem__codeGutter {
+ display: none;
+ }
+
+ .hb-filesystem__binaryState {
+ padding: 1.1rem 1rem 1.2rem;
+ width: min(100%, 100%);
+ }
+
+ .hb-filesystem__binaryShell {
+ padding: 1rem;
+ }
+
+ .hb-filesystem__assetFrame {
+ padding: 0.9rem;
}
- .hb-filesystem__editorHost,
- .hb-filesystem__editorTextarea {
- min-height: 420px;
+ .hb-filesystem__pdfFrame {
+ min-height: 60vh;
}
}
diff --git a/tests/visual/harness/main.jsx b/tests/visual/harness/main.jsx
index 62376b5..aea91c9 100644
--- a/tests/visual/harness/main.jsx
+++ b/tests/visual/harness/main.jsx
@@ -1,4 +1,7 @@
-import "../../../dist/styles/styles.css";
+import "@xterm/xterm/css/xterm.css";
+import "../../../src/styles/terminal-core.css";
+import "../../../src/styles/terminal.css";
+import "../../../src/styles/filesystem.css";
import { createRoot } from "react-dom/client";
import { VisualHarness } from "./registry.jsx";
diff --git a/tests/visual/harness/registry.jsx b/tests/visual/harness/registry.jsx
index ddafb08..18ace4a 100644
--- a/tests/visual/harness/registry.jsx
+++ b/tests/visual/harness/registry.jsx
@@ -1,8 +1,10 @@
import React from "react";
-import * as components from "../../../dist/esm/index.js";
+import * as components from "../../../src/index.ts";
import { hyperbrowserTerminalScenario } from "../scenarios/hyperbrowser-terminal.scenario.jsx";
import { hyperbrowserVncViewerScenario } from "../scenarios/hyperbrowser-vnc-viewer.scenario.jsx";
import { hyperbrowserHlsPlaybackScenario } from "../scenarios/hyperbrowser-hls-playback.scenario.jsx";
+import { filesystemWorkspaceScenario } from "../scenarios/filesystem-workspace.scenario.jsx";
+import { hyperbrowserFileWorkspaceScenario } from "../scenarios/hyperbrowser-file-workspace.scenario.jsx";
import { smokeScenario } from "../scenarios/smoke.example.jsx";
import { terminalPrimitivesScenario } from "../scenarios/terminal-primitives.scenario.jsx";
import { terminalSurfaceScenario } from "../scenarios/terminal-surface.scenario.jsx";
@@ -11,7 +13,9 @@ const scenarios = [
smokeScenario,
terminalPrimitivesScenario,
terminalSurfaceScenario,
+ filesystemWorkspaceScenario,
hyperbrowserTerminalScenario,
+ hyperbrowserFileWorkspaceScenario,
hyperbrowserVncViewerScenario,
hyperbrowserHlsPlaybackScenario,
];
diff --git a/tests/visual/scenarios/filesystem-workspace.scenario.jsx b/tests/visual/scenarios/filesystem-workspace.scenario.jsx
index e8fb829..f6b1922 100644
--- a/tests/visual/scenarios/filesystem-workspace.scenario.jsx
+++ b/tests/visual/scenarios/filesystem-workspace.scenario.jsx
@@ -82,10 +82,18 @@ function createMockFilesystemAdapter() {
"/workspace/assets/logo.png",
{
contentType: "image/png",
- contents: "",
- encoding: "base64",
- readOnlyReason:
- "Binary file preview is not available in the visual harness.",
+ previewKind: "image",
+ type: "file",
+ url:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlH0jUAAAAASUVORK5CYII=",
+ },
+ ],
+ [
+ "/workspace/assets/archive.zip",
+ {
+ contentType: "application/zip",
+ previewKind: "binary",
+ readOnlyReason: "Archive previews are intentionally disabled in the visual harness.",
type: "file",
},
],
@@ -143,6 +151,10 @@ function createMockFilesystemAdapter() {
"/workspace/assets/logo.png",
{ name: "logo.png", path: "/workspace/assets/logo.png", type: "file" },
],
+ [
+ "/workspace/assets/archive.zip",
+ { name: "archive.zip", path: "/workspace/assets/archive.zip", type: "file" },
+ ],
[
"/tmp/notes.txt",
{ name: "notes.txt", path: "/tmp/notes.txt", type: "file" },
@@ -296,28 +308,31 @@ function createMockFilesystemAdapter() {
async stat(path) {
return cloneEntry(normalizePath(path));
},
- async readFile(path) {
+ async previewFile(path) {
const normalizedPath = normalizePath(path);
ensureFile(normalizedPath);
const record = files.get(normalizedPath);
- const isBinary =
- record.contentType &&
- !record.contentType.startsWith("text/") &&
- record.contentType !== "application/typescript";
- if (isBinary) {
+ if (record.previewKind === "image") {
return {
contentType: record.contentType,
- contents: "",
- encoding: record.encoding,
+ kind: "image",
path: normalizedPath,
- readOnly: true,
- readOnlyReason: record.readOnlyReason,
+ url: record.url,
+ };
+ }
+ if (record.previewKind === "binary") {
+ return {
+ contentType: record.contentType,
+ kind: "binary",
+ path: normalizedPath,
+ reason: record.readOnlyReason,
};
}
return {
contentType: record.contentType,
contents: record.contents,
encoding: record.encoding,
+ kind: "text",
path: normalizedPath,
readOnly: Boolean(record.truncated),
readOnlyReason: record.truncated
@@ -430,20 +445,21 @@ function ControlLabel({ children }) {
function FilesystemWorkspaceDemo({ components }) {
const adapterRef = React.useRef(null);
- const [theme, setTheme] = React.useState("atlas");
- const [initialPath, setInitialPath] = React.useState(
- "/workspace/src/index.ts",
- );
+ const [preset, setPreset] = React.useState("atlas");
+ const [appearance, setAppearance] = React.useState("light");
+ const [workspacePath, setWorkspacePath] = React.useState("/workspace");
const [eventLog, setEventLog] = React.useState("Ready.");
+ const fileWorkspacePresets =
+ components.fileWorkspacePresets ?? components.fileWorkspaceThemePresets ?? {};
+ const filesystemTheme =
+ typeof components.createFileWorkspaceTheme === "function"
+ ? components.createFileWorkspaceTheme(preset, { appearance })
+ : { appearance, preset };
if (!adapterRef.current) {
adapterRef.current = createMockFilesystemAdapter();
}
- React.useEffect(() => {
- components.configureMonacoLoader?.({ vsPath: "/dist/monaco/vs" });
- }, [components]);
-
return (
@@ -460,10 +476,10 @@ function FilesystemWorkspaceDemo({ components }) {
}}
>
- Theme
+ Preset
- Initial path
+ Appearance
+
+
+ Workspace path
+
@@ -516,26 +546,23 @@ function FilesystemWorkspaceDemo({ components }) {
Event log
{eventLog}
- Open `large.log` and `logo.png` to confirm the read-only v1 rules.
+ Open `large.log`, `logo.png`, and `archive.zip` to exercise text,
+ image, and binary preview states.
setEventLog(`Created directory ${path}`)}
- onCreateFile={(path) => setEventLog(`Created file ${path}`)}
- onDelete={(path) => setEventLog(`Deleted ${path}`)}
onOpenFile={(path) => setEventLog(`Opened ${path}`)}
- onRename={(path, nextPath) =>
- setEventLog(`Renamed ${path} to ${nextPath}`)
- }
- onSaveFile={(path) => setEventLog(`Saved ${path}`)}
+ onWorkspacePathChange={(path) => {
+ setWorkspacePath(path);
+ setEventLog(`Workspace: ${path}`);
+ }}
style={{ minHeight: "780px" }}
- theme={theme}
- title="Filesystem Workspace"
+ {...filesystemTheme}
+ title="Filesystem Browser"
+ workspacePath={workspacePath}
/>
);
diff --git a/tests/visual/scenarios/hyperbrowser-file-workspace.scenario.jsx b/tests/visual/scenarios/hyperbrowser-file-workspace.scenario.jsx
index 12e3dc6..7abef0b 100644
--- a/tests/visual/scenarios/hyperbrowser-file-workspace.scenario.jsx
+++ b/tests/visual/scenarios/hyperbrowser-file-workspace.scenario.jsx
@@ -1,7 +1,7 @@
import React from "react";
const DEFAULT_API_BASE_URL = "http://localhost:8080/api";
-const DEFAULT_INITIAL_PATH = "/";
+const DEFAULT_WORKSPACE_PATH = "/";
const inputStyle = {
border: "1px solid #cbd5e1",
@@ -52,25 +52,24 @@ function ControlLabel({ children }) {
function HyperbrowserFileWorkspaceDemo({
HyperbrowserFileWorkspace,
- configureMonacoLoader,
fileWorkspaceThemePresets,
}) {
const [mode, setMode] = React.useState("api");
- const [theme, setTheme] = React.useState("atlas");
+ const [preset, setPreset] = React.useState("atlas");
+ const [appearance, setAppearance] = React.useState("light");
const [sandboxId, setSandboxId] = React.useState("");
const [apiBaseUrl, setApiBaseUrl] = React.useState(DEFAULT_API_BASE_URL);
const [apiToken, setApiToken] = React.useState("");
const [runtimeBaseUrl, setRuntimeBaseUrl] = React.useState("");
const [bootstrapUrl, setBootstrapUrl] = React.useState("");
- const [initialPath, setInitialPath] = React.useState(DEFAULT_INITIAL_PATH);
+ const [workspacePath, setWorkspacePath] = React.useState(DEFAULT_WORKSPACE_PATH);
const [appliedConfig, setAppliedConfig] = React.useState(null);
const [launchCount, setLaunchCount] = React.useState(0);
const [latestEvent, setLatestEvent] = React.useState("Idle.");
React.useEffect(() => {
setApiBaseUrl(DEFAULT_API_BASE_URL);
- configureMonacoLoader?.({ vsPath: "/dist/monaco/vs" });
- }, [configureMonacoLoader]);
+ }, []);
const activeConfig = React.useMemo(() => {
if (!appliedConfig) {
@@ -85,19 +84,19 @@ function HyperbrowserFileWorkspaceDemo({
Authorization: `Bearer ${appliedConfig.apiToken}`,
}
: undefined,
- initialPath: appliedConfig.initialPath || DEFAULT_INITIAL_PATH,
sandboxId: appliedConfig.sandboxId,
+ workspacePath: appliedConfig.workspacePath || DEFAULT_WORKSPACE_PATH,
};
}
return {
bootstrapUrl: appliedConfig.bootstrapUrl,
- initialPath: appliedConfig.initialPath || DEFAULT_INITIAL_PATH,
runtimeBaseUrl: appliedConfig.runtimeBaseUrl,
+ workspacePath: appliedConfig.workspacePath || DEFAULT_WORKSPACE_PATH,
};
}, [appliedConfig]);
- const themeNames = Object.keys(fileWorkspaceThemePresets ?? {});
+ const presetNames = Object.keys(fileWorkspaceThemePresets ?? {});
const canLaunch =
mode === "api"
? Boolean(sandboxId.trim() && apiBaseUrl.trim())
@@ -110,27 +109,29 @@ function HyperbrowserFileWorkspaceDemo({
setLatestEvent("Connecting...");
setAppliedConfig({
+ appearance,
apiBaseUrl: apiBaseUrl.trim(),
apiToken: apiToken.trim(),
bootstrapUrl: bootstrapUrl.trim(),
- initialPath: initialPath.trim() || DEFAULT_INITIAL_PATH,
mode,
+ preset,
runtimeBaseUrl: runtimeBaseUrl.trim(),
sandboxId: sandboxId.trim(),
- theme,
+ workspacePath: workspacePath.trim() || DEFAULT_WORKSPACE_PATH,
});
setLaunchCount((value) => value + 1);
};
const resetForm = () => {
setMode("api");
- setTheme("atlas");
+ setPreset("atlas");
+ setAppearance("light");
setSandboxId("");
setApiBaseUrl(DEFAULT_API_BASE_URL);
setApiToken("");
setRuntimeBaseUrl("");
setBootstrapUrl("");
- setInitialPath(DEFAULT_INITIAL_PATH);
+ setWorkspacePath(DEFAULT_WORKSPACE_PATH);
setAppliedConfig(null);
setLaunchCount(0);
setLatestEvent("Idle.");
@@ -158,25 +159,36 @@ function HyperbrowserFileWorkspaceDemo({
- Theme
+ Preset
- Initial path
+ Appearance
+
+
+
+ Workspace path
setInitialPath(event.target.value)}
+ value={workspacePath}
+ onChange={(event) => setWorkspacePath(event.target.value)}
placeholder="/"
style={inputStyle}
type="text"
@@ -366,21 +378,19 @@ function HyperbrowserFileWorkspaceDemo({
{
- setLatestEvent(`Created directory: ${path}`)
- }
- onCreateFile={(path) => setLatestEvent(`Created file: ${path}`)}
- onDelete={(path) => setLatestEvent(`Deleted: ${path}`)}
+ appearance={appliedConfig.appearance}
+ key={launchCount}
onError={(message) => setLatestEvent(`Error: ${message}`)}
onOpenFile={(path) => setLatestEvent(`Opened: ${path}`)}
- onRename={(path, nextPath) =>
- setLatestEvent(`Renamed ${path} -> ${nextPath}`)
- }
- onSaveFile={(path) => setLatestEvent(`Saved: ${path}`)}
+ onWorkspacePathChange={(path) => {
+ setAppliedConfig((current) =>
+ current ? { ...current, workspacePath: path } : current
+ );
+ setLatestEvent(`Workspace: ${path}`);
+ }}
+ preset={appliedConfig.preset}
style={{ minHeight: "780px" }}
- theme={appliedConfig.theme}
- title="Hyperbrowser File Workspace"
+ title="Hyperbrowser File Browser"
/>
}
@@ -401,14 +411,10 @@ export const hyperbrowserFileWorkspaceScenario = {
title: "Hyperbrowser File Workspace",
render({ components }) {
const HyperbrowserFileWorkspace = components.HyperbrowserFileWorkspace;
- const configureMonacoLoader = components.configureMonacoLoader;
- const fileWorkspaceThemePresets = components.fileWorkspaceThemePresets;
+ const fileWorkspaceThemePresets =
+ components.fileWorkspacePresets ?? components.fileWorkspaceThemePresets;
- if (
- typeof HyperbrowserFileWorkspace !== "function" ||
- typeof configureMonacoLoader !== "function" ||
- !fileWorkspaceThemePresets
- ) {
+ if (typeof HyperbrowserFileWorkspace !== "function" || !fileWorkspaceThemePresets) {
return (