diff --git a/src-tauri/src/commands/system_apps.rs b/src-tauri/src/commands/system_apps.rs index 26ac114..5000d07 100644 --- a/src-tauri/src/commands/system_apps.rs +++ b/src-tauri/src/commands/system_apps.rs @@ -103,15 +103,28 @@ pub fn workspace_import_files( let mut copied = Vec::new(); for source in &source_paths { let source = Path::new(source); - if !source.is_file() { - return Err(format!("`{}` is not a regular file.", source.display())); + // Accept a regular file OR a directory (imported recursively). Anything + // else (missing path, socket, fifo, …) is rejected. Symlinked dirs the + // user explicitly picks are followed; symlinks *inside* a copied tree + // are skipped by the recursive copy (avoids loops / escaping the source). + let is_dir = source.is_dir(); + if !is_dir && !source.is_file() { + return Err(format!( + "`{}` is not a regular file or directory.", + source.display() + )); } let name = source .file_name() .ok_or_else(|| format!("`{}` has no file name.", source.display()))? .to_string_lossy() .to_string(); - let dest = copy_to_unique_destination(source, &dest_dir, &name)?; + // Reuse the hardened recursive copy from `commands::workspace` (folder + // copy skips symlinks + protected/heavy dirs like `.git`/`target`, and + // is collision-safe via the shared naming helper). + let dest = crate::commands::workspace::copy_artifact_to_unique_destination( + source, &dest_dir, &name, is_dir, + )?; copied.push( dest.file_name() .map(|n| n.to_string_lossy().to_string()) diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index c9d3037..5120268 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -2164,7 +2164,7 @@ fn resolve_copy_source(source_root: &Path, rel: &str) -> Result<(PathBuf, bool, /// 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( +pub(crate) fn copy_artifact_to_unique_destination( source: &Path, dest_dir: &Path, name: &str, diff --git a/src/pages/Workspace.tsx b/src/pages/Workspace.tsx index 0fe0b7c..0fe38fd 100644 --- a/src/pages/Workspace.tsx +++ b/src/pages/Workspace.tsx @@ -2092,6 +2092,27 @@ const Workspace = () => { } }, [workspaceId, loadSnapshot]); + // Folder picker is separate from the file picker because native OS dialogs + // can't select files and directories at once. Folders are imported + // recursively (the backend skips symlinks + protected dirs like .git/target). + const handleAddFolder = useCallback(async () => { + try { + const picked = await openFileDialog({ + directory: true, + multiple: true, + title: 'Add folder to workspace', + }); + const paths = (Array.isArray(picked) ? picked : picked ? [picked] : []).filter( + (p): p is string => typeof p === 'string' + ); + if (paths.length === 0) return; + await importWorkspaceFiles(workspaceId, paths); + await loadSnapshot(false); + } catch (err) { + setError(errorMessage(err, 'Failed to add folder.')); + } + }, [workspaceId, loadSnapshot]); + return (
{ +