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 <artmore@protonmail.com>
This commit is contained in:
Iuliia Ivashko
2026-02-08 04:32:14 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9534e3d016
commit b432437b02
14 changed files with 1140 additions and 9 deletions
+1
View File
@@ -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.
+5 -4
View File
@@ -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",
},
+1
View File
@@ -2672,6 +2672,7 @@ name = "openchamber-desktop"
version = "1.6.5"
dependencies = [
"anyhow",
"base64 0.22.1",
"log",
"reqwest",
"serde",
+1
View File
@@ -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"] }
+519 -1
View File
@@ -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<R: tauri::Runtime>(app: &tauri::AppHandle<R>, 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<String>) -> 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<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InstalledAppsCache {
updated_at: u64,
apps: Vec<InstalledAppInfo>,
}
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<InstalledAppInfo>,
has_cache: bool,
is_cache_stale: bool,
}
#[tauri::command]
fn desktop_filter_installed_apps(apps: Vec<String>) -> Result<Vec<String>, String> {
#[cfg(target_os = "macos")]
{
let mut installed: Vec<String> = 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<String>,
force: Option<bool>,
) -> Result<InstalledAppsResponse, String> {
#[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<String> = 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<String, String> = 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<String> = 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<String, String> = 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<String> = 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<String>) -> Result<Vec<AppIconPayload>, String> {
#[cfg(target_os = "macos")]
{
let mut results: Vec<AppIconPayload> = 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<PathBuf> {
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<InstalledAppsCache> {
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<String, String>,
force_icon_refresh: bool,
) -> Vec<InstalledAppInfo> {
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<PathBuf> {
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<String> {
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<String> {
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,
+1
View File
@@ -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",
+10
View File
@@ -56,6 +56,7 @@ function App({ apis }: AppProps) {
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => 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
@@ -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,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAB+C9pSAAAACXBIWXMAABYlAAAWJQFJUiTwAAABnWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4yNTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MjU2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cl6wHhsAAAXaSURBVFgJ7VddbBRVFP5mdme6dOnu2tZawOAPjfxU+bGQYCRAsvw8qNGEQPTRJ0I0amL0wfjgA/HBR8KLD8YgDxJEUkVFxSYYTSRii6DQQCMGIQqlW7p0u+zMzo/fuTuzO9Nt0Td94CRn7pl7z5zznZ977y5wh/7jDGiz+T979qD5Ujbfd90xlll+stOF1uI40B1+4HhkjnZk9CgLQ9iXp2/BdcbgVc/h0sAgduywudJEMwLY9Of4ugtW5p3CpL7W1jTN88VmjdQYvnDKF1mczkYuNZLeCVg3X8fa9u+nqzUB2HRpdN2pSseRQknPoUL1Jo2ICTrPGcCzdwPdHENcAnicKRqcAk7cpL5J1r0JlAtPYV1XDETM/FtH3m19r+f5by+XjNX/xnmCX3/cCzydi4CKiC7lw+PArhGgoPPFq/6E0+9vwM6d5VBNpuv03cLNfeNTRh9KnJIiV2/PvSngycC5RD+dE5zb3g7s6QESzAZc2l6wuY9SnWIAxv10r81uU85Vt1FvtpEtlc/SMFUkUofeZ2IBta0DWDmXgkfbyTRz1qAYAMczOz3p1elOxYPyEllj421hdELViPO6Kudk3ia3UGe5ABDbvtnJZ52SdYmCZ3stdeexBabFdeAbYopEowtagVUZqFapBrtAGqpiVaFrGgyjZlrmTD5yEqoEJj4iFMuA62i6L3WPZkAiuHgarZ/vbWSBkTzO2rfTR4XOJVJhjfX44MBn+OTocVWbcF5MalxXPeVL6zYonoGo44YOtDI7qHC1lkL5nHnOc+tJRi3K6iygLNGMjt1A1XVV6iUzOvVtAvMlS2I/yBYlRf8MgA6szmXQ1jDfKhSgjft6DRtrkgarAiAw5nI9v2WDSn+Zxfd9DawGxIlPPQUg0A2HGABfEIYlCDU4+q0d8O+jRzHCCFYy+nu4BaeYAoksBCDrPYsXQQ6iitgiSQaS1FHHtMzFil4DpxTl4UhORSn4WOaaiGsbu4iFRkMnYQlEV0oSJQGQ4FyYgSRDjpqPZcCR6EOOWonIEsBqArAIQOMLzw0VXRRERF2VoA6Atk1+MzsASekMJYgaFEeHR4Cr85lNGntYzgKCYd/NSNIDCXr0ZJ2jwTsjSvEMzFQCCVmKHBRahn2DNb4rDRx8pnbXOOIg0JELLMHOF1AUkaRj1V8c2TookkMS83WK9QCVpRwtf5wCykQWRKDyJ44Ytc452QUV6inmN9IDIv/6y2+YLDuqTywBEHxv8rsoxQC4Fpf4cZ2pbJ4/huxXr0EvFmoRCrAIVymLQ3Eid0GJYPsPfISBLwdwi79YQnCqBNS7LQDP5qYSAKEDypOrX4WVWYLsFy+i9cwh6CUmUKIJI2Gq5cSbnLLw849D2Ld3L4olC1u3P0c1ow5Ozgixa3puWChONG1D3eLZUQOglvng+Vp5dBfseesx5/yHyI4cBTL3wsssRGs2g6/ppHijiMLoNSSMNHofy6Nn6SPsAR02nUoTtrDTSrdoi8CTni55rlOsCf1ypaDxlFMNU1epCV5XL6Y6dmOq+BeS48NIlq7Anpjg5dOFbPdDWLQyj/aubnUKSkMKi3NhkUd4kieYtbRbYS0bFAOQKI8NO363z1RJHmamtnlwhGksxV2w/gl29WRtm8kWtWUnRShLnQvXgDOXmLg2HzlvbDiyHD8Y517YP2i4FtueFPbB9FFqKcyobk4A5y7zquUFa7IXojyHoeXmAFcY755vaI6A56Xsofm/7+cmblBTpOldQ5vs3PJDVS+RVSAaus2SpJTO80t4NTNSOQfCDrtFkBevA0ME6HGvPdDpFlekzm7rf3nFQNRQEwBZTL9warObWfx21Uv1+fx1ERqVNampGoOHpF1tsdp07RnoGMxK1vT97rbK4IP6+Tc+fWXVsahaYGL6VO09d//GXHXr7jVeqmuppqU6ff4x0RO6lqRxgxHJpWKSlcw5eWfjq5rq/CdhaL5l6JWxjDc6bP7w5sn+/uMs2B36H2bgb6v9raK0+o9IAAAAAElFTkSuQmCC';
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 (
<img
src={src}
alt=""
className="h-4 w-4 rounded-sm"
onError={() => setFailed(true)}
/>
);
}
return (
<span
className={cn(
'h-4 w-4 rounded-sm flex items-center justify-center',
'bg-[var(--surface-muted)] text-[9px] font-medium text-muted-foreground'
)}
>
{initial}
</span>
);
};
type OpenInAppButtonProps = {
directory: string;
className?: string;
};
export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) => {
const [selectedAppId, setSelectedAppId] = React.useState(getStoredAppId);
const [availableApps, setAvailableApps] = React.useState<OpenInAppOption[]>(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<ReturnType<typeof setTimeout> | null>(null);
const retryAttemptRef = React.useRef(0);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).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<InstalledDesktopAppInfo[]>).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 (
<div
className={cn(
'app-region-no-drag inline-flex h-7 items-center self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-sm overflow-hidden',
className
)}
>
<button
type="button"
onClick={() => void handleOpen(selectedApp)}
className={cn(
'inline-flex h-full items-center gap-2 px-3 typography-ui-label font-medium',
'text-foreground hover:bg-interactive-hover transition-colors'
)}
aria-label={`Open in ${selectedApp.label}`}
>
<AppIcon
label={selectedApp.label}
iconDataUrl={selectedApp.iconDataUrl}
fallbackIconDataUrl={selectedApp.fallbackIconDataUrl}
/>
<span className={cn(isScanning ? 'animate-pulse text-muted-foreground' : undefined)}>Open</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-full w-7 items-center justify-center',
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors'
)}
aria-label="Choose app to open"
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 max-h-[70vh] overflow-y-auto">
<DropdownMenuItem className="flex items-center gap-2" onClick={() => void handleCopyPath()}>
<RiFileCopyLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Copy Path</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{availableApps.map((app) => (
<DropdownMenuItem
key={app.id}
className="flex items-center gap-2"
onClick={() => void handleSelect(app)}
>
<AppIcon
label={app.label}
iconDataUrl={app.iconDataUrl}
fallbackIconDataUrl={app.fallbackIconDataUrl}
/>
<span className="typography-ui-label text-foreground">{app.label}</span>
{selectedApp.id === app.id ? (
<RiCheckLine className="ml-auto h-4 w-4 text-primary" />
) : null}
</DropdownMenuItem>
))}
{isCacheStale ? (
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => void loadInstalledApps(true)}
>
<RiRefreshLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Refresh Apps</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
@@ -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 = () => {
<div className="flex-1" />
<div className="flex items-center gap-1 pr-3">
<OpenInAppButton directory={openDirectory} className="mr-1" />
{isDesktopApp && (
<DesktopHostSwitcherButton headerIconButtonClass={headerIconButtonClass} />
)}
@@ -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';
+36 -3
View File
@@ -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)),
},
})
},
}
}
+1
View File
@@ -451,6 +451,7 @@ export interface SettingsPayload {
diffViewMode?: 'single' | 'stacked';
directoryShowHidden?: boolean;
filesViewShowGitignored?: boolean;
openInAppId?: string;
[key: string]: unknown;
}
+138
View File
@@ -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<boolean> => {
return false;
}
};
export const openDesktopPath = async (path: string, app?: string | null): Promise<boolean> => {
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<string[]> => {
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<Record<string, string>> => {
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<string, string> = {};
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<FetchDesktopInstalledAppsResult> => {
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 };
}
};
+8
View File
@@ -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;