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 1539e005..079457c9 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). @@ -22,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"), @@ -113,30 +115,68 @@ 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(); - - 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); - if !stdout.trim().is_empty() { - available.push(OpenerApp { - id: id.to_string(), - name: prettify_app_name(id), - }); - } + // 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(); + + 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)); + } + } + + // 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 { + if let Ok(app) = handle.join() { + available.push(app); } } @@ -150,6 +190,77 @@ 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_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([ + "-s", + "format", + "png", + "-z", + "32", + "32", + &icns_path, + "--out", + &tmp_png_str, + ]) + .output() + .ok()?; + + if !sips.status.success() { + return None; + } + + // 4. Read and base64-encode the PNG + let png_bytes = std::fs::read(tmp_file.path()).ok()?; + + 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..7e296e64 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -516,22 +516,59 @@ }); } + 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, - onSelect: () => handleOpenInApp(app.id), - })); + const terminals: MenuItem[] = []; + const editors: MenuItem[] = []; + const fileBrowsers: MenuItem[] = []; - items.push( - { type: 'separator' }, - { + 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 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.sort(sortByLabel)); + if (editors.length > 0) { + if (items.length > 0) items.push({ type: 'separator' }); + items.push(...editors.sort(sortByLabel)); + } + if (fileBrowsers.length > 0) { + if (items.length > 0) items.push({ type: 'separator' }); + items.push(...fileBrowsers.sort(sortByLabel)); + } + + 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/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..70311dae 100644 --- a/apps/staged/src/lib/shared/menu/MenuSurface.svelte +++ b/apps/staged/src/lib/shared/menu/MenuSurface.svelte @@ -273,7 +273,16 @@ handleAction(item); }} > - {#if item.icon} + {#if item.iconSrc} + + {:else if item.icon} {/if} {item.label} @@ -353,7 +362,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); } @@ -403,6 +412,11 @@ flex-shrink: 0; } + .menu-item-icon { + flex-shrink: 0; + border-radius: 3px; + } + .menu-item.danger { color: var(--ui-danger); } @@ -425,7 +439,7 @@ .menu-separator { height: 1px; margin: 4px 0; - background: var(--border-subtle); + background: var(--border-muted); } .submenu-container { 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;