add cached fuzzy file search and debounce unified picker
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -8408,6 +8408,7 @@
|
|||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -8431,6 +8432,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"packages/electron-app": {
|
||||||
"name": "@codenomad/electron-app",
|
"name": "@codenomad/electron-app",
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export interface WorkspaceFileResponse {
|
|||||||
contents: string
|
contents: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||||
|
|
||||||
export interface InstanceData {
|
export interface InstanceData {
|
||||||
messageHistory: string[]
|
messageHistory: string[]
|
||||||
}
|
}
|
||||||
@@ -112,6 +114,7 @@ export interface BinaryRecord {
|
|||||||
path: string
|
path: string
|
||||||
label: string
|
label: string
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
lastValidatedAt?: string
|
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(),
|
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) {
|
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/workspaces", async () => {
|
app.get("/api/workspaces", async () => {
|
||||||
return deps.workspaceManager.list()
|
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<{
|
app.get<{
|
||||||
Params: { id: string }
|
Params: { id: string }
|
||||||
Querystring: { path?: string }
|
Querystring: { path?: string }
|
||||||
@@ -70,6 +96,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||||
if (error instanceof Error && error.message === "Workspace not found") {
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
reply.code(404)
|
reply.code(404)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { EventBus } from "../events/bus"
|
|||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
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 { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||||
import { WorkspaceRuntime } from "./runtime"
|
import { WorkspaceRuntime } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
@@ -43,6 +45,11 @@ export class WorkspaceManager {
|
|||||||
return browser.list(relativePath)
|
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 {
|
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||||
const workspace = this.requireWorkspace(workspaceId)
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
@@ -55,14 +62,17 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
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")
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
||||||
|
|
||||||
const proxyPath = `/workspaces/${id}/instance`
|
const proxyPath = `/workspaces/${id}/instance`
|
||||||
|
|
||||||
|
|
||||||
const descriptor: WorkspaceRecord = {
|
const descriptor: WorkspaceRecord = {
|
||||||
id,
|
id,
|
||||||
path: workspacePath,
|
path: workspacePath,
|
||||||
@@ -120,6 +130,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.workspaces.delete(id)
|
this.workspaces.delete(id)
|
||||||
|
clearWorkspaceSearchCache(workspace.path)
|
||||||
if (!wasRunning) {
|
if (!wasRunning) {
|
||||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<FilePickerProps> = (props) => {
|
|
||||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
|
||||||
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<FileItem>((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 (
|
|
||||||
<Show when={props.open}>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="dropdown-surface bottom-full left-0 mb-2 max-w-2xl rounded-lg"
|
|
||||||
style={{ "z-index": 100 }}
|
|
||||||
>
|
|
||||||
<div ref={scrollContainerRef} class="dropdown-content max-h-96">
|
|
||||||
<Show
|
|
||||||
when={!loading() && isInitialized()}
|
|
||||||
fallback={
|
|
||||||
<div class="dropdown-loading">
|
|
||||||
<div class="spinner inline-block h-4 w-4 mr-2"></div>
|
|
||||||
<span>Loading files...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={files().length > 0}
|
|
||||||
fallback={<div class="dropdown-empty">No matching files</div>}
|
|
||||||
>
|
|
||||||
<For each={files()}>
|
|
||||||
{(file, index) => (
|
|
||||||
<div
|
|
||||||
data-file-selected={index() === selectedIndex()}
|
|
||||||
class={`dropdown-item border-b px-4 py-2 font-mono text-sm ${
|
|
||||||
index() === selectedIndex() ? "dropdown-item-highlight" : ""
|
|
||||||
}`}
|
|
||||||
style="border-color: var(--border-muted)"
|
|
||||||
onClick={() => handleSelect(file.path)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index())}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span>{file.path}</span>
|
|
||||||
<Show when={file.isGitFile && (file.added || file.removed)}>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Show when={file.added}>
|
|
||||||
<span class="dropdown-diff-added">+{file.added}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={file.removed}>
|
|
||||||
<span class="dropdown-diff-removed">-{file.removed}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dropdown-footer p-2">
|
|
||||||
<div class="flex items-center justify-between px-2">
|
|
||||||
<span>↑↓ Navigate • Enter Select • Esc Close</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FilePicker
|
|
||||||
@@ -573,7 +573,14 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setAtPosition(null)
|
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") {
|
if (item.type === "agent") {
|
||||||
const agentName = item.agent.name
|
const agentName = item.agent.name
|
||||||
const existingAttachments = attachments()
|
const existingAttachments = attachments()
|
||||||
@@ -605,25 +612,26 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
} else if (item.type === "file") {
|
} else if (item.type === "file") {
|
||||||
const path = item.file.path
|
const displayPath = item.file.path
|
||||||
const isFolder = path.endsWith("/")
|
const relativePath = item.file.relativePath ?? displayPath
|
||||||
const filename = path.split("/").pop() || path
|
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||||
|
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
const currentPrompt = prompt()
|
const currentPrompt = prompt()
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
const cursorPos = textareaRef?.selectionStart || 0
|
const cursorPos = textareaRef?.selectionStart || 0
|
||||||
|
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
|
||||||
|
|
||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
const before = currentPrompt.substring(0, pos + 1)
|
const before = currentPrompt.substring(0, pos + 1)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const newPrompt = before + path + after
|
const newPrompt = before + folderMention + after
|
||||||
setPrompt(newPrompt)
|
setPrompt(newPrompt)
|
||||||
setSearchQuery(path)
|
setSearchQuery(folderMention)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (textareaRef) {
|
if (textareaRef) {
|
||||||
const newCursorPos = pos + 1 + path.length
|
const newCursorPos = pos + 1 + folderMention.length
|
||||||
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -632,11 +640,20 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
return
|
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 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) {
|
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)
|
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,65 @@ import type { Agent } from "../types/session"
|
|||||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||||
import { cliApi } from "../lib/api-client"
|
import { cliApi } from "../lib/api-client"
|
||||||
|
|
||||||
|
const SEARCH_RESULT_LIMIT = 100
|
||||||
|
const SEARCH_DEBOUNCE_MS = 200
|
||||||
|
|
||||||
|
type LoadingState = "idle" | "listing" | "search"
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
path: string
|
path: string
|
||||||
|
relativePath: string
|
||||||
added?: number
|
added?: number
|
||||||
removed?: number
|
removed?: number
|
||||||
isGitFile: boolean
|
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 }
|
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
|
||||||
@@ -27,62 +81,182 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||||
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loadingState, setLoadingState] = createSignal<LoadingState>("idle")
|
||||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||||
|
const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let lastWorkspaceId: string | null = null
|
||||||
|
let lastQuery = ""
|
||||||
|
let inflightWorkspaceId: string | null = null
|
||||||
|
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
|
||||||
|
let activeRequestId = 0
|
||||||
|
let queryDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
async function fetchFiles(searchQuery: string) {
|
function resetScrollPosition() {
|
||||||
setLoading(true)
|
setTimeout(() => {
|
||||||
|
if (scrollContainerRef) {
|
||||||
|
scrollContainerRef.scrollTop = 0
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFileResults(nextFiles: FileItem[]) {
|
||||||
|
setFiles(nextFiles)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
resetScrollPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWorkspaceSnapshot(workspaceId: string): Promise<FileItem[]> {
|
||||||
|
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 {
|
try {
|
||||||
if (allFiles().length === 0) {
|
if (!normalizedQuery) {
|
||||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
const snapshot = await ensureWorkspaceSnapshot(workspaceId)
|
||||||
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
if (!shouldApplyResults(requestId, workspaceId)) {
|
||||||
path: entry.path,
|
return
|
||||||
isGitFile: false,
|
}
|
||||||
}))
|
applyFileResults(snapshot)
|
||||||
setAllFiles(scannedFiles)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredFiles = searchQuery.trim()
|
const results = await cliApi.searchWorkspaceFiles(workspaceId, normalizedQuery, {
|
||||||
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
|
limit: SEARCH_RESULT_LIMIT,
|
||||||
: allFiles()
|
})
|
||||||
|
if (!shouldApplyResults(requestId, workspaceId)) {
|
||||||
setFiles(filteredFiles)
|
return
|
||||||
setSelectedIndex(0)
|
}
|
||||||
|
applyFileResults(mapEntriesToFileItems(results))
|
||||||
setTimeout(() => {
|
|
||||||
if (scrollContainerRef) {
|
|
||||||
scrollContainerRef.scrollTop = 0
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
if (workspaceId === props.workspaceId) {
|
||||||
setFiles([])
|
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||||
|
if (shouldApplyResults(requestId, workspaceId)) {
|
||||||
|
applyFileResults([])
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} 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(() => {
|
createEffect(() => {
|
||||||
if (props.open && !isInitialized()) {
|
if (!props.open) {
|
||||||
setIsInitialized(true)
|
resetPickerState()
|
||||||
fetchFiles(props.searchQuery)
|
|
||||||
lastQuery = props.searchQuery
|
|
||||||
return
|
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
|
lastQuery = props.searchQuery
|
||||||
fetchFiles(props.searchQuery)
|
const shouldSkipDebounce = workspaceChanged || normalizeQuery(props.searchQuery).length === 0
|
||||||
|
scheduleLoadFilesForQuery(props.searchQuery, props.workspaceId, shouldSkipDebounce)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
|
|
||||||
@@ -154,8 +328,19 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
const agentCount = () => filteredAgents().length
|
const agentCount = () => filteredAgents().length
|
||||||
const fileCount = () => files().length
|
const fileCount = () => files().length
|
||||||
|
const isLoading = () => loadingState() !== "idle"
|
||||||
|
const loadingMessage = () => {
|
||||||
|
if (loadingState() === "search") {
|
||||||
|
return "Searching..."
|
||||||
|
}
|
||||||
|
if (loadingState() === "listing") {
|
||||||
|
return "Loading workspace..."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -164,8 +349,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
<div class="dropdown-header">
|
<div class="dropdown-header">
|
||||||
<div class="dropdown-header-title">
|
<div class="dropdown-header-title">
|
||||||
Select Agent or File
|
Select Agent or File
|
||||||
<Show when={loading()}>
|
<Show when={isLoading()}>
|
||||||
<span class="ml-2">Loading...</span>
|
<span class="ml-2">{loadingMessage()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,8 +421,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path)
|
const itemIndex = allItems().findIndex(
|
||||||
const isFolder = file.path.endsWith("/")
|
(item) => item.type === "file" && item.file.relativePath === file.relativePath,
|
||||||
|
)
|
||||||
|
const isFolder = file.isDirectory
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`dropdown-item py-1.5 ${
|
class={`dropdown-item py-1.5 ${
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import type {
|
|||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
WorkspaceFileResponse,
|
WorkspaceFileResponse,
|
||||||
|
WorkspaceFileSearchResponse,
|
||||||
|
|
||||||
WorkspaceLogEntry,
|
WorkspaceLogEntry,
|
||||||
WorkspaceEventPayload,
|
WorkspaceEventPayload,
|
||||||
WorkspaceEventType,
|
WorkspaceEventType,
|
||||||
@@ -99,12 +100,33 @@ export const cliApi = {
|
|||||||
const params = new URLSearchParams({ path: relativePath })
|
const params = new URLSearchParams({ path: relativePath })
|
||||||
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
|
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
|
||||||
},
|
},
|
||||||
|
searchWorkspaceFiles(
|
||||||
|
id: string,
|
||||||
|
query: string,
|
||||||
|
opts?: { limit?: number; type?: "file" | "directory" | "all" },
|
||||||
|
): Promise<WorkspaceFileSearchResponse> {
|
||||||
|
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<WorkspaceFileSearchResponse>(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/files/search?${params.toString()}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
|
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
|
||||||
const params = new URLSearchParams({ path: relativePath })
|
const params = new URLSearchParams({ path: relativePath })
|
||||||
return request<WorkspaceFileResponse>(
|
return request<WorkspaceFileResponse>(
|
||||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchConfig(): Promise<AppConfig> {
|
fetchConfig(): Promise<AppConfig> {
|
||||||
return request<AppConfig>("/api/config/app")
|
return request<AppConfig>("/api/config/app")
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user