import { FileCode2Icon, FileWarningIcon, LoaderCircleIcon } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { Group, Panel, Separator } from "react-resizable-panels"; import { cn } from "../lib/cn"; import { prefersReducedMotion } from "../lib/motion"; import { useThemeType } from "../lib/use-theme-type"; import { CapturedFileUnavailableError, type SandboxFilesGitSummary, type UseSandboxFilesResult, } from "../hooks/use-sandbox-files"; import type { UseSandboxGitResult } from "../hooks/use-sandbox-git"; import { filePathVisibility, type FileNodeVisibilityPredicate } from "../file-node-visibility"; import { CodeEditor } from "./code-editor"; import { FileBrowser } from "./file-browser"; import { PierreFile } from "./pierre-file"; import { useOpenGeniText } from "../messages/runtime"; export type SandboxFilesProps = { /** From `useSandboxFiles(...)`. */ files: UseSandboxFilesResult; /** Compatibility fallback for callers that have not yet adopted `files.gitSummary`. */ git?: UseSandboxGitResult | undefined; /** @deprecated Diffs live in the dedicated Changes tab now; accepted for * source-compat but unused (the Files surface is a browser + viewer). */ stagedGit?: UseSandboxGitResult | undefined; /** Whether a FileSystem surface is advertised (drives the unavailable notice). */ fileSystemAvailable?: boolean | undefined; /** Use Pierre's Shiki highlighter for the viewer (default true; plain fallback). */ usePierre?: boolean | undefined; /** Allow in-place editing of tree files (CodeMirror). Default true. When false * the surface is review-only: every text file opens in the read-only viewer. */ editable?: boolean | undefined; /** Fired once when the user first edits an open file (wake-on-edit intent). The * dock warms the box on this so the save lands fast; opening/reading never fires * it. Browsing the tree/diff must not warm a box. */ onEditIntent?: (() => void) | undefined; /** Restore the file last viewed in this task. Invalid/missing paths still * surface the ordinary file-view error instead of changing files silently. */ initialSelectedPath?: string | null | undefined; onSelectedPathChange?: ((path: string | null) => void) | undefined; /** Presentation-only node filter shared with the tree and selected-file view. */ isNodeVisible?: FileNodeVisibilityPredicate | undefined; /** A deliberate file-open path routed here by the parent workspace. */ requestedPath?: string | undefined; /** 1-based line to reveal after `requestedPath` opens. */ requestedLine?: number | null | undefined; /** Identity for one guarded-file request. Increment this when the same path is * deliberately requested again; it also lets a pending request be consumed * without overriding later manual tree navigation. Defaults to the path. */ requestedPathRequestId?: string | number | undefined; /** False while the parent is waking a cold sandbox for `requestedPath`. */ requestedPathReady?: boolean | undefined; /** The machine is cold and no durable capture exists yet. Render an explicit * wake gate instead of an empty-tree lie or an implicit Channel-A read. */ workspaceResting?: boolean | undefined; /** A deliberate wake has started but the live file surface is not ready yet. */ workspaceWaking?: boolean | undefined; /** Whether live reads are currently authoritative. */ liveWorkspaceReady?: boolean | undefined; /** Failed live negotiation; never present a failure as an ongoing wake. */ workspaceError?: Error | null | undefined; /** Deliberately wake the machine to read content absent from the capture. */ onWakeWorkspace?: (() => void) | undefined; themeType?: "dark" | "light" | undefined; className?: string | undefined; }; /** * The Files surface: a branch/dirty header, the full lazy file tree, and a * viewer/editor pane for the selected file. This is the workspace BROWSER — pick * a file to read or edit it. Diff review lives in the dedicated Changes tab (this * surface deliberately does NOT replicate the changed-files list, and does not * diff here — one job per tab). The agent commits; the human reviews. */ export function SandboxFiles({ files, git, fileSystemAvailable = true, usePierre = true, editable = true, onEditIntent, initialSelectedPath, onSelectedPathChange, isNodeVisible, requestedPath, requestedLine, requestedPathRequestId, requestedPathReady = true, workspaceResting = false, workspaceWaking = false, liveWorkspaceReady = true, workspaceError = null, onWakeWorkspace, themeType, className, }: SandboxFilesProps) { const t = useOpenGeniText(); const [selected, setSelected] = useState(() => initialSelectedPath ?? null); // View vs Edit for the selected file. Resets to View on every new selection so // opening a file never lands you in a stale dirty editor for a different path. const [editMode, setEditMode] = useState(false); const [focusLine, setFocusLine] = useState(null); const [treeRevealRequest, setTreeRevealRequest] = useState<{ path: string; requestId: string | number; } | null>(null); const [liveRequestedPath, setLiveRequestedPath] = useState(null); const [viewReloadRevision, setViewReloadRevision] = useState(0); const viewerScrollRef = useRef(null); const pendingRequestRef = useRef(null); const handledRequestRef = useRef(null); const requestKey = requestedPath ? (requestedPathRequestId ?? requestedPath) : null; useEffect(() => { if (!requestedPath || requestKey === null) { pendingRequestRef.current = null; handledRequestRef.current = null; setTreeRevealRequest(null); return; } if (handledRequestRef.current === requestKey) { return; } if (!requestedPathReady) { pendingRequestRef.current = requestKey; setTreeRevealRequest(null); return; } if (isNodeVisible && files.loading && files.tree.length === 0) return; if (isNodeVisible && !files.loading && files.tree.length === 0) { handledRequestRef.current = requestKey; pendingRequestRef.current = null; setTreeRevealRequest(null); return; } if (filePathVisibility(files.tree, requestedPath, isNodeVisible) === "hidden") { handledRequestRef.current = requestKey; pendingRequestRef.current = null; setTreeRevealRequest(null); return; } handledRequestRef.current = requestKey; pendingRequestRef.current = null; setSelected(requestedPath); onSelectedPathChange?.(requestedPath); setEditMode(false); setFocusLine(requestedLine != null && requestedLine > 0 ? requestedLine : null); setTreeRevealRequest({ path: requestedPath, requestId: requestKey }); setViewReloadRevision((revision) => revision + 1); }, [ files.loading, files.tree, isNodeVisible, onSelectedPathChange, requestKey, requestedLine, requestedPath, requestedPathReady, ]); // Side-by-side (tree left, viewer right) once the surface is wide enough; // stacked (tree over viewer) on a narrow dock. Tracked off the container so it // reacts to the dock resize, not just the viewport. const rootRef = useRef(null); const [wide, setWide] = useState(false); useEffect(() => { const el = rootRef.current; if (!el) return; const update = () => setWide(el.getBoundingClientRect().width >= 720); update(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(update); observer.observe(el); // WorkspaceDock maximization changes its persistent surface from a sized // panel child to `fixed inset-0`. Chromium can retain the child's observer // size across that containing-block change even though its visible bounding // box is now viewport-wide. Follow the surface mode as a deterministic // second signal so the tree/viewer orientation matches what is painted. const surface = el.closest("[data-workspace-surface]"); const surfaceObserver = surface && typeof MutationObserver !== "undefined" ? new MutationObserver(update) : null; surfaceObserver?.observe(surface!, { attributes: true, attributeFilter: ["class", "style"], }); return () => { observer.disconnect(); surfaceObserver?.disconnect(); }; }, []); // Resolve the effective viewer theme from the host palette (the `data-og-theme` // attribute the demo/app sets), defaulting to dark, unless the caller forced one. const resolvedTheme = useThemeType(themeType); // The selected tree file opens in the viewer (read-only) or the editor (when // writable). Nothing is auto-selected — the pane waits for a tree click, so the // Files tab opens as a calm browser, not a diff. const selectedVisibility = selected ? filePathVisibility(files.tree, selected, isNodeVisible) : "unknown"; const visibilityPending = Boolean(isNodeVisible && files.loading && files.tree.length === 0); const viewPath = selectedVisibility === "hidden" || visibilityPending ? null : selected; useEffect(() => { if (selected === null || selectedVisibility !== "hidden") return; setSelected(null); onSelectedPathChange?.(null); setEditMode(false); setFocusLine(null); setTreeRevealRequest(null); setLiveRequestedPath(null); }, [onSelectedPathChange, selected, selectedVisibility]); const fileView = useFileView( viewPath, files.readFile, `${files.contentRevision ?? 0}:${viewReloadRevision}`, ); useEffect(() => { if (focusLine === null && viewReloadRevision > 0 && viewerScrollRef.current) { viewerScrollRef.current.scrollTop = 0; } }, [focusLine, viewReloadRevision]); // Selecting a (different) file always returns to View — never drop the user into // an editor whose buffer belongs to the previously-selected path. Manual // navigation also consumes a pending guarded-file request: a late cold→warm // transition must never pull the user away from the file they chose meanwhile. const selectFile = useCallback( (path: string) => { if (pendingRequestRef.current !== null) { handledRequestRef.current = pendingRequestRef.current; pendingRequestRef.current = null; } setSelected(path); onSelectedPathChange?.(path); setEditMode(false); setFocusLine(null); setTreeRevealRequest(null); setLiveRequestedPath(null); setViewReloadRevision(0); }, [onSelectedPathChange], ); // A tree file is editable only when it is a real, fully-loaded text file: not // binary (would corrupt on save) and not truncated (we only hold a PREFIX). The // editor is then additionally gated on the `editable` prop; anything failing this // opens read-only in the viewer. const canEdit = editable && viewPath !== null && !fileView.loading && fileView.error === null && !fileView.isBinary && !fileView.truncated && fileView.content !== null; const showEditor = canEdit && editMode; const captureFileUnavailable = fileView.error instanceof CapturedFileUnavailableError ? fileView.error : null; const waitingForSelectedFile = !workspaceError && liveRequestedPath === viewPath && (!liveWorkspaceReady || files.loading) && captureFileUnavailable !== null; if (workspaceError && !liveWorkspaceReady && files.source !== "capture") { return (

{workspaceError.message}

{onWakeWorkspace ? ( {t("sandboxFiles.retryLiveWorkspace")} ) : null}
); } if (workspaceResting || workspaceWaking) { return (
) : ( ) } title={ workspaceWaking ? t("sandboxFiles.wakingWorkspace") : t("sandboxFiles.workspaceIsResting") } announce="status" >

