Skip to content

Commit ef5e35f

Browse files
committed
fix: 收敛文件索引活跃根与前端缓存
- fileIndex 将活跃根与内存缓存同步裁剪并返回 trimmed 指标,降低大仓常驻 - 主进程合并多窗口活跃根,避免跨窗口互相清空 watcher - 前端 @ 搜索收紧缓存数量、增加空闲/隐藏清理与自动恢复最近根,防止旧结果回写 - host 类型同步 trimmed 字段 Signed-off-by: Lulu <[email protected]>
1 parent 1402c80 commit ef5e35f

File tree

4 files changed

+207
-12
lines changed

4 files changed

+207
-12
lines changed

electron/fileIndex.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type MemEntry = {
3636
dirs: string[];
3737
};
3838

39-
const MAX_MEM_ENTRIES = 5; // 简易 LRU 上限
39+
const MAX_MEM_ENTRIES = 3; // 简易 LRU 上限:主动收敛缓存规模,避免大仓库常驻占用
4040
const memLRU: Map<string, MemEntry> = new Map(); // key -> entry(插入顺序即 LRU)
4141

4242
// 调试与日志开关:环境变量 CODEX_FILEINDEX_DEBUG=1 或在 userData 放置 fileindex-debug.on 文件
@@ -292,7 +292,7 @@ function stopWatcherByKey(key: string) {
292292
} catch {}
293293
}
294294

295-
export function setActiveRoots(activeRoots: string[]): { closed: number; remain: number } {
295+
export function setActiveRoots(activeRoots: string[]): { closed: number; remain: number; trimmed: number } {
296296
try {
297297
const allowed = new Set<string>((activeRoots || []).map((r) => canonKey(String(r || ''))));
298298
let closed = 0;
@@ -301,8 +301,16 @@ export function setActiveRoots(activeRoots: string[]): { closed: number; remain:
301301
try { stopWatcherByKey(key); closed++; } catch {}
302302
}
303303
}
304-
return { closed, remain: watchers.size };
305-
} catch { return { closed: 0, remain: watchers.size }; }
304+
// 同步收敛内存缓存:仅保留当前活跃根对应的条目,避免历史大仓库长期驻留
305+
let trimmed = 0;
306+
for (const key of Array.from(memLRU.keys())) {
307+
if (allowed.has(key)) continue;
308+
memLRU.delete(key);
309+
trimmed++;
310+
}
311+
if (trimmed > 0) logDbg(`cache.mem.trim active=${allowed.size} trimmed=${trimmed} remain=${memLRU.size}`);
312+
return { closed, remain: watchers.size, trimmed };
313+
} catch { return { closed: 0, remain: watchers.size, trimmed: 0 }; }
306314
}
307315

308316
function toPosixAbs(p: string): string { return String(p || '').replace(/\\/g, '/'); }

electron/main.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ const ptyManager = new PTYManager(() => mainWindow);
4747
const sessionPastedImages = new Set<string>();
4848
const codexBridges = new Map<string, CodexBridge>();
4949

50+
// 记录每个渲染进程声明的活跃根集合,统一合并后再驱动 fileIndex,避免多窗口互相清空 watcher
51+
const activeRootsBySender = new Map<number, Set<string>>();
52+
const activeRootsSenderHooked = new Set<number>();
53+
54+
function applyMergedActiveRoots(): { closed: number; remain: number; trimmed: number } {
55+
const merged = new Set<string>();
56+
for (const roots of activeRootsBySender.values()) {
57+
for (const r of roots) merged.add(r);
58+
}
59+
const mergedList = Array.from(merged);
60+
return (fileIndex as any).setActiveRoots ? (fileIndex as any).setActiveRoots(mergedList) : { closed: 0, remain: 0, trimmed: 0 };
61+
}
62+
5063
type CodexBridgeDescriptor = { key: string; options: CodexBridgeOptions };
5164

5265
function deriveCodexBridgeDescriptor(cfg: AppSettings): CodexBridgeDescriptor {
@@ -933,10 +946,29 @@ ipcMain.handle('fileIndex.candidates', async (_e, { root }: { root: string }) =>
933946
}
934947
});
935948

