Skip to content

feat(artifacts): drag-and-drop copy of artifacts between workspaces#109

Merged
juacker merged 5 commits into
mainfrom
feat/artifact-copy-drag
Jul 12, 2026
Merged

feat(artifacts): drag-and-drop copy of artifacts between workspaces#109
juacker merged 5 commits into
mainfrom
feat/artifact-copy-drag

Conversation

@juacker

@juacker juacker commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

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

  • Drag source — artifact tree rows are draggable; the drag payload ({ workspaceId, path, kind, name }) travels in a custom application/x-clai-artifact MIME so it's distinct from OS file drops.
  • Drop target — workspace rail rows highlight on drag-over and, on drop, call the new workspace_copy_path command. 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 (foofoo (1) → …). Per the agreed defaults:

  • Copy, never move — a drag can't destroy the source.
  • Destination = target workspace root, (n) suffix on collision (matches "+ Add files" import semantics).
  • Folders allowed (recursive). Symlinks are skipped (no loops / tree escape) and protected/heavy dirs (.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, now pub(crate)).

Verification

  • Backend: cargo fmt / clippy -D warnings clean; new unit test covers file suffixing, recursive dir copy, and the symlink + .git skip.
  • Frontend: tsc / eslint / 162 vitest / vite build clean. No bindings change (request struct is Deserialize-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.

juacker added 5 commits July 12, 2026 17:00
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
juacker marked this pull request as ready for review July 12, 2026 15:59
@juacker
juacker merged commit 6f2698b into main Jul 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant