fix(files): search large workspaces beyond candidate cap (#623)

## Summary
- count only fuzzy-qualified paths toward the 8000 workspace search
candidate cap
- scope the bounded cache to the current query and entry type
- stop recursive directory-link cycles without hiding legitimate alias
paths
- expand the @file picker to its parent width and horizontally scroll
long paths

## Validation
- 6 focused filesystem search/cache tests pass
- server typecheck passes
- workspace UI/Electron typecheck passes
- UI production build passes
- gatekeeper round 3: zero findings
- full server suite: 241 pass, 3 skip; the unrelated existing Windows
git-worktrees fixture fails to parse its mocked branch output

Closes #503
This commit is contained in:
Pascal André 2026-07-26 18:51:56 +02:00 committed by GitHub
parent b1bb8a723a
commit 9d47f73ea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 122 additions and 28 deletions

View file

@ -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 {

View file

@ -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 })
}
})

View file

@ -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<string, WorkspaceCandidateCacheEntry>()
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[] {

View file

@ -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<string> }> = [
{ 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<string>
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
}

View file

@ -443,7 +443,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<Show when={props.open}>
<div
ref={containerRef}
class="dropdown-surface bottom-full left-0 mb-1 max-w-md"
class="dropdown-surface bottom-full left-0 mb-1"
>
<div class="dropdown-header">
<div class="dropdown-header-title">
@ -456,7 +456,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</div>
</div>
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
<div ref={scrollContainerRef} class="dropdown-content max-h-60 overflow-x-auto">
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
<div class="dropdown-empty">{t("unifiedPicker.empty")}</div>
</Show>
@ -593,7 +593,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => props.onSelect({ type: "file", file }, "click")}
>
<div class="flex items-center gap-2 text-sm">
<div class="flex min-w-full w-max items-center gap-2 text-sm">
<Show
when={isFolder}
fallback={
@ -616,7 +616,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
/>
</svg>
</Show>
<span class="truncate">{file.path}</span>
<span class="whitespace-nowrap">{file.path}</span>
</div>
</div>
)