Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ pub(crate) struct WorkspaceSettings {
pub(crate) git_root: Option<String>,
}

#[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<String>,
#[serde(default)]
pub(crate) command: Option<String>,
#[serde(default)]
pub(crate) args: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct AppSettings {
#[serde(default, rename = "codexBin")]
Expand Down Expand Up @@ -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<WorkspaceGroup>,
#[serde(default = "default_open_app_targets", rename = "openAppTargets")]
pub(crate) open_app_targets: Vec<OpenAppTarget>,
#[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")]
pub(crate) selected_open_app_id: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
Expand Down Expand Up @@ -600,6 +617,63 @@ fn default_workspace_groups() -> Vec<WorkspaceGroup> {
Vec::new()
}

fn default_open_app_targets() -> Vec<OpenAppTarget> {
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 {
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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]
Expand Down
244 changes: 237 additions & 7 deletions src-tauri/src/workspaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1506,21 +1515,242 @@ pub(crate) async fn list_workspace_files(
#[tauri::command]
pub(crate) async fn open_workspace_in(
path: String,
app: String,
app: Option<String>,
args: Vec<String>,
command: Option<String>,
) -> 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 {
Err("Failed to open app".to_string())
}
}

#[cfg(target_os = "macos")]
fn app_search_roots() -> Vec<PathBuf> {
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<PathBuf> {
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<String> {
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<PathBuf> {
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::<String>();
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<Vec<u8>> {
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<String> {
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<Option<String>, 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;
Expand Down
Loading