Skip to content

Commit ea3bbd3

Browse files
authored
feat: add "Add Workspace from URL" workflow (clone → register) (#457)
1 parent b6f30a5 commit ea3bbd3

23 files changed

Lines changed: 780 additions & 17 deletions

File tree

src-tauri/src/bin/codex_monitor_daemon.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,38 @@ impl DaemonState {
233233
.await
234234
}
235235

236+
async fn add_workspace_from_git_url(
237+
&self,
238+
url: String,
239+
destination_path: String,
240+
target_folder_name: Option<String>,
241+
codex_bin: Option<String>,
242+
client_version: String,
243+
) -> Result<WorkspaceInfo, String> {
244+
let client_version = client_version.clone();
245+
workspaces_core::add_workspace_from_git_url_core(
246+
url,
247+
destination_path,
248+
target_folder_name,
249+
codex_bin,
250+
&self.workspaces,
251+
&self.sessions,
252+
&self.app_settings,
253+
&self.storage_path,
254+
move |entry, default_bin, codex_args, codex_home| {
255+
spawn_with_client(
256+
self.event_sink.clone(),
257+
client_version.clone(),
258+
entry,
259+
default_bin,
260+
codex_args,
261+
codex_home,
262+
)
263+
},
264+
)
265+
.await
266+
}
267+
236268
async fn add_worktree(
237269
&self,
238270
parent_id: String,
@@ -507,7 +539,11 @@ impl DaemonState {
507539
.await
508540
}
509541

510-
async fn set_codex_feature_flag(&self, feature_key: String, enabled: bool) -> Result<(), String> {
542+
async fn set_codex_feature_flag(
543+
&self,
544+
feature_key: String,
545+
enabled: bool,
546+
) -> Result<(), String> {
511547
codex_config::write_feature_enabled(feature_key.as_str(), enabled)
512548
}
513549

@@ -789,7 +825,8 @@ impl DaemonState {
789825
cursor: Option<String>,
790826
limit: Option<u32>,
791827
) -> Result<Value, String> {
792-
codex_core::experimental_feature_list_core(&self.sessions, workspace_id, cursor, limit).await
828+
codex_core::experimental_feature_list_core(&self.sessions, workspace_id, cursor, limit)
829+
.await
793830
}
794831

795832
async fn collaboration_mode_list(&self, workspace_id: String) -> Result<Value, String> {
@@ -933,8 +970,14 @@ impl DaemonState {
933970
visibility: String,
934971
branch: Option<String>,
935972
) -> Result<Value, String> {
936-
git_ui_core::create_github_repo_core(&self.workspaces, workspace_id, repo, visibility, branch)
937-
.await
973+
git_ui_core::create_github_repo_core(
974+
&self.workspaces,
975+
workspace_id,
976+
repo,
977+
visibility,
978+
branch,
979+
)
980+
.await
938981
}
939982

940983
async fn list_git_roots(

src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ pub(super) async fn try_handle(
5959
};
6060
Some(serde_json::to_value(workspace).map_err(|err| err.to_string()))
6161
}
62+
"add_workspace_from_git_url" => {
63+
let url = match parse_string(params, "url") {
64+
Ok(value) => value,
65+
Err(err) => return Some(Err(err)),
66+
};
67+
let destination_path = match parse_string(params, "destination_path") {
68+
Ok(value) => value,
69+
Err(err) => return Some(Err(err)),
70+
};
71+
let target_folder_name = parse_optional_string(params, "target_folder_name");
72+
let codex_bin = parse_optional_string(params, "codex_bin");
73+
let workspace = match state
74+
.add_workspace_from_git_url(
75+
url,
76+
destination_path,
77+
target_folder_name,
78+
codex_bin,
79+
client_version.to_string(),
80+
)
81+
.await
82+
{
83+
Ok(value) => value,
84+
Err(err) => return Some(Err(err)),
85+
};
86+
Some(serde_json::to_value(workspace).map_err(|err| err.to_string()))
87+
}
6288
"add_worktree" => {
6389
let parent_id = match parse_string(params, "parentId") {
6490
Ok(value) => value,

src-tauri/src/lib.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,7 @@ pub fn run() {
7878
.unwrap_or(false)
7979
|| std::env::var_os("WAYLAND_DISPLAY").is_some();
8080
let has_nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists();
81-
if is_wayland
82-
&& has_nvidia
83-
&& std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none()
81+
if is_wayland && has_nvidia && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none()
8482
{
8583
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
8684
}
@@ -178,6 +176,7 @@ pub fn run() {
178176
workspaces::list_workspaces,
179177
workspaces::is_workspace_path_dir,
180178
workspaces::add_workspace,
179+
workspaces::add_workspace_from_git_url,
181180
workspaces::add_clone,
182181
workspaces::add_worktree,
183182
workspaces::worktree_setup_status,

src-tauri/src/menu.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
9595
MenuItemBuilder::with_id("file_new_clone_agent", "New Clone Agent").build(handle)?;
9696
let add_workspace_item =
9797
MenuItemBuilder::with_id("file_add_workspace", "Add Workspaces...").build(handle)?;
98+
let add_workspace_from_url_item =
99+
MenuItemBuilder::with_id("file_add_workspace_from_url", "Add Workspace from URL...")
100+
.build(handle)?;
98101

99102
registry.register("file_new_agent", &new_agent_item);
100103
registry.register("file_new_worktree_agent", &new_worktree_agent_item);
@@ -115,6 +118,7 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
115118
&new_clone_agent_item,
116119
&PredefinedMenuItem::separator(handle)?,
117120
&add_workspace_item,
121+
&add_workspace_from_url_item,
118122
&PredefinedMenuItem::separator(handle)?,
119123
&close_window_item,
120124
&quit_item,
@@ -132,6 +136,7 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
132136
&new_clone_agent_item,
133137
&PredefinedMenuItem::separator(handle)?,
134138
&add_workspace_item,
139+
&add_workspace_from_url_item,
135140
&PredefinedMenuItem::separator(handle)?,
136141
&PredefinedMenuItem::close_window(handle, None)?,
137142
#[cfg(not(target_os = "macos"))]
@@ -342,6 +347,7 @@ pub(crate) fn handle_menu_event<R: tauri::Runtime>(
342347
"file_new_worktree_agent" => emit_menu_event(app, "menu-new-worktree-agent"),
343348
"file_new_clone_agent" => emit_menu_event(app, "menu-new-clone-agent"),
344349
"file_add_workspace" => emit_menu_event(app, "menu-add-workspace"),
350+
"file_add_workspace_from_url" => emit_menu_event(app, "menu-add-workspace-from-url"),
345351
"file_open_settings" => emit_menu_event(app, "menu-open-settings"),
346352
"file_close_window" | "window_close" => {
347353
if let Some(window) = app.get_webview_window("main") {

src-tauri/src/shared/workspaces_core.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ mod worktree;
77

88
pub(crate) use connect::connect_workspace_core;
99
pub(crate) use crud_persistence::{
10-
add_clone_core, add_workspace_core, remove_workspace_core, update_workspace_codex_bin_core,
11-
update_workspace_settings_core,
10+
add_clone_core, add_workspace_core, add_workspace_from_git_url_core, remove_workspace_core,
11+
update_workspace_codex_bin_core, update_workspace_settings_core,
1212
};
1313
pub(crate) use git_orchestration::{apply_worktree_changes_core, run_git_command_unit};
1414
pub(crate) use helpers::{is_workspace_path_dir_core, list_workspaces_core};

src-tauri/src/shared/workspaces_core/crud_persistence.rs

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::collections::HashMap;
22
use std::future::Future;
3-
use std::path::PathBuf;
3+
use std::path::{Component, Path, PathBuf};
44
use std::sync::Arc;
55

66
use tokio::sync::Mutex;
@@ -222,6 +222,162 @@ where
222222
})
223223
}
224224

225+
fn default_repo_name_from_url(url: &str) -> Option<String> {
226+
let trimmed = url.trim().trim_end_matches('/');
227+
let tail = trimmed.rsplit('/').next()?.trim();
228+
if tail.is_empty() {
229+
return None;
230+
}
231+
let without_git_suffix = tail.strip_suffix(".git").unwrap_or(tail);
232+
if without_git_suffix.is_empty() {
233+
None
234+
} else {
235+
Some(without_git_suffix.to_string())
236+
}
237+
}
238+
239+
fn validate_target_folder_name(value: &str) -> Result<String, String> {
240+
let trimmed = value.trim();
241+
if trimmed.is_empty() {
242+
return Err("Target folder name is required.".to_string());
243+
}
244+
245+
if trimmed.contains('/') || trimmed.contains('\\') {
246+
return Err(
247+
"Target folder name must be a single relative folder name without separators or traversal."
248+
.to_string(),
249+
);
250+
}
251+
252+
let path = Path::new(trimmed);
253+
match (path.components().next(), path.components().nth(1)) {
254+
(Some(Component::Normal(_)), None) => Ok(trimmed.to_string()),
255+
_ => Err(
256+
"Target folder name must be a single relative folder name without separators or traversal."
257+
.to_string(),
258+
),
259+
}
260+
}
261+
262+
pub(crate) async fn add_workspace_from_git_url_core<F, Fut>(
263+
url: String,
264+
destination_path: String,
265+
target_folder_name: Option<String>,
266+
codex_bin: Option<String>,
267+
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
268+
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
269+
app_settings: &Mutex<AppSettings>,
270+
storage_path: &PathBuf,
271+
spawn_session: F,
272+
) -> Result<WorkspaceInfo, String>
273+
where
274+
F: Fn(WorkspaceEntry, Option<String>, Option<String>, Option<PathBuf>) -> Fut,
275+
Fut: Future<Output = Result<Arc<WorkspaceSession>, String>>,
276+
{
277+
let url = url.trim().to_string();
278+
if url.is_empty() {
279+
return Err("Remote Git URL is required.".to_string());
280+
}
281+
let destination_path = destination_path.trim().to_string();
282+
if destination_path.is_empty() {
283+
return Err("Destination folder is required.".to_string());
284+
}
285+
let destination_parent = PathBuf::from(&destination_path);
286+
if !destination_parent.is_dir() {
287+
return Err("Destination folder must be an existing directory.".to_string());
288+
}
289+
290+
let folder_name = target_folder_name
291+
.as_deref()
292+
.map(str::trim)
293+
.filter(|value| !value.is_empty())
294+
.map(str::to_string)
295+
.or_else(|| default_repo_name_from_url(&url))
296+
.ok_or_else(|| "Could not determine target folder name from URL.".to_string())?;
297+
let folder_name = validate_target_folder_name(&folder_name)?;
298+
299+
let clone_path = destination_parent.join(folder_name);
300+
if clone_path.exists() {
301+
let is_empty = std::fs::read_dir(&clone_path)
302+
.map_err(|err| format!("Failed to inspect destination path: {err}"))?
303+
.next()
304+
.is_none();
305+
if !is_empty {
306+
return Err("Destination path already exists and is not empty.".to_string());
307+
}
308+
}
309+
310+
let clone_path_string = clone_path.to_string_lossy().to_string();
311+
if let Err(error) =
312+
git_core::run_git_command(&destination_parent, &["clone", &url, &clone_path_string]).await
313+
{
314+
let _ = tokio::fs::remove_dir_all(&clone_path).await;
315+
return Err(error);
316+
}
317+
318+
let workspace_name = clone_path
319+
.file_name()
320+
.and_then(|s| s.to_str())
321+
.unwrap_or("Workspace")
322+
.to_string();
323+
let entry = WorkspaceEntry {
324+
id: Uuid::new_v4().to_string(),
325+
name: workspace_name,
326+
path: clone_path_string,
327+
codex_bin,
328+
kind: WorkspaceKind::Main,
329+
parent_id: None,
330+
worktree: None,
331+
settings: WorkspaceSettings::default(),
332+
};
333+
334+
let (default_bin, codex_args) = {
335+
let settings = app_settings.lock().await;
336+
(
337+
settings.codex_bin.clone(),
338+
resolve_workspace_codex_args(&entry, None, Some(&settings)),
339+
)
340+
};
341+
let codex_home = resolve_workspace_codex_home(&entry, None);
342+
let session = match spawn_session(entry.clone(), default_bin, codex_args, codex_home).await {
343+
Ok(session) => session,
344+
Err(error) => {
345+
let _ = tokio::fs::remove_dir_all(&clone_path).await;
346+
return Err(error);
347+
}
348+
};
349+
350+
if let Err(error) = {
351+
let mut workspaces = workspaces.lock().await;
352+
workspaces.insert(entry.id.clone(), entry.clone());
353+
let list: Vec<_> = workspaces.values().cloned().collect();
354+
write_workspaces(storage_path, &list)
355+
} {
356+
{
357+
let mut workspaces = workspaces.lock().await;
358+
workspaces.remove(&entry.id);
359+
}
360+
let mut child = session.child.lock().await;
361+
kill_child_process_tree(&mut child).await;
362+
let _ = tokio::fs::remove_dir_all(&clone_path).await;
363+
return Err(error);
364+
}
365+
366+
sessions.lock().await.insert(entry.id.clone(), session);
367+
368+
Ok(WorkspaceInfo {
369+
id: entry.id,
370+
name: entry.name,
371+
path: entry.path,
372+
codex_bin: entry.codex_bin,
373+
connected: true,
374+
kind: entry.kind,
375+
parent_id: entry.parent_id,
376+
worktree: entry.worktree,
377+
settings: entry.settings,
378+
})
379+
}
380+
225381
pub(crate) async fn remove_workspace_core<FRunGit, FutRunGit, FIsMissing, FRemoveDirAll>(
226382
id: String,
227383
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
@@ -550,3 +706,45 @@ pub(crate) async fn update_workspace_codex_bin_core(
550706
settings: entry_snapshot.settings,
551707
})
552708
}
709+
710+
#[cfg(test)]
711+
mod tests {
712+
use super::{default_repo_name_from_url, validate_target_folder_name};
713+
714+
#[test]
715+
fn derives_repo_name_from_https_url() {
716+
assert_eq!(
717+
default_repo_name_from_url("https://github.com/org/repo.git"),
718+
Some("repo".to_string())
719+
);
720+
}
721+
722+
#[test]
723+
fn derives_repo_name_from_ssh_url() {
724+
assert_eq!(
725+
default_repo_name_from_url("git@github.com:org/repo.git"),
726+
Some("repo".to_string())
727+
);
728+
}
729+
730+
#[test]
731+
fn accepts_single_relative_target_folder_name() {
732+
assert_eq!(
733+
validate_target_folder_name("my-project"),
734+
Ok("my-project".to_string())
735+
);
736+
}
737+
738+
#[test]
739+
fn rejects_target_folder_name_with_separators() {
740+
let err =
741+
validate_target_folder_name("nested/project").expect_err("name should be rejected");
742+
assert!(err.contains("without separators"));
743+
}
744+
745+
#[test]
746+
fn rejects_target_folder_name_with_traversal() {
747+
let err = validate_target_folder_name("../project").expect_err("name should be rejected");
748+
assert!(err.contains("without separators or traversal"));
749+
}
750+
}

0 commit comments

Comments
 (0)