1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult, Atom } from "effect/reactivity";
3import { projectEnvironment } from "~/state/projects";
4import { Spinner } from "~/components/ui/spinner";
5import {
6 AuthPreviewOperateScope,
7 type EditorId,
8 type EnvironmentId,
9 type ResolvedKeybindingsConfig,
10 type ScopedThreadRef,
11} from "@t3tools/contracts";
12import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
13import { AuthFilesystemWriteScope } from "@t3tools/contracts";
14import {
15 isWorkspaceAudioPreviewPath,
16 isWorkspaceImagePreviewPath,
17 isWorkspaceVideoPreviewPath,
18} from "@t3tools/shared/filePreview";
19import {
20 DEFAULT_TOKENIZE_MAX_LENGTH,
21 VirtualizedFile,
22 getFiletypeFromFileName,
23 type File as FileInstance,
24 type FileContents,
25 type PostRenderPhase,
26 type SelectedLineRange,
27} from "@pierre/diffs";
28import {
29 Editor,
30 type EditorChangeEvent,
31 type EditorFactory,
32 type EditorOptions,
33} from "@pierre/diffs/edit";
34import type { WorkerPoolManager } from "@pierre/diffs/worker";
35import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react";
36import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
37import { useFilesystemReadAccess } from "~/state/filesystem";
38import {
39 isAtomCommandInterrupted,
40 squashAtomCommandFailure,
41} from "@t3tools/client-runtime/state/runtime";
42import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
43import { FolderTree, Globe2, WrapTextIcon } from "lucide-react";
44import { Code2, Eye, Table2 } from "lucide";
45import * as Schema from "effect/Schema";
46import { useCallback, useEffect, useMemo, useRef, useState } from "react";
47
48import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
49import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
50import { OpenInPicker } from "~/components/chat/OpenInPicker";
51import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
52import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
53import { MorphIcon } from "~/components/MorphIcon";
54import { useRemoteOpenState } from "~/remoteOpen";
55import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
56import { useTheme } from "~/hooks/useTheme";
57import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
58import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
59import { resolveDiffThemeName } from "~/lib/diffRendering";
60import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
61import { cn } from "~/lib/utils";
62import type { ChatFileAttachment } from "~/types";
63import { isAbsolutePath } from "@t3tools/shared/path";
64import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
65import { ScrollArea } from "~/components/ui/scroll-area";
66import { stackedThreadToast, toastManager } from "~/components/ui/toast";
67import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
68import { buildFileReviewComment } from "~/reviewCommentContext";
69import { assetEnvironment } from "~/state/assets";
70import { usePreviewAvailable } from "~/browser/previewRuntime";
71import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
72import { previewEnvironment } from "~/state/preview";
73import { useEnvironmentScope } from "~/state/session";
74import { useAtomCommand } from "~/state/use-atom-command";
75import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
76
77import { AttachmentFilePreview } from "./AttachmentFilePreview";
78import { AudioPreview } from "./AudioPreview";
79import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame";
80import { DelimitedTablePreview } from "./DelimitedTablePreview";
81import FileBrowserPanel from "./FileBrowserPanel";
82import { FileBreadcrumbs } from "./FileBreadcrumbs";
83import { FileMarkdownPreview } from "./FileMarkdownPreview";
84import {
85 type FileCommentAnnotationEntry,
86 type FileCommentAnnotationGroup,
87 type FileCommentLineAnnotation,
88 formatFileCommentRange,
89 nextFileCommentId,
90 normalizeFileCommentRange,
91 remapFileCommentAnnotations,
92} from "./fileCommentAnnotations";
93import { installFileEditorDismissal } from "./fileEditorDismissal";
94import {
95 FILE_LINK_REVEAL_ATTRIBUTE,
96 FILE_LINK_REVEAL_UNSAFE_CSS,
97 FILE_SURFACE_SUBHEADER_CLASS,
98 FileSurfaceAction,
99 FileSurfaceFailure,
100 FileSurfaceLoading,
101} from "./fileSurfaceChrome";
102import SourceFilePreview from "./ReadOnlySourcePreview";
103import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
104import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
105import { projectFileCacheKey } from "./fileContentRevision";
106import {
107 filePreviewReadErrorMessage,
108 isMarkdownPreviewFile,
109 resolveFilePreviewPath,
110 shouldShowFileExplorer,
111 workspaceAssetResource,
112} from "./filePreviewMode";
113import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
114import {
115 getProjectFileContents,
116 setProjectFileQueryData,
117 useProjectFileQuery,
118} from "./projectFilesQueryState";
119
120interface FilePreviewPanelProps {
121 environmentId: EnvironmentId;
122 cwd: string;
123 projectName: string;
124 relativePath: string | null;
125 attachment?: ChatFileAttachment;
126 threadRef: ScopedThreadRef;
127 composerDraftTarget: ScopedThreadRef | DraftId;
128 keybindings: ResolvedKeybindingsConfig;
129 availableEditors: ReadonlyArray<EditorId>;
130 revealLine: number | null;
131 revealRequestId: number;
132 onOpenFile: (relativePath: string) => void;
133 onPendingChange: (relativePath: string, pending: boolean) => void;
134 selectedFilePending: boolean;
135 workspaceMutationId: string | null;
136}
137
138const EMPTY_FILE_WATCH = Atom.make(AsyncResult.initial<number>());
139
140const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
141const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
142const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
143const RENDER_TABLE_STORAGE_KEY = "t3code.renderTable";
144// Shared by the read-only and annotated surfaces, so it is generic over annotation metadata.
145type FilePostRender = <LAnnotation>(
146 fileContainer: HTMLElement,
147 instance: FileInstance<LAnnotation, undefined>,
148 phase: PostRenderPhase,
149) => void;
150
151function WorkspaceImagePreview(props: {
152 readonly environmentId: EnvironmentId;
153 readonly threadRef: ScopedThreadRef;
154 /** The thread is a draft the server does not know yet. */
155 readonly draft: boolean;
156 readonly absolutePath: string;
157 readonly workspaceRoot: string;
158 readonly alt: string;
159 readonly workspaceMutationId: string | null;
160}) {
161 const resource = useMemo(
162 () =>
163 workspaceAssetResource({
164 kind: "workspace-file",
165 threadRef: props.threadRef,
166 draft: props.draft,
167 workspaceRoot: props.workspaceRoot,
168 absolutePath: props.absolutePath,
169 }),
170 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
171 );
172 const assetUrl = useAssetUrlState(props.environmentId, resource);
173 const [failedUrl, setFailedUrl] = useState<string | null>(null);
174 const revisionSuffix =
175 props.workspaceMutationId === null
176 ? ""
177 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
178 const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
179 const actionsSource: MediaActionSource = {
180 kind: "image",
181 name: props.alt,
182 src: imageUrl,
183 reference: mediaFileReference(props.absolutePath, props.workspaceRoot),
184 asset: { environmentId: props.environmentId, resource },
185 };
186
187 if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) {
188 return (
189 <MediaActions source={actionsSource}>
190 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
191 Unable to load workspace image.
192 </div>
193 </MediaActions>
194 );
195 }
196
197 return assetUrl._tag === "Success" && imageUrl !== null ? (
198 // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
199 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
200 <MediaActions source={actionsSource}>
201 <img
202 className="max-h-full max-w-full object-contain"
203 src={imageUrl}
204 alt={props.alt}
205 onError={() => setFailedUrl(imageUrl)}
206 />
207 </MediaActions>
208 </div>
209 ) : (
210 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
211 <Spinner size="lg" />
212 </div>
213 );
214}
215
216/**
217 * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in
218 * a sandboxed frame with an opaque origin, so a page cannot reach the app's
219 * session or storage. A file inside the workspace may load sibling assets; a
220 * host file outside it is served on its own.
221 */
222function WorkspaceBrowserPreview(props: {
223 readonly environmentId: EnvironmentId;
224 readonly threadRef: ScopedThreadRef;
225 /** The thread is a draft the server does not know yet. */
226 readonly draft: boolean;
227 readonly absolutePath: string;
228 readonly workspaceRoot: string;
229 readonly title: string;
230 readonly workspaceMutationId: string | null;
231}) {
232 const insideWorkspace =
233 mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined;
234 const resource = useMemo(
235 () =>
236 workspaceAssetResource({
237 kind: insideWorkspace ? "workspace-file" : "media-file",
238 threadRef: props.threadRef,
239 draft: props.draft,
240 workspaceRoot: props.workspaceRoot,
241 absolutePath: props.absolutePath,
242 }),
243 [insideWorkspace, props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
244 );
245 const assetUrl = useAssetUrlState(props.environmentId, resource);
246 const revisionSuffix =
247 props.workspaceMutationId === null
248 ? ""
249 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
250
251 if (assetUrl._tag === "Failure") {
252 return (
253 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
254 Unable to load file preview.
255 </div>
256 );
257 }
258 if (assetUrl._tag !== "Success") {
259 return (
260 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
261 <Spinner size="lg" />
262 </div>
263 );
264 }
265 return (
266 <BrowserDocumentFrame
267 src={`${assetUrl.url}${revisionSuffix}`}
268 title={props.title}
269 pdf={isPdfPreviewFile(props.absolutePath)}
270 />
271 );
272}
273
274function WorkspaceVideoPreview(props: {
275 readonly environmentId: EnvironmentId;
276 readonly threadRef: ScopedThreadRef;
277 /** The thread is a draft the server does not know yet. */
278 readonly draft: boolean;
279 readonly absolutePath: string;
280 readonly workspaceRoot: string;
281 readonly name: string;
282 readonly workspaceMutationId: string | null;
283}) {
284 const resource = useMemo(
285 () =>
286 workspaceAssetResource({
287 kind: "media-file",
288 threadRef: props.threadRef,
289 draft: props.draft,
290 workspaceRoot: props.workspaceRoot,
291 absolutePath: props.absolutePath,
292 }),
293 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
294 );
295 const assetUrl = useAssetUrlState(props.environmentId, resource);
296 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
297 useWorkspaceMutationRefresh({
298 mutationId: props.workspaceMutationId,
299 resourceKey: JSON.stringify([props.environmentId, resource]),
300 refresh: () => {
301 // Failed refreshes flow through assetUrl and can be retried from the player.
302 void refreshAssetUrl().catch(() => undefined);
303 },
304 });
305 const revisionSuffix =
306 props.workspaceMutationId === null
307 ? ""
308 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
309 const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
310
311 return (
312 <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4">
313 <MediaVideoPlayer
314 src={latestUrl}
315 sourceFailed={assetUrl._tag === "Failure"}
316 label={props.name}
317 revision={props.workspaceMutationId}
318 preload="metadata"
319 className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center"
320 videoClassName="h-auto w-auto max-w-full"
321 onRetry={refreshAssetUrl}
322 actionsSource={{
323 kind: "video",
324 name: props.name,
325 src: latestUrl,
326 reference: mediaFileReference(props.absolutePath, props.workspaceRoot),
327 asset: { environmentId: props.environmentId, resource },
328 }}
329 />
330 </div>
331 );
332}
333
334function WorkspaceAudioPreview(props: {
335 readonly environmentId: EnvironmentId;
336 readonly threadRef: ScopedThreadRef;
337 /** The thread is a draft the server does not know yet. */
338 readonly draft: boolean;
339 readonly absolutePath: string;
340 readonly workspaceRoot: string;
341 readonly name: string;
342 readonly workspaceMutationId: string | null;
343}) {
344 const resource = useMemo(
345 () =>
346 workspaceAssetResource({
347 kind: "media-file",
348 threadRef: props.threadRef,
349 draft: props.draft,
350 workspaceRoot: props.workspaceRoot,
351 absolutePath: props.absolutePath,
352 }),
353 [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath],
354 );
355 const assetUrl = useAssetUrlState(props.environmentId, resource);
356 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
357 const [failedUrl, setFailedUrl] = useState<string | null>(null);
358 useWorkspaceMutationRefresh({
359 mutationId: props.workspaceMutationId,
360 resourceKey: JSON.stringify([props.environmentId, resource]),
361 refresh: () => {
362 void refreshAssetUrl().catch(() => undefined);
363 },
364 });
365 const revisionSuffix =
366 props.workspaceMutationId === null
367 ? ""
368 : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
369 const url = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
370 if (assetUrl._tag === "Failure" || (url !== null && failedUrl === url)) {
371 return (
372 <FileSurfaceFailure
373 message="Unable to load audio."
374 onRetry={() => {
375 setFailedUrl(null);
376 void refreshAssetUrl().catch(() => undefined);
377 }}
378 />
379 );
380 }
381 if (url === null) return <FileSurfaceLoading />;
382 return <AudioPreview src={url} name={props.name} onError={() => setFailedUrl(url)} />;
383}
384
385function clampFileLine(contents: string, requestedLine: number): number {
386 let lineCount = 1;
387 for (let index = 0; index < contents.length; index += 1) {
388 const character = contents.charCodeAt(index);
389 if (character === 10) {
390 lineCount += 1;
391 } else if (character === 13) {
392 lineCount += 1;
393 if (contents.charCodeAt(index + 1) === 10) index += 1;
394 }
395 }
396 return Math.min(Math.max(1, requestedLine), lineCount);
397}
398
399function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): void {
400 const root = fileContainer.shadowRoot ?? fileContainer;
401 for (const element of root.querySelectorAll<HTMLElement>(`[${FILE_LINK_REVEAL_ATTRIBUTE}]`)) {
402 element.removeAttribute(FILE_LINK_REVEAL_ATTRIBUTE);
403 }
404 if (line === null) return;
405
406 root
407 .querySelector<HTMLElement>(`[data-line="${line}"]`)
408 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");
409 root
410 .querySelector<HTMLElement>(`[data-column-number="${line}"]`)
411 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");
412}
413
414/**
415 * Frames to keep retrying while the file contents or line metrics are not
416 * available yet (fresh mounts hydrate asynchronously).
417 */
418const REVEAL_MAX_ATTEMPTS = 30;
419/**
420 * After scrolling to the target, hold it for a short window so late
421 * programmatic scroll resets (editable-editor focus and state restoration)
422 * cannot silently snap the file back to the top. Real user input cancels the
423 * guard immediately.
424 */
425const REVEAL_GUARD_FRAMES = 20;
426const REVEAL_GUARD_TOLERANCE_PX = 2;
427
428interface FileRevealState {
429 frameId: number | null;
430 cancelGuard: (() => void) | null;
431 handledRequestId: number | null;
432 latestRequestId: number | null;
433}
434
435function useFileLineReveal(
436 relativePath: string | null,
437 revealLine: number | null,
438 revealRequestId: number,
439): FilePostRender {
440 const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>());
441
442 return useCallback<FilePostRender>(
443 (fileContainer, instance, phase) => {
444 if (relativePath === null) return;
445
446 const existingState = revealStatesByPath.get(relativePath);
447 const state: FileRevealState = existingState ?? {
448 frameId: null,
449 cancelGuard: null,
450 handledRequestId: null,
451 latestRequestId: null,
452 };
453 if (!existingState) revealStatesByPath.set(relativePath, state);
454
455 const cancelPendingReveal = () => {
456 if (state.frameId !== null) {
457 cancelAnimationFrame(state.frameId);
458 state.frameId = null;
459 }
460 state.cancelGuard?.();
461 };
462
463 if (phase === "unmount") {
464 cancelPendingReveal();
465 return;
466 }
467
468 const contents = instance.file?.contents;
469 const targetLine =
470 revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine);
471 updateFileLinkReveal(fileContainer, targetLine);
472
473 if (!(instance instanceof VirtualizedFile)) return;
474
475 if (state.latestRequestId !== revealRequestId) {
476 cancelPendingReveal();
477 state.latestRequestId = revealRequestId;
478 state.handledRequestId = null;
479 }
480
481 if (revealLine === null) {
482 fileContainer.style.minHeight = "";
483 return;
484 }
485
486 const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer");
487 if (!scrollContainer) return;
488 fileContainer.style.minHeight = `${Math.ceil(
489 Math.max(instance.height, scrollContainer.clientHeight),
490 )}px`;
491
492 if (state.handledRequestId === revealRequestId || state.frameId !== null) {
493 return;
494 }
495
496 const resolveScrollTarget = (line: number): number | null => {
497 const linePosition = instance.getLinePosition(line);
498 if (!linePosition) return null;
499
500 const scrollContainerRect = scrollContainer.getBoundingClientRect();
501 const fileTop =
502 scrollContainer.scrollTop +
503 fileContainer.getBoundingClientRect().top -
504 scrollContainerRect.top;
505 const root = fileContainer.shadowRoot ?? fileContainer;
506 const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`);
507 const renderedLineRect = renderedLineElement?.getBoundingClientRect();
508
509 return resolveCenteredFileLineScrollTop({
510 scrollTop: scrollContainer.scrollTop,
511 scrollHeight: scrollContainer.scrollHeight,
512 viewportTop: scrollContainerRect.top,
513 viewportHeight: scrollContainer.clientHeight,
514 fileTop,
515 estimatedLine: linePosition,
516 ...(renderedLineRect && renderedLineRect.height > 0
517 ? {
518 renderedLine: {
519 top: renderedLineRect.top,
520 height: renderedLineRect.height,
521 },
522 }
523 : {}),
524 });
525 };
526
527 const guardScrollTarget = (line: number) => {
528 let framesLeft = REVEAL_GUARD_FRAMES;
529 let guardFrameId: number | null = null;
530 const cancelGuard = () => {
531 if (guardFrameId !== null) {
532 cancelAnimationFrame(guardFrameId);
533 guardFrameId = null;
534 }
535 scrollContainer.removeEventListener("wheel", cancelGuard);
536 scrollContainer.removeEventListener("touchstart", cancelGuard);
537 scrollContainer.removeEventListener("pointerdown", cancelGuard, true);
538 window.removeEventListener("keydown", cancelGuard, true);
539 if (state.cancelGuard === cancelGuard) state.cancelGuard = null;
540 };
541 scrollContainer.addEventListener("wheel", cancelGuard, { passive: true });
542 scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true });
543 // Pierre stops gutter pointer events from bubbling. Listen in capture
544 // so starting a comment cancels the reveal guard before the row expands.
545 scrollContainer.addEventListener("pointerdown", cancelGuard, {
546 passive: true,
547 capture: true,
548 });
549 window.addEventListener("keydown", cancelGuard, true);
550 const holdTarget = () => {
551 guardFrameId = null;
552 framesLeft -= 1;
553 if (framesLeft <= 0 || !scrollContainer.isConnected) {
554 cancelGuard();
555 return;
556 }
557 const targetTop = resolveScrollTarget(line);
558 if (
559 targetTop !== null &&
560 Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX
561 ) {
562 scrollContainer.scrollTop = targetTop;
563 }
564 guardFrameId = requestAnimationFrame(holdTarget);
565 };
566 guardFrameId = requestAnimationFrame(holdTarget);
567 state.cancelGuard = cancelGuard;
568 };
569
570 const scheduleReveal = (attempt: number) => {
571 state.frameId = requestAnimationFrame(() => {
572 state.frameId = null;
573 if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) {
574 return;
575 }
576
577 // Contents and line metrics can lag the first post-render on fresh
578 // mounts; clamping against missing contents would scroll to line 1
579 // and wrongly mark the request handled.
580 const currentContents = instance.file?.contents;
581 const line =
582 currentContents === undefined ? null : clampFileLine(currentContents, revealLine);
583 const targetTop = line === null ? null : resolveScrollTarget(line);
584 if (line === null || targetTop === null) {
585 if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1);
586 return;
587 }
588 updateFileLinkReveal(fileContainer, line);
589
590 scrollContainer.scrollTop = targetTop;
591 state.handledRequestId = revealRequestId;
592 guardScrollTarget(line);
593 });
594 };
595
596 scheduleReveal(0);
597 },
598 [revealStatesByPath, relativePath, revealLine, revealRequestId],
599 );
600}
601
602const createFileEditor: EditorFactory<FileCommentAnnotationGroup, undefined> = (
603 editorType,
604 options,
605 editStateKey,
606) => new Editor(editorType, options, editStateKey);
607
608function editableFileContents(
609 environmentId: EnvironmentId,
610 cwd: string,
611 relativePath: string,
612 contents: string,
613): FileContents {
614 return {
615 name: relativePath,
616 contents,
617 cacheKey: `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`,
618 };
619}
620
621function needsWorkerHighlight(workerPool: WorkerPoolManager | undefined, file: FileContents) {
622 if (workerPool?.isWorkingPool() !== true) return false;
623 if ((file.lang ?? getFiletypeFromFileName(file.name)) === "text") return false;
624 let lines = 1;
625 for (
626 let index = file.contents.indexOf("\n");
627 index !== -1;
628 index = file.contents.indexOf("\n", index + 1)
629 ) {
630 lines += 1;
631 }
632 return lines <= DEFAULT_TOKENIZE_MAX_LENGTH;
633}
634
635/**
636 * Pierre highlights an active edit session on the main thread, so each version
637 * of the file becomes editable only once it has rendered the worker's
638 * highlight. A failed worker highlight falls back to main-thread highlighting.
639 */
640function useEditableAfterHighlight(file: FileContents) {
641 const workerPool = useWorkerPool();
642 const [highlightedFile, setHighlightedFile] = useState<FileContents | null>(null);
643 const needsHighlight = useMemo(() => needsWorkerHighlight(workerPool, file), [file, workerPool]);
644 const ready = !needsHighlight || highlightedFile === file;
645
646 useEffect(() => {
647 if (ready || workerPool === undefined) return;
648 workerPool.primeFileHighlightCache(file).catch(() => setHighlightedFile(file));
649 }, [file, ready, workerPool]);
650
651 const onPostRender = useCallback(
652 (renderedFile: FileContents | undefined, phase: PostRenderPhase) => {
653 if (ready || phase === "unmount" || renderedFile?.cacheKey !== file.cacheKey) return;
654 // The pool caches a result just before the instance renders it, so a
655 // render that sees the cache has painted highlighted rows.
656 if (workerPool?.getFileResultCache(file) !== undefined) setHighlightedFile(file);
657 },
658 [file, ready, workerPool],
659 );
660 return { ready, onPostRender };
661}
662
663interface EditableFileSurfaceProps {
664 environmentId: EnvironmentId;
665 cwd: string;
666 relativePath: string;
667 composerDraftTarget: ScopedThreadRef | DraftId;
668 contents: string;
669 resolvedTheme: "light" | "dark";
670 revealRequestId: number;
671 wordWrap: boolean;
672 onPostRender: FilePostRender;
673 onPendingChange: (relativePath: string, pending: boolean) => void;
674}
675
676interface FileSelectionOverride {
677 revealRequestId: number;
678 range: SelectedLineRange | null;
679}
680
681function EditableFileSurface({
682 environmentId,
683 cwd,
684 relativePath,
685 composerDraftTarget,
686 contents,
687 resolvedTheme,
688 revealRequestId,
689 wordWrap,
690 onPostRender,
691 onPendingChange,
692}: EditableFileSurfaceProps) {
693 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
694 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
695 const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]);
696 const [selectionOverride, setSelectionOverride] = useState<FileSelectionOverride | null>(null);
697 const selectedRange =
698 selectionOverride?.revealRequestId === revealRequestId ? selectionOverride.range : null;
699 const setSelectedRange = useCallback(
700 (range: SelectedLineRange | null) => {
701 setSelectionOverride({ revealRequestId, range });
702 },
703 [revealRequestId],
704 );
705 const surfaceRef = useRef<HTMLDivElement>(null);
706 const selectionFrameRef = useRef<number | null>(null);
707 const saveCoordinator = useFileSaveCoordinator({
708 environmentId,
709 cwd,
710 relativePath,
711 onPendingChange,
712 });
713 // The editor owns the draft, so its own edits echoing back through the file
714 // query keep the file identity. Only a change from elsewhere replaces it.
715 const [externalFile, setExternalFile] = useState(() =>
716 editableFileContents(environmentId, cwd, relativePath, contents),
717 );
718 const [editedContents, setEditedContents] = useState<string | null>(null);
719 if (contents !== (editedContents ?? externalFile.contents)) {
720 setExternalFile(editableFileContents(environmentId, cwd, relativePath, contents));
721 setEditedContents(null);
722 }
723 const { ready: editable, onPostRender: onEditablePostRender } =
724 useEditableAfterHighlight(externalFile);
725 const editorRef = useRef<Editor<"file", FileCommentAnnotationGroup, undefined> | null>(null);
726 const editorOptions = useMemo<EditorOptions<"file", FileCommentAnnotationGroup, undefined>>(
727 () => ({
728 onAttach: (editor) => {
729 editorRef.current = editor;
730 },
731 onComplete: () => {
732 editorRef.current = null;
733 },
734 }),
735 [],
736 );
737
738 // Mirrors the draft out to the save queue, the optimistic file query and the
739 // composer's review comments.
740 const handleEditChange = useCallback(
741 ({
742 file,
743 lineAnnotations: nextLineAnnotations,
744 }: EditorChangeEvent<"file", FileCommentAnnotationGroup, undefined>) => {
745 // Adopting an external change reports it as an edit; it is already on disk.
746 if (file.contents === getProjectFileContents(environmentId, cwd, relativePath)) return;
747 setEditedContents(file.contents);
748 setProjectFileQueryData(environmentId, cwd, relativePath, file.contents);
749 saveCoordinator.change(file.contents);
750 if (!nextLineAnnotations) return;
751 const remapped = remapFileCommentAnnotations(nextLineAnnotations);
752 // The editor hands back the array it was given until an edit moves an annotation.
753 setLineAnnotations((current) => (current === nextLineAnnotations ? current : remapped));
754 for (const annotation of remapped) {
755 for (const entry of annotation.metadata.entries) {
756 if (entry.kind !== "comment") continue;
757 addReviewComment(
758 composerDraftTarget,
759 buildFileReviewComment({
760 id: entry.id,
761 filePath: relativePath,
762 startLine: entry.startLine,
763 endLine: entry.endLine,
764 text: entry.text,
765 contents: file.contents,
766 }),
767 );
768 }
769 }
770 },
771 [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator],
772 );
773
774 const removeAnnotationEntry = useCallback(
775 (entryId: string) => {
776 setSelectedRange(null);
777 removeReviewComment(composerDraftTarget, entryId);
778 setLineAnnotations((current) => {
779 return current.flatMap((annotation) => {
780 const entries = annotation.metadata.entries.filter((entry) => entry.id !== entryId);
781 return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : [];
782 });
783 });
784 },
785 [composerDraftTarget, removeReviewComment, setSelectedRange],
786 );
787
788 const submitAnnotationEntry = useCallback(
789 (entryId: string, text: string) => {
790 setSelectedRange(null);
791 const entry = lineAnnotations
792 .flatMap((annotation) => annotation.metadata.entries)
793 .find((candidate) => candidate.id === entryId);
794 if (entry) {
795 addReviewComment(
796 composerDraftTarget,
797 buildFileReviewComment({
798 id: entry.id,
799 filePath: relativePath,
800 startLine: entry.startLine,
801 endLine: entry.endLine,
802 text,
803 contents,
804 }),
805 );
806 }
807 setLineAnnotations((current) =>
808 current.map((annotation) => ({
809 ...annotation,
810 metadata: {
811 entries: annotation.metadata.entries.map((annotationEntry) =>
812 annotationEntry.id === entryId
813 ? { ...annotationEntry, kind: "comment", text }
814 : annotationEntry,
815 ),
816 },
817 })),
818 );
819 },
820 [
821 addReviewComment,
822 composerDraftTarget,
823 contents,
824 lineAnnotations,
825 relativePath,
826 setSelectedRange,
827 ],
828 );
829
830 const beginComment = useCallback((range: SelectedLineRange) => {
831 editorRef.current?.setSelections([]);
832 editorRef.current?.blur();
833 const { startLine, endLine } = normalizeFileCommentRange(range);
834 const draftEntry: FileCommentAnnotationEntry = {
835 id: nextFileCommentId(),
836 kind: "draft",
837 startLine,
838 endLine,
839 text: "",
840 };
841 setLineAnnotations((current) => {
842 const withoutDraft = current.flatMap((annotation) => {
843 const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft");
844 return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : [];
845 });
846 const existingIndex = withoutDraft.findIndex(
847 (annotation) => annotation.lineNumber === endLine,
848 );
849 if (existingIndex < 0) {
850 return [
851 ...withoutDraft,
852 {
853 lineNumber: endLine,
854 metadata: { entries: [draftEntry] },
855 },
856 ];
857 }
858 return withoutDraft.map((annotation, index) =>
859 index === existingIndex
860 ? {
861 ...annotation,
862 metadata: { entries: [...annotation.metadata.entries, draftEntry] },
863 }
864 : annotation,
865 );
866 });
867 }, []);
868 const hasOpenCommentForm = lineAnnotations.some((annotation) =>
869 annotation.metadata.entries.some((entry) => entry.kind === "draft"),
870 );
871 useEffect(() => {
872 const root = surfaceRef.current;
873 if (!root) return;
874 return installFileEditorDismissal({
875 root,
876 editor: { setSelections: (selections) => editorRef.current?.setSelections(selections) },
877 isBlocked: () => hasOpenCommentForm,
878 onDismiss: () => setSelectedRange(null),
879 });
880 }, [hasOpenCommentForm, setSelectedRange]);
881 const handleLineSelectionEnd = useCallback(
882 (range: SelectedLineRange | null) => {
883 setSelectedRange(range);
884 if (range) {
885 beginComment(range);
886 }
887 },
888 [beginComment, setSelectedRange],
889 );
890
891 const handlePostRender = useCallback<FilePostRender>(
892 (fileContainer, instance, phase) => {
893 onPostRender(fileContainer, instance, phase);
894 onEditablePostRender(instance.file, phase);
895
896 if (selectionFrameRef.current !== null) {
897 cancelAnimationFrame(selectionFrameRef.current);
898 selectionFrameRef.current = null;
899 }
900 if (phase === "unmount") return;
901
902 selectionFrameRef.current = requestAnimationFrame(() => {
903 selectionFrameRef.current = null;
904 if (!fileContainer.isConnected) return;
905 instance.setSelectedLines(selectedRange, { notify: false });
906 });
907 },
908 [onEditablePostRender, onPostRender, selectedRange],
909 );
910
911 return (
912 <EditProvider createEditor={createFileEditor}>
913 <div ref={surfaceRef} className="flex min-h-0 flex-1">
914 <Virtualizer
915 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
916 config={{
917 overscrollSize: 600,
918 intersectionObserverMargin: 1200,
919 }}
920 >
921 <File<FileCommentAnnotationGroup>
922 file={externalFile}
923 edit={editable}
924 editorOptions={editorOptions}
925 onEditChange={handleEditChange}
926 options={{
927 disableFileHeader: true,
928 enableGutterUtility: !hasOpenCommentForm,
929 enableLineSelection: !hasOpenCommentForm,
930 onGutterUtilityClick: setSelectedRange,
931 onLineSelectionChange: setSelectedRange,
932 onLineSelectionEnd: handleLineSelectionEnd,
933 overflow: wordWrap ? "wrap" : "scroll",
934 theme: resolveDiffThemeName(resolvedTheme),
935 preferredHighlighter: PREFERRED_HIGHLIGHTER,
936 themeType: resolvedTheme,
937 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
938 onPostRender: handlePostRender,
939 }}
940 selectedLines={selectedRange}
941 lineAnnotations={lineAnnotations}
942 renderAnnotation={(annotation) => (
943 <div className="py-1">
944 {annotation.metadata.entries.map((entry) => (
945 <DiffCommentAnnotation
946 key={entry.id}
947 kind={entry.kind}
948 rangeLabel={formatFileCommentRange(entry.startLine, entry.endLine)}
949 text={entry.text}
950 onCancel={() => removeAnnotationEntry(entry.id)}
951 onComment={(text) => submitAnnotationEntry(entry.id, text)}
952 onDelete={() => removeAnnotationEntry(entry.id)}
953 />
954 ))}
955 </div>
956 )}
957 className="min-h-full"
958 />
959 </Virtualizer>
960 </div>
961 </EditProvider>
962 );
963}
964
965function RenderedMarkdownSurface({
966 environmentId,
967 cwd,
968 relativePath,
969 contents,
970 threadRef,
971 readOnly,
972 draft,
973 revision,
974 onPendingChange,
975}: Omit<
976 EditableFileSurfaceProps,
977 | "resolvedTheme"
978 | "composerDraftTarget"
979 | "revealLine"
980 | "revealRequestId"
981 | "wordWrap"
982 | "onPostRender"
983> & {
984 threadRef: ScopedThreadRef;
985 readOnly: boolean;
986 draft: boolean;
987 revision: string | null;
988}) {
989 const saveCoordinator = useFileSaveCoordinator({
990 environmentId,
991 cwd,
992 relativePath,
993 onPendingChange,
994 });
995
996 return (
997 <FileMarkdownPreview
998 text={contents}
999 cwd={cwd}
1000 relativePath={relativePath}
1001 threadRef={threadRef}
1002 draft={draft}
1003 revision={revision}
1004 readOnly={readOnly}
1005 onChange={(nextContents) => {
1006 if (nextContents === getProjectFileContents(environmentId, cwd, relativePath)) return;
1007 setProjectFileQueryData(environmentId, cwd, relativePath, nextContents);
1008 saveCoordinator.change(nextContents);
1009 }}
1010 />
1011 );
1012}
1013
1014function renderedToggleLabel(mode: "markdown" | "html" | "table", rendered: boolean): string {
1015 if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown";
1016 if (mode === "table") return rendered ? "Show source" : "Show table";
1017 return rendered ? "Show HTML source" : "Show rendered page";
1018}
1019
1020function initialExplorerOpen(): boolean {
1021 try {
1022 return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true;
1023 } catch (error) {
1024 console.error(error);
1025 return true;
1026 }
1027}
1028
1029export default function FilePreviewPanel({
1030 environmentId,
1031 cwd,
1032 projectName,
1033 relativePath: requestedPath,
1034 attachment,
1035 threadRef,
1036 composerDraftTarget,
1037 keybindings,
1038 availableEditors,
1039 revealLine,
1040 revealRequestId,
1041 onOpenFile,
1042 onPendingChange,
1043 selectedFilePending,
1044 workspaceMutationId: orchestrationMutationId,
1045}: FilePreviewPanelProps) {
1046 const relativePath =
1047 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
1048 // A draft's composer target is its draft id; a thread the server knows is a ref.
1049 const draft = typeof composerDraftTarget === "string";
1050 const { resolvedTheme } = useTheme();
1051 const wordWrap = useClientSettings((settings) => settings.wordWrap);
1052 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
1053 const primaryEnvironmentId = usePrimaryEnvironmentId();
1054 const remoteOpenState = useRemoteOpenState(environmentId);
1055 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
1056 const previewAvailable = usePreviewAvailable(environmentId);
1057 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1058 reportFailure: false,
1059 });
1060 const openPreview = useAtomCommand(previewEnvironment.open, {
1061 reportFailure: false,
1062 });
1063 const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath);
1064 const isAudio = relativePath !== null && !isVideo && isWorkspaceAudioPreviewPath(relativePath);
1065 const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath);
1066 const isMedia = isImage || isVideo || isAudio;
1067 // PDFs have no text to show; HTML has, and can toggle between page and source.
1068 const isPdf = relativePath !== null && isPdfPreviewFile(relativePath);
1069 const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath);
1070 // A file outside the workspace (an absolute path) is shown, never edited.
1071 const isHostFile =
1072 attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
1073 const fileAccess = useFilesystemReadAccess(environmentId);
1074 const { canReadFiles } = fileAccess;
1075 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
1076 // Media and PDFs render from their absolute path, so their contents are never
1077 // shown. The read still runs: a folder named `assets.png` is only knowable as a
1078 // folder from the read failure, and the server stats before reading, so a folder
1079 // costs an open and a stat and returns no body.
1080 const file = useProjectFileQuery(
1081 environmentId,
1082 cwd,
1083 relativePath,
1084 attachment === undefined && relativePath !== null,
1085 );
1086 const watched = useAtomValue(
1087 attachment === undefined && relativePath !== null && canReadFiles
1088 ? projectEnvironment.watch({ environmentId, input: { cwd, relativePath, directory: false } })
1089 : EMPTY_FILE_WATCH,
1090 );
1091 const workspaceMutationId = AsyncResult.isSuccess(watched)
1092 ? `${orchestrationMutationId ?? ""}:${watched.value}`
1093 : orchestrationMutationId;
1094 const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath;
1095 // A chat link cannot tell a folder from a file, so a folder arrives here as
1096 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
1097 // pane, and let the tree fill the surface with the folder revealed. Mutation
1098 // refresh stays on so the surface notices if the path becomes a file. A host
1099 // path cannot be revealed in the workspace tree, so it keeps the read error.
1100 const isDirectory = file.isNotFile && !isHostFile;
1101 // Everything preview-related keys off previewPath; a folder has no preview.
1102 const previewPath = isDirectory ? null : relativePath;
1103 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
1104 const showExplorer = shouldShowFileExplorer({
1105 relativePath: previewPath,
1106 explorerOpen,
1107 attachmentOpen: attachment !== undefined,
1108 });
1109 // Reading markdown rendered is a preference, not a property of one file. Keeping
1110 // it on the panel meant a thread switch dropped it and forced source back.
1111 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
1112 RENDER_MARKDOWN_STORAGE_KEY,
1113 true,
1114 Schema.Boolean,
1115 );
1116 const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage(
1117 RENDER_BROWSER_FILE_STORAGE_KEY,
1118 true,
1119 Schema.Boolean,
1120 );
1121 const [renderTablePreferred, setRenderTablePreferred] = useLocalStorage(
1122 RENDER_TABLE_STORAGE_KEY,
1123 true,
1124 Schema.Boolean,
1125 );
1126 // Paired with the path on purpose: each file surface counts its reveals from
1127 // one, so a bare id would let a dismissed reveal on one file swallow the first
1128 // reveal on the next.
1129 const [handledReveal, setHandledReveal] = useState<{ path: string; requestId: number } | null>(
1130 null,
1131 );
1132 const breadcrumbRef = useRef<HTMLDivElement>(null);
1133 const isMarkdown = previewPath ? isMarkdownPreviewFile(previewPath) : false;
1134 const tableDelimiter =
1135 previewPath && attachment === undefined ? filePreviewDelimiter({ name: previewPath }) : null;
1136 // A reveal still wins over the preference: the line only exists in the source.
1137 const revealHandled =
1138 revealLine === null ||
1139 (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);
1140 const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;
1141 const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled);
1142 const renderTable = tableDelimiter !== null && renderTablePreferred && revealHandled;
1143 const renderedMode = isMarkdown
1144 ? ("markdown" as const)
1145 : tableDelimiter
1146 ? ("table" as const)
1147 : isHtml
1148 ? ("html" as const)
1149 : null;
1150 const canToggleRendered =
1151 previewPath !== null && attachment === undefined && renderedMode !== null;
1152 const updateClientSettings = useUpdateClientSettings();
1153 // Word wrap only reaches the text bodies. A rendered Markdown document, a table and the
1154 // browser frame all lay themselves out, so the toggle stays hidden rather than inert.
1155 const showsRawText =
1156 previewPath !== null &&
1157 file.data !== null &&
1158 !(isMarkdown && renderMarkdown) &&
1159 !(tableDelimiter && renderTable) &&
1160 !renderBrowserFile;
1161 const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile;
1162 const setRenderedPreferred = isMarkdown
1163 ? setRenderMarkdownPreferred
1164 : tableDelimiter
1165 ? setRenderTablePreferred
1166 : setRenderBrowserFilePreferred;
1167 const canOpenInBrowser =
1168 canOperatePreview &&
1169 previewPath !== null &&
1170 attachment === undefined &&
1171 !isVideo &&
1172 previewAvailable &&
1173 isBrowserPreviewFile(previewPath);
1174 const absolutePath =
1175 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
1176 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
1177 useWorkspaceMutationRefresh({
1178 enabled:
1179 attachment === undefined &&
1180 relativePath !== null &&
1181 // Media and PDFs never show their contents, so re-reading them on every
1182 // workspace mutation is waste. A folder named like one still re-reads, so
1183 // it notices when the path becomes a file.
1184 (isDirectory || (!isMedia && !isPdf)) &&
1185 !selectedFilePending,
1186 mutationId: orchestrationMutationId,
1187 refresh: file.refresh,
1188 resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
1189 });
1190
1191 useEffect(() => {
1192 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
1193 "[data-current-file-crumb='true']",
1194 );
1195 currentCrumb?.scrollIntoView({ block: "nearest", inline: "end" });
1196 }, [relativePath]);
1197
1198 const toggleExplorer = () => {
1199 setExplorerOpen((current) => {
1200 const next = !current;
1201 try {
1202 setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean);
1203 } catch (error) {
1204 console.error(error);
1205 }
1206 return next;
1207 });
1208 };
1209
1210 const handleOpenInBrowser = useCallback(() => {
1211 if (!canReadFiles || !canOperatePreview || !absolutePath || !environmentHttpBaseUrl) return;
1212 void (async () => {
1213 const result = await openFileInPreview({
1214 threadRef,
1215 filePath: absolutePath,
1216 workspaceRoot: cwd,
1217 httpBaseUrl: environmentHttpBaseUrl,
1218 createAssetUrl,
1219 openPreview,
1220 });
1221 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
1222 return;
1223 }
1224 const error = squashAtomCommandFailure(result);
1225 toastManager.add(
1226 stackedThreadToast({
1227 type: "error",
1228 title: "Unable to open file in browser",
1229 description: error instanceof Error ? error.message : "An error occurred.",
1230 }),
1231 );
1232 })();
1233 }, [
1234 absolutePath,
1235 canReadFiles,
1236 canOperatePreview,
1237 createAssetUrl,
1238 cwd,
1239 environmentHttpBaseUrl,
1240 openPreview,
1241 threadRef,
1242 ]);
1243
1244 if (attachment === undefined && !canReadFiles) {
1245 if (fileAccess.isPending) {
1246 return (
1247 <div className="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
1248 <Spinner className="size-4" />
1249 Checking file access...
1250 </div>
1251 );
1252 }
1253 return (
1254 <div className="p-4 text-sm text-muted-foreground">
1255 {fileAccess.error ?? "This connection cannot read host files."}
1256 </div>
1257 );
1258 }
1259
1260 return (
1261 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
1262 {relativePath && attachment === undefined ? (
1263 <div className={FILE_SURFACE_SUBHEADER_CLASS} data-surface-subheader>
1264 <ScrollArea
1265 radius="none"
1266 ref={breadcrumbRef}
1267 hideScrollbars
1268 scrollFade
1269 className="min-w-0 flex-1"
1270 data-file-breadcrumbs
1271 >
1272 <div className="flex h-full w-max min-w-full items-center text-xs">
1273 <FileBreadcrumbs
1274 cwd={cwd}
1275 environmentId={environmentId}
1276 onOpenFile={onOpenFile}
1277 projectName={projectName}
1278 relativePath={relativePath}
1279 workspaceMutationId={workspaceMutationId}
1280 />
1281 </div>
1282 </ScrollArea>
1283 {absolutePath &&
1284 (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (
1285 <OpenInPicker
1286 environmentId={environmentId}
1287 keybindings={keybindings}
1288 availableEditors={availableEditors}
1289 openInCwd={absolutePath}
1290 compact
1291 />
1292 ) : null}
1293 {canToggleRendered && renderedMode ? (
1294 <FileSurfaceAction
1295 label={renderedToggleLabel(renderedMode, rendered)}
1296 pressed={rendered}
1297 onPress={() => {
1298 const pressed = !rendered;
1299 setRenderedPreferred(pressed);
1300 setHandledReveal(
1301 pressed && relativePath !== null
1302 ? { path: relativePath, requestId: revealRequestId }
1303 : null,
1304 );
1305 }}
1306 >
1307 <MorphIcon
1308 className="size-3.5"
1309 icon={rendered ? Code2 : renderedMode === "table" ? Table2 : Eye}
1310 />
1311 </FileSurfaceAction>
1312 ) : null}
1313 {showsRawText ? (
1314 <FileSurfaceAction
1315 label={wordWrap ? "Disable word wrap" : "Enable word wrap"}
1316 pressed={wordWrap}
1317 onPress={() => updateClientSettings({ wordWrap: !wordWrap })}
1318 >
1319 <WrapTextIcon className="size-3.5" />
1320 </FileSurfaceAction>
1321 ) : null}
1322 {canOpenInBrowser ? (
1323 <FileSurfaceAction label="Open file in preview browser" onPress={handleOpenInBrowser}>
1324 <Globe2 className="size-3.5" />
1325 </FileSurfaceAction>
1326 ) : null}
1327 {!isHostFile && previewPath !== null ? (
1328 <FileSurfaceAction
1329 label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
1330 pressed={explorerOpen}
1331 onPress={toggleExplorer}
1332 >
1333 <FolderTree className="size-3.5" />
1334 </FileSurfaceAction>
1335 ) : null}
1336 </div>
1337 ) : null}
1338 {relativePath && !attachment && !isHostFile && !canWriteFiles && !fileAccess.isPending ? (
1339 <div className="shrink-0 border-b px-3 py-1.5 text-2xs text-muted-foreground">
1340 Read-only connection. Unsaved edits are kept until write access returns.
1341 </div>
1342 ) : null}
1343 {previewPath &&
1344 attachment === undefined &&
1345 !isMedia &&
1346 !renderBrowserFile &&
1347 file.data?.truncated ? (
1348 <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-2xs text-warning-foreground">
1349 Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
1350 </div>
1351 ) : null}
1352 <div className="flex min-h-0 flex-1 overflow-hidden">
1353 <div
1354 className={cn("min-w-0 flex-1 flex-col overflow-hidden", previewPath ? "flex" : "hidden")}
1355 >
1356 {isDirectory ? null : relativePath && attachment ? (
1357 <AttachmentFilePreview
1358 key={`${environmentId}:${attachment.id}`}
1359 name={attachment.name}
1360 mimeType={attachment.mimeType}
1361 sizeBytes={attachment.sizeBytes}
1362 asset={{ environmentId, attachmentId: attachment.id }}
1363 htmlRender={attachment.htmlRender === true}
1364 threadRef={threadRef}
1365 />
1366 ) : relativePath && isVideo && absolutePath ? (
1367 <WorkspaceVideoPreview
1368 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
1369 environmentId={environmentId}
1370 threadRef={threadRef}
1371 draft={draft}
1372 absolutePath={absolutePath}
1373 workspaceRoot={cwd}
1374 name={relativePath}
1375 workspaceMutationId={workspaceMutationId}
1376 />
1377 ) : relativePath && isAudio && absolutePath ? (
1378 <WorkspaceAudioPreview
1379 key={`${environmentId}:${threadRef.threadId}:${absolutePath}`}
1380 environmentId={environmentId}
1381 threadRef={threadRef}
1382 draft={draft}
1383 absolutePath={absolutePath}
1384 workspaceRoot={cwd}
1385 name={relativePath}
1386 workspaceMutationId={workspaceMutationId}
1387 />
1388 ) : relativePath && isImage && absolutePath ? (
1389 <WorkspaceImagePreview
1390 key={absolutePath}
1391 environmentId={environmentId}
1392 threadRef={threadRef}
1393 draft={draft}
1394 absolutePath={absolutePath}
1395 workspaceRoot={cwd}
1396 alt={relativePath}
1397 workspaceMutationId={workspaceMutationId}
1398 />
1399 ) : relativePath && renderBrowserFile && absolutePath ? (
1400 <WorkspaceBrowserPreview
1401 key={absolutePath}
1402 environmentId={environmentId}
1403 threadRef={threadRef}
1404 draft={draft}
1405 absolutePath={absolutePath}
1406 workspaceRoot={cwd}
1407 title={relativePath}
1408 workspaceMutationId={workspaceMutationId}
1409 />
1410 ) : relativePath && file.error && file.data === null ? (
1411 <div
1412 role="alert"
1413 className="scrollbar-gutter-both flex min-h-0 flex-1 flex-col overflow-auto"
1414 >
1415 <div className="my-auto flex shrink-0 flex-col gap-3 px-6 py-6 text-center text-xs leading-relaxed">
1416 <p className="text-destructive">
1417 {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error}
1418 </p>
1419 {attemptedPath ? (
1420 <p className="text-muted-foreground">
1421 Attempted path
1422 <code className="block break-all font-mono text-foreground select-all">
1423 {attemptedPath}
1424 </code>
1425 </p>
1426 ) : null}
1427 {!isHostFile ? (
1428 <p className="text-muted-foreground">
1429 Workspace folder:{" "}
1430 <code className="break-all font-mono select-all">
1431 {file.readError?.cwd ?? cwd}
1432 </code>
1433 . Check the link's path or locate the file in Files.
1434 </p>
1435 ) : null}
1436 </div>
1437 </div>
1438 ) : relativePath && file.data === null ? (
1439 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
1440 <Spinner size="lg" />
1441 </div>
1442 ) : relativePath && file.data ? (
1443 isMarkdown && renderMarkdown ? (
1444 // Each file owns its editor selection and undo history.
1445 <RenderedMarkdownSurface
1446 key={relativePath}
1447 environmentId={environmentId}
1448 cwd={cwd}
1449 relativePath={relativePath}
1450 threadRef={threadRef}
1451 contents={file.data.contents}
1452 readOnly={isHostFile || !canWriteFiles || file.data.truncated}
1453 draft={draft}
1454 revision={workspaceMutationId}
1455 onPendingChange={onPendingChange}
1456 />
1457 ) : tableDelimiter && renderTable ? (
1458 <DelimitedTablePreview
1459 key={relativePath}
1460 name={relativePath}
1461 text={file.data.contents}
1462 delimiter={tableDelimiter}
1463 />
1464 ) : file.data.truncated || isHostFile || !canWriteFiles ? (
1465 <SourceFilePreview
1466 name={relativePath}
1467 text={file.data.contents}
1468 cacheKey={projectFileCacheKey(cwd, relativePath, file.data.contents)}
1469 onPostRender={onFilePostRender}
1470 />
1471 ) : (
1472 <DiffWorkerPoolProvider>
1473 <EditableFileSurface
1474 key={`${relativePath}:${resolvedTheme}`}
1475 environmentId={environmentId}
1476 cwd={cwd}
1477 relativePath={relativePath}
1478 composerDraftTarget={composerDraftTarget}
1479 contents={file.data.contents}
1480 resolvedTheme={resolvedTheme}
1481 revealRequestId={revealRequestId}
1482 wordWrap={wordWrap}
1483 onPostRender={onFilePostRender}
1484 onPendingChange={onPendingChange}
1485 />
1486 </DiffWorkerPoolProvider>
1487 )
1488 ) : null}
1489 </div>
1490 {showExplorer ? (
1491 <aside
1492 className={cn(
1493 "flex min-h-0 shrink-0 bg-background",
1494 previewPath
1495 ? "w-[min(22rem,46%)] min-w-64 border-l border-border/60"
1496 : "min-w-0 flex-1",
1497 )}
1498 >
1499 <FileBrowserPanel
1500 key={`${environmentId}:${cwd}`}
1501 environmentId={environmentId}
1502 cwd={cwd}
1503 projectName={projectName}
1504 selectedPath={relativePath}
1505 selectedPathRevealId={revealRequestId}
1506 onOpenFile={onOpenFile}
1507 workspaceMutationId={orchestrationMutationId}
1508 {...(previewPath && !isMedia && !isPdf
1509 ? { onRefreshSelectedFile: file.refresh }
1510 : {})}
1511 />
1512 </aside>
1513 ) : null}
1514 </div>
1515 </div>
1516 );
1517}