Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions packages/app/src/components/dialog-select-directory-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
Expand Down Expand Up @@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
Expand Down Expand Up @@ -102,16 +104,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))

async function load(path: string, generation: number, preload = true) {
async function load(path: string, generation: number, eager = false) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request =
listings.get(key) ??
sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
Expand All @@ -121,8 +128,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (preload && advanceTreePreload(advanced, key)) {
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
if (!eager && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
}
return true
}
Expand Down
36 changes: 36 additions & 0 deletions packages/app/src/components/directory-picker-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
displayPickerPath,
pickerParent,
pickerRoot,
Expand Down Expand Up @@ -168,6 +169,41 @@ test("advances preloading once for every expanded directory", () => {
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})

test("limits background tasks and prioritizes newly requested work", async () => {
const queue = createPriorityTaskQueue<void>(2)
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const started: string[] = []
let active = 0
let maximum = 0
const task = (name: string, blocker?: Promise<void>) => async () => {
started.push(name)
active++
maximum = Math.max(maximum, active)
await blocker
active--
}

const running = [
queue.schedule("first", "background", task("first", first.promise)),
queue.schedule("second", "background", task("second", second.promise)),
queue.schedule("preload", "background", task("preload")),
queue.schedule("opened", "user", task("opened")),
]
await Promise.resolve()
expect(started).toEqual(["first", "second"])

first.resolve()
await running[0]
await Promise.resolve()
expect(started).toEqual(["first", "second", "opened"])

second.resolve()
await Promise.all(running)
expect(started).toEqual(["first", "second", "opened", "preload"])
expect(maximum).toBe(2)
})

test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
Expand Down
73 changes: 73 additions & 0 deletions packages/app/src/components/directory-picker-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) {
return request === current
}

export function createPriorityTaskQueue<T>(concurrency: number) {
type Job = {
key: string
priority: "user" | "background"
promise: Promise<T>
run: () => void
}

const jobs = new Map<string, Job>()
const user: Job[] = []
const background: Job[] = []
let active = 0

const drain = () => {
while (active < concurrency) {
const job = user.pop() ?? background.shift()
if (!job) return
active++
job.run()
}
}

const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
const existing = jobs.get(key)
if (existing) {
if (priority === "user") promote(key)
return existing.promise
}

const deferred = Promise.withResolvers<T>()
const job: Job = {
key,
priority,
promise: deferred.promise,
run: () => {
const complete = () => {
active--
jobs.delete(key)
drain()
}
Promise.resolve()
.then(task)
.then(
(value) => {
complete()
deferred.resolve(value)
},
(error) => {
complete()
deferred.reject(error)
},
)
},
}
jobs.set(key, job)
;(priority === "user" ? user : background).push(job)
drain()
return job.promise
}

const promote = (key: string) => {
const job = jobs.get(key)
if (!job || job.priority === "user") return
const index = background.indexOf(job)
if (index === -1) return
background.splice(index, 1)
job.priority = "user"
user.push(job)
}

return { schedule, promote }
}

export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
Expand Down
Loading