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