diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c588e9369..ef82fc57f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,7 @@ pub fn run() { workspaces::list_workspace_files, workspaces::read_workspace_file, workspaces::open_workspace_in, + workspaces::get_open_app_icon, git::list_git_branches, git::checkout_git_branch, git::create_git_branch, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index c222b0525..bcf292423 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -264,6 +264,19 @@ pub(crate) struct WorkspaceSettings { pub(crate) git_root: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct OpenAppTarget { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) kind: String, + #[serde(default, rename = "appName")] + pub(crate) app_name: Option, + #[serde(default)] + pub(crate) command: Option, + #[serde(default)] + pub(crate) args: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct AppSettings { #[serde(default, rename = "codexBin")] @@ -424,6 +437,10 @@ pub(crate) struct AppSettings { pub(crate) composer_code_block_copy_use_modifier: bool, #[serde(default = "default_workspace_groups", rename = "workspaceGroups")] pub(crate) workspace_groups: Vec, + #[serde(default = "default_open_app_targets", rename = "openAppTargets")] + pub(crate) open_app_targets: Vec, + #[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")] + pub(crate) selected_open_app_id: String, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -600,6 +617,63 @@ fn default_workspace_groups() -> Vec { Vec::new() } +fn default_open_app_targets() -> Vec { + vec![ + OpenAppTarget { + id: "vscode".to_string(), + label: "VS Code".to_string(), + kind: "app".to_string(), + app_name: Some("Visual Studio Code".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "cursor".to_string(), + label: "Cursor".to_string(), + kind: "app".to_string(), + app_name: Some("Cursor".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "zed".to_string(), + label: "Zed".to_string(), + kind: "app".to_string(), + app_name: Some("Zed".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "ghostty".to_string(), + label: "Ghostty".to_string(), + kind: "app".to_string(), + app_name: Some("Ghostty".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "antigravity".to_string(), + label: "Antigravity".to_string(), + kind: "app".to_string(), + app_name: Some("Antigravity".to_string()), + command: None, + args: Vec::new(), + }, + OpenAppTarget { + id: "finder".to_string(), + label: "Finder".to_string(), + kind: "finder".to_string(), + app_name: None, + command: None, + args: Vec::new(), + }, + ] +} + +fn default_selected_open_app_id() -> String { + "vscode".to_string() +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -649,6 +723,8 @@ impl Default for AppSettings { composer_list_continuation: default_composer_list_continuation(), composer_code_block_copy_use_modifier: default_composer_code_block_copy_use_modifier(), workspace_groups: default_workspace_groups(), + open_app_targets: default_open_app_targets(), + selected_open_app_id: default_selected_open_app_id(), } } } @@ -730,6 +806,9 @@ mod tests { assert!(!settings.composer_list_continuation); assert!(!settings.composer_code_block_copy_use_modifier); assert!(settings.workspace_groups.is_empty()); + assert_eq!(settings.selected_open_app_id, "vscode"); + assert_eq!(settings.open_app_targets.len(), 6); + assert_eq!(settings.open_app_targets[0].id, "vscode"); } #[test] diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 3c1faa2ae..898f082b1 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -4,6 +4,15 @@ use std::io::Read; use std::path::PathBuf; use std::process::Stdio; +#[cfg(target_os = "macos")] +use base64::Engine as _; +#[cfg(target_os = "macos")] +use std::fs; +#[cfg(target_os = "macos")] +use std::path::Path; +#[cfg(target_os = "macos")] +use std::time::{SystemTime, UNIX_EPOCH}; + use ignore::WalkBuilder; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -1506,14 +1515,26 @@ pub(crate) async fn list_workspace_files( #[tauri::command] pub(crate) async fn open_workspace_in( path: String, - app: String, + app: Option, + args: Vec, + command: Option, ) -> Result<(), String> { - let status = std::process::Command::new("open") - .arg("-a") - .arg(app) - .arg(path) - .status() - .map_err(|error| format!("Failed to open app: {error}"))?; + let status = if let Some(command) = command { + let mut cmd = std::process::Command::new(command); + cmd.args(args).arg(path); + cmd.status() + .map_err(|error| format!("Failed to open app: {error}"))? + } else if let Some(app) = app { + let mut cmd = std::process::Command::new("open"); + cmd.arg("-a").arg(app).arg(path); + if !args.is_empty() { + cmd.arg("--args").args(args); + } + cmd.status() + .map_err(|error| format!("Failed to open app: {error}"))? + } else { + return Err("Missing app or command".to_string()); + }; if status.success() { Ok(()) } else { @@ -1521,6 +1542,215 @@ pub(crate) async fn open_workspace_in( } } +#[cfg(target_os = "macos")] +fn app_search_roots() -> Vec { + let mut roots = vec![ + PathBuf::from("/Applications"), + PathBuf::from("/System/Applications"), + PathBuf::from("/Applications/Utilities"), + ]; + if let Ok(home) = std::env::var("HOME") { + roots.push(PathBuf::from(home).join("Applications")); + } + roots +} + +#[cfg(target_os = "macos")] +fn normalize_app_bundle_name(app_name: &str) -> String { + let trimmed = app_name.trim(); + if trimmed.to_ascii_lowercase().ends_with(".app") { + trimmed.to_string() + } else { + format!("{trimmed}.app") + } +} + +#[cfg(target_os = "macos")] +fn find_app_bundle(app_name: &str) -> Option { + let trimmed = app_name.trim(); + if trimmed.contains('/') { + let direct = PathBuf::from(trimmed); + if direct.exists() { + return Some(direct); + } + } + let normalized = normalize_app_bundle_name(app_name); + let normalized_lower = normalized.to_ascii_lowercase(); + for root in app_search_roots() { + if !root.exists() { + continue; + } + if let Ok(entries) = fs::read_dir(&root) { + for entry in entries.flatten() { + let path = entry.path(); + let file_name = match path.file_name() { + Some(name) => name.to_string_lossy().to_string(), + None => continue, + }; + if file_name.to_ascii_lowercase() == normalized_lower && path.is_dir() { + return Some(path); + } + } + } + } + None +} + +#[cfg(target_os = "macos")] +fn defaults_read(info_domain: &Path, key: &str) -> Option { + let output = std::process::Command::new("defaults") + .arg("read") + .arg(info_domain.as_os_str()) + .arg(key) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +#[cfg(target_os = "macos")] +fn resolve_icon_name(bundle_path: &Path) -> String { + let info_domain = bundle_path.join("Contents/Info"); + defaults_read(&info_domain, "CFBundleIconFile") + .or_else(|| defaults_read(&info_domain, "CFBundleIconName")) + .unwrap_or_else(|| { + bundle_path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| "AppIcon".to_string()) + }) +} + +#[cfg(target_os = "macos")] +fn resolve_icon_path(bundle_path: &Path, icon_name: &str) -> Option { + let resources_dir = bundle_path.join("Contents/Resources"); + if !resources_dir.exists() { + return None; + } + + let icon_path = PathBuf::from(icon_name); + if icon_path.extension().is_some() { + let direct = resources_dir.join(icon_path); + if direct.exists() { + return Some(direct); + } + } + + let candidates = [ + format!("{icon_name}.icns"), + format!("{icon_name}.png"), + "AppIcon.icns".to_string(), + "AppIcon.png".to_string(), + "app.icns".to_string(), + ]; + for candidate in candidates { + let path = resources_dir.join(candidate); + if path.exists() { + return Some(path); + } + } + + let icon_name_lower = icon_name.to_ascii_lowercase(); + if let Ok(entries) = fs::read_dir(resources_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let ext = path + .extension() + .map(|ext| ext.to_string_lossy().to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("icns" | "png")) { + continue; + } + let stem = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if stem == icon_name_lower { + return Some(path); + } + } + } + + None +} + +#[cfg(target_os = "macos")] +fn temp_png_path(app_name: &str) -> PathBuf { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default(); + let safe_name = app_name + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect::(); + std::env::temp_dir().join(format!("codex-monitor-icon-{safe_name}-{ts}.png")) +} + +#[cfg(target_os = "macos")] +fn load_icon_png_bytes(icon_path: &Path, app_name: &str) -> Option> { + let ext = icon_path + .extension() + .map(|ext| ext.to_string_lossy().to_ascii_lowercase()); + if matches!(ext.as_deref(), Some("png")) { + return fs::read(icon_path).ok(); + } + let out_path = temp_png_path(app_name); + let status = std::process::Command::new("sips") + .arg("-s") + .arg("format") + .arg("png") + .arg(icon_path.as_os_str()) + .arg("--out") + .arg(out_path.as_os_str()) + .status() + .ok()?; + if !status.success() { + let _ = fs::remove_file(&out_path); + return None; + } + let bytes = fs::read(&out_path).ok(); + let _ = fs::remove_file(&out_path); + bytes +} + +#[cfg(target_os = "macos")] +fn get_open_app_icon_inner(app_name: &str) -> Option { + let bundle_path = find_app_bundle(app_name)?; + let icon_name = resolve_icon_name(&bundle_path); + let icon_path = resolve_icon_path(&bundle_path, &icon_name)?; + let png_bytes = load_icon_png_bytes(&icon_path, app_name)?; + let encoded = base64::engine::general_purpose::STANDARD.encode(png_bytes); + Some(format!("data:image/png;base64,{encoded}")) +} + +#[tauri::command] +pub(crate) async fn get_open_app_icon(app_name: String) -> Result, String> { + #[cfg(target_os = "macos")] + { + let trimmed = app_name.trim().to_string(); + if trimmed.is_empty() { + return Ok(None); + } + let result = tokio::task::spawn_blocking(move || get_open_app_icon_inner(&trimmed)) + .await + .map_err(|err| err.to_string())?; + return Ok(result); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = app_name; + Ok(None) + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/src/App.tsx b/src/App.tsx index fee785345..6faafc8bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -94,6 +94,8 @@ import type { ComposerEditorSettings, WorkspaceInfo, } from "./types"; +import { OPEN_APP_STORAGE_KEY } from "./features/app/constants"; +import { useOpenAppIcons } from "./features/app/hooks/useOpenAppIcons"; const AboutView = lazy(() => import("./features/about/components/AboutView").then((module) => ({ @@ -729,6 +731,28 @@ function MainApp() { [appSettings.workspaceGroups], ); + const handleSelectOpenAppId = useCallback( + (id: string) => { + if (typeof window !== "undefined") { + window.localStorage.setItem(OPEN_APP_STORAGE_KEY, id); + } + setAppSettings((current) => { + if (current.selectedOpenAppId === id) { + return current; + } + const nextSettings = { + ...current, + selectedOpenAppId: id, + }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + }, + [queueSaveSettings, setAppSettings], + ); + + const openAppIconById = useOpenAppIcons(appSettings.openAppTargets); + const persistProjectCopiesFolder = useCallback( async (groupId: string, copiesFolder: string) => { await queueSaveSettings({ @@ -1383,6 +1407,10 @@ function MainApp() { activeItems, activeRateLimits, codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier, + openAppTargets: appSettings.openAppTargets, + openAppIconById, + selectedOpenAppId: appSettings.selectedOpenAppId, + onSelectOpenAppId: handleSelectOpenAppId, approvals, userInputRequests, handleApprovalDecision, @@ -1864,6 +1892,7 @@ function MainApp() { reduceTransparency, onToggleTransparency: setReduceTransparency, appSettings, + openAppIconById, onUpdateAppSettings: async (next) => { await queueSaveSettings(next); }, diff --git a/src/features/app/components/MainHeader.tsx b/src/features/app/components/MainHeader.tsx index ac16245b6..4e73e854e 100644 --- a/src/features/app/components/MainHeader.tsx +++ b/src/features/app/components/MainHeader.tsx @@ -3,7 +3,7 @@ import Check from "lucide-react/dist/esm/icons/check"; import Copy from "lucide-react/dist/esm/icons/copy"; import Terminal from "lucide-react/dist/esm/icons/terminal"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import type { BranchInfo, WorkspaceInfo } from "../../../types"; +import type { BranchInfo, OpenAppTarget, WorkspaceInfo } from "../../../types"; import type { ReactNode } from "react"; import { OpenAppMenu } from "./OpenAppMenu"; @@ -14,6 +14,10 @@ type MainHeaderProps = { disableBranchMenu?: boolean; parentPath?: string | null; worktreePath?: string | null; + openTargets: OpenAppTarget[]; + openAppIconById: Record; + selectedOpenAppId: string; + onSelectOpenAppId: (id: string) => void; branchName: string; branches: BranchInfo[]; onCheckoutBranch: (name: string) => Promise | void; @@ -51,6 +55,10 @@ export function MainHeader({ disableBranchMenu = false, parentPath = null, worktreePath = null, + openTargets, + openAppIconById, + selectedOpenAppId, + onSelectOpenAppId, branchName, branches, onCheckoutBranch, @@ -480,7 +488,13 @@ export function MainHeader({
- + {showTerminalButton && (
{openMenuOpen && (
- {openTargets.map((target) => ( + {resolvedOpenTargets.map((target) => ( + + +
+ + + + ); + })} + +
+ +
+ Commands receive the selected path as the final argument. Apps use macOS open + with optional args. +
+
+ + )} {activeSection === "codex" && (
Codex
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 0349e72a2..aaf5865ac 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -9,6 +9,12 @@ import { clampCodeFontSize, normalizeFontFamily, } from "../../../utils/fonts"; +import { + DEFAULT_OPEN_APP_ID, + DEFAULT_OPEN_APP_TARGETS, + OPEN_APP_STORAGE_KEY, +} from "../../app/constants"; +import { normalizeOpenAppTargets } from "../../app/utils/openApp"; const allowedThemes = new Set(["system", "light", "dark"]); @@ -59,9 +65,31 @@ const defaultSettings: AppSettings = { composerListContinuation: false, composerCodeBlockCopyUseModifier: false, workspaceGroups: [], + openAppTargets: DEFAULT_OPEN_APP_TARGETS, + selectedOpenAppId: DEFAULT_OPEN_APP_ID, }; function normalizeAppSettings(settings: AppSettings): AppSettings { + const normalizedTargets = + settings.openAppTargets && settings.openAppTargets.length + ? normalizeOpenAppTargets(settings.openAppTargets) + : DEFAULT_OPEN_APP_TARGETS; + const storedOpenAppId = + typeof window === "undefined" + ? null + : window.localStorage.getItem(OPEN_APP_STORAGE_KEY); + const hasPersistedSelection = normalizedTargets.some( + (target) => target.id === settings.selectedOpenAppId, + ); + const hasStoredSelection = + !hasPersistedSelection && + storedOpenAppId !== null && + normalizedTargets.some((target) => target.id === storedOpenAppId); + const selectedOpenAppId = hasPersistedSelection + ? settings.selectedOpenAppId + : hasStoredSelection + ? storedOpenAppId + : normalizedTargets[0]?.id ?? DEFAULT_OPEN_APP_ID; return { ...settings, uiScale: clampUiScale(settings.uiScale), @@ -75,6 +103,8 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { DEFAULT_CODE_FONT_FAMILY, ), codeFontSize: clampCodeFontSize(settings.codeFontSize), + openAppTargets: normalizedTargets, + selectedOpenAppId, }; } diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index 9ce2f7005..25eeb2aeb 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -5,6 +5,8 @@ import { getGitHubIssues, getGitLog, getGitStatus, + getOpenAppIcon, + openWorkspaceIn, stageGitAll, respondToServerRequest, respondToUserInputRequest, @@ -93,6 +95,34 @@ describe("tauri invoke wrappers", () => { }); }); + it("maps openWorkspaceIn options", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({}); + + await openWorkspaceIn("/tmp/project", { + appName: "Xcode", + args: ["--reuse-window"], + }); + + expect(invokeMock).toHaveBeenCalledWith("open_workspace_in", { + path: "/tmp/project", + app: "Xcode", + command: null, + args: ["--reuse-window"], + }); + }); + + it("invokes get_open_app_icon", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce("data:image/png;base64,abc"); + + await getOpenAppIcon("Xcode"); + + expect(invokeMock).toHaveBeenCalledWith("get_open_app_icon", { + appName: "Xcode", + }); + }); + it("fills sendUserMessage defaults in payload", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({}); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index cf7d09d2a..a29f9836e 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -124,8 +124,24 @@ export async function applyWorktreeChanges(workspaceId: string): Promise { return invoke("apply_worktree_changes", { workspaceId }); } -export async function openWorkspaceIn(path: string, app: string): Promise { - return invoke("open_workspace_in", { path, app }); +export async function openWorkspaceIn( + path: string, + options: { + appName?: string | null; + command?: string | null; + args?: string[]; + }, +): Promise { + return invoke("open_workspace_in", { + path, + app: options.appName ?? null, + command: options.command ?? null, + args: options.args ?? [], + }); +} + +export async function getOpenAppIcon(appName: string): Promise { + return invoke("get_open_app_icon", { appName }); } export async function connectWorkspace(id: string): Promise { diff --git a/src/styles/settings.css b/src/styles/settings.css index d5747c02d..55a38a84e 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -416,6 +416,120 @@ gap: 10px; } +.settings-open-apps { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-open-app-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 12px; + border: 1px solid var(--border-muted); + background: var(--surface-card); + flex-wrap: wrap; +} + +.settings-open-app-icon-wrap { + flex: 0 0 auto; + width: 24px; + height: 24px; + border-radius: 8px; + border: 1px solid var(--border-muted); + background: var(--surface-control); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.settings-open-app-icon { + width: 18px; + height: 18px; + border-radius: 5px; +} + +.settings-open-app-fields { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.settings-open-app-field { + min-width: 0; + display: inline-flex; + align-items: center; +} + +.settings-open-app-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex: 0 0 auto; +} + +.settings-open-app-default { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.settings-open-app-order { + display: inline-flex; + gap: 4px; +} + +.settings-open-app-input--label { + width: 140px; +} + +.settings-open-app-kind { + width: 96px; + min-width: 96px; +} + +.settings-open-app-input--appname { + width: 220px; + max-width: 240px; +} + +.settings-open-app-input--command { + width: 200px; + max-width: 220px; +} + +.settings-open-app-input--args { + flex: 1; + min-width: 140px; +} + +.settings-open-app-footer { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.settings-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .settings-override-row { display: flex; align-items: center; diff --git a/src/types.ts b/src/types.ts index d69adfa4d..b4abb551d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,15 @@ export type ComposerEditorSettings = { continueListOnShiftEnter: boolean; }; +export type OpenAppTarget = { + id: string; + label: string; + kind: "app" | "command" | "finder"; + appName?: string | null; + command?: string | null; + args: string[]; +}; + export type AppSettings = { codexBin: string | null; backendMode: BackendMode; @@ -135,6 +144,8 @@ export type AppSettings = { composerListContinuation: boolean; composerCodeBlockCopyUseModifier: boolean; workspaceGroups: WorkspaceGroup[]; + openAppTargets: OpenAppTarget[]; + selectedOpenAppId: string; }; export type CodexDoctorResult = {