From 392708f077f81936a1d0442824f8c11f17a596eb Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 19 Feb 2026 10:14:09 +0100 Subject: [PATCH 1/5] Add workspace import from remote Git URL --- src-tauri/src/bin/codex_monitor_daemon.rs | 51 +++++- .../bin/codex_monitor_daemon/rpc/workspace.rs | 26 +++ src-tauri/src/lib.rs | 5 +- src-tauri/src/menu.rs | 6 + src-tauri/src/shared/workspaces_core.rs | 4 +- .../workspaces_core/crud_persistence.rs | 149 ++++++++++++++++++ src-tauri/src/workspaces/commands.rs | 43 +++++ src/App.tsx | 33 ++++ src/features/app/components/AppModals.tsx | 43 +++++ src/features/app/hooks/useAppMenuEvents.ts | 7 + .../app/hooks/useWorkspaceActions.test.tsx | 1 + src/features/app/hooks/useWorkspaceActions.ts | 32 ++++ src/features/home/components/Home.tsx | 12 ++ .../layout/hooks/layoutNodes/types.ts | 1 + .../components/WorkspaceFromUrlPrompt.tsx | 110 +++++++++++++ .../hooks/useWorkspaceFromUrlPrompt.ts | 93 +++++++++++ .../workspaces/hooks/useWorkspaces.test.tsx | 33 ++++ .../workspaces/hooks/useWorkspaces.ts | 57 +++++++ src/services/events.ts | 10 ++ src/services/tauri.ts | 14 ++ 20 files changed, 721 insertions(+), 9 deletions(-) create mode 100644 src/features/workspaces/components/WorkspaceFromUrlPrompt.tsx create mode 100644 src/features/workspaces/hooks/useWorkspaceFromUrlPrompt.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 297cc5058..b53f47cd9 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -233,6 +233,38 @@ impl DaemonState { .await } + async fn add_workspace_from_git_url( + &self, + url: String, + destination_path: String, + target_folder_name: Option, + codex_bin: Option, + client_version: String, + ) -> Result { + 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, @@ -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) } @@ -789,7 +825,8 @@ impl DaemonState { cursor: Option, limit: Option, ) -> Result { - 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 { @@ -933,8 +970,14 @@ impl DaemonState { visibility: String, branch: Option, ) -> Result { - 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( diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index f519ce10b..09a1d5c6f 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa3758383..4d1ea7b46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); } @@ -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, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 03664e9e7..aec705eeb 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -95,6 +95,9 @@ pub(crate) fn build_menu( 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); @@ -115,6 +118,7 @@ pub(crate) fn build_menu( &new_clone_agent_item, &PredefinedMenuItem::separator(handle)?, &add_workspace_item, + &add_workspace_from_url_item, &PredefinedMenuItem::separator(handle)?, &close_window_item, &quit_item, @@ -132,6 +136,7 @@ pub(crate) fn build_menu( &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"))] @@ -342,6 +347,7 @@ pub(crate) fn handle_menu_event( "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") { diff --git a/src-tauri/src/shared/workspaces_core.rs b/src-tauri/src/shared/workspaces_core.rs index 0b97a6871..2b0eac9a0 100644 --- a/src-tauri/src/shared/workspaces_core.rs +++ b/src-tauri/src/shared/workspaces_core.rs @@ -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}; diff --git a/src-tauri/src/shared/workspaces_core/crud_persistence.rs b/src-tauri/src/shared/workspaces_core/crud_persistence.rs index 5e974d71c..8c51f1743 100644 --- a/src-tauri/src/shared/workspaces_core/crud_persistence.rs +++ b/src-tauri/src/shared/workspaces_core/crud_persistence.rs @@ -222,6 +222,134 @@ where }) } +fn default_repo_name_from_url(url: &str) -> Option { + 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()) + } +} + +pub(crate) async fn add_workspace_from_git_url_core( + url: String, + destination_path: String, + target_folder_name: Option, + codex_bin: Option, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + storage_path: &PathBuf, + spawn_session: F, +) -> Result +where + F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, + Fut: Future, 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 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()); + } + return Err("Destination path already exists.".to_string()); + } + + let clone_path_string = clone_path.to_string_lossy().to_string(); + git_core::run_git_command(&destination_parent, &["clone", &url, &clone_path_string]).await?; + + 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( id: String, workspaces: &Mutex>, @@ -550,3 +678,24 @@ pub(crate) async fn update_workspace_codex_bin_core( settings: entry_snapshot.settings, }) } + +#[cfg(test)] +mod tests { + use super::default_repo_name_from_url; + + #[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()) + ); + } +} diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index c4ba776a8..76a4df488 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -129,6 +129,49 @@ pub(crate) async fn add_workspace( .await } +#[tauri::command] +pub(crate) async fn add_workspace_from_git_url( + url: String, + destination_path: String, + target_folder_name: Option, + codex_bin: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + let destination_path = remote_backend::normalize_path_for_remote(destination_path); + let codex_bin = codex_bin.map(remote_backend::normalize_path_for_remote); + let response = remote_backend::call_remote( + &*state, + app, + "add_workspace_from_git_url", + json!({ + "url": url, + "destination_path": destination_path, + "target_folder_name": target_folder_name, + "codex_bin": codex_bin + }), + ) + .await?; + return serde_json::from_value(response).map_err(|err| err.to_string()); + } + + workspaces_core::add_workspace_from_git_url_core( + url, + destination_path, + target_folder_name, + codex_bin, + &state.workspaces, + &state.sessions, + &state.app_settings, + &state.storage_path, + |entry, default_bin, codex_args, codex_home| { + spawn_with_app(&app, entry, default_bin, codex_args, codex_home) + }, + ) + .await +} + #[tauri::command] pub(crate) async fn add_clone( source_workspace_id: String, diff --git a/src/App.tsx b/src/App.tsx index 9a77f6d7e..572b41054 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -86,6 +86,7 @@ import { useComposerInsert } from "@app/hooks/useComposerInsert"; import { useRenameThreadPrompt } from "@threads/hooks/useRenameThreadPrompt"; import { useWorktreePrompt } from "@/features/workspaces/hooks/useWorktreePrompt"; import { useClonePrompt } from "@/features/workspaces/hooks/useClonePrompt"; +import { useWorkspaceFromUrlPrompt } from "@/features/workspaces/hooks/useWorkspaceFromUrlPrompt"; import { useWorkspaceController } from "@app/hooks/useWorkspaceController"; import { useWorkspaceSelection } from "@/features/workspaces/hooks/useWorkspaceSelection"; import { useGitHubPanelController } from "@app/hooks/useGitHubPanelController"; @@ -211,6 +212,7 @@ function MainApp() { setActiveWorkspaceId, addWorkspace, addWorkspaceFromPath, + addWorkspaceFromGitUrl, addWorkspacesFromPaths, addCloneAgent, addWorktreeAgent, @@ -1176,6 +1178,23 @@ function MainApp() { }, }); + + const { + workspaceFromUrlPrompt, + openWorkspaceFromUrlPrompt, + closeWorkspaceFromUrlPrompt, + chooseWorkspaceFromUrlDestinationPath, + submitWorkspaceFromUrlPrompt, + updateWorkspaceFromUrlUrl, + updateWorkspaceFromUrlTargetFolderName, + clearWorkspaceFromUrlDestinationPath, + canSubmitWorkspaceFromUrlPrompt, + } = useWorkspaceFromUrlPrompt({ + onSubmit: async (url, destinationPath, targetFolderName) => { + await handleAddWorkspaceFromGitUrl(url, destinationPath, targetFolderName); + }, + }); + const showHome = !activeWorkspace; const { latestAgentRuns, @@ -1611,6 +1630,7 @@ function MainApp() { const { handleAddWorkspace, handleAddWorkspacesFromPaths, + handleAddWorkspaceFromGitUrl, handleAddAgent, handleAddWorktreeAgent, handleAddCloneAgent, @@ -1618,6 +1638,7 @@ function MainApp() { isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspaceFromGitUrl, addWorkspacesFromPaths, setActiveThreadId, setActiveTab, @@ -1844,6 +1865,9 @@ function MainApp() { onAddWorkspace: () => { void handleAddWorkspace(); }, + onAddWorkspaceFromUrl: () => { + openWorkspaceFromUrlPrompt(); + }, onAddAgent: (workspace) => { void handleAddAgent(workspace); }, @@ -1945,6 +1969,7 @@ function MainApp() { onOpenDebug: handleDebugClick, showDebugButton, onAddWorkspace: handleAddWorkspace, + onAddWorkspaceFromUrl: openWorkspaceFromUrlPrompt, onSelectHome: handleSidebarSelectHome, onSelectWorkspace: handleSidebarSelectWorkspace, onConnectWorkspace: handleSidebarConnectWorkspace, @@ -2520,6 +2545,14 @@ function MainApp() { onClonePromptClearCopiesFolder={clearCloneCopiesFolder} onClonePromptCancel={cancelClonePrompt} onClonePromptConfirm={confirmClonePrompt} + workspaceFromUrlPrompt={workspaceFromUrlPrompt} + workspaceFromUrlCanSubmit={canSubmitWorkspaceFromUrlPrompt} + onWorkspaceFromUrlPromptUrlChange={updateWorkspaceFromUrlUrl} + onWorkspaceFromUrlPromptTargetFolderNameChange={updateWorkspaceFromUrlTargetFolderName} + onWorkspaceFromUrlPromptChooseDestinationPath={chooseWorkspaceFromUrlDestinationPath} + onWorkspaceFromUrlPromptClearDestinationPath={clearWorkspaceFromUrlDestinationPath} + onWorkspaceFromUrlPromptCancel={closeWorkspaceFromUrlPrompt} + onWorkspaceFromUrlPromptConfirm={submitWorkspaceFromUrlPrompt} branchSwitcher={branchSwitcher} branches={branches} workspaces={workspaces} diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index 1301a5dfe..d91e42b8c 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -5,6 +5,7 @@ import type { SettingsViewProps } from "../../settings/components/SettingsView"; import { useRenameThreadPrompt } from "../../threads/hooks/useRenameThreadPrompt"; import { useClonePrompt } from "../../workspaces/hooks/useClonePrompt"; import { useWorktreePrompt } from "../../workspaces/hooks/useWorktreePrompt"; +import { useWorkspaceFromUrlPrompt } from "../../workspaces/hooks/useWorkspaceFromUrlPrompt"; import type { BranchSwitcherState } from "../../git/hooks/useBranchSwitcher"; import { useGitBranches } from "../../git/hooks/useGitBranches"; @@ -23,6 +24,11 @@ const ClonePrompt = lazy(() => default: module.ClonePrompt, })), ); +const WorkspaceFromUrlPrompt = lazy(() => + import("../../workspaces/components/WorkspaceFromUrlPrompt").then((module) => ({ + default: module.WorkspaceFromUrlPrompt, + })), +); const BranchSwitcherPrompt = lazy(() => import("../../git/components/BranchSwitcherPrompt").then((module) => ({ default: module.BranchSwitcherPrompt, @@ -39,6 +45,9 @@ type RenamePromptState = ReturnType["renamePrompt" type WorktreePromptState = ReturnType["worktreePrompt"]; type ClonePromptState = ReturnType["clonePrompt"]; +type WorkspaceFromUrlPromptState = ReturnType< + typeof useWorkspaceFromUrlPrompt +>["workspaceFromUrlPrompt"]; type AppModalsProps = { renamePrompt: RenamePromptState; @@ -74,6 +83,14 @@ type AppModalsProps = { onClonePromptClearCopiesFolder: () => void; onClonePromptCancel: () => void; onClonePromptConfirm: () => void; + workspaceFromUrlPrompt: WorkspaceFromUrlPromptState; + workspaceFromUrlCanSubmit: boolean; + onWorkspaceFromUrlPromptUrlChange: (value: string) => void; + onWorkspaceFromUrlPromptTargetFolderNameChange: (value: string) => void; + onWorkspaceFromUrlPromptChooseDestinationPath: () => void; + onWorkspaceFromUrlPromptClearDestinationPath: () => void; + onWorkspaceFromUrlPromptCancel: () => void; + onWorkspaceFromUrlPromptConfirm: () => void; branchSwitcher: BranchSwitcherState; branches: BranchInfo[]; workspaces: WorkspaceInfo[]; @@ -115,6 +132,14 @@ export const AppModals = memo(function AppModals({ onClonePromptClearCopiesFolder, onClonePromptCancel, onClonePromptConfirm, + workspaceFromUrlPrompt, + workspaceFromUrlCanSubmit, + onWorkspaceFromUrlPromptUrlChange, + onWorkspaceFromUrlPromptTargetFolderNameChange, + onWorkspaceFromUrlPromptChooseDestinationPath, + onWorkspaceFromUrlPromptClearDestinationPath, + onWorkspaceFromUrlPromptCancel, + onWorkspaceFromUrlPromptConfirm, branchSwitcher, branches, workspaces, @@ -205,6 +230,24 @@ export const AppModals = memo(function AppModals({ /> )} + {workspaceFromUrlPrompt && ( + + + + )} {branchSwitcher && ( ; baseWorkspaceRef: MutableRefObject; onAddWorkspace: () => void; + onAddWorkspaceFromUrl: () => void; onAddAgent: (workspace: WorkspaceInfo) => void; onAddWorktreeAgent: (workspace: WorkspaceInfo) => void; onAddCloneAgent: (workspace: WorkspaceInfo) => void; @@ -41,6 +43,7 @@ export function useAppMenuEvents({ activeWorkspaceRef, baseWorkspaceRef, onAddWorkspace, + onAddWorkspaceFromUrl, onAddAgent, onAddWorktreeAgent, onAddCloneAgent, @@ -81,6 +84,10 @@ export function useAppMenuEvents({ onAddWorkspace(); }); + useTauriEvent(subscribeMenuAddWorkspaceFromUrl, () => { + onAddWorkspaceFromUrl(); + }); + useTauriEvent(subscribeMenuOpenSettings, () => { onOpenSettings(); }); diff --git a/src/features/app/hooks/useWorkspaceActions.test.tsx b/src/features/app/hooks/useWorkspaceActions.test.tsx index ba6caaf7f..873979c3f 100644 --- a/src/features/app/hooks/useWorkspaceActions.test.tsx +++ b/src/features/app/hooks/useWorkspaceActions.test.tsx @@ -35,6 +35,7 @@ describe("useWorkspaceActions telemetry", () => { isCompact: false, addWorkspace: vi.fn(async () => null), addWorkspaceFromPath: vi.fn(async () => null), + addWorkspaceFromGitUrl: vi.fn(async () => null), addWorkspacesFromPaths: vi.fn(async () => null), setActiveThreadId, setActiveTab: vi.fn(), diff --git a/src/features/app/hooks/useWorkspaceActions.ts b/src/features/app/hooks/useWorkspaceActions.ts index c7951953b..da65fba1f 100644 --- a/src/features/app/hooks/useWorkspaceActions.ts +++ b/src/features/app/hooks/useWorkspaceActions.ts @@ -7,6 +7,11 @@ type Params = { isCompact: boolean; addWorkspace: () => Promise; addWorkspaceFromPath: (path: string) => Promise; + addWorkspaceFromGitUrl: ( + url: string, + destinationPath: string, + targetFolderName?: string | null, + ) => Promise; addWorkspacesFromPaths: (paths: string[]) => Promise; setActiveThreadId: (threadId: string | null, workspaceId: string) => void; setActiveTab: (tab: "home" | "projects" | "codex" | "git" | "log") => void; @@ -23,6 +28,7 @@ export function useWorkspaceActions({ isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspaceFromGitUrl, addWorkspacesFromPaths, setActiveThreadId, setActiveTab, @@ -107,6 +113,31 @@ export function useWorkspaceActions({ [addWorkspaceFromPath, handleWorkspaceAdded, onDebug], ); + + const handleAddWorkspaceFromGitUrl = useCallback( + async (url: string, destinationPath: string, targetFolderName?: string | null) => { + try { + const workspace = await addWorkspaceFromGitUrl(url, destinationPath, targetFolderName); + if (workspace) { + handleWorkspaceAdded(workspace); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onDebug({ + id: `${Date.now()}-client-add-workspace-from-url-error`, + timestamp: Date.now(), + source: "error", + label: "workspace/add-from-url error", + payload: message, + }); + alert(`Failed to import workspace from URL. + +${message}`); + } + }, + [addWorkspaceFromGitUrl, handleWorkspaceAdded, onDebug], + ); + const handleAddAgent = useCallback( async (workspace: WorkspaceInfo) => { exitDiffView(); @@ -155,6 +186,7 @@ export function useWorkspaceActions({ handleAddWorkspace, handleAddWorkspacesFromPaths, handleAddWorkspaceFromPath, + handleAddWorkspaceFromGitUrl, handleAddAgent, handleAddWorktreeAgent, handleAddCloneAgent, diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index f1c14c9c9..26a54a2ea 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -23,6 +23,7 @@ type UsageWorkspaceOption = { type HomeProps = { onOpenSettings: () => void; onAddWorkspace: () => void; + onAddWorkspaceFromUrl?: () => void; latestAgentRuns: LatestAgentRun[]; isLoadingLatestAgents: boolean; localUsageSnapshot: LocalUsageSnapshot | null; @@ -40,6 +41,7 @@ type HomeProps = { export function Home({ onOpenSettings, onAddWorkspace, + onAddWorkspaceFromUrl, latestAgentRuns, isLoadingLatestAgents, localUsageSnapshot, @@ -248,6 +250,16 @@ export function Home({ Add Workspaces +