From 8711f64f25eeb0b5a672f8f3d864297499d58f1e Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:08:13 +1000 Subject: [PATCH 1/7] feat: record import-applied sets in core history (source: import) --- crates/openwith-core/src/config.rs | 25 ++++++++++++++++++--- crates/openwith-gui/src/{main.ts => app.ts} | 0 2 files changed, 22 insertions(+), 3 deletions(-) rename crates/openwith-gui/src/{main.ts => app.ts} (100%) diff --git a/crates/openwith-core/src/config.rs b/crates/openwith-core/src/config.rs index 9c6075e..ebe8ca8 100644 --- a/crates/openwith-core/src/config.rs +++ b/crates/openwith-core/src/config.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use super::types::AppInfo; -use super::{launchservices, listing, scanner, uti}; +use super::{history, launchservices, listing, scanner, uti}; #[derive(Serialize, Deserialize)] pub struct Config { @@ -114,7 +114,7 @@ pub fn import_associations(config: &Config, apps: &[AppInfo], dry_run: bool) -> continue; } - let previous = current.map(|c| scanner::resolve_name(apps, &c)); + let previous = current.clone().map(|c| scanner::resolve_name(apps, &c)); if dry_run { applied.push((ext_key.clone(), display_name, previous)); @@ -123,6 +123,16 @@ pub fn import_associations(config: &Config, apps: &[AppInfo], dry_run: bool) -> match launchservices::set_default(&bundle_id, &uti_str) { Ok(_) => { + // Best-effort: history must never fail the import itself. + let _ = history::record(history::HistoryEvent { + kind: "set".into(), + key: ext_key.clone(), + old: current, + new: Some(bundle_id), + detail: None, + timestamp: history::now_secs(), + source: "import".into(), + }); applied.push((ext_key.clone(), display_name, previous)); } Err(e) => { @@ -154,7 +164,7 @@ pub fn import_associations(config: &Config, apps: &[AppInfo], dry_run: bool) -> continue; } - let previous = current.map(|c| scanner::resolve_name(apps, &c)); + let previous = current.clone().map(|c| scanner::resolve_name(apps, &c)); if dry_run { applied.push((display_key, display_name, previous)); @@ -163,6 +173,15 @@ pub fn import_associations(config: &Config, apps: &[AppInfo], dry_run: bool) -> match launchservices::set_default_scheme_handler(&bundle_id, &scheme) { Ok(_) => { + let _ = history::record(history::HistoryEvent { + kind: "set_scheme".into(), + key: display_key.clone(), + old: current, + new: Some(bundle_id), + detail: None, + timestamp: history::now_secs(), + source: "import".into(), + }); applied.push((display_key, display_name, previous)); } Err(e) => { diff --git a/crates/openwith-gui/src/main.ts b/crates/openwith-gui/src/app.ts similarity index 100% rename from crates/openwith-gui/src/main.ts rename to crates/openwith-gui/src/app.ts From 8fc768c592f8d2fc92ab1aae6bc7ad543378d147 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:08:27 +1000 Subject: [PATCH 2/7] feat: add openwith history and undo, record CLI changes set/export/import now append to the shared history log; openwith history lists recent events (-n, --json), openwith undo reverts the most recent set with drift protection (--force to override). --- crates/openwith-cli/src/cli.rs | 17 +++ crates/openwith-cli/src/commands/export.rs | 18 +++ crates/openwith-cli/src/commands/history.rs | 134 ++++++++++++++++++++ crates/openwith-cli/src/commands/import.rs | 21 +++ crates/openwith-cli/src/commands/mod.rs | 2 + crates/openwith-cli/src/commands/set.rs | 22 ++++ crates/openwith-cli/src/commands/undo.rs | 94 ++++++++++++++ crates/openwith-cli/src/main.rs | 6 + 8 files changed, 314 insertions(+) create mode 100644 crates/openwith-cli/src/commands/history.rs create mode 100644 crates/openwith-cli/src/commands/undo.rs diff --git a/crates/openwith-cli/src/cli.rs b/crates/openwith-cli/src/cli.rs index f49d9c1..4e2e6bf 100644 --- a/crates/openwith-cli/src/cli.rs +++ b/crates/openwith-cli/src/cli.rs @@ -21,6 +21,8 @@ MANAGE openwith set Set the default app (name or bundle ID) openwith current -s http Show the handler for a URL scheme openwith set -s http Set the handler for a URL scheme + openwith history Show recent changes (--json for scripts) + openwith undo Revert the most recent change CONFIG openwith export Export current associations to TOML @@ -99,6 +101,21 @@ pub enum Commands { }, /// Launch interactive TUI (apps view) Apps, + /// Show recent default changes recorded by the CLI and GUI + History { + /// Maximum number of events to show + #[arg(short = 'n', long, default_value_t = 20)] + limit: usize, + /// Print JSON + #[arg(long)] + json: bool, + }, + /// Revert the most recent default change + Undo { + /// Revert even if the default has changed since the recorded event + #[arg(long)] + force: bool, + }, /// Export current file associations to TOML Export { /// Output file path (default: stdout) diff --git a/crates/openwith-cli/src/commands/export.rs b/crates/openwith-cli/src/commands/export.rs index 2a87b0b..f5848b2 100644 --- a/crates/openwith-cli/src/commands/export.rs +++ b/crates/openwith-cli/src/commands/export.rs @@ -1,5 +1,6 @@ use anyhow::Result; +use openwith_core::history::{self, HistoryEvent}; use openwith_core::{config, scanner}; pub fn run(output: Option<&str>) -> Result<()> { @@ -13,6 +14,23 @@ pub fn run(output: Option<&str>) -> Result<()> { match output { Some(path) => { std::fs::write(path, &toml_str)?; + let file_name = std::path::Path::new(path) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()); + let _ = history::record(HistoryEvent { + kind: "export".into(), + key: file_name, + old: None, + new: None, + detail: Some(format!( + "{} extensions · {} schemes", + cfg.associations.len(), + cfg.schemes.len() + )), + timestamp: history::now_secs(), + source: "cli".into(), + }); println!( "Exported {} associations and {} scheme handlers to {}", cfg.associations.len(), diff --git a/crates/openwith-cli/src/commands/history.rs b/crates/openwith-cli/src/commands/history.rs new file mode 100644 index 0000000..95afdb9 --- /dev/null +++ b/crates/openwith-cli/src/commands/history.rs @@ -0,0 +1,134 @@ +use anyhow::Result; + +use openwith_core::history::{self, HistoryEvent}; +use openwith_core::scanner; + +pub fn run(limit: usize, json: bool) -> Result<()> { + let events = history::recent(limit)?; + + if json { + let out: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "kind": e.kind, + "key": e.key, + "old": e.old, + "new": e.new, + "detail": e.detail, + "timestamp": e.timestamp, + "source": e.source, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(()); + } + + if events.is_empty() { + println!("No history yet — changes made by the CLI or GUI will appear here."); + return Ok(()); + } + + // Resolve bundle IDs to app names for display. + eprintln!("Scanning applications..."); + let apps = scanner::scan_all_apps()?; + let name_of = |bid: &Option| -> Option { + bid.as_ref().map(|b| scanner::resolve_name(&apps, b)) + }; + + for event in &events { + let when = relative_time(event.timestamp); + let line = describe(event, name_of(&event.old), name_of(&event.new)); + println!(" {:>12} {} [{}]", when, line, event.source); + } + + Ok(()) +} + +fn describe(event: &HistoryEvent, old_name: Option, new_name: Option) -> String { + match event.kind.as_str() { + "set" | "set_scheme" => { + let new = new_name.unwrap_or_else(|| "?".into()); + let was = old_name.map(|o| format!(" (was {o})")).unwrap_or_default(); + format!("set {} → {}{}", event.key, new, was) + } + "export" => format!( + "exported {}{}", + event.key, + event + .detail + .as_ref() + .map(|d| format!(" — {d}")) + .unwrap_or_default() + ), + "import" => format!( + "imported {}{}", + event.key, + event + .detail + .as_ref() + .map(|d| format!(" — {d}")) + .unwrap_or_default() + ), + other => format!("{} {}", other, event.key), + } +} + +fn relative_time(timestamp: u64) -> String { + let now = history::now_secs(); + let ago = now.saturating_sub(timestamp); + match ago { + 0..=59 => "just now".into(), + 60..=3599 => format!("{}m ago", ago / 60), + 3600..=86_399 => format!("{}h ago", ago / 3600), + 86_400..=604_799 => format!("{}d ago", ago / 86_400), + _ => month_day(timestamp), + } +} + +/// "Jun 30"-style date from unix seconds, without pulling in a date crate. +/// Days-to-civil conversion per Howard Hinnant's algorithm. +fn month_day(timestamp: u64) -> String { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let days = (timestamp / 86_400) as i64; + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + let this_year = { + let zy = (history::now_secs() / 86_400) as i64 + 719_468; + let e = zy.div_euclid(146_097); + let d = zy - e * 146_097; + let y = (d - d / 1460 + d / 36_524 - d / 146_096) / 365; + let dy = d - (365 * y + y / 4 - y / 100); + let m = (5 * dy + 2) / 153; + let m = if m < 10 { m + 3 } else { m - 9 }; + y + e * 400 + i64::from(m <= 2) + }; + if year == this_year { + format!("{} {}", MONTHS[(month - 1) as usize], day) + } else { + format!("{} {} {}", MONTHS[(month - 1) as usize], day, year) + } +} + +#[cfg(test)] +mod tests { + use super::month_day; + + #[test] + fn month_day_formats_known_dates() { + // 2026-06-30 12:00:00 UTC + assert!(month_day(1_782_820_800).starts_with("Jun 30")); + // 2000-01-01 00:00:00 UTC — includes year since it's not current + assert_eq!(month_day(946_684_800), "Jan 1 2000"); + } +} diff --git a/crates/openwith-cli/src/commands/import.rs b/crates/openwith-cli/src/commands/import.rs index b031f3d..386e460 100644 --- a/crates/openwith-cli/src/commands/import.rs +++ b/crates/openwith-cli/src/commands/import.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use std::collections::HashSet; +use openwith_core::history::{self, HistoryEvent}; use openwith_core::{config, scanner, uti}; pub fn run(path: &str, dry_run: bool) -> Result<()> { @@ -24,6 +25,26 @@ pub fn run(path: &str, dry_run: bool) -> Result<()> { } let result = config::import_associations(&cfg, &apps, dry_run); + if !dry_run { + let file_name = std::path::Path::new(path) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()); + let _ = history::record(HistoryEvent { + kind: "import".into(), + key: file_name, + old: None, + new: None, + detail: Some(format!( + "{} applied · {} skipped", + result.applied.len(), + result.unchanged.len() + result.skipped.len() + )), + timestamp: history::now_secs(), + source: "cli".into(), + }); + } + // Warn about extensions changed as a side effect of a shared UTI, // unless they are themselves part of the import. let all_extensions = scanner::all_extensions(&apps); diff --git a/crates/openwith-cli/src/commands/mod.rs b/crates/openwith-cli/src/commands/mod.rs index 7023903..fcdc335 100644 --- a/crates/openwith-cli/src/commands/mod.rs +++ b/crates/openwith-cli/src/commands/mod.rs @@ -1,6 +1,8 @@ pub mod current; pub mod export; +pub mod history; pub mod import; pub mod list; pub mod set; pub mod tui; +pub mod undo; diff --git a/crates/openwith-cli/src/commands/set.rs b/crates/openwith-cli/src/commands/set.rs index 38d164c..fe9e61a 100644 --- a/crates/openwith-cli/src/commands/set.rs +++ b/crates/openwith-cli/src/commands/set.rs @@ -1,5 +1,6 @@ use anyhow::Result; +use openwith_core::history::{self, HistoryEvent}; use openwith_core::types::AppInfo; use openwith_core::{launchservices, scanner, uti}; @@ -34,6 +35,17 @@ pub fn run(ext: &str, app_name: &str, scheme: bool) -> Result<()> { // Set default launchservices::set_default(&bundle_id, &uti)?; + // Best-effort: history must never fail the change itself. + let _ = history::record(HistoryEvent { + kind: "set".into(), + key: format!(".{ext}"), + old: previous.clone(), + new: Some(bundle_id.clone()), + detail: None, + timestamp: history::now_secs(), + source: "cli".into(), + }); + let was = previous .map(|p| format!(" (was: {})", scanner::resolve_name(&apps, &p))) .unwrap_or_default(); @@ -70,6 +82,16 @@ fn run_scheme(apps: &[AppInfo], scheme: &str, bundle_id: &str, display_name: &st launchservices::set_default_scheme_handler(bundle_id, &scheme)?; + let _ = history::record(HistoryEvent { + kind: "set_scheme".into(), + key: format!("{scheme}://"), + old: previous.clone(), + new: Some(bundle_id.to_string()), + detail: None, + timestamp: history::now_secs(), + source: "cli".into(), + }); + let was = previous .map(|p| format!(" (was: {})", scanner::resolve_name(apps, &p))) .unwrap_or_default(); diff --git a/crates/openwith-cli/src/commands/undo.rs b/crates/openwith-cli/src/commands/undo.rs new file mode 100644 index 0000000..293be8c --- /dev/null +++ b/crates/openwith-cli/src/commands/undo.rs @@ -0,0 +1,94 @@ +use anyhow::{Result, bail}; + +use openwith_core::history::{self, HistoryEvent}; +use openwith_core::{launchservices, scanner, uti}; + +/// Revert the most recent recorded default change (from CLI, GUI, or import). +pub fn run(force: bool) -> Result<()> { + let events = history::recent(100)?; + let Some(event) = events + .iter() + .find(|e| matches!(e.kind.as_str(), "set" | "set_scheme") && e.old.is_some()) + else { + println!("Nothing to undo — no recorded change has a previous default."); + return Ok(()); + }; + + let old = event.old.as_deref().expect("checked above"); + let new = event.new.as_deref().unwrap_or_default(); + + // If the default has drifted since the event was recorded, a blind revert + // would clobber a change the user made elsewhere. + let current = current_handler(event)?; + if !force + && current + .as_deref() + .is_none_or(|c| !c.eq_ignore_ascii_case(new)) + { + bail!( + "the default for {} has changed since that event (now {}, event set {}); \ + re-run with --force to revert to {} anyway", + event.key, + current.as_deref().unwrap_or("unset"), + new, + old + ); + } + + eprintln!("Scanning applications..."); + let apps = scanner::scan_all_apps()?; + + apply_revert(event, old)?; + + let _ = history::record(HistoryEvent { + kind: event.kind.clone(), + key: event.key.clone(), + old: event.new.clone(), + new: Some(old.to_string()), + detail: None, + timestamp: history::now_secs(), + source: "cli".into(), + }); + + println!( + "Reverted {} → {} (was {})", + event.key, + scanner::resolve_name(&apps, old), + scanner::resolve_name(&apps, new) + ); + + if event.kind == "set" { + let ext = event.key.trim_start_matches('.'); + if let Ok(uti_str) = uti::uti_for_extension(ext) { + let siblings = + uti::extensions_sharing_uti(ext, &uti_str, &scanner::all_extensions(&apps)); + if let Some(note) = uti::shared_uti_note(ext, &uti_str, &siblings) { + eprintln!("note: {}", note); + } + } + } + + Ok(()) +} + +fn current_handler(event: &HistoryEvent) -> Result> { + if event.kind == "set_scheme" { + let scheme = event.key.trim_end_matches("://"); + Ok(launchservices::query_default_scheme_handler(scheme)?) + } else { + let ext = event.key.trim_start_matches('.'); + Ok(launchservices::query_default_bundle_id(ext)?) + } +} + +fn apply_revert(event: &HistoryEvent, old: &str) -> Result<()> { + if event.kind == "set_scheme" { + let scheme = event.key.trim_end_matches("://"); + launchservices::set_default_scheme_handler(old, scheme)?; + } else { + let ext = event.key.trim_start_matches('.'); + let uti_str = uti::uti_for_extension(ext)?; + launchservices::set_default(old, &uti_str)?; + } + Ok(()) +} diff --git a/crates/openwith-cli/src/main.rs b/crates/openwith-cli/src/main.rs index 2d25d63..76bc117 100644 --- a/crates/openwith-cli/src/main.rs +++ b/crates/openwith-cli/src/main.rs @@ -19,6 +19,12 @@ fn main() -> Result<()> { Some(cli::Commands::Apps) => { commands::tui::run(commands::tui::InitialView::Apps)?; } + Some(cli::Commands::History { limit, json }) => { + commands::history::run(limit, json)?; + } + Some(cli::Commands::Undo { force }) => { + commands::undo::run(force)?; + } Some(cli::Commands::Export { output }) => { commands::export::run(output.as_deref())?; } From 4c72ff8d9eb8d71fb1ec5804435d5a449426edf3 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:10:02 +1000 Subject: [PATCH 3/7] feat: menu bar popover with tray icon, global shortcut, live settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports prototype variants 1d/2b: tray icon opens a popover (extension lookup with picker, Recent Changes with per-entry Undo, open-main/quit footer), global shortcut ⌥⌘O, hide on blur. Backend gains an apps cache so popover lookups skip the rescan, plus picker/recent-changes commands. 'Show in menu bar' (default on) and 'Launch at login' (autostart plugin) settings are now functional. --- Cargo.lock | 199 ++++++++++- crates/openwith-gui/package-lock.json | 14 +- crates/openwith-gui/package.json | 1 + crates/openwith-gui/src-tauri/Cargo.toml | 5 +- .../src-tauri/capabilities/default.json | 16 +- crates/openwith-gui/src-tauri/src/commands.rs | 196 ++++++++++- crates/openwith-gui/src-tauri/src/lib.rs | 36 ++ crates/openwith-gui/src-tauri/src/tray.rs | 60 ++++ crates/openwith-gui/src-tauri/tauri.conf.json | 15 + crates/openwith-gui/src/api.ts | 30 ++ crates/openwith-gui/src/app.ts | 43 ++- crates/openwith-gui/src/main.ts | 9 + crates/openwith-gui/src/menubar.ts | 267 +++++++++++++++ crates/openwith-gui/src/state.ts | 7 +- crates/openwith-gui/src/styles.css | 320 ++++++++++++++++++ 15 files changed, 1189 insertions(+), 29 deletions(-) create mode 100644 crates/openwith-gui/src-tauri/src/tray.rs create mode 100644 crates/openwith-gui/src/main.ts create mode 100644 crates/openwith-gui/src/menubar.ts diff --git a/Cargo.lock b/Cargo.lock index c998cf2..f33dc13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -390,6 +401,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -876,13 +893,33 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -893,7 +930,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1026,7 +1063,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1398,6 +1435,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1517,6 +1564,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1862,6 +1927,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2227,6 +2305,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -2572,8 +2660,11 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tauri-plugin-positioner", ] [[package]] @@ -2870,6 +2961,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.39.4" @@ -2936,6 +3033,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3691,7 +3799,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3699,6 +3807,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", @@ -3741,7 +3850,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3811,6 +3920,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -3853,6 +3976,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -3875,6 +4013,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-positioner" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686204dc3171a2d59436e470c8ea99f08c5f5bf63ef1a40f900d6d48f433b816" +dependencies = [ + "log", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4322,7 +4475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5176,6 +5329,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5208,7 +5370,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5263,6 +5425,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/openwith-gui/package-lock.json b/crates/openwith-gui/package-lock.json index 944fa8e..8d68a68 100644 --- a/crates/openwith-gui/package-lock.json +++ b/crates/openwith-gui/package-lock.json @@ -1,14 +1,15 @@ { "name": "openwith-gui", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openwith-gui", - "version": "0.1.0", + "version": "0.5.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2" }, @@ -1091,6 +1092,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", + "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", diff --git a/crates/openwith-gui/package.json b/crates/openwith-gui/package.json index 3d4f4fb..759a59d 100644 --- a/crates/openwith-gui/package.json +++ b/crates/openwith-gui/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2" }, diff --git a/crates/openwith-gui/src-tauri/Cargo.toml b/crates/openwith-gui/src-tauri/Cargo.toml index 9985c4e..8c0b89d 100644 --- a/crates/openwith-gui/src-tauri/Cargo.toml +++ b/crates/openwith-gui/src-tauri/Cargo.toml @@ -17,8 +17,11 @@ tauri-build = { version = "2", features = [] } [dependencies] openwith-core = { path = "../../openwith-core" } anyhow.workspace = true -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon", "image-png", "macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" +tauri-plugin-global-shortcut = "2" +tauri-plugin-positioner = { version = "2", features = ["tray-icon"] } +tauri-plugin-autostart = "2" serde.workspace = true serde_json.workspace = true diff --git a/crates/openwith-gui/src-tauri/capabilities/default.json b/crates/openwith-gui/src-tauri/capabilities/default.json index 803b0a6..a02011a 100644 --- a/crates/openwith-gui/src-tauri/capabilities/default.json +++ b/crates/openwith-gui/src-tauri/capabilities/default.json @@ -1,7 +1,17 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], - "permissions": ["core:default", "opener:default", "dialog:default"] + "description": "Capability for the main and menu-bar windows", + "windows": ["main", "menubar"], + "permissions": [ + "core:default", + "opener:default", + "dialog:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus" + ] } diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index b7380d9..9a45038 100644 --- a/crates/openwith-gui/src-tauri/src/commands.rs +++ b/crates/openwith-gui/src-tauri/src/commands.rs @@ -1,13 +1,40 @@ +use std::sync::{Arc, Mutex}; + use serde::Serialize; +use tauri::{AppHandle, Manager, State}; use openwith_core::history::{self, HistoryEvent}; +use openwith_core::types::AppInfo; use openwith_core::{config, launchservices, listing, scanner, uti}; +use crate::tray; + /// History writes are best-effort — never fail the change that triggered them. fn record_history(event: HistoryEvent) { let _ = history::record(event); } +/// Scanned apps, shared between the main window and the menu-bar popover so +/// popover lookups don't pay the multi-second scan. +#[derive(Default)] +pub struct AppsCache(Mutex>>>); + +fn cached_apps(cache: &State<'_, AppsCache>) -> Result>, String> { + let mut slot = cache.0.lock().expect("apps cache poisoned"); + if let Some(apps) = slot.as_ref() { + return Ok(Arc::clone(apps)); + } + let apps = Arc::new(scanner::scan_all_apps().map_err(|e| e.to_string())?); + *slot = Some(Arc::clone(&apps)); + Ok(apps) +} + +fn refresh_apps(cache: &State<'_, AppsCache>) -> Result>, String> { + let apps = Arc::new(scanner::scan_all_apps().map_err(|e| e.to_string())?); + *cache.0.lock().expect("apps cache poisoned") = Some(Arc::clone(&apps)); + Ok(apps) +} + #[derive(Serialize)] pub struct AppDto { pub name: String, @@ -77,6 +104,140 @@ pub struct ImportSkippedDto { pub reason: String, } +#[derive(Serialize)] +pub struct ExtMatchDto { + pub ext: String, + pub app_name: Option, + pub bundle_id: Option, +} + +#[derive(Serialize)] +pub struct PickerAppDto { + pub name: String, + pub bundle_id: String, + pub current: bool, +} + +#[derive(Serialize)] +pub struct RecentChangeDto { + pub kind: String, + pub key: String, + pub app_name: String, + pub old_bundle_id: Option, + pub timestamp: u64, +} + +/// Prefix-match known extensions for the menu-bar popover, newest defaults +/// resolved live (cheap: one Launch Services query per shown row). +#[tauri::command] +pub fn search_extensions( + query: String, + cache: State<'_, AppsCache>, +) -> Result, String> { + let q = query.trim().trim_start_matches('.').to_lowercase(); + if q.is_empty() { + return Ok(Vec::new()); + } + let apps = cached_apps(&cache)?; + let mut exts = scanner::all_extensions(&apps); + exts.retain(|e| e.starts_with(&q)); + exts.sort(); + exts.truncate(3); + + Ok(exts + .into_iter() + .map(|ext| { + let bundle_id = launchservices::query_default_bundle_id(&ext).ok().flatten(); + let app_name = bundle_id.as_ref().map(|b| scanner::resolve_name(&apps, b)); + ExtMatchDto { + ext, + app_name, + bundle_id, + } + }) + .collect()) +} + +/// Apps offered in the popover's picker for one extension: declared +/// supporters, or every app when nothing declares it. +#[tauri::command] +pub fn get_ext_picker( + ext: String, + cache: State<'_, AppsCache>, +) -> Result, String> { + let ext = ext.trim_start_matches('.').to_lowercase(); + let apps = cached_apps(&cache)?; + let current = launchservices::query_default_bundle_id(&ext).ok().flatten(); + + let mut source: Vec<&AppInfo> = apps + .iter() + .filter(|a| a.extensions.contains(&ext)) + .collect(); + if source.is_empty() { + source = apps.iter().collect(); + } + source.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(source + .into_iter() + .map(|a| PickerAppDto { + name: a.name.clone(), + bundle_id: a.bundle_id.clone(), + current: current + .as_deref() + .is_some_and(|c| c.eq_ignore_ascii_case(&a.bundle_id)), + }) + .collect()) +} + +/// Recent set events for the popover's Recent Changes list, names resolved. +#[tauri::command] +pub fn get_recent_changes( + limit: usize, + cache: State<'_, AppsCache>, +) -> Result, String> { + let apps = cached_apps(&cache)?; + let events = history::recent(100).map_err(|e| e.to_string())?; + Ok(events + .into_iter() + .filter(|e| matches!(e.kind.as_str(), "set" | "set_scheme")) + .take(limit) + .map(|e| RecentChangeDto { + kind: e.kind, + key: e.key, + app_name: e + .new + .as_ref() + .map(|b| scanner::resolve_name(&apps, b)) + .unwrap_or_else(|| "?".into()), + old_bundle_id: e.old, + timestamp: e.timestamp, + }) + .collect()) +} + +#[tauri::command] +pub fn show_main_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + if let Some(popover) = app.get_webview_window("menubar") { + let _ = popover.hide(); + } +} + +#[tauri::command] +pub fn quit_app(app: AppHandle) { + app.exit(0); +} + +#[tauri::command] +pub fn set_tray_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { + tray::set_enabled(&app, enabled).map_err(|e| e.to_string()) +} + #[derive(Serialize)] pub struct HistoryEventDto { pub kind: String, @@ -140,8 +301,8 @@ pub fn relaunch_finder() -> Result<(), String> { } #[tauri::command] -pub fn get_snapshot() -> Result { - let apps = scanner::scan_all_apps().map_err(|e| e.to_string())?; +pub fn get_snapshot(cache: State<'_, AppsCache>) -> Result { + let apps = refresh_apps(&cache)?; let app_dtos = apps .iter() @@ -187,8 +348,12 @@ pub fn get_snapshot() -> Result { } #[tauri::command] -pub fn set_default(ext: String, app: String) -> Result { - let apps = scanner::scan_all_apps().map_err(|e| e.to_string())?; +pub fn set_default( + ext: String, + app: String, + cache: State<'_, AppsCache>, +) -> Result { + let apps = cached_apps(&cache)?; let (bundle_id, display_name) = scanner::resolve_app_or_bundle_id(&apps, &app).map_err(|e| e.to_string())?; @@ -238,8 +403,12 @@ pub fn set_default(ext: String, app: String) -> Result { } #[tauri::command] -pub fn set_scheme_default(scheme: String, app: String) -> Result { - let apps = scanner::scan_all_apps().map_err(|e| e.to_string())?; +pub fn set_scheme_default( + scheme: String, + app: String, + cache: State<'_, AppsCache>, +) -> Result { + let apps = cached_apps(&cache)?; let (bundle_id, display_name) = scanner::resolve_app_or_bundle_id(&apps, &app).map_err(|e| e.to_string())?; @@ -290,8 +459,11 @@ pub fn set_scheme_default(scheme: String, app: String) -> Result) -> Result { - let apps = scanner::scan_all_apps().map_err(|e| e.to_string())?; +pub fn export_toml( + path: Option, + cache: State<'_, AppsCache>, +) -> Result { + let apps = cached_apps(&cache)?; let (cfg, display_names) = config::export_associations(&apps).map_err(|e| e.to_string())?; let toml_str = config::to_toml(&cfg, &display_names).map_err(|e| e.to_string())?; @@ -324,11 +496,15 @@ pub fn export_toml(path: Option) -> Result { } #[tauri::command] -pub fn import_toml(path: String, dry_run: bool) -> Result { +pub fn import_toml( + path: String, + dry_run: bool, + cache: State<'_, AppsCache>, +) -> Result { let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let cfg = config::from_toml(&content).map_err(|e| e.to_string())?; - let apps = scanner::scan_all_apps().map_err(|e| e.to_string())?; + let apps = cached_apps(&cache)?; let result = config::import_associations(&cfg, &apps, dry_run); if !dry_run { diff --git a/crates/openwith-gui/src-tauri/src/lib.rs b/crates/openwith-gui/src-tauri/src/lib.rs index d21fa69..d38c960 100644 --- a/crates/openwith-gui/src-tauri/src/lib.rs +++ b/crates/openwith-gui/src-tauri/src/lib.rs @@ -1,10 +1,40 @@ mod commands; +mod tray; + +use tauri_plugin_autostart::MacosLauncher; +use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let toggle_shortcut = Shortcut::new(Some(Modifiers::ALT | Modifiers::SUPER), Code::KeyO); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_positioner::init()) + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + None, + )) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_shortcuts([toggle_shortcut]) + .expect("valid shortcut") + .with_handler(move |app, shortcut, event| { + if shortcut == &toggle_shortcut && event.state == ShortcutState::Pressed { + tray::toggle_popover(app); + } + }) + .build(), + ) + .manage(commands::AppsCache::default()) + .manage(tray::TrayState::default()) + .on_window_event(|window, event| { + // The popover behaves like a menu: clicking anywhere else closes it. + if window.label() == "menubar" && matches!(event, tauri::WindowEvent::Focused(false)) { + let _ = window.hide(); + } + }) .invoke_handler(tauri::generate_handler![ commands::detect_cli, commands::relaunch_finder, @@ -14,6 +44,12 @@ pub fn run() { commands::set_scheme_default, commands::export_toml, commands::import_toml, + commands::search_extensions, + commands::get_ext_picker, + commands::get_recent_changes, + commands::show_main_window, + commands::quit_app, + commands::set_tray_enabled, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/crates/openwith-gui/src-tauri/src/tray.rs b/crates/openwith-gui/src-tauri/src/tray.rs new file mode 100644 index 0000000..a3ea270 --- /dev/null +++ b/crates/openwith-gui/src-tauri/src/tray.rs @@ -0,0 +1,60 @@ +use std::sync::Mutex; + +use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_positioner::{Position, WindowExt}; + +/// The live tray icon, if the "Show in menu bar" setting is on. +#[derive(Default)] +pub struct TrayState(pub Mutex>); + +pub fn set_enabled(app: &AppHandle, enabled: bool) -> tauri::Result<()> { + let state = app.state::(); + let mut slot = state.0.lock().expect("tray state poisoned"); + if enabled { + if slot.is_none() { + *slot = Some(build(app)?); + } + } else if let Some(tray) = slot.take() { + // Dropping the handle removes the icon from the menu bar. + drop(tray); + } + Ok(()) +} + +fn build(app: &AppHandle) -> tauri::Result { + let mut builder = TrayIconBuilder::with_id("openwith-tray") + .tooltip("OpenWith") + .on_tray_icon_event(|tray, event| { + tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event); + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + toggle_popover(tray.app_handle()); + } + }); + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + builder.build(app) +} + +pub fn toggle_popover(app: &AppHandle) { + let Some(window) = app.get_webview_window("menubar") else { + return; + }; + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + return; + } + // Anchor under the tray icon when we have one; otherwise (shortcut with + // the tray disabled) fall back to the top-right corner. + if window.move_window(Position::TrayBottomCenter).is_err() { + let _ = window.move_window(Position::TopRight); + } + let _ = window.show(); + let _ = window.set_focus(); +} diff --git a/crates/openwith-gui/src-tauri/tauri.conf.json b/crates/openwith-gui/src-tauri/tauri.conf.json index 08103bb..cc79f93 100644 --- a/crates/openwith-gui/src-tauri/tauri.conf.json +++ b/crates/openwith-gui/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ }, "app": { "withGlobalTauri": true, + "macOSPrivateApi": true, "windows": [ { "title": "OpenWith", @@ -20,6 +21,20 @@ "dragDropEnabled": true, "titleBarStyle": "Overlay", "hiddenTitle": true + }, + { + "label": "menubar", + "title": "OpenWith Quick Access", + "width": 360, + "height": 470, + "visible": false, + "decorations": false, + "transparent": true, + "shadow": false, + "resizable": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "dragDropEnabled": true } ], "security": { diff --git a/crates/openwith-gui/src/api.ts b/crates/openwith-gui/src/api.ts index c4bf8ec..59a14c1 100644 --- a/crates/openwith-gui/src/api.ts +++ b/crates/openwith-gui/src/api.ts @@ -60,6 +60,26 @@ export interface ImportPreviewDto { skipped: ImportSkippedDto[]; } +export interface ExtMatchDto { + ext: string; + app_name: string | null; + bundle_id: string | null; +} + +export interface PickerAppDto { + name: string; + bundle_id: string; + current: boolean; +} + +export interface RecentChangeDto { + kind: "set" | "set_scheme"; + key: string; + app_name: string; + old_bundle_id: string | null; + timestamp: number; +} + export interface HistoryEventDto { kind: "set" | "set_scheme" | "export" | "import"; key: string; @@ -84,4 +104,14 @@ export const api = { invoke("import_toml", { path, dryRun }), getHistory: (limit: number) => invoke("get_history", { limit }), + searchExtensions: (query: string) => + invoke("search_extensions", { query }), + getExtPicker: (ext: string) => + invoke("get_ext_picker", { ext }), + getRecentChanges: (limit: number) => + invoke("get_recent_changes", { limit }), + showMainWindow: () => invoke("show_main_window"), + quitApp: () => invoke("quit_app"), + setTrayEnabled: (enabled: boolean) => + invoke("set_tray_enabled", { enabled }), }; diff --git a/crates/openwith-gui/src/app.ts b/crates/openwith-gui/src/app.ts index ccc61e7..42f568b 100644 --- a/crates/openwith-gui/src/app.ts +++ b/crates/openwith-gui/src/app.ts @@ -1,5 +1,10 @@ import { getVersion } from "@tauri-apps/api/app"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { + disable as disableAutostart, + enable as enableAutostart, + isEnabled as autostartEnabled, +} from "@tauri-apps/plugin-autostart"; import { ask, open as openDialog, @@ -360,8 +365,8 @@ function renderSettings(): string {
GENERAL
- ${toggleRow("launchAtLogin", s.launchAtLogin, "Launch at login", "Arrives with the menu bar panel in v0.5.1", true)} - ${toggleRow("showMenuBar", s.showMenuBar, "Show in menu bar", "Quick-access panel with ⌥⌘O — arrives in v0.5.1", true)} + ${toggleRow("launchAtLogin", s.launchAtLogin, "Launch at login", "Start OpenWith when you log in")} + ${toggleRow("showMenuBar", s.showMenuBar, "Show in menu bar", "Quick-access panel with ⌥⌘O")}
Open on tabWhich view the main window starts on ${segmented("set-open-tab", "tab", [{ key: "extensions", label: "Extensions" }, { key: "apps", label: "Apps" }], s.openOnTab)} @@ -922,6 +927,8 @@ root.addEventListener("click", (e) => { break; case "toggle": { const key = target.dataset.toggle as + | "launchAtLogin" + | "showMenuBar" | "confirmBeforeApplying" | "warnUtiConflicts" | "showBundleIds" @@ -929,6 +936,14 @@ root.addEventListener("click", (e) => { | "autoUpdateCheck"; state.settings[key] = !state.settings[key]; saveSettings(); + if (key === "launchAtLogin") { + void applyLaunchAtLogin(state.settings.launchAtLogin); + } else if (key === "showMenuBar") { + api.setTrayEnabled(state.settings.showMenuBar).catch(() => { + state.toast = { text: "Couldn't update the menu bar icon" }; + render(); + }); + } render(); break; } @@ -1008,9 +1023,33 @@ getCurrentWebview().onDragDropEvent((event) => { // ---------- bootstrap ---------- +async function applyLaunchAtLogin(wanted: boolean) { + try { + if (wanted) await enableAutostart(); + else await disableAutostart(); + } catch { + // reflect reality back into the toggle rather than showing a lie + state.settings.launchAtLogin = await autostartEnabled().catch(() => false); + saveSettings(); + render(); + } +} + async function bootstrap() { render(); + // Apply persisted preferences that live outside the webview. + api.setTrayEnabled(state.settings.showMenuBar).catch(() => {}); + autostartEnabled() + .then((actual) => { + if (actual !== state.settings.launchAtLogin) { + state.settings.launchAtLogin = actual; + saveSettings(); + render(); + } + }) + .catch(() => {}); + getVersion().then((v) => { state.appVersion = v; if (state.settings.autoUpdateCheck) void checkForUpdates(); diff --git a/crates/openwith-gui/src/main.ts b/crates/openwith-gui/src/main.ts new file mode 100644 index 0000000..37a28df --- /dev/null +++ b/crates/openwith-gui/src/main.ts @@ -0,0 +1,9 @@ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +// One Vite bundle serves both windows; the label picks the UI. +if (getCurrentWebviewWindow().label === "menubar") { + document.documentElement.classList.add("popover-window"); + void import("./menubar"); +} else { + void import("./app"); +} diff --git a/crates/openwith-gui/src/menubar.ts b/crates/openwith-gui/src/menubar.ts new file mode 100644 index 0000000..2082c1d --- /dev/null +++ b/crates/openwith-gui/src/menubar.ts @@ -0,0 +1,267 @@ +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +import { + api, + type ExtMatchDto, + type PickerAppDto, + type RecentChangeDto, +} from "./api"; +import { avatarColor, initials } from "./colors"; +import { escapeHtml } from "./state"; + +const root = document.getElementById("app")!; +const popoverWindow = getCurrentWebviewWindow(); + +interface PopoverState { + query: string; + matches: ExtMatchDto[]; + recent: RecentChangeDto[]; + picker: { ext: string; apps: PickerAppDto[] } | null; +} + +const state: PopoverState = { + query: "", + matches: [], + recent: [], + picker: null, +}; + +function chip(name: string): string { + return `${escapeHtml(initials(name))}`; +} + +function relTime(timestamp: number): string { + const ago = Math.max(0, Math.floor(Date.now() / 1000) - timestamp); + if (ago < 60) return "now"; + if (ago < 3600) return `${Math.floor(ago / 60)}m ago`; + if (ago < 86_400) return `${Math.floor(ago / 3600)}h ago`; + return `${Math.floor(ago / 86_400)}d ago`; +} + +function renderMatches(): string { + if (state.query.trim() === "") { + return ""; + } + if (state.matches.length === 0) { + return `
No extension matches “${escapeHtml(state.query)}”
`; + } + return state.matches + .map((m, i) => { + const app = m.app_name ?? "(none)"; + return ` +
+ ${chip(app)} + + .${escapeHtml(m.ext)} → ${escapeHtml(app)} + ${escapeHtml(m.bundle_id ?? "no default set")} + + +
`; + }) + .join(""); +} + +function renderRecent(): string { + if (state.recent.length === 0) { + return `
No changes recorded yet.
`; + } + return state.recent + .map((e, i) => { + const right = e.old_bundle_id + ? `` + : `${relTime(e.timestamp)}`; + return ` +
+ + ${escapeHtml(e.key)} + → ${escapeHtml(e.app_name)} + ${right} +
`; + }) + .join(""); +} + +function renderPicker(): string { + if (!state.picker) return ""; + const rows = state.picker.apps + .map( + (a) => ` +
+ ${chip(a.name)} + ${escapeHtml(a.name)} + ${a.current ? `CURRENT` : ""} +
`, + ) + .join(""); + return ` +
+
+
Open .${escapeHtml(state.picker.ext)} with…
+
${rows}
+
+
`; +} + +function render() { + const active = document.activeElement as HTMLInputElement | null; + const hadFocus = active?.id === "pop-search"; + const selStart = active?.selectionStart ?? null; + + root.innerHTML = ` +
+
+ OpenWith + ⌥⌘O +
+
+
Drop a file to look up its default
+
or type an extension below
+
+ +
${renderMatches()}
+
+ +
${renderRecent()}
+
+ + ${renderPicker()} +
`; + + if (hadFocus) { + const el = document.getElementById("pop-search") as HTMLInputElement | null; + el?.focus(); + if (el && selStart !== null) el.setSelectionRange(selStart, selStart); + } +} + +async function refreshMatches() { + try { + state.matches = state.query.trim() + ? await api.searchExtensions(state.query) + : []; + } catch { + state.matches = []; + } + render(); +} + +async function refreshRecent() { + try { + state.recent = await api.getRecentChanges(4); + } catch { + state.recent = []; + } + render(); +} + +async function openPicker(ext: string) { + try { + state.picker = { ext, apps: await api.getExtPicker(ext) }; + } catch { + state.picker = null; + } + render(); +} + +async function choose(bundleId: string) { + const picker = state.picker; + state.picker = null; + if (!picker) return; + try { + await api.setDefault(picker.ext, bundleId); + } catch { + // surfaced by the refreshed rows showing the unchanged default + } + await Promise.all([refreshMatches(), refreshRecent()]); +} + +async function undo(index: number) { + const entry = state.recent[index]; + if (!entry?.old_bundle_id) return; + try { + if (entry.kind === "set_scheme") { + await api.setSchemeDefault(entry.key.replace(/:\/\/$/, ""), entry.old_bundle_id); + } else { + await api.setDefault(entry.key.replace(/^\./, ""), entry.old_bundle_id); + } + } catch { + // leave the list as-is; the refresh below shows the real state + } + await Promise.all([refreshMatches(), refreshRecent()]); +} + +root.addEventListener("click", (e) => { + const target = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null; + if (!target) return; + switch (target.dataset.action) { + case "change": + void openPicker(target.dataset.ext!); + break; + case "choose": + void choose(target.dataset.bundleId!); + break; + case "close-picker": + state.picker = null; + render(); + break; + case "swallow": + break; + case "undo": + void undo(Number(target.dataset.index)); + break; + case "open-main": + void api.showMainWindow(); + break; + case "quit": + void api.quitApp(); + break; + } +}); + +root.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + if (target.id === "pop-search") { + state.query = target.value; + void refreshMatches(); + } +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + if (state.picker) { + state.picker = null; + render(); + } else { + void popoverWindow.hide(); + } + } +}); + +// Fresh data each time the popover opens; focus the search field. +void popoverWindow.onFocusChanged(({ payload: focused }) => { + if (focused) { + void refreshRecent(); + document.getElementById("pop-search")?.focus(); + } +}); + +getCurrentWebview().onDragDropEvent((event) => { + if (event.payload.type !== "drop") return; + const path = event.payload.paths[0]; + if (!path) return; + const filename = path.split("/").pop() ?? path; + const dot = filename.lastIndexOf("."); + if (dot <= 0) return; + state.query = filename.slice(dot + 1).toLowerCase(); + void refreshMatches(); +}); + +render(); +void refreshRecent(); diff --git a/crates/openwith-gui/src/state.ts b/crates/openwith-gui/src/state.ts index 3a91e66..4bdc9c5 100644 --- a/crates/openwith-gui/src/state.ts +++ b/crates/openwith-gui/src/state.ts @@ -38,9 +38,8 @@ export interface UpdateStatus { } /** User preferences, persisted to localStorage. Mirrors the prototype's - * Settings pane. `launchAtLogin` and `showMenuBar` render disabled until the - * menu bar panel lands in v0.5.1 — they're stored so flipping them then - * doesn't lose intent. */ + * Settings pane. `launchAtLogin` mirrors the autostart plugin's real state + * (synced at bootstrap); `showMenuBar` drives tray creation. */ export interface SettingsState { launchAtLogin: boolean; showMenuBar: boolean; @@ -57,7 +56,7 @@ const SETTINGS_KEY = "openwith.settings"; const DEFAULT_SETTINGS: SettingsState = { launchAtLogin: false, - showMenuBar: false, + showMenuBar: true, confirmBeforeApplying: false, warnUtiConflicts: true, showBundleIds: true, diff --git a/crates/openwith-gui/src/styles.css b/crates/openwith-gui/src/styles.css index cbbe2ff..7377fdf 100644 --- a/crates/openwith-gui/src/styles.css +++ b/crates/openwith-gui/src/styles.css @@ -1158,6 +1158,326 @@ body { } } +/* menu-bar popover (prototype 1d light / 2b dark) */ +:root { + --pop-bg: rgba(250, 248, 245, 0.96); + --pop-drop-bg: #fff; + --pop-drop-text: #5c574f; + --pop-footer-text: #5c574f; + --pop-shadow: 0 18px 50px rgba(43, 41, 38, 0.25); + --pop-picker-bg: #fff; +} + +@media (prefers-color-scheme: dark) { + :root { + --pop-bg: rgba(32, 29, 27, 0.94); + --pop-drop-bg: #292521; + --pop-drop-text: #a89f92; + --pop-footer-text: #a89f92; + --pop-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); + --pop-picker-bg: var(--sheet-bg); + } +} + +html.popover-window, +html.popover-window body { + background: transparent; +} + +.popover { + width: 360px; + background: var(--pop-bg); + backdrop-filter: blur(20px); + border: 1px solid var(--claim-border); + border-radius: 12px; + box-shadow: var(--pop-shadow); + overflow: hidden; + color: var(--text); + position: relative; + display: flex; + flex-direction: column; +} + +.pop-head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px 10px; +} + +.pop-head .title { + font-size: 12.5px; + font-weight: 700; +} + +.pop-head .hotkey { + margin-left: auto; + font-size: 10.5px; + color: var(--text-faintest); +} + +.pop-dropzone { + margin: 0 12px 10px; + border: 1.5px dashed var(--claim-border); + border-radius: 9px; + padding: 14px; + text-align: center; + background: var(--pop-drop-bg); +} + +.pop-dropzone .line1 { + font-size: 12px; + color: var(--pop-drop-text); + font-weight: 500; +} + +.pop-dropzone .line2 { + font-size: 10.5px; + color: var(--text-faintest); + margin-top: 3px; +} + +.pop-search { + display: flex; + align-items: center; + gap: 7px; + margin: 0 12px 10px; + background: var(--pill-bg); + border-radius: 8px; + padding: 7px 10px; +} + +.pop-search .icon { + font-size: 11px; + color: var(--text-faint); +} + +.pop-search input { + border: none; + background: transparent; + outline: none; + font-family: ui-monospace, Menlo, monospace; + font-size: 12px; + width: 100%; + color: var(--text); +} + +.pop-matches { + margin: 0 8px 8px; + min-height: 44px; +} + +.pop-row { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 8px; + border-radius: 7px; +} + +.pop-row.first { + background: var(--field-bg); +} + +.pop-row-text { + min-width: 0; +} + +.pop-row-text .line { + display: block; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pop-row-text .bid { + display: block; + font-size: 10px; + color: var(--text-faint); + font-family: ui-monospace, Menlo, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pop-link { + margin-left: auto; + flex: none; + font-size: 10.5px; + color: var(--accent); + font-weight: 600; + cursor: default; + background: none; + border: none; + font-family: inherit; +} + +.pop-link:hover { + text-decoration: underline; +} + +.pop-empty { + padding: 10px 8px; + font-size: 11.5px; + color: var(--text-faintest); + font-style: italic; +} + +.pop-recent { + border-top: 1px solid var(--header-border); + padding: 8px 14px; +} + +.pop-section-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text-faintest); + margin-bottom: 6px; +} + +.pop-recent-rows { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11.5px; +} + +.pop-recent-row { + display: flex; + gap: 8px; + align-items: center; +} + +.pop-recent-row .tick { + color: var(--ok-text); +} + +.pop-recent-row .mono { + font-family: ui-monospace, Menlo, monospace; +} + +.pop-muted { + color: var(--text-faint); +} + +.pop-muted.italic { + font-style: italic; + font-size: 11px; +} + +.pop-recent-row .pop-link.small { + font-size: 10.5px; +} + +.pop-time { + margin-left: auto; + color: var(--text-faintest); + font-size: 10.5px; +} + +.pop-footer { + display: flex; + border-top: 1px solid var(--header-border); + font-size: 11.5px; + color: var(--pop-footer-text); +} + +.pop-footer button { + flex: 1; + text-align: center; + padding: 9px; + cursor: default; + background: none; + border: none; + color: inherit; + font-family: inherit; + font-size: inherit; +} + +.pop-footer button:first-child { + border-right: 1px solid var(--header-border); +} + +.pop-footer button:hover { + background: var(--field-bg); +} + +.pop-picker-overlay { + position: absolute; + inset: 0; + background: var(--sheet-overlay); + z-index: 5; + display: grid; + place-items: center; +} + +.pop-picker { + width: 290px; + max-height: 85%; + display: flex; + flex-direction: column; + background: var(--pop-picker-bg); + border: 1px solid var(--panel-border); + border-radius: 11px; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +.pop-picker-title { + padding: 11px 13px 8px; + font-size: 12.5px; + font-weight: 700; +} + +.pop-picker-title .accent { + color: var(--accent); +} + +.pop-picker-rows { + padding: 4px 6px 8px; + display: flex; + flex-direction: column; + gap: 1px; + overflow-y: auto; +} + +.pop-picker-row { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 8px; + border-radius: 7px; + cursor: default; +} + +.pop-picker-row:hover { + background: var(--field-bg); +} + +.pop-picker-row .avatar { + width: 20px; + height: 20px; + font-size: 9px; +} + +.pop-picker-row .name { + font-size: 12px; +} + +.pop-picker-row .name.bold { + font-weight: 700; +} + +.current-tag { + margin-left: auto; + font-size: 9.5px; + font-weight: 600; + color: var(--text-faintest); +} + /* drop-on-window overlay */ .window-dropzone { position: absolute; From 2df370e7a4dd4bd7d249d2e7b569aa40ee27bfe0 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:10:43 +1000 Subject: [PATCH 4/7] docs: document history/undo commands and menu-bar architecture --- CLAUDE.md | 16 ++++++++++++---- README.md | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 243791f..42e492d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,8 @@ cargo run -p openwith-cli -- set -s http Firefox # set default browser cargo run -p openwith-cli -- export -o out.toml # export associations to TOML cargo run -p openwith-cli -- import --dry-run out.toml # preview an import cargo run -p openwith-cli -- import out.toml # import associations from TOML +cargo run -p openwith-cli -- history # recent changes from CLI + GUI (--json) +cargo run -p openwith-cli -- undo # revert the most recent change cargo check # quick compile check cargo test # run tests cargo clippy # lint checks @@ -60,16 +62,21 @@ crates/ set.rs -- `openwith set ` with name/bundle-ID resolution export.rs -- `openwith export` dump associations + schemes to TOML import.rs -- `openwith import` apply TOML (idempotent, `--dry-run`) + history.rs -- `openwith history` list recent events (relative dates, --json) + undo.rs -- `openwith undo` revert last set (drift check, --force) tui.rs -- ratatui TUI: Extensions + Apps tabs, loading screen, AppPicker + Help openwith-gui/ -- Tauri v2 GUI ("OpenWith.app") src/ -- vanilla TS + Vite frontend (no framework) - main.ts -- render functions + delegated event handling + main.ts -- entry: dispatches to app.ts (main window) or menubar.ts by window label + app.ts -- main window render functions + delegated event handling + menubar.ts -- tray popover (prototype 1d/2b): ext lookup, Recent Changes + Undo state.ts -- app state, derived views, settings persistence (localStorage) api.ts -- typed invoke() wrappers mirroring the Rust DTOs styles.css -- design-prototype palette, light + dark via prefers-color-scheme src-tauri/src/ - commands.rs -- #[tauri::command] wrappers over openwith-core (snapshot, set, export/import, detect_cli) - lib.rs -- tauri Builder + plugin registration (dialog, opener) + commands.rs -- #[tauri::command] wrappers over openwith-core + apps cache + tray.rs -- tray icon lifecycle + popover positioning (plugin-positioner) + lib.rs -- tauri Builder, plugins (dialog, opener, positioner, autostart, global-shortcut ⌥⌘O) ``` ### Key patterns @@ -85,7 +92,8 @@ crates/ - Loading screen enters TUI alternate screen immediately, shows ASCII logo + spinner while scanning in background. - Export/import uses serde + toml crate with `BTreeMap` for sorted, human-readable TOML; import validates apps exist and skips associations already set correctly. - GUI: single `get_snapshot` command returns apps + associations (with sibling-UTI conflict data) + contested schemes in one call; the frontend is a plain render-to-innerHTML loop with `data-action` event delegation, no framework. Versions are lockstep: `tauri.conf.json` omits `version` so the app version comes from `workspace.package` in the root Cargo.toml. -- `openwith-core::history` is the shared change log (capped at 500 events, best-effort writes that never fail the triggering change). The GUI records set/export/import events and renders export/import in the Profiles HISTORY panel; CLI recording plus `openwith history`/`openwith undo` land in Phase 2 (v0.5.1). +- `openwith-core::history` is the shared change log (capped at 500 events, best-effort writes that never fail the triggering change). CLI, GUI, and core import all record into it; the GUI Profiles panel shows export/import events, the menu-bar popover shows set events with per-entry Undo, and `openwith history`/`openwith undo` read the same file. +- The GUI is two windows off one Vite bundle: `main` and a hidden transparent `menubar` popover (requires `macOSPrivateApi: true`). The popover hides on blur and is toggled by the tray icon or ⌥⌘O. A backend `AppsCache` (refreshed by `get_snapshot`) keeps popover lookups instant. - GUI settings live in localStorage (`openwith.settings`). The Settings pane mirrors the design prototype's full layout; controls whose feature ships in a later 0.5.x phase (launch at login, menu bar) render disabled with an "arrives in v0.5.1" note rather than as silently-dead toggles. - GUI visual source of truth is the claude.design prototype "OpenWith GUI Explorations" (project 14225854-984c-4e5c-8d2b-8c9ce38a1624), variants 1c (light) / 2a (dark): glyph tab icons (⌸ ⊞ ⤴ ⇅ — never emoji), 2-char initial chips (20px rows / 26px app list / 52px detail), fixed mid accent oklch(0.62 0.14 45) for tab underline + toggles, inverted toast. Check UI changes against it before shipping. diff --git a/README.md b/README.md index 030186a..f92faf9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ openwith set md Typora # Set Typora as default for .md openwith set md abnerworks.Typora # Bundle IDs work too openwith current -s http # Show the default browser openwith set -s http Firefox # Set the default browser +openwith history # Recent changes (recorded by CLI and GUI alike) +openwith undo # Revert the most recent change ``` Run `openwith --help` to see all commands. From 318fb4283776595632dcb7d20db23e24df3582a0 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:10:43 +1000 Subject: [PATCH 5/7] chore: bump workspace version to 0.5.1 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/openwith-gui/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f33dc13..2b1cccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "openwith-cli" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "clap", @@ -2640,7 +2640,7 @@ dependencies = [ [[package]] name = "openwith-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "core-foundation", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "openwith-gui" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "openwith-core", diff --git a/Cargo.toml b/Cargo.toml index ce4ec4c..2013b6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/openwith-core", "crates/openwith-cli", "crates/openwith-gui/src-tauri"] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2024" license = "MIT" repository = "https://github.com/ColeMei/openwith" diff --git a/crates/openwith-gui/package.json b/crates/openwith-gui/package.json index 759a59d..591b8af 100644 --- a/crates/openwith-gui/package.json +++ b/crates/openwith-gui/package.json @@ -1,7 +1,7 @@ { "name": "openwith-gui", "private": true, - "version": "0.5.0", + "version": "0.5.1", "type": "module", "scripts": { "dev": "vite", From 0ac120891e5b0c2e0480d1b712827f3d4bab963a Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:21:37 +1000 Subject: [PATCH 6/7] fix: monochrome template tray icon, subtler popover shadow Menu bar icons should be template images so macOS recolors them for light/dark bars and the pressed state; the glyph is the logo's document-with-arrow motif simplified for 22pt (SVG source alongside). Popover shadow drops from the prototype's mock-scale 18/50 to a native-feeling 6/22. --- .../src-tauri/icons/tray-template.png | Bin 0 -> 835 bytes .../src-tauri/icons/tray-template.svg | 13 +++++++++++++ crates/openwith-gui/src-tauri/src/tray.rs | 14 ++++++++------ crates/openwith-gui/src/styles.css | 4 ++-- 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 crates/openwith-gui/src-tauri/icons/tray-template.png create mode 100644 crates/openwith-gui/src-tauri/icons/tray-template.svg diff --git a/crates/openwith-gui/src-tauri/icons/tray-template.png b/crates/openwith-gui/src-tauri/icons/tray-template.png new file mode 100644 index 0000000000000000000000000000000000000000..2faf76df7c0cd79990ed976ed3005238fcec1a41 GIT binary patch literal 835 zcmV-J1HAl+P)UOOX2D&T}iV&?L zN-d2AvJtQwI00cp?6C;h1=aTw zgRg{E4pVpq>{aLQfx8J}Y5(j3jsqQF7}yWImI#e3L>tH`@JYl1kv#?4czvbo3aecQ;Z~3G0(h*g z(4Gaw*8?|z8DK{hwUZ=}-OV}25jvn`qWEio4s%f9qIu)}WO#h@6?dku5)K+HsW0 zC}Ot{2a~!AjEiiln7tISlqM{?55Oj=u+wclW|y1g46?Shap1O7v+I(C;Qh|4rcB)2 zgyWbtp7)fy2Ny z7rBN0egKzTAY`iMGJ(2wi8Oo{iJ!V(rL^fR@YcpD{NB5Fx!>3q%_?}s>{)QE^Is7@;jB5dL05#WGx5S7r% z@h$`+W4eA0OzS%8<8a302qMkMg#K;pAjO-@>BP}f-V$;+J4k6dfBXyUu zmQ$a+tKO#hC~ykcQsB7tmK?>R9k(va1MQ@*&!d!2>*E~X4=})ggFl%RDj?Jney;!k N002ovPDHLkV1kQhj06Ax literal 0 HcmV?d00001 diff --git a/crates/openwith-gui/src-tauri/icons/tray-template.svg b/crates/openwith-gui/src-tauri/icons/tray-template.svg new file mode 100644 index 0000000..3a1458d --- /dev/null +++ b/crates/openwith-gui/src-tauri/icons/tray-template.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/crates/openwith-gui/src-tauri/src/tray.rs b/crates/openwith-gui/src-tauri/src/tray.rs index a3ea270..80327b7 100644 --- a/crates/openwith-gui/src-tauri/src/tray.rs +++ b/crates/openwith-gui/src-tauri/src/tray.rs @@ -23,8 +23,13 @@ pub fn set_enabled(app: &AppHandle, enabled: bool) -> tauri::Result<()> { } fn build(app: &AppHandle) -> tauri::Result { - let mut builder = TrayIconBuilder::with_id("openwith-tray") + // Monochrome template image: macOS recolors it for light/dark menu bars + // and the pressed state, like native status items. + let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray-template.png"))?; + TrayIconBuilder::with_id("openwith-tray") .tooltip("OpenWith") + .icon(icon) + .icon_as_template(true) .on_tray_icon_event(|tray, event| { tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event); if let TrayIconEvent::Click { @@ -35,11 +40,8 @@ fn build(app: &AppHandle) -> tauri::Result { { toggle_popover(tray.app_handle()); } - }); - if let Some(icon) = app.default_window_icon() { - builder = builder.icon(icon.clone()); - } - builder.build(app) + }) + .build(app) } pub fn toggle_popover(app: &AppHandle) { diff --git a/crates/openwith-gui/src/styles.css b/crates/openwith-gui/src/styles.css index 7377fdf..64eafea 100644 --- a/crates/openwith-gui/src/styles.css +++ b/crates/openwith-gui/src/styles.css @@ -1164,7 +1164,7 @@ body { --pop-drop-bg: #fff; --pop-drop-text: #5c574f; --pop-footer-text: #5c574f; - --pop-shadow: 0 18px 50px rgba(43, 41, 38, 0.25); + --pop-shadow: 0 6px 22px rgba(43, 41, 38, 0.18); --pop-picker-bg: #fff; } @@ -1174,7 +1174,7 @@ body { --pop-drop-bg: #292521; --pop-drop-text: #a89f92; --pop-footer-text: #a89f92; - --pop-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); + --pop-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); --pop-picker-bg: var(--sheet-bg); } } From 7388271b86ba460a4c721c832beae5e46033e2c4 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 12:34:48 +1000 Subject: [PATCH 7/7] fix: undo consumes history entries instead of stacking new ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undo semantics per the prototype's dUndo: the reverted event is marked undone and the revert recorded as is_undo. Recent Changes (popover) is an undo stack — entries disappear when undone, nothing is appended; the Profiles HISTORY panel becomes the full ledger and now includes sets and reverts (fixing set-history being menu-bar-only). CLI undo skips consumed events. mark_undone matches on new-handler and undoable state so same-second timestamp collisions can't leave an event eternally re-undoable (regression test included). --- crates/openwith-cli/src/commands/export.rs | 1 + crates/openwith-cli/src/commands/history.rs | 6 +- crates/openwith-cli/src/commands/import.rs | 1 + crates/openwith-cli/src/commands/set.rs | 2 + crates/openwith-cli/src/commands/undo.rs | 15 ++- crates/openwith-core/src/config.rs | 2 + crates/openwith-core/src/history.rs | 104 +++++++++++++++++- crates/openwith-gui/src-tauri/src/commands.rs | 100 +++++++++++++++-- crates/openwith-gui/src-tauri/src/lib.rs | 1 + crates/openwith-gui/src/api.ts | 10 +- crates/openwith-gui/src/app.ts | 68 +++++++----- crates/openwith-gui/src/menubar.ts | 8 +- crates/openwith-gui/src/styles.css | 5 + 13 files changed, 271 insertions(+), 52 deletions(-) diff --git a/crates/openwith-cli/src/commands/export.rs b/crates/openwith-cli/src/commands/export.rs index f5848b2..9e2b6c8 100644 --- a/crates/openwith-cli/src/commands/export.rs +++ b/crates/openwith-cli/src/commands/export.rs @@ -30,6 +30,7 @@ pub fn run(output: Option<&str>) -> Result<()> { )), timestamp: history::now_secs(), source: "cli".into(), + ..Default::default() }); println!( "Exported {} associations and {} scheme handlers to {}", diff --git a/crates/openwith-cli/src/commands/history.rs b/crates/openwith-cli/src/commands/history.rs index 95afdb9..7aa3306 100644 --- a/crates/openwith-cli/src/commands/history.rs +++ b/crates/openwith-cli/src/commands/history.rs @@ -18,6 +18,8 @@ pub fn run(limit: usize, json: bool) -> Result<()> { "detail": e.detail, "timestamp": e.timestamp, "source": e.source, + "undone": e.undone, + "is_undo": e.is_undo, }) }) .collect(); @@ -51,7 +53,9 @@ fn describe(event: &HistoryEvent, old_name: Option, new_name: Option { let new = new_name.unwrap_or_else(|| "?".into()); let was = old_name.map(|o| format!(" (was {o})")).unwrap_or_default(); - format!("set {} → {}{}", event.key, new, was) + let verb = if event.is_undo { "undid: set" } else { "set" }; + let reverted = if event.undone { " · reverted" } else { "" }; + format!("{} {} → {}{}{}", verb, event.key, new, was, reverted) } "export" => format!( "exported {}{}", diff --git a/crates/openwith-cli/src/commands/import.rs b/crates/openwith-cli/src/commands/import.rs index 386e460..cd39bb0 100644 --- a/crates/openwith-cli/src/commands/import.rs +++ b/crates/openwith-cli/src/commands/import.rs @@ -42,6 +42,7 @@ pub fn run(path: &str, dry_run: bool) -> Result<()> { )), timestamp: history::now_secs(), source: "cli".into(), + ..Default::default() }); } diff --git a/crates/openwith-cli/src/commands/set.rs b/crates/openwith-cli/src/commands/set.rs index fe9e61a..1bed33c 100644 --- a/crates/openwith-cli/src/commands/set.rs +++ b/crates/openwith-cli/src/commands/set.rs @@ -44,6 +44,7 @@ pub fn run(ext: &str, app_name: &str, scheme: bool) -> Result<()> { detail: None, timestamp: history::now_secs(), source: "cli".into(), + ..Default::default() }); let was = previous @@ -90,6 +91,7 @@ fn run_scheme(apps: &[AppInfo], scheme: &str, bundle_id: &str, display_name: &st detail: None, timestamp: history::now_secs(), source: "cli".into(), + ..Default::default() }); let was = previous diff --git a/crates/openwith-cli/src/commands/undo.rs b/crates/openwith-cli/src/commands/undo.rs index 293be8c..978425b 100644 --- a/crates/openwith-cli/src/commands/undo.rs +++ b/crates/openwith-cli/src/commands/undo.rs @@ -4,12 +4,10 @@ use openwith_core::history::{self, HistoryEvent}; use openwith_core::{launchservices, scanner, uti}; /// Revert the most recent recorded default change (from CLI, GUI, or import). +/// Undo consumes the event: it's marked undone and won't be offered again. pub fn run(force: bool) -> Result<()> { let events = history::recent(100)?; - let Some(event) = events - .iter() - .find(|e| matches!(e.kind.as_str(), "set" | "set_scheme") && e.old.is_some()) - else { + let Some(event) = events.iter().find(|e| e.undoable()) else { println!("Nothing to undo — no recorded change has a previous default."); return Ok(()); }; @@ -40,14 +38,21 @@ pub fn run(force: bool) -> Result<()> { apply_revert(event, old)?; + let _ = history::mark_undone( + &event.kind, + &event.key, + event.timestamp, + event.new.as_deref(), + ); let _ = history::record(HistoryEvent { kind: event.kind.clone(), key: event.key.clone(), old: event.new.clone(), new: Some(old.to_string()), - detail: None, timestamp: history::now_secs(), source: "cli".into(), + is_undo: true, + ..Default::default() }); println!( diff --git a/crates/openwith-core/src/config.rs b/crates/openwith-core/src/config.rs index ebe8ca8..7402dca 100644 --- a/crates/openwith-core/src/config.rs +++ b/crates/openwith-core/src/config.rs @@ -132,6 +132,7 @@ pub fn import_associations(config: &Config, apps: &[AppInfo], dry_run: bool) -> detail: None, timestamp: history::now_secs(), source: "import".into(), + ..Default::default() }); applied.push((ext_key.clone(), display_name, previous)); } @@ -181,6 +182,7 @@ pub fn import_associations(config: &Config, apps: &[AppInfo], dry_run: bool) -> detail: None, timestamp: history::now_secs(), source: "import".into(), + ..Default::default() }); applied.push((display_key, display_name, previous)); } diff --git a/crates/openwith-core/src/history.rs b/crates/openwith-core/src/history.rs index 4ba271e..5d6f2b7 100644 --- a/crates/openwith-core/src/history.rs +++ b/crates/openwith-core/src/history.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// Keep the log bounded; older events fall off the front. const MAX_EVENTS: usize = 500; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] pub struct HistoryEvent { /// "set" | "set_scheme" | "export" | "import" pub kind: String, @@ -32,6 +32,24 @@ pub struct HistoryEvent { pub timestamp: u64, /// "cli" | "gui" | "import" pub source: String, + /// This change was later reverted via Undo. The ledger keeps the row; + /// undo-stack views (popover Recent Changes) hide it. + #[serde(default)] + pub undone: bool, + /// This event is itself an Undo (the compensating revert). + #[serde(default)] + pub is_undo: bool, +} + +impl HistoryEvent { + /// A live, revertible change: has a previous handler and hasn't been + /// undone, and isn't itself a revert. + pub fn undoable(&self) -> bool { + matches!(self.kind.as_str(), "set" | "set_scheme") + && self.old.is_some() + && !self.undone + && !self.is_undo + } } pub fn now_secs() -> u64 { @@ -87,6 +105,37 @@ pub fn recent_at(path: &Path, limit: usize) -> Result> { Ok(events) } +/// Flag the newest matching *undoable* event as undone. Matching includes the +/// new-handler value and skips consumed events: second-resolution timestamps +/// can collide (a set and its revert within one second), and flagging the +/// wrong twin would leave the undone event eternally re-undoable. +/// No-op if the event has already fallen off the capped log. +pub fn mark_undone(kind: &str, key: &str, timestamp: u64, new: Option<&str>) -> Result<()> { + mark_undone_at(&history_path()?, kind, key, timestamp, new) +} + +pub fn mark_undone_at( + path: &Path, + kind: &str, + key: &str, + timestamp: u64, + new: Option<&str>, +) -> Result<()> { + let mut events = load(path); + if let Some(event) = events.iter_mut().rev().find(|e| { + e.kind == kind + && e.key == key + && e.timestamp == timestamp + && e.new.as_deref() == new + && e.undoable() + }) { + event.undone = true; + let json = serde_json::to_string_pretty(&events)?; + std::fs::write(path, json).with_context(|| format!("writing {}", path.display()))?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -102,11 +151,9 @@ mod tests { HistoryEvent { kind: kind.into(), key: key.into(), - old: None, - new: None, - detail: None, timestamp: ts, source: "gui".into(), + ..Default::default() } } @@ -145,6 +192,55 @@ mod tests { std::fs::remove_file(&path).unwrap(); } + #[test] + fn mark_undone_flags_the_matching_event() { + let path = temp_log("undone"); + let _ = std::fs::remove_file(&path); + + let mut set = event("set", ".md", 10); + set.old = Some("a".into()); + set.new = Some("b".into()); + record_at(&path, set).unwrap(); + record_at(&path, event("export", "x.toml", 11)).unwrap(); + + assert!(recent_at(&path, 5).unwrap()[1].undoable()); + mark_undone_at(&path, "set", ".md", 10, Some("b")).unwrap(); + + let events = recent_at(&path, 5).unwrap(); + assert!(events[1].undone); + assert!(!events[1].undoable()); + // unknown event → silent no-op + mark_undone_at(&path, "set", ".zzz", 99, None).unwrap(); + + std::fs::remove_file(&path).unwrap(); + } + + #[test] + fn mark_undone_skips_consumed_twins_on_timestamp_collision() { + let path = temp_log("collision"); + let _ = std::fs::remove_file(&path); + + // A set and another event in the same second, the newer one already + // undone — marking must flag the still-active older twin. + let mut a = event("set", ".md", 10); + a.old = Some("typora".into()); + a.new = Some("textedit".into()); + let mut b = event("set", ".md", 10); + b.old = Some("textedit".into()); + b.new = Some("typora".into()); + b.undone = true; + record_at(&path, a).unwrap(); + record_at(&path, b).unwrap(); + + mark_undone_at(&path, "set", ".md", 10, Some("textedit")).unwrap(); + + let events = recent_at(&path, 5).unwrap(); + assert!(events.iter().all(|e| e.undone)); + assert!(events.iter().all(|e| !e.undoable())); + + std::fs::remove_file(&path).unwrap(); + } + #[test] fn log_is_capped() { let path = temp_log("cap"); diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index 9a45038..a1cdc96 100644 --- a/crates/openwith-gui/src-tauri/src/commands.rs +++ b/crates/openwith-gui/src-tauri/src/commands.rs @@ -69,11 +69,15 @@ pub struct SnapshotDto { #[derive(Serialize)] pub struct SetResultDto { pub key: String, + pub kind: String, pub app_name: String, pub bundle_id: String, pub previous_app_name: Option, pub unchanged: bool, pub siblings: Vec, + /// Timestamp of the recorded history event; lets the frontend undo this + /// exact change (0 when nothing was recorded). + pub timestamp: u64, } #[derive(Serialize)] @@ -191,6 +195,7 @@ pub fn get_ext_picker( } /// Recent set events for the popover's Recent Changes list, names resolved. +/// Undo-stack view: undone changes and the reverts themselves are hidden. #[tauri::command] pub fn get_recent_changes( limit: usize, @@ -200,7 +205,7 @@ pub fn get_recent_changes( let events = history::recent(100).map_err(|e| e.to_string())?; Ok(events .into_iter() - .filter(|e| matches!(e.kind.as_str(), "set" | "set_scheme")) + .filter(|e| matches!(e.kind.as_str(), "set" | "set_scheme") && !e.undone && !e.is_undo) .take(limit) .map(|e| RecentChangeDto { kind: e.kind, @@ -216,6 +221,60 @@ pub fn get_recent_changes( .collect()) } +/// Undo one recorded change: restore the previous handler, mark the original +/// event consumed, and record the revert as an is_undo event. +#[tauri::command] +pub fn undo_change( + kind: String, + key: String, + timestamp: u64, + cache: State<'_, AppsCache>, +) -> Result { + let apps = cached_apps(&cache)?; + let events = history::recent(500).map_err(|e| e.to_string())?; + let event = events + .iter() + .find(|e| e.kind == kind && e.key == key && e.timestamp == timestamp && e.undoable()) + .ok_or("that change is no longer undoable")?; + let old = event.old.clone().expect("undoable implies old"); + let new = event.new.clone().unwrap_or_default(); + + let siblings = if kind == "set_scheme" { + let scheme = key.trim_end_matches("://"); + launchservices::set_default_scheme_handler(&old, scheme).map_err(|e| e.to_string())?; + Vec::new() + } else { + let ext = key.trim_start_matches('.'); + let uti_str = uti::uti_for_extension(ext).map_err(|e| e.to_string())?; + launchservices::set_default(&old, &uti_str).map_err(|e| e.to_string())?; + uti::extensions_sharing_uti(ext, &uti_str, &scanner::all_extensions(&apps)) + }; + + let _ = history::mark_undone(&kind, &key, timestamp, event.new.as_deref()); + let now = history::now_secs(); + record_history(HistoryEvent { + kind: kind.clone(), + key: key.clone(), + old: event.new.clone(), + new: Some(old.clone()), + timestamp: now, + source: "gui".into(), + is_undo: true, + ..Default::default() + }); + + Ok(SetResultDto { + key, + kind, + app_name: scanner::resolve_name(&apps, &old), + bundle_id: old, + previous_app_name: Some(scanner::resolve_name(&apps, &new)), + unchanged: false, + siblings, + timestamp: now, + }) +} + #[tauri::command] pub fn show_main_window(app: AppHandle) { if let Some(window) = app.get_webview_window("main") { @@ -242,26 +301,35 @@ pub fn set_tray_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { pub struct HistoryEventDto { pub kind: String, pub key: String, - pub old: Option, - pub new: Option, + pub old_name: Option, + pub new_name: Option, pub detail: Option, pub timestamp: u64, pub source: String, + pub undone: bool, + pub is_undo: bool, } +/// Full ledger for the Profiles HISTORY panel, bundle IDs resolved to names. #[tauri::command] -pub fn get_history(limit: usize) -> Result, String> { +pub fn get_history( + limit: usize, + cache: State<'_, AppsCache>, +) -> Result, String> { + let apps = cached_apps(&cache)?; let events = history::recent(limit).map_err(|e| e.to_string())?; Ok(events .into_iter() .map(|e| HistoryEventDto { kind: e.kind, key: e.key, - old: e.old, - new: e.new, + old_name: e.old.as_ref().map(|b| scanner::resolve_name(&apps, b)), + new_name: e.new.as_ref().map(|b| scanner::resolve_name(&apps, b)), detail: e.detail, timestamp: e.timestamp, source: e.source, + undone: e.undone, + is_undo: e.is_undo, }) .collect()) } @@ -367,24 +435,27 @@ pub fn set_default( { return Ok(SetResultDto { key: format!(".{ext}"), + kind: "set".into(), app_name: display_name, bundle_id, previous_app_name: None, unchanged: true, siblings: Vec::new(), + timestamp: 0, }); } launchservices::set_default(&bundle_id, &uti_str).map_err(|e| e.to_string())?; + let timestamp = history::now_secs(); record_history(HistoryEvent { kind: "set".into(), key: format!(".{ext}"), old: previous.clone(), new: Some(bundle_id.clone()), - detail: None, - timestamp: history::now_secs(), + timestamp, source: "gui".into(), + ..Default::default() }); let previous_app_name = previous.map(|p| scanner::resolve_name(&apps, &p)); @@ -394,11 +465,13 @@ pub fn set_default( Ok(SetResultDto { key: format!(".{ext}"), + kind: "set".into(), app_name: display_name, bundle_id, previous_app_name, unchanged: false, siblings, + timestamp, }) } @@ -426,35 +499,40 @@ pub fn set_scheme_default( { return Ok(SetResultDto { key: format!("{scheme}://"), + kind: "set_scheme".into(), app_name: display_name, bundle_id, previous_app_name: None, unchanged: true, siblings: Vec::new(), + timestamp: 0, }); } launchservices::set_default_scheme_handler(&bundle_id, &scheme).map_err(|e| e.to_string())?; + let timestamp = history::now_secs(); record_history(HistoryEvent { kind: "set_scheme".into(), key: format!("{scheme}://"), old: previous.clone(), new: Some(bundle_id.clone()), - detail: None, - timestamp: history::now_secs(), + timestamp, source: "gui".into(), + ..Default::default() }); let previous_app_name = previous.map(|p| scanner::resolve_name(&apps, &p)); Ok(SetResultDto { key: format!("{scheme}://"), + kind: "set_scheme".into(), app_name: display_name, bundle_id, previous_app_name, unchanged: false, siblings: Vec::new(), + timestamp, }) } @@ -485,6 +563,7 @@ pub fn export_toml( )), timestamp: history::now_secs(), source: "gui".into(), + ..Default::default() }); } @@ -524,6 +603,7 @@ pub fn import_toml( )), timestamp: history::now_secs(), source: "gui".into(), + ..Default::default() }); } diff --git a/crates/openwith-gui/src-tauri/src/lib.rs b/crates/openwith-gui/src-tauri/src/lib.rs index d38c960..190c7d4 100644 --- a/crates/openwith-gui/src-tauri/src/lib.rs +++ b/crates/openwith-gui/src-tauri/src/lib.rs @@ -47,6 +47,7 @@ pub fn run() { commands::search_extensions, commands::get_ext_picker, commands::get_recent_changes, + commands::undo_change, commands::show_main_window, commands::quit_app, commands::set_tray_enabled, diff --git a/crates/openwith-gui/src/api.ts b/crates/openwith-gui/src/api.ts index 59a14c1..73369f5 100644 --- a/crates/openwith-gui/src/api.ts +++ b/crates/openwith-gui/src/api.ts @@ -29,11 +29,13 @@ export interface SnapshotDto { export interface SetResultDto { key: string; + kind: "set" | "set_scheme"; app_name: string; bundle_id: string; previous_app_name: string | null; unchanged: boolean; siblings: string[]; + timestamp: number; } export interface ExportResultDto { @@ -83,11 +85,13 @@ export interface RecentChangeDto { export interface HistoryEventDto { kind: "set" | "set_scheme" | "export" | "import"; key: string; - old: string | null; - new: string | null; + old_name: string | null; + new_name: string | null; detail: string | null; timestamp: number; source: string; + undone: boolean; + is_undo: boolean; } export const api = { @@ -110,6 +114,8 @@ export const api = { invoke("get_ext_picker", { ext }), getRecentChanges: (limit: number) => invoke("get_recent_changes", { limit }), + undoChange: (kind: string, key: string, timestamp: number) => + invoke("undo_change", { kind, key, timestamp }), showMainWindow: () => invoke("show_main_window"), quitApp: () => invoke("quit_app"), setTrayEnabled: (enabled: boolean) => diff --git a/crates/openwith-gui/src/app.ts b/crates/openwith-gui/src/app.ts index 42f568b..3f77892 100644 --- a/crates/openwith-gui/src/app.ts +++ b/crates/openwith-gui/src/app.ts @@ -222,25 +222,41 @@ function historyDate(timestamp: number): string { return d.toLocaleDateString([], { month: "short", day: "numeric" }); } +function historyRow(e: import("./api").HistoryEventDto): string { + let icon: string; + let iconClass = ""; + let text: string; + if (e.kind === "export") { + icon = "↓"; + text = `Exported ${escapeHtml(e.key)}`; + } else if (e.kind === "import") { + icon = "✓"; + iconClass = "ok"; + text = `Imported ${escapeHtml(e.key)}`; + } else if (e.is_undo) { + icon = "↩"; + text = `Undid ${escapeHtml(e.key)} → ${escapeHtml(e.new_name ?? "?")}`; + } else { + icon = "→"; + iconClass = e.undone ? "" : "ok"; + const was = e.old_name ? ` (was ${escapeHtml(e.old_name)})` : ""; + text = `Set ${escapeHtml(e.key)} → ${escapeHtml(e.new_name ?? "?")}${was}`; + } + const detail = e.detail ? escapeHtml(e.detail) : e.undone ? "reverted" : ""; + return ` +
+ ${icon} + ${text} + ${detail} + ${escapeHtml(historyDate(e.timestamp))} +
`; +} + function renderHistory(): string { - const events = state.history.filter( - (e) => e.kind === "export" || e.kind === "import", - ); const rows = - events.length > 0 - ? events - .map((e) => { - const isImport = e.kind === "import"; - return ` -
- ${isImport ? "✓" : "↓"} - ${isImport ? "Imported" : "Exported"} ${escapeHtml(e.key)} - ${escapeHtml(e.detail ?? "")} - ${escapeHtml(historyDate(e.timestamp))} -
`; - }) - .join("") - : `
No exports or imports yet.
`; + state.history.length > 0 + ? state.history.map(historyRow).join("") + : `
Changes, exports, and imports will appear here.
`; return `
@@ -610,18 +626,20 @@ function buildToast(result: SetResultDto) { : ""; return { text: `Set ${result.key} → ${result.app_name}${was}${extra}`, - undo: result.previous_app_name - ? () => undoSet(result.key, result.previous_app_name!) - : undefined, + undo: + result.previous_app_name && result.timestamp > 0 + ? () => undoSet(result) + : undefined, }; } -async function undoSet(key: string, previousAppName: string) { +async function undoSet(setResult: SetResultDto) { try { - const isScheme = key.endsWith("://"); - const result = isScheme - ? await api.setSchemeDefault(key.slice(0, -3), previousAppName) - : await api.setDefault(key.slice(1), previousAppName); + const result = await api.undoChange( + setResult.kind, + setResult.key, + setResult.timestamp, + ); applySetResult(result, false); state.toast = { text: `Reverted ${result.key} → ${result.app_name}` }; afterApply(); diff --git a/crates/openwith-gui/src/menubar.ts b/crates/openwith-gui/src/menubar.ts index 2082c1d..ec1a9b0 100644 --- a/crates/openwith-gui/src/menubar.ts +++ b/crates/openwith-gui/src/menubar.ts @@ -186,11 +186,9 @@ async function undo(index: number) { const entry = state.recent[index]; if (!entry?.old_bundle_id) return; try { - if (entry.kind === "set_scheme") { - await api.setSchemeDefault(entry.key.replace(/:\/\/$/, ""), entry.old_bundle_id); - } else { - await api.setDefault(entry.key.replace(/^\./, ""), entry.old_bundle_id); - } + // Consumes the entry: it disappears from this list instead of piling + // a compensating row on top. + await api.undoChange(entry.kind, entry.key, entry.timestamp); } catch { // leave the list as-is; the refresh below shows the real state } diff --git a/crates/openwith-gui/src/styles.css b/crates/openwith-gui/src/styles.css index 64eafea..b3ffcf0 100644 --- a/crates/openwith-gui/src/styles.css +++ b/crates/openwith-gui/src/styles.css @@ -799,6 +799,11 @@ body { background: var(--field-bg); } +.history-row.undone .history-text { + color: var(--text-faint); + text-decoration: line-through; +} + .history-icon { flex: none; font-size: 11px;