1358 lines · 47.9 KB · TypeScript
RawDownload
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.