{workspaceWaking ? t("sandboxFiles.connectingToTheLiveFile") : t("sandboxFiles.noCapturedRevisionIsAvailable")}

{!workspaceWaking && onWakeWorkspace ? ( {t("sandboxFiles.openLiveWorkspace")} ) : null}
); } if (!fileSystemAvailable) { return ( } title={t("sandboxFiles.filesUnavailable")} announce="alert" > {t("sandboxFiles.thisSandboxDoesNotExpose")} ); } return (
{/* Branch + dirty header (context; NOT the changed-files list). */} {/* Tree pane: the full lazy file tree. A fixed left column when wide, a top band when narrow. */} {/* Viewer pane: the selected file's contents (read-only) or the editor. Fills the remaining width when side-by-side, sits below the tree when stacked. */}
{selected ? focusLine !== null ? `${selected}:${focusLine}` : selected : t("sandboxFiles.noFileSelected")} {/* View/Edit toggle — only for a real, fully-loaded text file the editor can safely round-trip. Binary/truncated/read-only files never get an Edit affordance (they'd corrupt on save or can't be written). */} {canEdit && ( setEditMode(v === "edit")} /> )}
{viewPath ? ( showEditor && fileView.content !== null ? ( files.writeFile(viewPath, contents, { expectedContent: expectedContents }) } onOverwrite={(contents) => files.writeFile(viewPath, contents, { force: true })} onReload={() => setViewReloadRevision((revision) => revision + 1)} {...(onEditIntent ? { onEditIntent } : {})} className="h-full" /> ) : workspaceError && (liveRequestedPath === viewPath || requestedPath === viewPath) && !liveWorkspaceReady ? (

{workspaceError.message}

{onWakeWorkspace ? ( {t("sandboxFiles.retryLiveFile")} ) : null}
) : waitingForSelectedFile ? ( } title={t("sandboxFiles.wakingWorkspace")} announce="status" > {t("sandboxFiles.openingViewpathWhenTheLive", { viewPath })} ) : captureFileUnavailable ? ( } title={t("sandboxFiles.onMachine")} >

