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 | undefined>>() const loads = createPriorityTaskQueue | undefined>(3) const advanced = new Set() 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 => { 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 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 ( {props.title ?? language.t("command.project.open")}
{ 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} />
void navigate(home())}> ~ void navigate(pickerRoot(root()) || root())}> {language.t("dialog.directory.root")} void navigate(pickerParent(root()))}> {language.t("dialog.directory.parent")}
0}>
{(suggestion, index) => ( )}
{ const scroller = tree ?.getFileTreeContainer() ?.shadowRoot?.querySelector("[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")) }} >
{language.t("common.loading")}
{language.t("dialog.directory.readError")}
{policy.result(root(), selected(), rootValid())}
dialog.close()}> {language.t("common.cancel")} {action[policy.action]}
) }