feat(artifacts): drag-and-drop copy of artifacts between workspaces#109
Merged
Conversation
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.
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 <workspace>") or reports failure; dropping
onto the source workspace is a no-op with a hint.
The Windows CI job failed to compile the test: `std::os::unix::fs::symlink` doesn't exist on Windows. Keep the file/dir/collision asserts cross-platform and gate only the symlink creation + assertion behind #[cfg(unix)].
Two UX fixes surfaced while testing the artifact drag-copy: 1. Banners no longer reflow the app. The error/flash banners were flex children of the layout column, so showing one pushed the rail + detail down — during a drag-drop the rail row slid out from under the cursor. They now render inside .body as an absolute, shadowed overlay (top, full width): same visual spot, but they float over content instead of moving it. Fixes it for ALL banners, and keeps --fleet-content-top / composer alignment stable since .body's top no longer shifts. 2. Drag ghost excludes the trash button. Dragging an artifact used the whole row wrapper as the drag image, so the hover-only delete/trash icon bled into it. setDragImage now uses the row button (name + icon).
Addresses the static-review findings on PR #109: - SECURITY (blocker): the copy source is now validated by resolve_copy_source — rejects a symlink item, canonicalizes the requested path and requires it to stay within the canonicalized workspace root, so a top-level OR intermediate symlinked parent can no longer escape the root and exfiltrate an external file. Previously containment was lexical only and the file branch's File::open followed symlinks. - SECURITY (major): protected dirs (.git/node_modules/.clai/target/…) are now rejected at ANY depth of the requested path, not just the first component, so a crafted `proj/.git/config` can't be copied directly. - CONCURRENCY (major): the unbounded traversal + copy now runs in spawn_blocking instead of on the async executor thread. - FE minors: rail drop guard also validates kind+name; error/flash banners render in a .bannerStack overlay (no overlap); flash uses a single cleared timer (no stacked timers / unmount leak). New unit tests cover the symlink item, intermediate symlink escape, nested protected component, and traversal rejection. Rejected as false positives (verified against the code): the "partial dir not cleaned up" finding (the caller already remove_dir_all's the candidate on any recursive error) and the "nested protected dirs copied during recursion" finding (should_skip_artifact_dir runs per-entry at every depth).
juacker
marked this pull request as ready for review
July 12, 2026 15:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
PR B of the artifacts trio: drag a file or folder from the artifacts drawer onto another workspace in the rail to copy it there. No more manual export/import to move an artifact between workspaces.
How it works
draggable; the drag payload ({ workspaceId, path, kind, name }) travels in a customapplication/x-clai-artifactMIME so it's distinct from OS file drops.workspace_copy_pathcommand. A transient banner confirms "Copied «x» to " (or reports failure); dropping onto the source workspace is a no-op with a hint.Backend (
workspace_copy_path)Resolves both workspace roots, enforces containment + the protected-path guard on the source, and copies into the destination root under a collision-free name (
foo→foo (1)→ …). Per the agreed defaults:(n)suffix on collision (matches "+ Add files" import semantics)..git,node_modules,target, …) are skipped so a folder copy doesn't drag in VCS/build state.Reuses the exclusive-create collision helpers from the import path (
destination_candidate/copy_to_unique_destination, nowpub(crate)).Verification
cargo fmt/clippy -D warningsclean; new unit test covers file suffixing, recursive dir copy, and the symlink +.gitskip.tsc/eslint/ 162 vitest /vite buildclean. No bindings change (request struct isDeserialize-only).Test locally
Open a workspace, drag a file (and a folder) from the artifacts drawer onto a different workspace in the left rail → the row highlights, drop copies it, and the banner confirms. Open that workspace to see the copy at its root; a name collision gets a
(n)suffix. Dropping onto the same workspace shows the "already here" hint.