{captureFileUnavailable.reason === "too-large" ? t("sandboxFiles.thisFileIsLargerThan") : captureFileUnavailable.reason === "content-missing" ? t("sandboxFiles.theCapturedCopyIsNo") : t("sandboxFiles.thisFileWasIndexedBut")}

{onWakeWorkspace ? ( { setLiveRequestedPath(viewPath); if (liveWorkspaceReady) void files.refresh(); else onWakeWorkspace(); }} > {liveWorkspaceReady ? t("sandboxFiles.retryLiveFile") : t("sandboxFiles.openLiveFile")} ) : null}
) : fileView.error ? ( {t("sandboxFiles.couldNotOpenViewpathMessage", { viewPath, message: fileView.error.message, })} ) : fileView.loading ? ( {t("sandboxFiles.loadingViewpath", { viewPath })} ) : fileView.imageUrl ? ( ) : fileView.isBinary ? ( {t("sandboxFiles.viewpathIsABinaryFile", { viewPath })} {fileView.sizeBytes ?? 0} {t("sandboxFiles.bytes")} ) : fileView.content !== null ? ( <> {fileView.truncated && (
{t("sandboxFiles.largeFileShowingATruncated")} {fileView.sizeBytes ?? 0} {t("sandboxFiles.bytesLoadedEditingIsDisabled")}
)} {usePierre && focusLine === null ? ( {fileView.content} } className="p-1" /> ) : focusLine !== null ? ( ) : (
                      {fileView.content}
                    
)} ) : ( {t("sandboxFiles.loadingViewpath", { viewPath })} ) ) : // Nothing selected — the tree shows the whole workspace; pick a file. requestedPath && !requestedPathReady ? ( workspaceError ? (

