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>
This commit is contained in:
Sergio
2026-05-10 11:56:48 +00:00
parent 99838b849b
commit ce424eb6af
2 changed files with 212 additions and 5 deletions
+160 -5
View File
@@ -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"));
}
}