refactor(desktop): centralize picker path domain

This commit is contained in:
LukeParkerDev 2026-06-07 18:23:38 +10:00
parent e5c671fcfa
commit 9d1454fa4f
6 changed files with 371 additions and 361 deletions

View file

@ -18,8 +18,13 @@ import {
pickerAbsoluteInput,
pickerMode,
preloadTreeDirectories,
} from "./directory-tree"
import { cleanInput, displayPath, parentOf, rootOf, useDirectorySearch } from "./dialog-select-directory"
cleanPickerInput,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
pickerParent,
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
interface DialogSelectDirectoryV2Props {
@ -62,13 +67,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const start = createMemo(
() => props.start || sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const search = useDirectorySearch({ sdk, home, start })
const search = createDirectorySearch({ sdk, home, start })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanInput(value).replace(/\/+$/, "")
const current = displayPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return []
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 directories.slice(0, 5)
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 ?? [])
@ -77,8 +82,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
]
return Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8)
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(/\/+$/, "")
@ -108,7 +117,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
}
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanInput(path), home()))
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home()))
if (!value) return
const token = ++navigation
setLoading(true)
@ -117,7 +126,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
setSuggestionsOpen(false)
setActiveSuggestion(-1)
setRoot(value)
setInput(displayPath(value, value, home()))
setInput(displayPickerPath(value, value, home()))
listings.clear()
advanced.clear()
tree?.resetPaths([])
@ -128,10 +137,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
}
function complete() {
const items = suggestions() ?? []
const items = currentSuggestions()
const match = items[activeSuggestion()] ?? items[0]
if (!match) return
const value = displayPath(match.absolute, input(), home())
const value = displayPickerPath(match.absolute, input(), home())
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
if (match.type === "file") {
setSelected(
@ -147,7 +156,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
void navigate(suggestion.absolute)
return
}
setInput(displayPath(suggestion.absolute, input(), home()))
setInput(displayPickerPath(suggestion.absolute, input(), home()))
setSelected(
policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "",
)
@ -157,11 +166,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
function moveSuggestion(delta: -1 | 1) {
setSuggestionsOpen(true)
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, suggestions()?.length ?? 0))
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
}
function activeSuggestionValue() {
const items = suggestions() ?? []
const items = currentSuggestions()
return items[activeSuggestion()] ?? items[0]
}
@ -253,7 +262,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
spellcheck={false}
class="!w-full"
onInput={(event) => {
setInput(cleanInput(event.currentTarget.value))
setInput(cleanPickerInput(event.currentTarget.value))
setSelected("")
setSuggestionsOpen(true)
setActiveSuggestion(-1)
@ -267,12 +276,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
/>
<div class="directory-picker-v2-actions">
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>~</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(rootOf(root()) || root())}>Root</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(parentOf(root()))}>Parent</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>Root</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>Parent</ButtonV2>
</div>
<Show when={suggestionsOpen() && (suggestions()?.length ?? 0) > 0}>
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
<For each={suggestions()}>
<For each={currentSuggestions()}>
{(suggestion, index) => (
<button
id={`directory-picker-v2-suggestion-${index()}`}
@ -282,7 +291,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onPointerMove={() => setActiveSuggestion(index())}
onClick={() => chooseSuggestion(suggestion)}
>
{displayPath(suggestion.absolute, input(), home())}
{displayPickerPath(suggestion.absolute, input(), home())}
{suggestion.type === "directory" ? "/" : ""}
</button>
)}

View file

@ -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"
}
export 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
}
export 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 ""
}
export 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 ""
}
export 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[]) {
})
}
export 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,7 +73,7 @@ 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,
@ -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">

View file

@ -12,7 +12,10 @@ import {
preloadTreeDirectories,
selectedTreePath,
treeEntries,
} from "./directory-tree"
treePathWithin,
currentPickerSuggestions,
displayPickerPath,
} from "./directory-picker-domain"
test("maps server directory entries into Pierre paths", () => {
expect(
@ -59,6 +62,7 @@ test("centralizes file and directory selection policy", () => {
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.navigation("/tmp")).toBe("/tmp")
expect(directory.result("/repo", "")).toBe("/repo")
expect(directory.result("/repo", "", false)).toBeUndefined()
@ -69,6 +73,28 @@ test("accepts mutations only from the active navigation", () => {
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()
})
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("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")

View file

@ -0,0 +1,301 @@
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: "Select file",
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: "Select folder",
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 || 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) {
return input.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\/+$/, "") || "/"
}
export function treePathWithin(base: string | undefined, path: string) {
if (!base) return false
const rootPath = absoluteTreePath(base, "")
const targetPath = absoluteTreePath(path, "")
const insensitive = /^[A-Za-z]:\//.test(rootPath)
const root = insensitive ? rootPath.toLowerCase() : rootPath
const target = insensitive ? targetPath.toLowerCase() : targetPath
return target === root || target.startsWith(root + "/")
}
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 = root.replace(/\\/g, "/").replace(/\/+$/, "")
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
if (!relative) return base || "/"
if (!base || base === "/") return "/" + 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)
const prefix = absoluteTreePath(base, "")
if (absolute === prefix) return ""
if (absolute.startsWith(prefix + "/")) return absolute.slice(prefix.length + 1)
return absolute
}
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
}
export function nativePickerPath(path: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(value)) 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("//")) 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)
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
const index = value.lastIndexOf("/")
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; start: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const start = args.start()
if (!start) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(start), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || start), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || start), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(start), 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)
}
}

View file

@ -2,9 +2,14 @@ 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
@ -32,9 +37,7 @@ export function useDirectoryPicker() {
if (!selected) input.onSelect(null)
}
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
void import("./dialog-select-directory-v2").then(({ DialogSelectDirectoryV2 }) => {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
})
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)

View file

@ -1,134 +0,0 @@
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: "Select file",
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: "Select folder",
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 || 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) {
return input.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\/+$/, "") || "/"
}
export function treePathWithin(base: string | undefined, path: string) {
if (!base) return false
const root = absoluteTreePath(base, "").toLowerCase()
const target = absoluteTreePath(path, "").toLowerCase()
return target === root || target.startsWith(root + "/")
}
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 = root.replace(/\\/g, "/").replace(/\/+$/, "")
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
if (!relative) return base || "/"
if (!base || base === "/") return "/" + 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)
const prefix = absoluteTreePath(base, "")
if (absolute === prefix) return ""
if (absolute.startsWith(prefix + "/")) return absolute.slice(prefix.length + 1)
return absolute
}
return directory ? absoluteTreePath(root, path) : undefined
}