Add CLI server and move UI to HTTP API
This commit is contained in:
297
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
297
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
|
||||
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
let cachedEntries: FileSystemEntry[] | null = null
|
||||
let entriesPromise: Promise<FileSystemEntry[]> | null = null
|
||||
|
||||
async function loadFileSystemEntries(): Promise<FileSystemEntry[]> {
|
||||
if (cachedEntries) {
|
||||
return cachedEntries
|
||||
}
|
||||
if (entriesPromise) {
|
||||
return entriesPromise
|
||||
}
|
||||
entriesPromise = cliApi
|
||||
.listFileSystem(".")
|
||||
.then((entries) => {
|
||||
cachedEntries = entries.slice().sort((a, b) => a.path.localeCompare(b.path))
|
||||
entriesPromise = null
|
||||
return cachedEntries
|
||||
})
|
||||
.catch((error) => {
|
||||
entriesPromise = null
|
||||
throw error
|
||||
})
|
||||
return entriesPromise
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
if (!root) {
|
||||
return relativePath
|
||||
}
|
||||
if (!relativePath || relativePath === "." || relativePath === "./") {
|
||||
return root
|
||||
}
|
||||
const separator = root.includes("\\") ? "\\" : "/"
|
||||
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
|
||||
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function formatRootLabel(root: string): string {
|
||||
if (!root) return "Workspace Root"
|
||||
const parts = root.split(/[/\\]/).filter(Boolean)
|
||||
return parts[parts.length - 1] || root || "Workspace Root"
|
||||
}
|
||||
|
||||
interface FileSystemBrowserDialogProps {
|
||||
open: boolean
|
||||
mode: "directories" | "files"
|
||||
title: string
|
||||
description?: string
|
||||
onSelect: (absolutePath: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
|
||||
let searchInputRef: HTMLInputElement | undefined
|
||||
|
||||
async function refreshEntries() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [items, meta] = await Promise.all([loadFileSystemEntries(), getServerMeta()])
|
||||
setEntries(items)
|
||||
setRootPath(meta.workspaceRoot)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = createMemo(() => {
|
||||
const query = searchQuery().trim().toLowerCase()
|
||||
const mode = props.mode
|
||||
const root = rootPath()
|
||||
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
|
||||
|
||||
const baseEntries = mode === "directories" && root
|
||||
? [
|
||||
{
|
||||
name: formatRootLabel(root),
|
||||
path: ".",
|
||||
type: "directory" as const,
|
||||
},
|
||||
...matchesType,
|
||||
]
|
||||
: matchesType
|
||||
|
||||
if (!query) {
|
||||
return baseEntries
|
||||
}
|
||||
|
||||
return baseEntries.filter((entry) => {
|
||||
const absolute = resolveAbsolutePath(root, entry.path)
|
||||
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
|
||||
|
||||
createEffect(() => {
|
||||
const list = visibleEntries()
|
||||
if (list.length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
if (selectedIndex() >= list.length) {
|
||||
setSelectedIndex(list.length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) {
|
||||
return
|
||||
}
|
||||
setSearchQuery("")
|
||||
setSelectedIndex(0)
|
||||
void refreshEntries()
|
||||
setTimeout(() => searchInputRef?.focus(), 50)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!props.open) return
|
||||
const results = visibleEntries()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
const entry = results[selectedIndex()]
|
||||
if (entry) {
|
||||
handleEntrySelect(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
||||
<div class="modal-surface max-h-full w-full max-w-3xl overflow-hidden rounded-xl bg-surface p-0" role="dialog" aria-modal="true">
|
||||
<div class="panel flex flex-col">
|
||||
<div class="panel-header flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="panel-title">{props.title}</h3>
|
||||
<p class="panel-subtitle">
|
||||
{props.description || "Search for a path under the configured workspace root."}
|
||||
</p>
|
||||
<Show when={rootPath()}>
|
||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
<X class="w-4 h-4" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
|
||||
<div class="selector-input-group">
|
||||
<div class="flex items-center gap-2 px-3 text-muted">
|
||||
<Search class="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
ref={(el) => {
|
||||
searchInputRef = el
|
||||
}}
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
|
||||
class="selector-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
|
||||
<Show
|
||||
when={!loading() && !error()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center py-6 text-sm text-secondary">
|
||||
<Show
|
||||
when={loading()}
|
||||
fallback={<span class="text-red-500">{error()}</span>}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
<span>Loading filesystem…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={visibleEntries().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||
<p>No matches.</p>
|
||||
<Show when={searchQuery().trim().length === 0}>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||
Retry
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={visibleEntries()}>
|
||||
{(entry, index) => (
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item flex items-center gap-3 text-left"
|
||||
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
onClick={() => handleEntrySelect(entry)}
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
|
||||
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
||||
<FolderIcon class="w-4 h-4" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
|
||||
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileSystemBrowserDialog
|
||||
Reference in New Issue
Block a user