experiment: better web picker using @pierre/tree (#31208)
This commit is contained in:
parent
25cb2be619
commit
88f5b9a90e
39 changed files with 1297 additions and 211 deletions
107
packages/app/src/components/dialog-select-directory-v2.css
Normal file
107
packages/app/src/components/dialog-select-directory-v2.css
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
.directory-picker-v2-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 2px 16px 0;
|
||||
}
|
||||
|
||||
.directory-picker-v2-path {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.directory-picker-v2-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.directory-picker-v2-suggestions {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 36px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
}
|
||||
|
||||
.directory-picker-v2-suggestions button {
|
||||
overflow: hidden;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-picker-v2-suggestions button:hover,
|
||||
.directory-picker-v2-suggestions button[data-active] {
|
||||
color: var(--v2-text-text-base);
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.directory-picker-v2-browser {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
isolation: isolate;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.directory-picker-v2-tree {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--trees-bg-override: transparent;
|
||||
--trees-fg-override: var(--v2-text-text-base);
|
||||
--trees-fg-muted-override: var(--v2-text-text-muted);
|
||||
--trees-bg-muted-override: transparent;
|
||||
--trees-selected-bg-override: transparent;
|
||||
--trees-selected-fg-override: var(--v2-text-text-base);
|
||||
--trees-selected-focused-border-color-override: transparent;
|
||||
--trees-focus-ring-color-override: transparent;
|
||||
--trees-focus-ring-width-override: 0px;
|
||||
--trees-focus-ring-offset-override: 0px;
|
||||
--trees-border-color-override: var(--v2-border-border-base);
|
||||
--trees-font-family-override: var(--font-family-sans);
|
||||
--trees-font-size-override: 12px;
|
||||
--trees-item-height: 24px;
|
||||
--trees-border-radius-override: 4px;
|
||||
}
|
||||
|
||||
.directory-picker-v2-state {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.directory-picker-v2-selection {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
340
packages/app/src/components/dialog-select-directory-v2.tsx
Normal file
340
packages/app/src/components/dialog-select-directory-v2.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import "@pierre/trees/web-components"
|
||||
import { FileTree } from "@pierre/trees"
|
||||
import { Dialog, DialogFooter } 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 {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
advanceTreePreload,
|
||||
nextSuggestionIndex,
|
||||
nextTreeScrollTop,
|
||||
pickerFileSearchQuery,
|
||||
pickerAbsoluteInput,
|
||||
pickerMode,
|
||||
preloadTreeDirectories,
|
||||
cleanPickerInput,
|
||||
createDirectorySearch,
|
||||
currentPickerSuggestions,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
|
||||
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.createServerCtx(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 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),
|
||||
() => 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 typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
if (!typed || 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 files = await sdk.client.find
|
||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), 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, preload = true) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const request =
|
||||
listings.get(key) ??
|
||||
sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.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 (preload && advanceTreePreload(advanced, key)) {
|
||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
||||
}
|
||||
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 title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
||||
<div class="directory-picker-v2-body">
|
||||
<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()}`}
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|||
import { List } from "@opencode-ai/ui/list"
|
||||
import type { ListRef } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
|
|
@ -24,89 +23,9 @@ type Row = {
|
|||
group: "recent" | "folders"
|
||||
}
|
||||
|
||||
function cleanInput(value: string) {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||
}
|
||||
|
||||
function normalizePath(input: string) {
|
||||
const v = input.replaceAll("\\", "/")
|
||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||
return v.replace(/\/+/g, "/")
|
||||
}
|
||||
|
||||
function normalizeDriveRoot(input: string) {
|
||||
const v = normalizePath(input)
|
||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||
return v
|
||||
}
|
||||
|
||||
function trimTrailing(input: string) {
|
||||
const v = normalizeDriveRoot(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
return v.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function joinPath(base: string | undefined, rel: string) {
|
||||
const b = trimTrailing(base ?? "")
|
||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||
if (!b) return r
|
||||
if (!r) return b
|
||||
if (b.endsWith("/")) return b + r
|
||||
return b + "/" + r
|
||||
}
|
||||
|
||||
function rootOf(input: string) {
|
||||
const v = normalizeDriveRoot(input)
|
||||
if (v.startsWith("//")) return "//"
|
||||
if (v.startsWith("/")) return "/"
|
||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||
return ""
|
||||
}
|
||||
|
||||
function parentOf(input: string) {
|
||||
const v = trimTrailing(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||
return v.slice(0, i)
|
||||
}
|
||||
|
||||
function modeOf(input: string) {
|
||||
const raw = normalizeDriveRoot(input.trim())
|
||||
if (!raw) return "relative" as const
|
||||
if (raw.startsWith("~")) return "tilde" as const
|
||||
if (rootOf(raw)) return "absolute" as const
|
||||
return "relative" as const
|
||||
}
|
||||
|
||||
function tildeOf(absolute: string, home: string) {
|
||||
const full = trimTrailing(absolute)
|
||||
if (!home) return ""
|
||||
|
||||
const hn = trimTrailing(home)
|
||||
const lc = full.toLowerCase()
|
||||
const hc = hn.toLowerCase()
|
||||
if (lc === hc) return "~"
|
||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||
return ""
|
||||
}
|
||||
|
||||
function displayPath(path: string, input: string, home: string) {
|
||||
const full = trimTrailing(path)
|
||||
if (modeOf(input) === "absolute") return full
|
||||
return tildeOf(full, home) || full
|
||||
}
|
||||
|
||||
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full, home)
|
||||
const full = displayPickerPath(absolute, "", "")
|
||||
const tilde = displayPickerPath(full, "~", home)
|
||||
const withSlash = (value: string) => {
|
||||
if (!value) return ""
|
||||
if (value.endsWith("/")) return value
|
||||
|
|
@ -128,120 +47,6 @@ function uniqueRows(rows: Row[]) {
|
|||
})
|
||||
}
|
||||
|
||||
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
const scoped = (value: string) => {
|
||||
const base = args.start()
|
||||
if (!base) return
|
||||
|
||||
const raw = normalizeDriveRoot(value)
|
||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||
|
||||
const h = args.home()
|
||||
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||
|
||||
const root = rootOf(raw)
|
||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||
return { directory: trimTrailing(base), path: raw }
|
||||
}
|
||||
|
||||
const dirs = async (dir: string) => {
|
||||
const key = trimTrailing(dir)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((n) => n.type === "directory")
|
||||
.map((n) => ({
|
||||
name: n.name,
|
||||
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
|
||||
})),
|
||||
)
|
||||
|
||||
cache.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
const match = async (dir: string, query: string, limit: number) => {
|
||||
const items = await dirs(dir)
|
||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||
}
|
||||
|
||||
return async (filter: string) => {
|
||||
const token = ++current
|
||||
const active = () => token === current
|
||||
|
||||
const value = cleanInput(filter)
|
||||
const scopedInput = scoped(value)
|
||||
if (!scopedInput) return [] as string[]
|
||||
|
||||
const raw = normalizeDriveRoot(value)
|
||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||
const query = normalizeDriveRoot(scopedInput.path)
|
||||
|
||||
const find = () =>
|
||||
args.sdk.client.find
|
||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
if (!isPath) {
|
||||
const results = await find()
|
||||
if (!active()) return []
|
||||
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||
}
|
||||
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
|
||||
const tail = segments[segments.length - 1] ?? ""
|
||||
|
||||
const cap = 12
|
||||
const branch = 4
|
||||
let paths = [scopedInput.directory]
|
||||
for (const part of head) {
|
||||
if (!active()) return []
|
||||
if (part === "..") {
|
||||
paths = paths.map(parentOf)
|
||||
continue
|
||||
}
|
||||
|
||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||
if (!active()) return []
|
||||
paths = Array.from(new Set(next)).slice(0, cap)
|
||||
if (paths.length === 0) return [] as string[]
|
||||
}
|
||||
|
||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||
if (!active()) return []
|
||||
const deduped = Array.from(new Set(out))
|
||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||
const expand = !raw.endsWith("/")
|
||||
if (!expand || !tail) {
|
||||
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
|
||||
return items.slice(0, 50)
|
||||
}
|
||||
|
||||
const needle = tail.toLowerCase()
|
||||
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
|
||||
const target = exact[0]
|
||||
if (!target) return deduped.slice(0, 50)
|
||||
|
||||
const children = await match(target, "", 30)
|
||||
if (!active()) return []
|
||||
const items = Array.from(new Set([...deduped, ...children]))
|
||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||
|
|
@ -268,10 +73,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||
)
|
||||
|
||||
const directories = useDirectorySearch({
|
||||
const directories = createDirectorySearch({
|
||||
sdk,
|
||||
home,
|
||||
start,
|
||||
base: start,
|
||||
})
|
||||
|
||||
const recentProjects = createMemo(() => {
|
||||
|
|
@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
||||
}
|
||||
ref={(r) => (list = r)}
|
||||
onFilter={(value) => setFilter(cleanInput(value))}
|
||||
onFilter={(value) => setFilter(cleanPickerInput(value))}
|
||||
onKeyEvent={(e, item) => {
|
||||
if (e.key !== "Tab") return
|
||||
if (e.shiftKey) return
|
||||
|
|
@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const value = displayPath(item.absolute, filter(), home())
|
||||
const value = displayPickerPath(item.absolute, filter(), home())
|
||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||
}}
|
||||
onSelect={(path) => {
|
||||
|
|
@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||
}}
|
||||
>
|
||||
{(item) => {
|
||||
const path = displayPath(item.absolute, filter(), home())
|
||||
const path = displayPickerPath(item.absolute, filter(), home())
|
||||
if (path === "~") {
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
|
|
|
|||
|
|
@ -7,18 +7,25 @@ import { List } from "@opencode-ai/ui/list"
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
const DialogSelectFileV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
|
||||
type EntryType = "command" | "file" | "session"
|
||||
|
||||
type Entry = {
|
||||
|
|
@ -264,6 +271,9 @@ function createSessionEntries(props: {
|
|||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
|
|
@ -383,6 +393,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||
state.cleanup?.()
|
||||
})
|
||||
|
||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
|
||||
return (
|
||||
<DialogSelectFileV2
|
||||
server={server.current}
|
||||
mode="file"
|
||||
start={projectDirectory()}
|
||||
title={language.t("session.header.searchFiles")}
|
||||
onSelect={(result) => {
|
||||
if (typeof result !== "string") return
|
||||
open(result)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
||||
<List
|
||||
|
|
|
|||
191
packages/app/src/components/directory-picker-domain.test.ts
Normal file
191
packages/app/src/components/directory-picker-domain.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
advanceTreePreload,
|
||||
nextSuggestionIndex,
|
||||
nextTreeScrollTop,
|
||||
pickerTreeEntries,
|
||||
pickerSearchEntries,
|
||||
pickerFileSearchQuery,
|
||||
pickerMode,
|
||||
preloadTreeDirectories,
|
||||
selectedTreePath,
|
||||
treeEntries,
|
||||
treePathWithin,
|
||||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
pickerAbsoluteInput,
|
||||
} from "./directory-picker-domain"
|
||||
|
||||
test("maps server directory entries into Pierre paths", () => {
|
||||
expect(
|
||||
treeEntries("src/", [
|
||||
{ name: "components", type: "directory" },
|
||||
{ name: "index.ts", type: "file" },
|
||||
]),
|
||||
).toEqual(["src/components/", "src/index.ts"])
|
||||
})
|
||||
|
||||
test("maps Pierre paths back to the selected server root", () => {
|
||||
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
|
||||
expect(absoluteTreePath("C:/", "")).toBe("C:/")
|
||||
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
|
||||
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
|
||||
})
|
||||
|
||||
test("includes files only when the picker selects files", () => {
|
||||
const nodes = [
|
||||
{ name: "components", type: "directory" as const },
|
||||
{ name: "index.ts", type: "file" as const },
|
||||
]
|
||||
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
|
||||
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
|
||||
})
|
||||
|
||||
test("includes files in file autocomplete while preserving directory navigation", () => {
|
||||
const nodes = [
|
||||
{ name: "src", absolute: "/repo/src", type: "directory" as const },
|
||||
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
|
||||
]
|
||||
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
|
||||
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
|
||||
})
|
||||
|
||||
test("centralizes file and directory selection policy", () => {
|
||||
const file = pickerMode("file", "/repo")
|
||||
expect(file.includeFiles).toBeTrue()
|
||||
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
|
||||
expect(file.selection("/repo", "src/")).toBeUndefined()
|
||||
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
|
||||
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
|
||||
expect(file.navigation("/repo/src")).toBe("/repo/src")
|
||||
expect(file.navigation("/tmp")).toBeUndefined()
|
||||
|
||||
const directory = pickerMode("directory")
|
||||
expect(directory.includeFiles).toBeFalse()
|
||||
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
|
||||
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
|
||||
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
|
||||
expect(directory.navigation("/tmp")).toBe("/tmp")
|
||||
expect(directory.result("/repo", "")).toBe("/repo")
|
||||
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
|
||||
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
|
||||
expect(directory.result("/repo", "", false)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("accepts mutations only from the active navigation", () => {
|
||||
expect(activeTreeNavigation(3, 3)).toBeTrue()
|
||||
expect(activeTreeNavigation(2, 3)).toBeFalse()
|
||||
})
|
||||
|
||||
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
|
||||
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
|
||||
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
|
||||
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
|
||||
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe(
|
||||
"src/file.ts",
|
||||
)
|
||||
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
|
||||
expect(treePathWithin("/", "/src")).toBeTrue()
|
||||
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
|
||||
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
|
||||
})
|
||||
|
||||
test("displays paths using the selected server path format", () => {
|
||||
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
|
||||
"C:\\Users\\luke\\repos",
|
||||
)
|
||||
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
|
||||
"C:\\Users\\luke\\repos",
|
||||
)
|
||||
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
|
||||
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
|
||||
})
|
||||
|
||||
test("treats the server share prefix as the UNC root", () => {
|
||||
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
|
||||
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
|
||||
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
|
||||
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
|
||||
})
|
||||
|
||||
test("resolves relative input against the current picker root", () => {
|
||||
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
|
||||
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
|
||||
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
|
||||
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
|
||||
})
|
||||
|
||||
test("exposes autocomplete results only for their source query", () => {
|
||||
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
|
||||
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
|
||||
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
|
||||
})
|
||||
|
||||
test("scopes file autocomplete to the current browser root", () => {
|
||||
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
|
||||
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
||||
})
|
||||
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
client: {
|
||||
find: {
|
||||
files: (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
let base = "/repo"
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||
|
||||
await search("components")
|
||||
base = "/repo/src"
|
||||
await search("components")
|
||||
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
})
|
||||
|
||||
test("identifies the next directory level to preload", () => {
|
||||
expect(
|
||||
preloadTreeDirectories("src/", [
|
||||
{ name: "components", type: "directory" },
|
||||
{ name: "index.ts", type: "file" },
|
||||
{ name: "utils", type: "directory" },
|
||||
]),
|
||||
).toEqual(["src/components/", "src/utils/"])
|
||||
})
|
||||
|
||||
test("advances preloading once for every expanded directory", () => {
|
||||
const advanced = new Set<string>()
|
||||
expect(advanceTreePreload(advanced, "")).toBeTrue()
|
||||
expect(advanceTreePreload(advanced, "")).toBeFalse()
|
||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
||||
})
|
||||
|
||||
test("clamps bridged tree wheel scrolling", () => {
|
||||
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
||||
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
||||
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
|
||||
})
|
||||
|
||||
test("wraps autocomplete keyboard navigation", () => {
|
||||
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
|
||||
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
|
||||
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
|
||||
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
|
||||
})
|
||||
|
||||
test("returns absolute directories and relative files", () => {
|
||||
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
|
||||
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
|
||||
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
|
||||
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
|
||||
})
|
||||
331
packages/app/src/components/directory-picker-domain.ts
Normal file
331
packages/app/src/components/directory-picker-domain.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
||||
const prefix = parent.replace(/^\/+|\/+$/g, "")
|
||||
return nodes.map((node) => {
|
||||
const path = prefix ? `${prefix}/${node.name}` : node.name
|
||||
return node.type === "directory" ? path + "/" : path
|
||||
})
|
||||
}
|
||||
|
||||
export function pickerTreeEntries(
|
||||
parent: string,
|
||||
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
||||
mode: "directory" | "file",
|
||||
) {
|
||||
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
|
||||
}
|
||||
|
||||
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
|
||||
nodes: readonly T[],
|
||||
mode: "directory" | "file",
|
||||
) {
|
||||
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
|
||||
}
|
||||
|
||||
export function pickerMode(mode: "directory" | "file", base?: string) {
|
||||
if (mode === "file") {
|
||||
return {
|
||||
includeFiles: true,
|
||||
action: "file" as const,
|
||||
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
||||
return treeEntries(parent, nodes)
|
||||
},
|
||||
navigation(path: string) {
|
||||
return treePathWithin(base, path) ? path : undefined
|
||||
},
|
||||
result(root: string, selected: string) {
|
||||
return selected || undefined
|
||||
},
|
||||
selection(root: string, path: string) {
|
||||
if (!treePathWithin(base, root)) return
|
||||
return selectedTreePath(root, path, "file", base)
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
includeFiles: false,
|
||||
action: "directory" as const,
|
||||
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
||||
return treeEntries(
|
||||
parent,
|
||||
nodes.filter((node) => node.type === "directory"),
|
||||
)
|
||||
},
|
||||
navigation(path: string) {
|
||||
return path
|
||||
},
|
||||
result(root: string, selected: string, valid = true) {
|
||||
if (!valid) return
|
||||
return selected || (root ? nativePickerPath(root) : undefined)
|
||||
},
|
||||
selection(root: string, path: string) {
|
||||
return selectedTreePath(root, path, "directory")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function pickerFileSearchQuery(root: string, input: string, home: string) {
|
||||
const value = input.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\/+$/, "")
|
||||
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
if (value === base) return ""
|
||||
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
|
||||
return value
|
||||
}
|
||||
|
||||
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
||||
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
||||
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
||||
return canonicalPickerPath(absolute)
|
||||
}
|
||||
|
||||
export function treePathWithin(base: string | undefined, path: string) {
|
||||
return pickerRelativePath(base, path) !== undefined
|
||||
}
|
||||
|
||||
export function canonicalPickerPath(path: string) {
|
||||
const value = normalizePickerDrive(path)
|
||||
const root = pickerRoot(value)
|
||||
const parts = value.slice(root.length).split("/")
|
||||
const resolved = parts.reduce<string[]>((output, part) => {
|
||||
if (!part || part === ".") return output
|
||||
if (part === "..") {
|
||||
output.pop()
|
||||
return output
|
||||
}
|
||||
output.push(part)
|
||||
return output
|
||||
}, [])
|
||||
return joinPickerPath(root, resolved.join("/"))
|
||||
}
|
||||
|
||||
export function pickerRelativePath(base: string | undefined, path: string) {
|
||||
if (!base) return
|
||||
const rootPath = canonicalPickerPath(base)
|
||||
const targetPath = canonicalPickerPath(path)
|
||||
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
|
||||
const root = insensitive ? rootPath.toLowerCase() : rootPath
|
||||
const target = insensitive ? targetPath.toLowerCase() : targetPath
|
||||
if (target === root) return ""
|
||||
const prefix = root.endsWith("/") ? root : root + "/"
|
||||
if (!target.startsWith(prefix)) return
|
||||
return targetPath.slice(prefix.length)
|
||||
}
|
||||
|
||||
export function currentPickerSuggestions<T>(
|
||||
result: { query: string; items: readonly T[] } | undefined,
|
||||
query: string,
|
||||
) {
|
||||
if (result?.query !== query) return []
|
||||
return result.items
|
||||
}
|
||||
|
||||
export function preloadTreeDirectories(
|
||||
parent: string,
|
||||
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
||||
) {
|
||||
return treeEntries(
|
||||
parent,
|
||||
nodes.filter((node) => node.type === "directory"),
|
||||
)
|
||||
}
|
||||
|
||||
export function advanceTreePreload(advanced: Set<string>, path: string) {
|
||||
if (advanced.has(path)) return false
|
||||
advanced.add(path)
|
||||
return true
|
||||
}
|
||||
|
||||
export function activeTreeNavigation(request: number, current: number) {
|
||||
return request === current
|
||||
}
|
||||
|
||||
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
||||
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
||||
}
|
||||
|
||||
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
|
||||
if (count === 0) return -1
|
||||
return (current + delta + count) % count
|
||||
}
|
||||
|
||||
export function absoluteTreePath(root: string, path: string) {
|
||||
const base = trimPickerPath(root)
|
||||
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
||||
if (!relative) return base || "/"
|
||||
if (!base || base === "/") return "/" + relative
|
||||
if (base.endsWith("/")) return base + relative
|
||||
return `${base}/${relative}`
|
||||
}
|
||||
|
||||
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
|
||||
const directory = path.endsWith("/")
|
||||
if (mode === "file") {
|
||||
if (directory) return
|
||||
if (!base) return path
|
||||
const absolute = absoluteTreePath(root, path)
|
||||
return pickerRelativePath(base, absolute)
|
||||
}
|
||||
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
|
||||
}
|
||||
|
||||
export function nativePickerPath(path: string) {
|
||||
const value = trimPickerPath(path)
|
||||
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
|
||||
return value
|
||||
}
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
|
||||
export function cleanPickerInput(value: string) {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||
}
|
||||
|
||||
export function normalizePickerPath(input: string) {
|
||||
const value = input.replaceAll("\\", "/")
|
||||
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
|
||||
return value.replace(/\/+/g, "/")
|
||||
}
|
||||
|
||||
export function normalizePickerDrive(input: string) {
|
||||
const value = normalizePickerPath(input)
|
||||
if (/^[A-Za-z]:$/.test(value)) return value + "/"
|
||||
return value
|
||||
}
|
||||
|
||||
export function trimPickerPath(input: string) {
|
||||
const value = normalizePickerDrive(input)
|
||||
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function joinPickerPath(base: string | undefined, relative: string) {
|
||||
const root = trimPickerPath(base ?? "")
|
||||
const path = trimPickerPath(relative).replace(/^\/+/, "")
|
||||
if (!root) return path
|
||||
if (!path) return root
|
||||
if (root.endsWith("/")) return root + path
|
||||
return root + "/" + path
|
||||
}
|
||||
|
||||
export function pickerRoot(input: string) {
|
||||
const value = normalizePickerDrive(input)
|
||||
if (value.startsWith("//")) {
|
||||
const [server, share] = value.slice(2).split("/")
|
||||
if (server && share) return `//${server}/${share}`
|
||||
return "//"
|
||||
}
|
||||
if (value.startsWith("/")) return "/"
|
||||
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
|
||||
return ""
|
||||
}
|
||||
|
||||
export function pickerParent(input: string) {
|
||||
const value = trimPickerPath(input)
|
||||
const root = pickerRoot(value)
|
||||
if (value === root) return value
|
||||
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
||||
const index = value.lastIndexOf("/")
|
||||
if (index < root.length) return root
|
||||
if (index <= 0) return "/"
|
||||
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
|
||||
return value.slice(0, index)
|
||||
}
|
||||
|
||||
function pickerTilde(absolute: string, home: string) {
|
||||
const path = trimPickerPath(absolute)
|
||||
if (!home) return ""
|
||||
const root = trimPickerPath(home)
|
||||
if (/^[A-Za-z]:\//.test(root)) return ""
|
||||
if (path === root) return "~"
|
||||
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
|
||||
return ""
|
||||
}
|
||||
|
||||
export function displayPickerPath(path: string, input: string, home: string) {
|
||||
const value = trimPickerPath(path)
|
||||
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
|
||||
return pickerTilde(value, home) || value
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
const scoped = (value: string) => {
|
||||
const base = args.base()
|
||||
if (!base) return
|
||||
const raw = normalizePickerDrive(value)
|
||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||
const home = args.home()
|
||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||
const root = pickerRoot(raw)
|
||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||
return { directory: trimPickerPath(base), path: raw }
|
||||
}
|
||||
|
||||
const directories = async (directory: string) => {
|
||||
const key = trimPickerPath(directory)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||
)
|
||||
cache.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
const match = async (directory: string, query: string, limit: number) => {
|
||||
const items = await directories(directory)
|
||||
if (!query) return items.slice(0, limit).map((item) => item.absolute)
|
||||
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
|
||||
}
|
||||
|
||||
return async (filter: string) => {
|
||||
const token = ++current
|
||||
const active = () => token === current
|
||||
const value = cleanPickerInput(filter)
|
||||
const input = scoped(value)
|
||||
if (!input) return [] as string[]
|
||||
const raw = normalizePickerDrive(value)
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
}
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
||||
const tail = segments.at(-1) ?? ""
|
||||
let paths = [input.directory]
|
||||
for (const part of head) {
|
||||
if (!active()) return []
|
||||
if (part === "..") {
|
||||
paths = paths.map(pickerParent)
|
||||
continue
|
||||
}
|
||||
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
|
||||
if (!active() || paths.length === 0) return []
|
||||
}
|
||||
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
|
||||
if (!active()) return []
|
||||
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
|
||||
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
|
||||
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
|
||||
if (!target) return matches.slice(0, 50)
|
||||
const children = await match(target, "", 30)
|
||||
if (!active()) return []
|
||||
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { lazy } from "solid-js"
|
||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
const DialogSelectDirectoryV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
title?: string
|
||||
|
|
@ -13,6 +19,7 @@ type DirectoryPickerInput = {
|
|||
|
||||
export function useDirectoryPicker() {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
|
||||
return (input: DirectoryPickerInput) => {
|
||||
|
|
@ -21,9 +28,18 @@ export function useDirectoryPicker() {
|
|||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory {...input} />,
|
||||
() => input.onSelect(null),
|
||||
)
|
||||
let selected = false
|
||||
const onSelect = (result: string | string[] | null) => {
|
||||
selected = result !== null
|
||||
input.onSelect(result)
|
||||
}
|
||||
const cancel = () => {
|
||||
if (!selected) input.onSelect(null)
|
||||
}
|
||||
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
packages/app/src/components/pierre-tree.test.ts
Normal file
23
packages/app/src/components/pierre-tree.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
|
||||
|
||||
test("reports directory expansion changes", () => {
|
||||
const changes: Array<{ path: string; expanded: boolean }> = []
|
||||
const tree = new FileTree({
|
||||
paths: ["src/"],
|
||||
onExpansionChange: (change) => changes.push(change),
|
||||
})
|
||||
|
||||
const src = tree.getItem("src/")
|
||||
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
|
||||
const directory = src as FileTreeDirectoryHandle
|
||||
|
||||
directory.expand()
|
||||
directory.collapse()
|
||||
|
||||
expect(changes).toEqual([
|
||||
{ path: "src/", expanded: true },
|
||||
{ path: "src/", expanded: false },
|
||||
])
|
||||
tree.cleanUp()
|
||||
})
|
||||
|
|
@ -1157,6 +1157,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
},
|
||||
addPart,
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
})
|
||||
|
||||
const fileAttachmentInput = () => (
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type PromptAttachmentsInput = {
|
|||
focusEditor: () => void
|
||||
addPart: (part: ContentPart) => boolean
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
|
@ -63,6 +64,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||
mime,
|
||||
dataUrl: url,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,31 @@ describe("buildRequestParts", () => {
|
|||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("preserves an external attachment source path for the model", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,AAA",
|
||||
},
|
||||
],
|
||||
text: "inspect this",
|
||||
messageID: "msg_external",
|
||||
sessionID: "ses_external",
|
||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||
})
|
||||
|
||||
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
|
||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
)
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||
type: "file",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
filename: attachment.sourcePath ?? attachment.filename,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue