From 370402d0539eae034dcce1caa79635178956c55c Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 20 Jul 2026 19:36:31 +0200 Subject: [PATCH] perf: cache per-repo install status instead of per-frame disk I/O The grid stat'ed the filesystem for every card on every redraw (installed_app_path + a version-file read), and the detail footer did the same. Install state only changes at catalog load, download completion and uninstall: a cache refreshed exactly there now feeds the views, which no longer touch the disk per frame. (The detail Launch button keeps resolving the real path once per render - it needs the PathBuf, and it is a single page.) Also collapses a match-arm guard in the desktop-entry scan skip that the just-released clippy 1.97 rejects (collapsible_match) - CI runs resolve the stable toolchain at run time, so every future run would have failed on it, the pending release included. --- src/main.rs | 2 ++ src/scan.rs | 6 ++---- src/state.rs | 6 ++++++ src/ui/app_grid.rs | 8 ++++++-- src/ui/detail.rs | 6 +++++- src/update.rs | 25 +++++++++++++++++++++++++ 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index b557d33..9092aad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -240,6 +240,7 @@ impl App { update_queue: Vec::new(), release_notes: std::collections::HashMap::new(), fetching_notes: std::collections::HashSet::new(), + install_status: std::collections::HashMap::new(), keyboard_cursor: None, window_size: ( prefs.window_width.unwrap_or(1000.0).clamp(640.0, 7680.0), @@ -256,6 +257,7 @@ impl App { // Cached catalog repos may have icons already on disk: decode them now // so the offline/pre-fetch grid is not a wall of fallback hexagons. app.reload_app_icons(); + app.refresh_install_status(); set_active_theme(&app.selected_theme, &app.selected_variant); set_high_contrast(app.high_contrast); diff --git a/src/scan.rs b/src/scan.rs index 0d940bb..a88a921 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -606,10 +606,8 @@ fn parse_desktop_file(path: &Path) -> Result { // desktop launchers should show them, but Colony's own scan // must skip them - the app is already represented by its // store card, a local duplicate would appear twice. - "X-Colony-Managed" => { - if value.eq_ignore_ascii_case("true") { - anyhow::bail!("Colony-managed entry (represented by its store card)"); - } + "X-Colony-Managed" if value.eq_ignore_ascii_case("true") => { + anyhow::bail!("Colony-managed entry (represented by its store card)"); } _ => {} } diff --git a/src/state.rs b/src/state.rs index 444c8c8..3ad2c85 100644 --- a/src/state.rs +++ b/src/state.rs @@ -250,6 +250,11 @@ pub struct App { std::collections::HashMap)>, /// Repos whose release notes are currently being fetched. pub fetching_notes: std::collections::HashSet, + /// Install status per store repo: (installed, installed version). The + /// grid used to stat the filesystem per card per FRAME; this cache is + /// refreshed only when it can actually change (catalog load, install + /// completion, uninstall). + pub install_status: std::collections::HashMap)>, /// Keyboard-highlighted grid row, as a stable key ("repo:" or /// "app:") so catalog refreshes and re-filters cannot silently move /// the highlight to a different item. Cleared on search/section changes. @@ -544,6 +549,7 @@ impl App { update_queue: Vec::new(), release_notes: std::collections::HashMap::new(), fetching_notes: std::collections::HashSet::new(), + install_status: std::collections::HashMap::new(), keyboard_cursor: None, window_size: (1000.0, 700.0), window_save_gen: 0, diff --git a/src/ui/app_grid.rs b/src/ui/app_grid.rs index 854b440..e22b9a6 100644 --- a/src/ui/app_grid.rs +++ b/src/ui/app_grid.rs @@ -180,7 +180,11 @@ impl App { /// Informational (the action lives on the detail page) so the whole card can /// stay a single button that opens the detail view. fn repo_status<'a>(&self, repo: &'a ColonyRepo) -> Element<'a, Message> { - let installed = crate::github::installed_app_path(repo).is_some(); + let (installed, cached_version) = self + .install_status + .get(&repo.name) + .cloned() + .unwrap_or((false, None)); if !installed { return text(crate::i18n::t("status_get")) .size(self.sz(12)) @@ -188,7 +192,7 @@ impl App { .color(Palette::ACCENT()) .into(); } - let version = crate::github::load_installed_version(&repo.name); + let version = cached_version; if let Some(new_tag) = self.available_updates.get(&repo.name) { let head = row![ text(HEX_FILLED) diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 459916b..a055270 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -181,7 +181,11 @@ impl App { let mut footer_items: Vec> = Vec::new(); // Store-basics metadata: the installed version was recorded at install // time but never shown anywhere on the detail page. - if let Some(installed) = github::load_installed_version(&repo.name) { + if let Some(installed) = self + .install_status + .get(&repo.name) + .and_then(|(_, v)| v.clone()) + { footer_items.push( text(crate::i18n::t_fmt( "installed_version", diff --git a/src/update.rs b/src/update.rs index aa32e97..b39dd58 100644 --- a/src/update.rs +++ b/src/update.rs @@ -67,6 +67,26 @@ impl App { } } + /// Rebuild the per-repo install-status cache (one filesystem pass). The + /// grid and detail views read ONLY this cache - never the disk - so it + /// must be called whenever an install can have changed: catalog load, + /// download completion, uninstall. + pub fn refresh_install_status(&mut self) { + self.install_status = self + .colony_repo_list + .iter() + .map(|repo| { + let installed = github::installed_app_path(repo).is_some(); + let version = if installed { + github::load_installed_version(&repo.name) + } else { + None + }; + (repo.name.clone(), (installed, version)) + }) + .collect(); + } + /// Pop the next repo queued by "Update all" and start its download; no-op /// when the queue is empty. Called from BOTH completion arms so one failed /// install never strands the remaining queue. @@ -399,6 +419,7 @@ impl App { crate::persistence::prune_orphaned_repo_caches(&live); // Decode any freshly-cached app icons into image handles. self.reload_app_icons(); + self.refresh_install_status(); // New docs may have landed for the repo currently viewed. self.detail_md_source = None; self.refresh_detail_markdown(); @@ -588,6 +609,7 @@ impl App { // advertising: clear it, or the card keeps showing // "Update vX -> vX" until the next global check. self.available_updates.remove(&repo_name); + self.refresh_install_status(); let display_name = path .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -697,6 +719,9 @@ impl App { } else { self.status_message = i18n::t_fmt("uninstalled", &[("name", &repo_name)]); + // AFTER the directory removal, so the cache + // records the app as gone. + self.refresh_install_status(); return Task::perform( async { tokio::time::sleep(Duration::from_secs(4)).await;