386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import "@pierre/trees/web-components"
|
|
import { FileTree } from "@pierre/trees"
|
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
import { useGlobal } from "@/context/global"
|
|
import { useLanguage } from "@/context/language"
|
|
import { ServerConnection } from "@/context/server"
|
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
|
import {
|
|
absoluteTreePath,
|
|
activeTreeNavigation,
|
|
advanceTreePreload,
|
|
nextSuggestionIndex,
|
|
nextTreeScrollTop,
|
|
pickerFileSearchQuery,
|
|
pickerAbsoluteInput,
|
|
pickerMode,
|
|
preloadTreeDirectories,
|
|
cleanPickerInput,
|
|
createPriorityTaskQueue,
|
|
createDirectorySearch,
|
|
currentPickerSuggestions,
|
|
displayPickerPath,
|
|
pickerParent,
|
|
pickerRoot,
|
|
} from "./directory-picker-domain"
|
|
import "./dialog-select-directory-v2.css"
|
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
|
import { getFilename } from "@opencode-ai/core/util/path"
|
|
|
|
interface DialogSelectDirectoryV2Props {
|
|
title?: string
|
|
multiple?: boolean
|
|
onSelect: (result: string | string[] | null) => void
|
|
server: ServerConnection.Any
|
|
mode?: "directory" | "file"
|
|
start?: string
|
|
}
|
|
|
|
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|
const global = useGlobal()
|
|
const { sync, sdk } = global.ensureServerCtx(props.server)
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
const policy = pickerMode(props.mode ?? "directory", props.start)
|
|
const action = {
|
|
file: language.t("dialog.directory.action.selectFile"),
|
|
directory: language.t("dialog.directory.action.selectFolder"),
|
|
}
|
|
const [root, setRoot] = createSignal("")
|
|
const [input, setInput] = createSignal("")
|
|
const [selected, setSelected] = createSignal("")
|
|
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
|
|
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
|
|
const [loading, setLoading] = createSignal(false)
|
|
const [error, setError] = createSignal(false)
|
|
const [rootValid, setRootValid] = createSignal(false)
|
|
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
|
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
|
|
const advanced = new Set<string>()
|
|
let tree: FileTree | undefined
|
|
let container: HTMLDivElement | undefined
|
|
let pathArea: HTMLDivElement | undefined
|
|
let navigation = 0
|
|
|
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
const [fallbackPath] = createResource(
|
|
() => (missingBase() ? true : undefined),
|
|
async (): Promise<Path | undefined> => {
|
|
if ((await sdk.protocol) !== "v1") return
|
|
return sdk.client.path
|
|
.get()
|
|
.then((result) => result.data)
|
|
.catch(() => undefined)
|
|
},
|
|
{ initialValue: undefined },
|
|
)
|
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
|
const start = createMemo(
|
|
() =>
|
|
props.start ||
|
|
sync.data.path.home ||
|
|
sync.data.path.directory ||
|
|
fallbackPath()?.home ||
|
|
fallbackPath()?.directory,
|
|
)
|
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
|
const [suggestions] = createResource(input, async (value) => {
|
|
const cleaned = cleanPickerInput(value)
|
|
const typed = cleaned.replace(/\/+$/, "")
|
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
|
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
|
const base = pickerRoot(cleaned) || root() || start()
|
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
|
const files = await sdk.api.file
|
|
.find({
|
|
location: { directory: base },
|
|
query: pickerFileSearchQuery(base, value, home()),
|
|
type: "file",
|
|
limit: 20,
|
|
})
|
|
.then((result) => result.data)
|
|
.catch(() => [])
|
|
const results = [
|
|
...directories,
|
|
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
|
]
|
|
return {
|
|
query: value,
|
|
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
|
}
|
|
})
|
|
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
|
|
|
async function load(path: string, generation: number, eager = false) {
|
|
const key = path.replace(/\/+$/, "")
|
|
setError(false)
|
|
const absolute = absoluteTreePath(root(), key)
|
|
const existing = listings.get(key)
|
|
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
|
const request =
|
|
existing ??
|
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
|
return sdk.api.file
|
|
.list({ location: { directory: absolute } })
|
|
.then((result) =>
|
|
result.data.map((entry) => ({
|
|
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
|
type: entry.type,
|
|
})),
|
|
)
|
|
.catch(() => undefined)
|
|
})
|
|
listings.set(key, request)
|
|
const nodes = await request
|
|
if (!activeTreeNavigation(generation, navigation)) return false
|
|
if (!nodes) {
|
|
listings.delete(key)
|
|
if (!key) setError(true)
|
|
return false
|
|
}
|
|
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
|
if (!eager && advanceTreePreload(advanced, key)) {
|
|
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function navigate(path: string) {
|
|
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
|
if (!value) return
|
|
const token = ++navigation
|
|
setLoading(true)
|
|
setRootValid(false)
|
|
setSelected("")
|
|
setSuggestionsOpen(false)
|
|
setActiveSuggestion(-1)
|
|
setRoot(value)
|
|
setInput(displayPickerPath(value, value, home()))
|
|
listings.clear()
|
|
advanced.clear()
|
|
tree?.resetPaths([])
|
|
const valid = await load("", token)
|
|
if (!activeTreeNavigation(token, navigation)) return
|
|
setRootValid(valid)
|
|
setLoading(false)
|
|
}
|
|
|
|
function complete() {
|
|
const items = currentSuggestions()
|
|
const match = items[activeSuggestion()] ?? items[0]
|
|
if (!match) return
|
|
const value = displayPickerPath(match.absolute, input(), home())
|
|
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
|
if (match.type === "file") {
|
|
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
|
setSuggestionsOpen(false)
|
|
setActiveSuggestion(-1)
|
|
}
|
|
}
|
|
|
|
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
|
if (suggestion.type === "directory") {
|
|
void navigate(suggestion.absolute)
|
|
return
|
|
}
|
|
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
|
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
|
setSuggestionsOpen(false)
|
|
setActiveSuggestion(-1)
|
|
}
|
|
|
|
function moveSuggestion(delta: -1 | 1) {
|
|
setSuggestionsOpen(true)
|
|
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
|
|
}
|
|
|
|
function activeSuggestionValue() {
|
|
const items = currentSuggestions()
|
|
return items[activeSuggestion()] ?? items[0]
|
|
}
|
|
|
|
const keyActions: Partial<Record<string, () => void>> = {
|
|
ArrowDown: () => moveSuggestion(1),
|
|
ArrowUp: () => moveSuggestion(-1),
|
|
Enter: () => {
|
|
const suggestion = activeSuggestionValue()
|
|
if (suggestion) chooseSuggestion(suggestion)
|
|
if (!suggestion) void navigate(input())
|
|
},
|
|
Tab: complete,
|
|
}
|
|
|
|
function handleInputKey(event: KeyboardEvent) {
|
|
const action = keyActions[event.key]
|
|
if (!action) return
|
|
if (event.key === "Tab" && event.shiftKey) return
|
|
event.preventDefault()
|
|
action()
|
|
}
|
|
|
|
function resolve() {
|
|
const path = policy.result(root(), selected(), rootValid())
|
|
if (!path) return
|
|
props.onSelect(props.multiple ? [path] : path)
|
|
dialog.close()
|
|
}
|
|
|
|
onMount(() => {
|
|
const closeSuggestions = (event: PointerEvent) => {
|
|
if (pathArea?.contains(event.target as Node)) return
|
|
setSuggestionsOpen(false)
|
|
setActiveSuggestion(-1)
|
|
}
|
|
document.addEventListener("pointerdown", closeSuggestions)
|
|
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
|
tree = new FileTree({
|
|
paths: [],
|
|
flattenEmptyDirectories: false,
|
|
initialExpansion: "closed",
|
|
stickyFolders: true,
|
|
unsafeCSS: `
|
|
button[data-type="item"] {
|
|
background: transparent !important;
|
|
box-shadow: none !important;
|
|
}
|
|
button[data-type="item"]:hover {
|
|
background: var(--v2-overlay-simple-overlay-hover) !important;
|
|
}
|
|
button[data-type="item"]:focus-visible {
|
|
outline: none !important;
|
|
box-shadow: none !important;
|
|
}
|
|
[data-file-tree-virtualized-scroll] {
|
|
overscroll-behavior: contain;
|
|
scrollbar-width: thin;
|
|
}
|
|
`,
|
|
onExpansionChange(change) {
|
|
if (change.expanded) void load(change.path, navigation)
|
|
},
|
|
onSelectionChange(paths) {
|
|
const path = paths.at(-1)
|
|
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
|
},
|
|
})
|
|
if (!container) return
|
|
tree.render({ containerWrapper: container })
|
|
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
|
|
})
|
|
|
|
createEffect(() => {
|
|
const path = start()
|
|
if (!path || root()) return
|
|
void navigate(path)
|
|
})
|
|
|
|
onCleanup(() => tree?.cleanUp())
|
|
|
|
return (
|
|
<Dialog size="large" class="directory-picker-v2">
|
|
<DialogHeader>
|
|
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
|
|
</DialogHeader>
|
|
<DividerV2 />
|
|
<DialogBody class="directory-picker-v2-body pt-4!">
|
|
<div class="directory-picker-v2-path" ref={pathArea}>
|
|
<TextInputV2
|
|
value={input()}
|
|
autofocus
|
|
autocomplete="off"
|
|
spellcheck={false}
|
|
class="!w-full"
|
|
onInput={(event) => {
|
|
setInput(cleanPickerInput(event.currentTarget.value))
|
|
setSelected("")
|
|
setSuggestionsOpen(true)
|
|
setActiveSuggestion(-1)
|
|
}}
|
|
role="combobox"
|
|
aria-autocomplete="list"
|
|
aria-expanded={suggestionsOpen()}
|
|
aria-controls="directory-picker-v2-suggestions"
|
|
aria-activedescendant={
|
|
activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
|
|
}
|
|
onKeyDown={handleInputKey}
|
|
/>
|
|
<div class="directory-picker-v2-actions">
|
|
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
|
|
~
|
|
</ButtonV2>
|
|
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
|
|
{language.t("dialog.directory.root")}
|
|
</ButtonV2>
|
|
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
|
|
{language.t("dialog.directory.parent")}
|
|
</ButtonV2>
|
|
</div>
|
|
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
|
|
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
|
|
<For each={currentSuggestions()}>
|
|
{(suggestion, index) => (
|
|
<button
|
|
id={`directory-picker-v2-suggestion-${index()}`}
|
|
data-directory-path={suggestion.absolute}
|
|
role="option"
|
|
aria-selected={index() === activeSuggestion()}
|
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
|
onPointerMove={() => setActiveSuggestion(index())}
|
|
onClick={() => chooseSuggestion(suggestion)}
|
|
>
|
|
{displayPickerPath(suggestion.absolute, input(), home())}
|
|
{suggestion.type === "directory" ? "/" : ""}
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
<div
|
|
class="directory-picker-v2-browser"
|
|
ref={container}
|
|
onWheel={(event) => {
|
|
const scroller = tree
|
|
?.getFileTreeContainer()
|
|
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
|
if (!scroller) return
|
|
const next = nextTreeScrollTop(
|
|
scroller.scrollTop,
|
|
event.deltaY,
|
|
scroller.scrollHeight,
|
|
scroller.clientHeight,
|
|
)
|
|
if (next === scroller.scrollTop) return
|
|
event.preventDefault()
|
|
scroller.scrollTop = next
|
|
scroller.dispatchEvent(new Event("scroll"))
|
|
}}
|
|
>
|
|
<Show when={loading()}>
|
|
<div class="directory-picker-v2-state">{language.t("common.loading")}</div>
|
|
</Show>
|
|
<Show when={!loading() && error()}>
|
|
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
|
|
</Show>
|
|
</div>
|
|
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
|
</DialogBody>
|
|
<DialogFooter>
|
|
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
|
{language.t("common.cancel")}
|
|
</ButtonV2>
|
|
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
|
|
{action[policy.action]}
|
|
</ButtonV2>
|
|
</DialogFooter>
|
|
</Dialog>
|
|
)
|
|
}
|