返回 DeepSeek-Reasonix
projectTreeWindow.ts
根目录 / desktop / frontend / src / lib / projectTreeWindow.ts
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
226 lines TYPESCRIPT