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
39 changes: 30 additions & 9 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
mediaMimeTypeFromExtension,
} from "@t3tools/shared/filePreview";
import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
import { projectFileRootsKey } from "@t3tools/client-runtime/project-file-roots";

import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { AudioFilePreview } from "../../components/AudioFilePreview";
Expand Down Expand Up @@ -41,6 +42,7 @@ import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
import { FileMarkdownPreview } from "./FileMarkdownPreview";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { useFileTreeEntries } from "./useFileTreeEntries";
import { useSelectedThreadFileRoots } from "./useProjectFileRoots";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
import { SourceFileSurface } from "./SourceFileSurface";
import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
Expand All @@ -52,6 +54,7 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
import {
basename,
fileHeaderSubtitle,
fileRoutePathSegments,
isAudioPreviewFile,
isMarkdownPreviewFile,
isSvgImagePreviewFile,
Expand Down Expand Up @@ -420,12 +423,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
props.route.params,
);
const fileRoots = useSelectedThreadFileRoots();
const revealedInspectorRef = useRef(false);
const entriesQuery = useFileTreeEntries({
environmentId,
cwd: fileInspector.supported ? null : cwd,
searchQuery,
fileRoots,
});
const { filePath } = entriesQuery;
const handleReturnToThread = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack();
Expand All @@ -449,7 +455,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
const params = {
environmentId: String(environmentId),
threadId: String(threadId),
path: path.split("/").filter((segment) => segment.length > 0),
path: fileRoutePathSegments(path),
};
const navigationAction = resolveFileSelectionNavigationAction({
hasPersistentFileInspector: fileInspector.supported,
Expand All @@ -468,27 +474,32 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
<ThreadFileNavigatorPane
cwd={cwd}
environmentId={environmentId}
fileRoots={fileRoots}
headerInset={headerInset}
projectName={projectName}
selectedPath={null}
onSelectFile={handleSelectFile}
/>
) : null,
[cwd, environmentId, handleSelectFile, projectName],
[cwd, environmentId, fileRoots, handleSelectFile, projectName],
);
const handlePreviewFile = useCallback(
(relativePath: string) => {
(path: string) => {
if (environmentId === null || cwd === null) {
return;
}
preloadWorkspaceFileContents({
cwd,
environmentId,
relativePath,
relativePath: filePath(path),
theme: highlightTheme,
});
},
[cwd, environmentId, highlightTheme],
[cwd, environmentId, filePath, highlightTheme],
);
const handleSelectTreeFile = useCallback(
(path: string) => handleSelectFile(filePath(path)),
[filePath, handleSelectFile],
);
useEffect(() => {
if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {
Expand Down Expand Up @@ -535,7 +546,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
/>
<MaterialScreenContent insetHorizontal={layout.usesSplitView}>
<FileTreeBrowser
key={JSON.stringify([environmentId, cwd])}
key={JSON.stringify([environmentId, cwd, projectFileRootsKey(fileRoots.roots)])}
entries={entriesQuery.entries}
loadedDirectories={entriesQuery.loadedDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
Expand All @@ -546,7 +557,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
selectedPath={null}
onPreviewFile={handlePreviewFile}
onRefresh={entriesQuery.refresh}
onSelectFile={handleSelectFile}
onSelectFile={handleSelectTreeFile}
/>
<FilesToolbarBottomFade />
</MaterialScreenContent>
Expand Down Expand Up @@ -667,9 +678,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
);
const fileData = fileQuery.data as ProjectReadFileResult | null;

const fileRoots = useSelectedThreadFileRoots();
const handleSelectFile = useCallback(
(path: string) => {
const segments = path.split("/").filter(Boolean);
const segments = fileRoutePathSegments(path);
// 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 Down Expand Up @@ -697,13 +709,22 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
<ThreadFileNavigatorPane
cwd={cwd}
environmentId={environmentId}
fileRoots={fileRoots}
headerInset={headerInset}
projectName={projectName}
selectedPath={relativePath}
onSelectFile={handleSelectFile}
/>
) : undefined,
[cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath],
[
cwd,
environmentId,
fileInspector.supported,
fileRoots,
handleSelectFile,
projectName,
relativePath,
],
);
// The workspace inspector column spans the full window height. On iOS the
// pane brings its own nested native header; elsewhere it pads itself below
Expand Down
44 changes: 43 additions & 1 deletion apps/mobile/src/features/files/fileTree.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vite-plus/test";
import type { ProjectEntry } from "@t3tools/contracts";

import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree";
import {
buildFileTree,
defaultExpandedTreePaths,
flattenFileTree,
resolveRootedTreePath,
rootedTreePath,
} from "./fileTree";

const entries = [
{ kind: "file", path: "README.md" },
Expand Down Expand Up @@ -99,3 +105,39 @@ describe("mobile file tree helpers", () => {
expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]);
});
});

describe("multi-root tree paths", () => {
const roots = [
{ root: "/wt/api-5c882f59", label: "api" },
{ root: "/ws/a/docs", label: "a/docs" },
{ root: "/ws/b/docs/", label: "b/docs" },
{ root: "/ws/a/docs/drafts", label: "drafts" },
];

it("resolves a tree path to its root and the path inside it", () => {
expect(resolveRootedTreePath(roots, "api/src/main.ts")).toMatchObject({
root: "/wt/api-5c882f59",
relativePath: "src/main.ts",
});
expect(resolveRootedTreePath(roots, "a/docs")).toMatchObject({
root: "/ws/a/docs",
relativePath: "",
});
expect(resolveRootedTreePath(roots, "b/docs/plan.md")).toMatchObject({
root: "/ws/b/docs/",
relativePath: "plan.md",
});
});

it("resolves nothing for a path under no root", () => {
expect(resolveRootedTreePath(roots, "a")).toBeNull();
expect(resolveRootedTreePath(roots, "apis/main.ts")).toBeNull();
});

it("maps an absolute path back under the innermost root that holds it", () => {
expect(rootedTreePath(roots, "/wt/api-5c882f59/src/main.ts")).toBe("api/src/main.ts");
expect(rootedTreePath(roots, "/ws/b/docs/plan.md")).toBe("b/docs/plan.md");
expect(rootedTreePath(roots, "/ws/a/docs/drafts/todo.md")).toBe("drafts/todo.md");
expect(rootedTreePath(roots, "/ws/api/src/main.ts")).toBeNull();
});
});
40 changes: 40 additions & 0 deletions apps/mobile/src/features/files/fileTree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ProjectFileRoot } from "@t3tools/client-runtime/project-file-roots";
import type { ProjectEntry } from "@t3tools/contracts";
import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";

