Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src-tauri/src/commands/system_apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={styles.workspacePage}>
<WorkspaceHeader
Expand Down Expand Up @@ -2195,6 +2216,29 @@ const Workspace = () => {
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
</button>
<button
type="button"
className={styles.workspaceDrawerIconAction}
onClick={handleAddFolder}
title="Add a folder to the workspace (recursive)"
aria-label="Add a folder to the workspace (recursive)"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
<line x1="12" y1="10" x2="12" y2="16" />
<line x1="9" y1="13" x2="15" y2="13" />
</svg>
</button>
<button
type="button"
className={styles.workspaceDrawerIconAction}
Expand Down
Loading