From 1175f8dfa05d5584f90c4a949c0488641c7792d4 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 20:27:50 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20GUI=20polish=20=E2=80=94=20configur?= =?UTF-8?q?able=20shortcut,=20popover=20pin=20+=20live=20refresh,=20settin?= =?UTF-8?q?gs=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Popover shortcut is user-configurable (Settings recorder row; default stays ⌥⌘O). set_toggle_shortcut re-registers at runtime, the saved accelerator is re-applied at bootstrap, labels follow everywhere. - Popover Pin: suspends hide-on-blur for one showing so a file can be dragged in from Finder (grabbing the file blurs the panel, which used to hide it before the drag could start). Reset on every toggle/Esc. - Popover refreshes on an explicit popover-shown event from the backend; focus events proved unreliable for the transparent panel, so changes made in the main window never showed in Recent Changes. - "Warn on UTI conflicts" now also gates the UTI ⚠ badges in the Extensions table; "Show bundle IDs" now also gates the Apps detail header and popover rows (popover reloads settings on storage events). - Toasts auto-dismiss (5s, 8s with an Undo button) instead of living forever. - Settings: CLI version re-probed when the pane opens (was launch-only); "last checked" shows seconds so repeat Check Now clicks visibly react; install hint is tap-qualified (brew install ColeMei/openwith/openwith). --- crates/openwith-gui/src-tauri/src/commands.rs | 24 +++ crates/openwith-gui/src-tauri/src/lib.rs | 28 +++- crates/openwith-gui/src-tauri/src/tray.rs | 13 +- crates/openwith-gui/src/api.ts | 4 + crates/openwith-gui/src/app.ts | 154 +++++++++++++++--- crates/openwith-gui/src/menubar.ts | 43 ++++- crates/openwith-gui/src/state.ts | 26 +++ crates/openwith-gui/src/styles.css | 24 ++- 8 files changed, 277 insertions(+), 39 deletions(-) diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index ac2445b..036cf0f 100644 --- a/crates/openwith-gui/src-tauri/src/commands.rs +++ b/crates/openwith-gui/src-tauri/src/commands.rs @@ -1,7 +1,9 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use serde::Serialize; use tauri::{AppHandle, Manager, State}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; use openwith_core::history::{self, HistoryEvent}; use openwith_core::types::AppInfo; @@ -19,6 +21,28 @@ fn record_history(event: HistoryEvent) { #[derive(Default)] pub struct AppsCache(Mutex>>>); +/// While pinned the popover survives losing focus, so a file can be dragged +/// in from Finder (grabbing the file blurs the popover, which normally hides +/// it). Reset every time the popover is toggled open. +#[derive(Default)] +pub struct PopoverPinned(pub AtomicBool); + +#[tauri::command] +pub fn set_popover_pinned(pinned: bool, state: State<'_, PopoverPinned>) { + state.0.store(pinned, Ordering::Relaxed); +} + +/// Swap the global shortcut that toggles the popover. Only one toggle +/// shortcut exists at a time, so replacing means unregistering everything. +#[tauri::command] +pub fn set_toggle_shortcut(app: AppHandle, accelerator: String) -> Result<(), String> { + let shortcut: Shortcut = accelerator.parse().map_err(|e| format!("{e}"))?; + let shortcuts = app.global_shortcut(); + shortcuts.unregister_all().map_err(|e| e.to_string())?; + shortcuts.register(shortcut).map_err(|e| e.to_string())?; + Ok(()) +} + 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() { diff --git a/crates/openwith-gui/src-tauri/src/lib.rs b/crates/openwith-gui/src-tauri/src/lib.rs index a7c550e..ad15c48 100644 --- a/crates/openwith-gui/src-tauri/src/lib.rs +++ b/crates/openwith-gui/src-tauri/src/lib.rs @@ -1,12 +1,18 @@ mod commands; mod tray; +use std::sync::atomic::Ordering; + +use tauri::Manager; use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let toggle_shortcut = Shortcut::new(Some(Modifiers::ALT | Modifiers::SUPER), Code::KeyO); + // Default toggle shortcut; a saved custom one replaces it at bootstrap + // via the set_toggle_shortcut command. Only one shortcut is ever + // registered, so the handler doesn't need to know which combo it is. + let default_shortcut = Shortcut::new(Some(Modifiers::ALT | Modifiers::SUPER), Code::KeyO); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -18,20 +24,28 @@ pub fn run() { )) .plugin( tauri_plugin_global_shortcut::Builder::new() - .with_shortcuts([toggle_shortcut]) + .with_shortcuts([default_shortcut]) .expect("valid shortcut") - .with_handler(move |app, shortcut, event| { - if shortcut == &toggle_shortcut && event.state == ShortcutState::Pressed { + .with_handler(move |app, _shortcut, event| { + if event.state == ShortcutState::Pressed { tray::toggle_popover(app); } }) .build(), ) .manage(commands::AppsCache::default()) + .manage(commands::PopoverPinned::default()) .on_window_event(|window, event| match (window.label(), event) { - // The popover behaves like a menu: clicking anywhere else closes it. + // The popover behaves like a menu: clicking anywhere else closes + // it — unless pinned, which keeps it up for drag-and-drop. ("menubar", tauri::WindowEvent::Focused(false)) => { - let _ = window.hide(); + let pinned = window + .state::() + .0 + .load(Ordering::Relaxed); + if !pinned { + let _ = window.hide(); + } } // Standard macOS behavior: the close button hides the window, the // app keeps running (⌘Q quits). Destroying it would make the app @@ -59,6 +73,8 @@ pub fn run() { commands::quit_app, commands::set_tray_enabled, commands::set_dock_visible, + commands::set_popover_pinned, + commands::set_toggle_shortcut, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/openwith-gui/src-tauri/src/tray.rs b/crates/openwith-gui/src-tauri/src/tray.rs index 13de7c6..21d596c 100644 --- a/crates/openwith-gui/src-tauri/src/tray.rs +++ b/crates/openwith-gui/src-tauri/src/tray.rs @@ -1,7 +1,11 @@ +use std::sync::atomic::Ordering; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_positioner::{Position, WindowExt}; +use crate::commands::PopoverPinned; + const TRAY_ID: &str = "openwith-tray"; /// Add or remove the menu-bar icon. The app's tray registry is the source of @@ -45,6 +49,10 @@ pub fn toggle_popover(app: &AppHandle) { let Some(window) = app.get_webview_window("menubar") else { return; }; + // Every toggle starts unpinned; pinning is a per-showing choice. + app.state::() + .0 + .store(false, Ordering::Relaxed); if window.is_visible().unwrap_or(false) { let _ = window.hide(); return; @@ -56,4 +64,7 @@ pub fn toggle_popover(app: &AppHandle) { } let _ = window.show(); let _ = window.set_focus(); + // Focus events are unreliable for the transparent popover panel, so tell + // the webview explicitly that it just opened (refresh + reset pin UI). + let _ = window.emit("popover-shown", ()); } diff --git a/crates/openwith-gui/src/api.ts b/crates/openwith-gui/src/api.ts index 16817ad..a3adde2 100644 --- a/crates/openwith-gui/src/api.ts +++ b/crates/openwith-gui/src/api.ts @@ -122,4 +122,8 @@ export const api = { invoke("set_tray_enabled", { enabled }), setDockVisible: (visible: boolean) => invoke("set_dock_visible", { visible }), + setPopoverPinned: (pinned: boolean) => + invoke("set_popover_pinned", { pinned }), + setToggleShortcut: (accelerator: string) => + invoke("set_toggle_shortcut", { accelerator }), }; diff --git a/crates/openwith-gui/src/app.ts b/crates/openwith-gui/src/app.ts index 8152a1c..a95e65c 100644 --- a/crates/openwith-gui/src/app.ts +++ b/crates/openwith-gui/src/app.ts @@ -22,8 +22,10 @@ import { saveSettings, schemeRole, sheetApps, + shortcutGlyphs, state, type Tab, + type ToastState, } from "./state"; import { applyTheme, type Appearance } from "./theme"; @@ -71,9 +73,10 @@ function renderExtensions(): string { .map((r) => { const appName = r.app_name ?? "(none)"; const bid = r.bundle_id ?? ""; - const badge = r.conflict - ? `UTI ⚠` - : ""; + const badge = + r.conflict && state.settings.warnUtiConflicts + ? `UTI ⚠` + : ""; return `
.${escapeHtml(r.ext)} @@ -171,7 +174,7 @@ function renderAppDetail(app: AppDto): string { ${avatar(app.name, "avatar-lg")}
${escapeHtml(app.name)}
-
${escapeHtml(app.bundle_id)}
+ ${state.settings.showBundleIds ? `
${escapeHtml(app.bundle_id)}
` : ""}
@@ -383,8 +386,10 @@ function renderSettings(): string { 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"; + : `Not found — install with brew install ColeMei/openwith/openwith`; + const cliPill = state.cliVersion + ? "brew upgrade openwith" + : "brew install ColeMei/openwith/openwith"; return `
@@ -392,7 +397,11 @@ function renderSettings(): string {
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")} + ${toggleRow("showMenuBar", s.showMenuBar, "Show in menu bar", `Quick-access panel with ${shortcutGlyphs(s.toggleShortcut)}`)} +
+ Popover shortcut${state.recordingShortcut ? "Press the new keys… (Esc cancels)" : "Global shortcut that toggles the quick panel"} + +
${toggleRow("hideDockIcon", s.hideDockIcon, "Hide Dock icon", "Menu-bar-only mode — needs the menu bar icon on", !s.showMenuBar)}
AppearanceSystem follows your macOS setting @@ -497,6 +506,22 @@ function renderSheet(): string {
`; } +/** Toasts dismiss themselves; ones carrying an Undo button linger longer. */ +let toastTimer: number | undefined; + +function showToast(toast: ToastState | null) { + window.clearTimeout(toastTimer); + state.toast = toast; + if (!toast) return; + toastTimer = window.setTimeout( + () => { + state.toast = null; + render(); + }, + toast.undo ? 8000 : 5000, + ); +} + function renderToast(): string { if (!state.toast) return ""; const undoBtn = state.toast.undo @@ -623,7 +648,7 @@ function applySetResult(result: SetResultDto, announce = true) { result.siblings.forEach(patchOne); } if (announce) { - state.toast = buildToast(result); + showToast(buildToast(result)); } } @@ -656,10 +681,10 @@ async function undoSet(setResult: SetResultDto) { setResult.timestamp, ); applySetResult(result, false); - state.toast = { text: `Reverted ${result.key} → ${result.app_name}` }; + showToast({ text: `Reverted ${result.key} → ${result.app_name}` }); afterApply(); } catch (e) { - state.toast = { text: `Undo failed: ${e}` }; + showToast({ text: `Undo failed: ${e}` }); } render(); } @@ -687,7 +712,7 @@ async function chooseApp(bundleId: string) { applySetResult(result); if (!result.unchanged) afterApply(); } catch (e) { - state.toast = { text: `Failed: ${e}` }; + showToast({ text: `Failed: ${e}` }); } render(); } @@ -701,7 +726,7 @@ async function claimExt(ext: string) { applySetResult(result); if (!result.unchanged) afterApply(); } catch (e) { - state.toast = { text: `Failed: ${e}` }; + showToast({ text: `Failed: ${e}` }); } render(); } @@ -721,7 +746,7 @@ async function claimAll() { // skip failures, continue claiming the rest } } - state.toast = { text: `Claimed ${count} extension${count === 1 ? "" : "s"} for ${app.name}` }; + showToast({ text: `Claimed ${count} extension${count === 1 ? "" : "s"} for ${app.name}` }); if (count > 0) afterApply(); render(); } @@ -734,19 +759,19 @@ async function handleExport() { filters: [{ name: "TOML", extensions: ["toml"] }], }); } catch (e) { - state.toast = { text: `Export failed: ${e}` }; + showToast({ text: `Export failed: ${e}` }); render(); return; } if (!path) return; try { const result = await api.exportToml(path); - state.toast = { + showToast({ text: `Exported ${result.association_count} associations and ${result.scheme_count} schemes`, - }; + }); refreshHistory(); } catch (e) { - state.toast = { text: `Export failed: ${e}` }; + showToast({ text: `Export failed: ${e}` }); } render(); } @@ -760,7 +785,7 @@ async function startImportPreview(path: string) { preview, }; } catch (e) { - state.toast = { text: `Import failed: ${e}` }; + showToast({ text: `Import failed: ${e}` }); } render(); } @@ -773,7 +798,7 @@ async function handleImportChoose() { filters: [{ name: "TOML", extensions: ["toml"] }], }); } catch (e) { - state.toast = { text: `Import failed: ${e}` }; + showToast({ text: `Import failed: ${e}` }); render(); return; } @@ -793,13 +818,13 @@ async function applyImport() { const result = await api.importToml(pending.path, false); state.importPending = null; state.snapshot = await api.getSnapshot(); - state.toast = { + showToast({ 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}` }; + showToast({ text: `Import failed: ${e}` }); } finally { state.loading = false; render(); @@ -838,7 +863,7 @@ 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` }; + showToast({ text: `${filename} has no file extension` }); render(); return; } @@ -875,9 +900,12 @@ async function checkForUpdates() { ); if (!candidate) throw new Error("no releases found"); state.updateStatus.latest = candidate.tag_name.replace(/^v/, ""); + // Seconds matter: repeat "Check Now" clicks within the same minute must + // still visibly change something. state.updateStatus.checkedAt = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", + second: "2-digit", }); } catch (e) { state.updateStatus.error = e instanceof Error ? e.message : String(e); @@ -902,6 +930,20 @@ root.addEventListener("click", (e) => { break; case "settings-toggle": state.settingsOpen = !state.settingsOpen; + state.recordingShortcut = false; + if (state.settingsOpen) { + // Re-probe so a CLI installed or upgraded since launch shows up. + api.detectCli().then((v) => { + if (v !== state.cliVersion) { + state.cliVersion = v; + render(); + } + }); + } + render(); + break; + case "record-shortcut": + state.recordingShortcut = !state.recordingShortcut; render(); break; case "open-ext-sheet": @@ -946,7 +988,7 @@ root.addEventListener("click", (e) => { break; case "undo": if (state.toast?.undo) state.toast.undo(); - state.toast = null; + showToast(null); render(); break; case "sheet-scope": @@ -979,12 +1021,12 @@ root.addEventListener("click", (e) => { 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" }; + showToast({ text: "Couldn't update the menu bar icon" }); render(); }); } else if (key === "hideDockIcon") { api.setDockVisible(!state.settings.hideDockIcon).catch(() => { - state.toast = { text: "Couldn't change the Dock icon" }; + showToast({ text: "Couldn't change the Dock icon" }); render(); }); } @@ -1029,8 +1071,64 @@ root.addEventListener("input", (e) => { } }); +// ---------- shortcut recorder ---------- + +/** Map a KeyboardEvent.code to a token the Rust-side accelerator parser + * accepts: letters, digits, and function keys. */ +function accelKeyFromCode(code: string): string | null { + if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase(); + if (/^Digit[0-9]$/.test(code)) return code.slice(5); + if (/^F([1-9]|1[0-2])$/.test(code)) return code.toLowerCase(); + return null; +} + +async function applyShortcut(accel: string) { + const previous = state.settings.toggleShortcut; + state.recordingShortcut = false; + if (accel === previous) { + render(); + return; + } + try { + await api.setToggleShortcut(accel); + state.settings.toggleShortcut = accel; + saveSettings(); + showToast({ text: `Popover shortcut is now ${shortcutGlyphs(accel)}` }); + } catch (e) { + showToast({ text: `Couldn't set shortcut: ${e}` }); + void api.setToggleShortcut(previous).catch(() => {}); + } + render(); +} + +function recordShortcutKey(e: KeyboardEvent): void { + e.preventDefault(); + e.stopPropagation(); + if (e.key === "Escape") { + state.recordingShortcut = false; + render(); + return; + } + const key = accelKeyFromCode(e.code); + // Keep listening through modifier-only or unsupported presses, and require + // a real modifier so a bare letter can't hijack global typing. + if (!key || (!e.metaKey && !e.ctrlKey && !e.altKey)) return; + const accel = [ + e.ctrlKey ? "ctrl" : "", + e.altKey ? "alt" : "", + e.shiftKey ? "shift" : "", + e.metaKey ? "cmd" : "", + key, + ] + .filter(Boolean) + .join("+"); + void applyShortcut(accel); +} + document.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { + if (state.recordingShortcut) { + recordShortcutKey(e); + } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { e.preventDefault(); let id: string; if (state.sheet) { @@ -1093,6 +1191,8 @@ async function bootstrap() { // Apply persisted preferences that live outside the webview. api.setTrayEnabled(state.settings.showMenuBar).catch(() => {}); + // Replace the launch-time default with the saved popover shortcut. + api.setToggleShortcut(state.settings.toggleShortcut).catch(() => {}); if (state.settings.hideDockIcon && state.settings.showMenuBar) { api.setDockVisible(false).catch(() => {}); } diff --git a/crates/openwith-gui/src/menubar.ts b/crates/openwith-gui/src/menubar.ts index ec1a9b0..7687c56 100644 --- a/crates/openwith-gui/src/menubar.ts +++ b/crates/openwith-gui/src/menubar.ts @@ -8,7 +8,12 @@ import { type RecentChangeDto, } from "./api"; import { avatarColor, initials } from "./colors"; -import { escapeHtml } from "./state"; +import { + escapeHtml, + reloadSettings, + shortcutGlyphs, + state as shared, +} from "./state"; const root = document.getElementById("app")!; const popoverWindow = getCurrentWebviewWindow(); @@ -18,6 +23,8 @@ interface PopoverState { matches: ExtMatchDto[]; recent: RecentChangeDto[]; picker: { ext: string; apps: PickerAppDto[] } | null; + /** Pinned popovers survive losing focus, so files can be dragged in. */ + pinned: boolean; } const state: PopoverState = { @@ -25,6 +32,7 @@ const state: PopoverState = { matches: [], recent: [], picker: null, + pinned: false, }; function chip(name: string): string { @@ -49,12 +57,17 @@ function renderMatches(): string { return state.matches .map((m, i) => { const app = m.app_name ?? "(none)"; + // "no default set" is state, not a bundle ID — show it regardless. + const bid = + shared.settings.showBundleIds || !m.bundle_id + ? `${escapeHtml(m.bundle_id ?? "no default set")}` + : ""; return `
${chip(app)} .${escapeHtml(m.ext)} → ${escapeHtml(app)} - ${escapeHtml(m.bundle_id ?? "no default set")} + ${bid}
`; @@ -112,11 +125,12 @@ function render() {
OpenWith - ⌥⌘O + + ${escapeHtml(shortcutGlyphs(shared.settings.toggleShortcut))}
Drop a file to look up its default
-
or type an extension below
+
${state.pinned ? "pinned — go grab a file, the panel will wait" : "type an extension below · Pin above to drag a file in"}