11use std:: path:: Path ;
22use std:: time:: { SystemTime , UNIX_EPOCH } ;
33
4- use serde:: Serialize ;
4+ use serde:: { Deserialize , Serialize } ;
55use tauri:: { AppHandle , Emitter , Manager } ;
66use tauri_plugin_updater:: UpdaterExt ;
77
@@ -79,15 +79,6 @@ struct ScanComplete {
7979 count : usize ,
8080}
8181
82- #[ derive( Clone , Serialize ) ]
83- struct TimelineRepoFill {
84- commits : Vec < git:: CommitSummary > ,
85- /// True when these commits were just observed by the file watcher
86- /// (i.e. they're genuinely new since the user last looked). False on
87- /// initial discovery sweeps — those rows are pre-existing history.
88- fresh : bool ,
89- }
90-
9182#[ tauri:: command]
9283pub async fn list_recent_commits_cached (
9384 app : AppHandle ,
@@ -129,13 +120,114 @@ pub async fn recent_commits(
129120 all. truncate ( total) ;
130121
131122 let mut conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
132- cache:: upsert_commits ( & mut conn, & all) . map_err ( |e| e. to_string ( ) ) ?;
123+ let _ = cache:: upsert_commits ( & mut conn, & all) . map_err ( |e| e. to_string ( ) ) ?;
133124 Ok ( all)
134125 } )
135126 . await
136127 . map_err ( |e| e. to_string ( ) ) ?
137128}
138129
130+ /// Phase 1 windowed-pull API: one keyset-paginated page of the timeline.
131+ #[ tauri:: command]
132+ pub async fn list_commits_window (
133+ app : AppHandle ,
134+ filters : cache:: TimelineFilters ,
135+ cursor : Option < cache:: Cursor > ,
136+ direction : cache:: WindowDirection ,
137+ limit : usize ,
138+ ) -> Result < cache:: CommitWindow , String > {
139+ tauri:: async_runtime:: spawn_blocking ( move || -> Result < cache:: CommitWindow , String > {
140+ let conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
141+ cache:: list_commits_window ( & conn, & filters, cursor. as_ref ( ) , direction, limit)
142+ . map_err ( |e| e. to_string ( ) )
143+ } )
144+ . await
145+ . map_err ( |e| e. to_string ( ) ) ?
146+ }
147+
148+ /// Phase 1 windowed-pull API: rows centred on an anchor cursor.
149+ #[ tauri:: command]
150+ pub async fn list_commits_around_anchor (
151+ app : AppHandle ,
152+ filters : cache:: TimelineFilters ,
153+ anchor : cache:: Cursor ,
154+ before : usize ,
155+ after : usize ,
156+ ) -> Result < cache:: CommitAround , String > {
157+ tauri:: async_runtime:: spawn_blocking ( move || -> Result < cache:: CommitAround , String > {
158+ let conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
159+ cache:: list_commits_around_anchor ( & conn, & filters, & anchor, before, after, None )
160+ . map_err ( |e| e. to_string ( ) )
161+ } )
162+ . await
163+ . map_err ( |e| e. to_string ( ) ) ?
164+ }
165+
166+ /// Phase 9 windowed-pull API: a window centred on a 0-based rank — the
167+ /// random-access scrollbar's jump-load.
168+ #[ tauri:: command]
169+ pub async fn list_commits_at_rank (
170+ app : AppHandle ,
171+ filters : cache:: TimelineFilters ,
172+ rank : i64 ,
173+ before : usize ,
174+ after : usize ,
175+ ) -> Result < cache:: CommitAround , String > {
176+ tauri:: async_runtime:: spawn_blocking ( move || -> Result < cache:: CommitAround , String > {
177+ let conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
178+ cache:: list_commits_at_rank ( & conn, & filters, rank, before, after)
179+ . map_err ( |e| e. to_string ( ) )
180+ } )
181+ . await
182+ . map_err ( |e| e. to_string ( ) ) ?
183+ }
184+
185+ /// Phase 1 windowed-pull API: filtered total commit count for the scrollbar.
186+ #[ tauri:: command]
187+ pub async fn count_commits (
188+ app : AppHandle ,
189+ filters : cache:: TimelineFilters ,
190+ ) -> Result < i64 , String > {
191+ tauri:: async_runtime:: spawn_blocking ( move || -> Result < i64 , String > {
192+ let conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
193+ cache:: count_commits ( & conn, & filters) . map_err ( |e| e. to_string ( ) )
194+ } )
195+ . await
196+ . map_err ( |e| e. to_string ( ) ) ?
197+ }
198+
199+ /// Phase 2 windowed-pull API: the current commit generation. The frontend
200+ /// reads this once and pins it as its `view_generation` (a field on
201+ /// `TimelineFilters`) so the background scanner's later inserts never
202+ /// disturb the page sequence it is showing.
203+ #[ tauri:: command]
204+ pub async fn get_timeline_generation ( app : AppHandle ) -> Result < i64 , String > {
205+ tauri:: async_runtime:: spawn_blocking ( move || -> Result < i64 , String > {
206+ let conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
207+ cache:: current_generation ( & conn) . map_err ( |e| e. to_string ( ) )
208+ } )
209+ . await
210+ . map_err ( |e| e. to_string ( ) ) ?
211+ }
212+
213+ /// Phase 3/7 windowed-pull API: the AuthorsChip + RepoChip filter facets
214+ /// (author tallies + per-repo commit counts) under `filters`. The windowed
215+ /// timeline holds no full client-side commit array to tally from.
216+ #[ tauri:: command]
217+ pub async fn list_filter_facets (
218+ app : AppHandle ,
219+ filters : cache:: TimelineFilters ,
220+ ) -> Result < cache:: FilterFacets , String > {
221+ tauri:: async_runtime:: spawn_blocking (
222+ move || -> Result < cache:: FilterFacets , String > {
223+ let conn = cache:: open ( & app) . map_err ( |e| e. to_string ( ) ) ?;
224+ cache:: list_filter_facets ( & conn, & filters) . map_err ( |e| e. to_string ( ) )
225+ } ,
226+ )
227+ . await
228+ . map_err ( |e| e. to_string ( ) ) ?
229+ }
230+
139231#[ tauri:: command]
140232pub async fn list_branches ( repo_path : String ) -> Result < Vec < git:: BranchInfo > , String > {
141233 tauri:: async_runtime:: spawn_blocking ( move || {
@@ -195,16 +287,144 @@ pub async fn hide_repo(
195287 . map_err ( |e| e. to_string ( ) ) ?
196288}
197289
290+ /// Phase 6 detail-tier cache: an in-memory LRU of `changed_files` results,
291+ /// so expanding (or re-expanding) a commit doesn't recompute the git diff.
292+ /// Bounded by entry count; the file list of a very large commit is skipped
293+ /// (rare, and recomputing it on demand is fine). Lost on restart — it is a
294+ /// pure cache. Managed in `lib.rs` and shared by `changed_files` +
295+ /// `changed_files_batch`.
296+ pub struct ChangedFilesCache ( std:: sync:: Mutex < ChangedFilesLru > ) ;
297+
298+ struct ChangedFilesLru {
299+ /// key (`repo_path\0hash`) → (file list, last-access tick)
300+ entries : std:: collections:: HashMap < String , ( Vec < git:: ChangedFile > , u64 ) > ,
301+ tick : u64 ,
302+ }
303+
304+ /// Max cached commits before LRU eviction kicks in.
305+ const CHANGED_FILES_CACHE_CAP : usize = 256 ;
306+ /// Skip caching a commit whose changed-file list exceeds this — one huge
307+ /// entry would dominate the cache; recomputing it on demand is fine.
308+ const CHANGED_FILES_CACHE_MAX_FILES : usize = 1_000 ;
309+ /// Upper bound on commits one `changed_files_batch` call will process.
310+ const CHANGED_FILES_PREFETCH_CAP : usize = 100 ;
311+
312+ impl Default for ChangedFilesCache {
313+ fn default ( ) -> Self {
314+ Self ( std:: sync:: Mutex :: new ( ChangedFilesLru {
315+ entries : std:: collections:: HashMap :: new ( ) ,
316+ tick : 0 ,
317+ } ) )
318+ }
319+ }
320+
321+ impl ChangedFilesCache {
322+ fn key ( repo_path : & str , hash : & str ) -> String {
323+ format ! ( "{repo_path}\0 {hash}" )
324+ }
325+
326+ fn get ( & self , repo_path : & str , hash : & str ) -> Option < Vec < git:: ChangedFile > > {
327+ let mut lru = self . 0 . lock ( ) . ok ( ) ?;
328+ lru. tick += 1 ;
329+ let tick = lru. tick ;
330+ let entry = lru. entries . get_mut ( & Self :: key ( repo_path, hash) ) ?;
331+ entry. 1 = tick;
332+ Some ( entry. 0 . clone ( ) )
333+ }
334+
335+ fn contains ( & self , repo_path : & str , hash : & str ) -> bool {
336+ self . 0
337+ . lock ( )
338+ . map ( |lru| lru. entries . contains_key ( & Self :: key ( repo_path, hash) ) )
339+ . unwrap_or ( false )
340+ }
341+
342+ fn put ( & self , repo_path : & str , hash : & str , files : & [ git:: ChangedFile ] ) {
343+ if files. len ( ) > CHANGED_FILES_CACHE_MAX_FILES {
344+ return ;
345+ }
346+ let Ok ( mut lru) = self . 0 . lock ( ) else {
347+ return ;
348+ } ;
349+ lru. tick += 1 ;
350+ let tick = lru. tick ;
351+ lru. entries
352+ . insert ( Self :: key ( repo_path, hash) , ( files. to_vec ( ) , tick) ) ;
353+ // Evict least-recently-used entries down to the cap.
354+ while lru. entries . len ( ) > CHANGED_FILES_CACHE_CAP {
355+ let victim = lru
356+ . entries
357+ . iter ( )
358+ . min_by_key ( |( _, ( _, t) ) | * t)
359+ . map ( |( k, _) | k. clone ( ) ) ;
360+ match victim {
361+ Some ( k) => {
362+ lru. entries . remove ( & k) ;
363+ }
364+ None => break ,
365+ }
366+ }
367+ }
368+ }
369+
198370#[ tauri:: command]
199371pub async fn changed_files (
372+ app : AppHandle ,
200373 repo_path : String ,
201374 hash : String ,
202375) -> Result < Vec < git:: ChangedFile > , String > {
376+ tauri:: async_runtime:: spawn_blocking (
377+ move || -> Result < Vec < git:: ChangedFile > , String > {
378+ if let Some ( cache) = app. try_state :: < ChangedFilesCache > ( ) {
379+ if let Some ( hit) = cache. get ( & repo_path, & hash) {
380+ return Ok ( hit) ;
381+ }
382+ }
383+ let files = git:: changed_files ( Path :: new ( & repo_path) , & hash)
384+ . map_err ( |e| e. to_string ( ) ) ?;
385+ if let Some ( cache) = app. try_state :: < ChangedFilesCache > ( ) {
386+ cache. put ( & repo_path, & hash, & files) ;
387+ }
388+ Ok ( files)
389+ } ,
390+ )
391+ . await
392+ . map_err ( |e| e. to_string ( ) ) ?
393+ }
394+
395+ /// One commit reference for `changed_files_batch`.
396+ #[ derive( Deserialize ) ]
397+ #[ serde( rename_all = "camelCase" ) ]
398+ pub struct CommitRef {
399+ pub repo_path : String ,
400+ pub hash : String ,
401+ }
402+
403+ /// Phase 6 detail-tier prefetch: warm the `changed_files` cache for a set
404+ /// of commits — the rows in/near the timeline viewport — so expanding one
405+ /// is instant. Already-cached commits are skipped; the batch is capped so
406+ /// a huge request can't run unbounded git work.
407+ #[ tauri:: command]
408+ pub async fn changed_files_batch (
409+ app : AppHandle ,
410+ commits : Vec < CommitRef > ,
411+ ) -> Result < ( ) , String > {
203412 tauri:: async_runtime:: spawn_blocking ( move || {
204- git:: changed_files ( Path :: new ( & repo_path) , & hash) . map_err ( |e| e. to_string ( ) )
413+ let Some ( cache) = app. try_state :: < ChangedFilesCache > ( ) else {
414+ return ;
415+ } ;
416+ for c in commits. into_iter ( ) . take ( CHANGED_FILES_PREFETCH_CAP ) {
417+ if cache. contains ( & c. repo_path , & c. hash ) {
418+ continue ;
419+ }
420+ if let Ok ( files) = git:: changed_files ( Path :: new ( & c. repo_path ) , & c. hash ) {
421+ cache. put ( & c. repo_path , & c. hash , & files) ;
422+ }
423+ }
205424 } )
206425 . await
207- . map_err ( |e| e. to_string ( ) ) ?
426+ . map_err ( |e| e. to_string ( ) ) ?;
427+ Ok ( ( ) )
208428}
209429
210430#[ tauri:: command]
@@ -377,12 +597,11 @@ pub async fn open_diff(
377597
378598 eprintln ! ( "open_diff: building new diff window" ) ;
379599 let saved = settings:: load ( & app) . diff_window ;
380- let ( init_w, init_h) = match saved {
381- Some ( s) if monitor_can_contain ( & app, s. x , s. y , s. w , s. h ) => {
382- ( s. w as f64 , s. h as f64 )
383- }
384- _ => default_diff_size ( & app) ,
385- } ;
600+ // Always open at the modest default size. A remembered window size
601+ // restored across DPI scale factors was bloating the window (saved in
602+ // physical px, re-applied as logical); the user resizes from here.
603+ // Position + maximized state are still restored below.
604+ let ( init_w, init_h) = default_diff_size ( & app) ;
386605
387606 let mut builder = tauri:: WebviewWindowBuilder :: new (
388607 & app,
@@ -421,24 +640,22 @@ pub async fn open_diff(
421640 Ok ( ( ) )
422641}
423642
424- /// Pick a sensible default diff-window size based on the user's primary
425- /// monitor — ~70% of its dimensions, clamped to [800x600 .. 1400x900] so
426- /// it doesn't sprawl across multiple monitors on the first open.
643+ /// The diff window's default size — a modest fixed size the user resizes
644+ /// from. Returned in logical pixels (the window builder's `inner_size`
645+ /// unit). Clamped to fit the primary monitor so it never opens larger than
646+ /// the screen on a small display.
427647fn default_diff_size ( app : & AppHandle ) -> ( f64 , f64 ) {
428- const MIN_W : f64 = 800.0 ;
429- const MIN_H : f64 = 600.0 ;
430- const MAX_W : f64 = 1400.0 ;
431- const MAX_H : f64 = 900.0 ;
648+ const WANT_W : f64 = 1024.0 ;
649+ const WANT_H : f64 = 720.0 ;
432650 if let Ok ( Some ( monitor) ) = app. primary_monitor ( ) {
433- let size = monitor. size ( ) ;
434651 let scale = monitor. scale_factor ( ) ;
435- let logical_w = size. width as f64 / scale;
436- let logical_h = size. height as f64 / scale;
437- let w = ( logical_w * 0.70 ) . clamp ( MIN_W , MAX_W ) ;
438- let h = ( logical_h * 0.70 ) . clamp ( MIN_H , MAX_H ) ;
652+ let logical_w = monitor . size ( ) . width as f64 / scale;
653+ let logical_h = monitor . size ( ) . height as f64 / scale;
654+ let w = WANT_W . min ( logical_w - 80.0 ) . max ( 640.0 ) ;
655+ let h = WANT_H . min ( logical_h - 80.0 ) . max ( 480.0 ) ;
439656 return ( w, h) ;
440657 }
441- ( 1100.0 , 750.0 )
658+ ( WANT_W , WANT_H )
442659}
443660
444661/// Sanity-check a saved (x, y, w, h) against the current monitor layout —
@@ -539,12 +756,14 @@ pub struct UpdateStatePayload {
539756/// plus whether this is a Scoop install.
540757#[ tauri:: command]
541758pub fn update_get_state ( app : AppHandle ) -> UpdateStatePayload {
759+ // Never panic the IPC command on a poisoned mutex — a poisoned lock
760+ // still carries the last value; fall back to "no update" otherwise.
542761 let available = app
543762 . state :: < update:: UpdateState > ( )
544763 . available
545764 . lock ( )
546- . unwrap ( )
547- . clone ( ) ;
765+ . map ( |g| g . clone ( ) )
766+ . unwrap_or ( None ) ;
548767 UpdateStatePayload {
549768 available,
550769 scoop : update:: installed_via_scoop ( ) ,
@@ -605,6 +824,7 @@ pub async fn discover_repos(app: AppHandle) -> Result<usize, String> {
605824 . unwrap_or_default ( ) ;
606825 let path_str = path. to_string_lossy ( ) . into_owned ( ) ;
607826 let repo = cache:: Repo {
827+ id : 0 ,
608828 path : path_str. clone ( ) ,
609829 name : name. clone ( ) ,
610830 status : "active" . to_string ( ) ,
@@ -631,13 +851,25 @@ pub async fn discover_repos(app: AppHandle) -> Result<usize, String> {
631851 . collect :: < Vec < _ > > ( ) ;
632852
633853 if !commits. is_empty ( ) {
634- if let Ok ( mut conn) = cache:: open ( & app) {
635- let _ = cache:: upsert_commits ( & mut conn, & commits) ;
854+ let outcome = cache:: open ( & app) . ok ( ) . and_then ( |mut conn| {
855+ // Upsert the repo row FIRST so upsert_commits can
856+ // resolve a real repo_id — its COALESCE falls back
857+ // to 0 when the repos row does not exist yet.
858+ cache:: upsert_repos ( & mut conn, std:: slice:: from_ref ( & repo) ) . ok ( ) ?;
859+ cache:: upsert_commits ( & mut conn, & commits) . ok ( )
860+ } ) ;
861+ // Lightweight windowed-pull signal — the frontend
862+ // re-pulls the affected windows from the cache.
863+ if let Some ( o) = outcome {
864+ let _ = app. emit (
865+ "timeline://invalidated" ,
866+ cache:: TimelineInvalidated {
867+ generation : o. generation ,
868+ inserted : o. inserted ,
869+ repo_path : path_str. clone ( ) ,
870+ } ,
871+ ) ;
636872 }
637- let _ = app. emit (
638- "timeline://repo-fill" ,
639- TimelineRepoFill { commits, fresh : false } ,
640- ) ;
641873 }
642874
643875 // Attach the file watcher to this newly-discovered repo.
0 commit comments