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:
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user