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. Load more
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;