Skip to content

Commit 1869cc5

Browse files
var-ggclaude
andcommitted
Merge the timeline performance rebuild into main
Brings the windowed-pull timeline architecture (Phases 0-9), the GPT Pro review hardening, and the filter-chip dropdown virtualization onto main, ahead of the first Microsoft Store (MSIX) submission. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents ef2a05f + c0385e8 commit 1869cc5

19 files changed

Lines changed: 3905 additions & 813 deletions

src-tauri/src/cache.rs

Lines changed: 1137 additions & 71 deletions
Large diffs are not rendered by default.

src-tauri/src/commands.rs

Lines changed: 272 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::path::Path;
22
use std::time::{SystemTime, UNIX_EPOCH};
33

4-
use serde::Serialize;
4+
use serde::{Deserialize, Serialize};
55
use tauri::{AppHandle, Emitter, Manager};
66
use 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]
9283
pub 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]
140232
pub 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]
199371
pub 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.
427647
fn 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]
541758
pub 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

Comments
 (0)