diff --git a/phenoxtract-ui/package.json b/phenoxtract-ui/package.json index 6e7b2cd4..87a9fca5 100644 --- a/phenoxtract-ui/package.json +++ b/phenoxtract-ui/package.json @@ -20,8 +20,12 @@ "@angular/forms": "^20.1.4", "@angular/platform-browser": "^20.1.4", "@angular/router": "^20.1.4", + "@primeuix/themes": "^2.0.3", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", + "bootstrap-icons": "^1.13.1", + "primeng": "^21.1.9", "rxjs": "~7.8.2", "tslib": "^2.8.1", "zone.js": "~0.15.1" diff --git a/phenoxtract-ui/pnpm-lock.yaml b/phenoxtract-ui/pnpm-lock.yaml index 519c9b00..a1c72eba 100644 --- a/phenoxtract-ui/pnpm-lock.yaml +++ b/phenoxtract-ui/pnpm-lock.yaml @@ -23,12 +23,24 @@ importers: '@angular/router': specifier: ^20.1.4 version: 20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@primeuix/themes': + specifier: ^2.0.3 + version: 2.0.3 '@tauri-apps/api': specifier: ^2 version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.1 + version: 2.7.1 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.4 + bootstrap-icons: + specifier: ^1.13.1 + version: 1.13.1 + primeng: + specifier: ^21.1.9 + version: 21.1.9(d7ad9d8080258453d12745bbca4bb7ff) rxjs: specifier: ~7.8.2 version: 7.8.2 @@ -340,6 +352,14 @@ packages: vitest: optional: true + '@angular/cdk@21.2.14': + resolution: {integrity: sha512-806REq/CLf37nEhmmd8Q+ILN8z/RVG2vk2n8YZ/4TdHpcBCi5ux4AxLbpMmduLwGPOzPagJ6ggRzE5fnX0rmcQ==} + peerDependencies: + '@angular/common': ^21.0.0 || ^22.0.0 + '@angular/core': ^21.0.0 || ^22.0.0 + '@angular/platform-browser': ^21.0.0 || ^22.0.0 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/cli@20.3.31': resolution: {integrity: sha512-pqPhIKRBVlceAwB7KRpCwBhYBXSDQqdY0AYUVwHhhaTqOvzSZ38nWKQFlY/NUbdI1QGYb2ATJ2Bo9RJY/H+Mxg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -1634,6 +1654,28 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@primeuix/motion@0.0.10': + resolution: {integrity: sha512-PsZwOPq79Scp7/ionshRcQ5xKVf9+zuLcyY5mf6onK8chHT5C9JGphmcIZ4CzcqxuGEpsm8AIbTGy+zS3RtzLA==} + engines: {node: '>=12.11.0'} + + '@primeuix/styled@0.7.4': + resolution: {integrity: sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==} + engines: {node: '>=12.11.0'} + + '@primeuix/styles@2.0.3': + resolution: {integrity: sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==} + + '@primeuix/themes@2.0.3': + resolution: {integrity: sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==} + + '@primeuix/utils@0.6.4': + resolution: {integrity: sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==} + engines: {node: '>=12.11.0'} + + '@primeuix/utils@0.7.2': + resolution: {integrity: sha512-pmEbSfP0Phf9W9RweiM66zXnkn73ZeKyYINElbX3uZ2+stzzaba2svLAl3B1pHVcRw5t43O0VciaGe4ye2EXKw==} + engines: {node: '>=12.11.0'} + '@rolldown/binding-android-arm64@1.1.4': resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2201,6 +2243,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + '@tauri-apps/plugin-opener@2.5.4': resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} @@ -2539,6 +2584,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bootstrap-icons@1.13.1: + resolution: {integrity: sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==} + brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} @@ -4021,6 +4069,17 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + primeng@21.1.9: + resolution: {integrity: sha512-Z76PtF08X0PNSTCNMobao8Qm71vC56mtZSdUtX/gsn4+q4x0NbUUtkeK1pc33a+kxVm2zkmOwzUIEGmb9VdoIA==} + peerDependencies: + '@angular/cdk': ^21.0.0 + '@angular/common': ^21.0.0 + '@angular/core': ^21.0.7 + '@angular/forms': ^21.0.0 + '@angular/platform-browser': ^21.0.0 + '@angular/router': ^21.0.0 + rxjs: ^6.0.0 || ^7.8.1 + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5354,6 +5413,15 @@ snapshots: - tsx - yaml + '@angular/cdk@21.2.14(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/platform-browser': 20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)) + parse5: 8.0.1 + rxjs: 7.8.2 + tslib: 2.8.1 + '@angular/cli@20.3.31(chokidar@4.0.3)': dependencies: '@angular-devkit/architect': 0.2003.31(chokidar@5.0.0) @@ -6391,6 +6459,26 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@primeuix/motion@0.0.10': + dependencies: + '@primeuix/utils': 0.6.4 + + '@primeuix/styled@0.7.4': + dependencies: + '@primeuix/utils': 0.6.4 + + '@primeuix/styles@2.0.3': + dependencies: + '@primeuix/styled': 0.7.4 + + '@primeuix/themes@2.0.3': + dependencies: + '@primeuix/styled': 0.7.4 + + '@primeuix/utils@0.6.4': {} + + '@primeuix/utils@0.7.2': {} + '@rolldown/binding-android-arm64@1.1.4': optional: true @@ -6752,6 +6840,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-opener@2.5.4': dependencies: '@tauri-apps/api': 2.11.1 @@ -7170,6 +7262,8 @@ snapshots: boolbase@1.0.0: {} + bootstrap-icons@1.13.1: {} + brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 @@ -8810,6 +8904,21 @@ snapshots: pretty-bytes@7.1.0: {} + primeng@21.1.9(d7ad9d8080258453d12745bbca4bb7ff): + dependencies: + '@angular/cdk': 21.2.14(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@angular/common': 20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/forms': 20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@angular/platform-browser': 20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/router': 20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.25(@angular/common@20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@primeuix/motion': 0.0.10 + '@primeuix/styled': 0.7.4 + '@primeuix/styles': 2.0.3 + '@primeuix/utils': 0.7.2 + rxjs: 7.8.2 + tslib: 2.8.1 + proc-log@5.0.0: {} proc-log@6.1.0: {} diff --git a/phenoxtract-ui/src-tauri/Cargo.toml b/phenoxtract-ui/src-tauri/Cargo.toml index 80f10ce6..1b1de6a0 100644 --- a/phenoxtract-ui/src-tauri/Cargo.toml +++ b/phenoxtract-ui/src-tauri/Cargo.toml @@ -22,4 +22,9 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-dialog = "2.7.1" +thiserror = "2.0.18" +directories = "6.0.0" +[dev-dependencies] +tempfile = "3.27.0" diff --git a/phenoxtract-ui/src-tauri/capabilities/default.json b/phenoxtract-ui/src-tauri/capabilities/default.json index 4cdbf49a..778bfb5a 100644 --- a/phenoxtract-ui/src-tauri/capabilities/default.json +++ b/phenoxtract-ui/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "dialog:default" ] } diff --git a/phenoxtract-ui/src-tauri/icons/icon.ico b/phenoxtract-ui/src-tauri/icons/icon.ico index 06c23c82..b3636e4b 100644 Binary files a/phenoxtract-ui/src-tauri/icons/icon.ico and b/phenoxtract-ui/src-tauri/icons/icon.ico differ diff --git a/phenoxtract-ui/src-tauri/src/app_dirs.rs b/phenoxtract-ui/src-tauri/src/app_dirs.rs new file mode 100644 index 00000000..daa0ab8b --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/app_dirs.rs @@ -0,0 +1,84 @@ +use crate::error::PhenoxtractBackendError; +use directories::ProjectDirs; +use std::path::PathBuf; + +#[derive(Clone)] +pub struct AppDirs { + config_dir: PathBuf, + _data_dir: PathBuf, + _cache_dir: PathBuf, +} + +impl AppDirs { + pub fn new(config_dir: PathBuf, data_dir: PathBuf, cache_dir: PathBuf) -> Self { + Self { + config_dir, + _data_dir: data_dir, + _cache_dir: cache_dir, + } + } + pub fn config_dir(&self) -> &PathBuf { + &self.config_dir + } + pub fn _data_dir(&self) -> &PathBuf { + &self._data_dir + } + pub fn _cache_dir(&self) -> &PathBuf { + &self._cache_dir + } +} +pub fn init_app_dirs() -> Result { + let proj_dirs = ProjectDirs::from("com", "RobinsonLab", env!("CARGO_PKG_NAME")) + .expect("Could not determine project directories"); + + std::fs::create_dir_all(proj_dirs.config_dir()) + .map_err(|err| PhenoxtractBackendError::UnableToInitStateDirs(err.to_string()))?; + std::fs::create_dir_all(proj_dirs.data_dir()) + .map_err(|err| PhenoxtractBackendError::UnableToInitStateDirs(err.to_string()))?; + std::fs::create_dir_all(proj_dirs.cache_dir()) + .map_err(|err| PhenoxtractBackendError::UnableToInitStateDirs(err.to_string()))?; + + Ok(AppDirs::new( + proj_dirs.config_dir().to_path_buf(), + proj_dirs.data_dir().to_path_buf(), + proj_dirs.cache_dir().to_path_buf(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_app_dirs_new_and_getters() { + let mock_config = PathBuf::from("/mock/config/path"); + let mock_data = PathBuf::from("/mock/data/path"); + let mock_cache = PathBuf::from("/mock/cache/path"); + + let app_dirs = AppDirs::new(mock_config.clone(), mock_data.clone(), mock_cache.clone()); + + assert_eq!(app_dirs.config_dir(), &mock_config); + assert_eq!(app_dirs._data_dir(), &mock_data); + assert_eq!(app_dirs._cache_dir(), &mock_cache); + } + + #[test] + fn test_init_app_dirs_succeeds() { + let result = init_app_dirs(); + + assert!(result.is_ok(), "Failed to initialize app directories"); + + let dirs = result.unwrap(); + + assert!( + dirs.config_dir().exists(), + "Config directory was not created" + ); + assert!(dirs._data_dir().exists(), "Data directory was not created"); + assert!( + dirs._cache_dir().exists(), + "Cache directory was not created" + ); + } +} diff --git a/phenoxtract-ui/src-tauri/src/app_state_persistence/app_state.rs b/phenoxtract-ui/src-tauri/src/app_state_persistence/app_state.rs new file mode 100644 index 00000000..cbd914fa --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/app_state_persistence/app_state.rs @@ -0,0 +1,96 @@ +use crate::app_state_persistence::project_panel::ProjectPanel; +use crate::error::PhenoxtractBackendError; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::path::Path; + +#[derive(Serialize, Deserialize, Default, Clone, Debug)] +pub struct AppState { + pub project_panels: VecDeque, +} + +impl AppState { + pub fn save(&self, path: &Path) -> Result<(), PhenoxtractBackendError> { + let path = path.join("app_state.json"); + + let file = std::fs::File::create(path) + .map_err(|err| PhenoxtractBackendError::CantWriteAppState(err.to_string()))?; + serde_json::to_writer_pretty(&file, &self) + .map_err(|err| PhenoxtractBackendError::CantWriteAppState(err.to_string())) + } + + pub fn load(path: &Path) -> Result { + let path = path.join("app_state.json"); + + if path.exists() { + let state_str = std::fs::read_to_string(path) + .map_err(|err| PhenoxtractBackendError::CantReadAppState(err.to_string()))?; + serde_json::from_str(&state_str) + .map_err(|err| PhenoxtractBackendError::CantReadAppState(err.to_string())) + } else { + Ok(Self::default()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_state_persistence::app_state::AppState; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_load_non_existent_file_returns_default() { + let dir = tempdir().expect("Failed to create temp dir"); + + let result = AppState::load(dir.path()); + + assert!(result.is_ok()); + let state = result.unwrap(); + assert!(state.project_panels.is_empty()); + } + + #[test] + fn test_save_and_load_roundtrip() { + let dir = tempdir().expect("Failed to create temp dir"); + let state = AppState::default(); + + state.save(dir.path()).expect("Failed to save state"); + let loaded_state = AppState::load(dir.path()).expect("Failed to load state"); + + assert_eq!( + state.project_panels.len(), + loaded_state.project_panels.len() + ); + } + + #[test] + fn test_save_to_invalid_path_returns_error() { + let state = AppState::default(); + let bad_path = Path::new("/some/non/existent/path/that/should/fail"); + + let result = state.save(bad_path); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + PhenoxtractBackendError::CantWriteAppState(_) + )); + } + + #[test] + fn test_load_malformed_json_returns_error() { + let dir = tempdir().expect("Failed to create temp dir"); + let file_path = dir.path().join("app_state.json"); + fs::write(&file_path, "{ this is not valid json ]").expect("Failed to write garbage file"); + + let result = AppState::load(dir.path()); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + PhenoxtractBackendError::CantReadAppState(_) + )); + } +} diff --git a/phenoxtract-ui/src-tauri/src/app_state_persistence/mod.rs b/phenoxtract-ui/src-tauri/src/app_state_persistence/mod.rs new file mode 100644 index 00000000..bb5bf7f5 --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/app_state_persistence/mod.rs @@ -0,0 +1,2 @@ +pub mod app_state; +pub mod project_panel; diff --git a/phenoxtract-ui/src-tauri/src/app_state_persistence/project_panel.rs b/phenoxtract-ui/src-tauri/src/app_state_persistence/project_panel.rs new file mode 100644 index 00000000..5920c1d7 --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/app_state_persistence/project_panel.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProjectPanel { + pub name: String, + pub directory: String, +} + +impl ProjectPanel { + pub fn new(name: impl Into, directory: impl Into) -> Self { + Self { + name: name.into(), + directory: directory.into(), + } + } +} diff --git a/phenoxtract-ui/src-tauri/src/commands/mod.rs b/phenoxtract-ui/src-tauri/src/commands/mod.rs new file mode 100644 index 00000000..11374b84 --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/commands/mod.rs @@ -0,0 +1,27 @@ +use crate::app_state_persistence::project_panel::ProjectPanel; +use crate::types::SharedAppState; +use std::collections::VecDeque; + +#[tauri::command] +pub fn get_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +#[tauri::command] +pub fn get_project_panels( + state: tauri::State, +) -> Result, String> { + let app_state = state.read().map_err(|e| e.to_string())?; + Ok(app_state.project_panels.clone()) +} + +#[tauri::command] +pub fn post_project_panel( + project_panels: ProjectPanel, + state: tauri::State, +) -> Result<(), String> { + let mut app_state = state.write().map_err(|e| e.to_string())?; + + app_state.project_panels.push_front(project_panels); + Ok(()) +} diff --git a/phenoxtract-ui/src-tauri/src/error.rs b/phenoxtract-ui/src-tauri/src/error.rs new file mode 100644 index 00000000..e5390b06 --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/error.rs @@ -0,0 +1,11 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PhenoxtractBackendError { + #[error("Cant load App State: {0}.")] + CantReadAppState(String), + #[error("Cant save App State: {0}.")] + CantWriteAppState(String), + #[error("Unable to initialize app directories: {0}")] + UnableToInitStateDirs(String), +} diff --git a/phenoxtract-ui/src-tauri/src/lib.rs b/phenoxtract-ui/src-tauri/src/lib.rs index 4a277ef3..8407e0cd 100644 --- a/phenoxtract-ui/src-tauri/src/lib.rs +++ b/phenoxtract-ui/src-tauri/src/lib.rs @@ -1,14 +1,54 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +use crate::app_dirs::{init_app_dirs, AppDirs}; +use crate::app_state_persistence::app_state::AppState; +use crate::app_state_persistence::project_panel::ProjectPanel; +use crate::commands::{get_project_panels, get_version, post_project_panel}; +use std::sync::{Arc, RwLock}; +use tauri::Manager; + +mod app_dirs; +mod app_state_persistence; +mod commands; +mod error; +mod types; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let dummy_panels = vec![ + ProjectPanel::new("Immunology Data", "~/projects/my-project"), + ProjectPanel::new("prechter_data_analysis", "~/projects/my-project"), + ProjectPanel::new("acuteKidneyInjury", "~/projects/my-project"), + ]; + + let app_dirs = init_app_dirs().expect("Could not determine application directories"); + + let mut state = AppState::load(app_dirs.config_dir()).expect("Could not load app state"); + + state.project_panels = dummy_panels.into_iter().collect(); + tauri::Builder::default() + .manage(Arc::new(RwLock::new(state))) + .manage(app_dirs) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .plugin(tauri_plugin_dialog::init()) + .invoke_handler(tauri::generate_handler![ + get_version, + get_project_panels, + post_project_panel + ]) + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { .. } => { + let app_dirs = app_handle.state::(); + let state_manager = app_handle.state::>>(); + let state = state_manager.read().expect("Could not read state"); + state + .save(app_dirs.config_dir()) + .expect("Could not save state"); + } + tauri::RunEvent::Exit => { + // cleanup, app is exiting, can't be stopped here + } + _ => {} + }); } diff --git a/phenoxtract-ui/src-tauri/src/types.rs b/phenoxtract-ui/src-tauri/src/types.rs new file mode 100644 index 00000000..9778963e --- /dev/null +++ b/phenoxtract-ui/src-tauri/src/types.rs @@ -0,0 +1,4 @@ +use crate::app_state_persistence::app_state::AppState; +use std::sync::{Arc, RwLock}; + +pub type SharedAppState = Arc>; diff --git a/phenoxtract-ui/src-tauri/tauri.conf.json b/phenoxtract-ui/src-tauri/tauri.conf.json index 260fd6b2..ba88c376 100644 --- a/phenoxtract-ui/src-tauri/tauri.conf.json +++ b/phenoxtract-ui/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "phenoxtract-ui", - "width": 800, - "height": 600 + "width": 1280, + "height": 720 } ], "security": { diff --git a/phenoxtract-ui/src/app/app.component.html b/phenoxtract-ui/src/app/app.component.html index 1ca5a0b6..36d60322 100644 --- a/phenoxtract-ui/src/app/app.component.html +++ b/phenoxtract-ui/src/app/app.component.html @@ -2,6 +2,14 @@

