diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index a77b5c954..6a5f9596f 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -126,12 +126,20 @@ impl DaemonState { result } + async fn is_workspace_path_dir(&self, path: String) -> bool { + PathBuf::from(&path).is_dir() + } + async fn add_workspace( &self, path: String, codex_bin: Option, client_version: String, ) -> Result { + if !PathBuf::from(&path).is_dir() { + return Err("Workspace path must be a folder.".to_string()); + } + let name = PathBuf::from(&path) .file_name() .and_then(|s| s.to_str()) @@ -1499,6 +1507,11 @@ async fn handle_rpc_request( let workspaces = state.list_workspaces().await; serde_json::to_value(workspaces).map_err(|err| err.to_string()) } + "is_workspace_path_dir" => { + let path = parse_string(¶ms, "path")?; + let is_dir = state.is_workspace_path_dir(path).await; + serde_json::to_value(is_dir).map_err(|err| err.to_string()) + } "add_workspace" => { let path = parse_string(¶ms, "path")?; let codex_bin = parse_optional_string(¶ms, "codex_bin"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index badcb3cb5..1509f0a0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,7 @@ pub fn run() { menu::menu_set_accelerators, codex::codex_doctor, workspaces::list_workspaces, + workspaces::is_workspace_path_dir, workspaces::add_workspace, workspaces::add_clone, workspaces::add_worktree, diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 029f5a5b3..fba6be496 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -415,6 +415,25 @@ pub(crate) async fn list_workspaces( Ok(result) } +#[tauri::command] +pub(crate) async fn is_workspace_path_dir( + path: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + let response = remote_backend::call_remote( + &*state, + app, + "is_workspace_path_dir", + json!({ "path": path }), + ) + .await?; + return serde_json::from_value(response).map_err(|err| err.to_string()); + } + Ok(PathBuf::from(&path).is_dir()) +} + #[tauri::command] pub(crate) async fn add_workspace( path: String, @@ -433,6 +452,10 @@ pub(crate) async fn add_workspace( return serde_json::from_value(response).map_err(|err| err.to_string()); } + if !PathBuf::from(&path).is_dir() { + return Err("Workspace path must be a folder.".to_string()); + } + let name = PathBuf::from(&path) .file_name() .and_then(|s| s.to_str()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 547c79bee..1b4c35126 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,7 @@ "height": 700, "minWidth": 360, "minHeight": 600, - "dragDropEnabled": false, + "dragDropEnabled": true, "titleBarStyle": "Overlay", "hiddenTitle": true, "transparent": true, diff --git a/src/App.tsx b/src/App.tsx index c7c74cdfb..d7db6625a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { AppLayout } from "./features/app/components/AppLayout"; import { AppModals } from "./features/app/components/AppModals"; import { MainHeaderActions } from "./features/app/components/MainHeaderActions"; import { useLayoutNodes } from "./features/layout/hooks/useLayoutNodes"; +import { useWorkspaceDropZone } from "./features/workspaces/hooks/useWorkspaceDropZone"; import { useThreads } from "./features/threads/hooks/useThreads"; import { useWindowDrag } from "./features/layout/hooks/useWindowDrag"; import { useGitPanelController } from "./features/app/hooks/useGitPanelController"; @@ -154,6 +155,7 @@ function MainApp() { activeWorkspaceId, setActiveWorkspaceId, addWorkspace, + addWorkspaceFromPath, addCloneAgent, addWorktreeAgent, connectWorkspace, @@ -1040,6 +1042,7 @@ function MainApp() { const { handleAddWorkspace, + handleAddWorkspaceFromPath, handleAddAgent, handleAddWorktreeAgent, handleAddCloneAgent, @@ -1047,6 +1050,7 @@ function MainApp() { activeWorkspace, isCompact, addWorkspace, + addWorkspaceFromPath, connectWorkspace, startThreadForWorkspace, setActiveThreadId, @@ -1059,6 +1063,32 @@ function MainApp() { onDebug: addDebugEntry, }); + const handleDropWorkspacePaths = useCallback( + async (paths: string[]) => { + const uniquePaths = Array.from( + new Set(paths.filter((path) => path.length > 0)), + ); + if (uniquePaths.length === 0) { + return; + } + uniquePaths.forEach((path) => { + void handleAddWorkspaceFromPath(path); + }); + }, + [handleAddWorkspaceFromPath], + ); + + const { + dropTargetRef: workspaceDropTargetRef, + isDragOver: isWorkspaceDropActive, + handleDragOver: handleWorkspaceDragOver, + handleDragEnter: handleWorkspaceDragEnter, + handleDragLeave: handleWorkspaceDragLeave, + handleDrop: handleWorkspaceDrop, + } = useWorkspaceDropZone({ + onDropPaths: handleDropWorkspacePaths, + }); + const { handleSelectPullRequest, resetPullRequestSelection, @@ -1201,6 +1231,8 @@ function MainApp() { useMenuAcceleratorController({ appSettings, onDebug: addDebugEntry }); const isDefaultScale = Math.abs(uiScale - 1) < 0.001; + const dropOverlayActive = isWorkspaceDropActive; + const dropOverlayText = "Drop Project Here"; const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ isPhone ? " layout-phone" : "" }${isTablet ? " layout-tablet" : ""}${ @@ -1550,6 +1582,13 @@ function MainApp() { setCenterMode("chat"); }, onGoProjects: () => setActiveTab("projects"), + workspaceDropTargetRef, + isWorkspaceDropActive: dropOverlayActive, + workspaceDropText: dropOverlayText, + onWorkspaceDragOver: handleWorkspaceDragOver, + onWorkspaceDragEnter: handleWorkspaceDragEnter, + onWorkspaceDragLeave: handleWorkspaceDragLeave, + onWorkspaceDrop: handleWorkspaceDrop, }); const desktopTopbarLeftNodeWithToggle = !isCompact ? ( diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 2c2de098d..2186ad48d 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -1,6 +1,8 @@ import type { RateLimitSnapshot, ThreadSummary, WorkspaceInfo } from "../../../types"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; +import { FolderOpen } from "lucide-react"; import { SidebarCornerActions } from "./SidebarCornerActions"; import { SidebarFooter } from "./SidebarFooter"; import { SidebarHeader } from "./SidebarHeader"; @@ -67,6 +69,13 @@ type SidebarProps = { onDeleteWorktree: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; onReloadWorkspaceThreads: (workspaceId: string) => void; + workspaceDropTargetRef: RefObject; + isWorkspaceDropActive: boolean; + workspaceDropText: string; + onWorkspaceDragOver: (event: React.DragEvent) => void; + onWorkspaceDragEnter: (event: React.DragEvent) => void; + onWorkspaceDragLeave: (event: React.DragEvent) => void; + onWorkspaceDrop: (event: React.DragEvent) => void; }; export function Sidebar({ @@ -106,6 +115,13 @@ export function Sidebar({ onDeleteWorktree, onLoadOlderThreads, onReloadWorkspaceThreads, + workspaceDropTargetRef, + isWorkspaceDropActive, + workspaceDropText, + onWorkspaceDragOver, + onWorkspaceDragEnter, + onWorkspaceDragLeave, + onWorkspaceDrop, }: SidebarProps) { const [expandedWorkspaces, setExpandedWorkspaces] = useState( new Set(), @@ -264,8 +280,32 @@ export function Sidebar({ }, [addMenuAnchor]); return ( -