diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e88b7d1c..f80e351a1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -90,6 +90,54 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "anyhow" version = "1.0.82" @@ -110,6 +158,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-cli", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", @@ -695,6 +744,33 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + [[package]] name = "cocoa" version = "0.25.0" @@ -725,6 +801,12 @@ dependencies = [ "objc", ] +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "colored" version = "2.1.0" @@ -915,7 +997,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 2.0.60", ] @@ -2321,7 +2403,7 @@ dependencies = [ [[package]] name = "kcl-lib" -version = "0.1.51" +version = "0.1.52" dependencies = [ "anyhow", "approx", @@ -4580,6 +4662,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "structmeta" version = "0.2.0" @@ -4958,6 +5046,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-cli" +version = "2.0.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b079f01e923f7d3bf175e8d31b18861e6580f4b57ce0fdc16fbf69f9acd158c" +dependencies = [ + "clap", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror", +] + [[package]] name = "tauri-plugin-dialog" version = "2.0.0-beta.6" @@ -5820,6 +5923,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "uuid" version = "1.8.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index db98c75a6..487e8c0de 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,11 +15,12 @@ tauri-build = { version = "2.0.0-beta.13", features = [] } [dependencies] anyhow = "1" -kcl-lib = { version = "0.1.51", path = "../src/wasm-lib/kcl" } +kcl-lib = { version = "0.1.52", path = "../src/wasm-lib/kcl" } kittycad = "0.3.0" oauth2 = "4.4.2" serde_json = "1.0" tauri = { version = "2.0.0-beta.15", features = [ "devtools", "unstable"] } +tauri-plugin-cli = { version = "2.0.0-beta.3" } tauri-plugin-dialog = { version = "2.0.0-beta.6" } tauri-plugin-fs = { version = "2.0.0-beta.6" } tauri-plugin-http = { version = "2.0.0-beta.6" } diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index bb7b04b03..b80969817 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -7,6 +7,7 @@ "main" ], "permissions": [ + "cli:default", "path:default", "event:default", "window:default", @@ -23,7 +24,6 @@ "fs:allow-copy-file", "fs:allow-mkdir", "fs:allow-remove", - "fs:allow-remove", "fs:allow-rename", "fs:allow-exists", "fs:allow-stat", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ab929791a..155c36c66 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,8 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +pub(crate) mod state; + use std::{ env, path::{Path, PathBuf}, @@ -9,12 +11,13 @@ use std::{ use anyhow::Result; use kcl_lib::settings::types::{ - file::{FileEntry, Project}, + file::{FileEntry, Project, ProjectState}, project::ProjectConfiguration, - Configuration, + Configuration, DEFAULT_PROJECT_KCL_FILE, }; use oauth2::TokenResponse; use tauri::{ipc::InvokeError, Manager}; +use tauri_plugin_cli::CliExt; use tauri_plugin_shell::ShellExt; const DEFAULT_HOST: &str = "https://api.zoo.dev"; @@ -36,6 +39,19 @@ fn get_initial_default_dir(app: tauri::AppHandle) -> Result Result, InvokeError> { + let store = app.state::(); + Ok(store.get().await) +} + +#[tauri::command] +async fn set_state(app: tauri::AppHandle, state: Option) -> Result<(), InvokeError> { + let store = app.state::(); + store.set(state).await; + Ok(()) +} + fn get_app_settings_file_path(app: &tauri::AppHandle) -> Result { let app_config_dir = app.path().app_config_dir()?; Ok(app_config_dir.join(SETTINGS_FILE_NAME)) @@ -172,9 +188,9 @@ async fn list_projects(configuration: Configuration) -> Result, Inv /// Get information about a project. #[tauri::command] -async fn get_project_info(configuration: Configuration, project_name: &str) -> Result { +async fn get_project_info(configuration: Configuration, project_path: &str) -> Result { configuration - .get_project_info(project_name) + .get_project_info(project_path) .await .map_err(InvokeError::from_anyhow) } @@ -328,6 +344,8 @@ fn main() -> Result<()> { Ok(()) }) .invoke_handler(tauri::generate_handler![ + get_state, + set_state, get_initial_default_dir, initialize_project_directory, create_new_project_directory, @@ -342,6 +360,113 @@ fn main() -> Result<()> { read_project_settings_file, write_project_settings_file, ]) + .plugin(tauri_plugin_cli::init()) + .setup(|app| { + let mut verbose = false; + let mut source_path: Option = None; + match app.cli().matches() { + // `matches` here is a Struct with { args, subcommand }. + // `args` is `HashMap` where `ArgData` is a struct with { value, occurrences }. + // `subcommand` is `Option>` where `SubcommandMatches` is a struct with { name, matches }. + Ok(matches) => { + if let Some(verbose_flag) = matches.args.get("verbose") { + let Some(value) = verbose_flag.value.as_bool() else { + return Err( + anyhow::anyhow!("Error parsing CLI arguments: verbose flag is not a boolean").into(), + ); + }; + verbose = value; + } + + // Get the path we are trying to open. + if let Some(source_arg) = matches.args.get("source") { + // We don't do an else here because this can be null. + if let Some(value) = source_arg.value.as_str() { + source_path = Some(Path::new(value).to_path_buf()); + } + } + } + Err(err) => { + return Err(anyhow::anyhow!("Error parsing CLI arguments: {:?}", err).into()); + } + } + + // If we have a source path to open, make sure it exists. + let Some(source_path) = source_path else { + // The user didn't provide a source path to open. + // Run the app as normal. + app.manage(state::Store::default()); + return Ok(()); + }; + + if !source_path.exists() { + return Err(anyhow::anyhow!( + "Error: the path `{}` you are trying to open does not exist", + source_path.display() + ) + .into()); + } + + let runner: tauri::async_runtime::JoinHandle> = + tauri::async_runtime::spawn(async move { + // If the path is a directory, let's assume it is a project directory. + if source_path.is_dir() { + // Load the details about the project from the path. + let project = Project::from_path(&source_path).await.map_err(|e| { + anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e) + })?; + + if verbose { + println!("Project loaded from path: {}", source_path.display()); + } + + // Create the default file in the project. + // Write the initial project file. + let project_file = source_path.join(DEFAULT_PROJECT_KCL_FILE); + tokio::fs::write(&project_file, vec![]).await?; + + return Ok(ProjectState { + project, + current_file: Some(project_file.display().to_string()), + }); + } + + // We were given a file path, not a directory. + // Let's get the parent directory of the file. + let parent = source_path.parent().ok_or_else(|| { + anyhow::anyhow!( + "Error getting the parent directory of the file: {}", + source_path.display() + ) + })?; + + // Load the details about the project from the parent directory. + let project = Project::from_path(&parent).await.map_err(|e| { + anyhow::anyhow!("Error loading project from path {}: {:?}", source_path.display(), e) + })?; + + if verbose { + println!( + "Project loaded from path: {}, current file: {}", + parent.display(), + source_path.display() + ); + } + + Ok(ProjectState { + project, + current_file: Some(source_path.display().to_string()), + }) + }); + + // Block on the handle. + let store = tauri::async_runtime::block_on(runner)??; + + // Create a state object to hold the project. + app.manage(state::Store::new(store)); + + Ok(()) + }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_http::init()) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 000000000..310f262ea --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,21 @@ +//! State management for the application. + +use kcl_lib::settings::types::file::ProjectState; +use tokio::sync::Mutex; + +#[derive(Debug, Default)] +pub struct Store(Mutex>); + +impl Store { + pub fn new(p: ProjectState) -> Self { + Self(Mutex::new(Some(p))) + } + + pub async fn get(&self) -> Option { + self.0.lock().await.clone() + } + + pub async fn set(&self, p: Option) { + *self.0.lock().await = p; + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d61125a8a..a97b7d656 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -50,6 +50,22 @@ }, "identifier": "dev.zoo.modeling-app", "plugins": { + "cli": { + "description": "Zoo Modeling App CLI", + "args": [ + { + "short": "v", + "name": "verbose", + "description": "Verbosity level" + }, + { + "name": "source", + "index": 1, + "takesValue": true + } + ], + "subcommands": {} + }, "shell": { "open": true } diff --git a/src/Router.tsx b/src/Router.tsx index 27c2d5ebf..baba20407 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -30,6 +30,7 @@ import SettingsAuthProvider from 'components/SettingsAuthProvider' import LspProvider from 'components/LspProvider' import { KclContextProvider } from 'lang/KclProvider' import { BROWSER_PROJECT_NAME } from 'lib/constants' +import { getState, setState } from 'lib/tauri' const router = createBrowserRouter([ { @@ -52,10 +53,30 @@ const router = createBrowserRouter([ children: [ { path: paths.INDEX, - loader: () => - isTauri() + loader: async () => { + const inTauri = isTauri() + if (inTauri) { + const appState = await getState() + + if (appState) { + console.log('appState', appState) + // Reset the state. + // We do this so that we load the initial state from the cli but everything + // else we can ignore. + await setState(undefined) + // Redirect to the file if we have a file path. + if (appState.current_file) { + return redirect( + paths.FILE + '/' + encodeURIComponent(appState.current_file) + ) + } + } + } + + return inTauri ? redirect(paths.HOME) - : redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME), + : redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME) + }, }, { loader: fileLoader, diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx index 7e1646bf5..2665927f0 100644 --- a/src/components/FileMachineProvider.tsx +++ b/src/components/FileMachineProvider.tsx @@ -62,7 +62,7 @@ export const FileMachineProvider = ({ services: { readFiles: async (context: ContextFrom) => { const newFiles = isTauri() - ? (await getProjectInfo(context.project.name)).children + ? (await getProjectInfo(context.project.path)).children : [] return { ...context.project, diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index 48b4da1e9..ce6882062 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -25,7 +25,9 @@ import { createSettings } from './settings/initialSettings' // occurred during the settings load export const settingsLoader: LoaderFunction = async ({ params, -}): Promise> => { +}): Promise< + ReturnType | ReturnType +> => { let { settings } = await loadAndValidateSettings() // I don't love that we have to read the settings again here, @@ -105,7 +107,7 @@ export const fileLoader: LoaderFunction = async ({ const projectData: IndexLoaderData = { code, project: isTauri() - ? await getProjectInfo(projectName) + ? await getProjectInfo(projectPath) : { name: projectName, path: projectPath, diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ecaf90b4c..692d7047c 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -6,6 +6,17 @@ import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' import { Project } from 'wasm-lib/kcl/bindings/Project' import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry' +import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState' + +// Get the app state from tauri. +export async function getState(): Promise { + return await invoke('get_state') +} + +// Set the app state in tauri. +export async function setState(state: ProjectState | undefined): Promise { + return await invoke('set_state', { state }) +} // Get the initial default dir for holding all projects. export async function getInitialDefaultDir(): Promise { @@ -53,7 +64,7 @@ export async function listProjects( } export async function getProjectInfo( - projectName: string, + projectPath: string, configuration?: Configuration ): Promise { if (!configuration) { @@ -61,7 +72,7 @@ export async function getProjectInfo( } return await invoke('get_project_info', { configuration, - projectName, + projectPath, }) } diff --git a/src/wasm-lib/Cargo.lock b/src/wasm-lib/Cargo.lock index be932d1c7..8983bffd1 100644 --- a/src/wasm-lib/Cargo.lock +++ b/src/wasm-lib/Cargo.lock @@ -1895,7 +1895,7 @@ dependencies = [ [[package]] name = "kcl-lib" -version = "0.1.51" +version = "0.1.52" dependencies = [ "anyhow", "approx 0.5.1", diff --git a/src/wasm-lib/kcl/Cargo.toml b/src/wasm-lib/kcl/Cargo.toml index 972797c28..6f33242db 100644 --- a/src/wasm-lib/kcl/Cargo.toml +++ b/src/wasm-lib/kcl/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "kcl-lib" description = "KittyCAD Language implementation and tools" -version = "0.1.51" +version = "0.1.52" edition = "2021" license = "MIT" repository = "https://github.com/KittyCAD/modeling-app" diff --git a/src/wasm-lib/kcl/src/settings/types/file.rs b/src/wasm-lib/kcl/src/settings/types/file.rs index 7cb6f4efc..e366c0496 100644 --- a/src/wasm-lib/kcl/src/settings/types/file.rs +++ b/src/wasm-lib/kcl/src/settings/types/file.rs @@ -5,6 +5,15 @@ use parse_display::{Display, FromStr}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// State management for the application. +#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub struct ProjectState { + pub project: Project, + pub current_file: Option, +} + /// Information about project. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] #[ts(export)] @@ -23,6 +32,34 @@ pub struct Project { } impl Project { + #[cfg(not(target_arch = "wasm32"))] + /// Populate a project from a path. + pub async fn from_path>(path: P) -> Result { + // Check if they are using '.' as the path. + let path = if path.as_ref() == std::path::Path::new(".") { + std::env::current_dir()? + } else { + path.as_ref().to_path_buf() + }; + + // Make sure the path exists. + if !path.exists() { + return Err(anyhow::anyhow!("Path does not exist")); + } + + let file = crate::settings::utils::walk_dir(&path).await?; + let metadata = std::fs::metadata(path).ok().map(|m| m.into()); + let mut project = Self { + file, + metadata, + kcl_file_count: 0, + directory_count: 0, + }; + project.populate_kcl_file_count()?; + project.populate_directory_count()?; + Ok(project) + } + /// Populate the number of KCL files in the project. pub fn populate_kcl_file_count(&mut self) -> Result<()> { let mut count = 0; diff --git a/src/wasm-lib/kcl/src/settings/types/mod.rs b/src/wasm-lib/kcl/src/settings/types/mod.rs index f14384238..ef762ad29 100644 --- a/src/wasm-lib/kcl/src/settings/types/mod.rs +++ b/src/wasm-lib/kcl/src/settings/types/mod.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use validator::{Validate, ValidateRange}; const DEFAULT_THEME_COLOR: f64 = 264.5; -const DEFAULT_PROJECT_KCL_FILE: &str = "main.kcl"; +pub const DEFAULT_PROJECT_KCL_FILE: &str = "main.kcl"; const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "project-$nnn"; /// High level configuration. @@ -129,7 +129,7 @@ impl Configuration { continue; } - projects.push(self.get_project_info(&e.file_name().to_string_lossy()).await?); + projects.push(self.get_project_info(&e.path().display().to_string()).await?); } Ok(projects) @@ -137,21 +137,20 @@ impl Configuration { #[cfg(not(target_arch = "wasm32"))] /// Get information about a project. - pub async fn get_project_info(&self, project_name: &str) -> Result { - let main_dir = &self.ensure_project_directory_exists().await?; - - if project_name.is_empty() { - return Err(anyhow::anyhow!("Project name cannot be empty.")); + pub async fn get_project_info(&self, project_path: &str) -> Result { + // Check the directory. + let project_dir = std::path::Path::new(project_path); + if !project_dir.exists() { + return Err(anyhow::anyhow!("Project directory does not exist: {}", project_path)); } - // Check the directory. - let project_dir = main_dir.join(project_name); - if !project_dir.exists() { - return Err(anyhow::anyhow!("Project directory does not exist.")); + // Make sure it is a directory. + if !project_dir.is_dir() { + return Err(anyhow::anyhow!("Project path is not a directory: {}", project_path)); } let mut project = crate::settings::types::file::Project { - file: crate::settings::utils::walk_dir(&project_dir).await?, + file: crate::settings::utils::walk_dir(project_dir).await?, metadata: Some(tokio::fs::metadata(&project_dir).await?.into()), kcl_file_count: 0, directory_count: 0, diff --git a/src/wasm-lib/kcl/src/settings/utils.rs b/src/wasm-lib/kcl/src/settings/utils.rs index 59bc0503a..b41ef9675 100644 --- a/src/wasm-lib/kcl/src/settings/utils.rs +++ b/src/wasm-lib/kcl/src/settings/utils.rs @@ -1,6 +1,6 @@ //! Utility functions for settings. -use std::path::PathBuf; +use std::path::Path; use anyhow::Result; @@ -8,20 +8,21 @@ use crate::settings::types::file::FileEntry; /// Walk a directory recursively and return a list of all files. #[async_recursion::async_recursion] -pub async fn walk_dir(dir: &PathBuf) -> Result { +pub async fn walk_dir + Send>(dir: P) -> Result { let mut entry = FileEntry { name: dir + .as_ref() .file_name() .ok_or_else(|| anyhow::anyhow!("No file name"))? .to_string_lossy() .to_string(), - path: dir.display().to_string(), + path: dir.as_ref().display().to_string(), children: None, }; let mut children = vec![]; - let mut entries = tokio::fs::read_dir(&dir).await?; + let mut entries = tokio::fs::read_dir(&dir.as_ref()).await?; while let Some(e) = entries.next_entry().await? { if e.file_type().await?.is_dir() { children.push(walk_dir(&e.path()).await?); @@ -34,9 +35,8 @@ pub async fn walk_dir(dir: &PathBuf) -> Result { } } - if !children.is_empty() { - entry.children = Some(children); - } + // We don't set this to none if there are no children, because it's a directory. + entry.children = Some(children); Ok(entry) }