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
5 changes: 3 additions & 2 deletions apps/mobile/src/features/files/FileTreeBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function FileTreeBrowser(props: {
readonly isPending: boolean;
readonly isRefreshing: boolean;
readonly searchQuery: string;
readonly searchResultPaths: ReadonlySet<string> | null;
readonly searchTruncated: boolean;
readonly selectedPath: string | null;
readonly loadingDirectories: ReadonlySet<string>;
Expand Down Expand Up @@ -146,9 +147,9 @@ export function FileTreeBrowser(props: {
flattenFileTree({
nodes: tree,
expanded: expandedPaths,
searchQuery: props.searchQuery,
searchResultPaths: props.searchResultPaths,
}),
[expandedPaths, props.searchQuery, tree],
[expandedPaths, props.searchResultPaths, tree],
);

useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
isPending={entriesQuery.isPending}
isRefreshing={entriesQuery.isRefreshing}
searchQuery={searchQuery}
searchResultPaths={entriesQuery.searchResultPaths}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={null}
onPreviewFile={handlePreviewFile}
Expand Down
55 changes: 20 additions & 35 deletions apps/mobile/src/features/files/fileTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,46 +51,31 @@ describe("mobile file tree helpers", () => {
flattenFileTree({
nodes: tree,
expanded: new Set(),
searchQuery: "app",
searchResultPaths: new Set(["src/components/App.tsx"]),
}).map((item) => item.node.path),
).toEqual(["src", "src/components", "src/components/App.tsx"]);
});

it("supports fuzzy, whitespace-separated path queries", () => {
const tree = buildFileTree([
{
kind: "file",
path: "docs/internals/workspace-layout.md",
},
{
kind: "file",
path: ".repos/alchemy-effect/examples/aws-lambda/src/JobNotifications.ts",
},
{ kind: "directory", path: "apps/web/src/components/chat" },
{ kind: "file", path: "apps/web/src/components/chat/ChatHeader.test.ts" },
{ kind: "file", path: "apps/web/src/components/chat/ChatHeader.tsx" },
{ kind: "file", path: "apps/web/src/components/chat/Composer.tsx" },
]);

const expectedPaths = [
"apps",
"apps/web",
"apps/web/src",
"apps/web/src/components",
"apps/web/src/components/chat",
"apps/web/src/components/chat/ChatHeader.test.ts",
"apps/web/src/components/chat/ChatHeader.tsx",
];
it("excludes cached children of matched directories", () => {
expect(
flattenFileTree({
nodes: buildFileTree(entries),
expanded: new Set(["src", "src/components"]),
searchResultPaths: new Set(["src/components"]),
}).map((item) => item.node.path),
).toEqual(["src", "src/components"]);
});

for (const searchQuery of ["chat hea", "cht hdr"]) {
expect(
flattenFileTree({
nodes: tree,
expanded: new Set(),
searchQuery,
}).map((item) => item.node.path),
).toEqual(expectedPaths);
}
it("keeps an empty search empty and restores expansion when search clears", () => {
const tree = buildFileTree(entries);
expect(
flattenFileTree({ nodes: tree, expanded: new Set(["src"]), searchResultPaths: new Set() }),
).toEqual([]);
expect(
flattenFileTree({ nodes: tree, expanded: new Set(), searchResultPaths: null }).map(
(item) => item.node.path,
),
).toEqual(["src", "package.json", "README.md"]);
});

it("expands top-level directories by default", () => {
Expand Down
71 changes: 7 additions & 64 deletions apps/mobile/src/features/files/fileTree.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { ProjectEntry } from "@t3tools/contracts";
import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";

export interface FileTreeNode {
readonly path: string;
readonly name: string;
readonly kind: ProjectEntry["kind"];
readonly ignored?: boolean;
readonly children: ReadonlyArray<FileTreeNode>;
readonly searchSegments: ReadonlyArray<string>;
readonly searchWords: ReadonlyArray<string>;
}

export interface VisibleFileTreeNode {
Expand Down Expand Up @@ -37,43 +34,13 @@ function createMutableNode(
};
}

function splitSearchWords(value: string): ReadonlyArray<string> {
return value
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((word) => word.toLowerCase());
}

function buildNodeSearchTerms(path: string): {
readonly segments: ReadonlyArray<string>;
readonly words: ReadonlyArray<string>;
} {
const segments: string[] = [];
const words: string[] = [];

for (const segment of path.split("/")) {
if (!segment) {
continue;
}
segments.push(segment.toLowerCase());
words.push(...splitSearchWords(segment));
}

return { segments, words };
}

function freezeNode(node: MutableFileTreeNode): FileTreeNode {
const searchTerms = buildNodeSearchTerms(node.path);
return {
path: node.path,
name: node.name,
kind: node.kind,
...(node.ignored ? { ignored: true } : {}),
children: [...node.children.values()].sort(compareNodes).map(freezeNode),
searchSegments: searchTerms.segments,
searchWords: searchTerms.words,
};
}

Expand Down Expand Up @@ -133,44 +100,21 @@ export function defaultExpandedTreePaths(nodes: ReadonlyArray<FileTreeNode>): Re
return expanded;
}

function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean {
return (
scoreQueryMatch({
value,
query: token,
exactBase: 0,
prefixBase: 2,
boundaryBase: 4,
includesBase: 6,
...(fuzzy ? { fuzzyBase: 100 } : {}),
boundaryMarkers: ["/", "-", "_", "."],
}) !== null
);
}

function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray<string>): boolean {
return tokens.every(
(token) =>
node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) ||
node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)),
);
}

function flattenNode(
output: VisibleFileTreeNode[],
node: FileTreeNode,
depth: number,
expanded: ReadonlySet<string>,
searchTokens: ReadonlyArray<string>,
searchResultPaths: ReadonlySet<string> | null,
): boolean {
const isSearching = searchTokens.length > 0;
const matches = isSearching && nodeMatchesSearch(node, searchTokens);
const isSearching = searchResultPaths !== null;
const matches = searchResultPaths?.has(node.path) ?? false;
let descendantMatches = false;
const childOutput: VisibleFileTreeNode[] = [];

if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) {
for (const child of node.children) {
if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) {
if (flattenNode(childOutput, child, depth + 1, expanded, searchResultPaths)) {
descendantMatches = true;
}
}
Expand All @@ -189,13 +133,12 @@ function flattenNode(
export function flattenFileTree(input: {
readonly nodes: ReadonlyArray<FileTreeNode>;
readonly expanded: ReadonlySet<string>;
readonly searchQuery?: string;
// Null restores normal directory expansion; an empty set is a search with no results.
readonly searchResultPaths?: ReadonlySet<string> | null;
}): ReadonlyArray<VisibleFileTreeNode> {
const output: VisibleFileTreeNode[] = [];
const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? "");
const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean);
for (const node of input.nodes) {
flattenNode(output, node, 0, input.expanded, searchTokens);
flattenNode(output, node, 0, input.expanded, input.searchResultPaths ?? null);
}
return output;
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function ThreadFileNavigatorPane(props: {
isPending={fileAccess.isPending || entriesQuery.isPending}
isRefreshing={entriesQuery.isRefreshing}
searchQuery={searchQuery}
searchResultPaths={entriesQuery.searchResultPaths}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={props.selectedPath}
onPreviewFile={handlePreviewFile}
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/features/files/useFileTreeEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,14 @@ export function useFileTreeEntries(input: {
}
};
visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/")));
return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories };
return {
revision: entriesRevision,
entries: [...merged.values()],
reachableDirectories,
searchResultPaths: searching
? new Set((searchData?.entries ?? []).map((entry) => entry.path))
: null,
};
}, [directories, entriesRevision, rootData, searchData, searching]);

const refresh = useCallback(() => {
Expand Down Expand Up @@ -178,6 +185,7 @@ export function useFileTreeEntries(input: {

return {
entries: snapshot.entries,
searchResultPaths: snapshot.searchResultPaths,
isRefreshing: refreshingDirectories === directories,
error:
root.error ??
Expand Down
Loading