mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-14 19:03:27 +00:00
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:
parent
b1bb8a723a
commit
9d47f73ea7
5 changed files with 122 additions and 28 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
63
packages/server/src/filesystem/__tests__/search.test.ts
Normal file
63
packages/server/src/filesystem/__tests__/search.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue