diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 595b5dacc..794b06a89 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,6 +25,15 @@ const SETTINGS_FILE_NAME: &str = "settings.toml"; const PROJECT_SETTINGS_FILE_NAME: &str = "project.toml"; const PROJECT_FOLDER: &str = "zoo-modeling-app-projects"; +#[tauri::command] +async fn rename_project_directory(project_path: &str, new_name: &str) -> Result { + let project_dir = std::path::Path::new(project_path); + + kcl_lib::settings::types::file::rename_project_directory(project_dir, new_name) + .await + .map_err(InvokeError::from_anyhow) +} + #[tauri::command] fn get_initial_default_dir(app: tauri::AppHandle) -> Result { let dir = match app.path().document_dir() { @@ -389,6 +398,7 @@ fn main() -> Result<()> { write_app_settings_file, read_project_settings_file, write_project_settings_file, + rename_project_directory, ]) .plugin(tauri_plugin_cli::init()) .plugin(tauri_plugin_deep_link::init()) diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index fb29595af..026f0daae 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -26,6 +26,13 @@ export async function setState(state: ProjectState | undefined): Promise { return await invoke('set_state', { state }) } +export async function renameProjectDirectory( + projectPath: string, + newName: string +): Promise { + return invoke('rename_project_directory', { projectPath, newName }) +} + // Get the initial default dir for holding all projects. export async function getInitialDefaultDir(): Promise { if (!isTauri()) { diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 938c00a81..e828fbc09 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,5 +1,5 @@ import { FormEvent, useEffect } from 'react' -import { remove, rename } from '@tauri-apps/plugin-fs' +import { remove } from '@tauri-apps/plugin-fs' import { getNextProjectIndex, interpolateProjectNameWithIndex, @@ -35,7 +35,11 @@ import { useLspContext } from 'components/LspProvider' import { useRefreshSettings } from 'hooks/useRefreshSettings' import { LowerRightControls } from 'components/LowerRightControls' import { Project } from 'wasm-lib/kcl/bindings/Project' -import { createNewProjectDirectory, listProjects } from 'lib/tauri' +import { + createNewProjectDirectory, + listProjects, + renameProjectDirectory, +} from 'lib/tauri' // This route only opens in the Tauri desktop context for now, // as defined in Router.tsx, so we can use the Tauri APIs and types. @@ -122,10 +126,9 @@ const Home = () => { name = interpolateProjectNameWithIndex(name, nextIndex) } - await rename( + await renameProjectDirectory( await join(context.defaultDirectory, oldName), - await join(context.defaultDirectory, name), - {} + name ) return `Successfully renamed "${oldName}" to "${name}"` }, diff --git a/src/wasm-lib/kcl/src/settings/types/file.rs b/src/wasm-lib/kcl/src/settings/types/file.rs index ed997420d..99f07a7af 100644 --- a/src/wasm-lib/kcl/src/settings/types/file.rs +++ b/src/wasm-lib/kcl/src/settings/types/file.rs @@ -163,8 +163,7 @@ impl ProjectRoute { { // Get the project name. if let Some(project_name) = path - .strip_prefix(&configuration.settings.project.directory) - .unwrap() + .strip_prefix(&configuration.settings.project.directory)? .iter() .next() { @@ -347,6 +346,39 @@ where Ok(default_file.display().to_string()) } +#[cfg(not(target_arch = "wasm32"))] +/// Rename a directory for a project. +/// This returns the new path of the directory. +pub async fn rename_project_directory

(path: P, new_name: &str) -> Result +where + P: AsRef + Send, +{ + if new_name.is_empty() { + return Err(anyhow::anyhow!("New name for project cannot be empty")); + } + + // Make sure the path is a directory. + if !path.as_ref().is_dir() { + return Err(anyhow::anyhow!("Path `{}` is not a directory", path.as_ref().display())); + } + + // Make sure the new name does not exist. + let new_path = path + .as_ref() + .parent() + .ok_or_else(|| anyhow::anyhow!("Parent directory of `{}` not found", path.as_ref().display()))? + .join(new_name); + if new_path.exists() { + return Err(anyhow::anyhow!( + "Path `{}` already exists, cannot rename to an existing path", + new_path.display() + )); + } + + tokio::fs::rename(path.as_ref(), &new_path).await?; + Ok(new_path) +} + /// Information about a file or directory. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] #[ts(export)] @@ -722,4 +754,99 @@ mod tests { ); std::fs::remove_dir_all(dir).unwrap(); } + + #[tokio::test] + async fn test_rename_project_directory_empty_dir() { + let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let dir = std::env::temp_dir().join(&name); + std::fs::create_dir_all(&dir).unwrap(); + + let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let new_dir = super::rename_project_directory(&dir, &new_name).await.unwrap(); + assert_eq!(new_dir, std::env::temp_dir().join(&new_name)); + + std::fs::remove_dir_all(new_dir).unwrap(); + } + + #[tokio::test] + async fn test_rename_project_directory_empty_name() { + let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let dir = std::env::temp_dir().join(&name); + std::fs::create_dir_all(&dir).unwrap(); + + let result = super::rename_project_directory(&dir, "").await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "New name for project cannot be empty"); + + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn test_rename_project_directory_non_empty_dir() { + let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let dir = std::env::temp_dir().join(&name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("main.kcl"), vec![]).unwrap(); + + let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let new_dir = super::rename_project_directory(&dir, &new_name).await.unwrap(); + assert_eq!(new_dir, std::env::temp_dir().join(&new_name)); + + std::fs::remove_dir_all(new_dir).unwrap(); + } + + #[tokio::test] + async fn test_rename_project_directory_non_empty_dir_recursive() { + let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let dir = std::env::temp_dir().join(&name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(dir.join("assembly")).unwrap(); + std::fs::write(dir.join("assembly").join("main.kcl"), vec![]).unwrap(); + + let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let new_dir = super::rename_project_directory(&dir, &new_name).await.unwrap(); + assert_eq!(new_dir, std::env::temp_dir().join(&new_name)); + + std::fs::remove_dir_all(new_dir).unwrap(); + } + + #[tokio::test] + async fn test_rename_project_directory_dir_is_file() { + let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let dir = std::env::temp_dir().join(&name); + std::fs::write(&dir, vec![]).unwrap(); + + let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let result = super::rename_project_directory(&dir, &new_name).await; + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + format!("Path `{}` is not a directory", dir.display()) + ); + + std::fs::remove_file(dir).unwrap(); + } + + #[tokio::test] + async fn test_rename_project_directory_new_name_exists() { + let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let dir = std::env::temp_dir().join(&name); + std::fs::create_dir_all(&dir).unwrap(); + + let new_name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); + let new_dir = std::env::temp_dir().join(&new_name); + std::fs::create_dir_all(&new_dir).unwrap(); + + let result = super::rename_project_directory(&dir, &new_name).await; + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + format!( + "Path `{}` already exists, cannot rename to an existing path", + new_dir.display() + ) + ); + + std::fs::remove_dir_all(new_dir).unwrap(); + } }