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