gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Sutichaykuna

feat(yahweh-theme): persistencia de la preferencia de theme entre runs

Iter 13. El switcher ya cambiaba el chrome en runtime, pero al
cerrar/reabrir el theme volvía a Nebula default. Ahora se persiste
en $XDG_CONFIG_HOME/yahweh/theme (default ~/.config/yahweh/theme)
y se restaura al boot.

yahweh-theme:
- pub fn config_path() -> Option<PathBuf>: resuelve XDG; None en
  sandbox/CI sin HOME ni XDG_CONFIG_HOME.
- pub fn load_persisted() / persist(theme): API pública.
  Variantes load_from_path / persist_to_path con path explícito
  para tests y apps custom.
- Theme::install_default(cx) ahora load_persisted o cae a Nebula.
- Theme::set(cx, theme) ahora persist + set_global. Best-effort:
  io errors ignorados (no rebota la UX por un secundario).
- 5 tests nuevos: round-trip, missing file, unknown name, create
  parent dir, XDG_CONFIG_HOME respetado.

API pública de install_default/set sin cambio de shape — todos los
downstream compilan sin tocar nada. Smoke run verificado.

Beneficio: 4 apps yahweh-themed comparten preferencia persistente.
Usuario puede preset via `echo Aurora > ~/.config/yahweh/theme`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

ce424eb6af777cc52624f031529c0744ce54cd8c · ruwaq Sergio · (5 killa ñawpaq) · tayta 99838b849 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 16

crates/modules/ui_engine/libs/theme/src/lib.rs

2 qillqakuna · +212 −5

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 <sha>`.
 
 ## 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<PathBuf>`**: resuelve el path
+  XDG. Devuelve `None` si ni `XDG_CONFIG_HOME` ni `HOME` están
+  set (sandbox/CI).
+- **`pub fn load_persisted() -> Option<Theme>`**: 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::<Self>()
     }
 
-    /// 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<PathBuf> {
+    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<Theme> {
+    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<Theme> {
+    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"));
+    }
+}