diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 4417e32001e..8dcb63f02ed 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -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) { />
void navigate(home())}>~ - void navigate(rootOf(root()) || root())}>Root - void navigate(parentOf(root()))}>Parent + void navigate(pickerRoot(root()) || root())}>Root + void navigate(pickerParent(root()))}>Parent
- 0}> + 0}>
- + {(suggestion, index) => ( )} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 7c0ce41fa85..7e5bc54b1d5 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -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>>() - 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 (
diff --git a/packages/app/src/components/directory-tree.test.ts b/packages/app/src/components/directory-picker-domain.test.ts similarity index 78% rename from packages/app/src/components/directory-tree.test.ts rename to packages/app/src/components/directory-picker-domain.test.ts index abc3e16e3ea..cbdfdeb9e82 100644 --- a/packages/app/src/components/directory-tree.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -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") diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts new file mode 100644 index 00000000000..0a34d27e5f1 --- /dev/null +++ b/packages/app/src/components/directory-picker-domain.ts @@ -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( + 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( + 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, 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>>() + 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) + } +} diff --git a/packages/app/src/components/directory-picker.tsx b/packages/app/src/components/directory-picker.tsx index 6379c597740..31b15b7a6eb 100644 --- a/packages/app/src/components/directory-picker.tsx +++ b/packages/app/src/components/directory-picker.tsx @@ -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(() => , cancel) - }) + dialog.show(() => , cancel) return } dialog.show(() => , cancel) diff --git a/packages/app/src/components/directory-tree.ts b/packages/app/src/components/directory-tree.ts deleted file mode 100644 index 32387b497be..00000000000 --- a/packages/app/src/components/directory-tree.ts +++ /dev/null @@ -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( - 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, 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 -}