add cached fuzzy file search and debounce unified picker
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UnifiedPickerProps> = (props) => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [loadingState, setLoadingState] = createSignal<LoadingState>("idle")
|
||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
|
||||
const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal<string | null>(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<FileItem[]> | null = null
|
||||
let activeRequestId = 0
|
||||
let queryDebounceTimer: ReturnType<typeof setTimeout> | 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<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 {
|
||||
if (allFiles().length === 0) {
|
||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||
const scannedFiles: FileItem[] = entries.map<FileItem>((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<UnifiedPickerProps> = (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 (
|
||||
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -164,8 +349,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-header-title">
|
||||
Select Agent or File
|
||||
<Show when={loading()}>
|
||||
<span class="ml-2">Loading...</span>
|
||||
<Show when={isLoading()}>
|
||||
<span class="ml-2">{loadingMessage()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,8 +421,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
</div>
|
||||
<For each={files()}>
|
||||
{(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 (
|
||||
<div
|
||||
class={`dropdown-item py-1.5 ${
|
||||
|
||||
@@ -8,10 +8,11 @@ import type {
|
||||
FileSystemListResponse,
|
||||
InstanceData,
|
||||
ServerMeta,
|
||||
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
WorkspaceFileSearchResponse,
|
||||
|
||||
WorkspaceLogEntry,
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
@@ -99,12 +100,33 @@ export const cliApi = {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
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> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<WorkspaceFileResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app")
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user