import Stack from "expo-router/stack";
import { SymbolView } from "expo-symbols";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import {
EnvironmentId,
type ProjectListEntriesResult,
type ProjectReadFileResult,
ThreadId,
} from "@t3tools/contracts";
import { AppText as Text } from "../../components/AppText";
import { CopyTextButton } from "../../components/CopyTextButton";
import { EmptyState } from "../../components/EmptyState";
import { LoadingScreen } from "../../components/LoadingScreen";
import { cn } from "../../lib/cn";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
import { buildThreadFilesNavigation } from "../../lib/routes";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
import { useThemeColor } from "../../lib/useThemeColor";
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useEnvironmentQuery } from "../../state/query";
import { projectEnvironment } from "../../state/projects";
import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
import { FileMarkdownPreview } from "./FileMarkdownPreview";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { SourceFileSurface } from "./SourceFileSurface";
import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
import {
basename,
fileBreadcrumbs,
isBrowserPreviewFile,
isImagePreviewFile,
isMarkdownPreviewFile,
isSvgImagePreviewFile,
} from "./filePath";
import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl";
type FileViewMode = "preview" | "source";
function firstRouteParam(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}
function normalizeRoutePath(value: string | string[] | undefined): string | null {
const path = Array.isArray(value) ? value.join("/") : value;
if (path === undefined || path.trim().length === 0) {
return null;
}
return path;
}
function normalizeRouteLine(value: string | null): number | null {
if (value === null) {
return null;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function defaultViewMode(path: string | null): FileViewMode {
return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path))
? "preview"
: "source";
}
function ModeButton(props: {
readonly active: boolean;
readonly icon: "doc.text" | "eye";
readonly label: string;
readonly onPress: () => void;
}) {
const iconColor = String(
useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"),
);
return (
{props.label}
);
}
function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) {
const gradientId = `file-breadcrumb-${props.side}-fade`;
const isLeft = props.side === "left";
return (
);
}
function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) {
const iconColor = String(useThemeColor("--color-icon-muted"));
const cardColor = String(useThemeColor("--color-card"));
const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 });
const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false });
const breadcrumbs = useMemo(
() => fileBreadcrumbs(props.projectName, props.relativePath),
[props.projectName, props.relativePath],
);
const updateFadeVisibility = useCallback(
(metrics: Partial<(typeof scrollMetrics)["current"]>) => {
Object.assign(scrollMetrics.current, metrics);
const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current;
const maxOffset = Math.max(0, contentWidth - viewportWidth);
const next = {
left: maxOffset > 1 && offsetX > 1,
right: maxOffset > 1 && offsetX < maxOffset - 1,
};
setFadeVisibility((current) =>
current.left === next.left && current.right === next.right ? current : next,
);
},
[],
);
return (
{
updateFadeVisibility({ contentWidth });
}}
onLayout={(event) => {
updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width });
}}
onScroll={(event) => {
updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x });
}}
scrollEventThrottle={16}
>
{breadcrumbs.map((crumb, index) => (
{index > 0 ? (
) : null}
{crumb.label}
))}
{fadeVisibility.left ? : null}
{fadeVisibility.right ? : null}
);
}
function FilePreviewHeader(props: {
readonly activeMode: FileViewMode;
readonly showModeSelector: boolean;
readonly externalPreviewUri?: string | null;
readonly projectName: string;
readonly relativePath: string;
readonly onSetMode: (mode: FileViewMode) => void;
}) {
const iconColor = String(useThemeColor("--color-icon-muted"));
return (
{props.showModeSelector ? (
props.onSetMode("preview")}
/>
props.onSetMode("source")}
/>
{props.externalPreviewUri !== undefined ? (
{
if (typeof props.externalPreviewUri === "string") {
void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
}
}}
>
) : null}
) : null}
);
}
function FileContent(props: {
readonly activeMode: FileViewMode;
readonly previewUri: string | null;
readonly fileContents: string | null;
readonly fileError: string | null;
readonly relativePath: string;
readonly initialLine: number | null;
readonly truncated: boolean;
}) {
const isMarkdown = isMarkdownPreviewFile(props.relativePath);
const isBrowserFile = isBrowserPreviewFile(props.relativePath);
const isImageFile = isImagePreviewFile(props.relativePath);
if (props.activeMode === "preview" && isImageFile) {
if (isSvgImagePreviewFile(props.relativePath)) {
return ;
}
return (
);
}
if (props.activeMode === "preview" && isBrowserFile) {
return ;
}
if (props.fileError && props.fileContents === null) {
return (
);
}
if (props.fileContents === null) {
return (
Loading file...
);
}
return (
{props.truncated ? (
Partial file
Preview limited to the first 1 MB of a truncated file.
) : null}
{props.activeMode === "preview" && isMarkdown ? (
) : (
)}
);
}
function useThreadFilesWorkspace() {
const params = useLocalSearchParams<{
environmentId?: string | string[];
threadId?: string | string[];
}>();
const routeEnvironmentId = firstRouteParam(params.environmentId);
const routeThreadId = firstRouteParam(params.threadId);
const { selectedThread, selectedThreadProject } = useThreadSelection();
const { selectedThreadCwd } = useSelectedThreadWorktree();
const environmentId =
routeEnvironmentId !== null
? EnvironmentId.make(routeEnvironmentId)
: (selectedThread?.environmentId ?? null);
const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null;
const project = selectedThreadProject as {
readonly title?: string;
readonly workspaceRoot?: string;
} | null;
return {
cwd: selectedThreadCwd ?? project?.workspaceRoot ?? null,
environmentId,
projectName: project?.title ?? "Files",
selectedThread,
threadId,
};
}
function FilesUnavailable() {
return (
);
}
function FilesHeaderTitle(props: { readonly projectName: string }) {
const foregroundColor = String(useThemeColor("--color-foreground"));
const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary"));
return (
Files
{props.projectName}
);
}
function FilesToolbarBottomFade() {
const sheetColor = String(useThemeColor("--color-sheet"));
if (process.env.EXPO_OS !== "ios") {
return null;
}
return (
);
}
export function ThreadFilesTreeScreen() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();
const entriesQuery = useEnvironmentQuery(
environmentId !== null && cwd !== null
? projectEnvironment.listEntries({
environmentId,
input: { cwd },
})
: null,
);
const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
const handleSelectFile = useCallback(
(path: string) => {
if (environmentId === null || threadId === null) {
return;
}
router.push(buildThreadFilesNavigation({ environmentId, threadId }, path));
},
[environmentId, router, threadId],
);
if (selectedThread === null || environmentId === null || threadId === null) {
return ;
}
if (cwd === null) {
return ;
}
return (
,
headerSearchBarOptions: {
allowToolbarIntegration: true,
autoCapitalize: "none",
hideNavigationBar: false,
placeholder: "Search files",
onChangeText: (event) => {
setSearchQuery(event.nativeEvent.text);
},
onCancelButtonPress: () => {
setSearchQuery("");
},
},
}}
/>
);
}
export function ThreadFileScreen() {
const params = useLocalSearchParams<{
line?: string | string[];
path?: string | string[];
}>();
const relativePath = normalizeRoutePath(params.path);
const targetLine = normalizeRouteLine(firstRouteParam(params.line));
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();
const [modeOverride, setModeOverride] = useState<{
readonly path: string;
readonly mode: FileViewMode;
} | null>(null);
const [previewRevision, setPreviewRevision] = useState(0);
const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
const canPreview =
relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile);
const activeMode =
relativePath !== null && modeOverride?.path === relativePath
? modeOverride.mode
: defaultViewMode(relativePath);
const resolvedActiveMode = canPreview ? activeMode : "source";
const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null;
const assetPreviewUri = useWorkspaceFileAssetUrl({
cwd,
environmentId,
relativePath: assetPreviewPath,
threadId,
});
const previewUri =
assetPreviewUri === null || previewRevision === 0
? assetPreviewUri
: `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
const needsFileContents =
relativePath !== null &&
(resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
const fileQuery = useEnvironmentQuery(
environmentId !== null && cwd !== null && relativePath !== null && needsFileContents
? projectEnvironment.readFile({
environmentId,
input: { cwd, relativePath },
})
: null,
);
const fileData = fileQuery.data as ProjectReadFileResult | null;
if (selectedThread === null || environmentId === null || threadId === null) {
return ;
}
if (cwd === null) {
return ;
}
if (relativePath === null) {
return (
);
}
return (
{
if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) {
setPreviewRevision((current) => current + 1);
return;
}
fileQuery.refresh();
}}
/>
{
setModeOverride({ path: relativePath, mode });
}}
/>
);
}