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",
});