diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 3971ac257861..5780ef15c41f 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -19,6 +19,7 @@ import { pickerMode, preloadTreeDirectories, cleanPickerInput, + createPriorityTaskQueue, createDirectorySearch, currentPickerSuggestions, displayPickerPath, @@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [error, setError] = createSignal(false) const [rootValid, setRootValid] = createSignal(false) const listings = new Map | undefined>>() + const loads = createPriorityTaskQueue | undefined>(3) const advanced = new Set() let tree: FileTree | undefined let container: HTMLDivElement | undefined @@ -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 @@ -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 } diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index d28344c935a2..57464106106c 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -15,6 +15,7 @@ import { treePathWithin, currentPickerSuggestions, createDirectorySearch, + createPriorityTaskQueue, displayPickerPath, pickerParent, pickerRoot, @@ -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(2) + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const started: string[] = [] + let active = 0 + let maximum = 0 + const task = (name: string, blocker?: Promise) => 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) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 2faa8cb2e00b..9900265962ea 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) { return request === current } +export function createPriorityTaskQueue(concurrency: number) { + type Job = { + key: string + priority: "user" | "background" + promise: Promise + run: () => void + } + + const jobs = new Map() + 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) => { + const existing = jobs.get(key) + if (existing) { + if (priority === "user") promote(key) + return existing.promise + } + + const deferred = Promise.withResolvers() + 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)) }