Download packages/app/src/components/dialog-select-directory-v2.tsx from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/components/dialog-select-directory-v2.tsx
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/components/dialog-select-directory-v2.tsx
-
curl -L -o dialog-select-directory-v2.tsx https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/components/dialog-select-directory-v2.tsx
14.6 kB
| 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> | |
| ) | |
| } | |