@@ -22,6 +22,8 @@ let worker: Worker | null = null;
2222let workerLoaded = false ;
2323let currentRoot : string | null = null ; // 当前项目根(Windows/UNC)
2424let workerRoot : string | null = null ; // Worker 内当前已加载的根
25+ let lastActiveRoot : string | null = null ; // 最近一次激活的项目根(用于后台清理后的恢复)
26+ let restoreActiveRootPromise : Promise < void > | null = null ; // 控制并发恢复,避免竞争
2527const cacheByRoot = new Map < string , FileCandidate [ ] > ( ) ;
2628const loadingByRoot = new Map < string , Promise < FileCandidate [ ] > > ( ) ;
2729const 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 ; // 缓存根目录数量上限,收紧多仓缓存占用
5470const KEY_PREFIX_DIR = "D" ;
5571const 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
5778function 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+
6186function 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
88226function 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
223367export 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 {
318471export 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 ( ) => {
0 commit comments