Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 83 additions & 18 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
import {
basename,
fileHeaderSubtitle,
fileRoutePathSegments,
resolveWorkspaceFilePath,
resolveWorkspaceRelativeFilePath,
isAudioPreviewFile,
isMarkdownPreviewFile,
isSvgImagePreviewFile,
Expand Down Expand Up @@ -587,6 +590,32 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
props.route.params,
);
const fileAccessSession = useEnvironmentQuery(
environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null,
);
const fileEnvironment = useEnvironmentPresentation(environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const targetPath =
cwd !== null && relativePath !== null ? resolveWorkspaceFilePath(cwd, relativePath) : null;
const metadata = useEnvironmentQuery(
canReadFiles && environmentId !== null && targetPath !== null
? projectEnvironment.fileMetadata({ environmentId, input: { path: targetPath } })
: null,
);
const metadataPending = !metadata.isSuccess && metadata.error === null;
const directoryPath = metadata.data?.kind === "directory" ? targetPath : null;
const [searchQuery, setSearchQuery] = useState("");
const entriesQuery = useFileTreeEntries({
Comment thread
maria-rcks marked this conversation as resolved.
environmentId,
cwd: canReadFiles ? directoryPath : null,
searchQuery,
});
const [modeOverride, setModeOverride] = useState<{
readonly path: string;
readonly mode: FileViewMode;
Expand Down Expand Up @@ -614,7 +643,11 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
const resolvedActiveMode =
isVideoFile || isAudioFile ? "preview" : canPreview ? activeMode : "source";
const assetPreviewPath =
isBrowserFile || isImageFile || isVideoFile || isAudioFile ? relativePath : null;
!metadataPending &&
directoryPath === null &&
(isBrowserFile || isImageFile || isVideoFile || isAudioFile)
? relativePath
: null;
const assetPreview = useWorkspaceFileAssetUrlState({
cwd,
environmentId,
Expand Down Expand Up @@ -665,21 +698,12 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
void assetPreview.refresh().finally(() => setPreviewRevision((current) => current + 1));
};
const needsFileContents =
!metadataPending &&
directoryPath === null &&
relativePath !== null &&
!isVideoFile &&
!isAudioFile &&
(resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
const fileAccessSession = useEnvironmentQuery(
environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null,
);
const fileEnvironment = useEnvironmentPresentation(environmentId);
const fileAccess = resolveFilesystemReadAccess({
isCatalogReady: fileEnvironment.isReady,
connection: fileEnvironment.presentation?.connection ?? null,
session: fileAccessSession.data,
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const fileQuery = useEnvironmentQuery(
canReadFiles &&
environmentId !== null &&
Expand All @@ -695,7 +719,11 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
const fileData = fileQuery.data as ProjectReadFileResult | null;

const handleSelectFile = (path: string) => {
const segments = path.split("/").filter(Boolean);
const targetPath =
directoryPath === null ? path : resolveWorkspaceFilePath(directoryPath, path);
const segments = fileRoutePathSegments(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
resolveWorkspaceRelativeFilePath(cwd, targetPath) ?? targetPath,
);
// A draft has no thread. `ThreadFile` would stringify null and then wait forever for a
// thread to resolve, so a draft stays on its own route and carries its workspace along.
if (threadId === null) {
Expand All @@ -709,20 +737,22 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
);
return;
}
navigation.navigate("ThreadFile", {
const destination = {
environmentId: String(environmentId),
threadId: String(threadId),
path: segments,
});
};
if (directoryPath !== null) navigation.dispatch(StackActions.push("ThreadFile", destination));
else navigation.navigate("ThreadFile", destination);
};
const renderInspector = (headerInset: number) =>
fileInspector.supported && environmentId !== null && cwd !== null ? (
<ThreadFileNavigatorPane
cwd={cwd}
cwd={directoryPath ?? cwd}
environmentId={environmentId}
headerInset={headerInset}
projectName={projectName}
selectedPath={relativePath}
projectName={directoryPath ?? projectName}
selectedPath={directoryPath === null ? relativePath : null}
onSelectFile={handleSelectFile}
/>
) : undefined;
Expand Down Expand Up @@ -885,6 +915,41 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
);
}

if (metadataPending) {
return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
}
if (directoryPath !== null) {
return (
<View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-sheet"}>
<FilesBrowserHeader
projectName={directoryPath}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onRefresh={entriesQuery.refresh}
onBack={handleBack}
/>
<ScreenContent>
<FileTreeBrowser
key={JSON.stringify([environmentId, directoryPath])}
entries={entriesQuery.entries}
loadingDirectories={entriesQuery.loadingDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
error={entriesQuery.error}
isPending={entriesQuery.isPending}
isRefreshing={entriesQuery.isRefreshing}
searchQuery={searchQuery}
searchResultPaths={entriesQuery.searchResultPaths}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={null}
onRefresh={entriesQuery.refresh}
onSelectFile={handleSelectFile}
/>
<FilesToolbarBottomFade />
</ScreenContent>
</View>
);
}

const headerSubtitle = fileHeaderSubtitle(projectName, relativePath);

return (
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/features/files/filePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
fileRoutePathSegments,
resolveFileLinkPath,
isSvgImagePreviewFile,
resolveWorkspaceRelativeFilePath,
fileHeaderSubtitle,
Expand All @@ -16,6 +17,23 @@ describe("fileRoutePathSegments", () => {
});
});

describe("resolveFileLinkPath", () => {
it.each([
["/home/maria/project", "~/.agents/AGENTS.md", "/home/maria/.agents/AGENTS.md"],
["/Users/maria/project", "~/notes.md", "/Users/maria/notes.md"],
["C:\\Users\\maria\\project", "~\\.agents\\AGENTS.md", "C:\\Users\\maria\\.agents\\AGENTS.md"],
])("expands a home link from %s", (cwd, path, expected) => {
expect(resolveFileLinkPath(cwd, path)).toBe(expected);
expect(fileRoutePathSegments(expected).join("/")).toBe(expected);
});

it("does not turn an unresolved home into a workspace path", () => {
expect(resolveFileLinkPath("/repo", "~/notes.md")).toBeNull();
expect(resolveFileLinkPath(null, "~/notes.md")).toBeNull();
expect(resolveFileLinkPath("/repo", "../notes.md")).toBeNull();
});
});

describe("resolveWorkspaceRelativeFilePath", () => {
it("keeps normalized workspace-relative paths", () => {
expect(resolveWorkspaceRelativeFilePath("/repo", "./src/../src/main.ts")).toBe("src/main.ts");
Expand All @@ -30,6 +48,15 @@ describe("resolveWorkspaceRelativeFilePath", () => {
);
});

it("recognizes home links inside the workspace", () => {
expect(resolveWorkspaceRelativeFilePath("/home/maria/project", "~/project/src/main.ts")).toBe(
"src/main.ts",
);
expect(
resolveWorkspaceRelativeFilePath("/home/maria/project", "~/.agents/AGENTS.md"),
).toBeNull();
});

it("rejects paths outside the workspace", () => {
expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull();
expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull();
Expand Down
23 changes: 17 additions & 6 deletions apps/mobile/src/features/files/filePath.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
import {
isWorkspaceAudioPreviewPath,
isWorkspaceVideoPreviewPath,
Expand Down Expand Up @@ -66,16 +67,26 @@ function normalizeRelativePath(value: string): string | null {
return segments.length > 0 ? segments.join("/") : null;
}

export function resolveWorkspaceRelativeFilePath(
export function resolveFileLinkPath(
workspaceRoot: string | null | undefined,
targetPath: string,
): string | null {
if (!isAbsolutePath(targetPath)) {
if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) {
return null;
}
return normalizeRelativePath(targetPath);
if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) {
if (!workspaceRoot) return null;
const expanded = resolvePathLinkTarget(targetPath, workspaceRoot);
return isAbsolutePath(expanded) ? expanded : null;
}
return isAbsolutePath(targetPath) ? targetPath : normalizeRelativePath(targetPath);
}

export function resolveWorkspaceRelativeFilePath(
workspaceRoot: string | null | undefined,
targetPath: string,
): string | null {
const resolved = resolveFileLinkPath(workspaceRoot, targetPath);
if (resolved === null) return null;
targetPath = resolved;
if (!isAbsolutePath(targetPath)) return targetPath;
if (!workspaceRoot) {
return null;
}
Expand Down
Loading
Loading