diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0cf26a50..3b540ccc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -391,34 +391,149 @@ pub fn run() { let _ = window.set_title("Psysonic (Dev)"); } - // ── Dev: `--theme-watch ` live theme reload ───────── - // Poll a local theme.css and push it into the running app on save, - // so theme authors get a live loop without re-importing a zip. The - // frontend (dev only) installs it under the id in its - // `[data-theme='']` selector and applies it. Dev-builds only. + // ── Dev: `--theme-watch ` live theme reload ─── + // Poll a local theme.css — or every `themes/*/theme.css` in a + // cloned themes-repo checkout — and push contents into the running + // app, so theme authors get a live loop without re-importing zips. + // The frontend (dev only, main window) installs each payload under + // the id in its `[data-theme='']` selector; a save applies + // live, the directory startup sweep only installs so authors can + // switch between the checkout's themes in the UI. Dev-builds only. #[cfg(debug_assertions)] { let args: Vec = std::env::args().collect(); if let Some(i) = args.iter().position(|a| a == "--theme-watch") { match args.get(i + 1).cloned() { Some(path) => { - eprintln!("[theme-watch] watching {path}"); + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + use std::time::SystemTime; + use tauri::Listener; + + // Accept a repo root (has themes/), a themes/ dir, + // a single theme folder, or a bare theme.css. + enum WatchTarget { + Dir(PathBuf), + File(PathBuf), + } + let root = PathBuf::from(&path); + let target = if root.is_dir() && !root.join("theme.css").is_file() { + if root.join("themes").is_dir() { + WatchTarget::Dir(root.join("themes")) + } else { + WatchTarget::Dir(root) + } + } else if root.is_dir() { + WatchTarget::File(root.join("theme.css")) + } else { + WatchTarget::File(root) + }; + match &target { + WatchTarget::Dir(d) => { + eprintln!("[theme-watch] watching {}/*/theme.css", d.display()) + } + WatchTarget::File(f) => { + if !f.is_file() { + eprintln!( + "[theme-watch] warning: {} does not exist — nothing will load until it appears", + f.display() + ); + } + eprintln!("[theme-watch] watching {}", f.display()); + } + } + + // Per-file state: mtime (gates the read while a + // file is unchanged) and last pushed contents + // (change detection + ready re-send). Entries only + // update after a successful emit, so a failed push + // retries next tick instead of being marked seen. + type Seen = HashMap, String)>; + let seen: Arc> = Arc::new(Mutex::new(HashMap::new())); + + // The frontend announces its listeners (on mount + // and after every dev-server reload) via + // `theme-watch:ready`; re-send everything already + // loaded so no emit is lost to a webview that + // wasn't listening yet. Directory mode re-seeds + // install-only (the active theme id is persisted, + // so its CSS comes back without stealing the + // selection); single-file mode re-applies the one + // authored theme. Change detection keeps running + // untouched, so a save landing mid-reload still + // arrives as an applying `css` event. + let ready_event = if matches!(target, WatchTarget::Dir(_)) { + "theme-watch:css-seed" + } else { + "theme-watch:css" + }; + { + let seen = Arc::clone(&seen); + let handle = app.handle().clone(); + app.listen("theme-watch:ready", move |_| { + let Ok(m) = seen.lock() else { return }; + for (_, css) in m.values() { + let _ = handle.emit(ready_event, css); + } + }); + } + let handle = app.handle().clone(); - std::thread::spawn(move || { - let p = std::path::PathBuf::from(&path); - let mut last_css = String::new(); - loop { - if let Ok(css) = std::fs::read_to_string(&p) { - if css != last_css { - last_css = css.clone(); - let _ = handle.emit("theme-watch:css", css); + std::thread::spawn(move || loop { + let files: Vec = match &target { + // Re-scan each tick so theme folders added + // while running are picked up live. + WatchTarget::Dir(dir) => std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .map(|e| e.path().join("theme.css")) + .filter(|p| p.is_file()) + .collect() + }) + .unwrap_or_default(), + WatchTarget::File(f) => vec![f.clone()], + }; + for f in files { + let mtime = + std::fs::metadata(&f).and_then(|m| m.modified()).ok(); + let Ok(mut m) = seen.lock() else { continue }; + // mtime gate: skip the full read while the + // stamp is unchanged (a None stamp never + // matches, so filesystems without mtime + // fall back to read + compare). + if let Some((prev_mtime, _)) = m.get(&f) { + if prev_mtime.is_some() && *prev_mtime == mtime { + continue; } } - std::thread::sleep(std::time::Duration::from_millis(300)); + let Ok(css) = std::fs::read_to_string(&f) else { + continue; + }; + let event = match m.get_mut(&f) { + // mtime-only touch — restamp quietly. + Some(entry) if entry.1 == css => { + entry.0 = mtime; + continue; + } + // First sight in directory mode + // installs without stealing the + // active theme; a save applies. + None if matches!(target, WatchTarget::Dir(_)) => { + "theme-watch:css-seed" + } + _ => "theme-watch:css", + }; + if handle.emit(event, &css).is_ok() { + m.insert(f, (mtime, css)); + } } + std::thread::sleep(std::time::Duration::from_millis(300)); }); } - None => eprintln!("[theme-watch] usage: --theme-watch "), + None => eprintln!( + "[theme-watch] usage: --theme-watch " + ), } } } diff --git a/src/App.tsx b/src/App.tsx index 075b2fc3..ae89c673 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,26 +33,55 @@ export default function App() { syncInjectedThemes(installedThemes); }, [installedThemes]); - // Dev only: `--theme-watch ` (debug builds) pushes a local theme's - // CSS in on every save. Install it under the id in its `[data-theme='']` - // selector and apply it — the syncInjectedThemes effect above re-injects, so - // authoring is live without re-importing a zip. Never wired in production. + // Dev only: `--theme-watch ` (debug builds) pushes local + // theme CSS in on every save. Each payload is installed under the id in its + // `[data-theme='']` selector — the syncInjectedThemes effect above + // re-injects, so authoring is live without re-importing a zip. + // `theme-watch:css` also applies (single file, or a save in a watched + // directory); `theme-watch:css-seed` only installs (directory startup + // sweep), so authors can switch between a themes-repo checkout's themes in + // the UI. Never wired in production. useEffect(() => { if (!import.meta.env.DEV) return; - let unlisten: (() => void) | undefined; - void import('@tauri-apps/api/event').then(({ listen }) => { - const sub = listen('theme-watch:css', ({ payload }) => { - const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1]; + // Main window only: the mini player shares the same persisted store — a + // second subscriber would double every install and could write a stale + // whole-store snapshot over changes made in the main window. + if (getWindowKind() !== 'main') return; + const unlisteners: (() => void)[] = []; + void import('@tauri-apps/api/event').then(({ listen, emit }) => { + const install = (css: string, apply: boolean) => { + const id = css.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1]; if (!id) return; + // Only the CSS is the dev payload — keep a store-installed copy's + // metadata and installedAt, so watching a checkout doesn't rebrand + // real installs as 'dev' or reshuffle the Themes grid. + const prev = useInstalledThemesStore.getState().getInstalled(id); useInstalledThemesStore.getState().install({ - id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(), + id, + name: prev?.name ?? id, + author: prev?.author ?? 'dev', + version: prev?.version ?? '0.0.0', + description: prev?.description ?? '', + mode: prev?.mode ?? 'dark', + tags: prev?.tags, + css, + installedAt: prev?.installedAt ?? Date.now(), }); - useThemeStore.getState().setTheme(id); - }); + if (apply) useThemeStore.getState().setTheme(id); + }; + const subs = [ + listen('theme-watch:css', ({ payload }) => install(payload, true)), + listen('theme-watch:css-seed', ({ payload }) => install(payload, false)), + ]; // Guard the mocked-in-tests case where listen() isn't a promise. - if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; }); + for (const sub of subs) { + if (sub && typeof sub.then === 'function') void sub.then(u => { unlisteners.push(u); }); + } + // Announce the attached listeners (again after every dev-server reload) + // so the watcher (re-)sends current contents — no lost first emit. + void emit('theme-watch:ready').catch(() => {}); }).catch(() => {}); - return () => unlisten?.(); + return () => { unlisteners.forEach(u => u()); }; }, []); useEffect(() => { diff --git a/src/store/installedThemesStore.ts b/src/store/installedThemesStore.ts index 2640df03..4ac19cd2 100644 --- a/src/store/installedThemesStore.ts +++ b/src/store/installedThemesStore.ts @@ -36,7 +36,13 @@ export const useInstalledThemesStore = create()( (set, get) => ({ themes: [], install: (theme) => - set((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] })), + set((s) => ({ + // Replace in place so an update (or a dev theme-watch push) keeps + // the theme's position in the grid; append only when it's new. + themes: s.themes.some((t) => t.id === theme.id) + ? s.themes.map((t) => (t.id === theme.id ? theme : t)) + : [...s.themes, theme], + })), uninstall: (id) => set((s) => ({ themes: s.themes.filter((t) => t.id !== id) })), isInstalled: (id) => get().themes.some((t) => t.id === id),