From cacbc1466e6055ad82e6a402d7653016d836f893 Mon Sep 17 00:00:00 2001 From: Ignacio Cervino Date: Wed, 21 Jan 2026 10:24:01 -0300 Subject: [PATCH 1/3] Add drag-and-drop workspace import and fix DPI hit testing --- src-tauri/src/bin/codex_monitor_daemon.rs | 13 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/workspaces.rs | 23 ++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 76 ++++++- src/features/app/components/Sidebar.tsx | 42 +++- .../ComposerInput.attachments.test.tsx | 6 +- .../hooks/useComposerImageDrop.test.ts | 14 +- .../composer/hooks/useComposerImageDrop.ts | 111 ++++++---- src/features/layout/hooks/useLayoutNodes.tsx | 16 +- src/features/threads/hooks/useThreads.ts | 1 + .../hooks/useWorkspaceDropZone.test.ts | 111 ++++++++++ .../workspaces/hooks/useWorkspaceDropZone.ts | 208 ++++++++++++++++++ .../workspaces/hooks/useWorkspaces.test.tsx | 34 +++ .../workspaces/hooks/useWorkspaces.ts | 74 +++++-- src/services/dragDrop.ts | 76 +++++++ src/services/tauri.ts | 4 + src/styles/sidebar.css | 57 +++++ 18 files changed, 782 insertions(+), 87 deletions(-) create mode 100644 src/features/workspaces/hooks/useWorkspaceDropZone.test.ts create mode 100644 src/features/workspaces/hooks/useWorkspaceDropZone.ts create mode 100644 src/services/dragDrop.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index a91507fff..2c50aa6f1 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()) @@ -1474,6 +1482,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 2c36890b0..9d13a7604 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -281,6 +281,7 @@ pub fn run() { settings::update_app_settings, 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 f5fbd873e..f1e0eece7 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -411,6 +411,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, @@ -429,6 +448,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 1f99e73a0..9724b4a8a 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 1d697a57d..de653403c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { TabletLayout } from "./features/layout/components/TabletLayout"; import { PhoneLayout } from "./features/layout/components/PhoneLayout"; import { useLayoutNodes } from "./features/layout/hooks/useLayoutNodes"; import { useWorkspaces } from "./features/workspaces/hooks/useWorkspaces"; +import { useWorkspaceDropZone } from "./features/workspaces/hooks/useWorkspaceDropZone"; import { useThreads } from "./features/threads/hooks/useThreads"; import { useWindowDrag } from "./features/layout/hooks/useWindowDrag"; import { useGitStatus } from "./features/git/hooks/useGitStatus"; @@ -342,6 +343,7 @@ function MainApp() { activeWorkspaceId, setActiveWorkspaceId, addWorkspace, + addWorkspaceFromPath, addCloneAgent, addWorktreeAgent, connectWorkspace, @@ -1389,14 +1391,21 @@ function MainApp() { listThreadsForWorkspace }); + const handleWorkspaceAdded = useCallback( + (workspace: WorkspaceInfo) => { + setActiveThreadId(null, workspace.id); + if (isCompact) { + setActiveTab("codex"); + } + }, + [isCompact, setActiveTab, setActiveThreadId], + ); + const handleAddWorkspace = useCallback(async () => { try { const workspace = await addWorkspace(); if (workspace) { - setActiveThreadId(null, workspace.id); - if (isCompact) { - setActiveTab("codex"); - } + handleWorkspaceAdded(workspace); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1409,7 +1418,55 @@ function MainApp() { }); alert(`Failed to add workspace.\n\n${message}`); } - }, [addDebugEntry, addWorkspace, isCompact, setActiveTab, setActiveThreadId]); + }, [addDebugEntry, addWorkspace, handleWorkspaceAdded]); + + const handleAddWorkspaceFromPath = useCallback( + async (path: string) => { + try { + const workspace = await addWorkspaceFromPath(path); + if (workspace) { + handleWorkspaceAdded(workspace); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + addDebugEntry({ + id: `${Date.now()}-client-add-workspace-error`, + timestamp: Date.now(), + source: "error", + label: "workspace/add error", + payload: message + }); + alert(`Failed to add workspace.\n\n${message}`); + } + }, + [addDebugEntry, addWorkspaceFromPath, handleWorkspaceAdded], + ); + + 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 handleAddAgent = useCallback( async (workspace: (typeof workspaces)[number]) => { @@ -1678,6 +1735,8 @@ function MainApp() { 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" : ""}${ @@ -2068,6 +2127,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 485ef21b2..09665bf4f 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"; @@ -66,6 +68,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({ @@ -104,6 +113,13 @@ export function Sidebar({ onDeleteWorktree, onLoadOlderThreads, onReloadWorkspaceThreads, + workspaceDropTargetRef, + isWorkspaceDropActive, + workspaceDropText, + onWorkspaceDragOver, + onWorkspaceDragEnter, + onWorkspaceDragLeave, + onWorkspaceDrop, }: SidebarProps) { const [expandedWorkspaces, setExpandedWorkspaces] = useState( new Set(), @@ -262,8 +278,32 @@ export function Sidebar({ }, [addMenuAnchor]); return ( -