import type { EnvironmentId } from "@t3tools/contracts";import { Editor } from "@pierre/diffs/editor";import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react";import { ChevronRight, FolderTree, LoaderCircle } from "lucide-react";import { useEffect, useMemo, useRef, useState } from "react"; import { ensureEnvironmentApi } from "~/environmentApi";import { useTheme } from "~/hooks/useTheme";import { resolveDiffThemeName } from "~/lib/diffRendering";import { cn } from "~/lib/utils";import { ScrollArea } from "~/components/ui/scroll-area";import { Toggle } from "~/components/ui/toggle";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import FileBrowserPanel from "./FileBrowserPanel";import { projectFileCacheKey } from "./fileContentRevision";import { fileBreadcrumbs } from "./filePath";import { FileSaveCoordinator } from "./fileSaveCoordinator";import { confirmProjectFileQueryData, setProjectFileQueryData, useProjectFileQuery,} from "./projectFilesQueryState"; interface FilePreviewPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; relativePath: string | null; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void;} const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";const FILE_SAVE_DEBOUNCE_MS = 500; interface EditableFileSurfaceProps { environmentId: EnvironmentId; cwd: string; relativePath: string; contents: string; resolvedTheme: "light" | "dark"; onPendingChange: (relativePath: string, pending: boolean) => void;} function EditableFileSurface({ environmentId, cwd, relativePath, contents, resolvedTheme, onPendingChange,}: EditableFileSurfaceProps) { const saveCoordinator = useMemo( () => new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, onPendingChange: (pending) => onPendingChange(relativePath, pending), persist: async (nextContents) => { await ensureEnvironmentApi(environmentId).projects.writeFile({ cwd, relativePath, contents: nextContents, }); }, onConfirmed: (confirmedContents) => { confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); }, }), [cwd, environmentId, onPendingChange, relativePath], ); const editor = useMemo( () => new Editor({ onChange: (file) => { setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); saveCoordinator.change(file.contents); }, }), [cwd, environmentId, relativePath, saveCoordinator], ); useEffect( () => () => { editor.cleanUp(); saveCoordinator.dispose(); }, [editor, saveCoordinator], ); return ( <EditorProvider editor={editor}> <Virtualizer className="file-preview-virtualizer min-h-0 flex-1 overflow-auto" config={{ overscrollSize: 600, intersectionObserverMargin: 1200, }} > <File file={{ name: relativePath, contents, cacheKey: projectFileCacheKey(cwd, relativePath, contents), }} options={{ disableFileHeader: true, overflow: "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme, }} className="min-h-full" contentEditable /> </Virtualizer> </EditorProvider> );} function initialExplorerOpen(): boolean { try { return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; } catch { return true; }} export default function FilePreviewPanel({ environmentId, cwd, projectName, relativePath, onOpenFile, onPendingChange,}: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); const file = useProjectFileQuery(environmentId, cwd, relativePath); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); const breadcrumbRef = useRef<HTMLDivElement>(null); const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), [projectName, relativePath], ); useEffect(() => { const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>( "[data-current-file-crumb='true']", ); currentCrumb?.scrollIntoView({ block: "nearest", inline: "end" }); }, [relativePath]); const toggleExplorer = () => { setExplorerOpen((current) => { const next = !current; try { window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next)); } catch {} return next; }); }; return ( <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"> {relativePath ? ( <div className="surface-subheader gap-2 px-3" data-surface-subheader> <ScrollArea ref={breadcrumbRef} hideScrollbars scrollFade className="min-w-0 flex-1 rounded-none" data-file-breadcrumbs > <div className="flex h-full w-max min-w-full items-center text-xs"> {breadcrumbs.map((crumb, index) => ( <div key={crumb.path || "project"} className="flex min-w-0 shrink-0 items-center" data-current-file-crumb={crumb.kind === "file"} > {index > 0 ? ( <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" /> ) : null} <span className={cn( "max-w-40 truncate", crumb.kind === "file" ? "font-medium text-foreground" : "text-muted-foreground", )} title={crumb.path || projectName} > {crumb.label} </span> </div> ))} </div> </ScrollArea> <Tooltip> <TooltipTrigger render={ <Toggle className="shrink-0" pressed={explorerOpen} onPressedChange={toggleExplorer} aria-label={explorerOpen ? "Hide file explorer" : "Show file explorer"} variant="ghost" size="sm" > <FolderTree className="size-3.5" /> </Toggle> } /> <TooltipPopup> {explorerOpen ? "Hide file explorer" : "Show file explorer"} </TooltipPopup> </Tooltip> </div> ) : null} {relativePath && file.data?.truncated ? ( <div className="shrink-0 border-b border-amber-500/20 bg-amber-500/8 px-3 py-1.5 text-[11px] text-amber-700 dark:text-amber-300"> Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. </div> ) : null} <div className="flex min-h-0 flex-1 overflow-hidden"> <div className={cn( "min-w-0 flex-1 flex-col overflow-hidden", relativePath ? "flex" : "hidden", )} > {relativePath && file.error && file.data === null ? ( <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> {file.error} </div> ) : relativePath && file.data === null ? ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <LoaderCircle className="size-5 animate-spin" /> </div> ) : relativePath && file.data ? ( file.data.truncated ? ( <Virtualizer key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`} className="file-preview-virtualizer min-h-0 flex-1 overflow-auto" config={{ overscrollSize: 600, intersectionObserverMargin: 1200, }} > <File file={{ name: relativePath, contents: file.data.contents, cacheKey: projectFileCacheKey(cwd, relativePath, file.data.contents), }} options={{ disableFileHeader: true, overflow: "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme, }} className="min-h-full" /> </Virtualizer> ) : ( <EditableFileSurface key={`${relativePath}:${resolvedTheme}`} environmentId={environmentId} cwd={cwd} relativePath={relativePath} contents={file.data.contents} resolvedTheme={resolvedTheme} onPendingChange={onPendingChange} /> ) ) : null} </div> {explorerOpen || relativePath === null ? ( <aside className={cn( "flex min-h-0 shrink-0 bg-background", relativePath ? "w-[min(22rem,46%)] min-w-64 border-l border-border/60" : "min-w-0 flex-1", )} > <FileBrowserPanel key={`${environmentId}:${cwd}`} environmentId={environmentId} cwd={cwd} projectName={projectName} onOpenFile={onOpenFile} /> </aside> ) : null} </div> </div> );}
import type { EnvironmentId } from "@t3tools/contracts";
import { Editor } from "@pierre/diffs/editor";
import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react";
import { ChevronRight, FolderTree, LoaderCircle } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ensureEnvironmentApi } from "~/environmentApi";
import { useTheme } from "~/hooks/useTheme";
import { resolveDiffThemeName } from "~/lib/diffRendering";
import { cn } from "~/lib/utils";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Toggle } from "~/components/ui/toggle";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import FileBrowserPanel from "./FileBrowserPanel";
import { projectFileCacheKey } from "./fileContentRevision";
import { fileBreadcrumbs } from "./filePath";
import { FileSaveCoordinator } from "./fileSaveCoordinator";
import {
confirmProjectFileQueryData,
setProjectFileQueryData,
useProjectFileQuery,
} from "./projectFilesQueryState";
interface FilePreviewPanelProps {
environmentId: EnvironmentId;
cwd: string;
projectName: string;
relativePath: string | null;
onOpenFile: (relativePath: string) => void;
onPendingChange: (relativePath: string, pending: boolean) => void;
}
const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
const FILE_SAVE_DEBOUNCE_MS = 500;
interface EditableFileSurfaceProps {
environmentId: EnvironmentId;
cwd: string;
relativePath: string;
contents: string;
resolvedTheme: "light" | "dark";
onPendingChange: (relativePath: string, pending: boolean) => void;
}
function EditableFileSurface({
environmentId,
cwd,
relativePath,
contents,
resolvedTheme,
onPendingChange,
}: EditableFileSurfaceProps) {
const saveCoordinator = useMemo(
() =>
new FileSaveCoordinator({
debounceMs: FILE_SAVE_DEBOUNCE_MS,
onPendingChange: (pending) => onPendingChange(relativePath, pending),
persist: async (nextContents) => {
await ensureEnvironmentApi(environmentId).projects.writeFile({
cwd,
relativePath,
contents: nextContents,
});
},
onConfirmed: (confirmedContents) => {
confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents);
},
}),
[cwd, environmentId, onPendingChange, relativePath],
);
const editor = useMemo(
() =>
new Editor({
onChange: (file) => {
setProjectFileQueryData(environmentId, cwd, relativePath, file.contents);
saveCoordinator.change(file.contents);
},
}),
[cwd, environmentId, relativePath, saveCoordinator],
);
useEffect(
() => () => {
editor.cleanUp();
saveCoordinator.dispose();
},
[editor, saveCoordinator],
);
return (
<EditorProvider editor={editor}>
<Virtualizer
className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
config={{
overscrollSize: 600,
intersectionObserverMargin: 1200,
}}
>
<File
file={{
name: relativePath,
contents,
cacheKey: projectFileCacheKey(cwd, relativePath, contents),
}}
options={{
disableFileHeader: true,
overflow: "scroll",
theme: resolveDiffThemeName(resolvedTheme),
themeType: resolvedTheme,
}}
className="min-h-full"
contentEditable
/>
</Virtualizer>
</EditorProvider>
);
}
function initialExplorerOpen(): boolean {
try {
return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false";
} catch {
return true;
}
}
export default function FilePreviewPanel({
environmentId,
cwd,
projectName,
relativePath,
onOpenFile,
onPendingChange,
}: FilePreviewPanelProps) {
const { resolvedTheme } = useTheme();
const file = useProjectFileQuery(environmentId, cwd, relativePath);
const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
const breadcrumbRef = useRef<HTMLDivElement>(null);
const breadcrumbs = useMemo(
() => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
[projectName, relativePath],
);
useEffect(() => {
const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
"[data-current-file-crumb='true']",
);
currentCrumb?.scrollIntoView({ block: "nearest", inline: "end" });
}, [relativePath]);
const toggleExplorer = () => {
setExplorerOpen((current) => {
const next = !current;
try {
window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next));
} catch {}
return next;
});
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{relativePath ? (
<div className="surface-subheader gap-2 px-3" data-surface-subheader>
<ScrollArea
ref={breadcrumbRef}
hideScrollbars
scrollFade
className="min-w-0 flex-1 rounded-none"
data-file-breadcrumbs
>
<div className="flex h-full w-max min-w-full items-center text-xs">
{breadcrumbs.map((crumb, index) => (
<div
key={crumb.path || "project"}
className="flex min-w-0 shrink-0 items-center"
data-current-file-crumb={crumb.kind === "file"}
>
{index > 0 ? (
<ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
) : null}
<span
className={cn(
"max-w-40 truncate",
crumb.kind === "file"
? "font-medium text-foreground"
: "text-muted-foreground",
)}
title={crumb.path || projectName}
>
{crumb.label}
</span>
</div>
))}
</div>
</ScrollArea>
<Tooltip>
<TooltipTrigger
render={
<Toggle
className="shrink-0"
pressed={explorerOpen}
onPressedChange={toggleExplorer}
aria-label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
variant="ghost"
size="sm"
>
<FolderTree className="size-3.5" />
</Toggle>
}
/>
<TooltipPopup>
{explorerOpen ? "Hide file explorer" : "Show file explorer"}
</TooltipPopup>
</Tooltip>
</div>
) : null}
{relativePath && file.data?.truncated ? (
<div className="shrink-0 border-b border-amber-500/20 bg-amber-500/8 px-3 py-1.5 text-[11px] text-amber-700 dark:text-amber-300">
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
</div>
) : null}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div
className={cn(
"min-w-0 flex-1 flex-col overflow-hidden",
relativePath ? "flex" : "hidden",
)}
>
{relativePath && file.error && file.data === null ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
{file.error}
</div>
) : relativePath && file.data === null ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
<LoaderCircle className="size-5 animate-spin" />
</div>
) : relativePath && file.data ? (
file.data.truncated ? (
<Virtualizer
key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
config={{
overscrollSize: 600,
intersectionObserverMargin: 1200,
}}
>
<File
file={{
name: relativePath,
contents: file.data.contents,
cacheKey: projectFileCacheKey(cwd, relativePath, file.data.contents),
}}
options={{
disableFileHeader: true,
overflow: "scroll",
theme: resolveDiffThemeName(resolvedTheme),
themeType: resolvedTheme,
}}
className="min-h-full"
/>
</Virtualizer>
) : (
<EditableFileSurface
key={`${relativePath}:${resolvedTheme}`}
environmentId={environmentId}
cwd={cwd}
relativePath={relativePath}
contents={file.data.contents}
resolvedTheme={resolvedTheme}
onPendingChange={onPendingChange}
/>
)
) : null}
</div>
{explorerOpen || relativePath === null ? (
<aside
className={cn(
"flex min-h-0 shrink-0 bg-background",
relativePath
? "w-[min(22rem,46%)] min-w-64 border-l border-border/60"
: "min-w-0 flex-1",
)}
>
<FileBrowserPanel
key={`${environmentId}:${cwd}`}
environmentId={environmentId}
cwd={cwd}
projectName={projectName}
onOpenFile={onOpenFile}
/>
</aside>
) : null}
</div>
</div>
);
}