Welcome to Tauri + Angular!

+ + + diff --git a/phenoxtract-ui/src/app/app.component.ts b/phenoxtract-ui/src/app/app.component.ts index 7e186a6b..ac6ca5cf 100644 --- a/phenoxtract-ui/src/app/app.component.ts +++ b/phenoxtract-ui/src/app/app.component.ts @@ -1,20 +1,25 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { FormsModule } from '@angular/forms'; + import { invoke } from '@tauri-apps/api/core'; +import { FilePickerComponent } from './shared/components/file-picker/file-picker.component'; + @Component({ selector: 'app-root', - imports: [RouterOutlet], + standalone: true, + imports: [RouterOutlet, FormsModule, FilePickerComponent], templateUrl: './app.component.html', styleUrl: './app.component.css', }) export class AppComponent { greetingMessage = ''; + selectedFolder = ''; greet(event: SubmitEvent, name: string): void { event.preventDefault(); - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ invoke('greet', { name }).then((text) => { this.greetingMessage = text; }); diff --git a/phenoxtract-ui/src/app/app.config.ts b/phenoxtract-ui/src/app/app.config.ts index 414eeaeb..ef061ed4 100644 --- a/phenoxtract-ui/src/app/app.config.ts +++ b/phenoxtract-ui/src/app/app.config.ts @@ -6,11 +6,18 @@ import { import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { providePrimeNG } from 'primeng/config'; +import Aura from '@primeuix/themes/aura'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), + providePrimeNG({ + theme: { + preset: Aura, + }, + }), ], }; diff --git a/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.html b/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.html new file mode 100644 index 00000000..9f949a6c --- /dev/null +++ b/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.scss b/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.scss new file mode 100644 index 00000000..2b5a6322 --- /dev/null +++ b/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; +} + +input { + height: 48px; +} + +button { + width: 48px; +} diff --git a/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.ts b/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.ts new file mode 100644 index 00000000..e4e73771 --- /dev/null +++ b/phenoxtract-ui/src/app/shared/components/file-picker/file-picker.component.ts @@ -0,0 +1,140 @@ +import { Component, Input, forwardRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; +import { InputTextModule } from 'primeng/inputtext'; +import { ButtonModule } from 'primeng/button'; + +import { open, DialogFilter } from '@tauri-apps/plugin-dialog'; + +/** + * A reusable text input with an integrated native Tauri file/directory picker. + * + * This component wraps a PrimeNG input field and opens the operating system's + * file or directory selection dialog using the Tauri dialog plugin. + * + * It implements `ControlValueAccessor`, making it compatible with both + * Angular Reactive Forms and `[(ngModel)]`. + * + * Features: + * - File or directory selection + * - Optional multi-selection + * - File type filtering + * - Editable text input + * - PrimeNG styling + * + * @example + * Template-driven forms + * ```html + * + * + * ``` + * + * @example + * Reactive Forms + * ```ts + * form = this.fb.group({ + * folder: [''] + * }); + * ``` + * + * ```html + *
+ * + * + *
+ * ``` + * + * @example + * Selecting a file + * ```html + * + * + * ``` + */ +@Component({ + selector: 'app-file-picker', + standalone: true, + imports: [CommonModule, InputGroupModule, InputGroupAddonModule, InputTextModule, ButtonModule], + templateUrl: './file-picker.component.html', + styleUrls: ['./file-picker.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilePickerComponent), + multi: true, + }, + ], +}) +export class FilePickerComponent implements ControlValueAccessor { + @Input() directory = false; + @Input() multiple = false; + @Input() placeholder = ''; + @Input() filters?: DialogFilter[]; + + value = ''; + + private onChange = (_: string) => { + void _; + }; + private onTouched = () => {}; + + async triggerFileSelect() { + console.log('Button clicked'); + + try { + const selected = await open({ + directory: this.directory, + multiple: this.multiple, + filters: this.filters, + }); + + console.log(selected); + + if (selected !== null) { + this.value = selected as string; + this.onChange(this.value); + } + } catch (err) { + console.error(err); + } + } + + onInput(event: Event) { + this.value = (event.target as HTMLInputElement).value; + this.onChange(this.value); + } + + writeValue(value: string): void { + this.value = value ?? ''; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(_disabled: boolean): void { + void _disabled; + } +} diff --git a/phenoxtract-ui/src/styles.css b/phenoxtract-ui/src/styles.css index 90d4ee00..7b4f129b 100644 --- a/phenoxtract-ui/src/styles.css +++ b/phenoxtract-ui/src/styles.css @@ -1 +1,2 @@ /* You can add global styles to this file, and also import other style files */ +@import "bootstrap-icons/font/bootstrap-icons.css";