Initial public release
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
use crate::{DesktopRuntime, SettingsStore};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
path::{Path, PathBuf},
|
||||
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>,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryListResult, String> {
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(path, workspace_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 let Some(root) = &workspace_root {
|
||||
if !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())?;
|
||||
|
||||
while let Some(entry) = dir_entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_list_message())?
|
||||
{
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||
|
||||
let entry_path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
#[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_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_root = resolve_sandboxed_path(directory, workspace_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();
|
||||
|
||||
let mut files = 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() && files.len() < 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()) && files.len() < limit {
|
||||
queue.push_back(entry_path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = relative_path(&resolved_root, &entry_path);
|
||||
if !match_all {
|
||||
let lowercase_name = name_str.to_lowercase();
|
||||
let lowercase_path = relative_path.to_lowercase();
|
||||
if !lowercase_name.contains(&normalized_query)
|
||||
&& !lowercase_path.contains(&normalized_query)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let extension = entry_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase());
|
||||
|
||||
files.push(FileSearchHit {
|
||||
name: name_str.to_string(),
|
||||
path: normalize_path(&entry_path),
|
||||
relative_path: relative_path.replace('\\', "/"),
|
||||
extension,
|
||||
});
|
||||
|
||||
if files.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if files.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_path = resolve_creatable_path(trimmed, workspace_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_root: Option<&PathBuf>,
|
||||
) -> Result<PathBuf, FsCommandError> {
|
||||
let candidate_input = path
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let candidate_path = match (candidate_input, workspace_root) {
|
||||
(Some(value), _) => PathBuf::from(value),
|
||||
(None, Some(root)) => root.clone(),
|
||||
(None, None) => default_home_directory(),
|
||||
};
|
||||
|
||||
let resolved = if candidate_path.is_absolute() {
|
||||
candidate_path
|
||||
} else if let Some(root) = workspace_root {
|
||||
root.join(candidate_path)
|
||||
} else {
|
||||
default_home_directory().join(candidate_path)
|
||||
};
|
||||
|
||||
let canonicalized = fs::canonicalize(&resolved)
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
if let Some(root) = workspace_root {
|
||||
if !canonicalized.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(canonicalized)
|
||||
}
|
||||
|
||||
async fn resolve_creatable_path(
|
||||
path: &str,
|
||||
workspace_root: Option<&PathBuf>,
|
||||
) -> Result<PathBuf, FsCommandError> {
|
||||
let candidate = PathBuf::from(path);
|
||||
if candidate.as_os_str().is_empty() {
|
||||
return Err(FsCommandError::Other("Path is required".to_string()));
|
||||
}
|
||||
|
||||
let absolute = if candidate.is_absolute() {
|
||||
candidate
|
||||
} else if let Some(root) = workspace_root {
|
||||
root.join(candidate)
|
||||
} else {
|
||||
default_home_directory().join(candidate)
|
||||
};
|
||||
|
||||
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
||||
|
||||
let canonical_parent = fs::canonicalize(parent)
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
if let Some(root) = workspace_root {
|
||||
if !canonical_parent.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(absolute)
|
||||
}
|
||||
|
||||
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
|
||||
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
||||
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
|
||||
return Some(canonicalized);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
use crate::logging::log_file_path;
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopLogFile {
|
||||
pub file_name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
|
||||
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read log file: {err}"))?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("desktop.log")
|
||||
.to_string();
|
||||
|
||||
Ok(DesktopLogFile { file_name, content })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod logs;
|
||||
pub mod permissions;
|
||||
pub mod settings;
|
||||
pub mod terminal;
|
||||
pub mod notifications;
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPayload {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_notify<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: Option<NotificationPayload>,
|
||||
) -> Result<bool, String> {
|
||||
let title = payload
|
||||
.as_ref()
|
||||
.and_then(|p| p.title.as_deref())
|
||||
.unwrap_or("OpenChamber");
|
||||
let body = payload
|
||||
.as_ref()
|
||||
.and_then(|p| p.body.as_deref())
|
||||
.unwrap_or("Task completed");
|
||||
|
||||
match app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(body)
|
||||
.sound("Glass")
|
||||
.show()
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryPermissionRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryPermissionResult {
|
||||
success: bool,
|
||||
path: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartAccessingResult {
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Process directory selection from frontend
|
||||
/// Updates settings with lastDirectory
|
||||
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
|
||||
#[tauri::command]
|
||||
pub async fn process_directory_selection(
|
||||
path: String,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Validate directory exists
|
||||
let path_buf = PathBuf::from(&path);
|
||||
if !path_buf.exists() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with lastDirectory
|
||||
let mut settings = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert(
|
||||
"lastDirectory".to_string(),
|
||||
serde_json::Value::String(path.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.settings()
|
||||
.save(settings)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
||||
|
||||
info!(
|
||||
"[permissions] Updated settings with lastDirectory: {}",
|
||||
path
|
||||
);
|
||||
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(path),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy directory picker command (frontend handles actual dialog)
|
||||
#[tauri::command]
|
||||
pub async fn pick_directory(
|
||||
_app_handle: AppHandle,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some(
|
||||
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Request directory access (desktop implementation)
|
||||
/// For unsandboxed apps, just validates the path is accessible
|
||||
#[tauri::command]
|
||||
pub async fn request_directory_access(
|
||||
request: DirectoryPermissionRequest,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
let path = request.path;
|
||||
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
if !path_buf.exists() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// For unsandboxed apps, no bookmark needed - just verify access
|
||||
match std::fs::read_dir(&path_buf) {
|
||||
Ok(_) => Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(path),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some(format!("Cannot access directory: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start accessing directory (desktop implementation)
|
||||
#[tauri::command]
|
||||
pub async fn start_accessing_directory(
|
||||
path: String,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<StartAccessingResult, String> {
|
||||
// Check if directory exists and is accessible
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
|
||||
if !path_buf.exists() {
|
||||
return Ok(StartAccessingResult {
|
||||
success: false,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(StartAccessingResult {
|
||||
success: false,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to read the directory to verify access
|
||||
match std::fs::read_dir(&path_buf) {
|
||||
Ok(_) => {
|
||||
info!("Successfully started accessing directory: {}", path);
|
||||
Ok(StartAccessingResult {
|
||||
success: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to access directory {}: {}", path, e);
|
||||
Ok(StartAccessingResult {
|
||||
success: false,
|
||||
error: Some(format!("Failed to access directory: {}", e)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop accessing directory (desktop implementation)
|
||||
#[tauri::command]
|
||||
pub async fn stop_accessing_directory(
|
||||
_path: String,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<StartAccessingResult, String> {
|
||||
// For Stage 1, just confirm the operation
|
||||
// Full implementation would call stopAccessingSecurityScopedResource
|
||||
info!("Stopped accessing directory");
|
||||
Ok(StartAccessingResult {
|
||||
success: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
|
||||
#[tauri::command]
|
||||
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
|
||||
// For unsandboxed apps, no bookmarks needed
|
||||
// Directory access is restored from settings.lastDirectory
|
||||
info!("[permissions] Bookmark restore not needed for unsandboxed app");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingsLoadResult {
|
||||
settings: Value,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestartResult {
|
||||
restarted: bool,
|
||||
}
|
||||
|
||||
/// Load settings from disk (matches Express handler behavior)
|
||||
#[tauri::command]
|
||||
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
||||
let settings = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
|
||||
Ok(SettingsLoadResult {
|
||||
settings,
|
||||
source: "desktop".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save settings to disk with merge logic matching Express implementation
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
changes: Value,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<Value, String> {
|
||||
// Load current settings
|
||||
let current = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load current settings: {}", e))?;
|
||||
|
||||
// Sanitize incoming changes
|
||||
let sanitized_changes = sanitize_settings_update(&changes);
|
||||
|
||||
// Merge changes into current settings
|
||||
let merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||
|
||||
// Save merged settings
|
||||
state
|
||||
.settings()
|
||||
.save(merged.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
// Format response
|
||||
Ok(format_settings_response(&merged))
|
||||
}
|
||||
|
||||
/// Restart OpenCode CLI (matches Express /api/config/reload)
|
||||
#[tauri::command]
|
||||
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
||||
state
|
||||
.opencode
|
||||
.restart()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
|
||||
|
||||
Ok(RestartResult { restarted: true })
|
||||
}
|
||||
|
||||
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
||||
fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
|
||||
if let Some(obj) = payload.as_object() {
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// String fields
|
||||
if let Some(Value::String(s)) = obj.get("themeId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("themeId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("themeVariant") {
|
||||
if s == "light" || s == "dark" {
|
||||
result_obj.insert("themeVariant".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("lightThemeId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("lightThemeId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("darkThemeId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("darkThemeId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("lastDirectory") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("lastDirectory".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("homeDirectory".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("uiFont") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("uiFont".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("monoFont") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("monoFont".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean fields
|
||||
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
||||
result_obj.insert("useSystemTheme".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||
result_obj.insert("showReasoningTraces".to_string(), json!(b));
|
||||
}
|
||||
|
||||
// Array fields
|
||||
if let Some(arr) = obj.get("approvedDirectories") {
|
||||
result_obj.insert(
|
||||
"approvedDirectories".to_string(),
|
||||
normalize_string_array(arr),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = obj.get("securityScopedBookmarks") {
|
||||
result_obj.insert(
|
||||
"securityScopedBookmarks".to_string(),
|
||||
normalize_string_array(arr),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = obj.get("pinnedDirectories") {
|
||||
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
|
||||
}
|
||||
|
||||
// Typography sizes object (partial)
|
||||
if let Some(typo) = obj.get("typographySizes") {
|
||||
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
|
||||
result_obj.insert("typographySizes".to_string(), sanitized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Merge persisted settings (port of Express mergePersistedSettings)
|
||||
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
||||
let mut result = current.clone();
|
||||
|
||||
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
|
||||
// First apply all changes
|
||||
for (key, value) in changes_obj {
|
||||
result_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// Build approvedDirectories from base + additional
|
||||
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
|
||||
extract_string_vec(arr)
|
||||
} else if let Some(arr) = current.get("approvedDirectories") {
|
||||
extract_string_vec(arr)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut additional_approved = vec![];
|
||||
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
|
||||
if !s.is_empty() {
|
||||
additional_approved.push(s.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
|
||||
if !s.is_empty() {
|
||||
additional_approved.push(s.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
||||
for item in additional_approved {
|
||||
approved_set.insert(item);
|
||||
}
|
||||
let approved_vec: Vec<String> = approved_set.into_iter().collect();
|
||||
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
|
||||
|
||||
// Security scoped bookmarks
|
||||
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
|
||||
extract_string_vec(arr)
|
||||
} else if let Some(arr) = current.get("securityScopedBookmarks") {
|
||||
extract_string_vec(arr)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
|
||||
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
|
||||
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
|
||||
|
||||
// Merge typography sizes if present
|
||||
if changes_obj.contains_key("typographySizes") {
|
||||
let current_typo = current
|
||||
.get("typographySizes")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let changes_typo = changes_obj
|
||||
.get("typographySizes")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut merged_typo = current_typo;
|
||||
for (key, value) in changes_typo {
|
||||
merged_typo.insert(key, value);
|
||||
}
|
||||
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Format settings response (port of Express formatSettingsResponse)
|
||||
fn format_settings_response(settings: &Value) -> Value {
|
||||
let mut result = sanitize_settings_update(settings);
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
// Ensure array fields are normalized
|
||||
obj.insert(
|
||||
"approvedDirectories".to_string(),
|
||||
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
|
||||
);
|
||||
obj.insert(
|
||||
"securityScopedBookmarks".to_string(),
|
||||
normalize_string_array(
|
||||
settings
|
||||
.get("securityScopedBookmarks")
|
||||
.unwrap_or(&json!([])),
|
||||
),
|
||||
);
|
||||
obj.insert(
|
||||
"pinnedDirectories".to_string(),
|
||||
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
|
||||
);
|
||||
|
||||
// Typography sizes
|
||||
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
|
||||
settings.get("typographySizes").unwrap_or(&json!(null)),
|
||||
) {
|
||||
obj.insert("typographySizes".to_string(), sanitized_typo);
|
||||
}
|
||||
|
||||
// showReasoningTraces with fallback
|
||||
let show_reasoning = settings
|
||||
.get("showReasoningTraces")
|
||||
.and_then(|v| v.as_bool())
|
||||
.or_else(|| {
|
||||
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
|
||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||
Some(*b)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalize string array helper
|
||||
fn normalize_string_array(input: &Value) -> Value {
|
||||
if let Some(arr) = input.as_array() {
|
||||
let strings: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let unique: HashSet<String> = strings.into_iter().collect();
|
||||
json!(unique.into_iter().collect::<Vec<_>>())
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize typography sizes partial helper
|
||||
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
|
||||
if let Some(obj) = input.as_object() {
|
||||
let mut result = serde_json::Map::new();
|
||||
let mut populated = false;
|
||||
|
||||
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
|
||||
if let Some(Value::String(s)) = obj.get(*key) {
|
||||
if !s.is_empty() {
|
||||
result.insert(key.to_string(), json!(s));
|
||||
populated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if populated {
|
||||
Some(json!(result))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract string vector from JSON value
|
||||
fn extract_string_vec(value: &Value) -> Vec<String> {
|
||||
if let Some(arr) = value.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use log::error;
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
use tauri::{Emitter, State, Window};
|
||||
|
||||
const DEFAULT_SHELL: &str = "/bin/zsh";
|
||||
const DEFAULT_TERM: &str = "xterm-256color";
|
||||
const DEFAULT_COLORTERM: &str = "truecolor";
|
||||
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
|
||||
const TERM_PROGRAM_NAME: &str = "OpenChamber";
|
||||
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub struct TerminalSession {
|
||||
pub master: Box<dyn MasterPty + Send>,
|
||||
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
||||
}
|
||||
|
||||
pub struct TerminalState {
|
||||
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||
}
|
||||
|
||||
impl TerminalState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTerminalPayload {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateTerminalResponse {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_terminal_session(
|
||||
payload: CreateTerminalPayload,
|
||||
state: State<'_, TerminalState>,
|
||||
window: Window,
|
||||
) -> Result<CreateTerminalResponse, String> {
|
||||
let pty_system = NativePtySystem::default();
|
||||
let size = PtySize {
|
||||
rows: payload.rows,
|
||||
cols: payload.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
};
|
||||
|
||||
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
|
||||
let shell_path = resolve_shell();
|
||||
|
||||
let mut cmd = CommandBuilder::new(&shell_path);
|
||||
if shell_accepts_login_flag(&shell_path) {
|
||||
cmd.arg("-l");
|
||||
}
|
||||
if let Some(cwd) = working_dir.to_str() {
|
||||
cmd.cwd(cwd);
|
||||
}
|
||||
apply_terminal_environment(&mut cmd, &shell_path);
|
||||
|
||||
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||
let writer = Arc::new(Mutex::new(
|
||||
pair.master
|
||||
.take_writer()
|
||||
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
|
||||
));
|
||||
let master = pair.master;
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
state.sessions.lock().unwrap().insert(
|
||||
session_id.clone(),
|
||||
TerminalSession {
|
||||
master,
|
||||
writer: writer.clone(),
|
||||
child: child.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
spawn_reader_thread(reader, window.clone(), session_id.clone());
|
||||
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
|
||||
|
||||
Ok(CreateTerminalResponse { session_id })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_terminal_input(
|
||||
session_id: String,
|
||||
data: String,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let sessions = state.sessions.lock().unwrap();
|
||||
let Some(session) = sessions.get(&session_id) else {
|
||||
return Err("Terminal session not found".to_string());
|
||||
};
|
||||
|
||||
let mut writer = session
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| "Terminal busy".to_string())?;
|
||||
writer
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resize_terminal(
|
||||
session_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
let Some(session) = sessions.get_mut(&session_id) else {
|
||||
return Err("Terminal session not found".to_string());
|
||||
};
|
||||
|
||||
session
|
||||
.master
|
||||
.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_terminal(
|
||||
session_id: String,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let session = {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
sessions.remove(&session_id)
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
if let Ok(mut child) = session.child.lock() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let event_name = format!("terminal://{}", session_id);
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
|
||||
{
|
||||
error!("Failed to emit terminal data: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Terminal read error: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_exit_watcher(
|
||||
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
||||
window: Window,
|
||||
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||
session_id: String,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let status = {
|
||||
let mut guard = child.lock().expect("terminal child poisoned");
|
||||
guard.wait()
|
||||
};
|
||||
|
||||
let (exit_code, signal) = match status {
|
||||
Ok(status) => (
|
||||
status.exit_code() as i32,
|
||||
status.signal().map(|sig| sig.to_string()),
|
||||
),
|
||||
Err(err) => {
|
||||
error!("Failed to wait for terminal exit: {err}");
|
||||
(1, Some("Terminal crashed".to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
let event_name = format!("terminal://{}", session_id);
|
||||
let payload = serde_json::json!({
|
||||
"type": "exit",
|
||||
"exitCode": exit_code,
|
||||
"signal": signal
|
||||
});
|
||||
let _ = window.emit(&event_name, payload);
|
||||
|
||||
let mut sessions = sessions.lock().unwrap();
|
||||
sessions.remove(&session_id);
|
||||
});
|
||||
}
|
||||
|
||||
fn resolve_shell() -> String {
|
||||
env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
|
||||
}
|
||||
|
||||
fn shell_accepts_login_flag(shell_path: &str) -> bool {
|
||||
let shell_name = Path::new(shell_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(shell_path)
|
||||
.to_lowercase();
|
||||
|
||||
matches!(
|
||||
shell_name.as_str(),
|
||||
name if name.contains("zsh")
|
||||
|| name.contains("bash")
|
||||
|| name.contains("sh")
|
||||
|| name.contains("fish")
|
||||
|| name.contains("ksh")
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
|
||||
let maybe_path = input
|
||||
.map(|value| PathBuf::from(value))
|
||||
.or_else(|| dirs::home_dir());
|
||||
|
||||
let Some(path) = maybe_path else {
|
||||
return Err("Unable to determine working directory".to_string());
|
||||
};
|
||||
|
||||
if !path.exists() || !path.is_dir() {
|
||||
return Err(format!(
|
||||
"Working directory is not accessible: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
|
||||
cmd.env(
|
||||
"TERM",
|
||||
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
|
||||
);
|
||||
cmd.env(
|
||||
"COLORTERM",
|
||||
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
|
||||
);
|
||||
cmd.env(
|
||||
"LC_ALL",
|
||||
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
||||
);
|
||||
cmd.env(
|
||||
"LANG",
|
||||
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
||||
);
|
||||
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
|
||||
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
|
||||
cmd.env("OPENCHAMBER_DESKTOP", "1");
|
||||
cmd.env("SHELL", shell_path);
|
||||
}
|
||||
Reference in New Issue
Block a user