diff --git a/README.md b/README.md index 25208d6..a51f8f6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ TypeScript-first React component package scaffold with: - `npm run typecheck`: Validate TypeScript. - `npm run build`: Build ESM, CJS, and declaration outputs. -- `npm run test:visual`: Build package and start visual harness server. +- `npm run test:visual`: Start the visual harness server. ## Sandbox terminal usage @@ -243,6 +243,96 @@ Terminal primitives support: You can also create a reusable spreadable config with `createTerminalTheme(...)`. +## Filesystem workspace usage + +The public filesystem path is built from two layers: + +- `HyperbrowserFileWorkspace`: ready-to-use Hyperbrowser-backed filesystem browser +- `FileWorkspace`: lower-level browser shell when you want to bring your own adapter + +Import the packaged stylesheet once in your app entrypoint: + +```tsx +import "@hyperbrowser/ui/styles.css"; +``` + +### Basic `HyperbrowserFileWorkspace` example + +```tsx +import { useCallback } from "react"; +import { + createFileWorkspaceTheme, + HyperbrowserFileWorkspace, + type HyperbrowserFilesystemBrowserAuthParams, +} from "@hyperbrowser/ui"; +import "@hyperbrowser/ui/styles.css"; + +export function SandboxFiles({ + sandboxId, +}: { + sandboxId: string; +}) { + const getRuntimeBrowserAuth = useCallback( + async ({ + browserAuthEndpoint, + sandboxId: resolvedSandboxId, + signal, + }: HyperbrowserFilesystemBrowserAuthParams) => { + if (!browserAuthEndpoint) { + throw new Error("Sandbox filesystem auth endpoint is unavailable."); + } + + const response = await fetch(browserAuthEndpoint, { + method: "POST", + signal, + }); + + if (!response.ok) { + throw new Error( + `Failed to get filesystem auth for sandbox ${resolvedSandboxId ?? sandboxId}.`, + ); + } + + return response.json(); + }, + [sandboxId], + ); + + const filesystemTheme = createFileWorkspaceTheme("basic", { + appearance: "dark", + }); + + return ( + + ); +} +``` + +Notes: + +- `getRuntimeBrowserAuth(...)` receives `{ signal, sandboxId, browserAuthEndpoint }`. +- `sandboxId` is the standard customer path. +- `runtimeBaseUrl + bootstrapUrl` is also supported for already-bootstrapped runtime sessions. +- `apiBaseUrl` is only needed when you want the library to call the control-plane endpoint directly or when you need a non-default control-plane base. + +### Filesystem theming + +Filesystem theming supports: + +- `preset`: one of `basic`, `atlas`, `ledger` +- `appearance`: `"dark"` or `"light"` +- `chromeTheme`: partial chrome overrides +- `editorTheme`: partial editor typography overrides + +You can create a reusable spreadable config with `createFileWorkspaceTheme(...)`. + ## VNC component usage `HyperbrowserVncViewer` renders a noVNC viewer using a Hyperbrowser session token and diff --git a/package.json b/package.json index 8f0655a..b141f0b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "default": "./dist/esm/index.js" }, "./styles.css": "./dist/styles/styles.css", + "./filesystem.css": "./dist/styles/filesystem.css", "./terminal-core.css": "./dist/styles/terminal-core.css", "./terminal.css": "./dist/styles/terminal.css", "./package.json": "./package.json" @@ -34,7 +35,7 @@ "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:types && node ./scripts/write-dist-package-jsons.mjs && node ./scripts/copy-static-assets.mjs", "prepublishOnly": "npm run build", "typecheck": "tsc -p ./tsconfig.base.json --noEmit", - "test:visual": "npm run build && vite --config ./tests/visual/vite.config.mjs" + "test:visual": "vite --config ./tests/visual/vite.config.mjs" }, "peerDependencies": { "react": ">=18 <20", @@ -44,7 +45,6 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "hls.js": "^1.6.13", - "monaco-editor": "^0.52.2", "react-vnc": "^3.2.0" }, "devDependencies": { diff --git a/scripts/copy-static-assets.mjs b/scripts/copy-static-assets.mjs index 11b9dcf..cb4c453 100644 --- a/scripts/copy-static-assets.mjs +++ b/scripts/copy-static-assets.mjs @@ -11,6 +11,12 @@ const sourceTerminalCoreStylesPath = path.join( "terminal-core.css" ); const sourceTerminalStylesPath = path.join(rootDir, "src", "styles", "terminal.css"); +const sourceFilesystemStylesPath = path.join( + rootDir, + "src", + "styles", + "filesystem.css" +); const xtermStylesPath = path.join( rootDir, "node_modules", @@ -23,14 +29,22 @@ const terminalCoreBundle = (xtermStyles, terminalCoreStyles) => `${xtermStyles.trim()}\n\n${terminalCoreStyles.trim()}\n`; const terminalBundle = (xtermStyles, terminalCoreStyles, terminalStyles) => `${terminalCoreBundle(xtermStyles, terminalCoreStyles).trim()}\n\n${terminalStyles.trim()}\n`; +const fullStylesBundle = ( + xtermStyles, + terminalCoreStyles, + terminalStyles, + filesystemStyles +) => `${terminalBundle(xtermStyles, terminalCoreStyles, terminalStyles).trim()}\n\n${filesystemStyles.trim()}\n`; await mkdir(distStylesDir, { recursive: true }); -const [xtermStyles, terminalCoreStyles, terminalStyles] = await Promise.all([ - readFile(xtermStylesPath, "utf8"), - readFile(sourceTerminalCoreStylesPath, "utf8"), - readFile(sourceTerminalStylesPath, "utf8"), -]); +const [xtermStyles, terminalCoreStyles, terminalStyles, filesystemStyles] = + await Promise.all([ + readFile(xtermStylesPath, "utf8"), + readFile(sourceTerminalCoreStylesPath, "utf8"), + readFile(sourceTerminalStylesPath, "utf8"), + readFile(sourceFilesystemStylesPath, "utf8"), + ]); await Promise.all([ writeFile( @@ -43,9 +57,19 @@ await Promise.all([ terminalBundle(xtermStyles, terminalCoreStyles, terminalStyles), "utf8" ), + writeFile( + path.join(distStylesDir, "filesystem.css"), + `${filesystemStyles.trim()}\n`, + "utf8" + ), writeFile( path.join(distStylesDir, "styles.css"), - terminalBundle(xtermStyles, terminalCoreStyles, terminalStyles), + fullStylesBundle( + xtermStyles, + terminalCoreStyles, + terminalStyles, + filesystemStyles + ), "utf8" ), ]); diff --git a/src/components/filesystem/CodeEditorPane.tsx b/src/components/filesystem/CodeEditorPane.tsx index d935dd5..9dcecca 100644 --- a/src/components/filesystem/CodeEditorPane.tsx +++ b/src/components/filesystem/CodeEditorPane.tsx @@ -1,259 +1,138 @@ -import { useEffect, useRef, useState } from "react"; -import type * as MonacoEditor from "monaco-editor"; -import { loadMonaco } from "./monaco-loader"; -import type { FileDocument, ResolvedFileWorkspaceTheme } from "./types"; +import type { CSSProperties } from "react"; +import { formatFileSize } from "./filePreview"; +import type { FilePreview, ResolvedFileWorkspaceTheme } from "./types"; type CodeEditorPaneProps = { - document: FileDocument | null; - monacoVsPath?: string; - onChange: (nextValue: string) => void; - onSave?: () => void; + document: FilePreview | null; theme: ResolvedFileWorkspaceTheme; }; -function ensureTheme( - monaco: typeof MonacoEditor, - theme: ResolvedFileWorkspaceTheme -): string { - const themeName = `hb-filesystem-${theme.id}`; - monaco.editor.defineTheme(themeName, { - base: "vs", - inherit: true, - colors: { - "editor.background": theme.chrome.editorBackground, - "editor.foreground": theme.chrome.text, - "editor.lineHighlightBackground": theme.chrome.rowHover, - "editor.selectionBackground": theme.chrome.rowActive, - "editorCursor.foreground": theme.chrome.accent, - "editorIndentGuide.background1": theme.chrome.border, - "editorLineNumber.foreground": theme.chrome.textMuted, - "editorLineNumber.activeForeground": theme.chrome.text, - }, - rules: [], - }); - return themeName; +function toLineCount(contents: string): number { + if (!contents) { + return 1; + } + return contents.split("\n").length; } -export function CodeEditorPane({ - document, - onChange, - onSave, - theme, -}: CodeEditorPaneProps) { - const changeHandlerRef = useRef(onChange); - const containerRef = useRef(null); - const editorRef = useRef(null); - const modelsRef = useRef>(new Map()); - const monacoRef = useRef(null); - const saveHandlerRef = useRef(onSave); - const syncRef = useRef(false); - const viewStatesRef = useRef< - Map - >(new Map()); - const visibleDocumentPathRef = useRef(null); - const [loadState, setLoadState] = useState<"loading" | "ready" | "error">( - "loading" - ); - const [loadError, setLoadError] = useState(null); - - changeHandlerRef.current = onChange; - saveHandlerRef.current = onSave; - - useEffect(() => { - let active = true; - - async function setupEditor() { - if (!containerRef.current) { - return; - } - - try { - const monaco = await loadMonaco(); - if (!active || !containerRef.current) { - return; - } - - monacoRef.current = monaco; - const themeName = ensureTheme(monaco, theme); - const editor = monaco.editor.create(containerRef.current, { - automaticLayout: true, - fontFamily: theme.editor.fontFamily, - fontSize: theme.editor.fontSize, - lineHeight: Math.round(theme.editor.fontSize * theme.editor.lineHeight), - minimap: { - enabled: false, - }, - padding: { - top: 18, - bottom: 18, - }, - readOnly: true, - renderWhitespace: "selection", - scrollBeyondLastLine: false, - smoothScrolling: true, - tabSize: 2, - theme: themeName, - }); - - editor.onDidChangeModelContent(() => { - if (syncRef.current) { - return; - } - const currentModel = editor.getModel(); - if (!currentModel) { - return; - } - changeHandlerRef.current(currentModel.getValue()); - }); - - editor.onKeyDown((event) => { - if ( - saveHandlerRef.current && - (event.metaKey || event.ctrlKey) && - event.keyCode === monaco.KeyCode.KeyS - ) { - event.preventDefault(); - event.stopPropagation(); - saveHandlerRef.current(); - } - }); - - editorRef.current = editor; - setLoadState("ready"); - setLoadError(null); - } catch (error) { - if (!active) { - return; - } - setLoadError( - error instanceof Error ? error.message : "Failed to load Monaco editor." - ); - setLoadState("error"); - } - } - - void setupEditor(); - - return () => { - active = false; - visibleDocumentPathRef.current = null; - for (const model of modelsRef.current.values()) { - model.dispose(); - } - modelsRef.current.clear(); - viewStatesRef.current.clear(); - editorRef.current?.dispose(); - editorRef.current = null; - monacoRef.current = null; - }; - }, [theme]); - - useEffect(() => { - const editor = editorRef.current; - const monaco = monacoRef.current; - if (!editor || !monaco) { - return; - } - - const themeName = ensureTheme(monaco, theme); - monaco.editor.setTheme(themeName); - editor.updateOptions({ - fontFamily: theme.editor.fontFamily, - fontSize: theme.editor.fontSize, - lineHeight: Math.round(theme.editor.fontSize * theme.editor.lineHeight), - }); - }, [theme]); - - useEffect(() => { - const editor = editorRef.current; - const monaco = monacoRef.current; - if (!editor || !monaco) { - return; - } - - const previousPath = visibleDocumentPathRef.current; - if (previousPath && previousPath !== document?.path) { - viewStatesRef.current.set(previousPath, editor.saveViewState()); - } - - if (!document) { - visibleDocumentPathRef.current = null; - editor.setModel(null); - return; - } - - let model = modelsRef.current.get(document.path); - if (!model) { - model = monaco.editor.createModel( - document.contents, - document.language, - monaco.Uri.parse(`file://${document.path}`) - ); - modelsRef.current.set(document.path, model); - } - - if (model.getValue() !== document.contents) { - syncRef.current = true; - model.setValue(document.contents); - syncRef.current = false; - } - - if (document.language) { - monaco.editor.setModelLanguage(model, document.language); - } - - editor.setModel(model); - editor.updateOptions({ - readOnly: document.readOnly === true, - }); - const savedViewState = viewStatesRef.current.get(document.path); - if (savedViewState) { - editor.restoreViewState(savedViewState); - } - editor.focus(); - visibleDocumentPathRef.current = document.path; - }, [document]); - +export function CodeEditorPane({ document, theme }: CodeEditorPaneProps) { if (!document) { - return ( -
-

Choose a file to start editing

-

- Open a file from the explorer to inspect or edit its contents. -

-
- ); + return