Split workspace into electron and ui packages
This commit is contained in:
291
packages/ui/src/components/unified-picker.tsx
Normal file
291
packages/ui/src/components/unified-picker.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
added?: number
|
||||
removed?: number
|
||||
isGitFile: boolean
|
||||
}
|
||||
|
||||
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
|
||||
|
||||
interface UnifiedPickerProps {
|
||||
open: boolean
|
||||
onSelect: (item: PickerItem) => void
|
||||
onClose: () => void
|
||||
agents: Agent[]
|
||||
instanceClient: OpencodeClient | null
|
||||
searchQuery: string
|
||||
textareaRef?: HTMLTextAreaElement
|
||||
workspaceFolder: string
|
||||
}
|
||||
|
||||
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 [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
|
||||
async function fetchFiles(searchQuery: string) {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
||||
path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
}
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
let lastQuery = ""
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open && !isInitialized()) {
|
||||
setIsInitialized(true)
|
||||
fetchFiles(props.searchQuery)
|
||||
lastQuery = props.searchQuery
|
||||
return
|
||||
}
|
||||
|
||||
if (props.open && props.searchQuery !== lastQuery) {
|
||||
lastQuery = props.searchQuery
|
||||
fetchFiles(props.searchQuery)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
|
||||
const query = props.searchQuery.toLowerCase()
|
||||
const filtered = query
|
||||
? props.agents.filter(
|
||||
(agent) =>
|
||||
agent.name.toLowerCase().includes(query) ||
|
||||
(agent.description && agent.description.toLowerCase().includes(query)),
|
||||
)
|
||||
: props.agents
|
||||
|
||||
setFilteredAgents(filtered)
|
||||
})
|
||||
|
||||
const allItems = (): PickerItem[] => {
|
||||
const items: PickerItem[] = []
|
||||
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||
files().forEach((file) => items.push({ type: "file", file }))
|
||||
return items
|
||||
}
|
||||
|
||||
function scrollToSelected() {
|
||||
setTimeout(() => {
|
||||
const selectedElement = containerRef?.querySelector('[data-picker-selected="true"]')
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function handleSelect(item: PickerItem) {
|
||||
props.onSelect(item)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!props.open) return
|
||||
|
||||
const items = allItems()
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
handleSelect(selected)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const agentCount = () => filteredAgents().length
|
||||
const fileCount = () => files().length
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="dropdown-surface bottom-full left-0 mb-1 max-w-md"
|
||||
>
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-header-title">
|
||||
Select Agent or File
|
||||
<Show when={loading()}>
|
||||
<span class="ml-2">Loading...</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
|
||||
<Show when={agentCount() === 0 && fileCount() === 0}>
|
||||
<div class="dropdown-empty">No results found</div>
|
||||
</Show>
|
||||
|
||||
<Show when={agentCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
AGENTS
|
||||
</div>
|
||||
<For each={filteredAgents()}>
|
||||
{(agent) => {
|
||||
const itemIndex = allItems().findIndex(
|
||||
(item) => item.type === "agent" && item.agent.name === agent.name,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class={`dropdown-item ${
|
||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
data-picker-selected={itemIndex === selectedIndex()}
|
||||
onClick={() => handleSelect({ type: "agent", agent })}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="dropdown-icon-accent h-4 w-4 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{agent.name}</span>
|
||||
<Show when={agent.mode === "subagent"}>
|
||||
<span class="dropdown-badge">
|
||||
subagent
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={agent.description}>
|
||||
<div class="mt-0.5 text-xs" style="color: var(--text-muted)">
|
||||
{agent.description && agent.description.length > 80
|
||||
? agent.description.slice(0, 80) + "..."
|
||||
: agent.description}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={fileCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
FILES
|
||||
</div>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path)
|
||||
const isFolder = file.path.endsWith("/")
|
||||
return (
|
||||
<div
|
||||
class={`dropdown-item py-1.5 ${
|
||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
data-picker-selected={itemIndex === selectedIndex()}
|
||||
onClick={() => handleSelect({ type: "file", file })}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Show
|
||||
when={isFolder}
|
||||
fallback={
|
||||
<svg class="dropdown-icon h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg class="dropdown-icon-accent h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<span class="truncate">{file.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<div>
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Enter</span> select •{" "}
|
||||
<span class="font-medium">Esc</span> close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnifiedPicker
|
||||
Reference in New Issue
Block a user