add cached fuzzy file search and debounce unified picker
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -103,6 +103,8 @@ export interface WorkspaceFileResponse {
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
}
|
||||
@@ -112,6 +114,7 @@ export interface BinaryRecord {
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
|
||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||
isDefault: boolean
|
||||
lastValidatedAt?: string
|
||||
|
||||
61
packages/cli/src/filesystem/__tests__/search-cache.test.ts
Normal file
61
packages/cli/src/filesystem/__tests__/search-cache.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it } from "node:test"
|
||||
import type { FileSystemEntry } from "../../api-types"
|
||||
import {
|
||||
clearWorkspaceSearchCache,
|
||||
getWorkspaceCandidates,
|
||||
refreshWorkspaceCandidates,
|
||||
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
} from "../search-cache"
|
||||
|
||||
describe("workspace search cache", () => {
|
||||
beforeEach(() => {
|
||||
clearWorkspaceSearchCache()
|
||||
})
|
||||
|
||||
it("expires cached candidates after the TTL", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
const startTime = 1_000
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
||||
|
||||
const beforeExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||
)
|
||||
assert.ok(beforeExpiry)
|
||||
assert.equal(beforeExpiry.length, 1)
|
||||
assert.equal(beforeExpiry[0].name, "file-a")
|
||||
|
||||
const afterExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||
)
|
||||
assert.equal(afterExpiry, undefined)
|
||||
})
|
||||
|
||||
it("replaces cached entries when manually refreshed", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
||||
const initial = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(initial)
|
||||
assert.equal(initial[0].name, "file-a")
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
||||
const refreshed = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(refreshed)
|
||||
assert.equal(refreshed[0].name, "file-b")
|
||||
})
|
||||
})
|
||||
|
||||
function createEntry(name: string): FileSystemEntry {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
absolutePath: `/tmp/${name}`,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
66
packages/cli/src/filesystem/search-cache.ts
Normal file
66
packages/cli/src/filesystem/search-cache.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "path"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
|
||||
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||
|
||||
interface WorkspaceCandidateCacheEntry {
|
||||
expiresAt: number
|
||||
candidates: FileSystemEntry[]
|
||||
}
|
||||
|
||||
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
||||
|
||||
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
|
||||
const key = normalizeKey(rootDir)
|
||||
const cached = workspaceCandidateCache.get(key)
|
||||
if (!cached) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (cached.expiresAt <= now) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return cloneEntries(cached.candidates)
|
||||
}
|
||||
|
||||
export function refreshWorkspaceCandidates(
|
||||
rootDir: 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, {
|
||||
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
candidates: storedCandidates,
|
||||
})
|
||||
|
||||
return cloneEntries(storedCandidates)
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||
if (typeof rootDir === "undefined") {
|
||||
workspaceCandidateCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeKey(rootDir)
|
||||
workspaceCandidateCache.delete(key)
|
||||
}
|
||||
|
||||
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||
return entries.map((entry) => ({ ...entry }))
|
||||
}
|
||||
|
||||
function normalizeKey(rootDir: string) {
|
||||
return path.resolve(rootDir)
|
||||
}
|
||||
184
packages/cli/src/filesystem/search.ts
Normal file
184
packages/cli/src/filesystem/search.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 200
|
||||
const MAX_CANDIDATES = 8000
|
||||
const IGNORED_DIRECTORIES = new Set(
|
||||
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
|
||||
(name) => name.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
||||
export type WorkspaceFileSearchType = "all" | "file" | "directory"
|
||||
|
||||
export interface WorkspaceFileSearchOptions {
|
||||
limit?: number
|
||||
type?: WorkspaceFileSearchType
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
interface CandidateEntry {
|
||||
entry: FileSystemEntry
|
||||
key: string
|
||||
}
|
||||
|
||||
export function searchWorkspaceFiles(
|
||||
rootDir: string,
|
||||
query: string,
|
||||
options: WorkspaceFileSearchOptions = {},
|
||||
): FileSystemEntry[] {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
throw new Error("Search query is required")
|
||||
}
|
||||
|
||||
const normalizedRoot = path.resolve(rootDir)
|
||||
const limit = normalizeLimit(options.limit)
|
||||
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||
const refreshRequested = options.refresh === true
|
||||
|
||||
let entries: FileSystemEntry[] | undefined
|
||||
|
||||
try {
|
||||
if (!refreshRequested) {
|
||||
entries = getWorkspaceCandidates(normalizedRoot)
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = buildCandidateEntries(entries, typeFilter)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
|
||||
key: "key",
|
||||
limit,
|
||||
})
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return matches.map((match) => match.obj.entry)
|
||||
}
|
||||
|
||||
|
||||
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||
const queue: string[] = [""]
|
||||
const entries: FileSystemEntry[] = []
|
||||
|
||||
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
||||
const relativeDir = queue.pop() || ""
|
||||
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||
|
||||
let dirents: fs.Dirent[]
|
||||
try {
|
||||
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const entryName = dirent.name
|
||||
const lowerName = entryName.toLowerCase()
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
|
||||
const absolutePath = path.join(absoluteDir, entryName)
|
||||
|
||||
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absolutePath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = stats.isDirectory()
|
||||
|
||||
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
if (entries.length < MAX_CANDIDATES) {
|
||||
queue.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||
const normalizedPath = normalizeRelativeEntryPath(relativePath)
|
||||
const entry: FileSystemEntry = {
|
||||
name: entryName,
|
||||
path: normalizedPath,
|
||||
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
|
||||
type: entryType,
|
||||
size: entryType === "file" ? stats.size : undefined,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= MAX_CANDIDATES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
|
||||
const filtered: CandidateEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (!shouldInclude(entry.type, filter)) {
|
||||
continue
|
||||
}
|
||||
filtered.push({ entry, key: buildSearchKey(entry) })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number) {
|
||||
if (!limit || Number.isNaN(limit)) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
|
||||
return clamped
|
||||
}
|
||||
|
||||
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
|
||||
return filter === "all" || entryType === filter
|
||||
}
|
||||
|
||||
function normalizeRelativeEntryPath(relativePath: string): string {
|
||||
if (!relativePath) {
|
||||
return "."
|
||||
}
|
||||
let normalized = relativePath.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized || "."
|
||||
}
|
||||
|
||||
function buildSearchKey(entry: FileSystemEntry) {
|
||||
return entry.path.toLowerCase()
|
||||
}
|
||||
@@ -19,6 +19,16 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
type: z.enum(["all", "file", "directory"]).optional(),
|
||||
refresh: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === undefined ? undefined : value === "true")),
|
||||
})
|
||||
|
||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/workspaces", async () => {
|
||||
return deps.workspaceManager.list()
|
||||
@@ -57,6 +67,22 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
|
||||
}>("/api/workspaces/:id/files/search", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
|
||||
limit: query.limit,
|
||||
type: query.type,
|
||||
refresh: query.refresh,
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
@@ -70,6 +96,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof Error && error.message === "Workspace not found") {
|
||||
reply.code(404)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
@@ -43,6 +45,11 @@ export class WorkspaceManager {
|
||||
return browser.list(relativePath)
|
||||
}
|
||||
|
||||
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
return searchWorkspaceFiles(workspace.path, query, options)
|
||||
}
|
||||
|
||||
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
@@ -55,14 +62,17 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
id,
|
||||
path: workspacePath,
|
||||
@@ -120,6 +130,7 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user