936-
ipcMain.handle('fileIndex.activeRoots', async (_e, { roots }: { roots: string[] }) => {
949+
ipcMain.handle('fileIndex.activeRoots', async (event, { roots }: { roots: string[] }) => {
937950
try {
938951
const list = Array.isArray(roots) ? roots.filter((x) => typeof x === 'string') : [];
939-
const res = (fileIndex as any).setActiveRoots ? (fileIndex as any).setActiveRoots(list) : { closed: 0, remain: 0 };
952+
const senderId = (event as any)?.sender?.id;
953+
if (typeof senderId !== 'number') {
954+
const res = (fileIndex as any).setActiveRoots ? (fileIndex as any).setActiveRoots(list) : { closed: 0, remain: 0, trimmed: 0 };
955+
return { ok: true, ...res };
956+
}
957+
958+
// 记录当前窗口的活跃根集合,并在窗口销毁时自动移除,避免跨窗口互相“清空” watcher
959+
activeRootsBySender.set(senderId, new Set(list));
960+
if (!activeRootsSenderHooked.has(senderId)) {
961+
activeRootsSenderHooked.add(senderId);
962+
try {
963+
(event as any)?.sender?.once?.('destroyed', () => {
964+
activeRootsBySender.delete(senderId);
965+
activeRootsSenderHooked.delete(senderId);
966+
try { applyMergedActiveRoots(); } catch {}
967+
});
968+
} catch {}
969+
}
970+
971+
const res = applyMergedActiveRoots();
940972
return { ok: true, ...res };
941973
} catch (e: any) {
942974
return { ok: false, error: String(e) };

web/src/lib/atSearch.ts

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ let worker: Worker | null = null;
2222
let workerLoaded = false;
2323
let currentRoot: string | null = null; // 当前项目根(Windows/UNC)
2424
let workerRoot: string | null = null; // Worker 内当前已加载的根
25+
let lastActiveRoot: string | null = null; // 最近一次激活的项目根(用于后台清理后的恢复)
26+
let restoreActiveRootPromise: Promise<void> | null = null; // 控制并发恢复,避免竞争
2527
const cacheByRoot = new Map<string, FileCandidate[]>();
2628
const loadingByRoot = new Map<string, Promise<FileCandidate[]>>();
2729
const cacheIndexByRoot = new Map<string, Map<string, FileCandidate>>(); // key: D:/F:+rel
@@ -45,19 +47,42 @@ function ensureWorkerCleanupHook(): void {
4547
if (typeof window === "undefined") return;
4648
if (workerCleanupInstalled) return;
4749
try {
48-
window.addEventListener("beforeunload", () => { try { disposeWorker(); } catch {} });
50+
window.addEventListener("beforeunload", () => {
51+
try { cleanupRendererResources("unload"); } catch {}
52+
});
53+
window.addEventListener("visibilitychange", () => {
54+
try {
55+
if (typeof document !== "undefined" && document.hidden) {
56+
scheduleHiddenCleanup();
57+
} else {
58+
clearHiddenCleanupTimer();
59+
touchAtUsage(); // 回到前台时延长空闲计时,避免频繁重建索引
60+
// 若后台清理已释放索引,恢复最近的项目根,避免回到前台后 @ 面板为空
61+
restoreActiveRootIfCleared("visible").catch(() => {});
62+
}
63+
} catch {}
64+
});
4965
workerCleanupInstalled = true;
5066
} catch {}
5167
}
5268

53-
const MAX_CACHE_ROOTS = 6; // 缓存根目录数量上限,防止长期运行内存无限增长
69+
const MAX_CACHE_ROOTS = 3; // 缓存根目录数量上限,收紧多仓缓存占用
5470
const KEY_PREFIX_DIR = "D";
5571
const KEY_PREFIX_FILE = "F";
72+
const IDLE_DISPOSE_MS = 3 * 60 * 1000; // @ 面板空闲超时回收(ms)
73+
const HIDDEN_DISPOSE_MS = 90 * 1000; // 页面隐藏后延迟释放资源(ms)
74+
let idleTimer: number | null = null;
75+
let hiddenCleanupTimer: number | null = null;
76+
const loadGenByRoot = new Map<string, number>(); // 用于防止被清理后的旧异步加载回写缓存,并维护代际号
5677

5778
function normalizeRootKey(root: string | null | undefined): string {
5879
return String(root || "").toLowerCase();
5980
}
6081

82+
function isSameRoot(a?: string | null, b?: string | null): boolean {
83+
return normalizeRootKey(a) === normalizeRootKey(b);
84+
}
85+
6186
function buildIndexFor(list: FileCandidate[]): Map<string, FileCandidate> {
6287
const idx = new Map<string, FileCandidate>();
6388
for (const item of list) {
@@ -72,17 +97,130 @@ function enforceCacheLimit(preserveKey?: string): void {
7297
const keep = new Set<string>();
7398
if (preserveKey) keep.add(preserveKey);
7499
if (currentKey) keep.add(currentKey);
100+
let trimmed = false;
75101
for (const key of Array.from(cacheByRoot.keys())) {
76102
if (cacheByRoot.size <= MAX_CACHE_ROOTS) break;
77103
if (keep.has(key)) continue;
78-
cacheByRoot.delete(key);
79-
cacheIndexByRoot.delete(key);
80-
loadingByRoot.delete(key);
104+
invalidateRendererRoot(key);
105+
trimmed = true;
81106
if (workerRoot && normalizeRootKey(workerRoot) === key) {
82107
workerRoot = null;
83108
workerLoaded = false;
84109
}
85110
}
111+
// 若发生裁剪,顺带清理已失效根的代际计数,避免 Map 长期增长
112+
if (trimmed) pruneLoadGenerations(preserveKey ?? currentRoot);
113+
}
114+
115+
function bumpLoadGeneration(key: string): number {
116+
const next = (loadGenByRoot.get(key) ?? 0) + 1;
117+
loadGenByRoot.set(key, next);
118+
return next;
119+
}
120+
121+
function invalidateRendererRoot(key: string): void {
122+
cacheByRoot.delete(key);
123+
cacheIndexByRoot.delete(key);
124+
loadingByRoot.delete(key);
125+
bumpLoadGeneration(key);
126+
}
127+
128+
function trimRendererCaches(keepRoot?: string | null): void {
129+
const keepKey = keepRoot ? normalizeRootKey(keepRoot) : null;
130+
for (const key of Array.from(cacheByRoot.keys())) {
131+
if (keepKey && key === keepKey) continue;
132+
invalidateRendererRoot(key);
133+
}
134+
for (const key of Array.from(loadingByRoot.keys())) {
135+
if (keepKey && key === keepKey) continue;
136+
invalidateRendererRoot(key);
137+
}
138+
enforceCacheLimit(keepKey ?? undefined);
139+
pruneLoadGenerations(keepRoot);
140+
}
141+
142+
// 清理已被裁剪的根对应的 load 代际,避免 Map 无限增长
143+
function pruneLoadGenerations(keepRoot?: string | null): void {
144+
const keepKey = keepRoot ? normalizeRootKey(keepRoot) : null;
145+
const alive = new Set<string>();
146+
if (keepKey) alive.add(keepKey);
147+
if (currentRoot) alive.add(normalizeRootKey(currentRoot));
148+
if (workerRoot) alive.add(normalizeRootKey(workerRoot));
149+
for (const k of cacheByRoot.keys()) alive.add(k);
150+
for (const k of loadingByRoot.keys()) alive.add(k);
151+
for (const k of Array.from(loadGenByRoot.keys())) {
152+
if (!alive.has(k)) loadGenByRoot.delete(k);
153+
}
154+
}
155+
156+
function cleanupRendererResources(reason?: string): void {
157+
const prevRoot = currentRoot;
158+
if (prevRoot) lastActiveRoot = prevRoot; // 记录最近活跃根,便于恢复
159+
try { disposeWorker(); } catch {}
160+
// 清理所有缓存(包含当前根),避免在主进程 watcher 已关闭时继续复用陈旧列表
161+
trimRendererCaches(null);
162+
if (typeof window !== "undefined" && idleTimer !== null) {
163+
try { window.clearTimeout(idleTimer); } catch {}
164+
}
165+
idleTimer = null;
166+
if (typeof window !== "undefined" && hiddenCleanupTimer !== null) {
167+
try { window.clearTimeout(hiddenCleanupTimer); } catch {}
168+
}
169+
hiddenCleanupTimer = null;
170+
currentRoot = null;
171+
workerRoot = null;
172+
workerLoaded = false;
173+
loadGenByRoot.clear();
174+
// 主进程同步收敛:释放 fileIndex watcher 与内存缓存,避免长期驻留
175+
try {
176+
const p = (window as any).host?.fileIndex?.setActiveRoots?.([]);
177+
// 捕获 Promise 拒绝,避免窗口关闭阶段出现未处理异常
178+
if (p && typeof (p as any).catch === "function") (p as Promise<unknown>).catch(() => {});
179+
} catch {}
180+
perfLog(`cleanup reason='${reason || 'idle'}' root='${prevRoot || ''}' caches=${cacheByRoot.size}`);
181+
}
182+
183+
function touchAtUsage(): void {
184+
if (typeof window === "undefined") return;
185+
try { if (idleTimer !== null) window.clearTimeout(idleTimer); } catch {}
186+
idleTimer = window.setTimeout(() => cleanupRendererResources("idle"), IDLE_DISPOSE_MS);
187+
clearHiddenCleanupTimer();
188+
}
189+
190+
function clearHiddenCleanupTimer(): void {
191+
if (typeof window === "undefined") return;
192+
if (hiddenCleanupTimer !== null) {
193+
try { window.clearTimeout(hiddenCleanupTimer); } catch {}
194+
}
195+
hiddenCleanupTimer = null;
196+
}
197+
198+
function scheduleHiddenCleanup(): void {
199+
if (typeof window === "undefined" || typeof document === "undefined") return;
200+
clearHiddenCleanupTimer();
201+
try {
202+
if (!document.hidden) return;
203+
hiddenCleanupTimer = window.setTimeout(() => cleanupRendererResources("hidden"), HIDDEN_DISPOSE_MS);
204+
} catch {}
205+
}
206+
207+
// 在被后台/空闲清理后,尝试自动恢复最近一次的项目根,减少首次搜索空结果
208+
async function restoreActiveRootIfCleared(reason?: string): Promise<void> {
209+
if (currentRoot || !lastActiveRoot) return;
210+
if (restoreActiveRootPromise) {
211+
try { await restoreActiveRootPromise; } catch {}
212+
return;
213+
}
214+
const restoringRoot = lastActiveRoot;
215+
const p = (async () => {
216+
try {
217+
if (!restoringRoot || currentRoot) return;
218+
await setActiveFileIndexRoot(restoringRoot);
219+
perfLog(`restore.root reason='${reason || ''}' root='${restoringRoot || ''}'`);
220+
} catch {}
221+
})();
222+
restoreActiveRootPromise = p.finally(() => { restoreActiveRootPromise = null; });
223+
try { await restoreActiveRootPromise; } catch {}
86224
}
87225

88226
function storeCacheEntry(rootKey: string, list: FileCandidate[], index?: Map<string, FileCandidate>): Map<string, FileCandidate> {
@@ -195,6 +333,7 @@ async function loadCandidatesForRoot(root: string, excludes?: string[]): Promise
195333
const key = normalizeRootKey(root);
196334
if (cacheByRoot.has(key)) return cacheByRoot.get(key)!;
197335
if (loadingByRoot.has(key)) return loadingByRoot.get(key)!;
336+
const loadGen = bumpLoadGeneration(key); // 每次加载生成新的代际,便于在清理后阻止旧结果回写
198337
const p = (async () => {
199338
try {
200339
const t0 = Date.now();
@@ -205,6 +344,11 @@ async function loadCandidatesForRoot(root: string, excludes?: string[]): Promise
205344
const list = await getAllCandidates(root);
206345
perfLog(`candidates.loaded root='${root}' count=${list.length} dur=${Date.now() - t1}ms`);
207346
const idx = buildIndexFor(list);
347+
// 若在加载期间根被清理(如切换项目或 LRU 收缩),则跳过回写
348+
if ((loadGenByRoot.get(key) ?? 0) !== loadGen) {
349+
perfLog(`candidates.skip root='${root}' reason='invalidated'`);
350+
return list;
351+
}
208352
storeCacheEntry(root, list, idx);
209353
return list;
210354
} finally {
@@ -221,7 +365,16 @@ function postToWorkerForRoot(root: string, list: FileCandidate[]) {
221365
}
222366

223367
export async function setActiveFileIndexRoot(winRoot: string, excludes?: string[]): Promise<void> {
368+
touchAtUsage();
369+
const prevRoot = currentRoot;
370+
lastActiveRoot = winRoot;
224371
currentRoot = winRoot;
372+
const switched = !isSameRoot(prevRoot, winRoot);
373+
if (switched) {
374+
// 切换项目时主动收敛缓存与 Worker,避免旧大仓库数据常驻
375+
trimRendererCaches(winRoot);
376+
if (!isSameRoot(workerRoot, winRoot)) disposeWorker();
377+
}
225378
// 切换项目时:通知主进程仅保留当前根的 watcher,避免多项目同时监听带来的负载
226379
try { await (window as any).host?.fileIndex?.setActiveRoots?.([winRoot]); } catch {}
227380
// 首次调用时订阅主进程的索引变更事件
@@ -318,6 +471,8 @@ function scoreRule(item: RuleItem, query: string): number {
318471
export async function searchAtItems(query: string, scope: SearchScope, limit = 30): Promise<SearchResult[]> {
319472
const q = String(query || "").trim();
320473
const results: SearchResult[] = [];
474+
touchAtUsage();
475+
await restoreActiveRootIfCleared("search");
321476

322477
// 规则类:从候选文件中过滤 .cursor 相关规则文件
323478
const pickRules = async () => {

web/src/types/host.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export interface WslAPI {
197197
export interface FileIndexAPI {
198198
ensureIndex(args: { root: string; excludes?: string[] }): Promise<{ ok: boolean; total?: number; updatedAt?: number; error?: string }>;
199199
getAllCandidates(root: string): Promise<{ ok: boolean; items?: Array<{ rel: string; isDir: boolean }>; error?: string }>;
200-
setActiveRoots(roots: string[]): Promise<{ ok: boolean; closed?: number; remain?: number; error?: string }>;
200+
setActiveRoots(roots: string[]): Promise<{ ok: boolean; closed?: number; remain?: number; trimmed?: number; error?: string }>;
201201
onChanged?: (handler: (payload: { root: string; reason?: string; adds?: { rel: string; isDir: boolean }[]; removes?: { rel: string; isDir: boolean }[] }) => void) => () => void;
202202
}
203203

0 commit comments

Comments
 (0)