diff --git a/src/github.rs b/src/github.rs index d7a9593..1d55e91 100644 --- a/src/github.rs +++ b/src/github.rs @@ -22,16 +22,9 @@ pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// cannot fire an unbounded burst of requests at the GitHub API at once. const MAX_CONCURRENT_REPO_FETCHES: usize = 8; -// Storage and download/self-update now live in dedicated modules; re-export -// their public API so existing `github::` call sites keep working unchanged. -pub use crate::download::{apply_launcher_update, download_launcher_asset, download_release_asset}; use crate::persistence::save_repo_doc; use crate::persistence::save_repo_icon; -pub use crate::persistence::{ - colony_apps_dir, colony_data_dir, installed_app_path, load_favorites, load_installed_version, - load_preferences, load_repos_cache, load_scan_cache, read_repo_doc, save_favorites, - save_preferences, save_repos_cache, save_scan_cache, CachedApp, UserPreferences, -}; +use crate::persistence::{load_installed_version, load_repos_cache}; // --- HTTP ETag Cache --- @@ -909,7 +902,9 @@ pub async fn check_update_available( pinned_tag.to_string() }; - if target == installed { + // Case-insensitive: "Nightly" vs "nightly" must not read as an update + // (with non-semver tags the string fallback below would flag it forever). + if target.eq_ignore_ascii_case(&installed) { return None; } diff --git a/src/i18n.rs b/src/i18n.rs index 201dc46..3646f51 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -19,7 +19,7 @@ impl Locale { strings.insert("rescan".into(), "Rescan".into()); // GitHub panel - strings.insert("github_connect_desc".into(), "Connectez-vous à GitHub pour détecter les dépôts Colony (colony.json) du compte MotherSphere.".into()); + strings.insert("github_connect_desc".into(), "Connectez-vous à GitHub pour détecter les dépôts Colony (colony.json) de l'organisation Project-Colony.".into()); strings.insert("github_login".into(), "Se connecter avec GitHub".into()); strings.insert( "github_public_api".into(), @@ -162,7 +162,7 @@ impl Locale { // First launch — carousel (3 steps) strings.insert("welcome_title".into(), "Bienvenue dans Colony".into()); - strings.insert("welcome_desc".into(), "Le lanceur centralisé de l'écosystème MotherSphere. Découvrez, installez et lancez vos apps en un clic.".into()); + strings.insert("welcome_desc".into(), "Le lanceur centralisé de l'écosystème Project-Colony. Découvrez, installez et lancez vos apps en un clic.".into()); // Step 1 — interface tour strings.insert( "welcome_step1_title".into(), @@ -701,6 +701,12 @@ impl Locale { strings.insert("launch_action".into(), "Lancer".into()); strings.insert("section_security".into(), "Sécurité".into()); strings.insert("language_changed".into(), "Langue changée".into()); + strings.insert("clear_caches".into(), "Vider les caches du store".into()); + strings.insert("clear_caches_desc".into(), "Supprime les descriptions et icônes mises en cache (elles se re-téléchargent au prochain rafraîchissement). Les applications installées ne sont pas touchées.".into()); + strings.insert( + "caches_cleared".into(), + "{count} cache(s) supprimé(s)".into(), + ); strings.insert("launcher_update_system_managed".into(), "Mise à jour {version} disponible - cette installation est gérée par le gestionnaire de paquets, mettez à jour via « pacman -Syu » (colony-bin)".into()); // Detail tabs strings.insert("tab_readme".into(), "ReadMe".into()); @@ -716,7 +722,7 @@ impl Locale { strings.insert("rescan".into(), "Rescan".into()); // GitHub panel - strings.insert("github_connect_desc".into(), "Connect to GitHub to detect Colony repos (colony.json) from the MotherSphere account.".into()); + strings.insert("github_connect_desc".into(), "Connect to GitHub to detect Colony repos (colony.json) from the Project-Colony organization.".into()); strings.insert("github_login".into(), "Sign in with GitHub".into()); strings.insert( "github_public_api".into(), @@ -844,7 +850,7 @@ impl Locale { // First launch — carousel (3 steps) strings.insert("welcome_title".into(), "Welcome to Colony".into()); - strings.insert("welcome_desc".into(), "The centralized launcher for the MotherSphere ecosystem. Discover, install and launch apps in one click.".into()); + strings.insert("welcome_desc".into(), "The centralized launcher for the Project-Colony ecosystem. Discover, install and launch apps in one click.".into()); // Step 1 — interface tour strings.insert( "welcome_step1_title".into(), @@ -1337,6 +1343,9 @@ impl Locale { strings.insert("launch_action".into(), "Launch".into()); strings.insert("section_security".into(), "Security".into()); strings.insert("language_changed".into(), "Language changed".into()); + strings.insert("clear_caches".into(), "Clear store caches".into()); + strings.insert("clear_caches_desc".into(), "Removes cached descriptions and icons (they re-download on the next refresh). Installed applications are not touched.".into()); + strings.insert("caches_cleared".into(), "{count} cache(s) removed".into()); strings.insert("launcher_update_system_managed".into(), "Update {version} is available - this install is managed by the package manager, update via 'pacman -Syu' (colony-bin)".into()); // Detail tabs strings.insert("tab_readme".into(), "ReadMe".into()); diff --git a/src/main.rs b/src/main.rs index 9092aad..894f6d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,7 +39,7 @@ pub fn main() -> iced::Result { // Honor the saved language preference over environment locale detection, // and reopen at the last persisted window size (clamped to sanity). - let prefs = github::load_preferences(); + let prefs = crate::persistence::load_preferences(); i18n::init(prefs.language.clone()); let width = prefs.window_width.unwrap_or(1000.0).clamp(640.0, 7680.0); let height = prefs.window_height.unwrap_or(700.0).clamp(480.0, 4320.0); @@ -67,7 +67,7 @@ fn load_fonts() -> Task { impl App { fn boot() -> (Self, Task) { - let prefs = github::load_preferences(); + let prefs = crate::persistence::load_preferences(); // The filesystem application scan and its cache write are deferred off // the boot path (dispatched as a Rescan task below) so the window @@ -79,7 +79,7 @@ impl App { // The startup scan is disabled: restore the last scan from cache // (which was written on every scan but never read back) instead of // greeting the user with a permanently empty local-apps grid. - github::load_scan_cache() + crate::persistence::load_scan_cache() .unwrap_or_default() .into_iter() .map(|c| scan::Application { @@ -106,7 +106,7 @@ impl App { let font = default_font(); - let favorites = github::load_favorites(); + let favorites = crate::persistence::load_favorites(); // Determine initial section: if restore_session is on use last section, // otherwise use default_view to pick "favorites" section if configured. @@ -141,7 +141,7 @@ impl App { // refresh over the network - anonymously when no token is saved (the // unauthenticated GitHub API allows 60 req/h, plenty for one boot // fetch). Signing in is optional, exactly as the welcome flow promises. - let colony_repo_list = github::load_repos_cache().unwrap_or_default(); + let colony_repo_list = crate::persistence::load_repos_cache().unwrap_or_default(); let startup_task = { let token = match &github_state { GitHubState::Connected { session } => Some(session.access_token.clone()), diff --git a/src/message.rs b/src/message.rs index 777914e..b57d07b 100644 --- a/src/message.rs +++ b/src/message.rs @@ -56,6 +56,8 @@ pub enum Message { FetchReleaseNotes(String), /// (repo, Ok((tag, body_markdown))) - notes fetched (or failed). ReleaseNotesFetched(String, Result<(String, String), String>), + /// Manual "clear store caches" from Settings > Storage. + ClearStoreCaches, WindowResized(f32, f32), /// Debounced save of the window size (fires 1s after the LAST resize). PersistWindowSize(u64), diff --git a/src/oauth.rs b/src/oauth.rs index 6b28f4c..ec23ae7 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -199,7 +199,7 @@ const KEYRING_SERVICE: &str = "colony-launcher"; const KEYRING_USER: &str = "github-oauth"; fn token_path() -> PathBuf { - match crate::github::colony_data_dir() { + match crate::persistence::colony_data_dir() { Ok(dir) => { let auth_dir = dir.join("auth"); let _ = std::fs::create_dir_all(&auth_dir); diff --git a/src/persistence.rs b/src/persistence.rs index 3493dc2..a5ad07a 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -312,6 +312,27 @@ fn desktop_entry_filename(repo_name: &str) -> String { format!("colony-{}.desktop", repo_name.to_lowercase()) } +/// Remove ALL store caches (docs + icons for every repo). Manual cache +/// management from Settings > Storage; installs and preferences are NOT +/// touched. Returns the number of cache directories removed. +pub fn clear_store_caches() -> usize { + let Ok(base) = colony_data_dir() else { + return 0; + }; + let mut removed = 0; + for parent in ["repo-docs", "repo-icons"] { + let Ok(entries) = std::fs::read_dir(base.join(parent)) else { + continue; + }; + for entry in entries.flatten() { + if std::fs::remove_dir_all(entry.path()).is_ok() { + removed += 1; + } + } + } + removed +} + /// Remove doc/icon caches for repos that are NO LONGER in the catalog, so a /// deleted or renamed repo does not leave its caches behind forever. Runs /// after each successful catalog fetch (never on a cache fallback, where a diff --git a/src/ui/detail.rs b/src/ui/detail.rs index a055270..08866fc 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -208,7 +208,7 @@ impl App { .align_y(iced::Alignment::End); // Action row: Launch if installed, Download if not - let installed_path = github::installed_app_path(repo); + let installed_path = crate::persistence::installed_app_path(repo); let current_platform = github::current_platform_key(); let action_row = if let Some(app_path) = installed_path { diff --git a/src/ui/github_panel.rs b/src/ui/github_panel.rs index e1d66f4..6b09767 100644 --- a/src/ui/github_panel.rs +++ b/src/ui/github_panel.rs @@ -279,15 +279,24 @@ impl App { .font(self.app_font_with_weight(Weight::Medium)) .color(Palette::TEXT_PRIMARY()); - let card = container(name) + // Clickable: opens the repo's detail page (the panel used to render an + // inert list that duplicated the grid without its function). + button(name) + .on_press(Message::ColonyRepoSelected(repo.name.clone())) .padding([10, 14]) .width(Fill) - .style(|_theme| container::Style { - background: Some(Palette::BG_CARD().into()), - border: iced::Border::default().rounded(8), - ..Default::default() - }); - - card.into() + .style(|_theme, status| { + let bg = match status { + button::Status::Hovered => Palette::BG_CARD_HOVER(), + _ => Palette::BG_CARD(), + }; + button::Style { + background: Some(bg.into()), + text_color: Palette::TEXT_PRIMARY(), + border: iced::Border::default().rounded(8), + ..Default::default() + } + }) + .into() } } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 9caca00..7f74883 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -556,6 +556,30 @@ impl App { sections = sections.push(container(text("")).height(6)); + // Section: Caches + sections = sections.push( + self.view_collapsible_section( + "caches", + &i18n::t("clear_caches"), + column![ + text(i18n::t("clear_caches_desc")) + .size(self.sz(12)) + .font(self.app_font()) + .color(Palette::TEXT_MUTED()), + container(text("")).height(12), + self.action_button( + "\u{f1f8}", + i18n::t("clear_caches"), + Message::ClearStoreCaches, + ), + ] + .spacing(0) + .into(), + ), + ); + + sections = sections.push(container(text("")).height(6)); + // Section: Installation let apps_count = self.applications.len().to_string(); let repos_count = self.colony_repos().len().to_string(); diff --git a/src/update.rs b/src/update.rs index 6276c1c..4a0bdfe 100644 --- a/src/update.rs +++ b/src/update.rs @@ -76,9 +76,9 @@ impl App { .colony_repo_list .iter() .map(|repo| { - let installed = github::installed_app_path(repo).is_some(); + let installed = crate::persistence::installed_app_path(repo).is_some(); let version = if installed { - github::load_installed_version(&repo.name) + crate::persistence::load_installed_version(&repo.name) } else { None }; @@ -117,14 +117,17 @@ impl App { // does no disk I/O. let (content, is_placeholder) = match self.detail_tab { DetailTab::ReadMe => (repo.description.clone(), false), - DetailTab::License => match github::read_repo_doc(&repo.name, "LICENSE.md") { - Some(c) => (c, false), - None => (String::new(), true), - }, - DetailTab::Changelog => match github::read_repo_doc(&repo.name, "CHANGELOG.md") { + DetailTab::License => match crate::persistence::read_repo_doc(&repo.name, "LICENSE.md") + { Some(c) => (c, false), None => (String::new(), true), }, + DetailTab::Changelog => { + match crate::persistence::read_repo_doc(&repo.name, "CHANGELOG.md") { + Some(c) => (c, false), + None => (String::new(), true), + } + } }; self.detail_blocks = markdown_blocks::parse(&content); self.detail_is_placeholder = is_placeholder; @@ -132,7 +135,7 @@ impl App { } pub fn save_preferences(&self) { - let prefs = github::UserPreferences { + let prefs = crate::persistence::UserPreferences { selected_section: Some(self.selected_section), window_width: Some(self.window_size.0), window_height: Some(self.window_size.1), @@ -160,7 +163,7 @@ impl App { // Storage scan_on_startup: Some(self.scan_on_startup), }; - if let Err(e) = github::save_preferences(&prefs) { + if let Err(e) = crate::persistence::save_preferences(&prefs) { tracing::warn!("Failed to save preferences: {e}"); } } @@ -220,9 +223,9 @@ impl App { i18n::t_fmt("apps_found", &[("count", &apps.len().to_string())]); // Refresh the offline scan cache (previously written on // the boot path, now that the scan runs off-thread). - let cached: Vec = apps + let cached: Vec = apps .iter() - .map(|app| github::CachedApp { + .map(|app| crate::persistence::CachedApp { name: app.name.clone(), exec: app.exec.clone(), icon: app.icon.clone(), @@ -230,7 +233,7 @@ impl App { origin: format!("{:?}", app.origin), }) .collect(); - if let Err(e) = github::save_scan_cache(&cached) { + if let Err(e) = crate::persistence::save_scan_cache(&cached) { tracing::warn!("Failed to save scan cache: {e}"); } self.applications = apps; @@ -300,6 +303,9 @@ impl App { } Message::ColonyRepoSelected(name) => { self.active_colony_repo = Some(name); + // A selection made from the GitHub panel must actually show + // the detail page, not stay hidden behind the overlay. + self.show_github_menu = false; self.refresh_detail_markdown(); Task::none() } @@ -403,7 +409,7 @@ impl App { Message::GitHubReposFetched(repos) => { self.is_fetching_repos = false; let count = repos.len(); - if let Err(e) = github::save_repos_cache(&repos) { + if let Err(e) = crate::persistence::save_repos_cache(&repos) { tracing::warn!("Failed to save repos cache: {e}"); } // The catalog is stored regardless of sign-in state: anonymous @@ -435,7 +441,7 @@ impl App { self.is_fetching_repos = false; tracing::error!(error = %e, "GitHub error"); if self.colony_repo_list.is_empty() { - if let Some(cached) = github::load_repos_cache() { + if let Some(cached) = crate::persistence::load_repos_cache() { tracing::info!("Using {} cached repos as fallback", cached.len()); self.colony_repo_list = cached; } @@ -529,7 +535,7 @@ impl App { // in DownloadCompleted) meant a cancel landing // mid-install detached the blocking task and // left an installed binary with no metadata. - let path = github::download_release_asset( + let path = crate::download::download_release_asset( None, crate::download::AssetInstall { repo_name: repo_name.clone(), @@ -652,7 +658,7 @@ impl App { // The aborted task cannot clean up its staging file: sweep // the cancelled repo's *.part leftovers here. if let Some(repo) = self.downloading_repo.take() { - if let Ok(apps_dir) = github::colony_apps_dir() { + if let Ok(apps_dir) = crate::persistence::colony_apps_dir() { if let Ok(entries) = std::fs::read_dir(apps_dir.join(&repo)) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); @@ -715,7 +721,7 @@ impl App { // cleanup happens on catalog refresh instead.) self.release_notes.remove(&repo_name); crate::persistence::remove_desktop_entry(&repo_name); - match github::colony_apps_dir() { + match crate::persistence::colony_apps_dir() { Ok(apps_dir) => { let app_dir = apps_dir.join(&repo_name); if app_dir.exists() { @@ -760,6 +766,16 @@ impl App { }, ) } + Message::ClearStoreCaches => { + let removed = crate::persistence::clear_store_caches(); + self.app_icons.clear(); + self.release_notes.clear(); + self.detail_md_source = None; + self.refresh_detail_markdown(); + let msg = i18n::t_fmt("caches_cleared", &[("count", &removed.to_string())]); + self.status_message = msg.clone(); + self.push_notification(msg, NotificationLevel::Info) + } Message::CopyToClipboard(value) => iced::clipboard::write(value), Message::OpenUrl(url) => { if let Err(err) = open::that(&url) { @@ -996,7 +1012,7 @@ impl App { let repos: Vec<(String, String)> = self .colony_repos() .iter() - .filter(|r| github::installed_app_path(r).is_some()) + .filter(|r| crate::persistence::installed_app_path(r).is_some()) .filter_map(|r| { r.manifest .release_files @@ -1173,7 +1189,7 @@ impl App { } else { self.favorites.push(name); } - if let Err(e) = github::save_favorites(&self.favorites) { + if let Err(e) = crate::persistence::save_favorites(&self.favorites) { tracing::warn!("Failed to save favorites: {e}"); } Task::none() @@ -1430,9 +1446,14 @@ impl App { let download_task = Task::perform( async move { - github::download_launcher_asset(token, tag, asset, Some(progress_tx)) - .await - .map_err(|e| e.to_string()) + crate::download::download_launcher_asset( + token, + tag, + asset, + Some(progress_tx), + ) + .await + .map_err(|e| e.to_string()) }, Message::LauncherDownloadCompleted, ); @@ -1481,7 +1502,8 @@ impl App { Message::ApplyLauncherUpdate(new_binary) => Task::perform( async move { tokio::task::spawn_blocking(move || { - github::apply_launcher_update(&new_binary).map_err(|e| e.to_string()) + crate::download::apply_launcher_update(&new_binary) + .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string()) @@ -1735,10 +1757,13 @@ mod tests { let mut app = App::new_for_test(); let _ = app.update(Message::ToggleFavorite("Grape".into())); assert!(app.is_favorite("Grape")); - assert_eq!(crate::github::load_favorites(), vec!["Grape".to_string()]); + assert_eq!( + crate::persistence::load_favorites(), + vec!["Grape".to_string()] + ); let _ = app.update(Message::ToggleFavorite("Grape".into())); assert!(!app.is_favorite("Grape")); - assert!(crate::github::load_favorites().is_empty()); + assert!(crate::persistence::load_favorites().is_empty()); }); } }