fix(app): throttle directory tree loading (#33576)
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
pickerMode,
|
pickerMode,
|
||||||
preloadTreeDirectories,
|
preloadTreeDirectories,
|
||||||
cleanPickerInput,
|
cleanPickerInput,
|
||||||
|
createPriorityTaskQueue,
|
||||||
createDirectorySearch,
|
createDirectorySearch,
|
||||||
currentPickerSuggestions,
|
currentPickerSuggestions,
|
||||||
displayPickerPath,
|
displayPickerPath,
|
||||||
@@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
const [error, setError] = createSignal(false)
|
const [error, setError] = createSignal(false)
|
||||||
const [rootValid, setRootValid] = createSignal(false)
|
const [rootValid, setRootValid] = createSignal(false)
|
||||||
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
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>()
|
const advanced = new Set<string>()
|
||||||
let tree: FileTree | undefined
|
let tree: FileTree | undefined
|
||||||
let container: HTMLDivElement | undefined
|
let container: HTMLDivElement | undefined
|
||||||
@@ -102,16 +104,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
})
|
})
|
||||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
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(/\/+$/, "")
|
const key = path.replace(/\/+$/, "")
|
||||||
setError(false)
|
setError(false)
|
||||||
const absolute = absoluteTreePath(root(), key)
|
const absolute = absoluteTreePath(root(), key)
|
||||||
|
const existing = listings.get(key)
|
||||||
|
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||||
const request =
|
const request =
|
||||||
listings.get(key) ??
|
existing ??
|
||||||
sdk.client.file
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
|
return sdk.client.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ directory: absolute, path: "" })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data ?? [])
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
const nodes = await request
|
const nodes = await request
|
||||||
if (!activeTreeNavigation(generation, navigation)) return false
|
if (!activeTreeNavigation(generation, navigation)) return false
|
||||||
@@ -121,8 +128,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||||
if (preload && advanceTreePreload(advanced, key)) {
|
if (!eager && advanceTreePreload(advanced, key)) {
|
||||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
treePathWithin,
|
treePathWithin,
|
||||||
currentPickerSuggestions,
|
currentPickerSuggestions,
|
||||||
createDirectorySearch,
|
createDirectorySearch,
|
||||||
|
createPriorityTaskQueue,
|
||||||
displayPickerPath,
|
displayPickerPath,
|
||||||
pickerParent,
|
pickerParent,
|
||||||
pickerRoot,
|
pickerRoot,
|
||||||
@@ -168,6 +169,41 @@ test("advances preloading once for every expanded directory", () => {
|
|||||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
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", () => {
|
test("clamps bridged tree wheel scrolling", () => {
|
||||||
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
||||||
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
||||||
|
|||||||
@@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) {
|
|||||||
return request === current
|
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) {
|
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
||||||
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user