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..c9d3037 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -2042,6 +2042,192 @@ 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 (source, is_dir, name) = resolve_copy_source(source_root, &request.path)?; + + // The traversal + copy is unbounded (a folder can be arbitrarily large), + // so run it off the async executor thread. + let dest_root = dest_root.clone(); + let name_for_copy = name.clone(); + let created = tokio::task::spawn_blocking(move || { + copy_artifact_to_unique_destination(&source, &dest_root, &name_for_copy, is_dir) + }) + .await + .map_err(|error| format!("Copy task did not complete: {}", error))??; + + Ok(created + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&name) + .to_string()) +} + +/// Validate + resolve a copy *source* path relative to `source_root`. +/// +/// Rejects: the root itself, traversal/non-normal components, a protected dir +/// (`SKIPPED_ARTIFACT_DIRS`) at ANY depth (not just the first component), and a +/// symlink item. Resolves intermediate symlinks via `canonicalize` and +/// requires the canonical path to stay within the canonical root, so a +/// symlinked parent component can't escape the workspace. Returns +/// `(canonical source, is_dir, display name)`. +fn resolve_copy_source(source_root: &Path, rel: &str) -> Result<(PathBuf, bool, String), String> { + let rel = rel.trim(); + if rel.is_empty() { + return Err("Cannot copy the workspace root.".to_string()); + } + let relative = Path::new(rel); + for component in relative.components() { + match component { + Component::Normal(name) => { + if name + .to_str() + .map(|n| SKIPPED_ARTIFACT_DIRS.contains(&n)) + .unwrap_or(false) + { + return Err(format!("Refusing to copy a protected path: {}", rel)); + } + } + Component::CurDir => {} + // `..`, absolute prefixes, root: refuse before touching the fs. + _ => return Err(format!("Path {} is outside the workspace root", rel)), + } + } + + let requested = normalize_path(source_root.join(relative)); + if !requested.starts_with(source_root) { + return Err(format!("Path {} is outside the workspace root", rel)); + } + if requested == *source_root { + return Err("Cannot copy the workspace root.".to_string()); + } + + let link_meta = fs::symlink_metadata(&requested) + .map_err(|error| format!("Path not found: {}: {}", rel, error))?; + if link_meta.file_type().is_symlink() { + return Err(format!("Refusing to copy a symlink: {}", rel)); + } + + // Resolve intermediate symlinks and re-check containment. + let canon_root = source_root + .canonicalize() + .map_err(|error| format!("Failed to resolve the source workspace root: {}", error))?; + let canon_source = requested + .canonicalize() + .map_err(|error| format!("Failed to resolve {}: {}", rel, error))?; + if !canon_source.starts_with(&canon_root) { + return Err(format!("Path {} resolves outside the workspace root", rel)); + } + + let name = requested + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("`{}` has no file name.", rel))? + .to_string(); + + Ok((canon_source, link_meta.file_type().is_dir(), name)) +} + +/// 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 +3438,89 @@ 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() { + 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(); + #[cfg(unix)] + std::os::unix::fs::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"); + #[cfg(unix)] + assert!(!out.join("link").exists(), "symlink must be skipped"); + } + + #[test] + fn resolve_copy_source_guards_traversal_and_protected() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("proj/.git")).unwrap(); + std::fs::write(root.path().join("proj/.git/config"), b"x").unwrap(); + std::fs::write(root.path().join("proj/a.txt"), b"a").unwrap(); + + let (src, is_dir, name) = resolve_copy_source(root.path(), "proj/a.txt").unwrap(); + assert!(!is_dir); + assert_eq!(name, "a.txt"); + assert!(src.ends_with("a.txt")); + + // Protected dir at a non-leading component must be rejected (not just + // the first-component delete policy). + assert!(resolve_copy_source(root.path(), "proj/.git/config").is_err()); + // Traversal / non-normal components rejected before touching the fs. + assert!(resolve_copy_source(root.path(), "../etc/hosts").is_err()); + assert!(resolve_copy_source(root.path(), "proj/../../etc").is_err()); + // Empty / root rejected. + assert!(resolve_copy_source(root.path(), " ").is_err()); + } + + #[cfg(unix)] + #[test] + fn resolve_copy_source_rejects_symlink_item_and_escaping_parent() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret"), b"s").unwrap(); + + // A top-level symlink item pointing outside is rejected as a symlink. + std::os::unix::fs::symlink(outside.path().join("secret"), root.path().join("link")) + .unwrap(); + assert!(resolve_copy_source(root.path(), "link").is_err()); + + // An intermediate symlinked directory that escapes the root is rejected + // by canonical containment (the final component is a real file). + std::os::unix::fs::symlink(outside.path(), root.path().join("outdir")).unwrap(); + assert!(resolve_copy_source(root.path(), "outdir/secret").is_err()); + } } 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, 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..bc82503 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,46 @@ 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' && + typeof drag.kind === 'string' && + typeof drag.name === '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 flashTimerRef = useRef(null); const [collapsed, setCollapsed] = useState(() => { try { return localStorage.getItem(COLLAPSED_KEY) === '1'; @@ -373,6 +376,44 @@ const FleetLayout = () => { } }, [schedulerPaused, schedulerPauseBusy]); + // Transient flash toast with a single, cleared timer (no stacked timers). + const showFlash = useCallback((message: string) => { + if (flashTimerRef.current !== null) window.clearTimeout(flashTimerRef.current); + setFlash(message); + flashTimerRef.current = window.setTimeout(() => { + setFlash(''); + flashTimerRef.current = null; + }, 3000); + }, []); + useEffect( + () => () => { + if (flashTimerRef.current !== null) window.clearTimeout(flashTimerRef.current); + }, + [] + ); + + const handleArtifactDrop = useCallback( + async ( + destWorkspaceId: string, + drag: { workspaceId: string; path: string; kind: string; name: string } + ) => { + if (destWorkspaceId === drag.workspaceId) { + showFlash('That artifact is already in this workspace.'); + return; + } + try { + await copyWorkspacePath(drag.workspaceId, drag.path, destWorkspaceId); + const destTitle = + workspaces.find((w) => w.id === destWorkspaceId)?.title || 'workspace'; + setError(''); + showFlash(`Copied “${drag.name}” to ${destTitle}.`); + } catch (err) { + setError(errText(err, `Failed to copy “${drag.name}”.`)); + } + }, + [workspaces, showFlash] + ); + return (
@@ -414,9 +455,13 @@ const FleetLayout = () => {
- {error &&
{error}
} -
+ {(error || flash) && ( +
+ {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..0fe0b7c 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,24 @@ 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'; + // Drag ghost = the row itself (name + icon), not the whole wrapper — + // otherwise the hover-only delete/trash button bleeds into the image. + const rowEl = event.currentTarget.querySelector('button'); + if (rowEl) event.dataTransfer.setDragImage(rowEl, 16, 12); + }} + >