Expand Down Expand Up @@ -121,6 +122,45 @@ export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArr
return [...root.children.values()].sort(compareNodes).map(freezeNode);
}

const trimTrailingSeparators = (path: string) => path.replace(/[\\/]+$/, "");

/**
* A multi-root tree nests each root's files under its label. Returns the root
* a tree path sits in and the path inside that root, empty for the root's own
* node. Null for a path under no root, such as an ancestor of a nested label.
*/
export function resolveRootedTreePath(
roots: ReadonlyArray<ProjectFileRoot>,
treePath: string,
): (ProjectFileRoot & { readonly relativePath: string }) | null {
// The longest label wins, so a label containing "/" beats a shorter one it extends.
let owner: ProjectFileRoot | null = null;
for (const candidate of roots) {
const { label } = candidate;
if (treePath !== label && !treePath.startsWith(`${label}/`)) continue;
if (owner === null || label.length > owner.label.length) owner = candidate;
}
return owner ? { ...owner, relativePath: treePath.slice(owner.label.length + 1) } : null;
}

/**
* Tree path a multi-root tree lists an absolute file path under, or null when
* no root holds it. A root nested in another claims its own files.
*/
export function rootedTreePath(
roots: ReadonlyArray<ProjectFileRoot>,
absolutePath: string,
): string | null {
const normalized = absolutePath.replaceAll("\\", "/");
let match: { readonly prefix: string; readonly label: string } | null = null;
for (const { root, label } of roots) {
const prefix = `${trimTrailingSeparators(root).replaceAll("\\", "/")}/`;
if (!normalized.startsWith(prefix)) continue;
if (match === null || prefix.length > match.prefix.length) match = { prefix, label };
}
return match ? `${match.label}/${normalized.slice(match.prefix.length)}` : null;
}

export function defaultExpandedTreePaths(nodes: ReadonlyArray<FileTreeNode>): ReadonlySet<string> {
const expanded = new Set<string>();
for (const node of nodes) {
Expand Down
26 changes: 20 additions & 6 deletions apps/mobile/src/features/files/thread-file-navigator-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { projectFileRootsKey } from "@t3tools/client-runtime/project-file-roots";
import type { EnvironmentId } from "@t3tools/contracts";
import { SymbolView } from "../../components/AppSymbol";
import { MaterialScreenContent } from "../../components/MaterialScreenContent";
Expand All @@ -18,12 +19,14 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { useFileTreeEntries } from "./useFileTreeEntries";
import type { ProjectFileRootsState } from "./useProjectFileRoots";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";

export function ThreadFileNavigatorPane(props: {
readonly cwd: string;
readonly environmentId: EnvironmentId;
readonly fileRoots?: ProjectFileRootsState | undefined;
readonly headerInset: number;
readonly projectName: string;
readonly selectedPath: string | null;
Expand All @@ -40,17 +43,24 @@ export function ThreadFileNavigatorPane(props: {
environmentId: props.environmentId,
cwd: props.cwd,
searchQuery,
fileRoots: props.fileRoots,
});
const { filePath } = entriesQuery;
const { onSelectFile } = props;
const handlePreviewFile = useCallback(
(relativePath: string) => {
(path: string) => {
preloadWorkspaceFileContents({
cwd: props.cwd,
environmentId: props.environmentId,
relativePath,
relativePath: filePath(path),
theme: highlightTheme,
});
},
[highlightTheme, props.cwd, props.environmentId],
[filePath, highlightTheme, props.cwd, props.environmentId],
);
const handleSelectFile = useCallback(
(path: string) => onSelectFile(filePath(path)),
[filePath, onSelectFile],
);
const nativeHeaderRightBarButtonItems = useMemo(
() =>
Expand All @@ -71,18 +81,22 @@ export function ThreadFileNavigatorPane(props: {

const fileTree = (
<FileTreeBrowser
key={JSON.stringify([props.environmentId, props.cwd])}
key={JSON.stringify([
props.environmentId,
props.cwd,
projectFileRootsKey(props.fileRoots?.roots),
])}
entries={entriesQuery.entries}
loadedDirectories={entriesQuery.loadedDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
error={entriesQuery.error}
isPending={entriesQuery.isPending}
searchQuery={searchQuery}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={props.selectedPath}
selectedPath={entriesQuery.treePath(props.selectedPath)}
onPreviewFile={handlePreviewFile}
onRefresh={entriesQuery.refresh}
onSelectFile={props.onSelectFile}
onSelectFile={handleSelectFile}
/>
);

Expand Down
Loading
Loading