From b432437b029e37b3e8e09c5ea2fa7a3bd68abeb5 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Sun, 8 Feb 2026 04:32:14 +0200 Subject: [PATCH] feat: Add desktop Open In button (#350) * feat: add OpenInAppButton and macOS path opener * feat: filter installed apps on macOS and use in OpenInAppButton * feat: fetch and display macOS app icons in OpenInAppButton * feat(desktop): cache and fetch installed macOS apps * feat: add force refresh and retry for installed apps * feat(OpenInAppButton): add Copy Path action in dropdown header * feat: enhance caching mechanism for apps discovery --------- Co-authored-by: Bohdan Triapitsyn --- AGENTS.md | 1 + bun.lock | 9 +- packages/desktop/src-tauri/Cargo.lock | 1 + packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/src/main.rs | 520 +++++++++++++++++- packages/ui/package.json | 1 + packages/ui/src/App.tsx | 10 + .../components/desktop/OpenInAppButton.tsx | 403 ++++++++++++++ packages/ui/src/components/layout/Header.tsx | 15 + .../sections/openchamber/GitHubSettings.tsx | 2 +- packages/ui/src/components/ui/toast.ts | 39 +- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 138 +++++ packages/ui/src/lib/persistence.ts | 8 + 14 files changed, 1140 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/desktop/OpenInAppButton.tsx diff --git a/AGENTS.md b/AGENTS.md index 4825a05f..22c97559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,7 @@ All scripts are in `package.json`. - React: prefer function components + hooks; class only when needed (e.g. error boundaries). - Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`. - Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`. +- Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code. - No new deps unless asked. - Never add secrets (`.env`, keys) or log sensitive data. diff --git a/bun.lock b/bun.lock index b468d631..19e12767 100644 --- a/bun.lock +++ b/bun.lock @@ -96,7 +96,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.6.4", + "version": "1.6.5", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -105,7 +105,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.6.4", + "version": "1.6.5", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -152,6 +152,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror-lang-elixir": "^4.0.0", "express": "^5.1.0", "fuse.js": "^7.1.0", "ghostty-web": "^0.4.0", @@ -199,7 +200,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.6.4", + "version": "1.6.5", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.1.53", @@ -222,7 +223,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.6.4", + "version": "1.6.5", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 133f9503..ceadd61f 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2672,6 +2672,7 @@ name = "openchamber-desktop" version = "1.6.5" dependencies = [ "anyhow", + "base64 0.22.1", "log", "reqwest", "serde", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 74cdc5d6..cd0aa0a7 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ devtools = ["tauri/devtools"] [dependencies] anyhow = "1.0.86" +base64 = "0.22.1" log = "0.4.28" reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] } serde = { version = "1.0.210", features = ["derive"] } diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 998e7e86..2ae1844d 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose, Engine as _}; use serde::{Deserialize, Serialize}; use std::{ net::TcpListener, @@ -8,8 +9,9 @@ use std::{ sync::Mutex, time::Duration, }; -use std::{fs, path::PathBuf}; +use std::{collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}}; use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; fn eval_in_main_window(app: &tauri::AppHandle, script: &str) { @@ -367,6 +369,518 @@ fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Resul Ok(()) } +#[tauri::command] +fn desktop_open_path(path: String, app: Option) -> Result<(), String> { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Path is required".to_string()); + } + + #[cfg(target_os = "macos")] + { + let mut command = Command::new("open"); + if let Some(app_name) = app.as_ref().map(|value| value.trim()).filter(|value| !value.is_empty()) { + command.arg("-a").arg(app_name); + } + command.arg(trimmed); + command.spawn().map_err(|err| err.to_string())?; + return Ok(()); + } + + #[cfg(not(target_os = "macos"))] + { + Err("desktop_open_path is only supported on macOS".to_string()) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct InstalledAppInfo { + name: String, + icon_data_url: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InstalledAppsCache { + updated_at: u64, + apps: Vec, +} + +const INSTALLED_APPS_CACHE_TTL_SECS: u64 = 60 * 60 * 24; +const INSTALLED_APPS_CACHE_FILE: &str = "discovered-apps.json"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InstalledAppsResponse { + apps: Vec, + has_cache: bool, + is_cache_stale: bool, +} + +#[tauri::command] +fn desktop_filter_installed_apps(apps: Vec) -> Result, String> { + #[cfg(target_os = "macos")] + { + let mut installed: Vec = Vec::new(); + + for raw in apps { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + let bundle_name = if trimmed.ends_with(".app") { + trimmed.to_string() + } else { + format!("{trimmed}.app") + }; + + if is_app_bundle_installed(&bundle_name) { + installed.push(trimmed.to_string()); + } + } + + return Ok(installed); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = apps; + Err("desktop_filter_installed_apps is only supported on macOS".to_string()) + } +} + +#[tauri::command] +fn desktop_get_installed_apps( + app: tauri::AppHandle, + apps: Vec, + force: Option, +) -> Result { + #[cfg(target_os = "macos")] + { + let cache_path = installed_apps_cache_path(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| err.to_string())? + .as_secs(); + + let cache = read_installed_apps_cache(&cache_path); + let cached_apps = cache + .as_ref() + .map(|entry| entry.apps.clone()) + .unwrap_or_default(); + let has_cache = cache.is_some(); + let is_cache_stale = cache + .as_ref() + .map(|entry| now.saturating_sub(entry.updated_at) > INSTALLED_APPS_CACHE_TTL_SECS) + .unwrap_or(false); + + if has_cache { + if is_cache_stale { + log::info!("[open-in] cache hit (stale): {} apps", cached_apps.len()); + } else { + log::info!("[open-in] cache hit (fresh): {} apps", cached_apps.len()); + } + if log::log_enabled!(log::Level::Info) { + let names: Vec = cached_apps.iter().map(|app| app.name.clone()).collect(); + log::info!("[open-in] cache apps: {:?}", names); + } + } + + if !has_cache { + log::info!("[open-in] cache missing: refreshing app list"); + let app_handle = app.clone(); + let app_names = apps.clone(); + let force_icon_refresh = false; + let cached_icon_map: HashMap = HashMap::new(); + tauri::async_runtime::spawn_blocking(move || { + log::info!("[open-in] scan start: {} candidates", app_names.len()); + let refreshed = build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh); + if log::log_enabled!(log::Level::Info) { + let names: Vec = refreshed.iter().map(|entry| entry.name.clone()).collect(); + log::info!("[open-in] scan apps: {:?}", names); + } + log::info!("[open-in] scan done: {} installed", refreshed.len()); + let cache_entry = InstalledAppsCache { + updated_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or(0), + apps: refreshed.clone(), + }; + let cache_path = installed_apps_cache_path(); + let _ = write_installed_apps_cache(&cache_path, &cache_entry); + dispatch_installed_apps_update(&app_handle, &refreshed); + }); + } else if force.unwrap_or(false) { + log::info!("[open-in] manual refresh: refreshing app list"); + let app_handle = app.clone(); + let app_names = apps.clone(); + let force_icon_refresh = true; + let cached_icon_map: HashMap = HashMap::new(); + tauri::async_runtime::spawn_blocking(move || { + log::info!("[open-in] scan start: {} candidates", app_names.len()); + let refreshed = build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh); + if log::log_enabled!(log::Level::Info) { + let names: Vec = refreshed.iter().map(|entry| entry.name.clone()).collect(); + log::info!("[open-in] scan apps: {:?}", names); + } + log::info!("[open-in] scan done: {} installed", refreshed.len()); + let cache_entry = InstalledAppsCache { + updated_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or(0), + apps: refreshed.clone(), + }; + let cache_path = installed_apps_cache_path(); + let _ = write_installed_apps_cache(&cache_path, &cache_entry); + dispatch_installed_apps_update(&app_handle, &refreshed); + }); + } + + return Ok(InstalledAppsResponse { + apps: cached_apps, + has_cache, + is_cache_stale, + }); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = apps; + Err("desktop_get_installed_apps is only supported on macOS".to_string()) + } +} + +#[derive(Serialize)] +struct AppIconPayload { + app: String, + data_url: String, +} + +#[tauri::command] +fn desktop_fetch_app_icons(apps: Vec) -> Result, String> { + #[cfg(target_os = "macos")] + { + let mut results: Vec = Vec::new(); + + for raw in apps { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + let Some(app_path) = resolve_app_bundle_path(trimmed) else { + continue; + }; + + let Some(icon_path) = resolve_app_icon_path(&app_path) else { + continue; + }; + + let Some(data_url) = icon_to_data_url(&icon_path, trimmed) else { + continue; + }; + + results.push(AppIconPayload { + app: trimmed.to_string(), + data_url, + }); + } + + return Ok(results); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = apps; + Err("desktop_fetch_app_icons is only supported on macOS".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn resolve_app_bundle_path(app_name: &str) -> Option { + if app_name.trim().is_empty() { + return None; + } + + let bundle_name = if app_name.ends_with(".app") { + app_name.to_string() + } else { + format!("{app_name}.app") + }; + + let candidates = [ + format!("/Applications/{bundle_name}"), + format!("/System/Applications/{bundle_name}"), + format!("/System/Applications/Utilities/{bundle_name}"), + ]; + + for candidate in candidates { + let path = PathBuf::from(&candidate); + if path.exists() { + return Some(path); + } + } + + if let Some(home) = env::var_os("HOME") { + let user_app_path = PathBuf::from(home).join("Applications").join(&bundle_name); + if user_app_path.exists() { + return Some(user_app_path); + } + } + + if let Ok(output) = Command::new("mdfind").args(["-name", &bundle_name]).output() { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let path = PathBuf::from(trimmed); + if path.exists() { + return Some(path); + } + } + } + } + + None +} + +#[cfg(target_os = "macos")] +fn installed_apps_cache_path() -> PathBuf { + let home = env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")); + home + .join(".config") + .join("openchamber") + .join(INSTALLED_APPS_CACHE_FILE) +} + +#[cfg(target_os = "macos")] +fn read_installed_apps_cache(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +#[cfg(target_os = "macos")] +fn write_installed_apps_cache(path: &Path, cache: &InstalledAppsCache) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + let payload = serde_json::to_vec(cache).map_err(|err| err.to_string())?; + fs::write(path, payload).map_err(|err| err.to_string()) +} + +#[cfg(target_os = "macos")] +fn build_installed_apps( + apps: &[String], + cached_icon_map: &HashMap, + force_icon_refresh: bool, +) -> Vec { + let mut seen = HashSet::new(); + let mut results = Vec::new(); + + for raw in apps { + let trimmed = raw.trim(); + if trimmed.is_empty() || !seen.insert(trimmed.to_string()) { + continue; + } + + if let Some(app_path) = resolve_app_bundle_path(trimmed) { + let icon_data_url = if force_icon_refresh { + resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed)) + } else { + cached_icon_map + .get(trimmed) + .cloned() + .or_else(|| resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed))) + }; + results.push(InstalledAppInfo { + name: trimmed.to_string(), + icon_data_url, + }); + } + } + + results +} + +#[cfg(target_os = "macos")] +fn dispatch_installed_apps_update(app: &tauri::AppHandle, apps: &[InstalledAppInfo]) { + let event = serde_json::to_string("openchamber:installed-apps-updated") + .unwrap_or_else(|_| "\"openchamber:installed-apps-updated\"".into()); + let detail = serde_json::to_string(apps).unwrap_or_else(|_| "[]".into()); + let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));"); + eval_in_main_window(app, &script); +} + +#[cfg(target_os = "macos")] +fn resolve_app_icon_path(app_path: &Path) -> Option { + if !app_path.exists() { + return None; + } + + if let Some(icon_file) = read_bundle_icon_file(app_path) { + let icon_path = app_path + .join("Contents") + .join("Resources") + .join(&icon_file); + if icon_path.exists() { + return Some(icon_path); + } + } + + if let Ok(output) = Command::new("mdls") + .args(["-name", "kMDItemIconFile", "-raw", &app_path.to_string_lossy()]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let icon_name = stdout.trim(); + if !icon_name.is_empty() && icon_name != "(null)" { + let icon_file = if icon_name.ends_with(".icns") { + icon_name.to_string() + } else { + format!("{icon_name}.icns") + }; + let icon_path = app_path + .join("Contents") + .join("Resources") + .join(icon_file); + if icon_path.exists() { + return Some(icon_path); + } + } + } + } + + let resources_path = app_path.join("Contents").join("Resources"); + if let Ok(entries) = fs::read_dir(resources_path) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|value| value.to_str()) { + if ext.eq_ignore_ascii_case("icns") { + return Some(path); + } + } + } + } + + None +} + +#[cfg(target_os = "macos")] +fn read_bundle_icon_file(app_path: &Path) -> Option { + let plist_path = app_path.join("Contents").join("Info.plist"); + if !plist_path.exists() { + return None; + } + + let output = Command::new("defaults") + .args(["read", &plist_path.to_string_lossy(), "CFBundleIconFile"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let icon_name = stdout.trim(); + if icon_name.is_empty() { + return None; + } + + let icon_file = if icon_name.ends_with(".icns") { + icon_name.to_string() + } else { + format!("{icon_name}.icns") + }; + + Some(icon_file) +} + +#[cfg(target_os = "macos")] +fn icon_to_data_url(icon_path: &Path, app_name: &str) -> Option { + if !icon_path.exists() { + return None; + } + + let sanitized: String = app_name + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or(0); + let tmp_path = env::temp_dir().join(format!("openchamber-icon-{sanitized}-{timestamp}.png")); + + let status = Command::new("sips") + .args([ + "-s", + "format", + "png", + "-Z", + "32", + &icon_path.to_string_lossy(), + "--out", + &tmp_path.to_string_lossy(), + ]) + .status() + .ok()?; + + if !status.success() { + return None; + } + + let bytes = fs::read(&tmp_path).ok()?; + let _ = fs::remove_file(&tmp_path); + if bytes.is_empty() { + return None; + } + + let encoded = general_purpose::STANDARD.encode(bytes); + Some(format!("data:image/png;base64,{encoded}")) +} + +#[cfg(target_os = "macos")] +fn is_app_bundle_installed(bundle_name: &str) -> bool { + if bundle_name.trim().is_empty() { + return false; + } + + if let Ok(output) = Command::new("mdfind").args(["-name", bundle_name]).output() { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + return true; + } + } + } + + let app_path = format!("/Applications/{bundle_name}"); + let system_app_path = format!("/System/Applications/{bundle_name}"); + let utilities_path = format!("/System/Applications/Utilities/{bundle_name}"); + + if Path::new(&app_path).exists() || Path::new(&system_app_path).exists() || Path::new(&utilities_path).exists() { + return true; + } + + if let Some(home) = env::var_os("HOME") { + let user_app_path = PathBuf::from(home).join("Applications").join(bundle_name); + if user_app_path.exists() { + return true; + } + } + + false +} + const SIDECAR_NAME: &str = "openchamber-server"; const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] "; const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); @@ -1497,6 +2011,10 @@ fn main() { desktop_download_and_install_update, desktop_restart, desktop_set_auto_worktree_menu, + desktop_open_path, + desktop_filter_installed_apps, + desktop_get_installed_apps, + desktop_fetch_app_icons, desktop_hosts_get, desktop_hosts_set, desktop_host_probe, diff --git a/packages/ui/package.json b/packages/ui/package.json index 66d8e0e3..48aafd97 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror-lang-elixir": "^4.0.0", "express": "^5.1.0", "fuse.js": "^7.1.0", "ghostty-web": "^0.4.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 64a01ac1..c2773e76 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -56,6 +56,7 @@ function App({ apis }: AppProps) { const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); const [showCliOnboarding, setShowCliOnboarding] = React.useState(false); + const appReadyDispatchedRef = React.useRef(false); React.useEffect(() => { setIsVSCodeRuntime(apis.runtime.isVSCode); @@ -157,6 +158,15 @@ function App({ apis }: AppProps) { syncDirectoryAndSessions(); }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]); + React.useEffect(() => { + if (typeof window === 'undefined') return; + if (!isInitialized || isSwitchingDirectory) return; + if (appReadyDispatchedRef.current) return; + appReadyDispatchedRef.current = true; + (window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady = true; + window.dispatchEvent(new Event('openchamber:app-ready')); + }, [isInitialized, isSwitchingDirectory]); + useEventStream(); // Server-authoritative session status polling diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx new file mode 100644 index 00000000..6241a89a --- /dev/null +++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx @@ -0,0 +1,403 @@ +import React from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { toast } from '@/components/ui'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { cn } from '@/lib/utils'; +import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; +import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react'; + +const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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'; +const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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'; + +type OpenInAppOption = { + id: string; + label: string; + appName: string; + fallbackIconDataUrl?: string; + iconDataUrl?: string; +}; + +const OPEN_IN_APPS: OpenInAppOption[] = [ + { id: 'finder', label: 'Finder', appName: 'Finder', fallbackIconDataUrl: FINDER_DEFAULT_ICON_DATA_URL }, + { id: 'terminal', label: 'Terminal', appName: 'Terminal', fallbackIconDataUrl: TERMINAL_DEFAULT_ICON_DATA_URL }, + { id: 'iterm2', label: 'iTerm2', appName: 'iTerm' }, + { id: 'ghostty', label: 'Ghostty', appName: 'Ghostty' }, + { id: 'vscode', label: 'VS Code', appName: 'Visual Studio Code' }, + { id: 'intellij', label: 'IntelliJ', appName: 'IntelliJ IDEA' }, + { id: 'visual-studio', label: 'Visual Studio', appName: 'Visual Studio' }, + { id: 'cursor', label: 'Cursor', appName: 'Cursor' }, + { id: 'android-studio', label: 'Android Studio', appName: 'Android Studio' }, + { id: 'pycharm', label: 'PyCharm', appName: 'PyCharm' }, + { id: 'xcode', label: 'Xcode', appName: 'Xcode' }, + { id: 'sublime-text', label: 'Sublime', appName: 'Sublime Text' }, + { id: 'webstorm', label: 'WebStorm', appName: 'WebStorm' }, + { id: 'rider', label: 'Rider', appName: 'Rider' }, + { id: 'zed', label: 'Zed', appName: 'Zed' }, + { id: 'phpstorm', label: 'PhpStorm', appName: 'PhpStorm' }, + { id: 'eclipse', label: 'Eclipse', appName: 'Eclipse' }, + { id: 'windsurf', label: 'Windsurf', appName: 'Windsurf' }, + { id: 'vscodium', label: 'VSCodium', appName: 'VSCodium' }, + { id: 'rustrover', label: 'RustRover', appName: 'RustRover' }, + { id: 'trae', label: 'Trae', appName: 'Trae' }, +]; + +const DEFAULT_APP_ID = 'vscode'; +const ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']); +const getAlwaysAvailableApps = () => OPEN_IN_APPS.filter((app) => ALWAYS_AVAILABLE_APP_IDS.has(app.id)); + +const getStoredAppId = (): string => { + if (typeof window === 'undefined') { + return DEFAULT_APP_ID; + } + const stored = window.localStorage.getItem('openInAppId'); + if (stored && OPEN_IN_APPS.some((app) => app.id === stored)) { + return stored; + } + return DEFAULT_APP_ID; +}; + +const AppIcon = ({ + label, + iconDataUrl, + fallbackIconDataUrl, +}: { + label: string; + iconDataUrl?: string; + fallbackIconDataUrl?: string; +}) => { + const [failed, setFailed] = React.useState(false); + const initial = label.trim().slice(0, 1).toUpperCase() || '?'; + + const src = iconDataUrl || fallbackIconDataUrl; + + if (src && !failed) { + return ( + setFailed(true)} + /> + ); + } + + return ( + + {initial} + + ); +}; + +type OpenInAppButtonProps = { + directory: string; + className?: string; +}; + +export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) => { + const [selectedAppId, setSelectedAppId] = React.useState(getStoredAppId); + const [availableApps, setAvailableApps] = React.useState(getAlwaysAvailableApps); + const [hasLoadedApps, setHasLoadedApps] = React.useState(false); + const [isCacheStale, setIsCacheStale] = React.useState(false); + const [isScanning, setIsScanning] = React.useState(false); + const isMountedRef = React.useRef(true); + const isLoadingRef = React.useRef(false); + const keepScanningRef = React.useRef(false); + const hasLoadedAppsRef = React.useRef(false); + const retryTimeoutRef = React.useRef | null>(null); + const retryAttemptRef = React.useRef(0); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && typeof detail.openInAppId === 'string' && detail.openInAppId.length > 0) { + setSelectedAppId(detail.openInAppId); + } + }; + window.addEventListener('openchamber:settings-synced', handler); + return () => window.removeEventListener('openchamber:settings-synced', handler); + }, []); + + React.useEffect(() => { + return () => { + isMountedRef.current = false; + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + } + }; + }, []); + + const setLoadedState = React.useCallback((value: boolean) => { + hasLoadedAppsRef.current = value; + setHasLoadedApps(value); + }, []); + + const isDesktopLocal = isTauriShell() && isDesktopLocalOriginActive(); + + const applyInstalledApps = React.useCallback((installed: InstalledDesktopAppInfo[]) => { + if (installed.length === 0) { + setAvailableApps(getAlwaysAvailableApps()); + setLoadedState(false); + return; + } + + const allowed = new Set(installed.map((app) => app.name)); + const iconMap = new Map(installed.map((app) => [app.name, app.iconDataUrl ?? undefined])); + const filtered = OPEN_IN_APPS.filter( + (app) => allowed.has(app.appName) || ALWAYS_AVAILABLE_APP_IDS.has(app.id) + ); + const withIcons = filtered.map((app) => ({ + ...app, + iconDataUrl: iconMap.get(app.appName), + })); + setAvailableApps(withIcons); + setLoadedState(true); + }, [setLoadedState]); + + const loadInstalledApps = React.useCallback(async (force?: boolean) => { + if (isLoadingRef.current) return; + if (hasLoadedApps && !force) return; + const appNames = OPEN_IN_APPS.map((app) => app.appName); + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + if (force) { + console.info('[open-in] manual refresh requested'); + setLoadedState(false); + } else { + console.info('[open-in] load installed apps'); + } + isLoadingRef.current = true; + setIsScanning(true); + keepScanningRef.current = false; + try { + const { + apps: installed, + success, + hasCache, + isCacheStale: nextCacheStale, + } = await fetchDesktopInstalledApps(appNames, force); + if (!isMountedRef.current) return; + setIsCacheStale(hasCache ? nextCacheStale : false); + console.info('[open-in] installed apps returned', installed.map((app) => app.name)); + applyInstalledApps(installed); + if (success) { + if (!hasCache && installed.length === 0 && retryAttemptRef.current < 3) { + const delays = [800, 1600, 3200]; + const delay = delays[retryAttemptRef.current] ?? 3200; + retryAttemptRef.current += 1; + keepScanningRef.current = true; + retryTimeoutRef.current = setTimeout(() => { + void loadInstalledApps(); + }, delay); + return; + } + retryAttemptRef.current = 0; + keepScanningRef.current = false; + return; + } + if (retryAttemptRef.current < 3) { + const delays = [1000, 3000, 7000]; + const delay = delays[retryAttemptRef.current] ?? 7000; + retryAttemptRef.current += 1; + keepScanningRef.current = true; + retryTimeoutRef.current = setTimeout(() => { + void loadInstalledApps(); + }, delay); + } + } finally { + isLoadingRef.current = false; + if (!keepScanningRef.current) { + setIsScanning(false); + } + } + }, [applyInstalledApps, hasLoadedApps, setLoadedState]); + + React.useEffect(() => { + if (!isDesktopLocal) return; + if (typeof window === 'undefined') return; + void loadInstalledApps(); + const handler = () => { + console.info('[open-in] app ready, starting installed app scan'); + void loadInstalledApps(); + }; + window.addEventListener('openchamber:app-ready', handler); + const updateHandler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (Array.isArray(detail)) { + console.info('[open-in] received installed app update', detail.length); + retryAttemptRef.current = 3; + keepScanningRef.current = false; + setIsScanning(false); + setIsCacheStale(false); + applyInstalledApps(detail); + } + }; + window.addEventListener('openchamber:installed-apps-updated', updateHandler); + const flag = (window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady; + if (flag) { + console.info('[open-in] app ready flag already set'); + void loadInstalledApps(); + } + return () => { + window.removeEventListener('openchamber:app-ready', handler); + window.removeEventListener('openchamber:installed-apps-updated', updateHandler); + }; + }, [applyInstalledApps, isDesktopLocal, loadInstalledApps]); + + React.useEffect(() => { + if (!isDesktopLocal) return; + if (typeof window === 'undefined') return; + const fallbackTimer = window.setTimeout(() => { + if (!hasLoadedAppsRef.current) { + console.info('[open-in] fallback scan triggered'); + void loadInstalledApps(); + } + }, 5000); + return () => window.clearTimeout(fallbackTimer); + }, [isDesktopLocal, loadInstalledApps]); + + const selectedApp = availableApps.find((app) => app.id === selectedAppId) ?? availableApps[0]; + + React.useEffect(() => { + if (!selectedApp) return; + if (selectedAppId !== selectedApp.id) { + setSelectedAppId(selectedApp.id); + void updateDesktopSettings({ openInAppId: selectedApp.id }); + } + }, [selectedApp, selectedAppId]); + + if (!isDesktopLocal || !directory) { + return null; + } + + if (availableApps.length === 0) { + return null; + } + + const handleOpen = async (app: OpenInAppOption) => { + await openDesktopPath(directory, app.appName); + }; + + const handleSelect = async (app: OpenInAppOption) => { + setSelectedAppId(app.id); + await updateDesktopSettings({ openInAppId: app.id }); + await handleOpen(app); + }; + + const handleCopyPath = async () => { + if (typeof navigator === 'undefined') return; + const text = directory; + let copied = false; + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + copied = true; + } catch { + // fall through + } + } + if (!copied) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + copied = document.execCommand('copy'); + document.body.removeChild(textarea); + } + if (!copied) { + return; + } + toast.success('Path copied to clipboard'); + }; + + return ( +
+ + + + + + + void handleCopyPath()}> + + Copy Path + + + {availableApps.map((app) => ( + void handleSelect(app)} + > + + {app.label} + {selectedApp.id === app.id ? ( + + ) : null} + + ))} + {isCacheStale ? ( + void loadInstalledApps(true)} + > + + Refresh Apps + + ) : null} + + +
+ ); +}; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index c5bd4b8c..371bdc58 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -35,6 +35,7 @@ import { updateDesktopSettings } from '@/lib/persistence'; import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher'; +import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { isDesktopShell } from '@/lib/desktop'; const formatTime = (timestamp: number | null) => { @@ -222,11 +223,24 @@ export const Header: React.FC = () => { return sessions.find((s) => s.id === currentSessionId) ?? null; }, [currentSessionId, sessions]); + const worktreePath = useSessionStore((state) => { + if (!currentSessionId) return ''; + return state.worktreeMetadata.get(currentSessionId)?.path ?? ''; + }); + + const worktreeDirectory = React.useMemo(() => { + return normalize(worktreePath || ''); + }, [worktreePath]); + const sessionDirectory = React.useMemo(() => { const raw = typeof currentSession?.directory === 'string' ? currentSession.directory : ''; return normalize(raw || ''); }, [currentSession?.directory]); + const openDirectory = React.useMemo(() => { + return worktreeDirectory || sessionDirectory; + }, [sessionDirectory, worktreeDirectory]); + const [planTabAvailable, setPlanTabAvailable] = React.useState(false); const showPlanTab = planTabAvailable; @@ -627,6 +641,7 @@ export const Header: React.FC = () => {
+ {isDesktopApp && ( )} diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index ad4ec130..626fc6fb 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { toast } from 'sonner'; +import { toast } from '@/components/ui'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import type { GitHubAuthStatus } from '@/lib/api/types'; diff --git a/packages/ui/src/components/ui/toast.ts b/packages/ui/src/components/ui/toast.ts index c9ba3543..002fa69e 100644 --- a/packages/ui/src/components/ui/toast.ts +++ b/packages/ui/src/components/ui/toast.ts @@ -1,5 +1,6 @@ "use client" +import { isValidElement } from "react" import { toast as sonnerToast } from "sonner" import type { ExternalToast } from "sonner" @@ -11,6 +12,38 @@ const copyToClipboard = async (text: string) => { } } +const reactNodeToText = (value: React.ReactNode): string => { + if (value == null || typeof value === "boolean") { + return "" + } + if (typeof value === "string" || typeof value === "number") { + return String(value) + } + if (Array.isArray(value)) { + return value.map(reactNodeToText).join(" ").trim() + } + if (isValidElement(value)) { + const element = value as React.ReactElement<{ children?: React.ReactNode }> + return reactNodeToText(element.props?.children) + } + return "" +} + +const resolveToastDescription = (description: ExternalToast["description"]): React.ReactNode => { + if (typeof description === "function") { + return description() + } + return description +} + +const getToastCopyText = (message: string | React.ReactNode, data?: ExternalToast): string => { + const descriptionText = reactNodeToText(resolveToastDescription(data?.description)) + if (descriptionText.length > 0) { + return descriptionText + } + return reactNodeToText(message) +} + // Wrapper to automatically add OK button to success and info toasts, Copy button to error and warning toasts export const toast = { ...sonnerToast, @@ -37,7 +70,7 @@ export const toast = { ...data, action: data?.action || { label: 'Copy', - onClick: () => copyToClipboard(String(message)), + onClick: () => copyToClipboard(getToastCopyText(message, data)), }, }) }, @@ -46,8 +79,8 @@ export const toast = { ...data, action: data?.action || { label: 'Copy', - onClick: () => copyToClipboard(String(message)), + onClick: () => copyToClipboard(getToastCopyText(message, data)), }, }) }, -} \ No newline at end of file +} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index b4e0595d..67e65515 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -451,6 +451,7 @@ export interface SettingsPayload { diffViewMode?: 'single' | 'stacked'; directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; + openInAppId?: string; [key: string]: unknown; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 1df46bb1..7c4720c8 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -59,6 +59,7 @@ export type DesktopSettings = { defaultVariant?: string; defaultAgent?: string; defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id + openInAppId?: string; autoCreateWorktree?: boolean; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; @@ -322,3 +323,140 @@ export const restartToApplyUpdate = async (): Promise => { return false; } }; + +export const openDesktopPath = async (path: string, app?: string | null): Promise => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return false; + } + + const trimmed = path?.trim(); + if (!trimmed) { + return false; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + await tauri?.core?.invoke?.('desktop_open_path', { + path: trimmed, + app: typeof app === 'string' && app.trim().length > 0 ? app.trim() : undefined, + }); + return true; + } catch (error) { + console.warn('Failed to open path (tauri)', error); + return false; + } +}; + +export const filterInstalledDesktopApps = async (apps: string[]): Promise => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return []; + } + + const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; + if (candidate.length === 0) { + return []; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const result = await tauri?.core?.invoke?.('desktop_filter_installed_apps', { + apps: candidate, + }); + return Array.isArray(result) ? result.filter((value) => typeof value === 'string') : []; + } catch (error) { + console.warn('Failed to check installed apps (tauri)', error); + return []; + } +}; + +export const fetchDesktopAppIcons = async (apps: string[]): Promise> => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return {}; + } + + const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; + if (candidate.length === 0) { + return {}; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const result = await tauri?.core?.invoke?.('desktop_fetch_app_icons', { + apps: candidate, + }); + if (!Array.isArray(result)) { + return {}; + } + const map: Record = {}; + for (const entry of result) { + if (!entry || typeof entry !== 'object') continue; + const candidateEntry = entry as { app?: unknown; data_url?: unknown }; + if (typeof candidateEntry.app !== 'string' || typeof candidateEntry.data_url !== 'string') continue; + map[candidateEntry.app] = candidateEntry.data_url; + } + return map; + } catch (error) { + console.warn('Failed to fetch installed app icons (tauri)', error); + return {}; + } +}; + +export type InstalledDesktopAppInfo = { + name: string; + iconDataUrl?: string | null; +}; + +export type FetchDesktopInstalledAppsResult = { + apps: InstalledDesktopAppInfo[]; + success: boolean; + hasCache: boolean; + isCacheStale: boolean; +}; + +export const fetchDesktopInstalledApps = async ( + apps: string[], + force?: boolean +): Promise => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return { apps: [], success: false, hasCache: false, isCacheStale: false }; + } + + const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; + if (candidate.length === 0) { + return { apps: [], success: true, hasCache: false, isCacheStale: false }; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const result = await tauri?.core?.invoke?.('desktop_get_installed_apps', { + apps: candidate, + force: force === true ? true : undefined, + }); + if (!result || typeof result !== 'object') { + return { apps: [], success: false, hasCache: false, isCacheStale: false }; + } + const payload = result as { apps?: unknown; hasCache?: unknown; isCacheStale?: unknown }; + if (!Array.isArray(payload.apps)) { + return { apps: [], success: false, hasCache: false, isCacheStale: false }; + } + const installedApps = payload.apps + .filter((entry) => entry && typeof entry === 'object') + .map((entry) => { + const record = entry as { name?: unknown; iconDataUrl?: unknown }; + return { + name: typeof record.name === 'string' ? record.name : '', + iconDataUrl: typeof record.iconDataUrl === 'string' ? record.iconDataUrl : null, + }; + }) + .filter((entry) => entry.name.length > 0); + return { + apps: installedApps, + success: true, + hasCache: payload.hasCache === true, + isCacheStale: payload.isCacheStale === true, + }; + } catch (error) { + console.warn('Failed to fetch installed apps (tauri)', error); + return { apps: [], success: false, hasCache: false, isCacheStale: false }; + } +}; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 7460e2ad..2ab26988 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -71,6 +71,11 @@ const persistToLocalStorage = (settings: DesktopSettings) => { if (typeof settings.filesViewShowGitignored === 'boolean') { localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false'); } + if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) { + localStorage.setItem('openInAppId', settings.openInAppId); + } else { + localStorage.removeItem('openInAppId'); + } }; type PersistApi = { @@ -471,6 +476,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.filesViewShowGitignored === 'boolean') { result.filesViewShowGitignored = candidate.filesViewShowGitignored; } + if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) { + result.openInAppId = candidate.openInAppId; + } if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { result.memoryLimitHistorical = candidate.memoryLimitHistorical;