Files
openchamber/packages/desktop/src-tauri/src/commands/files.rs
T

1080 lines
32 KiB
Rust

use crate::path_utils::expand_tilde_path;
use crate::{DesktopRuntime, SettingsStore};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet, VecDeque},
path::{Path, PathBuf},
process::Command,
sync::OnceLock,
time::UNIX_EPOCH,
};
use tokio::fs;
const DEFAULT_FILE_SEARCH_LIMIT: usize = 60;
const MAX_FILE_SEARCH_LIMIT: usize = 400;
const FILE_SEARCH_MAX_CONCURRENCY: usize = 5;
const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[
"node_modules",
".git",
"dist",
"build",
".next",
".turbo",
".cache",
"coverage",
"tmp",
"logs",
];
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileListEntry {
name: String,
path: String,
is_directory: bool,
is_file: bool,
is_symbolic_link: bool,
size: Option<u64>,
modified_time: Option<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryListResult {
directory: String,
path: String,
entries: Vec<FileListEntry>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectoryResponse {
success: bool,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSearchHit {
name: String,
path: String,
relative_path: String,
extension: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFilesResponse {
root: String,
count: usize,
files: Vec<FileSearchHit>,
}
#[derive(Debug)]
enum FsCommandError {
NotFound,
AccessDenied,
NotDirectory,
OutsideWorkspace,
Other(String),
}
impl FsCommandError {
fn to_list_message(&self) -> String {
match self {
FsCommandError::NotFound => "Directory not found".to_string(),
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to list directory".to_string()
}
}
}
fn to_search_message(&self) -> String {
match self {
FsCommandError::NotFound => "Directory not found".to_string(),
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to search files".to_string()
}
}
}
fn to_create_message(&self) -> String {
match self {
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Parent path must be a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to create directory".to_string()
}
FsCommandError::NotFound => "Parent directory not found".to_string(),
}
}
}
impl From<std::io::Error> for FsCommandError {
fn from(error: std::io::Error) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => FsCommandError::NotFound,
std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied,
_ => FsCommandError::Other(error.to_string()),
}
}
}
#[tauri::command]
pub async fn list_directory(
path: Option<String>,
respect_gitignore: Option<bool>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<DirectoryListResult, String> {
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_list_message())?;
let metadata = fs::metadata(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
if !metadata.is_dir() {
return Err(FsCommandError::NotDirectory.to_list_message());
}
// Re-check boundary after canonicalization to guard against traversal
if !workspace_roots.is_empty()
&& !workspace_roots
.iter()
.any(|root| resolved_path.starts_with(root))
{
return Err(FsCommandError::OutsideWorkspace.to_list_message());
}
let mut entries = Vec::new();
let mut dir_entries = fs::read_dir(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
// Collect all entry names first for gitignore check
let mut all_entries: Vec<(tokio::fs::DirEntry, String)> = Vec::new();
while let Some(entry) = dir_entries
.next_entry()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?
{
let name = entry.file_name().to_string_lossy().to_string();
all_entries.push((entry, name));
}
// Get gitignored paths if requested
let ignored_names: HashSet<String> = if respect_gitignore.unwrap_or(false) {
let names: Vec<String> = all_entries.iter().map(|(_, name)| name.clone()).collect();
if names.is_empty() {
HashSet::new()
} else {
let cwd = resolved_path.clone();
tokio::task::spawn_blocking(move || {
let output = Command::new("git")
.arg("check-ignore")
.arg("--")
.args(&names)
.current_dir(&cwd)
.output();
match output {
Ok(out) => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
Err(_) => HashSet::new(),
}
})
.await
.unwrap_or_default()
}
} else {
HashSet::new()
};
for (entry, name) in all_entries {
// Skip gitignored entries
if !ignored_names.is_empty() && ignored_names.contains(&name) {
continue;
}
let file_type = entry
.file_type()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
let entry_path = entry.path();
let mut is_directory = file_type.is_dir();
let is_symlink = file_type.is_symlink();
if !is_directory && is_symlink {
if let Ok(link_meta) = fs::metadata(&entry_path).await {
is_directory = link_meta.is_dir();
}
}
let metadata = fs::metadata(&entry_path).await.ok();
let size = metadata
.as_ref()
.filter(|meta| meta.is_file())
.map(|meta| meta.len());
let modified_time = metadata
.and_then(|meta| meta.modified().ok())
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as i64);
entries.push(FileListEntry {
name,
path: normalize_path(&entry_path),
is_directory,
is_file: file_type.is_file(),
is_symbolic_link: is_symlink,
size,
modified_time,
});
}
Ok(DirectoryListResult {
directory: normalize_path(&resolved_path),
path: normalize_path(&resolved_path),
entries,
})
}
struct ScoredFileHit {
hit: FileSearchHit,
score: i32,
}
#[tauri::command]
pub async fn search_files(
directory: Option<String>,
query: Option<String>,
max_results: Option<usize>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<SearchFilesResponse, String> {
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_search_message())?;
let limit = clamp_search_limit(max_results);
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
let match_all = normalized_query.is_empty();
// Collect more candidates for fuzzy matching, then sort and trim
let collect_limit = if match_all {
limit
} else {
(limit * 3).max(200)
};
let mut candidates: Vec<ScoredFileHit> = Vec::new();
let mut queue = VecDeque::new();
let mut visited = HashSet::new();
queue.push_back(resolved_root.clone());
visited.insert(resolved_root.clone());
while !queue.is_empty() && candidates.len() < collect_limit {
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
let Some(dir) = queue.pop_front() else {
break;
};
let mut entries = match fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(_) => continue,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(file_type) = entry.file_type().await else {
continue;
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.is_empty() || name_str.starts_with('.') {
continue;
}
let entry_path = entry.path();
if file_type.is_dir() {
if should_skip_directory(&name_str) {
continue;
}
if visited.insert(entry_path.clone()) && candidates.len() < collect_limit {
queue.push_back(entry_path);
}
continue;
}
if !file_type.is_file() {
continue;
}
let relative_path = relative_path(&resolved_root, &entry_path);
let extension = entry_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase());
let hit = FileSearchHit {
name: name_str.to_string(),
path: normalize_path(&entry_path),
relative_path: relative_path.replace('\\', "/"),
extension,
};
if match_all {
candidates.push(ScoredFileHit { hit, score: 0 });
} else {
// Try fuzzy match against relative path (includes filename)
if let Some(score) = fuzzy_match_score(&normalized_query, &relative_path) {
candidates.push(ScoredFileHit { hit, score });
}
}
if candidates.len() >= collect_limit {
break;
}
}
if candidates.len() >= collect_limit {
break;
}
}
}
// Sort by score descending, then by path length, then alphabetically
if !match_all {
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => {
match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path),
other => other,
}
}
other => other,
});
}
let files: Vec<FileSearchHit> = candidates
.into_iter()
.take(limit)
.map(|scored| scored.hit)
.collect();
Ok(SearchFilesResponse {
root: normalize_path(&resolved_root),
count: files.len(),
files,
})
}
#[tauri::command]
pub async fn create_directory(
path: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<CreateDirectoryResponse, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Path is required".to_string());
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_create_message())?;
fs::create_dir_all(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_create_message())?;
Ok(CreateDirectoryResponse {
success: true,
path: normalize_path(&resolved_path),
})
}
async fn resolve_sandboxed_path(
path: Option<String>,
workspace_roots: &[PathBuf],
default_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> {
let candidate_input = path
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let fallback_root = default_root
.or_else(|| workspace_roots.first())
.cloned()
.unwrap_or_else(default_home_directory);
let candidate_path = match candidate_input {
Some(value) => expand_tilde_path(value),
None => fallback_root.clone(),
};
let resolved = if candidate_path.is_absolute() {
candidate_path
} else {
fallback_root.join(candidate_path)
};
let canonicalized = fs::canonicalize(&resolved)
.await
.map_err(FsCommandError::from)?;
if !workspace_roots.is_empty()
&& !workspace_roots
.iter()
.any(|root| canonicalized.starts_with(root))
{
return Err(FsCommandError::OutsideWorkspace);
}
Ok(canonicalized)
}
async fn resolve_creatable_path(
path: &str,
workspace_roots: &[PathBuf],
default_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> {
let candidate = expand_tilde_path(path);
if candidate.as_os_str().is_empty() {
return Err(FsCommandError::Other("Path is required".to_string()));
}
let fallback_root = default_root
.or_else(|| workspace_roots.first())
.cloned()
.unwrap_or_else(default_home_directory);
let absolute = if candidate.is_absolute() {
candidate
} else {
fallback_root.join(candidate)
};
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
let canonical_parent = fs::canonicalize(parent)
.await
.map_err(FsCommandError::from)?;
if !workspace_roots.is_empty()
&& !workspace_roots
.iter()
.any(|root| canonical_parent.starts_with(root))
{
return Err(FsCommandError::OutsideWorkspace);
}
Ok(absolute)
}
async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec<PathBuf>, Option<PathBuf>) {
let mut roots: Vec<PathBuf> = Vec::new();
let mut default_root: Option<PathBuf> = None;
let settings_value = settings.load().await.ok();
if let Some(value) = settings_value.as_ref() {
if let Some(active_id) = value.get("activeProjectId").and_then(|v| v.as_str()) {
if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) {
if let Some(active_path) = projects.iter().find_map(|entry| {
let id = entry.get("id").and_then(|v| v.as_str())?;
if id != active_id {
return None;
}
entry.get("path").and_then(|v| v.as_str())
}) {
if let Ok(canonicalized) =
fs::canonicalize(expand_tilde_path(active_path)).await
{
default_root = Some(canonicalized.clone());
roots.push(canonicalized);
}
}
}
}
if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) {
for entry in projects {
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(path)).await {
roots.push(canonicalized);
}
}
}
}
if let Some(last_dir) = value.get("lastDirectory").and_then(|v| v.as_str()) {
if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(last_dir)).await {
if default_root.is_none() {
default_root = Some(canonicalized.clone());
}
roots.push(canonicalized);
}
}
}
if default_root.is_none() {
if let Ok(Some(last_dir)) = settings.last_directory().await {
if let Ok(canonicalized) = fs::canonicalize(last_dir).await {
default_root = Some(canonicalized);
}
}
}
let mut deduped: Vec<PathBuf> = Vec::new();
for root in roots {
if !deduped.iter().any(|existing| existing == &root) {
deduped.push(root);
}
}
(deduped, default_root)
}
fn default_home_directory() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
fn clamp_search_limit(value: Option<usize>) -> usize {
let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT);
limit.clamp(1, MAX_FILE_SEARCH_LIMIT)
}
fn should_skip_directory(name: &str) -> bool {
if name.starts_with('.') {
return true;
}
FILE_SEARCH_EXCLUDED_DIRS
.iter()
.any(|dir| dir.eq_ignore_ascii_case(name))
}
/// Fuzzy match scoring function.
/// Returns Some(score) if the query fuzzy-matches the candidate, None otherwise.
/// Higher scores indicate better matches.
fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
if query.is_empty() {
return Some(0);
}
let q: Vec<char> = query.to_lowercase().chars().collect();
let c: Vec<char> = candidate.to_lowercase().chars().collect();
let c_str = candidate.to_lowercase();
// Fast path: exact substring match gets high score
if c_str.contains(query) {
if let Some(idx) = c_str.find(query) {
let mut bonus: i32 = 0;
if idx == 0 {
bonus = 20;
} else if let Some(prev) = c.get(idx.saturating_sub(1)) {
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' {
bonus = 15;
}
}
return Some(100 + bonus - (idx.min(20) as i32) - (c.len() as i32 / 5));
}
}
// Fuzzy match: all query chars must appear in order
let mut score: i32 = 0;
let mut last_index: i32 = -1;
let mut consecutive: i32 = 0;
for ch in &q {
if *ch == ' ' {
continue;
}
let search_start = if last_index < 0 {
0
} else {
(last_index + 1) as usize
};
let idx = c[search_start..].iter().position(|&c_char| c_char == *ch);
match idx {
None => return None, // No match
Some(relative_idx) => {
let idx = search_start + relative_idx;
let gap = idx as i32 - last_index - 1;
if gap == 0 {
consecutive += 1;
} else {
consecutive = 0;
}
score += 10;
score += (18 - idx as i32).max(0); // Prefer matches near start
score -= gap.min(10); // Penalize gaps
// Bonus for word boundary matches
if idx == 0 {
score += 12;
} else if let Some(prev) = c.get(idx - 1) {
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' '
{
score += 10;
}
}
score += if consecutive > 0 { 12 } else { 0 }; // Bonus for consecutive matches
last_index = idx as i32;
}
}
}
// Prefer shorter paths
score += (24 - c.len() as i32 / 3).max(0);
Some(score)
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn relative_path(root: &Path, target: &Path) -> String {
target
.strip_prefix(root)
.map(|relative| normalize_path(relative))
.unwrap_or_else(|_| normalize_path(target))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadFileResponse {
content: String,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadFileBinaryResponse {
data_url: String,
path: String,
}
fn get_image_mime_type(file_path: &str) -> &'static str {
let lower = file_path.to_lowercase();
if lower.ends_with(".png") {
return "image/png";
}
if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
return "image/jpeg";
}
if lower.ends_with(".gif") {
return "image/gif";
}
if lower.ends_with(".svg") {
return "image/svg+xml";
}
if lower.ends_with(".webp") {
return "image/webp";
}
if lower.ends_with(".ico") {
return "image/x-icon";
}
if lower.ends_with(".bmp") {
return "image/bmp";
}
if lower.ends_with(".avif") {
return "image/avif";
}
"application/octet-stream"
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileResponse {
success: bool,
path: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandResult {
command: String,
success: bool,
exit_code: Option<i32>,
stdout: Option<String>,
stderr: Option<String>,
error: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecCommandsResponse {
success: bool,
results: Vec<CommandResult>,
}
#[tauri::command]
pub async fn read_file(
path: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<ReadFileResponse, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Path is required".to_string());
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
.map_err(|_| "File not found".to_string())?;
if !metadata.is_file() {
return Err("Specified path is not a file".to_string());
}
let content = fs::read_to_string(&resolved_path)
.await
.map_err(|err| format!("Failed to read file: {}", err))?;
Ok(ReadFileResponse {
content,
path: normalize_path(&resolved_path),
})
}
#[tauri::command]
pub async fn read_file_binary(
path: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<ReadFileBinaryResponse, String> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
const MAX_BYTES: u64 = 10 * 1024 * 1024;
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Path is required".to_string());
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
.map_err(|_| "File not found".to_string())?;
if !metadata.is_file() {
return Err("Specified path is not a file".to_string());
}
if metadata.len() > MAX_BYTES {
return Err("File too large".to_string());
}
let bytes = fs::read(&resolved_path)
.await
.map_err(|err| format!("Failed to read file: {}", err))?;
let mime_type = get_image_mime_type(trimmed);
let data_url = format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes));
Ok(ReadFileBinaryResponse {
data_url,
path: normalize_path(&resolved_path),
})
}
#[tauri::command]
pub async fn write_file(
path: String,
content: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<WriteFileResponse, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Path is required".to_string());
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_create_message())?;
// Ensure parent directory exists
if let Some(parent) = resolved_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|err| format!("Failed to create parent directory: {}", err))?;
}
fs::write(&resolved_path, content)
.await
.map_err(|err| format!("Failed to write file: {}", err))?;
Ok(WriteFileResponse {
success: true,
path: normalize_path(&resolved_path),
})
}
static CACHED_LOGIN_SHELL_PATH: OnceLock<Option<String>> = OnceLock::new();
#[cfg(target_os = "macos")]
fn get_user_shell() -> Option<String> {
let username =
dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
let output = Command::new("dscl")
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.split(':').nth(1).map(|s| s.trim().to_string())
} else {
None
}
}
#[cfg(all(unix, not(target_os = "macos")))]
fn get_user_shell() -> Option<String> {
std::env::var("SHELL").ok()
}
#[cfg(not(unix))]
fn get_user_shell() -> Option<String> {
None
}
fn build_shell_path_command(shell: &str) -> Vec<String> {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("sh");
match shell_name {
"nu" | "nushell" => vec![
"-l".to_string(),
"-i".to_string(),
"-c".to_string(),
"echo $\"__PATH__=($env.PATH | str join (char esep))\"".to_string(),
],
"bash" => vec![
"-lic".to_string(),
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(),
],
"fish" => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"".to_string(),
],
_ => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"".to_string(),
],
}
}
fn detect_login_shell_path() -> Option<String> {
#[cfg(not(unix))]
{
None
}
#[cfg(unix)]
{
let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into());
let args = build_shell_path_command(&shell);
let output = match Command::new(&shell).args(&args).output() {
Ok(o) => o,
Err(_) => return None,
};
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("__PATH__=") {
if !path.is_empty() {
return Some(path.to_string());
}
}
}
None
}
}
fn get_cached_login_shell_path() -> Option<&'static String> {
CACHED_LOGIN_SHELL_PATH
.get_or_init(detect_login_shell_path)
.as_ref()
}
fn merge_paths(login_path: &str, current: &str) -> String {
let mut segments = Vec::new();
let mut seen = HashSet::new();
for part in login_path.split(':').chain(current.split(':')) {
if !part.is_empty() && !seen.contains(part) {
seen.insert(part.to_string());
segments.push(part);
}
}
segments.join(":")
}
fn build_augmented_env() -> HashMap<String, String> {
let mut env: HashMap<String, String> = std::env::vars().collect();
if let Some(login_path) = get_cached_login_shell_path() {
let current = env.get("PATH").cloned().unwrap_or_default();
env.insert("PATH".to_string(), merge_paths(login_path, &current));
}
env
}
#[tauri::command]
pub async fn exec_commands(
commands: Vec<String>,
cwd: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<ExecCommandsResponse, String> {
if commands.is_empty() {
return Err("Commands array is required".to_string());
}
let cwd_trimmed = cwd.trim();
if cwd_trimmed.is_empty() {
return Err("Working directory (cwd) is required".to_string());
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "Working directory not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_cwd)
.await
.map_err(|_| "Working directory not found".to_string())?;
if !metadata.is_dir() {
return Err("Specified cwd is not a directory".to_string());
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
if cfg!(windows) {
"cmd.exe".to_string()
} else {
"/bin/sh".to_string()
}
});
let shell_flag = if cfg!(windows) { "/c" } else { "-c" };
let augmented_env = build_augmented_env();
let mut results = Vec::new();
for cmd in commands {
let cmd_trimmed = cmd.trim();
if cmd_trimmed.is_empty() {
results.push(CommandResult {
command: cmd.clone(),
success: false,
exit_code: None,
stdout: None,
stderr: None,
error: Some("Invalid command".to_string()),
});
continue;
}
let cwd_clone = resolved_cwd.clone();
let shell_clone = shell.clone();
let cmd_clone = cmd_trimmed.to_string();
let env_clone = augmented_env.clone();
// Run command synchronously in blocking task
let result = tokio::task::spawn_blocking(move || {
match Command::new(&shell_clone)
.arg(shell_flag)
.arg(&cmd_clone)
.current_dir(&cwd_clone)
.envs(&env_clone)
.output()
{
Ok(output) => CommandResult {
command: cmd_clone,
success: output.status.success(),
exit_code: output.status.code(),
stdout: Some(String::from_utf8_lossy(&output.stdout).trim().to_string()),
stderr: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()),
error: None,
},
Err(err) => CommandResult {
command: cmd_clone,
success: false,
exit_code: None,
stdout: None,
stderr: None,
error: Some(err.to_string()),
},
}
})
.await
.unwrap_or_else(|err| CommandResult {
command: cmd.clone(),
success: false,
exit_code: None,
stdout: None,
stderr: None,
error: Some(format!("Task failed: {}", err)),
});
results.push(result);
}
let all_succeeded = results.iter().all(|r| r.success);
Ok(ExecCommandsResponse {
success: all_succeeded,
results,
})
}