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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions src-tauri/src/bin/codex_monitor_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,38 @@ impl DaemonState {
.await
}

async fn add_workspace_from_git_url(
&self,
url: String,
destination_path: String,
target_folder_name: Option<String>,
codex_bin: Option<String>,
client_version: String,
) -> Result<WorkspaceInfo, String> {
let client_version = client_version.clone();
workspaces_core::add_workspace_from_git_url_core(
url,
destination_path,
target_folder_name,
codex_bin,
&self.workspaces,
&self.sessions,
&self.app_settings,
&self.storage_path,
move |entry, default_bin, codex_args, codex_home| {
spawn_with_client(
self.event_sink.clone(),
client_version.clone(),
entry,
default_bin,
codex_args,
codex_home,
)
},
)
.await
}

async fn add_worktree(
&self,
parent_id: String,
Expand Down Expand Up @@ -507,7 +539,11 @@ impl DaemonState {
.await
}

async fn set_codex_feature_flag(&self, feature_key: String, enabled: bool) -> Result<(), String> {
async fn set_codex_feature_flag(
&self,
feature_key: String,
enabled: bool,
) -> Result<(), String> {
codex_config::write_feature_enabled(feature_key.as_str(), enabled)
}

Expand Down Expand Up @@ -789,7 +825,8 @@ impl DaemonState {
cursor: Option<String>,
limit: Option<u32>,
) -> Result<Value, String> {
codex_core::experimental_feature_list_core(&self.sessions, workspace_id, cursor, limit).await
codex_core::experimental_feature_list_core(&self.sessions, workspace_id, cursor, limit)
.await
}

async fn collaboration_mode_list(&self, workspace_id: String) -> Result<Value, String> {
Expand Down Expand Up @@ -933,8 +970,14 @@ impl DaemonState {
visibility: String,
branch: Option<String>,
) -> Result<Value, String> {
git_ui_core::create_github_repo_core(&self.workspaces, workspace_id, repo, visibility, branch)
.await
git_ui_core::create_github_repo_core(
&self.workspaces,
workspace_id,
repo,
visibility,
branch,
)
.await
}

async fn list_git_roots(
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,32 @@ pub(super) async fn try_handle(
};
Some(serde_json::to_value(workspace).map_err(|err| err.to_string()))
}
"add_workspace_from_git_url" => {
let url = match parse_string(params, "url") {
Ok(value) => value,
Err(err) => return Some(Err(err)),
};
let destination_path = match parse_string(params, "destination_path") {
Ok(value) => value,
Err(err) => return Some(Err(err)),
};
let target_folder_name = parse_optional_string(params, "target_folder_name");
let codex_bin = parse_optional_string(params, "codex_bin");
let workspace = match state
.add_workspace_from_git_url(
url,
destination_path,
target_folder_name,
codex_bin,
client_version.to_string(),
)
.await
{
Ok(value) => value,
Err(err) => return Some(Err(err)),
};
Some(serde_json::to_value(workspace).map_err(|err| err.to_string()))
}
"add_worktree" => {
let parent_id = match parse_string(params, "parentId") {
Ok(value) => value,
Expand Down
5 changes: 2 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ pub fn run() {
.unwrap_or(false)
|| std::env::var_os("WAYLAND_DISPLAY").is_some();
let has_nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists();
if is_wayland
&& has_nvidia
&& std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none()
if is_wayland && has_nvidia && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none()
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
Expand Down Expand Up @@ -178,6 +176,7 @@ pub fn run() {
workspaces::list_workspaces,
workspaces::is_workspace_path_dir,
workspaces::add_workspace,
workspaces::add_workspace_from_git_url,
workspaces::add_clone,
workspaces::add_worktree,
workspaces::worktree_setup_status,
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
MenuItemBuilder::with_id("file_new_clone_agent", "New Clone Agent").build(handle)?;
let add_workspace_item =
MenuItemBuilder::with_id("file_add_workspace", "Add Workspaces...").build(handle)?;
let add_workspace_from_url_item =
MenuItemBuilder::with_id("file_add_workspace_from_url", "Add Workspace from URL...")
.build(handle)?;

registry.register("file_new_agent", &new_agent_item);
registry.register("file_new_worktree_agent", &new_worktree_agent_item);
Expand All @@ -115,6 +118,7 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
&new_clone_agent_item,
&PredefinedMenuItem::separator(handle)?,
&add_workspace_item,
&add_workspace_from_url_item,
&PredefinedMenuItem::separator(handle)?,
&close_window_item,
&quit_item,
Expand All @@ -132,6 +136,7 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
&new_clone_agent_item,
&PredefinedMenuItem::separator(handle)?,
&add_workspace_item,
&add_workspace_from_url_item,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::close_window(handle, None)?,
#[cfg(not(target_os = "macos"))]
Expand Down Expand Up @@ -342,6 +347,7 @@ pub(crate) fn handle_menu_event<R: tauri::Runtime>(
"file_new_worktree_agent" => emit_menu_event(app, "menu-new-worktree-agent"),
"file_new_clone_agent" => emit_menu_event(app, "menu-new-clone-agent"),
"file_add_workspace" => emit_menu_event(app, "menu-add-workspace"),
"file_add_workspace_from_url" => emit_menu_event(app, "menu-add-workspace-from-url"),
"file_open_settings" => emit_menu_event(app, "menu-open-settings"),
"file_close_window" | "window_close" => {
if let Some(window) = app.get_webview_window("main") {
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/shared/workspaces_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ mod worktree;

pub(crate) use connect::connect_workspace_core;
pub(crate) use crud_persistence::{
add_clone_core, add_workspace_core, remove_workspace_core, update_workspace_codex_bin_core,
update_workspace_settings_core,
add_clone_core, add_workspace_core, add_workspace_from_git_url_core, remove_workspace_core,
update_workspace_codex_bin_core, update_workspace_settings_core,
};
pub(crate) use git_orchestration::{apply_worktree_changes_core, run_git_command_unit};
pub(crate) use helpers::{is_workspace_path_dir_core, list_workspaces_core};
Expand Down
200 changes: 199 additions & 1 deletion src-tauri/src/shared/workspaces_core/crud_persistence.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;

use tokio::sync::Mutex;
Expand Down Expand Up @@ -222,6 +222,162 @@ where
})
}

fn default_repo_name_from_url(url: &str) -> Option<String> {
let trimmed = url.trim().trim_end_matches('/');
let tail = trimmed.rsplit('/').next()?.trim();
if tail.is_empty() {
return None;
}
let without_git_suffix = tail.strip_suffix(".git").unwrap_or(tail);
if without_git_suffix.is_empty() {
None
} else {
Some(without_git_suffix.to_string())
}
}

fn validate_target_folder_name(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("Target folder name is required.".to_string());
}

if trimmed.contains('/') || trimmed.contains('\\') {
return Err(
"Target folder name must be a single relative folder name without separators or traversal."
.to_string(),
);
}

let path = Path::new(trimmed);
match (path.components().next(), path.components().nth(1)) {
(Some(Component::Normal(_)), None) => Ok(trimmed.to_string()),
_ => Err(
"Target folder name must be a single relative folder name without separators or traversal."
.to_string(),
),
}
}

pub(crate) async fn add_workspace_from_git_url_core<F, Fut>(
url: String,
destination_path: String,
target_folder_name: Option<String>,
codex_bin: Option<String>,
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
app_settings: &Mutex<AppSettings>,
storage_path: &PathBuf,
spawn_session: F,
) -> Result<WorkspaceInfo, String>
where
F: Fn(WorkspaceEntry, Option<String>, Option<String>, Option<PathBuf>) -> Fut,
Fut: Future<Output = Result<Arc<WorkspaceSession>, String>>,
{
let url = url.trim().to_string();
if url.is_empty() {
return Err("Remote Git URL is required.".to_string());
}
let destination_path = destination_path.trim().to_string();
if destination_path.is_empty() {
return Err("Destination folder is required.".to_string());
}
let destination_parent = PathBuf::from(&destination_path);
if !destination_parent.is_dir() {
return Err("Destination folder must be an existing directory.".to_string());
}

let folder_name = target_folder_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| default_repo_name_from_url(&url))
.ok_or_else(|| "Could not determine target folder name from URL.".to_string())?;
let folder_name = validate_target_folder_name(&folder_name)?;

let clone_path = destination_parent.join(folder_name);
if clone_path.exists() {
let is_empty = std::fs::read_dir(&clone_path)
.map_err(|err| format!("Failed to inspect destination path: {err}"))?
.next()
.is_none();
if !is_empty {
return Err("Destination path already exists and is not empty.".to_string());
}
}

let clone_path_string = clone_path.to_string_lossy().to_string();
if let Err(error) =
git_core::run_git_command(&destination_parent, &["clone", &url, &clone_path_string]).await
{
let _ = tokio::fs::remove_dir_all(&clone_path).await;
return Err(error);
}

let workspace_name = clone_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("Workspace")
.to_string();
let entry = WorkspaceEntry {
id: Uuid::new_v4().to_string(),
name: workspace_name,
path: clone_path_string,
codex_bin,
kind: WorkspaceKind::Main,
parent_id: None,
worktree: None,
settings: WorkspaceSettings::default(),
};

let (default_bin, codex_args) = {
let settings = app_settings.lock().await;
(
settings.codex_bin.clone(),
resolve_workspace_codex_args(&entry, None, Some(&settings)),
)
};
let codex_home = resolve_workspace_codex_home(&entry, None);
let session = match spawn_session(entry.clone(), default_bin, codex_args, codex_home).await {
Ok(session) => session,
Err(error) => {
let _ = tokio::fs::remove_dir_all(&clone_path).await;
return Err(error);
}
};

if let Err(error) = {
let mut workspaces = workspaces.lock().await;
workspaces.insert(entry.id.clone(), entry.clone());
let list: Vec<_> = workspaces.values().cloned().collect();
write_workspaces(storage_path, &list)
} {
{
let mut workspaces = workspaces.lock().await;
workspaces.remove(&entry.id);
}
let mut child = session.child.lock().await;
kill_child_process_tree(&mut child).await;
let _ = tokio::fs::remove_dir_all(&clone_path).await;
return Err(error);
}

sessions.lock().await.insert(entry.id.clone(), session);

Ok(WorkspaceInfo {
id: entry.id,
name: entry.name,
path: entry.path,
codex_bin: entry.codex_bin,
connected: true,
kind: entry.kind,
parent_id: entry.parent_id,
worktree: entry.worktree,
settings: entry.settings,
})
}

pub(crate) async fn remove_workspace_core<FRunGit, FutRunGit, FIsMissing, FRemoveDirAll>(
id: String,
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
Expand Down Expand Up @@ -550,3 +706,45 @@ pub(crate) async fn update_workspace_codex_bin_core(
settings: entry_snapshot.settings,
})
}

#[cfg(test)]
mod tests {
use super::{default_repo_name_from_url, validate_target_folder_name};

#[test]
fn derives_repo_name_from_https_url() {
assert_eq!(
default_repo_name_from_url("https://github.com/org/repo.git"),
Some("repo".to_string())
);
}

#[test]
fn derives_repo_name_from_ssh_url() {
assert_eq!(
default_repo_name_from_url("git@github.com:org/repo.git"),
Some("repo".to_string())
);
}

#[test]
fn accepts_single_relative_target_folder_name() {
assert_eq!(
validate_target_folder_name("my-project"),
Ok("my-project".to_string())
);
}

#[test]
fn rejects_target_folder_name_with_separators() {
let err =
validate_target_folder_name("nested/project").expect_err("name should be rejected");
assert!(err.contains("without separators"));
}

#[test]
fn rejects_target_folder_name_with_traversal() {
let err = validate_target_folder_name("../project").expect_err("name should be rejected");
assert!(err.contains("without separators or traversal"));
}
}
Loading
Loading