fix(desktop): harden picker navigation

This commit is contained in:
LukeParkerDev 2026-06-07 17:57:27 +10:00
parent 85a7c492cd
commit cd8866ecb0
3 changed files with 32 additions and 12 deletions

View file

@ -10,6 +10,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
@ -42,6 +43,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
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
@ -77,7 +79,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
return Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8)
})
async function load(path: string, preload = true) {
async function load(path: string, generation: number, preload = true) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
@ -89,17 +91,19 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
.catch(() => undefined)
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
if (!nodes) {
listings.delete(key)
setError(true)
return
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, false)))
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
}
return true
}
async function navigate(path: string) {
@ -107,6 +111,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
if (!value) return
const token = ++navigation
setLoading(true)
setRootValid(false)
setSelected("")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
@ -115,8 +120,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
listings.clear()
advanced.clear()
tree?.resetPaths([])
await load("")
if (token === navigation) setLoading(false)
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
setRootValid(valid)
setLoading(false)
}
function complete() {
@ -177,7 +184,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
}
function resolve() {
const path = policy.result(root(), selected())
const path = policy.result(root(), selected(), rootValid())
if (!path) return
props.onSelect(props.multiple ? [path] : path)
dialog.close()
@ -214,7 +221,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
}
`,
onExpansionChange(change) {
if (change.expanded) void load(change.path)
if (change.expanded) void load(change.path, navigation)
},
onSelectionChange(paths) {
const path = paths.at(-1)
@ -300,11 +307,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
<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">Unable to read this folder</div></Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected())}</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())} onClick={resolve}>
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
{policy.action}
</ButtonV2>
</DialogFooter>

View file

@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
@ -51,11 +52,18 @@ test("centralizes file and directory selection policy", () => {
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")).toBe("/tmp/example.txt")
const directory = pickerMode("directory")
expect(directory.includeFiles).toBeFalse()
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
expect(directory.result("/repo", "")).toBe("/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("scopes file autocomplete to the current browser root", () => {

View file

@ -46,7 +46,8 @@ export function pickerMode(mode: "directory" | "file", base?: string) {
nodes.filter((node) => node.type === "directory"),
)
},
result(root: string, selected: string) {
result(root: string, selected: string, valid = true) {
if (!valid) return
return selected || root || undefined
},
selection(root: string, path: string) {
@ -79,6 +80,10 @@ export function advanceTreePreload(advanced: Set<string>, path: string) {
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))
}
@ -105,7 +110,7 @@ export function selectedTreePath(root: string, path: string, mode: "directory" |
const prefix = absoluteTreePath(base, "")
if (absolute === prefix) return ""
if (absolute.startsWith(prefix + "/")) return absolute.slice(prefix.length + 1)
return path
return absolute
}
return directory ? absoluteTreePath(root, path) : undefined
}