| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; |
| 2 | import { environmentSession } from "../../state/session"; |
| 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 4 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 5 | import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; |
| 6 | import { Platform, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 9 | import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts"; |
| 10 | import { videoMimeType } from "@t3tools/shared/video"; |
| 11 | import { |
| 12 | isWorkspaceBrowserPreviewPath, |
| 13 | isWorkspaceImagePreviewPath, |
| 14 | mediaMimeTypeFromExtension, |
| 15 | } from "@t3tools/shared/filePreview"; |
| 16 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 17 | |
| 18 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 19 | import { AudioFilePreview } from "../../components/AudioFilePreview"; |
| 20 | import { EmptyState } from "../../components/EmptyState"; |
| 21 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| 22 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 23 | import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| 24 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 25 | import { isPdfFile } from "../../lib/filePreview"; |
| 26 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 27 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 28 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; |
| 29 | import { useMediaActions } from "../../state/mediaActions"; |
| 30 | import { type MediaActionsSource } from "../../lib/mediaActionsSource"; |
| 31 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 32 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 33 | import { useEnvironmentQuery } from "../../state/query"; |
| 34 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 35 | import { projectEnvironment } from "../../state/projects"; |
| 36 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; |
| 37 | import { |
| 38 | useAdaptiveWorkspaceLayout, |
| 39 | useAdaptiveWorkspacePaneRole, |
| 40 | useRegisterWorkspaceInspector, |
| 41 | } from "../layout/AdaptiveWorkspaceLayout"; |
| 42 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 43 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 44 | import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; |
| 45 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 46 | import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack"; |
| 47 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 48 | import { useFileTreeEntries } from "./useFileTreeEntries"; |
| 49 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 50 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 51 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; |
| 52 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 53 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 54 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 55 | import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; |
| 56 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; |
| 57 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 58 | import { |
| 59 | basename, |
| 60 | fileHeaderSubtitle, |
| 61 | isAudioPreviewFile, |
| 62 | isMarkdownPreviewFile, |
| 63 | isSvgImagePreviewFile, |
| 64 | isVideoPreviewFile, |
| 65 | } from "./filePath"; |
| 66 | import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; |
| 67 | |
| 68 | function 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 | |
| 104 | function 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 | |
| 178 | type 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. |
| 182 | function 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 | |
| 187 | function 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 | |
| 195 | function 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 | |
| 203 | function 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 | |
| 213 | function 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 | |
| 336 | type ThreadFilesRouteScreenProps = StaticScreenProps<{ |
| 337 | readonly environmentId: string; |
| 338 | readonly threadId: string; |
| 339 | }>; |
| 340 | |
| 341 | type 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 | |
| 352 | function 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 | |
| 384 | function 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 | |
| 397 | function 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 | |
| 425 | export 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 | |
| 590 | export 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 | } |