Restore fuzzy project folder search and desktop browsing (#1968)

* fix(projects): restore folder search and browsing

* fix(projects): preserve fuzzy search edge cases

* test(projects): move picker cleanup into fixture

* fix(projects): preserve absolute path descendants

* test(server): compare canonical search paths

* fix(projects): anchor root path suggestions

* fix(projects): anchor Windows root suggestions

* fix(projects): anchor directory search paths

* test(server): canonicalize directory search root

* fix(app): anchor rooted basename suggestions

* refactor(search): unify directory suggestions

Use one parameterized daemon search engine for project paths and workspace entries. Keep local recommendations as a small client overlay and remove unnecessary response metadata.

* fix(search): preserve filesystem root semantics

Accept absolute queries through configured root aliases while keeping traversal and output canonical. Keep blank suffix browsing matcher-independent and avoid metadata-keyed listing caches where Windows cannot invalidate them reliably.
This commit is contained in:
Mohamed Boudra
2026-07-10 17:52:55 +08:00
committed by GitHub
parent 1f0285c06b
commit 61a9117a6b
30 changed files with 1552 additions and 1465 deletions
@@ -12,31 +12,40 @@ describe("buildWorkingDirectorySuggestions", () => {
expect(results).toEqual(["/Users/me/projects/paseo"]);
});
it("prioritizes matching recommended directories before server matches", () => {
it("keeps fuzzy recommendation matches before de-duplicated daemon suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
serverPaths: [
"/Users/me/projects/playground",
"/Users/me/projects/paseo",
"/Users/me/projects/planbook",
recommendedPaths: ["/Users/me/projects/paseo-desktop", "/Users/me/documents"],
serverPaths: ["/Users/me/projects/paseo-plan", "/Users/me/projects/paseo-desktop"],
query: "pso",
});
expect(results).toEqual(["/Users/me/projects/paseo-desktop", "/Users/me/projects/paseo-plan"]);
});
it("does not reinterpret daemon-ranked suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: [],
serverPaths: ["/Users/me/projects/paseo-desktop"],
query: "a-query-ranked-by-the-daemon",
});
expect(results).toEqual(["/Users/me/projects/paseo-desktop"]);
});
it("leaves path-query semantics to the daemon", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: [
"/Users/me/archive/projects/paseo-desktop",
"/Users/me/projects/paseo-desktop",
],
query: "pla",
serverPaths: [],
query: "~/projects/pso",
});
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
expect(results).toEqual([]);
});
it("puts matching recommended items first when they also match query", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
serverPaths: ["/Users/me/projects/planbook", "/Users/me/projects/playground"],
query: "pla",
});
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
});
it("treats '~' as an active query and includes server suggestions", () => {
it("treats '~' as an active query and includes daemon suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo"],
serverPaths: ["/Users/me/documents", "/Users/me/projects"],
@@ -1,3 +1,5 @@
import { scoreMatch } from "./score-match";
export interface BuildWorkingDirectorySuggestionsInput {
recommendedPaths: string[];
serverPaths: string[];
@@ -7,40 +9,27 @@ export interface BuildWorkingDirectorySuggestionsInput {
export function buildWorkingDirectorySuggestions(
input: BuildWorkingDirectorySuggestionsInput,
): string[] {
const rawQuery = input.query.trim();
const query = input.query.trim();
const recommended = uniquePaths(input.recommendedPaths);
if (!rawQuery) {
if (!query) {
return recommended;
}
const normalizedQuery = normalizeQuery(rawQuery);
const shouldFilterByQuery = normalizedQuery.length > 0;
const matchingRecommended = recommended.filter((path) =>
recommendedPathMatchesQuery(path, query),
);
const recommendedMatches = shouldFilterByQuery
? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery))
: recommended;
const seen = new Set(recommendedMatches);
const ordered = [...recommendedMatches];
for (const entry of uniquePaths(input.serverPaths)) {
if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) {
continue;
}
if (seen.has(entry)) {
continue;
}
ordered.push(entry);
seen.add(entry);
}
return ordered;
// The request owner correlates these results with the current query. The
// daemon owns filesystem query parsing, filtering, and ranking; doing it
// again here creates a second search implementation that can disagree.
return uniquePaths([...matchingRecommended, ...input.serverPaths]);
}
function uniquePaths(paths: string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const pathEntry of paths) {
const trimmed = pathEntry.trim();
for (const path of paths) {
const trimmed = path.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
@@ -50,23 +39,20 @@ function uniquePaths(paths: string[]): string[] {
return ordered;
}
function normalizeQuery(query: string): string {
let normalized = query.trim();
if (!normalized) {
return "";
}
if (normalized.startsWith("~")) {
normalized = normalized.slice(1);
}
normalized = normalized.replace(/^\/+/, "").toLowerCase();
return normalized;
}
function pathMatchesQuery(candidatePath: string, query: string): boolean {
const lowerPath = candidatePath.toLowerCase();
if (lowerPath.includes(query)) {
function recommendedPathMatchesQuery(path: string, query: string): boolean {
const candidate = normalizePath(path);
const normalizedQuery = normalizePath(query);
if (["~", "~/"].includes(normalizedQuery)) {
return true;
}
const segments = lowerPath.split("/");
return (segments[segments.length - 1] ?? "").includes(query);
if (normalizedQuery.includes("/") || normalizedQuery.startsWith("~")) {
return false;
}
const basename = candidate.split("/").at(-1) ?? "";
return candidate.includes(normalizedQuery) || scoreMatch(normalizedQuery, basename) !== null;
}
function normalizePath(value: string): string {
return value.trim().replace(/\\/g, "/").toLowerCase();
}