diff --git a/package-lock.json b/package-lock.json index 54907173..2fac85a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8408,6 +8408,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" @@ -8431,6 +8432,12 @@ "node": ">=18" } }, + "packages/cli/node_modules/fuzzysort": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz", + "integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==", + "license": "MIT" + }, "packages/electron-app": { "name": "@codenomad/electron-app", "version": "0.1.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index cd4f9c53..25e3987f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/api-types.ts b/packages/cli/src/api-types.ts index bf74e7dc..29eb0962 100644 --- a/packages/cli/src/api-types.ts +++ b/packages/cli/src/api-types.ts @@ -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 diff --git a/packages/cli/src/filesystem/__tests__/search-cache.test.ts b/packages/cli/src/filesystem/__tests__/search-cache.test.ts new file mode 100644 index 00000000..f1facf9d --- /dev/null +++ b/packages/cli/src/filesystem/__tests__/search-cache.test.ts @@ -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(), + } +} diff --git a/packages/cli/src/filesystem/search-cache.ts b/packages/cli/src/filesystem/search-cache.ts new file mode 100644 index 00000000..5568204b --- /dev/null +++ b/packages/cli/src/filesystem/search-cache.ts @@ -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() + +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) +} diff --git a/packages/cli/src/filesystem/search.ts b/packages/cli/src/filesystem/search.ts new file mode 100644 index 00000000..77347b05 --- /dev/null +++ b/packages/cli/src/filesystem/search.ts @@ -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(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() +} diff --git a/packages/cli/src/server/routes/workspaces.ts b/packages/cli/src/server/routes/workspaces.ts index a2364e1f..effc85ba 100644 --- a/packages/cli/src/server/routes/workspaces.ts +++ b/packages/cli/src/server/routes/workspaces.ts @@ -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) diff --git a/packages/cli/src/workspaces/manager.ts b/packages/cli/src/workspaces/manager.ts index 80f98262..69186f9e 100644 --- a/packages/cli/src/workspaces/manager.ts +++ b/packages/cli/src/workspaces/manager.ts @@ -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 { + 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 }) } diff --git a/packages/ui/src/components/file-picker.tsx b/packages/ui/src/components/file-picker.tsx deleted file mode 100644 index b004dc6c..00000000 --- a/packages/ui/src/components/file-picker.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" - -import type { OpencodeClient } from "@opencode-ai/sdk/client" -import { cliApi } from "../lib/api-client" - -interface FileItem { - path: string - added?: number - removed?: number - isGitFile: boolean -} - -interface FilePickerProps { - open: boolean - onSelect: (path: string) => void - onNavigate: (direction: "up" | "down") => void - onClose: () => void - instanceClient: OpencodeClient - searchQuery: string - textareaRef?: HTMLTextAreaElement - workspaceId: string -} - -const FilePicker: Component = (props) => { - const [files, setFiles] = createSignal([]) - const [selectedIndex, setSelectedIndex] = createSignal(0) - const [loading, setLoading] = createSignal(false) - const [allFiles, setAllFiles] = createSignal([]) - const [isInitialized, setIsInitialized] = createSignal(false) - - let containerRef: HTMLDivElement | undefined - let scrollContainerRef: HTMLDivElement | undefined - - async function fetchFiles(searchQuery: string) { - console.log(`[FilePicker] Fetching files for query: "${searchQuery}"`) - setLoading(true) - - try { - if (allFiles().length === 0) { - console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`) - const entries = await cliApi.listWorkspaceFiles(props.workspaceId) - const scannedFiles: FileItem[] = entries.map((entry) => ({ - path: entry.path, - isGitFile: false, - })) - setAllFiles(scannedFiles) - console.log(`[FilePicker] Found ${scannedFiles.length} files`) - } - - const filteredFiles = searchQuery.trim() - ? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase())) - : allFiles() - - console.log(`[FilePicker] Showing ${filteredFiles.length} files`) - setFiles(filteredFiles) - setSelectedIndex(0) - - setTimeout(() => { - if (scrollContainerRef) { - scrollContainerRef.scrollTop = 0 - } - }, 0) - } catch (error) { - console.error(`[FilePicker] Failed to fetch files:`, error) - setFiles([]) - } finally { - setLoading(false) - } - } - - let lastQuery = "" - - createEffect(() => { - console.log( - `[FilePicker] Effect triggered - open: ${props.open}, query: "${props.searchQuery}", isInitialized: ${isInitialized()}`, - ) - - if (props.open && !isInitialized()) { - setIsInitialized(true) - console.log("[FilePicker] First open - fetching files") - fetchFiles(props.searchQuery) - lastQuery = props.searchQuery - return - } - - if (props.open && props.searchQuery !== lastQuery) { - console.log(`[FilePicker] Query changed from "${lastQuery}" to "${props.searchQuery}"`) - lastQuery = props.searchQuery - fetchFiles(props.searchQuery) - } - }) - - function scrollToSelected() { - setTimeout(() => { - const selectedElement = containerRef?.querySelector('[data-file-selected="true"]') - if (selectedElement) { - selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" }) - } - }, 0) - } - - function handleSelect(path: string) { - props.onSelect(path) - } - - function handleNavigateUp() { - setSelectedIndex((prev) => { - const next = Math.max(prev - 1, 0) - scrollToSelected() - return next - }) - } - - function handleNavigateDown() { - setSelectedIndex((prev) => { - const next = Math.min(prev + 1, files().length - 1) - scrollToSelected() - return next - }) - } - - createEffect(() => { - if (!props.open) return - const listener = (e: KeyboardEvent) => { - if (!props.open) return - const fileList = files() - - if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - props.onClose() - return - } - - if (fileList.length === 0) return - - if (e.key === "ArrowDown") { - e.preventDefault() - e.stopPropagation() - handleNavigateDown() - props.onNavigate("down") - } else if (e.key === "ArrowUp") { - e.preventDefault() - e.stopPropagation() - handleNavigateUp() - props.onNavigate("up") - } else if (e.key === "Enter") { - e.preventDefault() - e.stopPropagation() - if (fileList[selectedIndex()]) { - handleSelect(fileList[selectedIndex()].path) - } - } - } - - document.addEventListener("keydown", listener, true) - onCleanup(() => document.removeEventListener("keydown", listener, true)) - }) - - return ( - - - - - - - ) -} - -export default FilePicker diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 6331c2d2..bffd3404 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -573,7 +573,14 @@ export default function PromptInput(props: PromptInputProps) { setAtPosition(null) } - function handlePickerSelect(item: { type: "agent"; agent: Agent } | { type: "file"; file: { path: string; isGitFile: boolean } }) { + function handlePickerSelect( + item: + | { type: "agent"; agent: Agent } + | { + type: "file" + file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } + }, + ) { if (item.type === "agent") { const agentName = item.agent.name const existingAttachments = attachments() @@ -605,25 +612,26 @@ export default function PromptInput(props: PromptInputProps) { }, 0) } } else if (item.type === "file") { - const path = item.file.path - const isFolder = path.endsWith("/") - const filename = path.split("/").pop() || path + const displayPath = item.file.path + const relativePath = item.file.relativePath ?? displayPath + const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") if (isFolder) { const currentPrompt = prompt() const pos = atPosition() const cursorPos = textareaRef?.selectionStart || 0 + const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath if (pos !== null) { const before = currentPrompt.substring(0, pos + 1) const after = currentPrompt.substring(cursorPos) - const newPrompt = before + path + after + const newPrompt = before + folderMention + after setPrompt(newPrompt) - setSearchQuery(path) + setSearchQuery(folderMention) setTimeout(() => { if (textareaRef) { - const newCursorPos = pos + 1 + path.length + const newCursorPos = pos + 1 + folderMention.length textareaRef.setSelectionRange(newCursorPos, newCursorPos) } }, 0) @@ -632,11 +640,20 @@ export default function PromptInput(props: PromptInputProps) { return } + const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath + const pathSegments = normalizedPath.split("/") + const filename = (() => { + const candidate = pathSegments[pathSegments.length - 1] || normalizedPath + return candidate === "." ? "/" : candidate + })() + const existingAttachments = attachments() - const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedPath, + ) if (!alreadyAttached) { - const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder) + const attachment = createFileAttachment(normalizedPath, filename, "text/plain", undefined, props.instanceFolder) addAttachment(props.instanceId, props.sessionId, attachment) } diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 7b79f1d7..611dfa3a 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -3,11 +3,65 @@ import type { Agent } from "../types/session" import type { OpencodeClient } from "@opencode-ai/sdk/client" import { cliApi } from "../lib/api-client" +const SEARCH_RESULT_LIMIT = 100 +const SEARCH_DEBOUNCE_MS = 200 + +type LoadingState = "idle" | "listing" | "search" + interface FileItem { path: string + relativePath: string added?: number removed?: number isGitFile: boolean + isDirectory: boolean +} + +function formatDisplayPath(basePath: string, isDirectory: boolean) { + if (!isDirectory) { + return basePath + } + const trimmed = basePath.replace(/\/+$/, "") + return trimmed.length > 0 ? `${trimmed}/` : "./" +} + +function isRootPath(value: string) { + return value === "." || value === "./" || value === "/" +} + +function normalizeRelativePath(basePath: string, isDirectory: boolean) { + if (isRootPath(basePath)) { + return "." + } + const withoutPrefix = basePath.replace(/^\.\/+/, "") + if (isDirectory) { + const trimmed = withoutPrefix.replace(/\/+$/, "") + return trimmed || "." + } + return withoutPrefix +} + +function normalizeQuery(rawQuery: string) { + const trimmed = rawQuery.trim() + if (!trimmed) { + return "" + } + if (trimmed === "." || trimmed === "./") { + return "" + } + return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "") +} + +function mapEntriesToFileItems(entries: { path: string; type: "file" | "directory" }[]): FileItem[] { + return entries.map((entry) => { + const isDirectory = entry.type === "directory" + return { + path: formatDisplayPath(entry.path, isDirectory), + relativePath: normalizeRelativePath(entry.path, isDirectory), + isDirectory, + isGitFile: false, + } + }) } type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem } @@ -27,62 +81,182 @@ const UnifiedPicker: Component = (props) => { const [files, setFiles] = createSignal([]) const [filteredAgents, setFilteredAgents] = createSignal([]) const [selectedIndex, setSelectedIndex] = createSignal(0) - const [loading, setLoading] = createSignal(false) + const [loadingState, setLoadingState] = createSignal("idle") const [allFiles, setAllFiles] = createSignal([]) const [isInitialized, setIsInitialized] = createSignal(false) - + const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal(null) + let containerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined - - async function fetchFiles(searchQuery: string) { - setLoading(true) + let lastWorkspaceId: string | null = null + let lastQuery = "" + let inflightWorkspaceId: string | null = null + let inflightSnapshotPromise: Promise | null = null + let activeRequestId = 0 + let queryDebounceTimer: ReturnType | null = null + + function resetScrollPosition() { + setTimeout(() => { + if (scrollContainerRef) { + scrollContainerRef.scrollTop = 0 + } + }, 0) + } + + function applyFileResults(nextFiles: FileItem[]) { + setFiles(nextFiles) + setSelectedIndex(0) + resetScrollPosition() + } + + async function fetchWorkspaceSnapshot(workspaceId: string): Promise { + if (inflightWorkspaceId === workspaceId && inflightSnapshotPromise) { + return inflightSnapshotPromise + } + + inflightWorkspaceId = workspaceId + inflightSnapshotPromise = cliApi + .listWorkspaceFiles(workspaceId) + .then((entries) => mapEntriesToFileItems(entries)) + .then((snapshot) => { + setAllFiles(snapshot) + setCachedWorkspaceId(workspaceId) + return snapshot + }) + .catch((error) => { + console.error(`[UnifiedPicker] Failed to load workspace files:`, error) + setAllFiles([]) + setCachedWorkspaceId(null) + throw error + }) + .finally(() => { + if (inflightWorkspaceId === workspaceId) { + inflightWorkspaceId = null + inflightSnapshotPromise = null + } + }) + + return inflightSnapshotPromise + } + + async function ensureWorkspaceSnapshot(workspaceId: string) { + if (cachedWorkspaceId() === workspaceId && allFiles().length > 0) { + return allFiles() + } + + return fetchWorkspaceSnapshot(workspaceId) + } + + async function loadFilesForQuery(rawQuery: string, workspaceId: string) { + const normalizedQuery = normalizeQuery(rawQuery) + const requestId = ++activeRequestId + const hasCachedSnapshot = + !normalizedQuery && cachedWorkspaceId() === workspaceId && allFiles().length > 0 + const mode: LoadingState = normalizedQuery ? "search" : hasCachedSnapshot ? "idle" : "listing" + if (mode !== "idle") { + setLoadingState(mode) + } else { + setLoadingState("idle") + } try { - if (allFiles().length === 0) { - const entries = await cliApi.listWorkspaceFiles(props.workspaceId) - const scannedFiles: FileItem[] = entries.map((entry) => ({ - path: entry.path, - isGitFile: false, - })) - setAllFiles(scannedFiles) + if (!normalizedQuery) { + const snapshot = await ensureWorkspaceSnapshot(workspaceId) + if (!shouldApplyResults(requestId, workspaceId)) { + return + } + applyFileResults(snapshot) + return } - const filteredFiles = searchQuery.trim() - ? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase())) - : allFiles() - - setFiles(filteredFiles) - setSelectedIndex(0) - - setTimeout(() => { - if (scrollContainerRef) { - scrollContainerRef.scrollTop = 0 - } - }, 0) + const results = await cliApi.searchWorkspaceFiles(workspaceId, normalizedQuery, { + limit: SEARCH_RESULT_LIMIT, + }) + if (!shouldApplyResults(requestId, workspaceId)) { + return + } + applyFileResults(mapEntriesToFileItems(results)) } catch (error) { - console.error(`[UnifiedPicker] Failed to fetch files:`, error) - setFiles([]) + if (workspaceId === props.workspaceId) { + console.error(`[UnifiedPicker] Failed to fetch files:`, error) + if (shouldApplyResults(requestId, workspaceId)) { + applyFileResults([]) + } + } } finally { - setLoading(false) + if (shouldFinalizeRequest(requestId, workspaceId)) { + setLoadingState("idle") + } } } - let lastQuery = "" + function clearQueryDebounce() { + if (queryDebounceTimer) { + clearTimeout(queryDebounceTimer) + queryDebounceTimer = null + } + } + + function scheduleLoadFilesForQuery(rawQuery: string, workspaceId: string, immediate = false) { + clearQueryDebounce() + const normalizedQuery = normalizeQuery(rawQuery) + const shouldDebounce = !immediate && normalizedQuery.length > 0 + if (shouldDebounce) { + queryDebounceTimer = setTimeout(() => { + queryDebounceTimer = null + void loadFilesForQuery(rawQuery, workspaceId) + }, SEARCH_DEBOUNCE_MS) + return + } + void loadFilesForQuery(rawQuery, workspaceId) + } + + function shouldApplyResults(requestId: number, workspaceId: string) { + return props.open && workspaceId === props.workspaceId && requestId === activeRequestId + } + + + function shouldFinalizeRequest(requestId: number, workspaceId: string) { + return workspaceId === props.workspaceId && requestId === activeRequestId + } + + function resetPickerState() { + clearQueryDebounce() + setFiles([]) + setAllFiles([]) + setCachedWorkspaceId(null) + setIsInitialized(false) + setSelectedIndex(0) + setLoadingState("idle") + lastWorkspaceId = null + lastQuery = "" + activeRequestId = 0 + } + + onCleanup(() => { + clearQueryDebounce() + }) createEffect(() => { - if (props.open && !isInitialized()) { - setIsInitialized(true) - fetchFiles(props.searchQuery) - lastQuery = props.searchQuery + if (!props.open) { + resetPickerState() return } - if (props.open && props.searchQuery !== lastQuery) { + const workspaceChanged = lastWorkspaceId !== props.workspaceId + const queryChanged = lastQuery !== props.searchQuery + + if (!isInitialized() || workspaceChanged || queryChanged) { + setIsInitialized(true) + lastWorkspaceId = props.workspaceId lastQuery = props.searchQuery - fetchFiles(props.searchQuery) + const shouldSkipDebounce = workspaceChanged || normalizeQuery(props.searchQuery).length === 0 + scheduleLoadFilesForQuery(props.searchQuery, props.workspaceId, shouldSkipDebounce) } }) + + createEffect(() => { if (!props.open) return @@ -154,8 +328,19 @@ const UnifiedPicker: Component = (props) => { const agentCount = () => filteredAgents().length const fileCount = () => files().length - + const isLoading = () => loadingState() !== "idle" + const loadingMessage = () => { + if (loadingState() === "search") { + return "Searching..." + } + if (loadingState() === "listing") { + return "Loading workspace..." + } + return "" + } + return ( +
= (props) => { @@ -236,8 +421,10 @@ const UnifiedPicker: Component = (props) => {
{(file) => { - const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path) - const isFolder = file.path.endsWith("/") + const itemIndex = allItems().findIndex( + (item) => item.type === "file" && item.file.relativePath === file.relativePath, + ) + const isFolder = file.isDirectory return (
(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`) }, + searchWorkspaceFiles( + id: string, + query: string, + opts?: { limit?: number; type?: "file" | "directory" | "all" }, + ): Promise { + const trimmed = query.trim() + if (!trimmed) { + return Promise.resolve([]) + } + const params = new URLSearchParams({ q: trimmed }) + if (opts?.limit) { + params.set("limit", String(opts.limit)) + } + if (opts?.type) { + params.set("type", opts.type) + } + return request( + `/api/workspaces/${encodeURIComponent(id)}/files/search?${params.toString()}`, + ) + }, readWorkspaceFile(id: string, relativePath: string): Promise { const params = new URLSearchParams({ path: relativePath }) return request( `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, ) }, + fetchConfig(): Promise { return request("/api/config/app") },