diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b07d1..d299540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ ratio/diff ver `git show `. ## 2026-05-10 +### feat(yahweh-theme): persistencia de la preferencia de theme entre runs +Iter 13. El theme switcher ya cambiaba el chrome en runtime, pero +al cerrar y reabrir la app el theme volvía a Nebula default. Ahora +el name del theme se persiste en `$XDG_CONFIG_HOME/yahweh/theme` +(default `~/.config/yahweh/theme`) y se restaura al boot. + +Cambios en `yahweh-theme`: +- **`pub fn config_path() -> Option`**: resuelve el path + XDG. Devuelve `None` si ni `XDG_CONFIG_HOME` ni `HOME` están + set (sandbox/CI). +- **`pub fn load_persisted() -> Option`**: lee el archivo, + trim, busca el theme por name vía `Theme::by_name`. `None` si + el file no existe, lectura falla, o el name no matchea ningún + preset (e.g. preset renombrado entre versiones). +- **`pub fn persist(theme: &Theme) -> io::Result<()>`**: escribe + el name al config file. Crea el dir parent si no existe. +- **`pub fn load_from_path` y `pub fn persist_to_path`**: variantes + con path explícito — útiles para tests con tempfile y para apps + que quieren un path custom (multi-user, staging, etc.). +- **`Theme::install_default(cx)` cambia**: antes hardcoded + `nebula()`. Ahora intenta `load_persisted()`, fallback a Nebula. +- **`Theme::set(cx, theme)` cambia**: antes sólo `cx.set_global`. + Ahora también `persist(&theme)` antes (best-effort: ignora io + errors). El `theme_switcher` widget ya consume `Theme::set`, así + que sin cambios en su código el switching ahora persiste. + +5 tests nuevos (`persistence_tests`): +- `persist_then_load_round_trip` — escribir + leer Aurora. +- `load_from_missing_file_returns_none` — no rebota. +- `load_from_unknown_name_returns_none` — name desconocido → + `None` (degrada a default cuando se usa). +- `persist_creates_parent_dir_if_missing` — crea + `~/.config/yahweh/` si no existe. +- `config_path_uses_xdg_config_home_when_set` — respeta el env. + +Tests stack: ~5 nuevos en yahweh-theme. Todos los downstream +(nakui-ui, *-explorer) compilan sin tocar nada — la API pública +de `Theme::install_default` y `Theme::set` no cambió shape. +Smoke run del binario verificado: bootstrap OK, panic esperado +sin display. + +Beneficio operativo: +- Usuario abre `nakui-ui`, cicla a Aurora con el switcher, cierra + app. Próxima apertura: Aurora cargado del disco. Todas las + apps yahweh-themed (4 del repo) comparten la misma preferencia. +- Failure mode benigno: sin home dir o sin permisos de write, + el theme cambia in-memory pero no se persiste — el switcher + sigue usable, sólo no sobrevive al close. +- Path canónico documentado: usuarios que quieran preset el + theme antes de abrir la app pueden hacer + `echo Aurora > ~/.config/yahweh/theme`. + ### feat(minga-explorer): listings de items recientes en cada stat card Iter 12. Hasta ahora minga-explorer mostraba sólo counts (3 números). Ahora cada stat card muestra también un sample de los diff --git a/crates/modules/ui_engine/libs/theme/src/lib.rs b/crates/modules/ui_engine/libs/theme/src/lib.rs index 441ff53..61a7f5a 100644 --- a/crates/modules/ui_engine/libs/theme/src/lib.rs +++ b/crates/modules/ui_engine/libs/theme/src/lib.rs @@ -116,15 +116,24 @@ impl Theme { cx.global::() } - /// Default — primer preset de `all()`. La Shell lo carga al boot si no - /// hay otro persistido. + /// Carga el theme persistido si existe + es válido; sino default + /// a Nebula. El persisted lo escribe [`Self::set`] cada vez que el + /// theme cambia (típicamente vía `theme_switcher`). pub fn install_default(cx: &mut gpui::App) { - cx.set_global(Self::nebula()); + let theme = load_persisted().unwrap_or_else(Self::nebula); + cx.set_global(theme); } - /// Reemplaza el theme global. GPUI notifica a todos los `observe_global` - /// suscriptores en el siguiente frame. + /// Reemplaza el theme global y persiste su `name` al config file. + /// GPUI notifica a todos los `observe_global` suscriptores en el + /// siguiente frame. + /// + /// La persistencia es best-effort: si write falla (no hay home, + /// permission denied, etc.), el theme cambia in-memory pero no + /// sobrevive al restart. No se rebota — la UX no se interrumpe + /// por un I/O secundario. pub fn set(cx: &mut gpui::App, theme: Self) { + let _ = persist(&theme); cx.set_global(theme); } @@ -392,3 +401,149 @@ impl Theme { } } } + +// ============================================================================ +// Persistencia de la preferencia de theme +// ============================================================================ + +use std::path::{Path, PathBuf}; + +const CONFIG_SUBDIR: &str = "yahweh"; +const CONFIG_FILE: &str = "theme"; + +/// Path al archivo donde se persiste la preferencia de theme. +/// +/// Convención XDG: `$XDG_CONFIG_HOME/yahweh/theme` si está set; +/// sino `$HOME/.config/yahweh/theme`. `None` si ni `XDG_CONFIG_HOME` +/// ni `HOME` están definidos (típicamente en sandboxes / CI). +pub fn config_path() -> Option { + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(|h| PathBuf::from(h).join(".config")) + })?; + Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE)) +} + +/// Lee el theme persistido. `None` si: no hay config dir, file no +/// existe, lectura falla, o el name guardado no matchea ningún +/// preset (ej. tema renombrado entre versiones). +pub fn load_persisted() -> Option { + let path = config_path()?; + load_from_path(&path) +} + +/// Persiste el name del theme al config file. Crea el dir parent si +/// no existe. Devuelve el `io::Error` para que tests puedan +/// asertar; los call sites de producción pueden ignorarlo +/// (best-effort persistence). +pub fn persist(theme: &Theme) -> std::io::Result<()> { + let path = config_path().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)", + ) + })?; + persist_to_path(theme, &path) +} + +/// Variante de [`load_persisted`] que toma un path explícito. Útil +/// para tests + para apps que quieren su propio path +/// (ej. multi-user single-machine). +pub fn load_from_path(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let name = raw.trim(); + Theme::by_name(name) +} + +/// Variante de [`persist`] que toma un path explícito. +pub fn persist_to_path(theme: &Theme, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, theme.name) +} + +#[cfg(test)] +mod persistence_tests { + use super::*; + + fn unique_path(label: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "yahweh-theme-test-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + label + )); + p + } + + #[test] + fn persist_then_load_round_trip() { + let path = unique_path("round-trip"); + let theme = Theme::aurora(); + persist_to_path(&theme, &path).unwrap(); + let loaded = load_from_path(&path).expect("load"); + assert_eq!(loaded.name, "Aurora"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(path.parent().unwrap()); + } + + #[test] + fn load_from_missing_file_returns_none() { + let path = unique_path("missing"); + // path NO existe. + assert!(load_from_path(&path).is_none()); + } + + #[test] + fn load_from_unknown_name_returns_none() { + let path = unique_path("unknown"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "DefinitelyNotARealTheme").unwrap(); + assert!(load_from_path(&path).is_none()); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(path.parent().unwrap()); + } + + #[test] + fn persist_creates_parent_dir_if_missing() { + let path = unique_path("nested-create"); + // Aseguramos que el parent NO existe antes. + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + persist_to_path(&Theme::sunset(), &path).unwrap(); + assert!(path.exists()); + let loaded = load_from_path(&path).unwrap(); + assert_eq!(loaded.name, "Sunset"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(path.parent().unwrap()); + } + + #[test] + fn config_path_uses_xdg_config_home_when_set() { + // Snapshot del env, mutación local, restauración. + let prev = std::env::var("XDG_CONFIG_HOME").ok(); + // SAFETY: tests del crate single-thread por default; este + // env mutation no impacta otros tests del mismo proceso. + unsafe { + std::env::set_var("XDG_CONFIG_HOME", "/custom/xdg"); + } + let p = config_path().unwrap(); + unsafe { + match prev { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + assert_eq!(p, PathBuf::from("/custom/xdg/yahweh/theme")); + } +}