>>()
- 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
-}