From cf8b1c6f2a3424eddf4fc36cce045a1152c38591 Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:00:52 +0200 Subject: [PATCH 1/5] feat(artifacts): workspace_copy_path command for cross-workspace copy Backend for drag-and-drop copy of artifacts between workspaces. New workspace_copy_path resolves both workspace roots, enforces containment + the protected-path guard on the source, and copies the file/folder into the DESTINATION root under a collision-free name (foo -> foo (1) -> ...). - Always a copy, never a move (a drag can't destroy the source). - Folders copied recursively; symlinks skipped (no loops / tree escape) and protected/heavy dirs (.git, node_modules, target, ...) skipped. - Reuses the exclusive-create collision helpers from the import path (destination_candidate / copy_to_unique_destination, now pub(crate)). Unit-tested: file suffixing, recursive dir copy, symlink + .git skip. --- src-tauri/src/commands/system_apps.rs | 4 +- src-tauri/src/commands/workspace.rs | 180 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/system_apps.rs b/src-tauri/src/commands/system_apps.rs index 36c3e99..26ac114 100644 --- a/src-tauri/src/commands/system_apps.rs +++ b/src-tauri/src/commands/system_apps.rs @@ -122,7 +122,7 @@ pub fn workspace_import_files( } /// `report.md` → `report (1).md` → `report (2).md` … until free. -fn destination_candidate(dir: &Path, name: &str, copy_index: u32) -> std::path::PathBuf { +pub(crate) fn destination_candidate(dir: &Path, name: &str, copy_index: u32) -> std::path::PathBuf { if copy_index == 0 { return dir.join(name); } @@ -133,7 +133,7 @@ fn destination_candidate(dir: &Path, name: &str, copy_index: u32) -> std::path:: dir.join(format!("{} ({}){}", stem, copy_index, ext)) } -fn copy_to_unique_destination( +pub(crate) fn copy_to_unique_destination( source: &Path, dir: &Path, name: &str, diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index e7d222e..9668566 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -2042,6 +2042,142 @@ pub async fn workspace_delete_path( Ok(()) } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceCopyPathRequest { + pub source_workspace_id: String, + /// Path to copy, relative to the SOURCE workspace root. + pub path: String, + pub dest_workspace_id: String, +} + +/// Copy a file or folder from one workspace into another (drag-and-drop from +/// the artifacts drawer). Always a copy — never a move, so the source can't be +/// destroyed by a drag. The item lands at the destination workspace ROOT under +/// a collision-free name; folders are copied recursively. Returns the created +/// name (relative to the destination root). +#[tauri::command] +pub async fn workspace_copy_path( + request: WorkspaceCopyPathRequest, + state: State<'_, AppState>, +) -> Result { + let source_desc = + resolve_workspace_descriptor(state.inner(), Some(request.source_workspace_id.clone()))?; + let source_root = source_desc + .root_path + .as_ref() + .ok_or_else(|| "The source workspace does not expose a filesystem root".to_string())?; + ensure_agent_workspace_root(source_root)?; + + let dest_desc = + resolve_workspace_descriptor(state.inner(), Some(request.dest_workspace_id.clone()))?; + let dest_root = dest_desc + .root_path + .as_ref() + .ok_or_else(|| "The destination workspace does not expose a filesystem root".to_string())?; + ensure_agent_workspace_root(dest_root)?; + + let rel = request.path.trim(); + if rel.is_empty() { + return Err("Cannot copy the workspace root.".to_string()); + } + let relative = Path::new(rel); + if is_protected_artifact_path(relative) { + return Err(format!("Refusing to copy a protected path: {}", rel)); + } + + let source = normalize_path(source_root.join(relative)); + if !source.starts_with(source_root) { + return Err(format!("Path {} is outside the workspace root", rel)); + } + if source == *source_root { + return Err("Cannot copy the workspace root.".to_string()); + } + + let metadata = fs::symlink_metadata(&source) + .map_err(|error| format!("Path not found: {}: {}", rel, error))?; + + let name = source + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("`{}` has no file name.", rel))? + .to_string(); + + let dest = copy_artifact_to_unique_destination(&source, dest_root, &name, metadata.is_dir())?; + Ok(dest + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&name) + .to_string()) +} + +/// Copy `source` (file or dir) into `dest_dir` under a collision-free name +/// (`foo` → `foo (1)` → …). Files reuse the exclusive-create import helper; +/// dirs are created exclusively then filled by [`copy_dir_recursive`]. +fn copy_artifact_to_unique_destination( + source: &Path, + dest_dir: &Path, + name: &str, + is_dir: bool, +) -> Result { + if !is_dir { + return crate::commands::system_apps::copy_to_unique_destination(source, dest_dir, name); + } + for n in 0u32.. { + let candidate = crate::commands::system_apps::destination_candidate(dest_dir, name, n); + match fs::create_dir(&candidate) { + Ok(()) => { + if let Err(error) = copy_dir_recursive(source, &candidate) { + let _ = fs::remove_dir_all(&candidate); + return Err(error); + } + return Ok(candidate); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "Failed to create `{}`: {}", + candidate.display(), + error + )); + } + } + } + unreachable!("u32 exhausted finding a unique directory name") +} + +/// Recursively copy `src` into the (already-created) `dst`. Symlinks are +/// skipped (avoids loops and escaping the workspace tree), and protected/heavy +/// dirs (`.git`, `node_modules`, `target`, …) are skipped so a folder copy +/// doesn't drag in VCS/build state. +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + for entry in + fs::read_dir(src).map_err(|e| format!("Failed to read `{}`: {}", src.display(), e))? + { + let entry = entry.map_err(|e| format!("Failed to read `{}`: {}", src.display(), e))?; + let file_type = entry + .file_type() + .map_err(|e| format!("Failed to stat `{}`: {}", entry.path().display(), e))?; + if file_type.is_symlink() { + continue; + } + let from = entry.path(); + if file_type.is_dir() && should_skip_artifact_dir(&from) { + continue; + } + let to = dst.join(entry.file_name()); + if file_type.is_dir() { + fs::create_dir(&to) + .map_err(|e| format!("Failed to create `{}`: {}", to.display(), e))?; + copy_dir_recursive(&from, &to)?; + } else { + fs::copy(&from, &to) + .map_err(|e| format!("Failed to copy `{}`: {}", from.display(), e))?; + } + } + Ok(()) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceStoreImageRequest { @@ -3252,4 +3388,48 @@ mod tests { assert_eq!(viewer_for_path(Path::new("main.rs")), "text"); assert_eq!(viewer_for_path(Path::new("data.json")), "json"); } + + #[test] + fn copy_artifact_handles_files_dirs_collisions_and_skips() { + use std::os::unix::fs::symlink; + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + + // A file copies, and a second copy of the same name gets suffixed. + std::fs::write(src.path().join("note.md"), b"hello").unwrap(); + let a = copy_artifact_to_unique_destination( + &src.path().join("note.md"), + dst.path(), + "note.md", + false, + ) + .unwrap(); + assert_eq!(a.file_name().unwrap(), "note.md"); + let b = copy_artifact_to_unique_destination( + &src.path().join("note.md"), + dst.path(), + "note.md", + false, + ) + .unwrap(); + assert_eq!(b.file_name().unwrap(), "note (1).md"); + assert_eq!(std::fs::read(dst.path().join("note.md")).unwrap(), b"hello"); + + // A directory copies recursively, but symlinks and protected dirs + // (.git) are skipped. + let tree = src.path().join("proj"); + std::fs::create_dir_all(tree.join("sub")).unwrap(); + std::fs::create_dir_all(tree.join(".git")).unwrap(); + std::fs::write(tree.join("a.txt"), b"a").unwrap(); + std::fs::write(tree.join("sub/b.txt"), b"b").unwrap(); + std::fs::write(tree.join(".git/config"), b"x").unwrap(); + symlink("/etc/hostname", tree.join("link")).unwrap(); + + let out = copy_artifact_to_unique_destination(&tree, dst.path(), "proj", true).unwrap(); + assert_eq!(out.file_name().unwrap(), "proj"); + assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"a"); + assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"b"); + assert!(!out.join(".git").exists(), ".git must be skipped"); + assert!(!out.join("link").exists(), "symlink must be skipped"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1a831f4..1868a25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -395,6 +395,7 @@ pub fn run() { commands::workspace::workspace_search_artifacts, commands::workspace::workspace_write_file, commands::workspace::workspace_delete_path, + commands::workspace::workspace_copy_path, commands::workspace::workspace_store_image, commands::workspace::workspace_pick_and_store_image, commands::workspace::workspace_download_file, From 5d62fc1a89644706b39311afb21153d69ed399be Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:00:52 +0200 Subject: [PATCH 2/5] feat(artifacts): drag artifacts onto a workspace to copy them there Artifact tree rows are now draggable (payload: source workspaceId + path + kind, via a custom application/x-clai-artifact MIME). Workspace rail rows are drop targets: they highlight on drag-over and, on drop, copy the dragged file/folder into that workspace via copyWorkspacePath. A transient banner confirms ("Copied "x" to ") or reports failure; dropping onto the source workspace is a no-op with a hint. --- src/components/Fleet/WorkspaceRail.module.css | 6 +++ src/components/Fleet/WorkspaceRail.tsx | 41 ++++++++++++++++++- src/layouts/FleetLayout.module.css | 11 +++++ src/layouts/FleetLayout.tsx | 28 +++++++++++++ src/pages/Workspace.tsx | 28 +++++++++++-- src/workspace/client.ts | 12 ++++++ 6 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/components/Fleet/WorkspaceRail.module.css b/src/components/Fleet/WorkspaceRail.module.css index 8c8b5b2..f106a1d 100644 --- a/src/components/Fleet/WorkspaceRail.module.css +++ b/src/components/Fleet/WorkspaceRail.module.css @@ -274,6 +274,12 @@ border-color: var(--color-primary); } +.rowDropTarget { + outline: 2px dashed var(--color-primary); + outline-offset: -2px; + background: var(--color-primary-alpha-10, rgba(99, 102, 241, 0.12)); +} + .rowSelected:hover { background: var(--color-primary-alpha-08); } diff --git a/src/components/Fleet/WorkspaceRail.tsx b/src/components/Fleet/WorkspaceRail.tsx index ab577e4..9851786 100644 --- a/src/components/Fleet/WorkspaceRail.tsx +++ b/src/components/Fleet/WorkspaceRail.tsx @@ -31,6 +31,12 @@ interface WorkspaceRailProps { schedulerPaused: boolean; schedulerPauseBusy: boolean; onToggleSchedulerPaused: () => void; + /** Drop handler for an artifact dragged from the artifacts drawer onto a + * workspace row — copies it into that workspace. */ + onArtifactDrop?: ( + destWorkspaceId: string, + drag: { workspaceId: string; path: string; kind: string; name: string } + ) => void; } const isProcessing = ( @@ -91,9 +97,11 @@ const WorkspaceRail = ({ runNowBusyId, forkBusyId, pauseBusyId, + onArtifactDrop, }: WorkspaceRailProps) => { const [openMenuId, setOpenMenuId] = useState(null); const [query, setQuery] = useState(''); + const [dropTargetId, setDropTargetId] = useState(null); // Sort: attention first, then scheduled, then most-recently-updated. const sorted = useMemo( @@ -220,13 +228,44 @@ const WorkspaceRail = ({ return (
onSelect(ws.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') onSelect(ws.id); }} + onDragOver={(e) => { + if (!onArtifactDrop) return; + if (!e.dataTransfer.types.includes('application/x-clai-artifact')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + if (dropTargetId !== ws.id) setDropTargetId(ws.id); + }} + onDragLeave={() => { + setDropTargetId((prev) => (prev === ws.id ? null : prev)); + }} + onDrop={(e) => { + if (!onArtifactDrop) return; + const raw = e.dataTransfer.getData('application/x-clai-artifact'); + setDropTargetId(null); + if (!raw) return; + e.preventDefault(); + try { + const drag = JSON.parse(raw); + if ( + drag && + typeof drag.path === 'string' && + typeof drag.workspaceId === 'string' + ) { + onArtifactDrop(ws.id, drag); + } + } catch { + // Ignore malformed / foreign drops. + } + }} title={collapsed ? ws.title : undefined} > { const [workspaces, setWorkspaces] = useState([]); const [error, setError] = useState(''); + const [flash, setFlash] = useState(''); const [collapsed, setCollapsed] = useState(() => { try { return localStorage.getItem(COLLAPSED_KEY) === '1'; @@ -373,6 +375,30 @@ const FleetLayout = () => { } }, [schedulerPaused, schedulerPauseBusy]); + const handleArtifactDrop = useCallback( + async ( + destWorkspaceId: string, + drag: { workspaceId: string; path: string; kind: string; name: string } + ) => { + if (destWorkspaceId === drag.workspaceId) { + setFlash('That artifact is already in this workspace.'); + window.setTimeout(() => setFlash(''), 3000); + return; + } + try { + await copyWorkspacePath(drag.workspaceId, drag.path, destWorkspaceId); + const destTitle = + workspaces.find((w) => w.id === destWorkspaceId)?.title || 'workspace'; + setError(''); + setFlash(`Copied “${drag.name}” to ${destTitle}.`); + window.setTimeout(() => setFlash(''), 3000); + } catch (err) { + setError(errText(err, `Failed to copy “${drag.name}”.`)); + } + }, + [workspaces] + ); + return (
@@ -415,6 +441,7 @@ const FleetLayout = () => {
{error &&
{error}
} + {flash &&
{flash}
}
{ schedulerPaused={schedulerPaused} schedulerPauseBusy={schedulerPauseBusy} onToggleSchedulerPaused={handleToggleSchedulerPaused} + onArtifactDrop={handleArtifactDrop} />
diff --git a/src/pages/Workspace.tsx b/src/pages/Workspace.tsx index 777660f..7a1f150 100644 --- a/src/pages/Workspace.tsx +++ b/src/pages/Workspace.tsx @@ -571,12 +571,20 @@ const TrashGlyph = () => ( interface ArtifactTreeRowProps { row: ArtifactRow; isExpanded: boolean; + workspaceId: string; onToggle: (path: string) => void; onSelect?: (entry: WorkspaceFileEntry) => void; onDelete?: (entry: WorkspaceDirEntry) => void; } -const ArtifactTreeRow = ({ row, isExpanded, onToggle, onSelect, onDelete }: ArtifactTreeRowProps) => { +const ArtifactTreeRow = ({ + row, + isExpanded, + workspaceId, + onToggle, + onSelect, + onDelete, +}: ArtifactTreeRowProps) => { // Two-click delete (Insomnia-style): the first click ARMS the button (turns // red), the second click deletes. No blocking dialog — window.confirm does // not reliably block in the Tauri webview, and a popup per delete is @@ -617,7 +625,20 @@ const ArtifactTreeRow = ({ row, isExpanded, onToggle, onSelect, onDelete }: Arti else onSelect?.(dirEntryToFileEntry(entry)); }; return ( -
+
{ + // Payload for cross-workspace copy: dropped onto a rail row in + // WorkspaceRail. A custom MIME keeps it distinct from OS file drops. + event.dataTransfer.setData( + 'application/x-clai-artifact', + JSON.stringify({ workspaceId, path: entry.path, kind: entry.kind, name: entry.name }) + ); + event.dataTransfer.effectAllowed = 'copy'; + }} + >
- {error &&
{error}
} - {flash &&
{flash}
} -
+ {error &&
{error}
} + {flash &&
{flash}
}
- {error &&
{error}
} - {flash &&
{flash}
} + {(error || flash) && ( +
+ {error &&
{error}
} + {flash &&
{flash}
} +
+ )}