| 1 | import type { ProjectNode } from "./types"; |
| 2 | import { isStaleRead, releaseReadSnapshot } from "./readSnapshot"; |
| 3 | |
| 4 | export const PROJECT_TREE_WINDOW_INITIAL = 5; |
| 5 | export const PROJECT_TREE_WINDOW_STEP = 5; |
| 6 | export const PROJECT_TREE_SEARCH_PAGE = 50; |
| 7 | export const PROJECT_TREE_BACKEND_PAGE_MAX = 200; |
| 8 | |
| 9 | export type ProjectTreeListPageState = { |
| 10 | snapshotId?: string; |
| 11 | itemKeys?: string[]; |
| 12 | nextCursor?: string; |
| 13 | loading: boolean; |
| 14 | // A pending background read with a resident page keeps the painted UI quiet. |
| 15 | refreshing?: boolean; |
| 16 | initialized?: boolean; |
| 17 | error?: string; |
| 18 | }; |
| 19 | |
| 20 | export function projectTreeListShowsLoading(state: ProjectTreeListPageState | undefined): boolean { |
| 21 | return Boolean(state?.loading && !state.refreshing); |
| 22 | } |
| 23 | |
| 24 | const runtimeWindowLimits = new Map<string, number>(); |
| 25 | |
| 26 | export function projectTreeRuntimeWindowLimits(): Record<string, number> { |
| 27 | return Object.fromEntries(runtimeWindowLimits); |
| 28 | } |
| 29 | |
| 30 | export function rememberProjectTreeWindowLimit(key: string, limit: number): void { |
| 31 | if (limit <= PROJECT_TREE_WINDOW_INITIAL) runtimeWindowLimits.delete(key); |
| 32 | else runtimeWindowLimits.set(key, limit); |
| 33 | } |
| 34 | |
| 35 | export function forgetProjectTreeWindowLimit(key: string): void { |
| 36 | runtimeWindowLimits.delete(key); |
| 37 | } |
| 38 | |
| 39 | export function forgetProjectTreeWindowLimits(projectKeys: ReadonlySet<string>): void { |
| 40 | for (const key of runtimeWindowLimits.keys()) { |
| 41 | const separator = key.indexOf("\u001f"); |
| 42 | const projectKey = separator >= 0 ? key.slice(0, separator) : key; |
| 43 | if (!projectKeys.has(projectKey)) runtimeWindowLimits.delete(key); |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | export function resetProjectTreeRuntimeWindowLimits(projectKey?: string): void { |
| 48 | if (!projectKey) { |
| 49 | runtimeWindowLimits.clear(); |
| 50 | return; |
| 51 | } |
| 52 | const prefix = `${projectKey}\u001f`; |
| 53 | for (const key of runtimeWindowLimits.keys()) { |
| 54 | if (key.startsWith(prefix)) runtimeWindowLimits.delete(key); |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | export type ProjectTreeRequestLimiter = { |
| 59 | run<T>(task: () => Promise<T>): Promise<T>; |
| 60 | stats(): ProjectTreeRequestLimiterStats; |
| 61 | }; |
| 62 | |
| 63 | export type ProjectTreeRequestLimiterStats = { |
| 64 | active: number; |
| 65 | queued: number; |
| 66 | }; |
| 67 | |
| 68 | type ProjectTreePage<T> = { |
| 69 | snapshotId?: string; |
| 70 | replacedSnapshot?: boolean; |
| 71 | items: T[]; |
| 72 | nextCursor?: string; |
| 73 | revision: number; |
| 74 | complete?: boolean; |
| 75 | }; |
| 76 | |
| 77 | export async function loadProjectTreePageWindow<T, TPage extends ProjectTreePage<T>>( |
| 78 | initialCursor: string, |
| 79 | requestedLimit: number, |
| 80 | load: (cursor: string, limit: number) => Promise<TPage>, |
| 81 | recoveryLimit = requestedLimit, |
| 82 | allowRecovery = true, |
| 83 | ): Promise<TPage & { replacedSnapshot?: boolean }> { |
| 84 | const items: T[] = []; |
| 85 | let cursor = initialCursor; |
| 86 | let remaining = Math.max(1, Math.floor(requestedLimit)); |
| 87 | let result: TPage | undefined; |
| 88 | let revision = 0; |
| 89 | let incomplete = false; |
| 90 | |
| 91 | try { |
| 92 | while (remaining > 0) { |
| 93 | const page = await load(cursor, Math.min(remaining, PROJECT_TREE_BACKEND_PAGE_MAX)); |
| 94 | if (result?.snapshotId && page.snapshotId !== result.snapshotId) { |
| 95 | releaseReadSnapshot(page.snapshotId); |
| 96 | throw new Error("Mixed read snapshots in one list window"); |
| 97 | } |
| 98 | if (page.nextCursor && (page.nextCursor === cursor || page.items.length === 0)) throw new Error("List cursor did not advance"); |
| 99 | result = page; |
| 100 | items.push(...page.items); |
| 101 | revision = Math.max(revision, page.revision); |
| 102 | incomplete = incomplete || page.complete === false; |
| 103 | remaining -= page.items.length; |
| 104 | if (!page.nextCursor || page.items.length === 0) break; |
| 105 | cursor = page.nextCursor; |
| 106 | } |
| 107 | } catch (error) { |
| 108 | if (!initialCursor) releaseReadSnapshot(result?.snapshotId); |
| 109 | if (!allowRecovery || !isStaleRead(error)) throw error; |
| 110 | const rebuilt = await loadProjectTreePageWindow("", recoveryLimit, load, recoveryLimit, false); |
| 111 | return { ...rebuilt, replacedSnapshot: true }; |
| 112 | } |
| 113 | |
| 114 | if (!result) throw new Error("project tree page loader returned no page"); |
| 115 | return { |
| 116 | ...result, |
| 117 | items, |
| 118 | revision, |
| 119 | complete: incomplete ? false : result.complete, |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | export function createProjectTreeRequestLimiter(maxConcurrent = 4): ProjectTreeRequestLimiter { |
| 124 | const limit = Math.max(1, Math.floor(maxConcurrent)); |
| 125 | let active = 0; |
| 126 | const pending: Array<() => void> = []; |
| 127 | |
| 128 | const release = () => { |
| 129 | active = Math.max(0, active - 1); |
| 130 | pending.shift()?.(); |
| 131 | }; |
| 132 | |
| 133 | return { |
| 134 | run<T>(task: () => Promise<T>): Promise<T> { |
| 135 | return new Promise<T>((resolve, reject) => { |
| 136 | const start = () => { |
| 137 | active += 1; |
| 138 | void task().then(resolve, reject).finally(release); |
| 139 | }; |
| 140 | if (active < limit) start(); |
| 141 | else pending.push(start); |
| 142 | }); |
| 143 | }, |
| 144 | stats(): ProjectTreeRequestLimiterStats { |
| 145 | return { active, queued: pending.length }; |
| 146 | }, |
| 147 | }; |
| 148 | } |
| 149 | |
| 150 | export function projectTreeListKey(projectKey: string, groupID = "", query = ""): string { |
| 151 | const normalizedQuery = query.trim().toLowerCase(); |
| 152 | if (normalizedQuery) return `${projectKey}\u001fsearch\u001f${normalizedQuery}`; |
| 153 | return `${projectKey}\u001f${groupID ? `group:${groupID}` : "ungrouped"}`; |
| 154 | } |
| 155 | |
| 156 | export function projectTreeKnownGroupIDs( |
| 157 | pageStates: Readonly<Record<string, ProjectTreeListPageState>>, |
| 158 | projectKey: string, |
| 159 | ): string[] { |
| 160 | const prefix = `${projectKey}\u001fgroup:`; |
| 161 | return [...new Set(Object.keys(pageStates) |
| 162 | .filter((key) => key.startsWith(prefix)) |
| 163 | .map((key) => key.slice(prefix.length)) |
| 164 | .filter(Boolean))].sort(); |
| 165 | } |
| 166 | |
| 167 | export function projectTreeListNeedsInitialization(state: ProjectTreeListPageState | undefined): boolean { |
| 168 | return !state?.initialized && !state?.loading; |
| 169 | } |
| 170 | |
| 171 | export function projectTreeProjectsNeedingInitialLoad( |
| 172 | projects: readonly ProjectNode[], |
| 173 | expandedKeys: ReadonlySet<string>, |
| 174 | query: string, |
| 175 | pageStates: Readonly<Record<string, ProjectTreeListPageState>>, |
| 176 | folderKey: (project: ProjectNode) => string, |
| 177 | ): ProjectNode[] { |
| 178 | return projects.filter((project) => ( |
| 179 | !project.remote |
| 180 | && (project.kind === "project" || project.kind === "global_folder") |
| 181 | && expandedKeys.has(folderKey(project)) |
| 182 | && projectTreeListNeedsInitialization(pageStates[projectTreeListKey(project.key, "", query)]) |
| 183 | )); |
| 184 | } |
| 185 | |
| 186 | export async function reloadProjectTreeTopicLists( |
| 187 | project: ProjectNode, |
| 188 | query: string, |
| 189 | pageStates: Readonly<Record<string, ProjectTreeListPageState>>, |
| 190 | load: (project: ProjectNode, groupID: string) => Promise<void>, |
| 191 | ): Promise<void> { |
| 192 | const groupIDs = query.trim() ? [""] : ["", ...projectTreeKnownGroupIDs(pageStates, project.key)]; |
| 193 | await Promise.all(groupIDs.map((groupID) => load(project, groupID))); |
| 194 | } |
| 195 | |
| 196 | export type ProjectTreeWindowProjection = { |
| 197 | rows: ProjectNode[]; |
| 198 | hasHiddenLoadedRows: boolean; |
| 199 | }; |
| 200 | |
| 201 | export function projectTreeWindowProjection( |
| 202 | rows: ProjectNode[], |
| 203 | limit: number, |
| 204 | isActive: (node: ProjectNode) => boolean, |
| 205 | ): ProjectTreeWindowProjection { |
| 206 | if (rows.length <= limit) return { rows, hasHiddenLoadedRows: false }; |
| 207 | const visible = rows.slice(0, limit); |
| 208 | const active = rows.find((row) => isActive(row)); |
| 209 | const projected = !active || visible.some((row) => row.key === active.key) |
| 210 | ? visible |
| 211 | : [...visible, active]; |
| 212 | const visibleKeys = new Set(projected.map((row) => row.key)); |
| 213 | return { |
| 214 | rows: projected, |
| 215 | hasHiddenLoadedRows: rows.some((row) => !visibleKeys.has(row.key)), |
| 216 | }; |
| 217 | } |
| 218 | |
| 219 | export function projectTreeWindowRows( |
| 220 | rows: ProjectNode[], |
| 221 | limit: number, |
| 222 | isActive: (node: ProjectNode) => boolean, |
| 223 | ): ProjectNode[] { |
| 224 | return projectTreeWindowProjection(rows, limit, isActive).rows; |
| 225 | } |
| 226 |