From 61a9117a6b75ee454ff83a376fd6be59a5e1c264 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Jul 2026 11:52:55 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 15 +- docs/architecture.md | 9 + .../app/e2e/empty-project-persists.spec.ts | 21 + packages/app/e2e/fixtures.ts | 40 +- packages/app/e2e/helpers/desktop-updates.ts | 15 + .../app/e2e/helpers/project-picker-fixture.ts | 48 + packages/app/e2e/helpers/project-picker-ui.ts | 13 + .../app/e2e/project-picker-desktop.spec.ts | 60 + packages/app/package.json | 1 + .../project-picker-browse-button.electron.tsx | 45 + .../project-picker-browse-button.tsx | 5 + .../project-picker-browse-button.types.ts | 6 + .../src/components/project-picker-modal.tsx | 58 +- .../components/project-picker-options.test.ts | 4 +- .../app/src/desktop/pick-directory.test.ts | 33 + packages/app/src/desktop/pick-directory.ts | 23 + packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../working-directory-suggestions.test.ts | 47 +- .../utils/working-directory-suggestions.ts | 68 +- .../src/server/daemon-client.e2e.test.ts | 39 +- packages/server/src/server/session.ts | 43 +- .../src/utils/directory-suggestions.test.ts | 866 +++++---- .../server/src/utils/directory-suggestions.ts | 1550 ++++++----------- 30 files changed, 1552 insertions(+), 1465 deletions(-) create mode 100644 packages/app/e2e/helpers/project-picker-fixture.ts create mode 100644 packages/app/e2e/helpers/project-picker-ui.ts create mode 100644 packages/app/e2e/project-picker-desktop.spec.ts create mode 100644 packages/app/src/components/project-picker-browse-button.electron.tsx create mode 100644 packages/app/src/components/project-picker-browse-button.tsx create mode 100644 packages/app/src/components/project-picker-browse-button.types.ts create mode 100644 packages/app/src/desktop/pick-directory.test.ts create mode 100644 packages/app/src/desktop/pick-directory.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a877c233..c9538d2d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,8 +239,13 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] - name: playwright (shard ${{ matrix.shard }}/4) + include: + - { label: "shard 1/4", shard: 1, desktop: false } + - { label: "shard 2/4", shard: 2, desktop: false } + - { label: "shard 3/4", shard: 3, desktop: false } + - { label: "shard 4/4", shard: 4, desktop: false } + - { label: "desktop overlay", shard: "desktop", desktop: true } + name: playwright (${{ matrix.label }}) runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -276,13 +281,19 @@ jobs: run: npm run build:server - name: Install agent CLIs for provider tests + if: ${{ !matrix.desktop }} run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run Playwright E2E tests + if: ${{ !matrix.desktop }} run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4 env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Run desktop-overlay Playwright tests + if: ${{ matrix.desktop }} + run: npm run test:e2e:desktop --workspace=@getpaseo/app + - name: Upload test artifacts uses: actions/upload-artifact@v4 if: failure() diff --git a/docs/architecture.md b/docs/architecture.md index 744220be4..331b0ecb7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,6 +185,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such - Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`) - Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error` +`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon +configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy, +entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A +request without `cwd` searches the host home for absolute project paths; a request with `cwd` +searches that workspace and returns relative entries. Clients may prepend their small host-scoped +recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a +correlated daemon response. The legacy `directories` response field remains a projection of the +typed `entries` list. + **Binary frames (terminal stream protocol):** Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is: diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 30102e197..485155b48 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { test, expect, type Page } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; +import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; import { createTempGitRepo } from "./helpers/workspace"; @@ -74,6 +75,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise { } test.describe("Project picker search", () => { + test("opens a project from a fuzzy directory-name search", async ({ + page, + projectPickerFixture, + }) => { + await gotoAppShell(page); + await waitForSidebarProjectListReady(page); + await page.getByTestId("sidebar-add-project").click(); + + const input = page.getByPlaceholder("Type a directory path..."); + await expect(input).toBeVisible({ timeout: 30_000 }); + await input.fill(projectPickerFixture.fuzzyQuery); + + const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first(); + await expect(suggestion).toBeVisible({ timeout: 30_000 }); + await suggestion.click(); + + const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + projectPickerFixture.rememberProjectId(projectId); + }); + test("shows a loading state after typing while directory suggestions are pending", async ({ page, }) => { diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index ceda33e0a..a0abe9129 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,16 +1,30 @@ import { test as base, expect, type Page } from "@playwright/test"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; +import { + createProjectPickerFixture, + removeProjectPickerFixture, + type ProjectPickerFixture, +} from "./helpers/project-picker-fixture"; +import { connectSeedClient } from "./helpers/seed-client"; import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace"; const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; +interface TrackedProjectPickerFixture extends ProjectPickerFixture { + rememberProjectId: (projectId: string) => void; +} + // Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`. // `test.beforeEach` declared at the top level of a non-test fixture file is unreliable // across spec-file boundaries — Playwright sometimes skips it for the first test of a // subsequent spec when multiple specs run in the same worker. Auto fixtures run // reliably for every test that uses this `test` object. -const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({ +const test = base.extend<{ + paseoE2ESetup: void; + projectPickerFixture: TrackedProjectPickerFixture; + withWorkspace: WithWorkspace; +}>({ baseURL: async ({}, provide) => { const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) { @@ -102,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>( }, { auto: true }, ], + projectPickerFixture: async ({}, provide) => { + const resource = await createProjectPickerFixture(); + const { fixture } = resource; + let projectId: string | null = null; + try { + await provide({ + ...fixture, + rememberProjectId: (openedProjectId) => { + projectId = openedProjectId; + }, + }); + } finally { + try { + const client = await connectSeedClient(); + try { + await removeProjectPickerFixture(client, fixture, projectId); + } finally { + await client.close(); + } + } finally { + await resource.removeDirectory(); + } + } + }, withWorkspace: async ({ page }, provide) => { const handle = createWithWorkspace(page); await provide(handle.withWorkspace); diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 8548b6ed7..f8d910955 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -71,6 +71,7 @@ export interface DesktopBridgeConfig { * false so tests that only assert copy don't inadvertently trigger state changes. */ confirmShouldAccept?: boolean; + dialogOpenResult?: string | string[] | null; editorTargets?: DesktopEditorTargetConfig[]; editorRecordPath?: string; } @@ -96,6 +97,7 @@ export interface ConfirmDialogCall { declare global { interface Window { __capturedDialogCall: ConfirmDialogCall | undefined; + __capturedDialogOpenCalls: Array | undefined>; __recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise; } } @@ -157,6 +159,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi invoke: (command: string, args?: Record) => Promise; dialog: { ask: (message: string, options?: Record) => Promise; + open: (options?: Record) => Promise; }; getPendingOpenProject: () => Promise; events: { on: () => Promise<() => void> }; @@ -249,6 +252,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi }; return cfg.confirmShouldAccept ?? false; }, + open: async (options?: Record) => { + window.__capturedDialogOpenCalls.push(options); + return cfg.dialogOpenResult ?? null; + }, }, getPendingOpenProject: async () => null, events: { on: async () => () => undefined }, @@ -263,10 +270,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi }; } + window.__capturedDialogOpenCalls = []; (window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge; }, config); } +export async function waitForDirectoryDialog( + page: Page, +): Promise | undefined> { + await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1); + return page.evaluate(() => window.__capturedDialogOpenCalls[0]); +} + export async function openDesktopSettings(page: Page, serverId: string): Promise { await openSettings(page); await openSettingsHost(page, serverId); diff --git a/packages/app/e2e/helpers/project-picker-fixture.ts b/packages/app/e2e/helpers/project-picker-fixture.ts new file mode 100644 index 000000000..c126f5960 --- /dev/null +++ b/packages/app/e2e/helpers/project-picker-fixture.ts @@ -0,0 +1,48 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { SeedDaemonClient } from "./seed-client"; + +export interface ProjectPickerFixture { + projectPath: string; + projectName: string; + fuzzyQuery: string; +} + +interface ProjectPickerFixtureResource { + fixture: ProjectPickerFixture; + removeDirectory: () => Promise; +} + +export async function createProjectPickerFixture(): Promise { + const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-")); + const nonce = randomUUID().replaceAll("-", "").slice(0, 8); + const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`); + await mkdir(projectPath, { recursive: true }); + + return { + fixture: { + projectPath, + projectName: path.basename(projectPath), + fuzzyQuery: `psodfzt${nonce}`, + }, + removeDirectory: () => rm(root, { recursive: true, force: true }), + }; +} + +export async function removeProjectPickerFixture( + client: SeedDaemonClient, + fixture: ProjectPickerFixture, + knownProjectId: string | null = null, +): Promise { + let projectId = knownProjectId; + if (!projectId) { + const lookup = await client.addProject(fixture.projectPath); + projectId = lookup.project?.projectId ?? null; + if (!projectId) { + throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup"); + } + } + await client.removeProject(projectId); +} diff --git a/packages/app/e2e/helpers/project-picker-ui.ts b/packages/app/e2e/helpers/project-picker-ui.ts new file mode 100644 index 000000000..019d482e8 --- /dev/null +++ b/packages/app/e2e/helpers/project-picker-ui.ts @@ -0,0 +1,13 @@ +import { expect, type Page } from "@playwright/test"; + +export async function expectOpenedProject(page: Page, projectName: string): Promise { + const projectRow = page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectName }) + .first(); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + + const testId = await projectRow.getAttribute("data-testid"); + expect(testId).not.toBeNull(); + return testId!.replace("sidebar-project-row-", ""); +} diff --git a/packages/app/e2e/project-picker-desktop.spec.ts b/packages/app/e2e/project-picker-desktop.spec.ts new file mode 100644 index 000000000..e3ef038fb --- /dev/null +++ b/packages/app/e2e/project-picker-desktop.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates"; +import { expectOpenedProject } from "./helpers/project-picker-ui"; +import { getServerId } from "./helpers/server-id"; + +test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay"); + +test("Browse opens the folder selected by the desktop dialog", async ({ + page, + projectPickerFixture, +}) => { + await injectDesktopBridge(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + dialogOpenResult: projectPickerFixture.projectPath, + }); + await gotoAppShell(page); + + await page.getByTestId("sidebar-add-project").click(); + const browse = page.getByRole("button", { name: "Browse…" }); + await expect(browse).toBeVisible({ timeout: 30_000 }); + await browse.click(); + + const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + projectPickerFixture.rememberProjectId(projectId); +}); + +test("Browse owns Enter without opening the active typed path", async ({ + page, + projectPickerFixture, +}) => { + await injectDesktopBridge(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + dialogOpenResult: null, + }); + await gotoAppShell(page); + + await page.getByTestId("sidebar-add-project").click(); + const input = page.getByPlaceholder("Type a directory path..."); + await expect(input).toBeVisible({ timeout: 30_000 }); + await input.fill(projectPickerFixture.projectPath); + + const browse = page.getByRole("button", { name: "Browse…" }); + await expect(browse).toBeVisible({ timeout: 30_000 }); + await browse.press("Enter"); + + const dialogOptions = await waitForDirectoryDialog(page); + expect(dialogOptions).toEqual({ + directory: true, + multiple: false, + }); + await expect(input).toBeVisible(); + await expect( + page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectPickerFixture.projectName }), + ).toHaveCount(0); +}); diff --git a/packages/app/package.json b/packages/app/package.json index 8da6ddf80..e58eb4d89 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -22,6 +22,7 @@ "test": "vitest run", "test:browser": "vitest run --project browser", "test:e2e": "playwright test --project='Desktop Chrome'", + "test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts", "test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider", "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", diff --git a/packages/app/src/components/project-picker-browse-button.electron.tsx b/packages/app/src/components/project-picker-browse-button.electron.tsx new file mode 100644 index 000000000..d2c4889e1 --- /dev/null +++ b/packages/app/src/components/project-picker-browse-button.electron.tsx @@ -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 ( + + ); +} diff --git a/packages/app/src/components/project-picker-browse-button.tsx b/packages/app/src/components/project-picker-browse-button.tsx new file mode 100644 index 000000000..25c2a6a9e --- /dev/null +++ b/packages/app/src/components/project-picker-browse-button.tsx @@ -0,0 +1,5 @@ +import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types"; + +export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) { + return null; +} diff --git a/packages/app/src/components/project-picker-browse-button.types.ts b/packages/app/src/components/project-picker-browse-button.types.ts new file mode 100644 index 000000000..909b81c4f --- /dev/null +++ b/packages/app/src/components/project-picker-browse-button.types.ts @@ -0,0 +1,6 @@ +export interface ProjectPickerBrowseButtonProps { + serverId: string; + disabled: boolean; + onSelect: (path: string) => void; + onError: () => void; +} diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index cfa32afe0..9eb6f93cd 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -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} /> + ({ ...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", diff --git a/packages/app/src/components/project-picker-options.test.ts b/packages/app/src/components/project-picker-options.test.ts index cfe0d4a42..177b4092d 100644 --- a/packages/app/src/components/project-picker-options.test.ts +++ b/packages/app/src/components/project-picker-options.test.ts @@ -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", }); diff --git a/packages/app/src/desktop/pick-directory.test.ts b/packages/app/src/desktop/pick-directory.test.ts new file mode 100644 index 000000000..e95582fc1 --- /dev/null +++ b/packages/app/src/desktop/pick-directory.test.ts @@ -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(); + }); +}); diff --git a/packages/app/src/desktop/pick-directory.ts b/packages/app/src/desktop/pick-directory.ts new file mode 100644 index 000000000..3dd46db9f --- /dev/null +++ b/packages/app/src/desktop/pick-directory.ts @@ -0,0 +1,23 @@ +import { getDesktopHost, type DesktopDialogBridge } from "./host"; + +export async function pickDirectory( + dialog: DesktopDialogBridge | null = getDesktopHost()?.dialog ?? null, +): Promise { + 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."); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 3f3deca5a..f5f86b7a5 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1094,6 +1094,7 @@ export const ar: TranslationResources = { }, projectPicker: { placeholder: "اكتب مسار الدليل...", + browse: "استعراض…", opening: "افتتاح المشروع...", searching: "جارٍ البحث...", empty: "ابدأ بكتابة المسار", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3db321bbd..502957dff 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d4345c4d1..462a9669b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a5f922ddc..bb847f62f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index fc219c447..c7543ddc1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1108,6 +1108,7 @@ export const ja: TranslationResources = { }, projectPicker: { placeholder: "ディレクトリパスを入力...", + browse: "参照…", opening: "プロジェクトを開いています...", searching: "検索中...", empty: "パスを入力してください", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 83a733fbf..40f09e4bd 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index dd79f92a5..5d1ec33a8 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1120,6 +1120,7 @@ export const ru: TranslationResources = { }, projectPicker: { placeholder: "Введите путь к каталогу...", + browse: "Обзор…", opening: "Открытие проекта...", searching: "Идет поиск...", empty: "Начните вводить путь", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9db623473..76f85fae8 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1079,6 +1079,7 @@ export const zhCN: TranslationResources = { }, projectPicker: { placeholder: "输入目录路径...", + browse: "浏览…", opening: "正在打开 project...", searching: "正在搜索...", empty: "开始输入路径", diff --git a/packages/app/src/utils/working-directory-suggestions.test.ts b/packages/app/src/utils/working-directory-suggestions.test.ts index 9e9318d4f..480fc7738 100644 --- a/packages/app/src/utils/working-directory-suggestions.test.ts +++ b/packages/app/src/utils/working-directory-suggestions.test.ts @@ -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"], diff --git a/packages/app/src/utils/working-directory-suggestions.ts b/packages/app/src/utils/working-directory-suggestions.ts index 69e55b872..13d53e8b8 100644 --- a/packages/app/src/utils/working-directory-suggestions.ts +++ b/packages/app/src/utils/working-directory-suggestions.ts @@ -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(); 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(); } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index d5725e3e4..c66c8068b 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1,5 +1,5 @@ import { test, expect, beforeAll, afterAll } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir, homedir } from "node:os"; import path from "node:path"; @@ -831,6 +831,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as test("returns home-scoped directory suggestions", async () => { const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); + const rootBrowseDir = mkdtempSync(path.join(homedir(), "000-paseo-root-browse-")); const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); try { @@ -842,6 +843,17 @@ test("returns home-scoped directory suggestions", async () => { expect(insideResult.error).toBeNull(); expect(insideResult.directories).toContain(insideHomeDir); + const rootBrowseResult = await ctx.client.getDirectorySuggestions({ + query: "~", + limit: 100, + }); + expect(rootBrowseResult.error).toBeNull(); + expect(rootBrowseResult.directories).toContain(rootBrowseDir); + + const blankResult = await ctx.client.getDirectorySuggestions({ query: "", limit: 100 }); + expect(blankResult.error).toBeNull(); + expect(blankResult.entries).toEqual([]); + const outsideQuery = path.basename(outsideHomeDir); const outsideResult = await ctx.client.getDirectorySuggestions({ query: outsideQuery, @@ -851,10 +863,35 @@ test("returns home-scoped directory suggestions", async () => { expect(outsideResult.directories).not.toContain(outsideHomeDir); } finally { rmSync(insideHomeDir, { recursive: true, force: true }); + rmSync(rootBrowseDir, { recursive: true, force: true }); rmSync(outsideHomeDir, { recursive: true, force: true }); } }, 30000); +test("returns typed relative suggestions within a requested directory", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-workspace-suggestion-")); + const target = path.join(cwd, "src", "components", "message-renderer.tsx"); + + try { + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, ""); + + const result = await ctx.client.getDirectorySuggestions({ + cwd, + query: "msgrndr", + includeFiles: true, + includeDirectories: false, + limit: 20, + }); + + expect(result.error).toBeNull(); + expect(result.directories).toEqual([]); + expect(result.entries).toEqual([{ path: "src/components/message-renderer.tsx", kind: "file" }]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("receives server_info on websocket connect", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 939b017db..894ccb300 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -174,7 +174,7 @@ import { type AgentUpdatesService, } from "./session/agent-updates/agent-updates-service.js"; import { expandTilde } from "../utils/path.js"; -import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; +import { searchDirectoryEntries } from "../utils/directory-suggestions.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; @@ -223,6 +223,14 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis // the entire session message if they encounter an unknown provider. const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; +const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ + ".agents", + ".claude", + ".codex", + ".github", + ".paseo", + ".vscode", +]; function errorToFriendlyMessage(error: unknown): string { if (error instanceof Error) return error.message; @@ -3049,22 +3057,23 @@ export class Session { try { const workspaceCwd = cwd?.trim(); - const entries = workspaceCwd - ? await searchWorkspaceEntries({ - cwd: expandTilde(workspaceCwd), - query, - limit, - includeFiles, - includeDirectories, - matchMode, - }) - : ( - await searchHomeDirectories({ - homeDir: process.env.HOME ?? homedir(), - query, - limit, - }) - ).map((path) => ({ path, kind: "directory" as const })); + const searchesWorkspace = Boolean(workspaceCwd); + const entries = await searchDirectoryEntries({ + root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()), + query, + pathFormat: searchesWorkspace ? "relative" : "absolute", + pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted", + blankQueryBehavior: searchesWorkspace ? "children" : "none", + rootAliases: searchesWorkspace ? [] : ["~"], + traversableHiddenDirectoryNames: searchesWorkspace + ? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES + : [], + confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000, + includeFiles, + includeDirectories, + matchMode, + limit, + }); const directories = entries .filter((entry) => entry.kind === "directory") .map((entry) => entry.path); diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index bb6e1d3f7..810b68a6b 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,26 +1,426 @@ -import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; -import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js"; +import { searchDirectoryEntries } from "./directory-suggestions.js"; const isWindows = isPlatform("win32"); +const filesystemRootDirectoryName = isWindows ? "Windows" : "usr"; +const workspaceHiddenDirectories = [".agents", ".claude", ".codex", ".github", ".paseo", ".vscode"]; -describe("searchHomeDirectories", () => { +async function searchAbsoluteDirectoryPaths(options: { + homeDir: string; + query: string; + limit?: number; + maxDepth?: number; + maxDirectoriesScanned?: number; +}): Promise { + const entries = await searchDirectoryEntries({ + root: options.homeDir, + query: options.query, + pathFormat: "absolute", + includeDirectories: true, + includeFiles: false, + pathQueryPolicy: "rooted", + rootAliases: ["~"], + blankQueryBehavior: "none", + limit: options.limit, + maxDepth: options.maxDepth, + maxEntriesScanned: options.maxDirectoriesScanned, + confidentResultScanThreshold: 5_000, + }); + return entries.map((entry) => entry.path); +} + +async function searchRelativeDirectoryEntries(options: { + cwd: string; + query: string; + limit?: number; + includeFiles?: boolean; + includeDirectories?: boolean; + matchMode?: "fuzzy" | "suffix"; + maxDepth?: number; + maxEntriesScanned?: number; +}) { + return searchDirectoryEntries({ + root: options.cwd, + query: options.query, + pathFormat: "relative", + includeFiles: options.includeFiles, + includeDirectories: options.includeDirectories, + matchMode: options.matchMode, + pathQueryPolicy: "slashes", + blankQueryBehavior: "children", + traversableHiddenDirectoryNames: workspaceHiddenDirectories, + limit: options.limit, + maxDepth: options.maxDepth, + maxEntriesScanned: options.maxEntriesScanned, + }); +} + +describe("searchDirectoryEntries", () => { + let configuredSearchRoot: string; + let searchRoot: string; + + beforeEach(() => { + configuredSearchRoot = mkdtempSync(path.join(tmpdir(), "directory-search-")); + searchRoot = realpathSync.native(configuredSearchRoot); + mkdirSync(path.join(searchRoot, "projects", "paseo-desktop"), { recursive: true }); + mkdirSync(path.join(searchRoot, "src", "components"), { recursive: true }); + mkdirSync(path.join(searchRoot, ".hidden", "secret"), { recursive: true }); + writeFileSync(path.join(searchRoot, "src", "components", "message-renderer.tsx"), ""); + }); + + afterEach(() => { + rmSync(searchRoot, { recursive: true, force: true }); + }); + + it("applies result paths and entry kinds as parameters of one search", async () => { + const directories = await searchDirectoryEntries({ + root: configuredSearchRoot, + query: "pso", + pathFormat: "absolute", + includeFiles: false, + includeDirectories: true, + }); + const files = await searchDirectoryEntries({ + root: searchRoot, + query: "msgrndr", + pathFormat: "relative", + includeFiles: true, + includeDirectories: false, + }); + + expect({ directories, files }).toEqual({ + directories: [ + { + path: path.join(searchRoot, "projects", "paseo-desktop"), + kind: "directory", + }, + ], + files: [ + { + path: "src/components/message-renderer.tsx", + kind: "file", + }, + ], + }); + }); + + it("configures raw blank queries independently from explicit root aliases", async () => { + const rootEntries = [ + { path: "projects", kind: "directory" as const }, + { path: "src", kind: "directory" as const }, + ]; + const common = { + root: searchRoot, + pathFormat: "relative" as const, + includeFiles: false, + includeDirectories: true, + rootAliases: ["~"], + }; + + await expect( + searchDirectoryEntries({ + ...common, + query: "", + blankQueryBehavior: "none", + }), + ).resolves.toEqual([]); + + await expect( + searchDirectoryEntries({ + ...common, + query: "~", + blankQueryBehavior: "none", + }), + ).resolves.toEqual(rootEntries); + + await expect( + searchDirectoryEntries({ + ...common, + query: "", + blankQueryBehavior: "children", + }), + ).resolves.toEqual(rootEntries); + + const suffixRootBrowses = await Promise.all([ + searchDirectoryEntries({ + ...common, + query: "", + matchMode: "suffix", + blankQueryBehavior: "children", + }), + searchDirectoryEntries({ + ...common, + query: "~", + matchMode: "suffix", + blankQueryBehavior: "none", + }), + ]); + expect(suffixRootBrowses).toEqual([rootEntries, rootEntries]); + }); + + it("anchors rooted one-segment queries to their root parent", async () => { + mkdirSync(path.join(searchRoot, "nested", "pso-global"), { recursive: true }); + mkdirSync(path.join(searchRoot, "pso-root"), { recursive: true }); + const absoluteQuery = path.join(configuredSearchRoot, "pso"); + + const common = { + root: configuredSearchRoot, + pathFormat: "relative" as const, + includeFiles: false, + includeDirectories: true, + pathQueryPolicy: "rooted" as const, + rootAliases: ["~"], + }; + const expected = [{ path: "pso-root", kind: "directory" }]; + + await expect(searchDirectoryEntries({ ...common, query: "~/pso" })).resolves.toEqual(expected); + await expect(searchDirectoryEntries({ ...common, query: "./pso" })).resolves.toEqual(expected); + await expect(searchDirectoryEntries({ ...common, query: absoluteQuery })).resolves.toEqual( + expected, + ); + }); + + it("browses an absolute root and an absolute directory ending in a separator", async () => { + const common = { + root: configuredSearchRoot, + pathFormat: "relative" as const, + includeFiles: false, + includeDirectories: true, + pathQueryPolicy: "rooted" as const, + }; + const rootEntries = await searchDirectoryEntries({ ...common, query: configuredSearchRoot }); + const projectEntries = await searchDirectoryEntries({ + ...common, + query: `${path.join(configuredSearchRoot, "projects")}${path.sep}`, + }); + + expect({ rootEntries, projectEntries }).toEqual({ + rootEntries: [ + { path: "projects", kind: "directory" }, + { path: "src", kind: "directory" }, + ], + projectEntries: [{ path: "projects/paseo-desktop", kind: "directory" }], + }); + }); + + it("anchors single-segment absolute queries when the search root is a filesystem root", async () => { + const filesystemRoot = path.parse(searchRoot).root; + const exactPath = path.join(filesystemRoot, filesystemRootDirectoryName); + const incompletePath = exactPath.slice(0, -1); + const common = { + root: filesystemRoot, + pathFormat: "absolute" as const, + includeFiles: false, + includeDirectories: true, + pathQueryPolicy: "rooted" as const, + limit: 5, + }; + + await expect(searchDirectoryEntries({ ...common, query: incompletePath })).resolves.toEqual([]); + + const exactEntries = await searchDirectoryEntries({ ...common, query: exactPath }); + expect(exactEntries[0]).toEqual({ path: exactPath, kind: "directory" }); + expect( + exactEntries.every( + (entry) => entry.path === exactPath || entry.path.startsWith(`${exactPath}${path.sep}`), + ), + ).toBe(true); + }); + + it("does not return entries below the configured traversal depth", async () => { + await expect( + searchDirectoryEntries({ + root: searchRoot, + query: "message-renderer", + pathFormat: "relative", + includeFiles: true, + includeDirectories: false, + maxDepth: 2, + }), + ).resolves.toEqual([]); + }); + + it("does not spend the scan budget on excluded entry kinds", async () => { + const budgetRoot = path.join(searchRoot, "kind-budget"); + const target = path.join(budgetRoot, "z-projects", "paseo-target"); + mkdirSync(target, { recursive: true }); + for (let index = 0; index < 10; index += 1) { + writeFileSync(path.join(budgetRoot, `a-noise-${index}.txt`), ""); + } + + await expect( + searchDirectoryEntries({ + root: budgetRoot, + query: "paseo-target", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + maxEntriesScanned: 2, + }), + ).resolves.toEqual([{ path: "z-projects/paseo-target", kind: "directory" }]); + }); + + it("applies ignored-directory policy to parent-scoped queries", async () => { + mkdirSync(path.join(searchRoot, "node_modules")); + + await expect( + searchDirectoryEntries({ + root: searchRoot, + query: "./node", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + }), + ).resolves.toEqual([]); + }); + + it("does not traverse dependency, environment, or build-output directories", async () => { + const ignoredNames = [ + "node_modules", + "venv", + "env", + "virtualenv", + "dist", + "build", + "target", + "out", + "coverage", + "vendor", + "__pycache__", + ".git", + ]; + for (const name of ignoredNames) { + const ignoredTarget = path.join(searchRoot, name, "search-target.ts"); + mkdirSync(path.dirname(ignoredTarget), { recursive: true }); + writeFileSync(ignoredTarget, ""); + } + const visibleTarget = path.join(searchRoot, "src", "search-target.ts"); + writeFileSync(visibleTarget, ""); + + await expect( + searchDirectoryEntries({ + root: searchRoot, + query: "search-target.ts", + pathFormat: "relative", + includeFiles: true, + includeDirectories: false, + }), + ).resolves.toEqual([{ path: "src/search-target.ts", kind: "file" }]); + }); + + it.skipIf(isWindows)("rechecks cached symlink children against each search root", async () => { + const narrowRoot = path.join(searchRoot, "narrow"); + const outsideNarrowRoot = path.join(searchRoot, "outside"); + mkdirSync(narrowRoot, { recursive: true }); + mkdirSync(path.join(outsideNarrowRoot, "leaked-child"), { recursive: true }); + symlinkSync(outsideNarrowRoot, path.join(narrowRoot, "outside-link")); + + await searchDirectoryEntries({ + root: searchRoot, + query: "leaked-child", + pathFormat: "absolute", + includeFiles: false, + includeDirectories: true, + }); + + await expect( + searchDirectoryEntries({ + root: narrowRoot, + query: "outside", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + }), + ).resolves.toEqual([]); + }); + + it.skipIf(isWindows)("rechecks a cached symlink after its target changes", async () => { + const narrowRoot = path.join(searchRoot, "retargeted-link-root"); + const insideTarget = path.join(narrowRoot, "inside"); + const outsideTarget = path.join(searchRoot, "retargeted-link-outside"); + const link = path.join(narrowRoot, "project-link"); + mkdirSync(insideTarget, { recursive: true }); + mkdirSync(outsideTarget, { recursive: true }); + symlinkSync(insideTarget, link); + + await expect( + searchDirectoryEntries({ + root: narrowRoot, + query: "project-link", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + }), + ).resolves.toEqual([{ path: "project-link", kind: "directory" }]); + + unlinkSync(link); + symlinkSync(outsideTarget, link); + + await expect( + searchDirectoryEntries({ + root: narrowRoot, + query: "project-link", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + }), + ).resolves.toEqual([]); + }); + + it("refreshes a cached directory after a child is created", async () => { + const dynamicRoot = path.join(searchRoot, "dynamic-cache-root"); + mkdirSync(dynamicRoot); + + await expect( + searchDirectoryEntries({ + root: dynamicRoot, + query: "fresh-project", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + }), + ).resolves.toEqual([]); + + mkdirSync(path.join(dynamicRoot, "fresh-project")); + + await expect( + searchDirectoryEntries({ + root: dynamicRoot, + query: "fresh-project", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + }), + ).resolves.toEqual([{ path: "fresh-project", kind: "directory" }]); + }); +}); + +describe("absolute directory-path configuration", () => { let tempRoot: string; let homeDir: string; let outsideDir: string; beforeEach(() => { - tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); + tempRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); homeDir = path.join(tempRoot, "home"); outsideDir = path.join(tempRoot, "outside"); mkdirSync(homeDir, { recursive: true }); mkdirSync(outsideDir, { recursive: true }); - homeDir = realpathSync(homeDir); - outsideDir = realpathSync(outsideDir); + homeDir = realpathSync.native(homeDir); + outsideDir = realpathSync.native(outsideDir); mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); @@ -38,99 +438,147 @@ describe("searchHomeDirectories", () => { rmSync(tempRoot, { recursive: true, force: true }); }); - it("returns an empty list for blank queries", async () => { + it("does not inspect directories when the scan budget is zero", async () => { await expect( - searchHomeDirectories({ + searchAbsoluteDirectoryPaths({ homeDir, - query: " ", + query: "documents", limit: 10, + maxDirectoriesScanned: 0, }), ).resolves.toEqual([]); }); - it("returns only existing directories", async () => { - const results = await searchHomeDirectories({ - homeDir, - query: "proj", + it("shares the scan budget fairly between nested sibling branches", async () => { + const budgetHome = path.join(tempRoot, "nested-budget-home"); + const projectPath = path.join(budgetHome, "work", "client", "team", "paseo-desktop"); + mkdirSync(projectPath, { recursive: true }); + for (let index = 0; index < 10; index += 1) { + mkdirSync( + path.join(budgetHome, "work", "archive", `noise-${index.toString().padStart(2, "0")}`), + { recursive: true }, + ); + } + + const results = await searchAbsoluteDirectoryPaths({ + homeDir: budgetHome, + query: "paseo-desktop", + limit: 10, + maxDirectoriesScanned: 8, + }); + + expect(results.map((result) => realpathSync.native(result))).toEqual([ + realpathSync.native(projectPath), + ]); + }); + + it.skipIf(isWindows)("does not let a queued symlink hide the direct project branch", async () => { + const symlinkHome = path.join(tempRoot, "symlink-budget-home"); + const projectRoot = path.join(symlinkHome, "b-projects", "project-root"); + const projectPath = path.join(projectRoot, "paseo-desktop"); + const noisyBranch = path.join(symlinkHome, "a-noisy"); + mkdirSync(projectPath, { recursive: true }); + for (let index = 0; index < 10; index += 1) { + mkdirSync(path.join(noisyBranch, `noise-${index.toString().padStart(2, "0")}`), { + recursive: true, + }); + } + // The alias is intentionally queued behind the noise. Discovery must not + // reserve its target before the direct b-projects branch gets a turn. + symlinkSync(projectRoot, path.join(noisyBranch, "zz-target-link")); + + const results = await searchAbsoluteDirectoryPaths({ + homeDir: symlinkHome, + query: "paseo-desktop", + limit: 10, + maxDirectoriesScanned: 6, + }); + + expect(results.map((result) => realpathSync.native(result))).toEqual([ + realpathSync.native(projectPath), + ]); + }); + + it.skipIf(isWindows)("follows visible directory symlinks that stay inside home", async () => { + const symlinkHome = path.join(tempRoot, "internal-symlink-home"); + const projectPath = path.join(symlinkHome, ".linked", "project-root", "paseo-desktop"); + mkdirSync(projectPath, { recursive: true }); + symlinkSync(path.dirname(projectPath), path.join(symlinkHome, "linked-project")); + + const results = await searchAbsoluteDirectoryPaths({ + homeDir: symlinkHome, + query: "pso", limit: 10, }); - const resolvedResults = results.map((result) => realpathSync.native(result)); - expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects"))); - expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects", "paseo"))); - expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); + expect(results.map((result) => realpathSync.native(result))).toEqual([ + realpathSync.native(projectPath), + ]); + }); + + it.skipIf(isWindows)("matches the visible name of a directory symlink", async () => { + const symlinkHome = path.join(tempRoot, "visible-symlink-home"); + const projectsPath = path.join(symlinkHome, "projects"); + const targetPath = path.join(symlinkHome, "work", "current"); + const visibleProjectPath = path.join(projectsPath, "paseo"); + mkdirSync(projectsPath, { recursive: true }); + mkdirSync(targetPath, { recursive: true }); + symlinkSync(targetPath, visibleProjectPath); + + const results = await searchAbsoluteDirectoryPaths({ + homeDir: symlinkHome, + query: "paseo", + limit: 10, + }); + + expect(results).toContain(visibleProjectPath); + }); + + it("keeps scanning past weak fuzzy matches for a stronger late result", async () => { + const largeHome = path.join(tempRoot, "large-home"); + const exactMatchPath = path.join(largeHome, "pso"); + for (let index = 0; index < 8; index += 1) { + mkdirSync( + path.join(largeHome, `a-${index.toString().padStart(2, "0")}-project-search-output`), + { recursive: true }, + ); + } + mkdirSync(exactMatchPath); + + const results = await searchDirectoryEntries({ + root: largeHome, + query: "pso", + pathFormat: "absolute", + includeFiles: false, + includeDirectories: true, + limit: 1, + maxEntriesScanned: 20, + confidentResultScanThreshold: 5, + }); + + expect(results).toEqual([{ path: exactMatchPath, kind: "directory" }]); }); it("supports home-relative path query syntax", async () => { - const results = await searchHomeDirectories({ + const result = await searchAbsoluteDirectoryPaths({ homeDir, query: "~/projects/pa", limit: 10, }); - expect(results.map((result) => realpathSync.native(result))).toEqual([ + expect(result.map((entry) => realpathSync.native(entry))).toEqual([ realpathSync.native(path.join(homeDir, "projects", "paseo")), + realpathSync.native(path.join(homeDir, "projects", "playground")), ]); }); - it("prioritizes exact segment matches before segment-prefix matches", async () => { - const exactSegmentPath = path.join(homeDir, "something", "faro", "something-else"); - const prefixSegmentPath = path.join(homeDir, "something", "somethingelse", "faro-bla"); - mkdirSync(exactSegmentPath, { recursive: true }); - mkdirSync(prefixSegmentPath, { recursive: true }); - - const results = await searchHomeDirectories({ - homeDir, - query: "faro", - limit: 30, - }); - - const resolvedResults = results.map((result) => realpathSync.native(result)); - const exactIndex = resolvedResults.indexOf(realpathSync.native(exactSegmentPath)); - const prefixIndex = resolvedResults.indexOf(realpathSync.native(prefixSegmentPath)); - expect(exactIndex).toBeGreaterThanOrEqual(0); - expect(prefixIndex).toBeGreaterThanOrEqual(0); - expect(exactIndex).toBeLessThan(prefixIndex); - }); - - it("does not let Python virtual environments crowd out top-level project matches", async () => { - const projectPath = path.join(homeDir, "django-po-merge"); - mkdirSync(projectPath, { recursive: true }); - const dependencyPaths = ["venv", "env", "virtualenv"].map((environmentDirectoryName) => - path.join( - homeDir, - `${environmentDirectoryName}-project`, - environmentDirectoryName, - "Lib", - "site-packages", - "django", - ), - ); - for (const dependencyPath of dependencyPaths) { - mkdirSync(dependencyPath, { recursive: true }); - } - - const results = await searchHomeDirectories({ - homeDir, - query: "~/django", - limit: 30, - }); - - const resolvedResults = results.map((result) => realpathSync.native(result)); - const projectIndex = resolvedResults.indexOf(realpathSync.native(projectPath)); - expect(projectIndex).toBeGreaterThanOrEqual(0); - for (const dependencyPath of dependencyPaths) { - expect(resolvedResults).not.toContain(realpathSync.native(dependencyPath)); - } - }); - it("prioritizes partial matches that appear earlier in the path", async () => { const earlierPath = path.join(homeDir, "farofoo"); const laterPath = path.join(homeDir, "x", "y", "farofoo"); mkdirSync(earlierPath, { recursive: true }); mkdirSync(laterPath, { recursive: true }); - const results = await searchHomeDirectories({ + const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "arofo", limit: 30, @@ -144,31 +592,9 @@ describe("searchHomeDirectories", () => { expect(earlierIndex).toBeLessThan(laterIndex); }); - it.skipIf(isWindows)("returns home-root suggestions when query is '~'", async () => { - const results = await searchHomeDirectories({ - homeDir, - query: "~", - limit: 20, - }); - - expect(results).toContain(path.join(homeDir, "projects")); - expect(results).toContain(path.join(homeDir, "documents")); - expect(results).not.toContain(path.join(homeDir, ".hidden")); - }); - - it("does not return hidden directories during tree search", async () => { - const results = await searchHomeDirectories({ - homeDir, - query: "cache", - limit: 20, - }); - - expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); - }); - // POSIX-only: creates and follows a symlink escape fixture. it.skipIf(isWindows)("does not return paths that escape home through symlinks", async () => { - const results = await searchHomeDirectories({ + const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "outside", limit: 20, @@ -179,7 +605,7 @@ describe("searchHomeDirectories", () => { }); it("respects the result limit", async () => { - const results = await searchHomeDirectories({ + const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "p", limit: 1, @@ -189,21 +615,18 @@ describe("searchHomeDirectories", () => { }); }); -describe("searchWorkspaceEntries", () => { +describe("relative typed-entry configuration", () => { let tempRoot: string; let workspaceDir: string; - let outsideDir: string; beforeEach(() => { tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-suggestions-"))); workspaceDir = path.join(tempRoot, "workspace"); - outsideDir = path.join(tempRoot, "outside"); mkdirSync(path.join(workspaceDir, "src", "components"), { recursive: true, }); mkdirSync(path.join(workspaceDir, "docs"), { recursive: true }); - mkdirSync(path.join(outsideDir, "escaped"), { recursive: true }); writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n"); writeFileSync( @@ -211,79 +634,19 @@ describe("searchWorkspaceEntries", () => { "export const ChatInput = null;\n", ); writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n"); - - if (!isWindows) { - symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link")); - } }); afterEach(() => { rmSync(tempRoot, { recursive: true, force: true }); }); - it("returns relative file and directory suggestions for workspace queries", async () => { - const results = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "chat", - limit: 20, - includeFiles: true, - includeDirectories: true, - }); - - expect(results).toContainEqual({ - path: "src/components/chat-input.tsx", - kind: "file", - }); - expect(results.some((entry) => entry.path === path.join(workspaceDir, "src"))).toBe(false); - }); - - it("filters entries by kind", async () => { - const dirsOnly = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "src", - limit: 20, - includeFiles: false, - includeDirectories: true, - }); - expect(dirsOnly.some((entry) => entry.kind === "file")).toBe(false); - expect(dirsOnly.some((entry) => entry.path === "src")).toBe(true); - - const filesOnly = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "readme", - limit: 20, - includeFiles: true, - includeDirectories: false, - }); - expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]); - }); - - it("supports fuzzy basename queries for nested workspace files", async () => { - writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), ""); - - const results = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "msgrndr", - limit: 20, - includeFiles: true, - includeDirectories: false, - }); - - expect(results).toEqual([ - { - path: "src/components/message-renderer.tsx", - kind: "file", - }, - ]); - }); - it("ranks fuzzy basename matches after exact, prefix, and substring matches", async () => { writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr"), ""); writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr-panel.tsx"), ""); writeFileSync(path.join(workspaceDir, "src", "components", "use-msgrndr.ts"), ""); writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), ""); - const results = await searchWorkspaceEntries({ + const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: "msgrndr", limit: 20, @@ -305,7 +668,7 @@ describe("searchWorkspaceEntries", () => { writeFileSync(path.join(workspaceDir, "packages", "app", "src", "file.ts"), ""); writeFileSync(path.join(workspaceDir, "src", "paseo-config-file.ts"), ""); - const basenameResults = await searchWorkspaceEntries({ + const basenameResults = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: "file.ts", limit: 20, @@ -313,7 +676,7 @@ describe("searchWorkspaceEntries", () => { includeDirectories: false, matchMode: "suffix", }); - const suffixResults = await searchWorkspaceEntries({ + const suffixResults = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: "src/file.ts", limit: 20, @@ -346,7 +709,7 @@ describe("searchWorkspaceEntries", () => { mkdirSync(path.dirname(targetPath), { recursive: true }); writeFileSync(targetPath, ""); - const results = await searchWorkspaceEntries({ + const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: "packages/server/src/services/quota-fetcher/providers/local.ts", limit: 20, @@ -369,7 +732,7 @@ describe("searchWorkspaceEntries", () => { mkdirSync(path.dirname(targetPath), { recursive: true }); writeFileSync(targetPath, "daemon log\n"); - const results = await searchWorkspaceEntries({ + const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: ".dev/paseo-home/daemon.log", limit: 20, @@ -382,55 +745,21 @@ describe("searchWorkspaceEntries", () => { expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]); }); - it("suffix mode finds files under allowlisted hidden workspace directories", async () => { + it("traverses only allowlisted hidden directories without suggesting the directories", async () => { mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); - mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true }); + mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true }); writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); - writeFileSync(path.join(workspaceDir, ".github", "workflows", "ci.yml"), ""); + writeFileSync(path.join(workspaceDir, ".dev", "cache", "settings.local.json"), "{}"); - const claudeResults = await searchWorkspaceEntries({ + const suffixResults = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: "settings.local.json", limit: 20, includeFiles: true, - includeDirectories: false, + includeDirectories: true, matchMode: "suffix", }); - const githubResults = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "ci.yml", - limit: 20, - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - }); - - expect(claudeResults).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]); - expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]); - }); - - it("does not broadly traverse unlisted hidden workspace directories", async () => { - mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true }); - writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), ""); - writeFileSync(path.join(workspaceDir, "src", "needle.ts"), ""); - - const results = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "needle.ts", - limit: 20, - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - }); - - expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]); - }); - - it("does not suggest hidden directories even when includeDirectories is true", async () => { - mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); - writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); - - const results = await searchWorkspaceEntries({ + const fuzzyResults = await searchRelativeDirectoryEntries({ cwd: workspaceDir, query: "claude", limit: 20, @@ -439,136 +768,21 @@ describe("searchWorkspaceEntries", () => { matchMode: "fuzzy", }); - expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe( - false, - ); - expect(results).toContainEqual({ - path: ".claude/settings.local.json", - kind: "file", + expect({ suffixResults, fuzzyResults }).toEqual({ + suffixResults: [{ path: ".claude/settings.local.json", kind: "file" }], + fuzzyResults: [{ path: ".claude/settings.local.json", kind: "file" }], }); }); - it("path mode does not suggest hidden workspace directories", async () => { - mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); - writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); - - const results = await searchWorkspaceEntries({ + it("supports slash-style path queries", async () => { + const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, - query: "./", + query: "src/co", limit: 20, includeFiles: true, includeDirectories: true, - matchMode: "fuzzy", }); - expect(results).toContainEqual({ - path: "README.md", - kind: "file", - }); - expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe( - false, - ); - }); - - it("does not traverse .git while searching workspace files", async () => { - mkdirSync(path.join(workspaceDir, ".git", "objects", "ab"), { recursive: true }); - writeFileSync(path.join(workspaceDir, ".git", "objects", "ab", "deadbeef"), ""); - - const results = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "deadbeef", - limit: 20, - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - }); - - expect(results).toEqual([]); - }); - - // POSIX-only: creates and follows a symlink escape fixture. - it.skipIf(isWindows)( - "supports path-style queries and does not escape cwd through symlinks", - async () => { - const pathResults = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "src/co", - limit: 20, - includeFiles: true, - includeDirectories: true, - }); - expect(pathResults).toContainEqual({ - path: "src/components", - kind: "directory", - }); - - const escapedResults = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "escaped", - limit: 20, - includeFiles: true, - includeDirectories: true, - }); - expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false); - }, - ); - - it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => { - mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true }); - writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), ""); - - for (let index = 0; index < 120; index += 1) { - mkdirSync(path.join(workspaceDir, "node_modules", `pkg-${index}`), { recursive: true }); - writeFileSync( - path.join(workspaceDir, "node_modules", `pkg-${index}`, "index.js"), - "module.exports = {};\n", - ); - } - writeFileSync(path.join(workspaceDir, "node_modules", "_layout.tsx"), ""); - - const results = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "_layout.tsx", - limit: 20, - includeFiles: true, - includeDirectories: true, - maxEntriesScanned: 60, - }); - - expect(results).toContainEqual({ - path: "packages/app/src/app/_layout.tsx", - kind: "file", - }); - expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false); - }); - - it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => { - mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true }); - writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), ""); - - const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"]; - for (const heavyDir of heavyDirs) { - for (let index = 0; index < 30; index += 1) { - mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true }); - writeFileSync(path.join(workspaceDir, heavyDir, `bundle-${index}`, "needle.ts"), ""); - } - } - - const results = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "needle.ts", - limit: 20, - includeFiles: true, - includeDirectories: true, - maxEntriesScanned: 80, - }); - - expect(results).toContainEqual({ - path: "packages/app/src/needle.ts", - kind: "file", - }); - for (const heavyDir of heavyDirs) { - expect(results.some((entry) => entry.path.startsWith(`${heavyDir}/`))).toBe(false); - } + expect(results).toEqual([{ path: "src/components", kind: "directory" }]); }); }); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 36cfa7ef8..67d95fd4d 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -1,85 +1,90 @@ -import type { Dirent } from "node:fs"; +import type { Dirent, Stats } from "node:fs"; import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { isPathInsideRoot } from "./path.js"; -export interface SearchHomeDirectoriesOptions { - homeDir: string; - query: string; - limit?: number; - maxDepth?: number; - maxDirectoriesScanned?: number; -} +export type DirectorySuggestionKind = "file" | "directory"; +export type DirectorySuggestionPathFormat = "absolute" | "relative"; +export type DirectorySuggestionMatchMode = "fuzzy" | "suffix"; +export type PathQueryPolicy = "rooted" | "slashes"; +export type BlankQueryBehavior = "none" | "children"; -export type WorkspaceSuggestionKind = "file" | "directory"; - -export interface WorkspaceSuggestionEntry { +export interface DirectorySuggestionEntry { path: string; - kind: WorkspaceSuggestionKind; + kind: DirectorySuggestionKind; } -export interface SearchWorkspaceEntriesOptions { - cwd: string; +export interface SearchDirectoryEntriesOptions { + root: string; query: string; - limit?: number; + pathFormat: DirectorySuggestionPathFormat; includeFiles?: boolean; includeDirectories?: boolean; - matchMode?: WorkspaceMatchMode; + matchMode?: DirectorySuggestionMatchMode; + pathQueryPolicy?: PathQueryPolicy; + rootAliases?: string[]; + blankQueryBehavior?: BlankQueryBehavior; + traversableHiddenDirectoryNames?: string[]; + limit?: number; maxDepth?: number; maxEntriesScanned?: number; + confidentResultScanThreshold?: number; } -export type WorkspaceMatchMode = "fuzzy" | "suffix"; - -const DEFAULT_LIMIT = 30; -const MAX_LIMIT = 100; -const DEFAULT_MAX_DEPTH = 12; -const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000; -const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; -const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; - -interface QueryParts { +interface QueryPlan { isPathQuery: boolean; parentPart: string; searchTerm: string; + normalizedQuery: string; + browseExactPath?: boolean; } -interface RankedDirectory { - absolutePath: string; - matchTier: number; - segmentIndex: number; - matchOffset: number; +interface ChildEntry { + name: string; + resolvedPath: string; + kind: DirectorySuggestionKind; +} + +interface RawChildEntry { + name: string; + kind: DirectorySuggestionKind | "symlink"; +} + +interface TraversedEntry extends ChildEntry { + visiblePath: string; depth: number; } -interface ChildDirectoryEntry { - name: string; - absolutePath: string; -} - -interface ChildWorkspaceEntry { - name: string; - absolutePath: string; - kind: WorkspaceSuggestionKind; +interface RankedEntry extends DirectorySuggestionEntry { + matchTier: number; + segmentIndex: number; + matchOffset: number; + fuzzyScore: number; + depth: number; } interface DirectoryListCacheEntry { expiresAt: number; - entries: ChildDirectoryEntry[]; + modifiedAtMs: number; + changedAtMs: number; + entries: RawChildEntry[]; } -interface WorkspaceEntryListCacheEntry { - expiresAt: number; - entries: ChildWorkspaceEntry[]; -} - -const directoryListCache = new Map(); -const workspaceEntryListCache = new Map(); +const DEFAULT_LIMIT = 30; +const MAX_LIMIT = 100; +const DEFAULT_MAX_DEPTH = 12; +const DEFAULT_MAX_ENTRIES_SCANNED = 20_000; +const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; +const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; +// Windows does not reliably update directory mtime/ctime when children change, +// so metadata cannot safely validate a cross-request listing cache there. +const CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA = process.platform !== "win32"; +const MAX_CONFIDENT_FUZZY_SKIPS_PER_CHARACTER = 2; const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; -const NO_WORKSPACE_MATCH_TIER = 5; -const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ +const NO_MATCH_TIER = 5; +const IGNORED_DIRECTORY_NAMES = new Set([ "node_modules", "venv", "env", @@ -93,1034 +98,509 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ "__pycache__", ".git", ]); -const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([ - ".agents", - ".claude", - ".codex", - ".github", - ".paseo", - ".vscode", -]); +const directoryListCache = new Map(); -export async function searchHomeDirectories( - options: SearchHomeDirectoriesOptions, -): Promise { - const query = options.query.trim(); - if (!query) { - return []; - } +export async function searchDirectoryEntries( + options: SearchDirectoryEntriesOptions, +): Promise { + const root = await resolveDirectory(options.root); + if (!root) return []; - const limit = normalizeLimit(options.limit); - const homeRoot = await resolveDirectory(options.homeDir); - if (!homeRoot) { - return []; - } + const input = buildSearchInput(options, root); + if (!input) return []; - const queryParts = normalizeQueryParts(query, homeRoot); - if (!queryParts) { - return []; - } + const exact = + input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery) + ? await findExactEntry(input) + : null; + if (exact && input.limit === 1) return [exact]; - if (queryParts.isPathQuery) { - return searchWithinParentDirectory({ - homeRoot, - parentPart: queryParts.parentPart, - searchTerm: queryParts.searchTerm, - limit, - }); - } - - return searchAcrossHomeTree({ - homeRoot, - searchTerm: queryParts.searchTerm, - limit, - maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, - maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, - }); + const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; + const ranked = + input.plan.isPathQuery && (input.matchMode === "fuzzy" || browsesRoot) + ? await searchChildren(input) + : await searchTree(input); + const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit); + return exact + ? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit) + : results; } -interface RankedWorkspaceEntry { - relativePath: string; - kind: WorkspaceSuggestionKind; - matchTier: number; - segmentIndex: number; - matchOffset: number; - fuzzyScore: number; - depth: number; -} - -export async function searchWorkspaceEntries( - options: SearchWorkspaceEntriesOptions, -): Promise { - const limit = normalizeLimit(options.limit); +function buildSearchInput( + options: SearchDirectoryEntriesOptions, + root: string, +): SearchInput | null { const includeDirectories = options.includeDirectories ?? true; const includeFiles = options.includeFiles ?? false; - if (!includeDirectories && !includeFiles) { - return []; - } + if (!includeDirectories && !includeFiles) return null; - const workspaceRoot = await resolveDirectory(options.cwd); - if (!workspaceRoot) { - return []; - } + const plan = parseQuery({ + query: options.query, + root, + configuredRoot: path.resolve(options.root), + policy: options.pathQueryPolicy ?? "slashes", + aliases: options.rootAliases ?? [], + blankBehavior: options.blankQueryBehavior ?? "none", + }); + if (!plan) return null; - const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot); - if (!queryParts) { - return []; - } - - const matchMode = options.matchMode ?? "fuzzy"; - const exactEntry = - queryParts.isPathQuery && matchMode === "suffix" - ? await resolveWorkspaceExactEntry({ - workspaceRoot, - query: options.query, - includeDirectories, - includeFiles, - }) - : null; - if (exactEntry && limit <= 1) { - return [exactEntry]; - } - - if (queryParts.isPathQuery && matchMode !== "suffix") { - return searchWorkspaceWithinParentDirectory({ - workspaceRoot, - parentPart: queryParts.parentPart, - searchTerm: queryParts.searchTerm, - limit, - includeDirectories, - includeFiles, - }); - } - - const searchTerm = - matchMode === "suffix" - ? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/") - : queryParts.searchTerm; - const entries = await searchWorkspaceAcrossTree({ - workspaceRoot, - searchTerm, - limit, + return { + root, + plan, includeDirectories, includeFiles, - matchMode, + matchMode: options.matchMode ?? "fuzzy", + pathFormat: options.pathFormat, + hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []), + limit: normalizeLimit(options.limit), maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, - maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, - }); - return exactEntry ? prependWorkspaceEntry(exactEntry, entries).slice(0, limit) : entries; + maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, + confidentResultScanThreshold: options.confidentResultScanThreshold, + }; } -async function resolveWorkspaceExactEntry(input: { - workspaceRoot: string; - query: string; +async function findExactEntry(input: SearchInput): Promise { + if (!input.plan.normalizedQuery) return null; + const visiblePath = path.resolve(input.root, input.plan.normalizedQuery); + const resolvedPath = await realpath(visiblePath).catch(() => null); + if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; + const info = await stat(resolvedPath).catch(() => null); + const kind = getEntryKind(info); + if ( + !kind || + (kind === "directory" && !input.includeDirectories) || + (kind === "file" && !input.includeFiles) + ) + return null; + return formatEntry({ path: visiblePath, kind }, input.root, input.pathFormat); +} + +interface SearchInput { + root: string; + plan: QueryPlan; includeDirectories: boolean; includeFiles: boolean; -}): Promise { - const normalized = input.query - .trim() - .replace(/\\/g, "/") - .replace(/^\.\/+/, "") - .replace(/\/{2,}/g, "/"); + matchMode: DirectorySuggestionMatchMode; + pathFormat: DirectorySuggestionPathFormat; + hiddenDirectoryNames: Set; + limit: number; + maxDepth: number; + maxEntriesScanned: number; + confidentResultScanThreshold: number | undefined; +} + +async function searchChildren(input: SearchInput): Promise { + const visibleParent = path.resolve(input.root, input.plan.parentPart || "."); + const parent = await realpath(visibleParent).catch(() => null); + if (!parent || !isPathInsideRoot(input.root, parent)) return []; + const entries = await readChildren(parent); + return entries.flatMap((entry) => { + if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) + return []; + const candidate: TraversedEntry = { + ...entry, + visiblePath: path.join(visibleParent, entry.name), + depth: 1, + }; + return shouldSuggest(candidate, input) ? [rank(candidate, input)] : []; + }); +} + +async function searchTree(input: SearchInput): Promise { + if (!(input.maxEntriesScanned > 0)) return []; + const roots = (await readChildren(input.root)).filter((entry) => + isPathInsideRoot(input.root, entry.resolvedPath), + ); + const visited = new Set([input.root]); + const branches = roots.flatMap((entry) => + shouldDiscover(entry, input) + ? [ + walkBranch( + { ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 }, + input, + visited, + ), + ] + : [], + ); + const ranked: RankedEntry[] = []; + let scanned = 0; + const threshold = input.confidentResultScanThreshold; + for await (const entry of roundRobin(branches)) { + scanned += 1; + if (shouldSuggest(entry, input)) ranked.push(rank(entry, input)); + if ( + scanned >= input.maxEntriesScanned || + (threshold && scanned >= threshold && hasConfidentResult(ranked, input.plan.searchTerm)) + ) + break; + } + return ranked; +} + +async function* walkBranch( + entry: TraversedEntry, + input: SearchInput, + visited: Set, +): AsyncGenerator { + yield entry; + if ( + entry.kind !== "directory" || + visited.has(entry.resolvedPath) || + entry.depth >= input.maxDepth + ) + return; + visited.add(entry.resolvedPath); + const children = (await readChildren(entry.resolvedPath)).filter((child) => + isPathInsideRoot(input.root, child.resolvedPath), + ); + const branches = children.flatMap((child) => + shouldDiscover(child, input) + ? [ + walkBranch( + { + ...child, + visiblePath: path.join(entry.visiblePath, child.name), + depth: entry.depth + 1, + }, + input, + visited, + ), + ] + : [], + ); + yield* roundRobin(branches); +} + +async function* roundRobin(branches: Array>): AsyncGenerator { + let active = branches; + while (active.length) { + const nextRound: Array> = []; + for (const branch of active) { + const next = await branch.next(); + if (!next.done) { + nextRound.push(branch); + yield next.value; + } + } + active = nextRound; + } +} + +function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { + if (entry.kind === "file") { + return input.includeFiles && !entry.name.startsWith("."); + } + if (IGNORED_DIRECTORY_NAMES.has(entry.name)) return false; + if (!entry.name.startsWith(".")) return true; + return input.hiddenDirectoryNames.has(entry.name); +} + +function shouldSuggest(entry: TraversedEntry, input: SearchInput): boolean { + if (entry.name.startsWith(".")) return false; + if (entry.kind === "directory" && !input.includeDirectories) return false; + if (entry.kind === "file" && !input.includeFiles) return false; + if (!input.plan.normalizedQuery) return true; + if (input.matchMode === "suffix") + return suffixMatches(entry.visiblePath, input.root, input.plan.normalizedQuery); + return !input.plan.searchTerm || rank(entry, input).matchTier !== NO_MATCH_TIER; +} + +function rank(entry: TraversedEntry, input: SearchInput): RankedEntry { + const relativePath = normalizeRelativePath(input.root, entry.visiblePath); + const lowerPath = relativePath.toLowerCase(); + const query = input.plan.searchTerm.toLowerCase(); + const segments = lowerPath === "." ? [] : lowerPath.split("/"); + const exact = findSegmentMatchIndex(segments, (segment) => segment === query); + const prefix = findSegmentMatchIndex(segments, (segment) => segment.startsWith(query)); + const substring = findSegmentMatchIndex(segments, (segment) => segment.includes(query)); + const offset = lowerPath.indexOf(query); + const fuzzyScore = scoreFuzzySubsequence(query, segments.at(-1) ?? ""); + let matchTier = NO_MATCH_TIER; + let segmentIndex = NO_SEGMENT_INDEX; + if (!query) matchTier = 3; + else if (exact >= 0) { + matchTier = 0; + segmentIndex = exact; + } else if (prefix >= 0) { + matchTier = 1; + segmentIndex = prefix; + } else if (substring >= 0) { + matchTier = 2; + segmentIndex = substring; + } else if (input.pathFormat === "relative" ? lowerPath.startsWith(query) : offset >= 0) + matchTier = 3; + else if (fuzzyScore !== null) matchTier = 4; + return { + path: entry.visiblePath, + kind: entry.kind, + matchTier, + segmentIndex, + matchOffset: offset >= 0 ? offset : NO_MATCH_OFFSET, + fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, + depth: relativePath === "." ? 0 : segments.length, + }; +} + +function sortAndFormat( + entries: RankedEntry[], + root: string, + format: DirectorySuggestionPathFormat, +): DirectorySuggestionEntry[] { + const unique = new Map(); + for (const entry of entries) { + const key = `${entry.kind}:${entry.path}`; + const existing = unique.get(key); + if (!existing || compareRank(entry, existing) < 0) unique.set(key, entry); + } + return [...unique.values()].sort(compareRank).map((entry) => formatEntry(entry, root, format)); +} + +function formatEntry( + entry: DirectorySuggestionEntry, + root: string, + format: DirectorySuggestionPathFormat, +): DirectorySuggestionEntry { + return { + path: format === "absolute" ? entry.path : normalizeRelativePath(root, entry.path), + kind: entry.kind, + }; +} + +function compareRank(left: RankedEntry, right: RankedEntry): number { + return ( + left.matchTier - right.matchTier || + left.segmentIndex - right.segmentIndex || + left.matchOffset - right.matchOffset || + left.fuzzyScore - right.fuzzyScore || + left.depth - right.depth || + compareKinds(left.kind, right.kind) || + left.path.localeCompare(right.path) + ); +} + +function compareKinds(left: DirectorySuggestionKind, right: DirectorySuggestionKind): number { + if (left === right) return 0; + return left === "directory" ? -1 : 1; +} + +function hasConfidentResult(entries: RankedEntry[], query: string): boolean { + const maxFuzzyScore = query.length * MAX_CONFIDENT_FUZZY_SKIPS_PER_CHARACTER; + return entries.some( + (entry) => entry.matchTier < 4 || (entry.matchTier === 4 && entry.fuzzyScore <= maxFuzzyScore), + ); +} + +function suffixMatches(visiblePath: string, root: string, query: string): boolean { + const querySegments = query.toLowerCase().split("/").filter(Boolean); + if (querySegments.length === 0) return false; + const pathSegments = normalizeRelativePath(root, visiblePath) + .toLowerCase() + .split("/") + .filter(Boolean); + const offset = pathSegments.length - querySegments.length; + return ( + offset >= 0 && querySegments.every((segment, index) => pathSegments[offset + index] === segment) + ); +} + +function parseQuery(input: { + query: string; + root: string; + configuredRoot: string; + policy: PathQueryPolicy; + aliases: string[]; + blankBehavior: BlankQueryBehavior; +}): QueryPlan | null { + const normalizedInput = normalizeQueryInput(input); + if (!normalizedInput) return null; + const { typed, rooted } = normalizedInput; + const normalized = normalizedInput.normalized; + if (!normalized) { - return null; + const explicitlyBrowseRoot = rooted || typed === "."; + if (!explicitlyBrowseRoot && input.blankBehavior !== "children") return null; + return { isPathQuery: true, parentPart: "", searchTerm: "", normalizedQuery: "" }; } - - const candidatePath = path.isAbsolute(normalized) - ? path.resolve(normalized) - : path.resolve(input.workspaceRoot, normalized); - let resolvedPath: string; - try { - resolvedPath = await realpath(candidatePath); - } catch { - return null; - } - if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) { - return null; - } - - const stats = await stat(resolvedPath).catch(() => null); - if (!stats) { - return null; - } - if (stats.isFile() && input.includeFiles) { + if (normalizedInput.isAbsolute && isFilesystemRoot(input.root) && !normalized.includes("/")) { return { - path: normalizeRelativePath(input.workspaceRoot, resolvedPath), - kind: "file", + isPathQuery: true, + parentPart: normalized, + searchTerm: "", + normalizedQuery: normalized, + browseExactPath: true, }; } - if (stats.isDirectory() && input.includeDirectories) { - return { - path: normalizeRelativePath(input.workspaceRoot, resolvedPath), - kind: "directory", - }; + const isPathQuery = rooted || (input.policy === "slashes" && normalized.includes("/")); + const slash = normalized.lastIndexOf("/"); + return { + isPathQuery, + parentPart: isPathQuery && slash >= 0 ? normalized.slice(0, slash) : "", + searchTerm: isPathQuery && slash >= 0 ? normalized.slice(slash + 1) : normalized, + normalizedQuery: normalized, + }; +} + +function normalizeQueryInput(input: { + query: string; + root: string; + configuredRoot: string; + aliases: string[]; +}): { typed: string; normalized: string; rooted: boolean; isAbsolute: boolean } | null { + const typed = input.query.trim().replace(/\\/g, "/"); + let normalized = typed; + let rooted = false; + let isAbsolute = false; + for (const alias of input.aliases) { + if (normalized === alias || normalized.startsWith(`${alias}/`)) { + rooted = true; + normalized = normalized.slice(alias.length).replace(/^\/+/, ""); + break; + } } + if (path.isAbsolute(normalized)) { + isAbsolute = true; + const browseAbsoluteDirectory = normalized.endsWith("/"); + const absolutePath = path.resolve(normalized); + let queryRoot: string | null = null; + if (isPathInsideRoot(input.root, absolutePath)) { + queryRoot = input.root; + } else if (isPathInsideRoot(input.configuredRoot, absolutePath)) { + queryRoot = input.configuredRoot; + } + if (!queryRoot) return null; + rooted = true; + normalized = normalizeRelativePath(queryRoot, absolutePath); + if (browseAbsoluteDirectory && normalized !== ".") { + normalized = `${normalized}/`; + } + } + if (normalized.startsWith("./")) rooted = true; + normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); + if (normalized === "." && (rooted || typed === ".")) { + normalized = ""; + } + return { typed, normalized, rooted, isAbsolute }; +} + +function isFilesystemRoot(inputPath: string): boolean { + return path.relative(path.parse(inputPath).root, inputPath) === ""; +} + +async function resolveDirectory(inputPath: string): Promise { + const resolved = await realpath(path.resolve(inputPath)).catch(() => null); + if (!resolved) return null; + const info = await stat(resolved).catch(() => null); + return info?.isDirectory() ? resolved : null; +} + +async function readChildren(directory: string): Promise { + const directoryInfo = await stat(directory).catch(() => null); + if (!directoryInfo?.isDirectory()) return []; + + const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA + ? directoryListCache.get(directory) + : undefined; + let rawEntries: RawChildEntry[]; + if ( + cached && + cached.expiresAt > Date.now() && + cached.modifiedAtMs === directoryInfo.mtimeMs && + cached.changedAtMs === directoryInfo.ctimeMs + ) { + rawEntries = cached.entries; + } else { + const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]); + rawEntries = dirents + .map(toRawChildEntry) + .filter((entry): entry is RawChildEntry => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)); + if (CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA) { + directoryListCache.set(directory, { + expiresAt: Date.now() + DIRECTORY_LIST_CACHE_TTL_MS, + modifiedAtMs: directoryInfo.mtimeMs, + changedAtMs: directoryInfo.ctimeMs, + entries: rawEntries, + }); + pruneCache(); + } + } + + return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry)))) + .filter((entry): entry is ChildEntry => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function toRawChildEntry(dirent: Dirent): RawChildEntry | null { + if (dirent.isDirectory()) return { name: dirent.name, kind: "directory" }; + if (dirent.isFile()) return { name: dirent.name, kind: "file" }; + if (dirent.isSymbolicLink()) return { name: dirent.name, kind: "symlink" }; return null; } -function prependWorkspaceEntry( - entry: WorkspaceSuggestionEntry, - entries: WorkspaceSuggestionEntry[], -): WorkspaceSuggestionEntry[] { - return [ - entry, - ...entries.filter( - (candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path, - ), - ]; +async function resolveChild(directory: string, entry: RawChildEntry): Promise { + const visiblePath = path.join(directory, entry.name); + if (entry.kind !== "symlink") { + return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind }; + } + + const resolvedPath = await realpath(visiblePath).catch(() => null); + if (!resolvedPath) return null; + const info = await stat(resolvedPath).catch(() => null); + const kind = getEntryKind(info); + return kind ? { name: entry.name, resolvedPath, kind } : null; +} + +function getEntryKind(info: Stats | null): DirectorySuggestionKind | null { + if (info?.isDirectory()) return "directory"; + if (info?.isFile()) return "file"; + return null; +} + +function pruneCache(): void { + if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) return; + for (const [key, entry] of directoryListCache) + if (entry.expiresAt <= Date.now()) directoryListCache.delete(key); + while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + const key = directoryListCache.keys().next().value; + if (!key) return; + directoryListCache.delete(key); + } } function normalizeLimit(limit: number | undefined): number { - const candidate = limit ?? DEFAULT_LIMIT; - if (!Number.isFinite(candidate)) { - return DEFAULT_LIMIT; - } - const bounded = Math.trunc(candidate); - return Math.max(1, Math.min(MAX_LIMIT, bounded)); + const candidate = + typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : DEFAULT_LIMIT; + return Math.max(1, Math.min(MAX_LIMIT, candidate)); } -async function searchWithinParentDirectory(input: { - homeRoot: string; - parentPart: string; - searchTerm: string; - limit: number; -}): Promise { - const parentPath = path.resolve(input.homeRoot, input.parentPart || "."); - const parentRoot = await resolveDirectory(parentPath); - if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) { - return []; - } - - const searchLower = input.searchTerm.toLowerCase(); - const ranked: RankedDirectory[] = []; - const entries = await listChildDirectories({ - directory: parentRoot, - homeRoot: input.homeRoot, - }); - - for (const entry of entries) { - if (searchLower && !entry.name.toLowerCase().includes(searchLower)) { - continue; - } - - ranked.push( - rankDirectory({ - absolutePath: entry.absolutePath, - homeRoot: input.homeRoot, - searchLower, - }), - ); - } - - return dedupeAndSort(ranked).slice(0, input.limit); -} - -async function searchAcrossHomeTree(input: { - homeRoot: string; - searchTerm: string; - limit: number; - maxDepth: number; - maxDirectoriesScanned: number; -}): Promise { - const queue: Array<{ directory: string; depth: number }> = [ - { directory: input.homeRoot, depth: 0 }, - ]; - const visited = new Set([input.homeRoot]); - const ranked: RankedDirectory[] = []; - let scanned = 0; - const searchLower = input.searchTerm.toLowerCase(); - - for ( - let queueIndex = 0; - queueIndex < queue.length && scanned < input.maxDirectoriesScanned; - queueIndex += 1 - ) { - const current = queue[queueIndex]; - if (!current) continue; - const entries = await listChildDirectories({ - directory: current.directory, - homeRoot: input.homeRoot, - }); - - for (const entry of entries) { - const resolvedCandidate = entry.absolutePath; - if (visited.has(resolvedCandidate)) { - continue; - } - visited.add(resolvedCandidate); - scanned += 1; - - const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate); - if ( - relativePath.toLowerCase().includes(searchLower) || - entry.name.toLowerCase().includes(searchLower) - ) { - ranked.push( - rankDirectory({ - absolutePath: resolvedCandidate, - homeRoot: input.homeRoot, - searchLower, - }), - ); - } - - if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) { - queue.push({ directory: resolvedCandidate, depth: current.depth + 1 }); - } - } - } - - return dedupeAndSort(ranked).slice(0, input.limit); -} - -async function searchWorkspaceWithinParentDirectory(input: { - workspaceRoot: string; - parentPart: string; - searchTerm: string; - limit: number; - includeDirectories: boolean; - includeFiles: boolean; -}): Promise { - const parentPath = path.resolve(input.workspaceRoot, input.parentPart || "."); - const parentRoot = await resolveDirectory(parentPath); - if (!parentRoot || !isPathInsideRoot(input.workspaceRoot, parentRoot)) { - return []; - } - - const searchLower = input.searchTerm.toLowerCase(); - const ranked: RankedWorkspaceEntry[] = []; - const entries = await listWorkspaceChildEntries({ - directory: parentRoot, - workspaceRoot: input.workspaceRoot, - }); - - for (const entry of entries) { - if (entry.kind === "directory" && !input.includeDirectories) { - continue; - } - if (entry.kind === "file" && !input.includeFiles) { - continue; - } - if (isHiddenWorkspaceSuggestion(entry)) { - continue; - } - const rankedEntry = rankWorkspaceEntry({ - absolutePath: entry.absolutePath, - kind: entry.kind, - workspaceRoot: input.workspaceRoot, - searchLower, - }); - if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) { - continue; - } - - ranked.push(rankedEntry); - } - - return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit); -} - -async function searchWorkspaceAcrossTree(input: { - workspaceRoot: string; - searchTerm: string; - limit: number; - includeDirectories: boolean; - includeFiles: boolean; - matchMode: WorkspaceMatchMode; - maxDepth: number; - maxEntriesScanned: number; -}): Promise { - const queue: Array<{ directory: string; depth: number }> = [ - { directory: input.workspaceRoot, depth: 0 }, - ]; - const visited = new Set([input.workspaceRoot]); - const ranked: RankedWorkspaceEntry[] = []; - let scanned = 0; - const searchLower = input.searchTerm.toLowerCase(); - - for ( - let queueIndex = 0; - queueIndex < queue.length && scanned < input.maxEntriesScanned; - queueIndex += 1 - ) { - const current = queue[queueIndex]; - if (!current) continue; - - const entries = await listWorkspaceChildEntries({ - directory: current.directory, - workspaceRoot: input.workspaceRoot, - }); - - for (const entry of entries) { - scanned += 1; - - if (entry.kind === "directory") { - if ( - !visited.has(entry.absolutePath) && - current.depth < input.maxDepth && - scanned < input.maxEntriesScanned - ) { - visited.add(entry.absolutePath); - queue.push({ - directory: entry.absolutePath, - depth: current.depth + 1, - }); - } - } - - if (entry.kind === "directory" && !input.includeDirectories) { - continue; - } - // Hidden directories are traversed, but not offered as suggestions. - if (isHiddenWorkspaceSuggestion(entry)) { - continue; - } - if (entry.kind === "file" && !input.includeFiles) { - continue; - } - if ( - input.matchMode === "suffix" && - !workspaceEntryMatchesSuffixQuery({ - absolutePath: entry.absolutePath, - workspaceRoot: input.workspaceRoot, - query: input.searchTerm, - }) - ) { - continue; - } - - const rankedEntry = rankWorkspaceEntry({ - absolutePath: entry.absolutePath, - kind: entry.kind, - workspaceRoot: input.workspaceRoot, - searchLower, - }); - if ( - input.matchMode !== "suffix" && - searchLower && - rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER - ) { - continue; - } - - ranked.push(rankedEntry); - } - } - - return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit); -} - -function workspaceEntryMatchesSuffixQuery(input: { - absolutePath: string; - workspaceRoot: string; - query: string; -}): boolean { - const querySegments = input.query - .trim() - .replace(/\\/g, "/") - .replace(/^\.\/+/, "") - .split("/") - .filter(Boolean) - .map((segment) => segment.toLowerCase()); - if (querySegments.length === 0) { - return false; - } - - const pathSegments = normalizeRelativePath(input.workspaceRoot, input.absolutePath) - .split("/") - .filter(Boolean) - .map((segment) => segment.toLowerCase()); - if (querySegments.length > pathSegments.length) { - return false; - } - - const offset = pathSegments.length - querySegments.length; - return querySegments.every((segment, index) => pathSegments[offset + index] === segment); -} - -function dedupeAndSortWorkspaceEntries( - rankedEntries: RankedWorkspaceEntry[], -): WorkspaceSuggestionEntry[] { - const byPath = new Map(); - for (const entry of rankedEntries) { - const key = `${entry.kind}:${entry.relativePath}`; - const existing = byPath.get(key); - if (!existing || compareRankedWorkspaceEntries(entry, existing) < 0) { - byPath.set(key, entry); - } - } - - return Array.from(byPath.values()) - .sort(compareRankedWorkspaceEntries) - .map((entry) => ({ - path: entry.relativePath, - kind: entry.kind, - })); -} - -function compareRankedWorkspaceEntries( - left: RankedWorkspaceEntry, - right: RankedWorkspaceEntry, -): number { - if (left.matchTier !== right.matchTier) { - return left.matchTier - right.matchTier; - } - if (left.segmentIndex !== right.segmentIndex) { - return left.segmentIndex - right.segmentIndex; - } - if (left.matchOffset !== right.matchOffset) { - return left.matchOffset - right.matchOffset; - } - if (left.fuzzyScore !== right.fuzzyScore) { - return left.fuzzyScore - right.fuzzyScore; - } - if (left.depth !== right.depth) { - return left.depth - right.depth; - } - if (left.kind !== right.kind) { - return left.kind === "directory" ? -1 : 1; - } - return left.relativePath.localeCompare(right.relativePath); -} - -function dedupeAndSort(ranked: RankedDirectory[]): string[] { - const byPath = new Map(); - for (const entry of ranked) { - const existing = byPath.get(entry.absolutePath); - if (!existing || compareRankedDirectories(entry, existing) < 0) { - byPath.set(entry.absolutePath, entry); - } - } - - return Array.from(byPath.values()) - .sort(compareRankedDirectories) - .map((entry) => entry.absolutePath); -} - -function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number { - if (left.matchTier !== right.matchTier) { - return left.matchTier - right.matchTier; - } - if (left.segmentIndex !== right.segmentIndex) { - return left.segmentIndex - right.segmentIndex; - } - if (left.matchOffset !== right.matchOffset) { - return left.matchOffset - right.matchOffset; - } - if (left.depth !== right.depth) { - return left.depth - right.depth; - } - return left.absolutePath.localeCompare(right.absolutePath); -} - -function rankDirectory(input: { - absolutePath: string; - homeRoot: string; - searchLower: string; -}): RankedDirectory { - const relative = normalizeRelativePath(input.homeRoot, input.absolutePath); - const relativeLower = relative.toLowerCase(); - const depth = relative === "." ? 0 : relative.split("/").length; - const searchLower = input.searchLower; - if (!searchLower) { - return { - absolutePath: input.absolutePath, - matchTier: 3, - segmentIndex: NO_SEGMENT_INDEX, - matchOffset: 0, - depth, - }; - } - const segments = relativeLower === "." ? [] : relativeLower.split("/"); - const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower); - const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.startsWith(searchLower), - ); - const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.includes(searchLower), - ); - const matchOffset = relativeLower.indexOf(searchLower); - let matchTier = 4; - let segmentIndex = NO_SEGMENT_INDEX; - - if (exactSegmentIndex >= 0) { - matchTier = 0; - segmentIndex = exactSegmentIndex; - } else if (prefixSegmentIndex >= 0) { - matchTier = 1; - segmentIndex = prefixSegmentIndex; - } else if (partialSegmentIndex >= 0) { - matchTier = 2; - segmentIndex = partialSegmentIndex; - } else if (relativeLower.startsWith(searchLower)) { - matchTier = 3; - } - - return { - absolutePath: input.absolutePath, - matchTier, - segmentIndex, - matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, - depth, - }; -} - -function rankWorkspaceEntry(input: { - absolutePath: string; - kind: WorkspaceSuggestionKind; - workspaceRoot: string; - searchLower: string; -}): RankedWorkspaceEntry { - const relativePath = normalizeRelativePath(input.workspaceRoot, input.absolutePath); - const relativeLower = relativePath.toLowerCase(); - const depth = relativePath === "." ? 0 : relativePath.split("/").length; - const searchLower = input.searchLower; - if (!searchLower) { - return { - relativePath, - kind: input.kind, - matchTier: 3, - segmentIndex: NO_SEGMENT_INDEX, - matchOffset: 0, - fuzzyScore: NO_FUZZY_SCORE, - depth, - }; - } - - const segments = relativeLower === "." ? [] : relativeLower.split("/"); - const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower); - const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.startsWith(searchLower), - ); - const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.includes(searchLower), - ); - const matchOffset = relativeLower.indexOf(searchLower); - const basename = segments.at(-1) ?? ""; - const fuzzyScore = scoreFuzzySubsequence(searchLower, basename); - let matchTier = NO_WORKSPACE_MATCH_TIER; - let segmentIndex = NO_SEGMENT_INDEX; - - if (exactSegmentIndex >= 0) { - matchTier = 0; - segmentIndex = exactSegmentIndex; - } else if (prefixSegmentIndex >= 0) { - matchTier = 1; - segmentIndex = prefixSegmentIndex; - } else if (partialSegmentIndex >= 0) { - matchTier = 2; - segmentIndex = partialSegmentIndex; - } else if (relativeLower.startsWith(searchLower)) { - matchTier = 3; - } else if (fuzzyScore !== null) { - matchTier = 4; - } - - return { - relativePath, - kind: input.kind, - matchTier, - segmentIndex, - matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, - fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, - depth, - }; +function normalizeRelativePath(root: string, absolutePath: string): string { + const relative = path.relative(root, absolutePath); + return relative ? relative.split(path.sep).join("/") : "."; } function scoreFuzzySubsequence(query: string, candidate: string): number | null { - if (!query) { - return 0; - } - let queryIndex = 0; - let firstMatchIndex = -1; - let previousMatchIndex = -1; - let gapScore = 0; - - for ( - let candidateIndex = 0; - candidateIndex < candidate.length && queryIndex < query.length; - candidateIndex += 1 - ) { - if (candidate[candidateIndex] !== query[queryIndex]) { - continue; - } - - if (firstMatchIndex === -1) { - firstMatchIndex = candidateIndex; - } - if (previousMatchIndex >= 0) { - gapScore += candidateIndex - previousMatchIndex - 1; - } - previousMatchIndex = candidateIndex; + let first = -1; + let previous = -1; + let gaps = 0; + for (let index = 0; index < candidate.length && queryIndex < query.length; index += 1) { + if (candidate[index] !== query[queryIndex]) continue; + if (first < 0) first = index; + if (previous >= 0) gaps += index - previous - 1; + previous = index; queryIndex += 1; } - - if (queryIndex !== query.length || firstMatchIndex === -1) { - return null; - } - - return firstMatchIndex + gapScore; + return queryIndex === query.length && first >= 0 ? first + gaps : null; } function findSegmentMatchIndex( segments: string[], predicate: (segment: string) => boolean, ): number { - for (let index = 0; index < segments.length; index += 1) { - const segment = segments[index]; - if (!segment) { - continue; - } - if (predicate(segment)) { - return index; - } - } - return -1; + return segments.findIndex((segment) => predicate(segment)); } -function normalizeRelativePath(homeRoot: string, absolutePath: string): string { - const relative = path.relative(homeRoot, absolutePath); - if (!relative) { - return "."; - } - return relative.split(path.sep).join("/"); -} - -function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { - const typedQuery = query.trim().replace(/\\/g, "/"); - let normalized = typedQuery; - if (!normalized) { - return null; - } - - // Only treat the query as a literal path when the user explicitly roots it - // with ~, ~/, ./, or an absolute path. Bare queries like "faro/main" are - // search terms, not paths. - let isRooted = false; - - if (normalized.startsWith("~")) { - isRooted = true; - normalized = normalized.slice(1); - if (normalized.startsWith("/")) { - normalized = normalized.slice(1); - } - } - - if (path.isAbsolute(normalized)) { - isRooted = true; - const absolute = path.resolve(normalized); - if (!isPathInsideRoot(homeRoot, absolute)) { - return null; - } - normalized = normalizeRelativePath(homeRoot, absolute); - } - - if (normalized.startsWith("./")) { - isRooted = true; - } - normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); - if (!normalized) { - // Treat "~" and "~/" as a request to browse the home root. - if (typedQuery === "~" || typedQuery === "~/") { - return { - isPathQuery: true, - parentPart: "", - searchTerm: "", - }; - } - return null; - } - - const isPathQuery = isRooted && normalized.includes("/"); - if (!isPathQuery) { - return { - isPathQuery: false, - parentPart: "", - searchTerm: normalized, - }; - } - - const slashIndex = normalized.lastIndexOf("/"); - const parentPart = normalized.slice(0, slashIndex); - const searchTerm = normalized.slice(slashIndex + 1); - - return { - isPathQuery: true, - parentPart, - searchTerm, - }; -} - -function normalizeWorkspaceQueryParts(query: string, workspaceRoot: string): QueryParts | null { - let normalized = query.trim().replace(/\\/g, "/"); - - if (path.isAbsolute(normalized)) { - const absolute = path.resolve(normalized); - if (!isPathInsideRoot(workspaceRoot, absolute)) { - return null; - } - normalized = normalizeRelativePath(workspaceRoot, absolute); - } - - normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); - if (!normalized) { - return { - isPathQuery: true, - parentPart: "", - searchTerm: "", - }; - } - - const isPathQuery = normalized.includes("/"); - const slashIndex = normalized.lastIndexOf("/"); - const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; - const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; - - return { - isPathQuery, - parentPart, - searchTerm, - }; -} - -async function resolveDirectory(inputPath: string): Promise { - try { - const resolved = await realpath(path.resolve(inputPath)); - const stats = await stat(resolved); - if (!stats.isDirectory()) { - return null; - } - return resolved; - } catch { - return null; - } -} - -async function listChildDirectories(input: { - directory: string; - homeRoot: string; -}): Promise { - const now = Date.now(); - const cached = directoryListCache.get(input.directory); - if (cached && cached.expiresAt > now) { - return cached.entries; - } - - const dirents = await readdir(input.directory, { withFileTypes: true }).catch( - () => [] as Dirent[], - ); - const candidates = dirents.filter( - (dirent) => - !isHiddenDirectoryName(dirent.name) && - !isIgnoredSuggestionDirectoryName(dirent.name) && - (dirent.isDirectory() || dirent.isSymbolicLink()), - ); - const resolved = await Promise.all( - candidates.map(async (dirent) => { - const candidatePath = path.join(input.directory, dirent.name); - const absolutePath = await resolveDirectoryCandidate({ - candidatePath, - dirent, - homeRoot: input.homeRoot, - }); - return absolutePath ? { name: dirent.name, absolutePath } : null; - }), - ); - const entries: ChildDirectoryEntry[] = resolved.filter( - (entry): entry is ChildDirectoryEntry => entry !== null, - ); - - setDirectoryListCache(input.directory, { - expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, - entries, - }); - - return entries; -} - -async function listWorkspaceChildEntries(input: { - directory: string; - workspaceRoot: string; -}): Promise { - const now = Date.now(); - const cached = workspaceEntryListCache.get(input.directory); - if (cached && cached.expiresAt > now) { - return cached.entries; - } - - const dirents = await readdir(input.directory, { withFileTypes: true }).catch( - () => [] as Dirent[], - ); - const candidates = dirents.filter((dirent) => { - if (isIgnoredSuggestionDirectoryName(dirent.name)) { - return false; - } - if ( - isHiddenDirectoryName(dirent.name) && - !dirent.isFile() && - !isTraversableHiddenWorkspaceDirectoryName(dirent.name) - ) { - return false; - } - // Allowlisted hidden directories remain traversable so file links like - // `.claude/settings.local.json` can resolve, but hidden files (e.g. - // `.DS_Store`) should never be suggested. - if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) { - return false; - } - return true; - }); - - const resolved = await Promise.all( - candidates.map(async (dirent) => { - const candidatePath = path.join(input.directory, dirent.name); - const entry = await resolveWorkspaceCandidate({ - candidatePath, - dirent, - workspaceRoot: input.workspaceRoot, - }); - return entry - ? { name: dirent.name, absolutePath: entry.absolutePath, kind: entry.kind } - : null; - }), - ); - const entries: ChildWorkspaceEntry[] = resolved.filter( - (entry): entry is ChildWorkspaceEntry => entry !== null, - ); - - setWorkspaceEntryListCache(input.directory, { - expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, - entries, - }); - - return entries; -} - -async function resolveDirectoryCandidate(input: { - candidatePath: string; - dirent: Dirent; - homeRoot: string; -}): Promise { - if (input.dirent.isDirectory()) { - const resolved = path.resolve(input.candidatePath); - return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null; - } - - const resolved = await resolveDirectory(input.candidatePath); - if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) { - return null; - } - return resolved; -} - -async function resolveWorkspaceCandidate(input: { - candidatePath: string; - dirent: Dirent; - workspaceRoot: string; -}): Promise<{ absolutePath: string; kind: WorkspaceSuggestionKind } | null> { - if (input.dirent.isDirectory()) { - const resolved = path.resolve(input.candidatePath); - if (!isPathInsideRoot(input.workspaceRoot, resolved)) { - return null; - } - return { absolutePath: resolved, kind: "directory" }; - } - - if (input.dirent.isFile()) { - const resolved = path.resolve(input.candidatePath); - if (!isPathInsideRoot(input.workspaceRoot, resolved)) { - return null; - } - return { absolutePath: resolved, kind: "file" }; - } - - if (!input.dirent.isSymbolicLink()) { - return null; - } - - try { - const resolved = await realpath(input.candidatePath); - if (!isPathInsideRoot(input.workspaceRoot, resolved)) { - return null; - } - const stats = await stat(resolved); - if (stats.isDirectory()) { - return { absolutePath: resolved, kind: "directory" }; - } - if (stats.isFile()) { - return { absolutePath: resolved, kind: "file" }; - } - return null; - } catch { - return null; - } -} - -function isHiddenDirectoryName(name: string): boolean { - return name.startsWith("."); -} - -function isHiddenWorkspaceSuggestion(entry: ChildWorkspaceEntry): boolean { - return isHiddenDirectoryName(entry.name); -} - -function isIgnoredSuggestionDirectoryName(name: string): boolean { - return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name); -} - -function isTraversableHiddenWorkspaceDirectoryName(name: string): boolean { - return TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES.has(name); -} - -function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void { - directoryListCache.set(cacheKey, entry); - pruneDirectoryListCache(); -} - -function setWorkspaceEntryListCache(cacheKey: string, entry: WorkspaceEntryListCacheEntry): void { - workspaceEntryListCache.set(cacheKey, entry); - pruneWorkspaceEntryListCache(); -} - -function pruneDirectoryListCache(): void { - if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - return; - } - - const now = Date.now(); - for (const [cacheKey, entry] of directoryListCache) { - if (entry.expiresAt <= now) { - directoryListCache.delete(cacheKey); - } - } - - while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - const oldestKey = directoryListCache.keys().next().value; - if (!oldestKey) { - return; - } - directoryListCache.delete(oldestKey); - } -} - -function pruneWorkspaceEntryListCache(): void { - if (workspaceEntryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - return; - } - - const now = Date.now(); - for (const [cacheKey, entry] of workspaceEntryListCache) { - if (entry.expiresAt <= now) { - workspaceEntryListCache.delete(cacheKey); - } - } - - while (workspaceEntryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - const oldestKey = workspaceEntryListCache.keys().next().value; - if (!oldestKey) { - return; - } - workspaceEntryListCache.delete(oldestKey); - } +function sameEntry(left: DirectorySuggestionEntry, right: DirectorySuggestionEntry): boolean { + return left.path === right.path && left.kind === right.kind; }