diff --git a/CLAUDE.md b/CLAUDE.md index a90293d..c5d08e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ crates/ src-tauri/src/ 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) + lib.rs -- tauri Builder, plugins (dialog, opener, positioner, autostart, global-shortcut — default ⌥⌘O, user-configurable) ``` ### Key patterns @@ -93,7 +93,7 @@ crates/ - 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). 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. +- 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 a configurable global shortcut (default ⌥⌘O; `set_toggle_shortcut` swaps the registration at runtime, the saved accelerator is re-applied at bootstrap). A **Pin** button suspends hide-on-blur for one showing (backend `PopoverPinned` AtomicBool, reset on every toggle) so a file can be dragged in from Finder — without it the click into Finder blurs and hides the panel. Focus events are unreliable for the transparent panel, so the backend emits `popover-shown` on every open and the popover refreshes from that, not just from focus. 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. @@ -163,7 +163,14 @@ Run against the built .app (not just `tauri dev`) before tagging any release wit - [ ] Appearance: flip System/Light/Dark with the popover open — both windows restyle - [ ] Every Settings toggle: launch at login, confirm before applying, warn on UTI conflicts, show bundle IDs, relaunch Finder, check automatically, channel, open-on-tab - [ ] Set a default from the Extensions sheet; verify with `openwith current `; Undo from the toast; verify again +- [ ] Toasts dismiss themselves (~5s, ~8s with an Undo button) without being replaced - [ ] Popover: extension lookup, change, Recent Changes + per-entry Undo +- [ ] Make a change in the main window, then open the popover — it appears under Recent Changes +- [ ] Popover Pin: pin, click into Finder (panel must stay up), drag a file onto it — extension lookup runs; unpinned panel still hides on blur; Esc and tray-toggle reset the pin +- [ ] Rebind the popover shortcut in Settings; old combo dead, new combo toggles; survives an app relaunch; ⌥⌘O labels in Settings + popover follow +- [ ] Toggle "Warn on UTI conflicts" off — UTI ⚠ badges disappear from the Extensions table; sheet + toast warnings stay off too +- [ ] Toggle "Show bundle IDs" off — bundle IDs vanish from Extensions table, Apps detail header, and popover rows +- [ ] With the CLI upgraded via brew while the app runs: close and reopen Settings — the Command Line panel shows the new version without an app relaunch - [ ] Profiles: export; import via choose AND drag-drop; dry-run preview; apply; dismiss - [ ] History panel scrolls at 50 entries and updates after changes - [ ] Check Now (updates) reports a sensible result on both channels diff --git a/Cargo.lock b/Cargo.lock index 3f269a1..92ba4b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "openwith-cli" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "clap", @@ -2640,7 +2640,7 @@ dependencies = [ [[package]] name = "openwith-core" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "core-foundation", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "openwith-gui" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "openwith-core", diff --git a/Cargo.toml b/Cargo.toml index 453c337..6275fb9 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.2" +version = "0.5.3" edition = "2024" license = "MIT" repository = "https://github.com/ColeMei/openwith" diff --git a/crates/openwith-gui/package.json b/crates/openwith-gui/package.json index c45036f..e0cb095 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.2", + "version": "0.5.3", "type": "module", "scripts": { "dev": "vite", 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"}