Skip to main content

max / makenotwork

shared: theme-common delete-by-id + preview palette, updater progress + markdown hook theme-common: add delete_theme(custom_dir, id) with id validation and custom-dir scoping; add ThemePreview struct + load_theme_preview() for UI thumbnails that don't need the full color HashMap. 6 new tests, 34 total. tauri-updater-ui: wire pendingUpdate.downloadAndInstall(onEvent) with Started/Progress/Finished handlers into a thin progress bar + percent status line; add opts.renderMarkdown(body) hook so callers can render full release notes as sanitized HTML in a scrollable max-height block. Falls back to the 120-char text truncation when no hook is supplied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-25 19:56 UTC
Commit: 7458e2cc2bd0c1d5af50275403aa53e1e6a5732b
Parent: afb8766
2 files changed, +182 insertions, -4 deletions
@@ -13,6 +13,9 @@
13 13 * - namespace: object to attach { showUpdateBanner } onto (e.g. GoingsOn.updater)
14 14 * - bannerClass: CSS class name for the banner (optional, uses inline styles if omitted)
15 15 * - cssVars: override CSS variable names (optional)
16 + * - renderMarkdown: function(body) -> safe HTML string (optional). When supplied,
17 + * release notes render as HTML and the 120-char preview truncation is dropped.
18 + * The caller is responsible for sanitizing the output.
16 19 */
17 20
18 21 // eslint-disable-next-line no-unused-vars
@@ -73,8 +76,12 @@ function initUpdater(opts) {
73 76
74 77 if (body) {
75 78 const notes = document.createElement('div');
76 - notes.style.cssText = 'color: ' + vars.textSecondary + '; margin-bottom: 0.5rem;';
77 - notes.textContent = body.length > 120 ? body.substring(0, 120) + '...' : body;
79 + notes.style.cssText = 'color: ' + vars.textSecondary + '; margin-bottom: 0.5rem; max-height: 8rem; overflow-y: auto;';
80 + if (typeof opts.renderMarkdown === 'function') {
81 + notes.innerHTML = opts.renderMarkdown(body);
82 + } else {
83 + notes.textContent = body.length > 120 ? body.substring(0, 120) + '...' : body;
84 + }
78 85 banner.appendChild(notes);
79 86 }
80 87
@@ -119,11 +126,42 @@ function initUpdater(opts) {
119 126
120 127 banner.innerHTML = '';
121 128 const status = document.createElement('div');
122 - status.textContent = 'Downloading update...';
129 + status.textContent = 'Starting download...';
130 + status.style.cssText = 'margin-bottom: 0.5rem;';
123 131 banner.appendChild(status);
124 132
133 + const progressWrap = document.createElement('div');
134 + progressWrap.style.cssText = 'height: 4px; background: ' + vars.border + '; border-radius: 2px; overflow: hidden;';
135 + const progressBar = document.createElement('div');
136 + progressBar.style.cssText = 'height: 100%; width: 0%; background: ' + vars.accent + '; transition: width 120ms linear;';
137 + progressWrap.appendChild(progressBar);
138 + banner.appendChild(progressWrap);
139 +
140 + let total = 0;
141 + let received = 0;
142 + const onEvent = (e) => {
143 + if (!e || !e.event) return;
144 + if (e.event === 'Started') {
145 + total = (e.data && e.data.contentLength) || 0;
146 + received = 0;
147 + status.textContent = total > 0
148 + ? 'Downloading update (0%)...'
149 + : 'Downloading update...';
150 + } else if (e.event === 'Progress') {
151 + received += (e.data && e.data.chunkLength) || 0;
152 + if (total > 0) {
153 + const pct = Math.min(100, Math.round((received / total) * 100));
154 + progressBar.style.width = pct + '%';
155 + status.textContent = 'Downloading update (' + pct + '%)...';
156 + }
157 + } else if (e.event === 'Finished') {
158 + progressBar.style.width = '100%';
159 + status.textContent = 'Installing...';
160 + }
161 + };
162 +
125 163 try {
126 - await pendingUpdate.downloadAndInstall();
164 + await pendingUpdate.downloadAndInstall(onEvent);
127 165 status.textContent = 'Update installed. Restarting...';
128 166 } catch (err) {
129 167 status.textContent = 'Update failed: ' + err;
@@ -218,6 +218,70 @@ pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta,
218 218 Ok(parse_meta(&id, &table, true))
219 219 }
220 220
221 + /// Delete a custom theme by ID.
222 + ///
223 + /// Only operates on `custom_dir` — bundled themes are not deletable through
224 + /// this entry point. Returns `Err` if the ID is invalid, the file does not
225 + /// exist, or the underlying remove fails.
226 + pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
227 + validate_theme_id(id)?;
228 +
229 + let path = custom_dir.join(format!("{}.toml", id));
230 + if !path.is_file() {
231 + return Err(format!("Custom theme '{}' not found", id));
232 + }
233 +
234 + std::fs::remove_file(&path)
235 + .map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
236 + }
237 +
238 + /// A four-color palette for preview chips, swatches, etc.
239 + ///
240 + /// Smaller than `ThemeColors`: only the `primary` value from each of the four
241 + /// canonical sections, so callers rendering a list of theme thumbnails don't
242 + /// need to allocate a full `HashMap` per row.
243 + #[derive(Debug, Clone, Serialize)]
244 + #[serde(rename_all = "camelCase")]
245 + pub struct ThemePreview {
246 + pub meta: ThemeMeta,
247 + pub background: Option<String>,
248 + pub foreground: Option<String>,
249 + pub accent: Option<String>,
250 + pub border: Option<String>,
251 + }
252 +
253 + fn primary_color(table: &toml::Table, section: &str) -> Option<String> {
254 + table
255 + .get(section)
256 + .and_then(|s| s.as_table())
257 + .and_then(|s| s.get("primary"))
258 + .and_then(|v| v.as_str())
259 + .map(|s| s.to_string())
260 + }
261 +
262 + /// Load just the primary colors for a theme — for UI previews / thumbnails.
263 + pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
264 + validate_theme_id(id)?;
265 +
266 + let (path, is_custom) =
267 + find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
268 +
269 + let content = std::fs::read_to_string(&path)
270 + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
271 +
272 + let table: toml::Table = content
273 + .parse()
274 + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
275 +
276 + Ok(ThemePreview {
277 + meta: parse_meta(id, &table, is_custom),
278 + background: primary_color(&table, "background"),
279 + foreground: primary_color(&table, "foreground"),
280 + accent: primary_color(&table, "accent"),
281 + border: primary_color(&table, "border"),
282 + })
283 + }
284 +
221 285 /// Export a theme to a user-chosen path.
222 286 ///
223 287 /// Finds the theme by ID in the given directories and copies the TOML file
@@ -604,6 +668,82 @@ primary = "#c0caf5"
604 668 }
605 669
606 670 #[test]
671 + fn delete_theme_removes_file() {
672 + let custom = tempfile::tempdir().unwrap();
673 + let path = custom.path().join("doomed.toml");
674 + fs::write(&path, "[background]\nprimary = \"#000\"\n").unwrap();
675 + assert!(path.exists());
676 +
677 + delete_theme(custom.path(), "doomed").unwrap();
678 + assert!(!path.exists());
679 + }
680 +
681 + #[test]
682 + fn delete_theme_rejects_invalid_id() {
683 + let custom = tempfile::tempdir().unwrap();
684 + assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
685 + }
686 +
687 + #[test]
688 + fn delete_theme_not_found() {
689 + let custom = tempfile::tempdir().unwrap();
690 + assert!(delete_theme(custom.path(), "ghost").is_err());
691 + }
692 +
693 + #[test]
694 + fn load_theme_preview_returns_primaries() {
695 + let dir = tempfile::tempdir().unwrap();
696 + let content = r##"
697 + [meta]
698 + name = "Preview Me"
699 + variant = "dark"
700 +
701 + [background]
702 + primary = "#111"
703 + secondary = "#222"
704 +
705 + [foreground]
706 + primary = "#eee"
707 +
708 + [accent]
709 + primary = "#f0a"
710 +
711 + [border]
712 + primary = "#444"
713 + "##;
714 + fs::write(dir.path().join("preview.toml"), content).unwrap();
715 +
716 + let dirs = vec![(dir.path().to_path_buf(), false)];
717 + let p = load_theme_preview(&dirs, "preview").unwrap();
718 + assert_eq!(p.meta.name, "Preview Me");
719 + assert_eq!(p.background.as_deref(), Some("#111"));
720 + assert_eq!(p.foreground.as_deref(), Some("#eee"));
721 + assert_eq!(p.accent.as_deref(), Some("#f0a"));
722 + assert_eq!(p.border.as_deref(), Some("#444"));
723 + }
724 +
725 + #[test]
726 + fn load_theme_preview_missing_sections_are_none() {
727 + let dir = tempfile::tempdir().unwrap();
728 + let content = "[background]\nprimary = \"#000\"\n";
729 + fs::write(dir.path().join("sparse.toml"), content).unwrap();
730 +
731 + let dirs = vec![(dir.path().to_path_buf(), false)];
732 + let p = load_theme_preview(&dirs, "sparse").unwrap();
733 + assert_eq!(p.background.as_deref(), Some("#000"));
734 + assert!(p.foreground.is_none());
735 + assert!(p.accent.is_none());
736 + assert!(p.border.is_none());
737 + }
738 +
739 + #[test]
740 + fn load_theme_preview_not_found() {
741 + let dir = tempfile::tempdir().unwrap();
742 + let dirs = vec![(dir.path().to_path_buf(), false)];
743 + assert!(load_theme_preview(&dirs, "missing").is_err());
744 + }
745 +
746 + #[test]
607 747 fn export_theme_not_found() {
608 748 let dir = tempfile::tempdir().unwrap();
609 749 let dirs = vec![(dir.path().to_path_buf(), false)];