From 31acab3f5a935aaa58f227cf018288ea8e3a6042 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 15:54:14 +1000 Subject: [PATCH 1/5] feat: show app icons in the "Open In" menu Extract each app's icon on the backend using Info.plist and sips, converting to a 32x32 base64 PNG data URI. Icon extraction runs in parallel across threads for all detected apps. The frontend menu system gains an iconSrc field for image-based icons alongside existing Svelte component icons. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/util_commands.rs | 116 ++++++++++++++++-- .../branches/BranchCardActionsBar.svelte | 1 + .../src/lib/features/branches/branch.ts | 1 + .../src/lib/shared/menu/MenuSurface.svelte | 9 +- apps/staged/src/lib/shared/menu/types.ts | 1 + 5 files changed, 119 insertions(+), 9 deletions(-) diff --git a/apps/staged/src-tauri/src/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index 1539e005..4a3c9b6a 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -10,6 +10,7 @@ use std::path::Path; pub struct OpenerApp { id: String, name: String, + icon: Option, } /// Known applications with their bundle IDs (macOS). @@ -113,15 +114,18 @@ pub async fn check_blox_auth() -> Result<(), String> { /// Get available opener applications. /// -/// On macOS, uses mdfind to detect which apps are installed. -/// On other platforms, returns an empty list. +/// On macOS, uses mdfind to detect which apps are installed, then extracts +/// their icons in parallel using threads. On other platforms, returns an +/// empty list. #[tauri::command] pub async fn get_available_openers() -> Result, String> { #[cfg(target_os = "macos")] { use std::process::Command; + use std::thread; - let mut available = Vec::new(); + // First, find which apps are installed and get their .app paths. + let mut installed: Vec<(&str, String)> = Vec::new(); for (id, bundle_id) in KNOWN_OPENERS { let output = Command::new("mdfind") @@ -131,15 +135,37 @@ pub async fn get_available_openers() -> Result, String> { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.trim().is_empty() { - available.push(OpenerApp { - id: id.to_string(), - name: prettify_app_name(id), - }); + let first_line = stdout.trim().lines().next().unwrap_or("").to_string(); + if !first_line.is_empty() { + installed.push((id, first_line)); } } } + // Extract icons in parallel using threads. + let handles: Vec<_> = installed + .into_iter() + .map(|(id, app_path)| { + let id = id.to_string(); + thread::spawn(move || { + let icon = extract_app_icon(&app_path); + OpenerApp { + name: prettify_app_name(&id), + id, + icon, + } + }) + }) + .collect(); + + let mut available = Vec::with_capacity(handles.len()); + for handle in handles { + match handle.join() { + Ok(app) => available.push(app), + Err(_) => {} // Thread panicked — skip this app + } + } + Ok(available) } @@ -150,6 +176,80 @@ pub async fn get_available_openers() -> Result, String> { } } +/// Extract an app icon as a base64-encoded PNG data URI. +/// +/// Reads the icon filename from Info.plist, resolves the .icns file, +/// converts to a 32×32 PNG via `sips`, and base64-encodes the result. +/// Returns `None` if any step fails. +#[cfg(target_os = "macos")] +fn extract_app_icon(app_path: &str) -> Option { + use std::process::Command; + + // 1. Read CFBundleIconFile from Info.plist + let output = Command::new("defaults") + .arg("read") + .arg(format!("{app_path}/Contents/Info")) + .arg("CFBundleIconFile") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let mut icon_name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if icon_name.is_empty() { + return None; + } + + // 2. Append .icns if missing + if !icon_name.ends_with(".icns") { + icon_name.push_str(".icns"); + } + + let icns_path = format!("{app_path}/Contents/Resources/{icon_name}"); + if !Path::new(&icns_path).exists() { + return None; + } + + // 3. Convert to 32×32 PNG via sips into a temp file + let tmp_dir = std::env::temp_dir(); + let tmp_png = tmp_dir.join(format!( + "staged-icon-{}-{:?}.png", + std::process::id(), + std::thread::current().id() + )); + let tmp_png_str = tmp_png.to_string_lossy().to_string(); + + let sips = Command::new("sips") + .args([ + "-s", + "format", + "png", + "-z", + "32", + "32", + &icns_path, + "--out", + &tmp_png_str, + ]) + .output() + .ok()?; + + if !sips.status.success() { + let _ = std::fs::remove_file(&tmp_png); + return None; + } + + // 4. Read and base64-encode the PNG + let png_bytes = std::fs::read(&tmp_png).ok()?; + let _ = std::fs::remove_file(&tmp_png); + + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&png_bytes); + Some(format!("data:image/png;base64,{encoded}")) +} + /// Convert app ID to a human-readable name. #[cfg(target_os = "macos")] fn prettify_app_name(id: &str) -> String { diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index c60c1c1f..b105300f 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -520,6 +520,7 @@ const items: MenuItem[] = openerApps.map((app) => ({ type: 'action', label: app.name, + iconSrc: app.icon ?? undefined, onSelect: () => handleOpenInApp(app.id), })); diff --git a/apps/staged/src/lib/features/branches/branch.ts b/apps/staged/src/lib/features/branches/branch.ts index a82680ca..7f570ac8 100644 --- a/apps/staged/src/lib/features/branches/branch.ts +++ b/apps/staged/src/lib/features/branches/branch.ts @@ -5,6 +5,7 @@ import { writeText } from '@tauri-apps/plugin-clipboard-manager'; export interface OpenerApp { id: string; name: string; + icon: string | null; } // Cache for performance diff --git a/apps/staged/src/lib/shared/menu/MenuSurface.svelte b/apps/staged/src/lib/shared/menu/MenuSurface.svelte index 192be2fe..9c5f17f8 100644 --- a/apps/staged/src/lib/shared/menu/MenuSurface.svelte +++ b/apps/staged/src/lib/shared/menu/MenuSurface.svelte @@ -273,7 +273,9 @@ handleAction(item); }} > - {#if item.icon} + {#if item.iconSrc} + + {:else if item.icon} {/if} {item.label} @@ -403,6 +405,11 @@ flex-shrink: 0; } + .menu-item-icon { + flex-shrink: 0; + border-radius: 3px; + } + .menu-item.danger { color: var(--ui-danger); } diff --git a/apps/staged/src/lib/shared/menu/types.ts b/apps/staged/src/lib/shared/menu/types.ts index 2a572659..5e330fbb 100644 --- a/apps/staged/src/lib/shared/menu/types.ts +++ b/apps/staged/src/lib/shared/menu/types.ts @@ -4,6 +4,7 @@ export type MenuActionItem = { type: 'action'; label: string; icon?: MenuIconComponent; + iconSrc?: string; disabled?: boolean; danger?: boolean; closeOnSelect?: boolean; From e013fa73031c162cfa696d8689fffb5e8c0dd127 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 16:11:49 +1000 Subject: [PATCH 2/5] feat: add separators and improve styling in menus Group "Open In" submenu items by app type (terminals, editors/IDEs, file browsers, copy path) with separators between each group. Update the shared menu surface to use --bg-primary for better contrast in light themes and --border-muted for more visible separators. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- .../branches/BranchCardActionsBar.svelte | 61 ++++++++++++++----- .../src/lib/shared/menu/MenuSurface.svelte | 4 +- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index b105300f..675aa8cb 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -516,23 +516,56 @@ }); } + const terminalAppIds = new Set([ + 'terminal', + 'warp', + 'iterm', + 'hyper', + 'kitty', + 'alacritty', + 'ghostty', + ]); + const fileBrowserAppIds = new Set(['finder']); + function buildOpenInMenuItems(): MenuItem[] { - const items: MenuItem[] = openerApps.map((app) => ({ - type: 'action', - label: app.name, - iconSrc: app.icon ?? undefined, - onSelect: () => handleOpenInApp(app.id), - })); - - items.push( - { type: 'separator' }, - { + const terminals: MenuItem[] = []; + const editors: MenuItem[] = []; + const fileBrowsers: MenuItem[] = []; + + for (const app of openerApps) { + const item: MenuItem = { type: 'action', - label: 'Copy Path', - icon: Copy, - onSelect: handleCopyPath, + label: app.name, + iconSrc: app.icon ?? undefined, + onSelect: () => handleOpenInApp(app.id), + }; + if (terminalAppIds.has(app.id)) { + terminals.push(item); + } else if (fileBrowserAppIds.has(app.id)) { + fileBrowsers.push(item); + } else { + editors.push(item); } - ); + } + + const items: MenuItem[] = []; + if (terminals.length > 0) items.push(...terminals); + if (editors.length > 0) { + if (items.length > 0) items.push({ type: 'separator' }); + items.push(...editors); + } + if (fileBrowsers.length > 0) { + if (items.length > 0) items.push({ type: 'separator' }); + items.push(...fileBrowsers); + } + + if (items.length > 0) items.push({ type: 'separator' }); + items.push({ + type: 'action', + label: 'Copy Path', + icon: Copy, + onSelect: handleCopyPath, + }); return items; } diff --git a/apps/staged/src/lib/shared/menu/MenuSurface.svelte b/apps/staged/src/lib/shared/menu/MenuSurface.svelte index 9c5f17f8..98fd4687 100644 --- a/apps/staged/src/lib/shared/menu/MenuSurface.svelte +++ b/apps/staged/src/lib/shared/menu/MenuSurface.svelte @@ -355,7 +355,7 @@ padding: 4px; border: 1px solid var(--border-muted); border-radius: 8px; - background: var(--bg-elevated); + background: var(--bg-primary); box-shadow: var(--shadow-elevated); color: var(--text-primary); } @@ -432,7 +432,7 @@ .menu-separator { height: 1px; margin: 4px 0; - background: var(--border-subtle); + background: var(--border-muted); } .submenu-container { From f9c6c23d487ffdecc7b5655f76afb7af6450edfe Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 16:42:22 +1000 Subject: [PATCH 3/5] fix: resolve code review comments for open menu - Add ghostty (com.mitchellh.ghostty) to KNOWN_OPENERS so it can actually appear as an opener app, matching the frontend terminalAppIds - Parallelize mdfind calls across threads instead of running them sequentially, reducing latency when detecting installed apps - Replace manual temp file naming with the tempfile crate for robust unique file handling during icon extraction Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/Cargo.lock | 1 + apps/staged/src-tauri/Cargo.toml | 1 + apps/staged/src-tauri/src/util_commands.rs | 60 +++++++++++++--------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index c52c5da3..a01ac926 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -38,6 +38,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index db3d977d..7e0dfe22 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri-plugin-log = "2.8.0" thiserror = "2.0" anyhow = "1.0" base64 = "0.22" +tempfile = "3" sha2 = "0.11" # Review storage diff --git a/apps/staged/src-tauri/src/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index 4a3c9b6a..81172ad0 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -23,6 +23,7 @@ const KNOWN_OPENERS: &[(&str, &str)] = &[ ("hyper", "co.zeit.hyper"), ("kitty", "net.kovidgoyal.kitty"), ("alacritty", "org.alacritty"), + ("ghostty", "com.mitchellh.ghostty"), // Editors ("vscode", "com.microsoft.VSCode"), ("vscode-insiders", "com.microsoft.VSCodeInsiders"), @@ -124,21 +125,35 @@ pub async fn get_available_openers() -> Result, String> { use std::process::Command; use std::thread; - // First, find which apps are installed and get their .app paths. - let mut installed: Vec<(&str, String)> = Vec::new(); + // Find which apps are installed by running mdfind in parallel. + let mdfind_handles: Vec<_> = KNOWN_OPENERS + .iter() + .map(|(id, bundle_id)| { + let id = *id; + let bundle_id = *bundle_id; + thread::spawn(move || { + let output = Command::new("mdfind") + .arg(format!("kMDItemCFBundleIdentifier == '{bundle_id}'")) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let first_line = stdout.trim().lines().next().unwrap_or("").to_string(); + if first_line.is_empty() { + None + } else { + Some((id, first_line)) + } + }) + }) + .collect(); - for (id, bundle_id) in KNOWN_OPENERS { - let output = Command::new("mdfind") - .arg(format!("kMDItemCFBundleIdentifier == '{bundle_id}'")) - .output() - .map_err(|e| format!("Failed to run mdfind: {e}"))?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let first_line = stdout.trim().lines().next().unwrap_or("").to_string(); - if !first_line.is_empty() { - installed.push((id, first_line)); - } + let mut installed: Vec<(&str, String)> = Vec::new(); + for handle in mdfind_handles { + if let Ok(Some((id, path))) = handle.join() { + installed.push((id, path)); } } @@ -213,13 +228,12 @@ fn extract_app_icon(app_path: &str) -> Option { } // 3. Convert to 32×32 PNG via sips into a temp file - let tmp_dir = std::env::temp_dir(); - let tmp_png = tmp_dir.join(format!( - "staged-icon-{}-{:?}.png", - std::process::id(), - std::thread::current().id() - )); - let tmp_png_str = tmp_png.to_string_lossy().to_string(); + let tmp_file = tempfile::Builder::new() + .prefix("staged-icon-") + .suffix(".png") + .tempfile() + .ok()?; + let tmp_png_str = tmp_file.path().to_string_lossy().to_string(); let sips = Command::new("sips") .args([ @@ -237,13 +251,11 @@ fn extract_app_icon(app_path: &str) -> Option { .ok()?; if !sips.status.success() { - let _ = std::fs::remove_file(&tmp_png); return None; } // 4. Read and base64-encode the PNG - let png_bytes = std::fs::read(&tmp_png).ok()?; - let _ = std::fs::remove_file(&tmp_png); + let png_bytes = std::fs::read(tmp_file.path()).ok()?; use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&png_bytes); From 4efc65b2c28ac4958ebce2647f292b633a3f7402 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 16:49:50 +1000 Subject: [PATCH 4/5] fix: simplify match to if-let and prevent icon dragging - Replace match with empty Err arm with idiomatic if-let in thread join result handling - Add draggable="false" to menu icon images to prevent accidental drag-and-drop within the menu Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/util_commands.rs | 5 ++--- apps/staged/src/lib/shared/menu/MenuSurface.svelte | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/staged/src-tauri/src/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index 81172ad0..079457c9 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -175,9 +175,8 @@ pub async fn get_available_openers() -> Result, String> { let mut available = Vec::with_capacity(handles.len()); for handle in handles { - match handle.join() { - Ok(app) => available.push(app), - Err(_) => {} // Thread panicked — skip this app + if let Ok(app) = handle.join() { + available.push(app); } } diff --git a/apps/staged/src/lib/shared/menu/MenuSurface.svelte b/apps/staged/src/lib/shared/menu/MenuSurface.svelte index 98fd4687..70311dae 100644 --- a/apps/staged/src/lib/shared/menu/MenuSurface.svelte +++ b/apps/staged/src/lib/shared/menu/MenuSurface.svelte @@ -274,7 +274,14 @@ }} > {#if item.iconSrc} - + {:else if item.icon} {/if} From 375e6897e811ff345c64b0c4d715d86d5ea3857e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 11 May 2026 16:52:15 +1000 Subject: [PATCH 5/5] feat: sort Open In menu items alphabetically within each section Sort terminal, editor/IDE, and file browser apps by name within their respective sections in the Open In submenu. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCardActionsBar.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index 675aa8cb..7e296e64 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -548,15 +548,18 @@ } } + const sortByLabel = (a: MenuItem, b: MenuItem) => + (a.type === 'action' ? a.label : '').localeCompare(b.type === 'action' ? b.label : ''); + const items: MenuItem[] = []; - if (terminals.length > 0) items.push(...terminals); + if (terminals.length > 0) items.push(...terminals.sort(sortByLabel)); if (editors.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); - items.push(...editors); + items.push(...editors.sort(sortByLabel)); } if (fileBrowsers.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); - items.push(...fileBrowsers); + items.push(...fileBrowsers.sort(sortByLabel)); } if (items.length > 0) items.push({ type: 'separator' });