1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
3import { NativeStackScreenOptions } from "../../native/StackHeader";
4import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
5import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
6import { Platform, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
9import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts";
10import { videoMimeType } from "@t3tools/shared/video";
11import {
12 isWorkspaceBrowserPreviewPath,
13 isWorkspaceImagePreviewPath,
14 mediaMimeTypeFromExtension,
15} from "@t3tools/shared/filePreview";
16import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
17
18import { MaterialScreenContent } from "../../components/MaterialScreenContent";
19import { AudioFilePreview } from "../../components/AudioFilePreview";
20import { EmptyState } from "../../components/EmptyState";
21import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
22import { LoadingScreen } from "../../components/LoadingScreen";
23import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
24import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
25import { isPdfFile } from "../../lib/filePreview";
26import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
27import { useUniwindTheme } from "../../lib/useUniwindTheme";
28import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource";
29import { useMediaActions } from "../../state/mediaActions";
30import { type MediaActionsSource } from "../../lib/mediaActionsSource";
31import { useThreadSelection } from "../../state/use-thread-selection";
32import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
33import { useEnvironmentQuery } from "../../state/query";
34import { useEnvironmentPresentation } from "../../state/presentation";
35import { projectEnvironment } from "../../state/projects";
36import type { AssetUrlFailureReason } from "../../state/asset-url-state";
37import {
38 useAdaptiveWorkspaceLayout,
39 useAdaptiveWorkspacePaneRole,
40 useRegisterWorkspaceInspector,
41} from "../layout/AdaptiveWorkspaceLayout";
42import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
43import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
44import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
45import { FileMarkdownPreview } from "./FileMarkdownPreview";
46import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack";
47import { FileTreeBrowser } from "./FileTreeBrowser";
48import { useFileTreeEntries } from "./useFileTreeEntries";
49import { preloadWorkspaceFileContents } from "./preload-workspace-file";
50import { SourceFileSurface } from "./SourceFileSurface";
51import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
52import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
53import { ScreenHeader } from "../../components/ScreenHeader";
54import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
55import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
56import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
57import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
58import {
59 basename,
60 fileHeaderSubtitle,
61 isAudioPreviewFile,
62 isMarkdownPreviewFile,
63 isSvgImagePreviewFile,
64 isVideoPreviewFile,
65} from "./filePath";
66import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl";
67
68function FilesBrowserHeader(props: {
69 readonly projectName: string;
70 readonly searchQuery: string;
71 readonly onSearchQueryChange: (query: string) => void;
72 readonly onRefresh: () => void;
73 readonly onBack: () => void;
74}) {
75 return (
76 <ScreenHeader
77 title="Files"
78 subtitle={props.projectName}
79 onBack={props.onBack}
80 hideBottomBorder
81 matchSearchSurface
82 search={{
83 value: props.searchQuery,
84 onChangeText: props.onSearchQueryChange,
85 placeholder: "Search files",
86 closeAccessibilityLabel: "Close file search",
87 clearAccessibilityLabel: "Clear file search",
88 }}
89 menus={
90 Platform.OS === "android"
91 ? [
92 {
93 title: "File options",
94 icon: "ellipsis",
95 items: [{ id: "refresh", title: "Refresh files", onPress: props.onRefresh }],
96 },
97 ]
98 : undefined
99 }
100 />
101 );
102}
103
104function FileHeader(props: {
105 readonly title: string;
106 readonly subtitle: string;
107 readonly iconColor: string;
108 readonly activeMode: string;
109 readonly fileInspectorSupported: boolean;
110 readonly onBack: () => void;
111 readonly onReturnToThread: () => void;
112 readonly actions: ReadonlyArray<{
113 readonly id: string;
114 readonly title: string;
115 readonly icon: string;
116 readonly inline: boolean;
117 readonly onPress: () => unknown;
118 }>;
119}) {
120 const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
121 const modes = props.actions.filter(({ inline }) => inline);
122 return (
123 <ScreenHeader
124 title={props.title}
125 subtitle={props.subtitle}
126 onBack={props.onBack}
127 hideBottomBorder
128 options={{ headerTintColor: props.iconColor, headerTitle: props.title }}
129 backInSplitView={
130 props.fileInspectorSupported
131 ? {
132 accessibilityLabel: "Return to chat",
133 icon: "chevron.left",
134 onPress: props.onReturnToThread,
135 }
136 : undefined
137 }
138 actions={
139 props.fileInspectorSupported
140 ? [
141 {
142 accessibilityLabel: panes.auxiliaryPaneVisible
143 ? "Hide file navigator"
144 : "Show file navigator",
145 icon: "sidebar.right",
146 selected: panes.auxiliaryPaneVisible,
147 onPress: toggleAuxiliaryPane,
148 },
149 ]
150 : undefined
151 }
152 menus={[
153 {
154 title: "File actions",
155 icon: "ellipsis",
156 separateBackground: false,
157 items: [
158 ...(modes.length > 0
159 ? [
160 {
161 id: "modes",
162 inline: true,
163 items: modes.map((action) => ({
164 ...action,
165 selected: action.id === props.activeMode,
166 })),
167 },
168 ]
169 : []),
170 ...props.actions.filter(({ inline }) => !inline),
171 ],
172 },
173 ]}
174 />
175 );
176}
177
178type FileViewMode = "preview" | "source";
179
180// A blank param (a hand-typed deep link) is treated as missing, since branded
181// IDs reject whitespace-only values.
182function firstRouteParam(value: string | string[] | undefined): string | null {
183 const first = Array.isArray(value) ? value[0] : value;
184 return first === undefined || first.trim().length === 0 ? null : first;
185}
186
187function normalizeRoutePath(value: string | string[] | undefined): string | null {
188 const path = Array.isArray(value) ? value.join("/") : value;
189 if (path === undefined || path.trim().length === 0) {
190 return null;
191 }
192 return path;
193}
194
195function normalizeRouteLine(value: string | null): number | null {
196 if (value === null) {
197 return null;
198 }
199 const parsed = Number(value);
200 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
201}
202
203function defaultViewMode(path: string | null): FileViewMode {
204 return path !== null &&
205 (isWorkspaceBrowserPreviewPath(path) ||
206 isWorkspaceImagePreviewPath(path) ||
207 isVideoPreviewFile(path) ||
208 isAudioPreviewFile(path))
209 ? "preview"
210 : "source";
211}
212
213function FileContent(props: {
214 readonly activeMode: FileViewMode;
215 readonly cwd: string;
216 readonly environmentId: EnvironmentId;
217 readonly previewUri: string | null;
218 readonly previewFailure: AssetUrlFailureReason | null;
219 readonly onRetryPreview: () => void;
220 readonly videoSource: MediaVideoPreviewSource | null;
221 readonly mediaSource?: MediaActionsSource;
222 readonly resolveVideoUri: () => Promise<string | null>;
223 readonly fileContents: string | null;
224 readonly fileError: string | null;
225 readonly relativePath: string;
226 readonly threadId: ThreadId | null;
227 readonly initialLine: number | null;
228 readonly truncated: boolean;
229 readonly onRefresh?: () => Promise<void> | void;
230}) {
231 // Reopening a mutable host file must not reuse a poster from an earlier visit.
232 const thumbnailInstanceId = useId();
233 const insets = useSafeAreaInsets();
234 const columnMetrics = useNativeColumnLayoutMetrics();
235 const reservedHeaderInset =
236 Platform.OS === "ios" && props.truncated ? (columnMetrics?.safeArea.top ?? insets.top) : 0;
237 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
238 const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath);
239 const isImageFile = isWorkspaceImagePreviewPath(props.relativePath);
240 const isVideoFile = isVideoPreviewFile(props.relativePath);
241 const isAudioFile = isAudioPreviewFile(props.relativePath);
242 // Only the surfaces that wait on a signed asset URL can be blocked by one.
243 const needsAssetUrl =
244 isVideoFile ||
245 isAudioFile ||
246 (props.activeMode === "preview" && (isImageFile || isBrowserFile));
247
248 if (needsAssetUrl && props.previewFailure !== null) {
249 return (
250 <WorkspaceFilePreviewError
251 environmentId={props.environmentId}
252 reason={props.previewFailure}
253 onRetry={props.onRetryPreview}
254 />
255 );
256 }
257
258 if (isVideoFile) {
259 return (
260 <WorkspaceFileVideoPreview
261 name={basename(props.relativePath)}
262 thumbnailKey={`workspace-video:${thumbnailInstanceId}`}
263 uri={props.previewUri}
264 source={props.videoSource}
265 resolvePlaybackUri={props.resolveVideoUri}
266 />
267 );
268 }
269
270 if (isAudioFile) {
271 return props.previewUri === null ? (
272 <FilePreviewLoading message="Loading file..." />
273 ) : (
274 <AudioFilePreview uri={props.previewUri} onRetry={props.onRetryPreview} />
275 );
276 }
277
278 if (props.activeMode === "preview" && isImageFile) {
279 if (isSvgImagePreviewFile(props.relativePath)) {
280 return <WorkspaceFileWebPreview uri={props.previewUri} />;
281 }
282 return (
283 <WorkspaceFileImagePreview
284 accessibilityLabel={basename(props.relativePath)}
285 uri={props.previewUri}
286 actionsSource={props.mediaSource}
287 />
288 );
289 }
290
291 if (props.activeMode === "preview" && isBrowserFile) {
292 return <WorkspaceFileWebPreview uri={props.previewUri} />;
293 }
294
295 if (props.fileError && props.fileContents === null) {
296 return (
297 <View className="flex-1 items-center justify-center bg-sheet px-6">
298 <EmptyState title="File unavailable" detail={props.fileError} />
299 </View>
300 );
301 }
302
303 if (props.fileContents === null) {
304 return <FilePreviewLoading message="Loading file..." />;
305 }
306
307 return (
308 <View className="flex-1 bg-sheet" style={{ paddingTop: reservedHeaderInset }}>
309 {props.truncated ? (
310 <FilePreviewNotice title="Partial file">
311 Preview limited to the first 1 MB of a truncated file.
312 </FilePreviewNotice>
313 ) : null}
314 {props.activeMode === "preview" && isMarkdown ? (
315 <FileMarkdownPreview
316 cwd={props.cwd}
317 environmentId={props.environmentId}
318 markdown={props.fileContents}
319 relativePath={props.relativePath}
320 threadId={props.threadId}
321 onRefresh={props.onRefresh}
322 />
323 ) : (
324 <SourceFileSurface
325 contents={props.fileContents}
326 path={props.relativePath}
327 initialLine={props.initialLine}
328 headerInsetTop={props.truncated ? 0 : undefined}
329 onRefresh={props.onRefresh}
330 />
331 )}
332 </View>
333 );
334}
335
336type ThreadFilesRouteScreenProps = StaticScreenProps<{
337 readonly environmentId: string;
338 readonly threadId: string;
339}>;
340
341type ThreadFileRouteScreenProps = StaticScreenProps<{
342 readonly environmentId: string;
343 /** Absent for a project draft, which has no thread yet. */
344 readonly threadId?: string;
345 readonly path: string[];
346 readonly line?: string;
347 /** Supplied when there is no thread to resolve the workspace from. */
348 readonly cwd?: string;
349 readonly projectName?: string;
350}>;
351
352function useThreadFilesWorkspace(params: {
353 readonly environmentId?: string | string[];
354 readonly threadId?: string | string[];
355 readonly cwd?: string | string[];
356 readonly projectName?: string | string[];
357}) {
358 const routeEnvironmentId = firstRouteParam(params.environmentId);
359 const routeThreadId = firstRouteParam(params.threadId);
360 // A project draft has no thread to resolve a workspace from, so it names one itself.
361 const routeCwd = firstRouteParam(params.cwd);
362 const routeProjectName = firstRouteParam(params.projectName);
363 const { selectedThread, selectedThreadProject } = useThreadSelection();
364 const { selectedThreadCwd } = useSelectedThreadWorktree();
365 const environmentId =
366 routeEnvironmentId !== null
367 ? EnvironmentId.make(routeEnvironmentId)
368 : (selectedThread?.environmentId ?? null);
369 const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null;
370 const project = selectedThreadProject as {
371 readonly title?: string;
372 readonly workspaceRoot?: string;
373 } | null;
374
375 return {
376 cwd: routeCwd ?? selectedThreadCwd ?? project?.workspaceRoot ?? null,
377 environmentId,
378 projectName: routeProjectName ?? project?.title ?? "Files",
379 selectedThread,
380 threadId,
381 };
382}
383
384function FilesUnavailable({
385 detail = "This thread does not have an active workspace path.",
386}: {
387 detail?: string;
388}) {
389 return (
390 <View className="flex-1 items-center justify-center bg-sheet px-6">
391 <NativeStackScreenOptions options={{ title: "Files" }} />
392 <EmptyState title="Files unavailable" detail={detail} />
393 </View>
394 );
395}
396
397function FilesToolbarBottomFade() {
398 const sheetColor = String(useUniwindTheme()["--color-sheet"]);
399
400 if (process.env.EXPO_OS !== "ios") {
401 return null;
402 }
403
404 return (
405 <View
406 pointerEvents="none"
407 accessibilityElementsHidden
408 importantForAccessibility="no-hide-descendants"
409 className="absolute inset-x-0 bottom-0 z-[1] h-28"
410 >
411 <Svg width="100%" height="100%">
412 <Defs>
413 <LinearGradient id="files-toolbar-bottom-fade" x1="0%" x2="0%" y1="0%" y2="100%">
414 <Stop offset="0%" stopColor={sheetColor} stopOpacity={0} />
415 <Stop offset="58%" stopColor={sheetColor} stopOpacity={0.72} />
416 <Stop offset="100%" stopColor={sheetColor} stopOpacity={0.96} />
417 </LinearGradient>
418 </Defs>
419 <Rect width="100%" height="100%" fill="url(#files-toolbar-bottom-fade)" />
420 </Svg>
421 </View>
422 );
423}
424
425export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
426 useAdaptiveWorkspacePaneRole("inspector");
427 const navigation = useNavigation();
428 const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
429 const [searchQuery, setSearchQuery] = useState("");
430 const { themeAppearance: highlightTheme } = useAppearancePreferences();
431 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
432 props.route.params,
433 );
434 const revealedInspectorRef = useRef(false);
435 const fileAccessSession = useEnvironmentQuery(
436 environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null,
437 );
438 const fileEnvironment = useEnvironmentPresentation(environmentId);
439 const fileAccess = resolveFilesystemReadAccess({
440 isCatalogReady: fileEnvironment.isReady,
441 connection: fileEnvironment.presentation?.connection ?? null,
442 session: fileAccessSession.data,
443 sessionError: fileAccessSession.error,
444 });
445 const { canReadFiles } = fileAccess;
446 const entriesQuery = useFileTreeEntries({
447 environmentId,
448 cwd: !canReadFiles || fileInspector.supported ? null : cwd,
449 searchQuery,
450 });
451 const handleReturnToThread = useCallback(() => {
452 if (navigation.canGoBack()) {
453 navigation.goBack();
454 return;
455 }
456 if (environmentId !== null && threadId !== null) {
457 navigation.dispatch(
458 StackActions.replace("Thread", {
459 environmentId: String(environmentId),
460 threadId: String(threadId),
461 }),
462 );
463 }
464 }, [environmentId, navigation, threadId]);
465
466 const handleSelectFile = useCallback(
467 (path: string) => {
468 if (environmentId === null || threadId === null) {
469 return;
470 }
471 const params = {
472 environmentId: String(environmentId),
473 threadId: String(threadId),
474 path: path.split("/").filter((segment) => segment.length > 0),
475 };
476 const navigationAction = resolveFileSelectionNavigationAction({
477 hasPersistentFileInspector: fileInspector.supported,
478 });
479 if (navigationAction === "replace") {
480 navigation.dispatch(StackActions.replace("ThreadFile", params));
481 return;
482 }
483 navigation.navigate("ThreadFile", params);
484 },
485 [environmentId, fileInspector.supported, navigation, threadId],
486 );
487 const renderInspector = useCallback(
488 (headerInset: number) =>
489 environmentId !== null && cwd !== null ? (
490 <ThreadFileNavigatorPane
491 cwd={cwd}
492 environmentId={environmentId}
493 headerInset={headerInset}
494 projectName={projectName}
495 selectedPath={null}
496 onSelectFile={handleSelectFile}
497 />
498 ) : null,
499 [cwd, environmentId, handleSelectFile, projectName],
500 );
501 const handlePreviewFile = useCallback(
502 (relativePath: string) => {
503 if (environmentId === null || cwd === null) {
504 return;
505 }
506 preloadWorkspaceFileContents({
507 cwd,
508 environmentId,
509 relativePath,
510 theme: highlightTheme,
511 });
512 },
513 [cwd, environmentId, highlightTheme],
514 );
515 useEffect(() => {
516 if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {
517 revealedInspectorRef.current = true;
518 showAuxiliaryPane("inspector");
519 }
520 }, [cwd, fileInspector.supported, showAuxiliaryPane]);
521
522 if (selectedThread === null || environmentId === null || threadId === null) {
523 if (fileInspector.supported) {
524 return (
525 <ThreadRouteScreen
526 onReturnToThread={handleReturnToThread}
527 renderInspector={renderInspector}
528 route={props.route}
529 />
530 );
531 }
532 return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
533 }
534
535 if (!canReadFiles) {
536 if (fileAccess.isPending) {
537 return <LoadingScreen message="Checking file access..." messagePlacement="above-spinner" />;
538 }
539 return (
540 <FilesUnavailable detail={fileAccess.error ?? "This connection cannot read host files."} />
541 );
542 }
543 if (cwd === null) {
544 return <FilesUnavailable />;
545 }
546
547 if (fileInspector.supported) {
548 return (
549 <ThreadRouteScreen
550 onReturnToThread={handleReturnToThread}
551 renderInspector={renderInspector}
552 route={props.route}
553 />
554 );
555 }
556
557 const content = (
558 <>
559 <FilesBrowserHeader
560 projectName={projectName}
561 searchQuery={searchQuery}
562 onSearchQueryChange={setSearchQuery}
563 onRefresh={entriesQuery.refresh}
564 onBack={handleReturnToThread}
565 />
566 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
567 <FileTreeBrowser
568 key={JSON.stringify([environmentId, cwd])}
569 entries={entriesQuery.entries}
570 loadingDirectories={entriesQuery.loadingDirectories}
571 onLoadDirectory={entriesQuery.loadDirectory}
572 error={entriesQuery.error}
573 isPending={entriesQuery.isPending}
574 isRefreshing={entriesQuery.isRefreshing}
575 searchQuery={searchQuery}
576 searchTruncated={entriesQuery.searchTruncated}
577 selectedPath={null}
578 onPreviewFile={handlePreviewFile}
579 onRefresh={entriesQuery.refresh}
580 onSelectFile={handleSelectFile}
581 />
582 <FilesToolbarBottomFade />
583 </MaterialScreenContent>
584 </>
585 );
586
587 return Platform.OS === "android" ? <View className="flex-1 bg-header">{content}</View> : content;
588}
589
590export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
591 useAdaptiveWorkspacePaneRole("inspector");
592 const navigation = useNavigation();
593 const { fileInspector } = useAdaptiveWorkspaceLayout();
594 const { appearance, setCodeWordBreak } = useAppearancePreferences();
595 const iconColor = useUniwindTheme()["--color-icon"];
596 const params = props.route.params;
597 const relativePath = normalizeRoutePath(params.path);
598 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
599 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
600 props.route.params,
601 );
602 const [modeOverride, setModeOverride] = useState<{
603 readonly path: string;
604 readonly mode: FileViewMode;
605 } | null>(null);
606 const [previewRevision, setPreviewRevision] = useState(0);
607 const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]);
608 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
609 const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath);
610 const isAudioFile = relativePath !== null && !isVideoFile && isAudioPreviewFile(relativePath);
611 const isBrowserFile =
612 relativePath !== null && !isVideoFile && isWorkspaceBrowserPreviewPath(relativePath);
613 const isImageFile =
614 relativePath !== null && !isVideoFile && isWorkspaceImagePreviewPath(relativePath);
615 const canPreview =
616 relativePath !== null &&
617 (isMarkdownPreviewFile(relativePath) ||
618 isBrowserFile ||
619 isImageFile ||
620 isVideoFile ||
621 isAudioFile);
622 const activeMode =
623 relativePath !== null && modeOverride?.path === relativePath
624 ? modeOverride.mode
625 : defaultViewMode(relativePath);
626 const resolvedActiveMode =
627 isVideoFile || isAudioFile ? "preview" : canPreview ? activeMode : "source";
628 const assetPreviewPath =
629 isBrowserFile || isImageFile || isVideoFile || isAudioFile ? relativePath : null;
630 const assetPreview = useWorkspaceFileAssetUrlState({
631 cwd,
632 environmentId,
633 relativePath: assetPreviewPath,
634 threadId,
635 // A project draft names its workspace root explicitly: there is no thread to resolve one.
636 draftCwd: threadId === null ? cwd : null,
637 });
638 const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null;
639 const mediaSource = useMemo<MediaActionsSource | undefined>(
640 () =>
641 environmentId !== null &&
642 relativePath !== null &&
643 assetPreview.resource !== null &&
644 "path" in assetPreview.resource &&
645 typeof assetPreview.resource.path === "string" &&
646 (isImageFile || isVideoFile)
647 ? {
648 reference: mediaFileReference(assetPreview.resource.path, cwd),
649 name: basename(relativePath),
650 mimeType:
651 mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ??
652 "application/octet-stream",
653 environmentId,
654 ...(threadId === null ? {} : { threadId }),
655 resource: assetPreview.resource,
656 }
657 : undefined,
658 [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId],
659 );
660 const mediaActions = useMediaActions(mediaSource);
661 const videoSource = useMemo<MediaVideoPreviewSource | null>(
662 () =>
663 environmentId !== null &&
664 relativePath !== null &&
665 (assetPreview.resource?._tag === "media-file" ||
666 assetPreview.resource?._tag === "draft-workspace-file")
667 ? {
668 type: "media",
669 environmentId,
670 resource: assetPreview.resource,
671 name: basename(relativePath),
672 mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4",
673 actionsSource: mediaSource,
674 }
675 : null,
676 [assetPreview.resource, environmentId, relativePath, mediaSource],
677 );
678 const previewUri =
679 assetPreviewUri === null || previewRevision === 0
680 ? assetPreviewUri
681 : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
682 // Remounting the preview after a re-mint is what makes a failed asset URL retryable.
683 const handleRetryPreview = () => {
684 void assetPreview.refresh().finally(() => setPreviewRevision((current) => current + 1));
685 };
686 const needsFileContents =
687 relativePath !== null &&
688 !isVideoFile &&
689 !isAudioFile &&
690 (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
691 const fileAccessSession = useEnvironmentQuery(
692 environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null,
693 );
694 const fileEnvironment = useEnvironmentPresentation(environmentId);
695 const fileAccess = resolveFilesystemReadAccess({
696 isCatalogReady: fileEnvironment.isReady,
697 connection: fileEnvironment.presentation?.connection ?? null,
698 session: fileAccessSession.data,
699 sessionError: fileAccessSession.error,
700 });
701 const { canReadFiles } = fileAccess;
702 const fileQuery = useEnvironmentQuery(
703 canReadFiles &&
704 environmentId !== null &&
705 cwd !== null &&
706 relativePath !== null &&
707 needsFileContents
708 ? projectEnvironment.readFile({
709 environmentId,
710 input: { cwd, relativePath },
711 })
712 : null,
713 );
714 const fileData = fileQuery.data as ProjectReadFileResult | null;
715
716 const handleSelectFile = useCallback(
717 (path: string) => {
718 const segments = path.split("/").filter(Boolean);
719 // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a
720 // thread to resolve, so a draft stays on its own route and carries its workspace along.
721 if (threadId === null) {
722 navigation.dispatch(
723 StackActions.push("NewTaskFile", {
724 environmentId: String(environmentId),
725 ...(cwd === null ? {} : { cwd }),
726 projectName,
727 path: segments,
728 }),
729 );
730 return;
731 }
732 navigation.navigate("ThreadFile", {
733 environmentId: String(environmentId),
734 threadId: String(threadId),
735 path: segments,
736 });
737 },
738 [cwd, environmentId, navigation, projectName, threadId],
739 );
740 const renderInspector = useCallback(
741 (headerInset: number) =>
742 fileInspector.supported && environmentId !== null && cwd !== null ? (
743 <ThreadFileNavigatorPane
744 cwd={cwd}
745 environmentId={environmentId}
746 headerInset={headerInset}
747 projectName={projectName}
748 selectedPath={relativePath}
749 onSelectFile={handleSelectFile}
750 />
751 ) : undefined,
752 [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath],
753 );
754 // The workspace inspector column spans the full window height. On iOS the
755 // pane brings its own nested native header; elsewhere it pads itself below
756 // the top inset.
757 const safeAreaInsets = useSafeAreaInsets();
758 const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
759 // Hand the file navigator to the workspace so it renders beside the
760 // navigator, outside this screen's native header.
761 const renderWorkspaceInspector = useCallback(
762 () => (
763 <ThreadInspectorContentStack
764 mode="files"
765 resetKeys={[threadId, cwd]}
766 renderFiles={() => renderInspector(inspectorHeaderInset)}
767 />
768 ),
769 [cwd, inspectorHeaderInset, renderInspector, threadId],
770 );
771 useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
772
773 const fileMenuActions = useMemo(() => {
774 if (relativePath === null) return [];
775 const canToggleMode = canPreview && !isImageFile && !isVideoFile && !isAudioFile;
776 return [
777 canToggleMode
778 ? ({
779 id: "preview",
780 title: "Preview",
781 icon: "eye",
782 inline: true,
783 onPress: () => setModeOverride({ path: relativePath, mode: "preview" }),
784 } as const)
785 : null,
786 canToggleMode
787 ? ({
788 id: "source",
789 title: "Source",
790 icon: "doc.text",
791 inline: true,
792 onPress: () => setModeOverride({ path: relativePath, mode: "source" }),
793 } as const)
794 : null,
795 // Only the source body wraps; a rendered preview lays itself out.
796 resolvedActiveMode === "source"
797 ? ({
798 id: "word-wrap",
799 title: appearance.codeWordBreak ? "Disable word wrap" : "Enable word wrap",
800 icon: "text.alignleft",
801 inline: false,
802 onPress: () => setCodeWordBreak(!appearance.codeWordBreak),
803 } as const)
804 : null,
805 ...(mediaSource
806 ? mediaActions.actions
807 .filter(({ id }) => id !== "open-file")
808 .map((action) => ({
809 id: action.id,
810 title: action.title,
811 icon:
812 action.id === "save" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const),
813 inline: false,
814 onPress: action.run,
815 }))
816 : [
817 {
818 id: "copy-path",
819 title: "Copy path",
820 icon: "doc.on.doc",
821 inline: false,
822 onPress: () => copyTextWithHaptic(relativePath),
823 } as const,
824 ]),
825 // Selecting a long file by hand is painful on a phone, so copying the whole thing is
826 // the action most readers actually want. The attachment screen already offers it.
827 fileData?.contents != null
828 ? ({
829 id: "copy-contents",
830 title: fileData.truncated ? "Copy preview" : "Copy contents",
831 icon: "doc.on.doc",
832 inline: false,
833 onPress: () => copyTextWithHaptic(fileData.contents),
834 } as const)
835 : null,
836 isPdfFile({ name: relativePath }) && previewUri !== null
837 ? ({
838 id: "open-pdf",
839 title: "Open PDF",
840 icon: "arrow.up.left.and.arrow.down.right",
841 inline: false,
842 onPress: () =>
843 setFullScreenPreview({
844 kind: "pdf",
845 uri: previewUri,
846 name: basename(relativePath),
847 }),
848 } as const)
849 : null,
850 isBrowserFile && typeof assetPreviewUri === "string"
851 ? ({
852 id: "open-browser",
853 title: Platform.OS === "ios" ? "Open in Safari" : "Open in browser",
854 icon: "safari",
855 inline: false,
856 onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"),
857 } as const)
858 : null,
859 resolvedActiveMode === "preview" &&
860 (isBrowserFile || isImageFile || isVideoFile || isAudioFile)
861 ? ({
862 id: "refresh",
863 title: "Refresh",
864 icon: "arrow.clockwise",
865 inline: false,
866 onPress: async () => {
867 if (isVideoFile || isAudioFile) await assetPreview.refresh();
868 setPreviewRevision((current) => current + 1);
869 },
870 } as const)
871 : null,
872 ].filter((action) => action !== null);
873 }, [
874 appearance.codeWordBreak,
875 setCodeWordBreak,
876 assetPreviewUri,
877 assetPreview.refresh,
878 previewUri,
879 canPreview,
880 isAudioFile,
881 isBrowserFile,
882 isImageFile,
883 isVideoFile,
884 relativePath,
885 resolvedActiveMode,
886 mediaSource,
887 mediaActions.actions,
888 fileData?.contents,
889 fileData?.truncated,
890 ]);
891
892 const handleReturnToThread = useCallback(() => {
893 if (environmentId !== null && threadId !== null) {
894 navigation.dispatch(
895 StackActions.replace("Thread", {
896 environmentId: String(environmentId),
897 threadId: String(threadId),
898 }),
899 );
900 }
901 }, [environmentId, navigation, threadId]);
902 const handleBack = useCallback(() => {
903 if (navigation.canGoBack()) {
904 navigation.goBack();
905 return;
906 }
907 handleReturnToThread();
908 }, [handleReturnToThread, navigation]);
909
910 // A file opened from a project draft has no thread, and needs none: the thread only supplies
911 // the workspace to read from and the target to navigate back to, both of which a draft names
912 // for itself. Wait only for what this file actually cannot render without.
913 if (environmentId === null || (threadId !== null && selectedThread === null)) {
914 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
915 }
916
917 if (!canReadFiles) {
918 if (fileAccess.isPending) {
919 return <LoadingScreen message="Checking file access..." messagePlacement="above-spinner" />;
920 }
921 return (
922 <FilesUnavailable detail={fileAccess.error ?? "This connection cannot read host files."} />
923 );
924 }
925 if (cwd === null) {
926 return <FilesUnavailable />;
927 }
928
929 if (relativePath === null) {
930 return (
931 <View className="flex-1 items-center justify-center bg-sheet px-6">
932 <NativeStackScreenOptions options={{ title: "Files" }} />
933 <EmptyState title="File unavailable" detail="This file path is invalid." />
934 </View>
935 );
936 }
937
938 const headerSubtitle = fileHeaderSubtitle(projectName, relativePath);
939
940 return (
941 <View className="flex-1 bg-sheet">
942 <FileHeader
943 title={basename(relativePath)}
944 subtitle={headerSubtitle}
945 iconColor={iconColor}
946 activeMode={resolvedActiveMode}
947 fileInspectorSupported={fileInspector.supported}
948 onBack={handleBack}
949 onReturnToThread={handleReturnToThread}
950 actions={fileMenuActions}
951 />
952 <MaterialScreenContent>
953 <FileContent
954 key={previewKey}
955 activeMode={resolvedActiveMode}
956 cwd={cwd}
957 environmentId={environmentId}
958 previewUri={previewUri}
959 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
960 onRetryPreview={handleRetryPreview}
961 videoSource={videoSource}
962 mediaSource={mediaSource}
963 resolveVideoUri={assetPreview.refresh}
964 fileContents={fileData?.contents ?? null}
965 fileError={fileQuery.error}
966 initialLine={targetLine}
967 relativePath={relativePath}
968 threadId={threadId}
969 truncated={fileData?.truncated ?? false}
970 onRefresh={() => fileQuery.refresh()}
971 />
972 </MaterialScreenContent>
973 <FilePreviewModal
974 source={fullScreenPreview}
975 onRequestClose={() => setFullScreenPreview(null)}
976 />
977 </View>
978 );
979}