import { Spinner } from "~/components/ui/spinner";import type { ChatFileAttachment, EditorId, EnvironmentId, ResolvedKeybindingsConfig, ScopedThreadRef,} from "@t3tools/contracts";import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath,} from "@t3tools/shared/filePreview";import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";import { Editor } from "@pierre/diffs/editor";import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";import { isAtomCommandInterrupted, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { mediaFileReference } from "@t3tools/client-runtime/media-reference";import { Code2, Eye, FolderTree, Globe2 } from "lucide-react";import * as Schema from "effect/Schema";import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";import { OpenInPicker } from "~/components/chat/OpenInPicker";import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";import { useRemoteOpenState } from "~/remoteOpen";import { useClientSettings } from "~/hooks/useSettings";import { useTheme } from "~/hooks/useTheme";import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering";import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";import { cn } from "~/lib/utils";import { isPreviewSupportedInRuntime } from "~/previewStateStore";import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";import { ScrollArea } from "~/components/ui/scroll-area";import { Toggle } from "~/components/ui/toggle";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { stackedThreadToast, toastManager } from "~/components/ui/toast";import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { buildFileReviewComment } from "~/reviewCommentContext";import { assetEnvironment } from "~/state/assets";import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";import { previewEnvironment } from "~/state/preview";import { useAtomCommand } from "~/state/use-atom-command";import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import FileBrowserPanel from "./FileBrowserPanel";import { FileBreadcrumbs } from "./FileBreadcrumbs";import { FileMarkdownPreview } from "./FileMarkdownPreview";import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, type FileCommentLineAnnotation, formatFileCommentRange, nextFileCommentId, normalizeFileCommentRange, remapFileCommentAnnotations,} from "./fileCommentAnnotations";import { installFileEditorDismissal } from "./fileEditorDismissal";import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";import { isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer,} from "./filePreviewMode";import { useFileSaveCoordinator } from "./useFileSaveCoordinator";import { getOptimisticProjectFileQueryData, setProjectFileQueryData, useProjectFileQuery,} from "./projectFilesQueryState"; interface FilePreviewPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; threadRef: ScopedThreadRef; composerDraftTarget: ScopedThreadRef | DraftId; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray<EditorId>; revealLine: number | null; revealRequestId: number; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; workspaceMutationId: string | null;} const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";const FILE_LINK_REVEAL_UNSAFE_CSS = ` ${DIFF_SURFACE_THEME_UNSAFE_CSS} diffs-container { --diffs-bg: var(--code-background, var(--background)) !important; --diffs-light-bg: var(--code-background, var(--background)) !important; --diffs-dark-bg: var(--code-background, var(--background)) !important; background-color: var(--code-background, var(--background)) !important; color: var(--code-foreground, var(--foreground)) !important; } [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { background-color: light-dark( color-mix( in lab, var(--diffs-computed-diff-line-bg) 82%, var(--diffs-bg-selection-override, var(--diffs-selection-base)) ), color-mix( in lab, var(--diffs-computed-diff-line-bg) 75%, var(--diffs-bg-selection-override, var(--diffs-selection-base)) ) ) !important; } [${FILE_LINK_REVEAL_ATTRIBUTE}][data-column-number] { background-color: light-dark( color-mix( in lab, var(--diffs-computed-diff-line-bg) 75%, var(--diffs-bg-selection-number-override, var(--diffs-selection-base)) ), color-mix( in lab, var(--diffs-computed-diff-line-bg) 60%, var(--diffs-bg-selection-number-override, var(--diffs-selection-base)) ) ) !important; color: var(--diffs-selection-number-fg) !important; }`;type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>; function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; readonly workspaceRoot: string; readonly alt: string; readonly workspaceMutationId: string | null;}) { const resource = useMemo( () => ({ _tag: "workspace-file" as const, threadId: props.threadRef.threadId, path: props.absolutePath, }), [props.threadRef.threadId, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState<string | null>(null); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; const actionsSource: MediaActionSource = { kind: "image", name: props.alt, src: imageUrl, reference: mediaFileReference(props.absolutePath, props.workspaceRoot), asset: { environmentId: props.environmentId, resource }, }; if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return ( <MediaActions source={actionsSource}> <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> Unable to load workspace image. </div> </MediaActions> ); } return assetUrl._tag === "Success" && imageUrl !== null ? ( <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4"> <MediaActions source={actionsSource}> <img className="max-h-full max-w-full object-contain" src={imageUrl} alt={props.alt} onError={() => setFailedUrl(imageUrl)} /> </MediaActions> </div> ) : ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <Spinner className="size-5" /> </div> );} const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); function BrowserDocumentFrame(props: { readonly src: string; readonly title: string; readonly pdf: boolean;}) { const className = "min-h-0 flex-1 border-0 bg-white"; // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts. return props.pdf ? ( // oxlint-disable-next-line react/iframe-missing-sandbox <iframe key={props.src} src={props.src} title={props.title} className={className} /> ) : ( <iframe key={props.src} src={props.src} title={props.title} className={className} sandbox="allow-scripts allow-forms allow-popups allow-modals" /> );} function AttachmentBrowserPreview(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment;}) { const resource = useMemo( () => ({ _tag: "attachment" as const, attachmentId: props.attachment.id, fileName: props.attachment.name, mimeType: props.attachment.mimeType, disposition: "inline" as const, }), [props.attachment.id, props.attachment.mimeType, props.attachment.name], ); const assetUrl = useAssetUrlState(props.environmentId, resource); if (assetUrl._tag === "Failure") { return ( <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> Unable to load attachment preview. </div> ); } if (assetUrl._tag !== "Success") { return ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <Spinner className="size-5" /> </div> ); } return ( <BrowserDocumentFrame src={assetUrl.url} title={props.attachment.name} pdf={ isPdfPreviewFile(props.attachment.name) || props.attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() === "application/pdf" } /> );} /** * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in * a sandboxed frame with an opaque origin, so a page cannot reach the app's * session or storage. A file inside the workspace may load sibling assets; a * host file outside it is served on its own. */function WorkspaceBrowserPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; readonly workspaceRoot: string; readonly title: string; readonly workspaceMutationId: string | null;}) { const insideWorkspace = mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined; const resource = useMemo( () => ({ _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const), threadId: props.threadRef.threadId, path: props.absolutePath, }), [insideWorkspace, props.threadRef.threadId, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; if (assetUrl._tag === "Failure") { return ( <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> Unable to load file preview. </div> ); } if (assetUrl._tag !== "Success") { return ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <Spinner className="size-5" /> </div> ); } return ( <BrowserDocumentFrame src={`${assetUrl.url}${revisionSuffix}`} title={props.title} pdf={isPdfPreviewFile(props.absolutePath)} /> );} function WorkspaceVideoPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; readonly workspaceRoot: string; readonly name: string; readonly workspaceMutationId: string | null;}) { const resource = useMemo( () => ({ _tag: "media-file" as const, threadId: props.threadRef.threadId, path: props.absolutePath, }), [props.threadRef.threadId, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); useWorkspaceMutationRefresh({ mutationId: props.workspaceMutationId, resourceKey: JSON.stringify([props.environmentId, resource]), refresh: () => { // Failed refreshes flow through assetUrl and can be retried from the player. void refreshAssetUrl().catch(() => undefined); }, }); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; return ( <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4"> <MediaVideoPlayer src={latestUrl} sourceFailed={assetUrl._tag === "Failure"} label={props.name} revision={props.workspaceMutationId} preload="metadata" className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center" onRetry={refreshAssetUrl} actionsSource={{ kind: "video", name: props.name, src: latestUrl, reference: mediaFileReference(props.absolutePath, props.workspaceRoot), asset: { environmentId: props.environmentId, resource }, }} /> </div> );} function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { const character = contents.charCodeAt(index); if (character === 10) { lineCount += 1; } else if (character === 13) { lineCount += 1; if (contents.charCodeAt(index + 1) === 10) index += 1; } } return Math.min(Math.max(1, requestedLine), lineCount);} function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): void { const root = fileContainer.shadowRoot ?? fileContainer; for (const element of root.querySelectorAll<HTMLElement>(`[${FILE_LINK_REVEAL_ATTRIBUTE}]`)) { element.removeAttribute(FILE_LINK_REVEAL_ATTRIBUTE); } if (line === null) return; root .querySelector<HTMLElement>(`[data-line="${line}"]`) ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); root .querySelector<HTMLElement>(`[data-column-number="${line}"]`) ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");} /** * Frames to keep retrying while the file contents or line metrics are not * available yet (fresh mounts hydrate asynchronously). */const REVEAL_MAX_ATTEMPTS = 30;/** * After scrolling to the target, hold it for a short window so late * programmatic scroll resets (editable-editor focus and state restoration) * cannot silently snap the file back to the top. Real user input cancels the * guard immediately. */const REVEAL_GUARD_FRAMES = 20;const REVEAL_GUARD_TOLERANCE_PX = 2; interface FileRevealState { frameId: number | null; cancelGuard: (() => void) | null; handledRequestId: number | null; latestRequestId: number | null;} function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number,): FilePostRender { const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>()); return useCallback<FilePostRender>( (fileContainer, instance, phase) => { if (relativePath === null) return; const existingState = revealStatesByPath.get(relativePath); const state: FileRevealState = existingState ?? { frameId: null, cancelGuard: null, handledRequestId: null, latestRequestId: null, }; if (!existingState) revealStatesByPath.set(relativePath, state); const cancelPendingReveal = () => { if (state.frameId !== null) { cancelAnimationFrame(state.frameId); state.frameId = null; } state.cancelGuard?.(); }; if (phase === "unmount") { cancelPendingReveal(); return; } const contents = instance.file?.contents; const targetLine = revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; if (state.latestRequestId !== revealRequestId) { cancelPendingReveal(); state.latestRequestId = revealRequestId; state.handledRequestId = null; } if (revealLine === null) { fileContainer.style.minHeight = ""; return; } const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer"); if (!scrollContainer) return; fileContainer.style.minHeight = `${Math.ceil( Math.max(instance.height, scrollContainer.clientHeight), )}px`; if (state.handledRequestId === revealRequestId || state.frameId !== null) { return; } const resolveScrollTarget = (line: number): number | null => { const linePosition = instance.getLinePosition(line); if (!linePosition) return null; const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - scrollContainerRect.top; const root = fileContainer.shadowRoot ?? fileContainer; const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`);Lines 1–500 of 1358. Load more
import { Spinner } from "~/components/ui/spinner"; import type { ChatFileAttachment, EditorId, EnvironmentId, ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { Code2, Eye, FolderTree, Globe2 } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import FileBrowserPanel from "./FileBrowserPanel"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, type FileCommentLineAnnotation, formatFileCommentRange, nextFileCommentId, normalizeFileCommentRange, remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, setProjectFileQueryData, useProjectFileQuery, } from "./projectFilesQueryState"; interface FilePreviewPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; threadRef: ScopedThreadRef; composerDraftTarget: ScopedThreadRef | DraftId; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray<EditorId>; revealLine: number | null; revealRequestId: number; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; workspaceMutationId: string | null; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; const FILE_LINK_REVEAL_UNSAFE_CSS = ` ${DIFF_SURFACE_THEME_UNSAFE_CSS} diffs-container { --diffs-bg: var(--code-background, var(--background)) !important; --diffs-light-bg: var(--code-background, var(--background)) !important; --diffs-dark-bg: var(--code-background, var(--background)) !important; background-color: var(--code-background, var(--background)) !important; color: var(--code-foreground, var(--foreground)) !important; } [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { background-color: light-dark( color-mix( in lab, var(--diffs-computed-diff-line-bg) 82%, var(--diffs-bg-selection-override, var(--diffs-selection-base)) ), color-mix( in lab, var(--diffs-computed-diff-line-bg) 75%, var(--diffs-bg-selection-override, var(--diffs-selection-base)) ) ) !important; } [${FILE_LINK_REVEAL_ATTRIBUTE}][data-column-number] { background-color: light-dark( color-mix( in lab, var(--diffs-computed-diff-line-bg) 75%, var(--diffs-bg-selection-number-override, var(--diffs-selection-base)) ), color-mix( in lab, var(--diffs-computed-diff-line-bg) 60%, var(--diffs-bg-selection-number-override, var(--diffs-selection-base)) ) ) !important; color: var(--diffs-selection-number-fg) !important; } `; type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>; function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; readonly workspaceRoot: string; readonly alt: string; readonly workspaceMutationId: string | null; }) { const resource = useMemo( () => ({ _tag: "workspace-file" as const, threadId: props.threadRef.threadId, path: props.absolutePath, }), [props.threadRef.threadId, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState<string | null>(null); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; const actionsSource: MediaActionSource = { kind: "image", name: props.alt, src: imageUrl, reference: mediaFileReference(props.absolutePath, props.workspaceRoot), asset: { environmentId: props.environmentId, resource }, }; if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return ( <MediaActions source={actionsSource}> <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> Unable to load workspace image. </div> </MediaActions> ); } return assetUrl._tag === "Success" && imageUrl !== null ? ( <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4"> <MediaActions source={actionsSource}> <img className="max-h-full max-w-full object-contain" src={imageUrl} alt={props.alt} onError={() => setFailedUrl(imageUrl)} /> </MediaActions> </div> ) : ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <Spinner className="size-5" /> </div> ); } const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); function BrowserDocumentFrame(props: { readonly src: string; readonly title: string; readonly pdf: boolean; }) { const className = "min-h-0 flex-1 border-0 bg-white"; // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts. return props.pdf ? ( // oxlint-disable-next-line react/iframe-missing-sandbox <iframe key={props.src} src={props.src} title={props.title} className={className} /> ) : ( <iframe key={props.src} src={props.src} title={props.title} className={className} sandbox="allow-scripts allow-forms allow-popups allow-modals" /> ); } function AttachmentBrowserPreview(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; }) { const resource = useMemo( () => ({ _tag: "attachment" as const, attachmentId: props.attachment.id, fileName: props.attachment.name, mimeType: props.attachment.mimeType, disposition: "inline" as const, }), [props.attachment.id, props.attachment.mimeType, props.attachment.name], ); const assetUrl = useAssetUrlState(props.environmentId, resource); if (assetUrl._tag === "Failure") { return ( <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> Unable to load attachment preview. </div> ); } if (assetUrl._tag !== "Success") { return ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <Spinner className="size-5" /> </div> ); } return ( <BrowserDocumentFrame src={assetUrl.url} title={props.attachment.name} pdf={ isPdfPreviewFile(props.attachment.name) || props.attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() === "application/pdf" } /> ); } /** * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in * a sandboxed frame with an opaque origin, so a page cannot reach the app's * session or storage. A file inside the workspace may load sibling assets; a * host file outside it is served on its own. */ function WorkspaceBrowserPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; readonly workspaceRoot: string; readonly title: string; readonly workspaceMutationId: string | null; }) { const insideWorkspace = mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined; const resource = useMemo( () => ({ _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const), threadId: props.threadRef.threadId, path: props.absolutePath, }), [insideWorkspace, props.threadRef.threadId, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; if (assetUrl._tag === "Failure") { return ( <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> Unable to load file preview. </div> ); } if (assetUrl._tag !== "Success") { return ( <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> <Spinner className="size-5" /> </div> ); } return ( <BrowserDocumentFrame src={`${assetUrl.url}${revisionSuffix}`} title={props.title} pdf={isPdfPreviewFile(props.absolutePath)} /> ); } function WorkspaceVideoPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; readonly workspaceRoot: string; readonly name: string; readonly workspaceMutationId: string | null; }) { const resource = useMemo( () => ({ _tag: "media-file" as const, threadId: props.threadRef.threadId, path: props.absolutePath, }), [props.threadRef.threadId, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); useWorkspaceMutationRefresh({ mutationId: props.workspaceMutationId, resourceKey: JSON.stringify([props.environmentId, resource]), refresh: () => { // Failed refreshes flow through assetUrl and can be retried from the player. void refreshAssetUrl().catch(() => undefined); }, }); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; return ( <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4"> <MediaVideoPlayer src={latestUrl} sourceFailed={assetUrl._tag === "Failure"} label={props.name} revision={props.workspaceMutationId} preload="metadata" className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center" onRetry={refreshAssetUrl} actionsSource={{ kind: "video", name: props.name, src: latestUrl, reference: mediaFileReference(props.absolutePath, props.workspaceRoot), asset: { environmentId: props.environmentId, resource }, }} /> </div> ); } function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { const character = contents.charCodeAt(index); if (character === 10) { lineCount += 1; } else if (character === 13) { lineCount += 1; if (contents.charCodeAt(index + 1) === 10) index += 1; } } return Math.min(Math.max(1, requestedLine), lineCount); } function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): void { const root = fileContainer.shadowRoot ?? fileContainer; for (const element of root.querySelectorAll<HTMLElement>(`[${FILE_LINK_REVEAL_ATTRIBUTE}]`)) { element.removeAttribute(FILE_LINK_REVEAL_ATTRIBUTE); } if (line === null) return; root .querySelector<HTMLElement>(`[data-line="${line}"]`) ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); root .querySelector<HTMLElement>(`[data-column-number="${line}"]`) ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } /** * Frames to keep retrying while the file contents or line metrics are not * available yet (fresh mounts hydrate asynchronously). */ const REVEAL_MAX_ATTEMPTS = 30; /** * After scrolling to the target, hold it for a short window so late * programmatic scroll resets (editable-editor focus and state restoration) * cannot silently snap the file back to the top. Real user input cancels the * guard immediately. */ const REVEAL_GUARD_FRAMES = 20; const REVEAL_GUARD_TOLERANCE_PX = 2; interface FileRevealState { frameId: number | null; cancelGuard: (() => void) | null; handledRequestId: number | null; latestRequestId: number | null; } function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>()); return useCallback<FilePostRender>( (fileContainer, instance, phase) => { if (relativePath === null) return; const existingState = revealStatesByPath.get(relativePath); const state: FileRevealState = existingState ?? { frameId: null, cancelGuard: null, handledRequestId: null, latestRequestId: null, }; if (!existingState) revealStatesByPath.set(relativePath, state); const cancelPendingReveal = () => { if (state.frameId !== null) { cancelAnimationFrame(state.frameId); state.frameId = null; } state.cancelGuard?.(); }; if (phase === "unmount") { cancelPendingReveal(); return; } const contents = instance.file?.contents; const targetLine = revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; if (state.latestRequestId !== revealRequestId) { cancelPendingReveal(); state.latestRequestId = revealRequestId; state.handledRequestId = null; } if (revealLine === null) { fileContainer.style.minHeight = ""; return; } const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer"); if (!scrollContainer) return; fileContainer.style.minHeight = `${Math.ceil( Math.max(instance.height, scrollContainer.clientHeight), )}px`; if (state.handledRequestId === revealRequestId || state.frameId !== null) { return; } const resolveScrollTarget = (line: number): number | null => { const linePosition = instance.getLinePosition(line); if (!linePosition) return null; const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - scrollContainerRect.top; const root = fileContainer.shadowRoot ?? fileContainer; const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`);