Skip to content
Source
Sign inSign up

ThreadFilesRouteScreen.tsx

1,075 lines37.0 KB

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