diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7c601cac1..6a06b164d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2418,7 +2418,7 @@ dependencies = [ [[package]] name = "kcl-lib" -version = "0.1.52" +version = "0.1.53" dependencies = [ "anyhow", "approx", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 487e8c0de..40ed63a3f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,7 +15,7 @@ tauri-build = { version = "2.0.0-beta.13", features = [] } [dependencies] anyhow = "1" -kcl-lib = { version = "0.1.52", path = "../src/wasm-lib/kcl" } +kcl-lib = { version = "0.1.53", path = "../src/wasm-lib/kcl" } kittycad = "0.3.0" oauth2 = "4.4.2" serde_json = "1.0" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 013dd97c7..4f13d4ed8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -11,7 +11,7 @@ use std::{ use anyhow::Result; use kcl_lib::settings::types::{ - file::{FileEntry, Project, ProjectState}, + file::{FileEntry, Project, ProjectRoute, ProjectState}, project::ProjectConfiguration, Configuration, DEFAULT_PROJECT_KCL_FILE, }; @@ -209,6 +209,12 @@ async fn get_project_info(configuration: Configuration, project_path: &str) -> R .map_err(InvokeError::from_anyhow) } +/// Parse the project route. +#[tauri::command] +async fn parse_project_route(configuration: Configuration, route: &str) -> Result { + ProjectRoute::from_route(&configuration, route).map_err(InvokeError::from_anyhow) +} + #[tauri::command] async fn read_dir_recursive(path: &str) -> Result { kcl_lib::settings::utils::walk_dir(&Path::new(path).to_path_buf()) @@ -365,6 +371,7 @@ fn main() -> Result<()> { create_new_project_directory, list_projects, get_project_info, + parse_project_route, get_user, login, read_dir_recursive, @@ -423,6 +430,14 @@ fn main() -> Result<()> { let runner: tauri::async_runtime::JoinHandle> = tauri::async_runtime::spawn(async move { + // Fix for "." path, which is the current directory. + let source_path = if source_path == Path::new(".") { + std::env::current_dir() + .map_err(|e| anyhow::anyhow!("Error getting the current directory: {:?}", e))? + } else { + source_path + }; + // 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. diff --git a/src/Router.tsx b/src/Router.tsx index baba20407..d22e38eaf 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -59,7 +59,6 @@ const router = createBrowserRouter([ 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. diff --git a/src/lang/wasm.ts b/src/lang/wasm.ts index ab95a627e..8aa05b04c 100644 --- a/src/lang/wasm.ts +++ b/src/lang/wasm.ts @@ -14,6 +14,7 @@ import init, { parse_app_settings, parse_project_settings, default_project_settings, + parse_project_route, } from '../wasm-lib/pkg/wasm_lib' import { KCLError } from './errors' import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError' @@ -31,6 +32,7 @@ import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { TEST } from 'env' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' +import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' export type { Program } from '../wasm-lib/kcl/bindings/Program' export type { Value } from '../wasm-lib/kcl/bindings/Value' @@ -389,3 +391,18 @@ export function parseProjectSettings(toml: string): ProjectConfiguration { throw new Error(`Error parsing project settings: ${e}`) } } + +export function parseProjectRoute( + configuration: Configuration, + route_str: string +): ProjectRoute { + try { + const route: ProjectRoute = parse_project_route( + JSON.stringify(configuration), + route_str + ) + return route + } catch (e: any) { + throw new Error(`Error parsing project route: ${e}`) + } +} diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 05b6026ea..5f0f1794b 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -1,7 +1,11 @@ -import { sep } from '@tauri-apps/api/path' import { onboardingPaths } from 'routes/Onboarding/paths' import { BROWSER_FILE_NAME, BROWSER_PROJECT_NAME, FILE_EXT } from './constants' import { isTauri } from './isTauri' +import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' +import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' +import { parseProjectRoute, readAppSettingsFile } from './tauri' +import { parseProjectRoute as parseProjectRouteWasm } from 'lang/wasm' +import { readLocalStorageAppSettingsFile } from './settings/settingsUtils' const prependRoutes = (routesObject: Record) => (prepend: string) => { @@ -25,28 +29,23 @@ export const paths = { } as const export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}` -export function getProjectMetaByRouteId(id?: string, defaultDir = '') { +export async function getProjectMetaByRouteId( + id?: string, + configuration?: Configuration +): Promise { if (!id) return undefined - const s = isTauri() ? sep() : '/' - const decodedId = decodeURIComponent(id).replace(/\/$/, '') // remove trailing slash - const projectAndFile = - defaultDir === '/' - ? decodedId.replace(defaultDir, '') - : decodedId.replace(defaultDir + s, '') - const filePathParts = projectAndFile.split(s) - const projectName = filePathParts[0] - const projectPath = - (defaultDir === '/' ? defaultDir : defaultDir + s) + projectName - const lastPathPart = filePathParts[filePathParts.length - 1] - const currentFileName = - lastPathPart === projectName ? undefined : lastPathPart - const currentFilePath = lastPathPart === projectName ? undefined : decodedId + const inTauri = isTauri() - return { - projectName, - projectPath, - currentFileName, - currentFilePath, + if (!configuration) { + configuration = inTauri + ? await readAppSettingsFile() + : readLocalStorageAppSettingsFile() } + + const route = inTauri + ? await parseProjectRoute(configuration, id) + : parseProjectRouteWasm(configuration, id) + + return route } diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index ce6882062..33dec63ba 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -28,16 +28,18 @@ export const settingsLoader: LoaderFunction = async ({ }): Promise< ReturnType | ReturnType > => { - let { settings } = await loadAndValidateSettings() + let { settings, configuration } = await loadAndValidateSettings() // I don't love that we have to read the settings again here, // but we need to get the project path to load the project settings if (params.id) { - const defaultDir = settings.app.projectDirectory.current || '' - const projectPathData = getProjectMetaByRouteId(params.id, defaultDir) + const projectPathData = await getProjectMetaByRouteId( + params.id, + configuration + ) if (projectPathData) { - const { projectName } = projectPathData - const { settings: s } = await loadAndValidateSettings(projectName) + const { project_name } = projectPathData + const { settings: s } = await loadAndValidateSettings(project_name) settings = s } } @@ -71,17 +73,19 @@ export const onboardingRedirectLoader: ActionFunction = async (args) => { export const fileLoader: LoaderFunction = async ({ params, }): Promise => { - let { settings } = await loadAndValidateSettings() + let { configuration } = await loadAndValidateSettings() - const defaultDir = settings.app.projectDirectory.current || '/' - const projectPathData = getProjectMetaByRouteId(params.id, defaultDir) + const projectPathData = await getProjectMetaByRouteId( + params.id, + configuration + ) const isBrowserProject = params.id === decodeURIComponent(BROWSER_PATH) if (!isBrowserProject && projectPathData) { - const { projectName, projectPath, currentFileName, currentFilePath } = + const { project_name, project_path, current_file_name, current_file_path } = projectPathData - if (!currentFileName || !currentFilePath) { + if (!current_file_name || !current_file_path || !project_name) { return redirect( `${paths.FILE}/${encodeURIComponent( `${params.id}${isTauri() ? sep() : '/'}${PROJECT_ENTRYPOINT}` @@ -91,33 +95,33 @@ export const fileLoader: LoaderFunction = async ({ // TODO: PROJECT_ENTRYPOINT is hardcoded // until we support setting a project's entrypoint file - const code = await readTextFile(currentFilePath) + const code = await readTextFile(current_file_path) // Update both the state and the editor's code. // We explicitly do not write to the file here since we are loading from // the file system and not the editor. - codeManager.updateCurrentFilePath(currentFilePath) + codeManager.updateCurrentFilePath(current_file_path) codeManager.updateCodeStateEditor(code) kclManager.executeCode(true) // Set the file system manager to the project path // So that WASM gets an updated path for operations - fileSystemManager.dir = projectPath + fileSystemManager.dir = project_path const projectData: IndexLoaderData = { code, project: isTauri() - ? await getProjectInfo(projectPath) + ? await getProjectInfo(project_path, configuration) : { - name: projectName, - path: projectPath, + name: project_name, + path: project_path, children: [], kcl_file_count: 0, directory_count: 0, }, file: { - name: currentFileName, - path: currentFilePath, + name: current_file_name, + path: current_file_path, children: [], }, } diff --git a/src/lib/settings/settingsUtils.ts b/src/lib/settings/settingsUtils.ts index 26cc45c11..abf7f34dc 100644 --- a/src/lib/settings/settingsUtils.ts +++ b/src/lib/settings/settingsUtils.ts @@ -101,7 +101,7 @@ function localStorageProjectSettingsPath() { return '/' + BROWSER_PROJECT_NAME + '/project.toml' } -function readLocalStorageAppSettingsFile(): Configuration { +export function readLocalStorageAppSettingsFile(): Configuration { // TODO: Remove backwards compatibility after a few releases. let stored = localStorage.getItem(localStorageAppSettingsPath()) ?? diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 692d7047c..8a5a8791f 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -7,6 +7,7 @@ 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' +import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' // Get the app state from tauri. export async function getState(): Promise { @@ -80,6 +81,16 @@ export async function login(host: string): Promise { return await invoke('login', { host }) } +export async function parseProjectRoute( + configuration: Configuration, + route: string +): Promise { + return await invoke('parse_project_route', { + configuration, + route, + }) +} + export async function getUser( token: string | undefined, host: string diff --git a/src/wasm-lib/Cargo.lock b/src/wasm-lib/Cargo.lock index e0221f720..af0b75049 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.52" +version = "0.1.53" dependencies = [ "anyhow", "approx 0.5.1", diff --git a/src/wasm-lib/kcl/Cargo.toml b/src/wasm-lib/kcl/Cargo.toml index 17adad627..dc0fb4b5f 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.52" +version = "0.1.53" 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 e366c0496..819d533c8 100644 --- a/src/wasm-lib/kcl/src/settings/types/file.rs +++ b/src/wasm-lib/kcl/src/settings/types/file.rs @@ -5,6 +5,8 @@ use parse_display::{Display, FromStr}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use super::Configuration; + /// State management for the application. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] #[ts(export)] @@ -14,6 +16,100 @@ pub struct ProjectState { pub current_file: Option, } +/// Project route information. +#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub struct ProjectRoute { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_name: Option, + pub project_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_file_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_file_path: Option, +} + +impl ProjectRoute { + /// Get the project state from the url in the route. + pub fn from_route(configuration: &Configuration, route: &str) -> Result { + let path = std::path::Path::new(route); + // Check if the default project path is in the route. + let (project_path, project_name) = if path.starts_with(&configuration.settings.project.directory) + && configuration.settings.project.directory != std::path::PathBuf::default() + { + // Get the project name. + if let Some(project_name) = path + .strip_prefix(&configuration.settings.project.directory) + .unwrap() + .iter() + .next() + { + ( + configuration + .settings + .project + .directory + .join(project_name) + .display() + .to_string(), + Some(project_name.to_string_lossy().to_string()), + ) + } else { + (configuration.settings.project.directory.display().to_string(), None) + } + } else { + // Assume the project path is the parent directory of the file. + let project_dir = if path.display().to_string().ends_with(".kcl") { + path.parent() + .ok_or_else(|| anyhow::anyhow!("Parent directory not found: {}", path.display()))? + } else { + path + }; + + if project_dir == std::path::Path::new("/") { + ( + path.display().to_string(), + Some( + path.file_name() + .ok_or_else(|| anyhow::anyhow!("File name not found: {}", path.display()))? + .to_string_lossy() + .to_string(), + ), + ) + } else if let Some(project_name) = project_dir.file_name() { + ( + project_dir.display().to_string(), + Some(project_name.to_string_lossy().to_string()), + ) + } else { + (project_dir.display().to_string(), None) + } + }; + + let (current_file_name, current_file_path) = if path.display().to_string() == project_path { + (None, None) + } else { + ( + Some( + path.file_name() + .ok_or_else(|| anyhow::anyhow!("File name not found: {}", path.display()))? + .to_string_lossy() + .to_string(), + ), + Some(path.display().to_string()), + ) + }; + + Ok(Self { + project_name, + project_path, + current_file_name, + current_file_path, + }) + } +} + /// Information about project. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] #[ts(export)] @@ -233,3 +329,141 @@ impl From for FileMetadata { } } } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + #[test] + fn test_project_route_from_route_std_path() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = + std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects"); + + let route = "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly/main.kcl"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: Some("assembly".to_string()), + project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly".to_string(), + current_file_name: Some("main.kcl".to_string()), + current_file_path: Some( + "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly/main.kcl".to_string() + ), + } + ); + } + + #[test] + fn test_project_route_from_route_std_path_dir() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = + std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects"); + + let route = "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: Some("assembly".to_string()), + project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly".to_string(), + current_file_name: None, + current_file_path: None, + } + ); + } + + #[test] + fn test_project_route_from_route_std_path_dir_empty() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = + std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects"); + + let route = "/Users/macinatormax/Documents/kittycad-modeling-projects"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: None, + project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects".to_string(), + current_file_name: None, + current_file_path: None, + } + ); + } + + #[test] + fn test_project_route_from_route_outside_std_path() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = + std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects"); + + let route = "/Users/macinatormax/kittycad/modeling-app/main.kcl"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: Some("modeling-app".to_string()), + project_path: "/Users/macinatormax/kittycad/modeling-app".to_string(), + current_file_name: Some("main.kcl".to_string()), + current_file_path: Some("/Users/macinatormax/kittycad/modeling-app/main.kcl".to_string()), + } + ); + } + + #[test] + fn test_project_route_from_route_outside_std_path_dir() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = + std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects"); + + let route = "/Users/macinatormax/kittycad/modeling-app"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: Some("modeling-app".to_string()), + project_path: "/Users/macinatormax/kittycad/modeling-app".to_string(), + current_file_name: None, + current_file_path: None, + } + ); + } + + #[test] + fn test_project_route_from_route_browser() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = std::path::PathBuf::default(); + + let route = "/browser/main.kcl"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: Some("browser".to_string()), + project_path: "/browser".to_string(), + current_file_name: Some("main.kcl".to_string()), + current_file_path: Some("/browser/main.kcl".to_string()), + } + ); + } + + #[test] + fn test_project_route_from_route_browser_no_path() { + let mut configuration = crate::settings::types::Configuration::default(); + configuration.settings.project.directory = std::path::PathBuf::default(); + + let route = "/browser"; + let state = super::ProjectRoute::from_route(&configuration, route).unwrap(); + assert_eq!( + state, + super::ProjectRoute { + project_name: Some("browser".to_string()), + project_path: "/browser".to_string(), + current_file_name: None, + current_file_path: None, + } + ); + } +} diff --git a/src/wasm-lib/src/wasm.rs b/src/wasm-lib/src/wasm.rs index d54cb36c2..832c13cf8 100644 --- a/src/wasm-lib/src/wasm.rs +++ b/src/wasm-lib/src/wasm.rs @@ -507,3 +507,19 @@ pub fn parse_project_settings(toml_str: &str) -> Result { // gloo-serialize crate instead. JsValue::from_serde(&settings).map_err(|e| e.to_string()) } + +/// Parse the project route. +#[wasm_bindgen] +pub fn parse_project_route(configuration: &str, route: &str) -> Result { + console_error_panic_hook::set_once(); + + let configuration: kcl_lib::settings::types::Configuration = + serde_json::from_str(configuration).map_err(|e| e.to_string())?; + + let route = + kcl_lib::settings::types::file::ProjectRoute::from_route(&configuration, route).map_err(|e| e.to_string())?; + + // The serde-wasm-bindgen does not work here because of weird HashMap issues so we use the + // gloo-serialize crate instead. + JsValue::from_serde(&route).map_err(|e| e.to_string()) +}