feat(dev): extend --theme-watch to a themes-repo checkout (#1313)

Accept a repo root, themes/ dir, or a single theme folder besides a bare
theme.css: every themes/*/theme.css is polled (mtime-gated) and theme
folders added while running are picked up live. A startup sweep installs
each theme without stealing the active selection; a save still applies
live. A theme-watch:ready handshake re-sends loaded contents after
dev-server reloads, dev pushes preserve a store-installed copy's
metadata and grid position, and the watcher only runs in the main
window.
This commit is contained in:
Psychotoxical
2026-07-16 22:24:13 +02:00
committed by GitHub
parent 25b8b57328
commit fa390e9211
3 changed files with 180 additions and 30 deletions
+131 -16
View File
@@ -391,34 +391,149 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` 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='<id>']` selector and applies it. Dev-builds only.
// ── Dev: `--theme-watch <theme.css | dir>` 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='<id>']` 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<String> = 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<PathBuf, (Option<SystemTime>, String)>;
let seen: Arc<Mutex<Seen>> = 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<PathBuf> = 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 <path/to/theme.css>"),
None => eprintln!(
"[theme-watch] usage: --theme-watch <path/to/theme.css | path/to/themes-checkout>"
),
}
}
}
+42 -13
View File
@@ -33,26 +33,55 @@ export default function App() {
syncInjectedThemes(installedThemes);
}, [installedThemes]);
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
// 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 <theme.css | dir>` (debug builds) pushes local
// theme CSS in on every save. Each payload is installed under the id in its
// `[data-theme='<id>']` 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<string>('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<string>('theme-watch:css', ({ payload }) => install(payload, true)),
listen<string>('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(() => {
+7 -1
View File
@@ -36,7 +36,13 @@ export const useInstalledThemesStore = create<InstalledThemesState>()(
(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),