{workspaceError.message}

{onWakeWorkspace ? ( {t("sandboxFiles.retryLiveFile")} ) : null}
) : ( } title={t("sandboxFiles.wakingSandbox")} announce="status" > {t("sandboxFiles.openingRequestedpathWhenTheLive", { requestedPath })} ) ) : ( } title={t("sandboxFiles.chooseAFile")} > {t("sandboxFiles.selectAFileInThe")} )}
); } function WakeButton({ children, onClick }: { children: ReactNode; onClick: () => void }) { return ( ); } function LineNumberedFile({ path, contents, focusLine, }: { path: string; contents: string; focusLine: number; }) { const t = useOpenGeniText(); const lines = contents.split("\n"); const inRange = focusLine >= 1 && focusLine <= lines.length; const targetRef = useRef(null); useEffect(() => { targetRef.current?.scrollIntoView({ block: "center", behavior: prefersReducedMotion() ? "auto" : "smooth", }); }, [path, focusLine, contents]); return (
{!inRange ? (

{t("sandboxFiles.lineFocuslineIsPastThe", { focusLine })}

) : null}
{lines.map((text, index) => { const lineNumber = index + 1; const focused = inRange && lineNumber === focusLine; return (
{lineNumber}
{text || " "}
); })}
); } function GitHeader({ git, loading }: { git: SandboxFilesGitSummary; loading: boolean }) { const t = useOpenGeniText(); if (loading) { return (
{t("sandboxFiles.loadingRepository")}
); } const dirty = git.dirtyCount > 0; return (
); } function Segmented({ options, value, onChange, }: { options: { value: string; label: string }[]; value: string; onChange: (value: string) => void; }) { return (
{options.map((opt) => ( ))}
); } type FileViewState = { content: string | null; imageUrl?: string | null; isBinary: boolean; /** The backend truncated the read (size cap hit) — content is a PREFIX only. * Editing+saving such a file would write the prefix back and corrupt it, so * the editor must stay read-only for a truncated read. */ truncated: boolean; sizeBytes: number | null; loading: boolean; error: Error | null; }; /** * Read a file's contents for the viewer pane. Calls `fs.read` (text by default; * the backend flags binary), decodes a base64 payload if one comes back, and * exposes loading/error/binary state. Re-fetches when the path changes; ignores * a stale resolve after the selection moves on. */ function useFileView( path: string | null, readFile: UseSandboxFilesResult["readFile"], reloadRevision: string | number = 0, ): FileViewState { const previousPathRef = useRef(null); const [state, setState] = useState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: false, error: null, }); useEffect(() => { if (!path) { previousPathRef.current = null; setState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: false, error: null, }); return; } const pathChanged = previousPathRef.current !== path; previousPathRef.current = path; let cancelled = false; const abort = new AbortController(); if (pathChanged) { setState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: true, error: null, }); } else { // Keep the last successful preview visible while a remote Modal read // revalidates it. Replacing useful content with a multi-second spinner // makes a live workspace feel slower and hides the state being refreshed. setState((previous) => ({ ...previous, error: null })); } void readFile(path, { signal: abort.signal }) .then((res) => { if (cancelled) return; const content = res.isBinary ? null : res.encoding === "base64" ? decodeBase64Utf8(res.content) : res.content; setState({ content, imageUrl: !res.truncated && res.encoding === "base64" ? rasterImageUrl(path, res.content) : null, isBinary: res.isBinary, truncated: res.truncated, sizeBytes: res.sizeBytes, loading: false, error: null, }); }) .catch((cause) => { if (cancelled) return; const error = cause instanceof Error ? cause : new Error(String(cause)); setState((previous) => pathChanged ? { content: null, isBinary: false, truncated: false, sizeBytes: null, loading: false, error, } : { ...previous, loading: false, error }, ); }); return () => { cancelled = true; abort.abort(); }; }, [path, readFile, reloadRevision]); return state; } /** Only raster formats belong in this preview; never interpret SVG/HTML as a document. */ function rasterImageUrl(path: string, content: string): string | null { const extension = path.split(".").pop()?.toLowerCase(); const mime = extension === "png" ? "image/png" : extension === "jpg" || extension === "jpeg" ? "image/jpeg" : extension === "gif" ? "image/gif" : extension === "webp" ? "image/webp" : null; return mime ? `data:${mime};base64,${content}` : null; } function RasterFilePreview({ src, path }: { src: string; path: string }) { const t = useOpenGeniText(); const [failed, setFailed] = useState(false); return failed ? ( {t("sandboxFiles.couldNotDecodePathAs", { path })} ) : ( {path} setFailed(true)} className="max-w-full object-contain" /> ); } /** Decode a base64 payload to a UTF-8 string (browser `atob` + TextDecoder). */ function decodeBase64Utf8(b64: string): string { try { if (typeof atob === "function") { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new TextDecoder().decode(bytes); } } catch { /* fall through to returning the raw payload */ } return b64; } function Notice({ children, className, icon, title, announce, }: { children: ReactNode; className?: string | undefined; icon?: ReactNode | undefined; title?: string | undefined; announce?: "status" | "alert" | undefined; }) { return (
{icon ? ( {icon} ) : null} {title ?

{title}

: null}
{children}
); }