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/Cargo.lock b/Cargo.lock index c998cf2..2b1cccc 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" @@ -2536,7 +2624,7 @@ dependencies = [ [[package]] name = "openwith-cli" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "clap", @@ -2552,7 +2640,7 @@ dependencies = [ [[package]] name = "openwith-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "core-foundation", @@ -2564,7 +2652,7 @@ dependencies = [ [[package]] name = "openwith-gui" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "openwith-core", @@ -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/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/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. 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..9e2b6c8 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,24 @@ 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(), + ..Default::default() + }); 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..7aa3306 --- /dev/null +++ b/crates/openwith-cli/src/commands/history.rs @@ -0,0 +1,138 @@ +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, + "undone": e.undone, + "is_undo": e.is_undo, + }) + }) + .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(); + 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 {}{}", + 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..cd39bb0 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,27 @@ 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(), + ..Default::default() + }); + } + // 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..1bed33c 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,18 @@ 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(), + ..Default::default() + }); + let was = previous .map(|p| format!(" (was: {})", scanner::resolve_name(&apps, &p))) .unwrap_or_default(); @@ -70,6 +83,17 @@ 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(), + ..Default::default() + }); + 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..978425b --- /dev/null +++ b/crates/openwith-cli/src/commands/undo.rs @@ -0,0 +1,99 @@ +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). +/// 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| e.undoable()) 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::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()), + timestamp: history::now_secs(), + source: "cli".into(), + is_undo: true, + ..Default::default() + }); + + 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())?; } diff --git a/crates/openwith-core/src/config.rs b/crates/openwith-core/src/config.rs index 9c6075e..7402dca 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,17 @@ 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(), + ..Default::default() + }); applied.push((ext_key.clone(), display_name, previous)); } Err(e) => { @@ -154,7 +165,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 +174,16 @@ 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(), + ..Default::default() + }); applied.push((display_key, display_name, previous)); } Err(e) => { 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/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..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", @@ -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/icons/tray-template.png b/crates/openwith-gui/src-tauri/icons/tray-template.png new file mode 100644 index 0000000..2faf76d Binary files /dev/null and b/crates/openwith-gui/src-tauri/icons/tray-template.png differ 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/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index b7380d9..a1cdc96 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, @@ -42,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)] @@ -77,30 +108,228 @@ 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. +/// Undo-stack view: undone changes and the reverts themselves are hidden. +#[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") && !e.undone && !e.is_undo) + .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()) +} + +/// 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") { + 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, 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()) } @@ -140,8 +369,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 +416,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())?; @@ -202,24 +435,27 @@ pub fn set_default(ext: String, app: String) -> Result { { 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)); @@ -229,17 +465,23 @@ pub fn set_default(ext: String, app: String) -> Result { Ok(SetResultDto { key: format!(".{ext}"), + kind: "set".into(), app_name: display_name, bundle_id, previous_app_name, unchanged: false, siblings, + timestamp, }) } #[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())?; @@ -257,41 +499,49 @@ 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())?; @@ -313,6 +563,7 @@ pub fn export_toml(path: Option) -> Result { )), timestamp: history::now_secs(), source: "gui".into(), + ..Default::default() }); } @@ -324,11 +575,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 { @@ -348,6 +603,7 @@ pub fn import_toml(path: String, dry_run: bool) -> Result>); + +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 { + // 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 { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + toggle_popover(tray.app_handle()); + } + }) + .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..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 { @@ -60,14 +62,36 @@ 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; - 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 = { @@ -84,4 +108,16 @@ 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 }), + 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) => + invoke("set_tray_enabled", { enabled }), }; diff --git a/crates/openwith-gui/src/app.ts b/crates/openwith-gui/src/app.ts new file mode 100644 index 0000000..3f77892 --- /dev/null +++ b/crates/openwith-gui/src/app.ts @@ -0,0 +1,1092 @@ +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, + save as saveDialog, +} from "@tauri-apps/plugin-dialog"; + +import { api, type AppDto, type SetResultDto } from "./api"; +import { avatarColor, initials } from "./colors"; +import { + appStats, + escapeHtml, + filteredApps, + filteredAssociations, + findApp, + saveSettings, + schemeRole, + sheetApps, + state, + type Tab, +} from "./state"; + +const root = document.getElementById("app")!; + +function avatar(name: string, extraClass = ""): string { + return `${escapeHtml(initials(name))}`; +} + +// ---------- shell ---------- + +// Glyphs from the design prototype — monochrome text, not emoji. +const TABS: { id: Tab; icon: string; label: string }[] = [ + { id: "extensions", icon: "⌸", label: "Extensions" }, + { id: "apps", icon: "⊞", label: "Apps" }, + { id: "schemes", icon: "⤴", label: "Schemes" }, + { id: "profiles", icon: "⇅", label: "Profiles" }, +]; + +function renderHeader(): string { + const tabs = TABS.map( + (t) => ` + `, + ).join(""); + + return ` +
+ OpenWith +
+ ${tabs} + +
+
`; +} + +// ---------- extensions ---------- + +function renderExtensions(): string { + const rows = filteredAssociations(); + const showBids = state.settings.showBundleIds; + const gridClass = showBids ? "" : "no-bids"; + const rowsHtml = rows + .map((r) => { + const appName = r.app_name ?? "(none)"; + const bid = r.bundle_id ?? ""; + const badge = r.conflict + ? `UTI ⚠` + : ""; + return ` +
+ .${escapeHtml(r.ext)} + + ${avatar(appName)} + ${escapeHtml(appName)} + + ${showBids ? `${escapeHtml(bid)}` : ""} + ${badge} +
`; + }) + .join(""); + + return ` +
+
+ +
+ EXTDEFAULT APP${showBids ? "BUNDLE ID" : ""} +
+
${rowsHtml || ``}
+ +
+
`; +} + +// ---------- apps ---------- + +function renderApps(): string { + const apps = filteredApps(); + if (!state.selectedBundleId && apps.length > 0) { + state.selectedBundleId = apps[0].bundle_id; + } + const selected = findApp(state.selectedBundleId) ?? apps[0] ?? null; + + const rowsHtml = apps + .map((a) => { + const stats = appStats(a); + const selectedClass = + selected && a.bundle_id === selected.bundle_id ? "selected" : ""; + return ` +
+ ${avatar(a.name)} + ${escapeHtml(a.name)} + ${stats.defCount} +
`; + }) + .join(""); + + const detail = selected ? renderAppDetail(selected) : `
`; + + return ` +
+
+
+
+ + +
+
+
${rowsHtml}
+
+ ${detail} +
`; +} + +function renderAppDetail(app: AppDto): string { + const stats = appStats(app); + const defaultsHtml = + stats.defaults.length > 0 + ? stats.defaults + .map((e) => `.${escapeHtml(e)}`) + .join("") + : `not the default for anything yet`; + + const claimsHtml = + stats.claimable.length > 0 + ? stats.claimable + .map( + (c) => + `.${escapeHtml(c.ext)} +`, + ) + .join("") + : `already the default for everything it supports`; + + return ` +
+
+ ${avatar(app.name, "avatar-lg")} +
+
${escapeHtml(app.name)}
+
${escapeHtml(app.bundle_id)}
+
+ +
+
+
${stats.defCount}
default for
+
${stats.supCount}
supported
+
+ +
${defaultsHtml}
+ +
${claimsHtml}
+
`; +} + +// ---------- schemes ---------- + +function renderSchemes(): string { + const schemes = state.snapshot?.schemes ?? []; + const rows = schemes + .map((s) => { + const appName = s.app_name ?? "(none)"; + return ` +
+ + ${escapeHtml(s.scheme)}:// + ${escapeHtml(schemeRole(s))} + + + ${avatar(appName, "avatar-sm")} + ${escapeHtml(appName)} + + Change +
`; + }) + .join(""); + + return `
${rows}
`; +} + +// ---------- profiles (export/import) ---------- + +function historyDate(timestamp: number): string { + if (!timestamp) return ""; + const d = new Date(timestamp * 1000); + const today = new Date(); + if (d.toDateString() === today.toDateString()) { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + 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 rows = + state.history.length > 0 + ? state.history.map(historyRow).join("") + : `
Changes, exports, and imports will appear here.
`; + + return ` +
+
HISTORY
+
${rows}
+
`; +} + +function renderProfiles(): string { + const totalExt = state.snapshot?.associations.length ?? 0; + const totalSchemes = state.snapshot?.schemes.length ?? 0; + + return ` +
+
+
+
Export
+
Save all associations to a portable TOML file — like a dotfile.
+
${totalExt} extensions · ${totalSchemes} schemes
+ +
+
+
Import
+
Idempotent — correct entries skipped, missing apps ignored.
+
+ Drop a .toml here, or choose file… +
+
+
+ ${state.importPending ? renderImportPreview() : ""} + ${renderHistory()} +
`; +} + +function renderImportPreview(): string { + const pending = state.importPending!; + const preview = pending.preview; + const lines: string[] = []; + for (const a of preview.applied) { + const was = a.previous_app_name + ? ` (was ${escapeHtml(a.previous_app_name)})` + : ""; + lines.push( + `
✓ set ${escapeHtml(a.key)} → ${escapeHtml(a.app_name)}${was}
`, + ); + } + if (preview.unchanged > 0) { + lines.push(`
− ${preview.unchanged} already set correctly
`); + } + for (const s of preview.skipped) { + lines.push( + `
! skip ${escapeHtml(s.key)} → ${escapeHtml(s.app_name)}: ${escapeHtml(s.reason)}
`, + ); + } + + return ` +
+
+ DRY-RUN PREVIEW — ${escapeHtml(pending.fileName)} + + +
+
${lines.join("") || "No changes."}
+
`; +} + +// ---------- settings ---------- + +function toggleRow( + id: string, + on: boolean, + label: string, + desc: string, + disabled = false, +): string { + return ` +
+ + ${escapeHtml(label)} + ${escapeHtml(desc)} + + +
`; +} + +function segmented( + action: string, + dataKey: string, + options: { key: string; label: string }[], + current: string, +): string { + const buttons = options + .map( + (o) => + ``, + ) + .join(""); + return `${buttons}`; +} + +function updateStatusLine(): string { + const u = state.updateStatus; + const v = state.appVersion; + if (u.error) return `Check failed — ${escapeHtml(u.error)}`; + if (u.latest && v && u.latest !== v) + return `Update ${escapeHtml(u.latest)} available — brew upgrade --cask openwith`; + if (u.latest && u.checkedAt) + return `✓ Up to date · last checked ${escapeHtml(u.checkedAt)}`; + return `Updates ship via Homebrew`; +} + +function renderSettings(): string { + const s = state.settings; + + const cliStatus = state.cliVersion + ? `✓ Installed ${escapeHtml(state.cliVersion)} — GUI and CLI share the same engine` + : `Not found — install with brew install openwith`; + const cliPill = state.cliVersion ? "brew upgrade openwith" : "brew install openwith"; + + return ` +
+
+
+
GENERAL
+ ${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)} +
+
+
+
BEHAVIOR
+ ${toggleRow("confirmBeforeApplying", s.confirmBeforeApplying, "Confirm before applying", "Ask before changing a default")} + ${toggleRow("warnUtiConflicts", s.warnUtiConflicts, "Warn on UTI conflicts", "Flag changes that affect sibling extensions like .env / .txt")} + ${toggleRow("showBundleIds", s.showBundleIds, "Show bundle IDs", "Display raw bundle identifiers in lists")} + ${toggleRow("relaunchFinder", s.relaunchFinder, "Relaunch Finder after changes", "Clears stale icon caches — closes Finder windows")} +
+
+
UPDATES
+
+ + OpenWith ${escapeHtml(state.appVersion ?? "…")} + ${updateStatusLine()} + + +
+ ${toggleRow("autoUpdateCheck", s.autoUpdateCheck, "Check automatically", "Once per launch, in the background")} +
+ ChannelBeta gets new features earlier + ${segmented("set-channel", "channel", [{ key: "stable", label: "Stable" }, { key: "beta", label: "Beta" }], s.updateChannel)} +
+
+
+
COMMAND LINE
+
+ openwith${cliStatus} + ${cliPill} +
+
+
+
`; +} + +// ---------- sheet + toast ---------- + +function renderSheet(): string { + if (!state.sheet) return ""; + const sheet = state.sheet; + const apps = sheetApps(sheet); + const declaredCount = (state.snapshot?.apps ?? []).filter((a) => + sheet.kind === "ext" + ? a.extensions.includes(sheet.key) + : a.url_schemes.includes(sheet.key), + ).length; + + const target = sheet.kind === "ext" ? `.${sheet.key}` : `${sheet.key}://`; + + const warning = + sheet.kind === "ext" && sheet.conflict && state.settings.warnUtiConflicts + ? `
⚠ Shares a file type — also affects ${sheet.siblings + .slice(0, 6) + .map((s) => `.${escapeHtml(s)}`) + .join(", ")}${sheet.siblings.length > 6 ? ` +${sheet.siblings.length - 6}` : ""}
` + : ""; + + const appsHtml = apps + .map((a) => { + const current = + sheet.currentBundleId !== null && + a.bundle_id.toLowerCase() === sheet.currentBundleId.toLowerCase(); + return ` +
+ ${avatar(a.name, "avatar-sm")} + ${escapeHtml(a.name)} +
`; + }) + .join(""); + + const showAllToggle = + declaredCount > 0 + ? segmented("sheet-scope", "scope", [ + { key: "supporting", label: `Supporting (${declaredCount})` }, + { key: "all", label: "All apps" }, + ], sheet.showAll ? "all" : "supporting") + : `no app declares support — showing all`; + + return ` +
+
+
+
Open ${target} with…
+ ${warning} +
+
+ + +
+ ${showAllToggle} +
+
${appsHtml || `No apps match.`}
+
+
`; +} + +function renderToast(): string { + if (!state.toast) return ""; + const undoBtn = state.toast.undo + ? `` + : ""; + return ` +
+ ${escapeHtml(state.toast.text)} + ${undoBtn} +
`; +} + +function renderWindowDropOverlay(): string { + if (!state.windowDragOver) return ""; + return `
Drop to look up or import
`; +} + +// ---------- root ---------- + +function renderLoading(): string { + return ` +
+
+
Scanning applications…
+
`; +} + +function renderError(): string { + return `
Failed to load: ${escapeHtml(state.error ?? "unknown error")}
`; +} + +function renderMain(): string { + let body: string; + if (state.settingsOpen) { + body = renderSettings(); + } else { + switch (state.tab) { + case "extensions": + body = renderExtensions(); + break; + case "apps": + body = renderApps(); + break; + case "schemes": + body = renderSchemes(); + break; + case "profiles": + body = renderProfiles(); + break; + } + } + return ` + ${renderHeader()} + ${body} + ${renderSheet()} + ${renderToast()} + ${renderWindowDropOverlay()}`; +} + +function render() { + const active = document.activeElement as HTMLInputElement | null; + const focusId = active?.id || null; + const selStart = active?.selectionStart ?? null; + const selEnd = active?.selectionEnd ?? null; + + root.innerHTML = `
${ + state.loading ? renderLoading() : state.error ? renderError() : renderMain() + }
`; + + if (focusId) { + const el = document.getElementById(focusId) as HTMLInputElement | null; + if (el) { + el.focus(); + if (selStart !== null && selEnd !== null && "setSelectionRange" in el) { + el.setSelectionRange(selStart, selEnd); + } + } + } +} + +// ---------- mutation helpers ---------- + +function refreshHistory() { + api + .getHistory(50) + .then((events) => { + state.history = events; + render(); + }) + .catch(() => { + // history is display-only; a read failure just leaves the panel stale + }); +} + +function afterApply() { + refreshHistory(); + if (state.settings.relaunchFinder) { + api.relaunchFinder().catch(() => { + // Finder relaunch is best-effort; the association change already applied + }); + } +} + +function applySetResult(result: SetResultDto, announce = true) { + if (!state.snapshot) return; + const isScheme = result.key.endsWith("://"); + if (isScheme) { + const bare = result.key.slice(0, -3); + const s = state.snapshot.schemes.find((s) => s.scheme === bare); + if (s) { + s.bundle_id = result.bundle_id; + s.app_name = result.app_name; + } + } else { + const bare = result.key.slice(1); + const patchOne = (ext: string) => { + const a = state.snapshot!.associations.find((a) => a.ext === ext); + if (a) { + a.bundle_id = result.bundle_id; + a.app_name = result.app_name; + } + }; + patchOne(bare); + result.siblings.forEach(patchOne); + } + if (announce) { + state.toast = buildToast(result); + } +} + +function buildToast(result: SetResultDto) { + if (result.unchanged) { + return { text: `${result.key} is already ${result.app_name}` }; + } + const was = result.previous_app_name ? ` (was ${result.previous_app_name})` : ""; + const extra = + result.siblings.length > 0 && state.settings.warnUtiConflicts + ? ` · also affects ${result.siblings + .slice(0, 3) + .map((s) => `.${s}`) + .join(", ")}${result.siblings.length > 3 ? ` +${result.siblings.length - 3}` : ""}` + : ""; + return { + text: `Set ${result.key} → ${result.app_name}${was}${extra}`, + undo: + result.previous_app_name && result.timestamp > 0 + ? () => undoSet(result) + : undefined, + }; +} + +async function undoSet(setResult: SetResultDto) { + try { + const result = await api.undoChange( + setResult.kind, + setResult.key, + setResult.timestamp, + ); + applySetResult(result, false); + state.toast = { text: `Reverted ${result.key} → ${result.app_name}` }; + afterApply(); + } catch (e) { + state.toast = { text: `Undo failed: ${e}` }; + } + render(); +} + +async function confirmApply(target: string, appName: string): Promise { + if (!state.settings.confirmBeforeApplying) return true; + return ask(`Set ${target} to open with ${appName}?`, { + title: "OpenWith", + kind: "info", + }); +} + +async function chooseApp(bundleId: string) { + const sheet = state.sheet; + if (!sheet) return; + const target = sheet.kind === "ext" ? `.${sheet.key}` : `${sheet.key}://`; + const appName = findApp(bundleId)?.name ?? bundleId; + if (!(await confirmApply(target, appName))) return; + state.sheet = null; + try { + const result = + sheet.kind === "ext" + ? await api.setDefault(sheet.key, bundleId) + : await api.setSchemeDefault(sheet.key, bundleId); + applySetResult(result); + if (!result.unchanged) afterApply(); + } catch (e) { + state.toast = { text: `Failed: ${e}` }; + } + render(); +} + +async function claimExt(ext: string) { + const app = findApp(state.selectedBundleId); + if (!app) return; + if (!(await confirmApply(`.${ext}`, app.name))) return; + try { + const result = await api.setDefault(ext, app.bundle_id); + applySetResult(result); + if (!result.unchanged) afterApply(); + } catch (e) { + state.toast = { text: `Failed: ${e}` }; + } + render(); +} + +async function claimAll() { + const app = findApp(state.selectedBundleId); + if (!app) return; + const stats = appStats(app); + if (!(await confirmApply(`${stats.claimable.length} extensions`, app.name))) return; + let count = 0; + for (const c of stats.claimable) { + try { + const result = await api.setDefault(c.ext, app.bundle_id); + applySetResult(result, false); + count++; + } catch { + // skip failures, continue claiming the rest + } + } + state.toast = { text: `Claimed ${count} extension${count === 1 ? "" : "s"} for ${app.name}` }; + if (count > 0) afterApply(); + render(); +} + +async function handleExport() { + let path: string | null; + try { + path = await saveDialog({ + defaultPath: "openwith.toml", + filters: [{ name: "TOML", extensions: ["toml"] }], + }); + } catch (e) { + state.toast = { text: `Export failed: ${e}` }; + render(); + return; + } + if (!path) return; + try { + const result = await api.exportToml(path); + state.toast = { + text: `Exported ${result.association_count} associations and ${result.scheme_count} schemes`, + }; + refreshHistory(); + } catch (e) { + state.toast = { text: `Export failed: ${e}` }; + } + render(); +} + +async function startImportPreview(path: string) { + try { + const preview = await api.importToml(path, true); + state.importPending = { + path, + fileName: path.split("/").pop() ?? path, + preview, + }; + } catch (e) { + state.toast = { text: `Import failed: ${e}` }; + } + render(); +} + +async function handleImportChoose() { + let selection: string | string[] | null; + try { + selection = await openDialog({ + multiple: false, + filters: [{ name: "TOML", extensions: ["toml"] }], + }); + } catch (e) { + state.toast = { text: `Import failed: ${e}` }; + render(); + return; + } + const path = Array.isArray(selection) ? selection[0] : selection; + if (!path) return; + state.tab = "profiles"; + await startImportPreview(path); +} + +async function applyImport() { + if (!state.importPending) return; + const pending = state.importPending; + if (!(await confirmApply(`${pending.preview.applied.length} entries from ${pending.fileName}`, "their listed apps"))) return; + state.loading = true; + render(); + try { + const result = await api.importToml(pending.path, false); + state.importPending = null; + state.snapshot = await api.getSnapshot(); + state.toast = { + text: `Applied ${result.applied.length}, unchanged ${result.unchanged}, skipped ${result.skipped.length}`, + }; + if (result.applied.length > 0) afterApply(); + else refreshHistory(); + } catch (e) { + state.toast = { text: `Import failed: ${e}` }; + } finally { + state.loading = false; + render(); + } +} + +function openSheet(kind: "ext" | "scheme", key: string) { + if (kind === "ext") { + const assoc = state.snapshot?.associations.find((a) => a.ext === key); + state.sheet = { + kind, + key, + conflict: assoc?.conflict ?? false, + siblings: assoc?.siblings ?? [], + currentBundleId: assoc?.bundle_id ?? null, + currentAppName: assoc?.app_name ?? null, + query: "", + showAll: false, + }; + } else { + const scheme = state.snapshot?.schemes.find((s) => s.scheme === key); + state.sheet = { + kind, + key, + conflict: false, + siblings: [], + currentBundleId: scheme?.bundle_id ?? null, + currentAppName: scheme?.app_name ?? null, + query: "", + showAll: false, + }; + } +} + +function lookupDroppedFile(path: string) { + const filename = path.split("/").pop() ?? path; + const dot = filename.lastIndexOf("."); + if (dot <= 0) { + state.toast = { text: `${filename} has no file extension` }; + render(); + return; + } + const ext = filename.slice(dot + 1).toLowerCase(); + state.settingsOpen = false; + state.tab = "extensions"; + openSheet("ext", ext); + render(); +} + +// ---------- update check ---------- + +interface GithubRelease { + tag_name: string; + prerelease: boolean; + draft: boolean; +} + +async function checkForUpdates() { + if (state.updateStatus.checking) return; + state.updateStatus.checking = true; + state.updateStatus.error = null; + render(); + try { + const resp = await fetch( + "https://api.github.com/repos/ColeMei/openwith/releases?per_page=15", + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!resp.ok) throw new Error(`GitHub returned ${resp.status}`); + const releases = (await resp.json()) as GithubRelease[]; + const beta = state.settings.updateChannel === "beta"; + const candidate = releases.find( + (r) => !r.draft && (beta || !r.prerelease), + ); + if (!candidate) throw new Error("no releases found"); + state.updateStatus.latest = candidate.tag_name.replace(/^v/, ""); + state.updateStatus.checkedAt = new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + } catch (e) { + state.updateStatus.error = e instanceof Error ? e.message : String(e); + } finally { + state.updateStatus.checking = false; + render(); + } +} + +// ---------- event delegation ---------- + +root.addEventListener("click", (e) => { + const target = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null; + if (!target) return; + const action = target.dataset.action; + + switch (action) { + case "tab": + state.settingsOpen = false; + state.tab = target.dataset.tab as Tab; + render(); + break; + case "settings-toggle": + state.settingsOpen = !state.settingsOpen; + render(); + break; + case "open-ext-sheet": + openSheet("ext", target.dataset.ext!); + render(); + break; + case "open-scheme-sheet": + openSheet("scheme", target.dataset.scheme!); + render(); + break; + case "close-sheet": + state.sheet = null; + render(); + break; + case "swallow": + break; + case "choose-app": + void chooseApp(target.dataset.bundleId!); + break; + case "select-app": + state.selectedBundleId = target.dataset.bundleId!; + render(); + break; + case "claim-ext": + void claimExt(target.dataset.ext!); + break; + case "claim-all": + void claimAll(); + break; + case "export": + void handleExport(); + break; + case "import-choose": + void handleImportChoose(); + break; + case "import-apply": + void applyImport(); + break; + case "import-cancel": + state.importPending = null; + render(); + break; + case "undo": + if (state.toast?.undo) state.toast.undo(); + state.toast = null; + render(); + break; + case "sheet-scope": + if (state.sheet) { + state.sheet.showAll = target.dataset.scope === "all"; + render(); + } + break; + case "check-updates": + void checkForUpdates(); + break; + case "toggle": { + const key = target.dataset.toggle as + | "launchAtLogin" + | "showMenuBar" + | "confirmBeforeApplying" + | "warnUtiConflicts" + | "showBundleIds" + | "relaunchFinder" + | "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; + } + case "set-open-tab": + state.settings.openOnTab = target.dataset.tab as Tab; + saveSettings(); + render(); + break; + case "set-channel": + state.settings.updateChannel = target.dataset.channel as "stable" | "beta"; + saveSettings(); + render(); + break; + } +}); + +root.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + if (target.dataset.action === "ext-query") { + state.extQuery = target.value; + render(); + } else if (target.dataset.action === "apps-query") { + state.appsQuery = target.value; + render(); + } else if (target.dataset.action === "sheet-query") { + if (state.sheet) { + state.sheet.query = target.value; + render(); + } + } +}); + +document.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { + e.preventDefault(); + let id: string; + if (state.sheet) { + id = "sheet-search-input"; + } else { + state.settingsOpen = false; + if (state.tab !== "apps") state.tab = "extensions"; + render(); + id = state.tab === "apps" ? "apps-search-input" : "ext-search-input"; + } + document.getElementById(id)?.focus(); + } else if (e.key === "Escape" && state.sheet) { + state.sheet = null; + render(); + } +}); + +// ---------- drag & drop ---------- + +getCurrentWebview().onDragDropEvent((event) => { + if (event.payload.type === "over") { + state.windowDragOver = true; + render(); + } else if (event.payload.type === "drop") { + state.windowDragOver = false; + const path = event.payload.paths[0]; + if (!path) { + render(); + return; + } + if (path.toLowerCase().endsWith(".toml")) { + state.tab = "profiles"; + state.settingsOpen = false; + void startImportPreview(path); + } else { + lookupDroppedFile(path); + } + } else { + state.windowDragOver = false; + render(); + } +}); + +// ---------- 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(); + else render(); + }); + api.detectCli().then((v) => { + state.cliVersion = v; + render(); + }); + refreshHistory(); + + try { + state.snapshot = await api.getSnapshot(); + } catch (e) { + state.error = String(e); + } finally { + state.loading = false; + render(); + } +} + +void bootstrap(); diff --git a/crates/openwith-gui/src/main.ts b/crates/openwith-gui/src/main.ts index ccc61e7..37a28df 100644 --- a/crates/openwith-gui/src/main.ts +++ b/crates/openwith-gui/src/main.ts @@ -1,1035 +1,9 @@ -import { getVersion } from "@tauri-apps/api/app"; -import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { - ask, - open as openDialog, - save as saveDialog, -} from "@tauri-apps/plugin-dialog"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { api, type AppDto, type SetResultDto } from "./api"; -import { avatarColor, initials } from "./colors"; -import { - appStats, - escapeHtml, - filteredApps, - filteredAssociations, - findApp, - saveSettings, - schemeRole, - sheetApps, - state, - type Tab, -} from "./state"; - -const root = document.getElementById("app")!; - -function avatar(name: string, extraClass = ""): string { - return `${escapeHtml(initials(name))}`; -} - -// ---------- shell ---------- - -// Glyphs from the design prototype — monochrome text, not emoji. -const TABS: { id: Tab; icon: string; label: string }[] = [ - { id: "extensions", icon: "⌸", label: "Extensions" }, - { id: "apps", icon: "⊞", label: "Apps" }, - { id: "schemes", icon: "⤴", label: "Schemes" }, - { id: "profiles", icon: "⇅", label: "Profiles" }, -]; - -function renderHeader(): string { - const tabs = TABS.map( - (t) => ` - `, - ).join(""); - - return ` -
- OpenWith -
- ${tabs} - -
-
`; -} - -// ---------- extensions ---------- - -function renderExtensions(): string { - const rows = filteredAssociations(); - const showBids = state.settings.showBundleIds; - const gridClass = showBids ? "" : "no-bids"; - const rowsHtml = rows - .map((r) => { - const appName = r.app_name ?? "(none)"; - const bid = r.bundle_id ?? ""; - const badge = r.conflict - ? `UTI ⚠` - : ""; - return ` -
- .${escapeHtml(r.ext)} - - ${avatar(appName)} - ${escapeHtml(appName)} - - ${showBids ? `${escapeHtml(bid)}` : ""} - ${badge} -
`; - }) - .join(""); - - return ` -
-
- -
- EXTDEFAULT APP${showBids ? "BUNDLE ID" : ""} -
-
${rowsHtml || ``}
- -
-
`; -} - -// ---------- apps ---------- - -function renderApps(): string { - const apps = filteredApps(); - if (!state.selectedBundleId && apps.length > 0) { - state.selectedBundleId = apps[0].bundle_id; - } - const selected = findApp(state.selectedBundleId) ?? apps[0] ?? null; - - const rowsHtml = apps - .map((a) => { - const stats = appStats(a); - const selectedClass = - selected && a.bundle_id === selected.bundle_id ? "selected" : ""; - return ` -
- ${avatar(a.name)} - ${escapeHtml(a.name)} - ${stats.defCount} -
`; - }) - .join(""); - - const detail = selected ? renderAppDetail(selected) : `
`; - - return ` -
-
-
-
- - -
-
-
${rowsHtml}
-
- ${detail} -
`; -} - -function renderAppDetail(app: AppDto): string { - const stats = appStats(app); - const defaultsHtml = - stats.defaults.length > 0 - ? stats.defaults - .map((e) => `.${escapeHtml(e)}`) - .join("") - : `not the default for anything yet`; - - const claimsHtml = - stats.claimable.length > 0 - ? stats.claimable - .map( - (c) => - `.${escapeHtml(c.ext)} +`, - ) - .join("") - : `already the default for everything it supports`; - - return ` -
-
- ${avatar(app.name, "avatar-lg")} -
-
${escapeHtml(app.name)}
-
${escapeHtml(app.bundle_id)}
-
- -
-
-
${stats.defCount}
default for
-
${stats.supCount}
supported
-
- -
${defaultsHtml}
- -
${claimsHtml}
-
`; -} - -// ---------- schemes ---------- - -function renderSchemes(): string { - const schemes = state.snapshot?.schemes ?? []; - const rows = schemes - .map((s) => { - const appName = s.app_name ?? "(none)"; - return ` -
- - ${escapeHtml(s.scheme)}:// - ${escapeHtml(schemeRole(s))} - - - ${avatar(appName, "avatar-sm")} - ${escapeHtml(appName)} - - Change -
`; - }) - .join(""); - - return `
${rows}
`; -} - -// ---------- profiles (export/import) ---------- - -function historyDate(timestamp: number): string { - if (!timestamp) return ""; - const d = new Date(timestamp * 1000); - const today = new Date(); - if (d.toDateString() === today.toDateString()) { - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } - return d.toLocaleDateString([], { month: "short", day: "numeric" }); -} - -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.
`; - - return ` -
-
HISTORY
-
${rows}
-
`; -} - -function renderProfiles(): string { - const totalExt = state.snapshot?.associations.length ?? 0; - const totalSchemes = state.snapshot?.schemes.length ?? 0; - - return ` -
-
-
-
Export
-
Save all associations to a portable TOML file — like a dotfile.
-
${totalExt} extensions · ${totalSchemes} schemes
- -
-
-
Import
-
Idempotent — correct entries skipped, missing apps ignored.
-
- Drop a .toml here, or choose file… -
-
-
- ${state.importPending ? renderImportPreview() : ""} - ${renderHistory()} -
`; -} - -function renderImportPreview(): string { - const pending = state.importPending!; - const preview = pending.preview; - const lines: string[] = []; - for (const a of preview.applied) { - const was = a.previous_app_name - ? ` (was ${escapeHtml(a.previous_app_name)})` - : ""; - lines.push( - `
✓ set ${escapeHtml(a.key)} → ${escapeHtml(a.app_name)}${was}
`, - ); - } - if (preview.unchanged > 0) { - lines.push(`
− ${preview.unchanged} already set correctly
`); - } - for (const s of preview.skipped) { - lines.push( - `
! skip ${escapeHtml(s.key)} → ${escapeHtml(s.app_name)}: ${escapeHtml(s.reason)}
`, - ); - } - - return ` -
-
- DRY-RUN PREVIEW — ${escapeHtml(pending.fileName)} - - -
-
${lines.join("") || "No changes."}
-
`; -} - -// ---------- settings ---------- - -function toggleRow( - id: string, - on: boolean, - label: string, - desc: string, - disabled = false, -): string { - return ` -
- - ${escapeHtml(label)} - ${escapeHtml(desc)} - - -
`; -} - -function segmented( - action: string, - dataKey: string, - options: { key: string; label: string }[], - current: string, -): string { - const buttons = options - .map( - (o) => - ``, - ) - .join(""); - return `${buttons}`; -} - -function updateStatusLine(): string { - const u = state.updateStatus; - const v = state.appVersion; - if (u.error) return `Check failed — ${escapeHtml(u.error)}`; - if (u.latest && v && u.latest !== v) - return `Update ${escapeHtml(u.latest)} available — brew upgrade --cask openwith`; - if (u.latest && u.checkedAt) - return `✓ Up to date · last checked ${escapeHtml(u.checkedAt)}`; - return `Updates ship via Homebrew`; -} - -function renderSettings(): string { - const s = state.settings; - - const cliStatus = state.cliVersion - ? `✓ Installed ${escapeHtml(state.cliVersion)} — GUI and CLI share the same engine` - : `Not found — install with brew install openwith`; - const cliPill = state.cliVersion ? "brew upgrade openwith" : "brew install openwith"; - - return ` -
-
-
-
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)} -
- Open on tabWhich view the main window starts on - ${segmented("set-open-tab", "tab", [{ key: "extensions", label: "Extensions" }, { key: "apps", label: "Apps" }], s.openOnTab)} -
-
-
-
BEHAVIOR
- ${toggleRow("confirmBeforeApplying", s.confirmBeforeApplying, "Confirm before applying", "Ask before changing a default")} - ${toggleRow("warnUtiConflicts", s.warnUtiConflicts, "Warn on UTI conflicts", "Flag changes that affect sibling extensions like .env / .txt")} - ${toggleRow("showBundleIds", s.showBundleIds, "Show bundle IDs", "Display raw bundle identifiers in lists")} - ${toggleRow("relaunchFinder", s.relaunchFinder, "Relaunch Finder after changes", "Clears stale icon caches — closes Finder windows")} -
-
-
UPDATES
-
- - OpenWith ${escapeHtml(state.appVersion ?? "…")} - ${updateStatusLine()} - - -
- ${toggleRow("autoUpdateCheck", s.autoUpdateCheck, "Check automatically", "Once per launch, in the background")} -
- ChannelBeta gets new features earlier - ${segmented("set-channel", "channel", [{ key: "stable", label: "Stable" }, { key: "beta", label: "Beta" }], s.updateChannel)} -
-
-
-
COMMAND LINE
-
- openwith${cliStatus} - ${cliPill} -
-
-
-
`; -} - -// ---------- sheet + toast ---------- - -function renderSheet(): string { - if (!state.sheet) return ""; - const sheet = state.sheet; - const apps = sheetApps(sheet); - const declaredCount = (state.snapshot?.apps ?? []).filter((a) => - sheet.kind === "ext" - ? a.extensions.includes(sheet.key) - : a.url_schemes.includes(sheet.key), - ).length; - - const target = sheet.kind === "ext" ? `.${sheet.key}` : `${sheet.key}://`; - - const warning = - sheet.kind === "ext" && sheet.conflict && state.settings.warnUtiConflicts - ? `
⚠ Shares a file type — also affects ${sheet.siblings - .slice(0, 6) - .map((s) => `.${escapeHtml(s)}`) - .join(", ")}${sheet.siblings.length > 6 ? ` +${sheet.siblings.length - 6}` : ""}
` - : ""; - - const appsHtml = apps - .map((a) => { - const current = - sheet.currentBundleId !== null && - a.bundle_id.toLowerCase() === sheet.currentBundleId.toLowerCase(); - return ` -
- ${avatar(a.name, "avatar-sm")} - ${escapeHtml(a.name)} -
`; - }) - .join(""); - - const showAllToggle = - declaredCount > 0 - ? segmented("sheet-scope", "scope", [ - { key: "supporting", label: `Supporting (${declaredCount})` }, - { key: "all", label: "All apps" }, - ], sheet.showAll ? "all" : "supporting") - : `no app declares support — showing all`; - - return ` -
-
-
-
Open ${target} with…
- ${warning} -
-
- - -
- ${showAllToggle} -
-
${appsHtml || `No apps match.`}
-
-
`; -} - -function renderToast(): string { - if (!state.toast) return ""; - const undoBtn = state.toast.undo - ? `` - : ""; - return ` -
- ${escapeHtml(state.toast.text)} - ${undoBtn} -
`; -} - -function renderWindowDropOverlay(): string { - if (!state.windowDragOver) return ""; - return `
Drop to look up or import
`; +// 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"); } - -// ---------- root ---------- - -function renderLoading(): string { - return ` -
-
-
Scanning applications…
-
`; -} - -function renderError(): string { - return `
Failed to load: ${escapeHtml(state.error ?? "unknown error")}
`; -} - -function renderMain(): string { - let body: string; - if (state.settingsOpen) { - body = renderSettings(); - } else { - switch (state.tab) { - case "extensions": - body = renderExtensions(); - break; - case "apps": - body = renderApps(); - break; - case "schemes": - body = renderSchemes(); - break; - case "profiles": - body = renderProfiles(); - break; - } - } - return ` - ${renderHeader()} - ${body} - ${renderSheet()} - ${renderToast()} - ${renderWindowDropOverlay()}`; -} - -function render() { - const active = document.activeElement as HTMLInputElement | null; - const focusId = active?.id || null; - const selStart = active?.selectionStart ?? null; - const selEnd = active?.selectionEnd ?? null; - - root.innerHTML = `
${ - state.loading ? renderLoading() : state.error ? renderError() : renderMain() - }
`; - - if (focusId) { - const el = document.getElementById(focusId) as HTMLInputElement | null; - if (el) { - el.focus(); - if (selStart !== null && selEnd !== null && "setSelectionRange" in el) { - el.setSelectionRange(selStart, selEnd); - } - } - } -} - -// ---------- mutation helpers ---------- - -function refreshHistory() { - api - .getHistory(50) - .then((events) => { - state.history = events; - render(); - }) - .catch(() => { - // history is display-only; a read failure just leaves the panel stale - }); -} - -function afterApply() { - refreshHistory(); - if (state.settings.relaunchFinder) { - api.relaunchFinder().catch(() => { - // Finder relaunch is best-effort; the association change already applied - }); - } -} - -function applySetResult(result: SetResultDto, announce = true) { - if (!state.snapshot) return; - const isScheme = result.key.endsWith("://"); - if (isScheme) { - const bare = result.key.slice(0, -3); - const s = state.snapshot.schemes.find((s) => s.scheme === bare); - if (s) { - s.bundle_id = result.bundle_id; - s.app_name = result.app_name; - } - } else { - const bare = result.key.slice(1); - const patchOne = (ext: string) => { - const a = state.snapshot!.associations.find((a) => a.ext === ext); - if (a) { - a.bundle_id = result.bundle_id; - a.app_name = result.app_name; - } - }; - patchOne(bare); - result.siblings.forEach(patchOne); - } - if (announce) { - state.toast = buildToast(result); - } -} - -function buildToast(result: SetResultDto) { - if (result.unchanged) { - return { text: `${result.key} is already ${result.app_name}` }; - } - const was = result.previous_app_name ? ` (was ${result.previous_app_name})` : ""; - const extra = - result.siblings.length > 0 && state.settings.warnUtiConflicts - ? ` · also affects ${result.siblings - .slice(0, 3) - .map((s) => `.${s}`) - .join(", ")}${result.siblings.length > 3 ? ` +${result.siblings.length - 3}` : ""}` - : ""; - return { - text: `Set ${result.key} → ${result.app_name}${was}${extra}`, - undo: result.previous_app_name - ? () => undoSet(result.key, result.previous_app_name!) - : undefined, - }; -} - -async function undoSet(key: string, previousAppName: string) { - try { - const isScheme = key.endsWith("://"); - const result = isScheme - ? await api.setSchemeDefault(key.slice(0, -3), previousAppName) - : await api.setDefault(key.slice(1), previousAppName); - applySetResult(result, false); - state.toast = { text: `Reverted ${result.key} → ${result.app_name}` }; - afterApply(); - } catch (e) { - state.toast = { text: `Undo failed: ${e}` }; - } - render(); -} - -async function confirmApply(target: string, appName: string): Promise { - if (!state.settings.confirmBeforeApplying) return true; - return ask(`Set ${target} to open with ${appName}?`, { - title: "OpenWith", - kind: "info", - }); -} - -async function chooseApp(bundleId: string) { - const sheet = state.sheet; - if (!sheet) return; - const target = sheet.kind === "ext" ? `.${sheet.key}` : `${sheet.key}://`; - const appName = findApp(bundleId)?.name ?? bundleId; - if (!(await confirmApply(target, appName))) return; - state.sheet = null; - try { - const result = - sheet.kind === "ext" - ? await api.setDefault(sheet.key, bundleId) - : await api.setSchemeDefault(sheet.key, bundleId); - applySetResult(result); - if (!result.unchanged) afterApply(); - } catch (e) { - state.toast = { text: `Failed: ${e}` }; - } - render(); -} - -async function claimExt(ext: string) { - const app = findApp(state.selectedBundleId); - if (!app) return; - if (!(await confirmApply(`.${ext}`, app.name))) return; - try { - const result = await api.setDefault(ext, app.bundle_id); - applySetResult(result); - if (!result.unchanged) afterApply(); - } catch (e) { - state.toast = { text: `Failed: ${e}` }; - } - render(); -} - -async function claimAll() { - const app = findApp(state.selectedBundleId); - if (!app) return; - const stats = appStats(app); - if (!(await confirmApply(`${stats.claimable.length} extensions`, app.name))) return; - let count = 0; - for (const c of stats.claimable) { - try { - const result = await api.setDefault(c.ext, app.bundle_id); - applySetResult(result, false); - count++; - } catch { - // skip failures, continue claiming the rest - } - } - state.toast = { text: `Claimed ${count} extension${count === 1 ? "" : "s"} for ${app.name}` }; - if (count > 0) afterApply(); - render(); -} - -async function handleExport() { - let path: string | null; - try { - path = await saveDialog({ - defaultPath: "openwith.toml", - filters: [{ name: "TOML", extensions: ["toml"] }], - }); - } catch (e) { - state.toast = { text: `Export failed: ${e}` }; - render(); - return; - } - if (!path) return; - try { - const result = await api.exportToml(path); - state.toast = { - text: `Exported ${result.association_count} associations and ${result.scheme_count} schemes`, - }; - refreshHistory(); - } catch (e) { - state.toast = { text: `Export failed: ${e}` }; - } - render(); -} - -async function startImportPreview(path: string) { - try { - const preview = await api.importToml(path, true); - state.importPending = { - path, - fileName: path.split("/").pop() ?? path, - preview, - }; - } catch (e) { - state.toast = { text: `Import failed: ${e}` }; - } - render(); -} - -async function handleImportChoose() { - let selection: string | string[] | null; - try { - selection = await openDialog({ - multiple: false, - filters: [{ name: "TOML", extensions: ["toml"] }], - }); - } catch (e) { - state.toast = { text: `Import failed: ${e}` }; - render(); - return; - } - const path = Array.isArray(selection) ? selection[0] : selection; - if (!path) return; - state.tab = "profiles"; - await startImportPreview(path); -} - -async function applyImport() { - if (!state.importPending) return; - const pending = state.importPending; - if (!(await confirmApply(`${pending.preview.applied.length} entries from ${pending.fileName}`, "their listed apps"))) return; - state.loading = true; - render(); - try { - const result = await api.importToml(pending.path, false); - state.importPending = null; - state.snapshot = await api.getSnapshot(); - state.toast = { - text: `Applied ${result.applied.length}, unchanged ${result.unchanged}, skipped ${result.skipped.length}`, - }; - if (result.applied.length > 0) afterApply(); - else refreshHistory(); - } catch (e) { - state.toast = { text: `Import failed: ${e}` }; - } finally { - state.loading = false; - render(); - } -} - -function openSheet(kind: "ext" | "scheme", key: string) { - if (kind === "ext") { - const assoc = state.snapshot?.associations.find((a) => a.ext === key); - state.sheet = { - kind, - key, - conflict: assoc?.conflict ?? false, - siblings: assoc?.siblings ?? [], - currentBundleId: assoc?.bundle_id ?? null, - currentAppName: assoc?.app_name ?? null, - query: "", - showAll: false, - }; - } else { - const scheme = state.snapshot?.schemes.find((s) => s.scheme === key); - state.sheet = { - kind, - key, - conflict: false, - siblings: [], - currentBundleId: scheme?.bundle_id ?? null, - currentAppName: scheme?.app_name ?? null, - query: "", - showAll: false, - }; - } -} - -function lookupDroppedFile(path: string) { - const filename = path.split("/").pop() ?? path; - const dot = filename.lastIndexOf("."); - if (dot <= 0) { - state.toast = { text: `${filename} has no file extension` }; - render(); - return; - } - const ext = filename.slice(dot + 1).toLowerCase(); - state.settingsOpen = false; - state.tab = "extensions"; - openSheet("ext", ext); - render(); -} - -// ---------- update check ---------- - -interface GithubRelease { - tag_name: string; - prerelease: boolean; - draft: boolean; -} - -async function checkForUpdates() { - if (state.updateStatus.checking) return; - state.updateStatus.checking = true; - state.updateStatus.error = null; - render(); - try { - const resp = await fetch( - "https://api.github.com/repos/ColeMei/openwith/releases?per_page=15", - { headers: { Accept: "application/vnd.github+json" } }, - ); - if (!resp.ok) throw new Error(`GitHub returned ${resp.status}`); - const releases = (await resp.json()) as GithubRelease[]; - const beta = state.settings.updateChannel === "beta"; - const candidate = releases.find( - (r) => !r.draft && (beta || !r.prerelease), - ); - if (!candidate) throw new Error("no releases found"); - state.updateStatus.latest = candidate.tag_name.replace(/^v/, ""); - state.updateStatus.checkedAt = new Date().toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - } catch (e) { - state.updateStatus.error = e instanceof Error ? e.message : String(e); - } finally { - state.updateStatus.checking = false; - render(); - } -} - -// ---------- event delegation ---------- - -root.addEventListener("click", (e) => { - const target = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null; - if (!target) return; - const action = target.dataset.action; - - switch (action) { - case "tab": - state.settingsOpen = false; - state.tab = target.dataset.tab as Tab; - render(); - break; - case "settings-toggle": - state.settingsOpen = !state.settingsOpen; - render(); - break; - case "open-ext-sheet": - openSheet("ext", target.dataset.ext!); - render(); - break; - case "open-scheme-sheet": - openSheet("scheme", target.dataset.scheme!); - render(); - break; - case "close-sheet": - state.sheet = null; - render(); - break; - case "swallow": - break; - case "choose-app": - void chooseApp(target.dataset.bundleId!); - break; - case "select-app": - state.selectedBundleId = target.dataset.bundleId!; - render(); - break; - case "claim-ext": - void claimExt(target.dataset.ext!); - break; - case "claim-all": - void claimAll(); - break; - case "export": - void handleExport(); - break; - case "import-choose": - void handleImportChoose(); - break; - case "import-apply": - void applyImport(); - break; - case "import-cancel": - state.importPending = null; - render(); - break; - case "undo": - if (state.toast?.undo) state.toast.undo(); - state.toast = null; - render(); - break; - case "sheet-scope": - if (state.sheet) { - state.sheet.showAll = target.dataset.scope === "all"; - render(); - } - break; - case "check-updates": - void checkForUpdates(); - break; - case "toggle": { - const key = target.dataset.toggle as - | "confirmBeforeApplying" - | "warnUtiConflicts" - | "showBundleIds" - | "relaunchFinder" - | "autoUpdateCheck"; - state.settings[key] = !state.settings[key]; - saveSettings(); - render(); - break; - } - case "set-open-tab": - state.settings.openOnTab = target.dataset.tab as Tab; - saveSettings(); - render(); - break; - case "set-channel": - state.settings.updateChannel = target.dataset.channel as "stable" | "beta"; - saveSettings(); - render(); - break; - } -}); - -root.addEventListener("input", (e) => { - const target = e.target as HTMLInputElement; - if (target.dataset.action === "ext-query") { - state.extQuery = target.value; - render(); - } else if (target.dataset.action === "apps-query") { - state.appsQuery = target.value; - render(); - } else if (target.dataset.action === "sheet-query") { - if (state.sheet) { - state.sheet.query = target.value; - render(); - } - } -}); - -document.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { - e.preventDefault(); - let id: string; - if (state.sheet) { - id = "sheet-search-input"; - } else { - state.settingsOpen = false; - if (state.tab !== "apps") state.tab = "extensions"; - render(); - id = state.tab === "apps" ? "apps-search-input" : "ext-search-input"; - } - document.getElementById(id)?.focus(); - } else if (e.key === "Escape" && state.sheet) { - state.sheet = null; - render(); - } -}); - -// ---------- drag & drop ---------- - -getCurrentWebview().onDragDropEvent((event) => { - if (event.payload.type === "over") { - state.windowDragOver = true; - render(); - } else if (event.payload.type === "drop") { - state.windowDragOver = false; - const path = event.payload.paths[0]; - if (!path) { - render(); - return; - } - if (path.toLowerCase().endsWith(".toml")) { - state.tab = "profiles"; - state.settingsOpen = false; - void startImportPreview(path); - } else { - lookupDroppedFile(path); - } - } else { - state.windowDragOver = false; - render(); - } -}); - -// ---------- bootstrap ---------- - -async function bootstrap() { - render(); - - getVersion().then((v) => { - state.appVersion = v; - if (state.settings.autoUpdateCheck) void checkForUpdates(); - else render(); - }); - api.detectCli().then((v) => { - state.cliVersion = v; - render(); - }); - refreshHistory(); - - try { - state.snapshot = await api.getSnapshot(); - } catch (e) { - state.error = String(e); - } finally { - state.loading = false; - render(); - } -} - -void bootstrap(); diff --git a/crates/openwith-gui/src/menubar.ts b/crates/openwith-gui/src/menubar.ts new file mode 100644 index 0000000..ec1a9b0 --- /dev/null +++ b/crates/openwith-gui/src/menubar.ts @@ -0,0 +1,265 @@ +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 { + // 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 + } + 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..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; @@ -1158,6 +1163,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 6px 22px rgba(43, 41, 38, 0.18); + --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 6px 22px rgba(0, 0, 0, 0.4); + --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;