diff --git a/packages/server/src/filesystem/__tests__/search-cache.test.ts b/packages/server/src/filesystem/__tests__/search-cache.test.ts index 1823c5f8..8000cf48 100644 --- a/packages/server/src/filesystem/__tests__/search-cache.test.ts +++ b/packages/server/src/filesystem/__tests__/search-cache.test.ts @@ -17,10 +17,11 @@ describe("workspace search cache", () => { const workspacePath = "/tmp/workspace" const startTime = 1_000 - refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime) + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], startTime) const beforeExpiry = getWorkspaceCandidates( workspacePath, + "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1, ) assert.ok(beforeExpiry) @@ -29,6 +30,7 @@ describe("workspace search cache", () => { const afterExpiry = getWorkspaceCandidates( workspacePath, + "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1, ) assert.equal(afterExpiry, undefined) @@ -37,16 +39,32 @@ describe("workspace search cache", () => { it("replaces cached entries when manually refreshed", () => { const workspacePath = "/tmp/workspace" - refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000) - const initial = getWorkspaceCandidates(workspacePath, 5_001) + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000) + const initial = getWorkspaceCandidates(workspacePath, "query-a", 5_001) assert.ok(initial) assert.equal(initial[0].name, "file-a") - refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000) - const refreshed = getWorkspaceCandidates(workspacePath, 6_001) + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-b")], 6_000) + const refreshed = getWorkspaceCandidates(workspacePath, "query-a", 6_001) assert.ok(refreshed) assert.equal(refreshed[0].name, "file-b") }) + + it("does not reuse candidates across query scopes", () => { + const workspacePath = "/tmp/workspace" + + refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000) + assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001)?.[0].name, "file-a") + assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined) + + refreshWorkspaceCandidates(workspacePath, "query-b", () => [createEntry("file-b")], 5_000) + assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined) + assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001)?.[0].name, "file-b") + + clearWorkspaceSearchCache(workspacePath) + assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined) + assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined) + }) }) function createEntry(name: string): FileSystemEntry { diff --git a/packages/server/src/filesystem/__tests__/search.test.ts b/packages/server/src/filesystem/__tests__/search.test.ts new file mode 100644 index 00000000..63fae4a7 --- /dev/null +++ b/packages/server/src/filesystem/__tests__/search.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { after, test } from "node:test" +import { searchWorkspaceFiles } from "../search" +import { getWorkspaceCandidates } from "../search-cache" + +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-")) + +after(() => fs.rmSync(workspace, { recursive: true, force: true })) + +test("finds a matching file after more than 8000 non-matching entries", () => { + fs.mkdirSync(path.join(workspace, "a")) + fs.mkdirSync(path.join(workspace, "b")) + + const [targetDirName, fillerDirName] = fs.readdirSync(workspace) + const fillerDir = path.join(workspace, fillerDirName) + const targetDir = path.join(workspace, targetDirName) + + for (let index = 0; index < 8_001; index += 1) { + fs.writeFileSync(path.join(fillerDir, `filler-${index}.txt`), "") + } + fs.writeFileSync(path.join(targetDir, "unique-search-target.txt"), "") + + const results = searchWorkspaceFiles(workspace, "unique-search-target", { + type: "file", + refresh: true, + }) + + assert.equal(results.some((entry) => entry.name === "unique-search-target.txt"), true) + + searchWorkspaceFiles(workspace, "filler", { type: "file", refresh: true }) + assert.equal(getWorkspaceCandidates(workspace, "file\0filler")?.length, 8_000) +}) + +test("does not revisit directory links", () => { + const cyclicWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-cycle-")) + try { + fs.symlinkSync(cyclicWorkspace, path.join(cyclicWorkspace, "cycle"), process.platform === "win32" ? "junction" : "dir") + assert.deepEqual(searchWorkspaceFiles(cyclicWorkspace, "not-present", { refresh: true }), []) + } finally { + fs.rmSync(cyclicWorkspace, { recursive: true, force: true }) + } +}) + +test("indexes both real and linked directory paths", () => { + const linkedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-link-")) + try { + const realDirectory = path.join(linkedWorkspace, "b") + fs.mkdirSync(realDirectory) + fs.writeFileSync(path.join(realDirectory, "needle.txt"), "") + fs.symlinkSync(realDirectory, path.join(linkedWorkspace, "a"), process.platform === "win32" ? "junction" : "dir") + + const linkedResults = searchWorkspaceFiles(linkedWorkspace, "a/needle", { type: "file", refresh: true }) + const realResults = searchWorkspaceFiles(linkedWorkspace, "b/needle", { type: "file", refresh: true }) + + assert.equal(linkedResults.some((entry) => entry.path === "a/needle.txt"), true) + assert.equal(realResults.some((entry) => entry.path === "b/needle.txt"), true) + } finally { + fs.rmSync(linkedWorkspace, { recursive: true, force: true }) + } +}) diff --git a/packages/server/src/filesystem/search-cache.ts b/packages/server/src/filesystem/search-cache.ts index 5568204b..17cf4e16 100644 --- a/packages/server/src/filesystem/search-cache.ts +++ b/packages/server/src/filesystem/search-cache.ts @@ -4,16 +4,17 @@ import type { FileSystemEntry } from "../api-types" export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000 interface WorkspaceCandidateCacheEntry { + scope: string expiresAt: number candidates: FileSystemEntry[] } const workspaceCandidateCache = new Map() -export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined { +export function getWorkspaceCandidates(rootDir: string, scope: string, now = Date.now()): FileSystemEntry[] | undefined { const key = normalizeKey(rootDir) const cached = workspaceCandidateCache.get(key) - if (!cached) { + if (!cached || cached.scope !== scope) { return undefined } @@ -27,19 +28,16 @@ export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileS export function refreshWorkspaceCandidates( rootDir: string, + scope: string, builder: () => FileSystemEntry[], now = Date.now(), ): FileSystemEntry[] { const key = normalizeKey(rootDir) const freshCandidates = builder() - if (!freshCandidates || freshCandidates.length === 0) { - workspaceCandidateCache.delete(key) - return [] - } - const storedCandidates = cloneEntries(freshCandidates) workspaceCandidateCache.set(key, { + scope, expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS, candidates: storedCandidates, }) @@ -53,8 +51,7 @@ export function clearWorkspaceSearchCache(rootDir?: string) { return } - const key = normalizeKey(rootDir) - workspaceCandidateCache.delete(key) + workspaceCandidateCache.delete(normalizeKey(rootDir)) } function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] { diff --git a/packages/server/src/filesystem/search.ts b/packages/server/src/filesystem/search.ts index 77347b05..53da6066 100644 --- a/packages/server/src/filesystem/search.ts +++ b/packages/server/src/filesystem/search.ts @@ -40,16 +40,19 @@ export function searchWorkspaceFiles( const limit = normalizeLimit(options.limit) const typeFilter: WorkspaceFileSearchType = options.type ?? "all" const refreshRequested = options.refresh === true + const cacheScope = `${typeFilter}\0${trimmedQuery.toLowerCase()}` let entries: FileSystemEntry[] | undefined try { if (!refreshRequested) { - entries = getWorkspaceCandidates(normalizedRoot) + entries = getWorkspaceCandidates(normalizedRoot, cacheScope) } if (!entries) { - entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot)) + entries = refreshWorkspaceCandidates(normalizedRoot, cacheScope, () => + collectCandidates(normalizedRoot, trimmedQuery, typeFilter), + ) } } catch (error) { clearWorkspaceSearchCache(normalizedRoot) @@ -57,7 +60,6 @@ export function searchWorkspaceFiles( } if (!entries || entries.length === 0) { - clearWorkspaceSearchCache(normalizedRoot) return [] } @@ -80,16 +82,25 @@ export function searchWorkspaceFiles( } -function collectCandidates(rootDir: string): FileSystemEntry[] { - const queue: string[] = [""] +function collectCandidates(rootDir: string, query: string, filter: WorkspaceFileSearchType): FileSystemEntry[] { + const queue: Array<{ relativeDir: string; ancestors: ReadonlySet }> = [ + { relativeDir: "", ancestors: new Set() }, + ] const entries: FileSystemEntry[] = [] while (queue.length > 0 && entries.length < MAX_CANDIDATES) { - const relativeDir = queue.pop() || "" + const queuedDirectory = queue.pop()! + const { relativeDir, ancestors } = queuedDirectory const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir let dirents: fs.Dirent[] + let branchAncestors: ReadonlySet try { + const realDir = normalizeDirectoryIdentity(fs.realpathSync.native(absoluteDir)) + if (ancestors.has(realDir)) { + continue + } + branchAncestors = new Set([...ancestors, realDir]) dirents = fs.readdirSync(absoluteDir, { withFileTypes: true }) } catch { continue @@ -115,9 +126,7 @@ function collectCandidates(rootDir: string): FileSystemEntry[] { const isDirectory = stats.isDirectory() if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) { - if (entries.length < MAX_CANDIDATES) { - queue.push(relativePath) - } + queue.push({ relativeDir: relativePath, ancestors: branchAncestors }) } const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file" @@ -131,8 +140,11 @@ function collectCandidates(rootDir: string): FileSystemEntry[] { modifiedAt: stats.mtime.toISOString(), } - entries.push(entry) + if (!shouldInclude(entry.type, filter) || !fuzzysort.single(query, buildSearchKey(entry))) { + continue + } + entries.push(entry) if (entries.length >= MAX_CANDIDATES) { break } @@ -182,3 +194,7 @@ function normalizeRelativeEntryPath(relativePath: string): string { function buildSearchKey(entry: FileSystemEntry) { return entry.path.toLowerCase() } + +function normalizeDirectoryIdentity(directoryPath: string) { + return process.platform === "win32" ? directoryPath.toLowerCase() : directoryPath +} diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index b06e54a7..6c9e5837 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -443,7 +443,7 @@ const UnifiedPicker: Component = (props) => {