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
@@ -0,0 +1,45 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { pickDirectory } from "@/desktop/pick-directory";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton({
serverId,
disabled,
onSelect,
onError,
}: ProjectPickerBrowseButtonProps) {
const { t } = useTranslation();
const isLocalDaemon = useIsLocalDaemon(serverId);
const handlePress = useCallback(() => {
void (async () => {
try {
const path = await pickDirectory();
if (path) {
onSelect(path);
}
} catch {
onError();
}
})();
}, [onError, onSelect]);
if (!isLocalDaemon) {
return null;
}
return (
<Button
variant="outline"
size="sm"
leftIcon={FolderOpen}
disabled={disabled}
onPress={handlePress}
>
{t("projectPicker.browse")}
</Button>
);
}
@@ -0,0 +1,5 @@
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
return null;
}
@@ -0,0 +1,6 @@
export interface ProjectPickerBrowseButtonProps {
serverId: string;
disabled: boolean;
onSelect: (path: string) => void;
onError: () => void;
}
@@ -21,6 +21,7 @@ import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { shortenPath } from "@/utils/shorten-path";
import { isNative } from "@/constants/platform";
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
interface PathRowProps {
@@ -155,31 +156,36 @@ export function ProjectPickerModal() {
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
queryFn: async () => {
if (!client) return [];
if (!client) {
return { query: debouncedQuery, paths: [] };
}
const result = await client.getDirectorySuggestions({
query: debouncedQuery,
includeDirectories: true,
includeFiles: false,
limit: 30,
});
return (
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
return {
query: debouncedQuery,
paths:
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
[],
};
},
enabled: Boolean(client) && isConnected && open,
staleTime: 15_000,
retry: false,
});
const options = useMemo(
() =>
buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const options = useMemo(() => {
const currentSuggestions =
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: currentSuggestions?.paths ?? [],
query,
});
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
const hasQuery = query.trim().length > 0;
const isSearching =
hasQuery &&
@@ -213,6 +219,8 @@ export function ProjectPickerModal() {
}
setOpenErrorReason(getOpenProjectFailureReason(result));
} catch {
setOpenErrorReason("open_failed");
} finally {
setIsSubmitting(false);
}
@@ -232,6 +240,10 @@ export function ProjectPickerModal() {
setOpenErrorReason(null);
}, []);
const handleBrowseError = useCallback(() => {
setOpenErrorReason("open_failed");
}, []);
useEffect(() => {
if (open) {
setQuery("");
@@ -262,7 +274,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -270,12 +282,6 @@ export function ProjectPickerModal() {
return;
}
if (key === "Enter") {
event.preventDefault();
submitActiveOption();
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (options.length === 0) return;
event.preventDefault();
@@ -291,7 +297,7 @@ export function ProjectPickerModal() {
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [close, open, options.length, submitActiveOption]);
}, [close, open, options.length]);
const panelStyle = useMemo(
() => [
@@ -343,6 +349,12 @@ export function ProjectPickerModal() {
returnKeyType="go"
onSubmitEditing={submitActiveOption}
/>
<ProjectPickerBrowseButton
serverId={serverId}
disabled={isSubmitting}
onSelect={handleSelectPath}
onError={handleBrowseError}
/>
</View>
<ProjectPickerResults
@@ -383,11 +395,15 @@ const styles = StyleSheet.create((theme) => ({
...theme.shadow.lg,
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
},
input: {
flex: 1,
fontSize: theme.fontSize.lg,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
it("puts an absolute path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/repo/api"],
serverPaths: [],
serverPaths: ["/repo/api"],
query: "/repo",
});
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
it("puts a home-relative path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/Users/mo/src/api"],
serverPaths: [],
serverPaths: ["/Users/mo/src/api"],
query: "~/src",
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "./host";
import { pickDirectory } from "./pick-directory";
describe("pickDirectory", () => {
it("opens a single-directory picker and returns the selection", async () => {
const recordedOptions: DesktopDialogOpenOptions[] = [];
const dialog: DesktopDialogBridge = {
open: async (options) => {
if (options) {
recordedOptions.push(options);
}
return "/repo/project";
},
};
await expect(pickDirectory(dialog)).resolves.toBe("/repo/project");
expect(recordedOptions).toEqual([
{
directory: true,
multiple: false,
},
]);
});
it("returns null when the picker is cancelled", async () => {
const dialog: DesktopDialogBridge = {
open: async () => null,
};
await expect(pickDirectory(dialog)).resolves.toBeNull();
});
});
@@ -0,0 +1,23 @@
import { getDesktopHost, type DesktopDialogBridge } from "./host";
export async function pickDirectory(
dialog: DesktopDialogBridge | null = getDesktopHost()?.dialog ?? null,
): Promise<string | null> {
const open = dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
});
if (selection === null) {
return null;
}
if (typeof selection === "string") {
return selection;
}
throw new Error("Unexpected directory picker response.");
}
+1
View File
@@ -1094,6 +1094,7 @@ export const ar: TranslationResources = {
},
projectPicker: {
placeholder: "اكتب مسار الدليل...",
browse: "استعراض…",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",
+1
View File
@@ -1101,6 +1101,7 @@ export const en = {
},
projectPicker: {
placeholder: "Type a directory path...",
browse: "Browse…",
opening: "Opening project...",
searching: "Searching...",
empty: "Start typing a path",
+1
View File
@@ -1130,6 +1130,7 @@ export const es: TranslationResources = {
},
projectPicker: {
placeholder: "Escriba una ruta de directorio...",
browse: "Explorar…",
opening: "Proyecto de apertura...",
searching: "Buscando...",
empty: "Comience a escribir una ruta",
+1
View File
@@ -1132,6 +1132,7 @@ export const fr: TranslationResources = {
},
projectPicker: {
placeholder: "Tapez un chemin de répertoire...",
browse: "Parcourir…",
opening: "Projet d'ouverture...",
searching: "Recherche en cours...",
empty: "Commencez à taper un chemin",
+1
View File
@@ -1108,6 +1108,7 @@ export const ja: TranslationResources = {
},
projectPicker: {
placeholder: "ディレクトリパスを入力...",
browse: "参照…",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",
+1
View File
@@ -1116,6 +1116,7 @@ export const ptBR: TranslationResources = {
},
projectPicker: {
placeholder: "Digite um caminho de diretório...",
browse: "Procurar…",
opening: "Abrindo projeto...",
searching: "Buscando...",
empty: "Comece digitando um caminho",
+1
View File
@@ -1120,6 +1120,7 @@ export const ru: TranslationResources = {
},
projectPicker: {
placeholder: "Введите путь к каталогу...",
browse: "Обзор…",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",
+1
View File
@@ -1079,6 +1079,7 @@ export const zhCN: TranslationResources = {
},
projectPicker: {
placeholder: "输入目录路径...",
browse: "浏览…",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",
@@ -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();
}