951 lines · 32.0 KB · TypeScript
RawDownload
import type {  EditorId,  EnvironmentId,  ResolvedKeybindingsConfig,  ScopedThreadRef,} from "@t3tools/contracts";import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";import { Editor } from "@pierre/diffs/editor";import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";import * as Schema from "effect/Schema";import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";import { useAssetUrlState } from "~/assets/assetUrls";import ChatMarkdown from "~/components/ChatMarkdown";import { OpenInPicker } from "~/components/chat/OpenInPicker";import { useClientSettings } from "~/hooks/useSettings";import { useTheme } from "~/hooks/useTheme";import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";import { resolveDiffThemeName } from "~/lib/diffRendering";import { cn } from "~/lib/utils";import { isPreviewSupportedInRuntime } from "~/previewStateStore";import { 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 { projectEnvironment } from "~/state/projects";import { useAtomCommand } from "~/state/use-atom-command";import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import FileBrowserPanel from "./FileBrowserPanel";import {  type FileCommentAnnotationEntry,  type FileCommentAnnotationGroup,  type FileCommentLineAnnotation,  formatFileCommentRange,  nextFileCommentId,  normalizeFileCommentRange,  remapFileCommentAnnotations,} from "./fileCommentAnnotations";import { installFileEditorDismissal } from "./fileEditorDismissal";import { LocalCommentAnnotation } from "./LocalCommentAnnotation";import { projectFileCacheKey } from "./fileContentRevision";import { fileBreadcrumbs } from "./filePath";import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";import { FileSaveCoordinator } from "./fileSaveCoordinator";import {  confirmProjectFileQueryData,  getOptimisticProjectFileQueryData,  setProjectFileQueryData,  useProjectFileQuery,} from "./projectFilesQueryState"; interface FilePreviewPanelProps {  environmentId: EnvironmentId;  cwd: string;  projectName: string;  relativePath: string | null;  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;} const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";const FILE_SAVE_DEBOUNCE_MS = 500;const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";const FILE_LINK_REVEAL_UNSAFE_CSS = `  [${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 alt: string;}) {  const assetUrl = useAssetUrlState(props.environmentId, {    _tag: "workspace-file",    threadId: props.threadRef.threadId,    path: props.absolutePath,  });  const [failedUrl, setFailedUrl] = useState<string | null>(null);   if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {    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 workspace image.      </div>    );  }   return assetUrl._tag === "Success" ? (    <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">      <img        className="max-h-full max-w-full object-contain"        src={assetUrl.url}        alt={props.alt}        onError={() => setFailedUrl(assetUrl.url)}      />    </div>  ) : (    <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">      <LoaderCircle className="size-5 animate-spin" />    </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, "");} function useFileLineReveal(  relativePath: string | null,  revealLine: number | null,  revealRequestId: number,): FilePostRender {  const [handledRequestIdsByPath] = useState(() => new Map<string, number>());  const [latestRequestIdsByPath] = useState(() => new Map<string, number>());  const [pendingFramesByPath] = useState(() => new Map<string, number>());   return useCallback<FilePostRender>(    (fileContainer, instance, phase) => {      if (relativePath === null) return;       const cancelPendingReveal = () => {        const frameId = pendingFramesByPath.get(relativePath);        if (frameId !== undefined) {          cancelAnimationFrame(frameId);          pendingFramesByPath.delete(relativePath);        }      };       if (phase === "unmount") {        cancelPendingReveal();        return;      }       const targetLine =        revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine);      updateFileLinkReveal(fileContainer, targetLine);       if (!(instance instanceof VirtualizedFile)) return;       if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) {        cancelPendingReveal();        latestRequestIdsByPath.set(relativePath, revealRequestId);      }       if (targetLine === 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 (        handledRequestIdsByPath.get(relativePath) === revealRequestId ||        pendingFramesByPath.has(relativePath)      ) {        return;      }       const reveal = () => {        pendingFramesByPath.delete(relativePath);        if (          latestRequestIdsByPath.get(relativePath) !== revealRequestId ||          !fileContainer.isConnected        ) {          return;        }         const linePosition = instance.getLinePosition(targetLine);        if (!linePosition) return;         const fileTop =          scrollContainer.scrollTop +          fileContainer.getBoundingClientRect().top -          scrollContainer.getBoundingClientRect().top;        const centeredTop = Math.max(          0,          fileTop +            linePosition.top -            Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2),        );        const maxScrollTop = Math.max(          0,          scrollContainer.scrollHeight - scrollContainer.clientHeight,        );         scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop);        handledRequestIdsByPath.set(relativePath, revealRequestId);      };       pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal));    },    [      handledRequestIdsByPath,      latestRequestIdsByPath,      pendingFramesByPath,      relativePath,      revealLine,      revealRequestId,    ],  );} interface EditableFileSurfaceProps {  environmentId: EnvironmentId;  cwd: string;  relativePath: string;  composerDraftTarget: ScopedThreadRef | DraftId;  contents: string;  resolvedTheme: "light" | "dark";  revealRequestId: number;  wordWrap: boolean;  onPostRender: FilePostRender;  onPendingChange: (relativePath: string, pending: boolean) => void;} interface FileSelectionOverride {  revealRequestId: number;  range: SelectedLineRange | null;} function useFileSaveCoordinator({  environmentId,  cwd,  relativePath,  onPendingChange,}: Pick<  EditableFileSurfaceProps,  "environmentId" | "cwd" | "relativePath" | "onPendingChange">): FileSaveCoordinator {  const writeFile = useAtomCommand(projectEnvironment.writeFile);  const coordinator = useMemo(    () =>      new FileSaveCoordinator({        debounceMs: FILE_SAVE_DEBOUNCE_MS,        onPendingChange: (pending) => onPendingChange(relativePath, pending),        persist: (nextContents) =>          writeFile({            environmentId,            input: { cwd, relativePath, contents: nextContents },          }),        onConfirmed: (confirmedContents) => {          confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents);        },      }),    [cwd, environmentId, onPendingChange, relativePath, writeFile],  );   useEffect(() => () => coordinator.dispose(), [coordinator]);  return coordinator;} function EditableFileSurface({  environmentId,  cwd,  relativePath,  composerDraftTarget,  contents,  resolvedTheme,  revealRequestId,  wordWrap,  onPostRender,  onPendingChange,}: EditableFileSurfaceProps) {  const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);  const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);  const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]);  const [selectionOverride, setSelectionOverride] = useState<FileSelectionOverride | null>(null);  const selectedRange =    selectionOverride?.revealRequestId === revealRequestId ? selectionOverride.range : null;  const setSelectedRange = useCallback(    (range: SelectedLineRange | null) => {      setSelectionOverride({ revealRequestId, range });    },    [revealRequestId],  );  const surfaceRef = useRef<HTMLDivElement>(null);  const selectionFrameRef = useRef<number | null>(null);  const saveCoordinator = useFileSaveCoordinator({    environmentId,    cwd,    relativePath,    onPendingChange,  });  const editor = useMemo(    () =>      new Editor<FileCommentAnnotationGroup>({        onChange: (file, nextLineAnnotations) => {          setProjectFileQueryData(environmentId, cwd, relativePath, file.contents);          saveCoordinator.change(file.contents);          if (nextLineAnnotations) {            const remapped = remapFileCommentAnnotations(              nextLineAnnotations as FileCommentLineAnnotation[],            );            setLineAnnotations(remapped);            for (const annotation of remapped) {              for (const entry of annotation.metadata.entries) {                if (entry.kind !== "comment") continue;                addReviewComment(                  composerDraftTarget,                  buildFileReviewComment({                    id: entry.id,                    filePath: relativePath,                    startLine: entry.startLine,                    endLine: entry.endLine,                    text: entry.text,                    contents: file.contents,                  }),                );              }            }          }        },      }),    [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator],  );   useEffect(    () => () => {      editor.cleanUp();    },    [editor],  );   const removeAnnotationEntry = useCallback(    (entryId: string) => {      setSelectedRange(null);      removeReviewComment(composerDraftTarget, entryId);      setLineAnnotations((current) => {        return current.flatMap((annotation) => {          const entries = annotation.metadata.entries.filter((entry) => entry.id !== entryId);          return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : [];        });      });    },    [composerDraftTarget, removeReviewComment, setSelectedRange],  );   const submitAnnotationEntry = useCallback(    (entryId: string, text: string) => {      setSelectedRange(null);      const entry = lineAnnotations        .flatMap((annotation) => annotation.metadata.entries)        .find((candidate) => candidate.id === entryId);      if (entry) {        addReviewComment(          composerDraftTarget,          buildFileReviewComment({            id: entry.id,            filePath: relativePath,            startLine: entry.startLine,            endLine: entry.endLine,            text,            contents,          }),        );      }      setLineAnnotations((current) =>        current.map((annotation) => ({          ...annotation,          metadata: {            entries: annotation.metadata.entries.map((annotationEntry) =>              annotationEntry.id === entryId                ? { ...annotationEntry, kind: "comment", text }                : annotationEntry,            ),          },        })),      );    },    [      addReviewComment,      composerDraftTarget,      contents,      lineAnnotations,      relativePath,      setSelectedRange,    ],  );   const beginComment = useCallback((range: SelectedLineRange) => {    const { startLine, endLine } = normalizeFileCommentRange(range);    const draftEntry: FileCommentAnnotationEntry = {      id: nextFileCommentId(),      kind: "draft",      startLine,      endLine,      text: "",    };    setLineAnnotations((current) => {      const withoutDraft = current.flatMap((annotation) => {        const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft");        return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : [];      });      const existingIndex = withoutDraft.findIndex(        (annotation) => annotation.lineNumber === endLine,      );      if (existingIndex < 0) {        return [          ...withoutDraft,          {            lineNumber: endLine,            metadata: { entries: [draftEntry] },          },        ];      }      return withoutDraft.map((annotation, index) =>        index === existingIndex          ? {              ...annotation,              metadata: { entries: [...annotation.metadata.entries, draftEntry] },            }          : annotation,      );    });  }, []);  const hasOpenCommentForm = lineAnnotations.some((annotation) =>    annotation.metadata.entries.some((entry) => entry.kind === "draft"),  );  useEffect(() => {    const root = surfaceRef.current;

Lines 1–500 of 951.