From 7e1cc92aa4a262dcbeef5bece0bf9aa3f9df497c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 28 Feb 2026 21:18:33 +0000 Subject: [PATCH] Add app update detection and multi-folder project support Feature 1 - Update Detection: Query Gitea releases API on startup (3s delay) and every 24h, compare patch versions by platform, show pulsing "Update" button in TopBar with dialog for release notes/downloads. Settings: auto-check toggle, manual check, dismiss per-version. Feature 2 - Multi-Folder Projects: Replace single `path` with `paths: Vec` (host_path + mount_name). Each folder mounts to `/workspace/{mount_name}`. Auto-migrate old single-path JSON on load. Container recreation via paths-fingerprint label. AddProjectDialog and ProjectCard support add/remove/edit of multiple folders. Co-Authored-By: Claude Opus 4.6 --- app/src-tauri/Cargo.lock | 278 +++++++++++++++++- app/src-tauri/Cargo.toml | 1 + app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/project_commands.rs | 22 +- app/src-tauri/src/commands/update_commands.rs | 117 ++++++++ app/src-tauri/src/docker/container.rs | 75 +++-- app/src-tauri/src/lib.rs | 3 + app/src-tauri/src/models/app_settings.rs | 10 + app/src-tauri/src/models/mod.rs | 2 + app/src-tauri/src/models/project.rs | 37 ++- app/src-tauri/src/models/update_info.rs | 37 +++ app/src-tauri/src/storage/projects_store.rs | 69 ++++- app/src/App.tsx | 11 + app/src/components/layout/TopBar.tsx | 60 +++- .../components/projects/AddProjectDialog.tsx | 143 ++++++--- app/src/components/projects/ProjectCard.tsx | 99 ++++++- app/src/components/settings/SettingsPanel.tsx | 49 +++ app/src/components/settings/UpdateDialog.tsx | 121 ++++++++ app/src/hooks/useProjects.ts | 5 +- app/src/hooks/useUpdates.ts | 72 +++++ app/src/lib/tauri-commands.ts | 11 +- app/src/lib/types.ts | 24 +- app/src/store/appState.ts | 14 +- 23 files changed, 1163 insertions(+), 98 deletions(-) create mode 100644 app/src-tauri/src/commands/update_commands.rs create mode 100644 app/src-tauri/src/models/update_info.rs create mode 100644 app/src/components/settings/UpdateDialog.tsx create mode 100644 app/src/hooks/useUpdates.ts diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 76f4db6..c2bf53f 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -523,6 +523,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -1333,8 +1339,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1344,9 +1352,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1649,6 +1659,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2153,6 +2180,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2985,6 +3018,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -3025,6 +3113,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3045,6 +3143,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3063,6 +3171,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3165,6 +3282,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3223,6 +3378,26 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3245,6 +3420,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3709,6 +3919,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3873,7 +4089,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4258,6 +4474,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -4286,6 +4517,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4504,6 +4745,7 @@ dependencies = [ "futures-util", "keyring", "log", + "reqwest 0.12.28", "serde", "serde_json", "tar", @@ -4604,6 +4846,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4856,6 +5104,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -4900,6 +5158,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5130,6 +5397,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 6c03bad..fffba15 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ dirs = "6" log = "0.4" env_logger = "0.11" tar = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 65ddb51..2c0a311 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod docker_commands; pub mod project_commands; pub mod settings_commands; pub mod terminal_commands; +pub mod update_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index aab49ea..6da0e21 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1,7 +1,7 @@ use tauri::State; use crate::docker; -use crate::models::{container_config, AuthMode, Project, ProjectStatus}; +use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -51,10 +51,26 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result, S #[tauri::command] pub async fn add_project( name: String, - path: String, + paths: Vec, state: State<'_, AppState>, ) -> Result { - let project = Project::new(name, path); + // Validate paths + if paths.is_empty() { + return Err("At least one folder path is required.".to_string()); + } + let mut seen_names = std::collections::HashSet::new(); + for p in &paths { + if p.mount_name.is_empty() { + return Err("Mount name cannot be empty.".to_string()); + } + if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') { + return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name)); + } + if !seen_names.insert(p.mount_name.clone()) { + return Err(format!("Duplicate mount name '{}'.", p.mount_name)); + } + } + let project = Project::new(name, paths); store_secrets_for_project(&project)?; state.projects_store.add(project) } diff --git a/app/src-tauri/src/commands/update_commands.rs b/app/src-tauri/src/commands/update_commands.rs new file mode 100644 index 0000000..f92168b --- /dev/null +++ b/app/src-tauri/src/commands/update_commands.rs @@ -0,0 +1,117 @@ +use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo}; + +const RELEASES_URL: &str = + "https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases"; + +#[tauri::command] +pub fn get_app_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +#[tauri::command] +pub async fn check_for_updates() -> Result, String> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let releases: Vec = client + .get(RELEASES_URL) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse releases: {}", e))?; + + let current_version = env!("CARGO_PKG_VERSION"); + let is_windows = cfg!(target_os = "windows"); + + // Filter releases by platform tag suffix + let platform_releases: Vec<&GiteaRelease> = releases + .iter() + .filter(|r| { + if is_windows { + r.tag_name.ends_with("-win") + } else { + !r.tag_name.ends_with("-win") + } + }) + .collect(); + + // Find the latest release with a higher patch version + // Version format: 0.1.X or v0.1.X (tag may have prefix/suffix) + let current_patch = parse_patch_version(current_version).unwrap_or(0); + + let mut best: Option<(&GiteaRelease, u32)> = None; + for release in &platform_releases { + if let Some(patch) = parse_patch_from_tag(&release.tag_name) { + if patch > current_patch { + if best.is_none() || patch > best.unwrap().1 { + best = Some((release, patch)); + } + } + } + } + + match best { + Some((release, _)) => { + let assets = release + .assets + .iter() + .map(|a| ReleaseAsset { + name: a.name.clone(), + browser_download_url: a.browser_download_url.clone(), + size: a.size, + }) + .collect(); + + // Reconstruct version string from tag + let version = extract_version_from_tag(&release.tag_name) + .unwrap_or_else(|| release.tag_name.clone()); + + Ok(Some(UpdateInfo { + version, + tag_name: release.tag_name.clone(), + release_url: release.html_url.clone(), + body: release.body.clone(), + assets, + published_at: release.published_at.clone(), + })) + } + None => Ok(None), + } +} + +/// Parse patch version from a semver string like "0.1.5" -> 5 +fn parse_patch_version(version: &str) -> Option { + let clean = version.trim_start_matches('v'); + let parts: Vec<&str> = clean.split('.').collect(); + if parts.len() >= 3 { + parts[2].parse().ok() + } else { + None + } +} + +/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5 +fn parse_patch_from_tag(tag: &str) -> Option { + let clean = tag.trim_start_matches('v'); + // Remove platform suffix + let clean = clean.strip_suffix("-win").unwrap_or(clean); + parse_patch_version(clean) +} + +/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5" +fn extract_version_from_tag(tag: &str) -> Option { + let clean = tag.trim_start_matches('v'); + let clean = clean.strip_suffix("-win").unwrap_or(clean); + // Validate it looks like a version + let parts: Vec<&str> = clean.split('.').collect(); + if parts.len() >= 3 && parts.iter().all(|p| p.parse::().is_ok()) { + Some(clean.to_string()) + } else { + None + } +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index a4ef37c..f3c98f6 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -8,7 +8,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use super::client::get_docker; -use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project}; +use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath}; /// Compute a fingerprint string for the custom environment variables. /// Sorted alphabetically so order changes do not cause spurious recreation. @@ -62,6 +62,20 @@ fn compute_bedrock_fingerprint(project: &Project) -> String { } } +/// Compute a fingerprint for the project paths so we can detect changes. +/// Sorted by mount_name so order changes don't cause spurious recreation. +fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String { + let mut parts: Vec = paths + .iter() + .map(|p| format!("{}:{}", p.mount_name, p.host_path)) + .collect(); + parts.sort(); + let joined = parts.join(","); + let mut hasher = DefaultHasher::new(); + joined.hash(&mut hasher); + format!("{:x}", hasher.finish()) +} + pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -231,24 +245,27 @@ pub async fn create_container( env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); } - let mut mounts = vec![ - // Project directory -> /workspace - Mount { - target: Some("/workspace".to_string()), - source: Some(project.path.clone()), + let mut mounts: Vec = Vec::new(); + + // Project directories -> /workspace/{mount_name} + for pp in &project.paths { + mounts.push(Mount { + target: Some(format!("/workspace/{}", pp.mount_name)), + source: Some(pp.host_path.clone()), typ: Some(MountTypeEnum::BIND), read_only: Some(false), ..Default::default() - }, - // Named volume for claude config persistence - Mount { - target: Some("/home/claude/.claude".to_string()), - source: Some(format!("triple-c-claude-config-{}", project.id)), - typ: Some(MountTypeEnum::VOLUME), - read_only: Some(false), - ..Default::default() - }, - ]; + }); + } + + // Named volume for claude config persistence + mounts.push(Mount { + target: Some("/home/claude/.claude".to_string()), + source: Some(format!("triple-c-claude-config-{}", project.id)), + typ: Some(MountTypeEnum::VOLUME), + read_only: Some(false), + ..Default::default() + }); // SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms) if let Some(ref ssh_path) = project.ssh_key_path { @@ -315,7 +332,7 @@ pub async fn create_container( labels.insert("triple-c.project-id".to_string(), project.id.clone()); labels.insert("triple-c.project-name".to_string(), project.name.clone()); labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode)); - labels.insert("triple-c.project-path".to_string(), project.path.clone()); + labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths)); labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project)); labels.insert("triple-c.image".to_string(), image_name.to_string()); @@ -324,12 +341,18 @@ pub async fn create_container( ..Default::default() }; + let working_dir = if project.paths.len() == 1 { + format!("/workspace/{}", project.paths[0].mount_name) + } else { + "/workspace".to_string() + }; + let config = Config { image: Some(image_name.to_string()), hostname: Some("triple-c".to_string()), env: Some(env_vars), labels: Some(labels), - working_dir: Some("/workspace".to_string()), + working_dir: Some(working_dir), host_config: Some(host_config), tty: Some(true), ..Default::default() @@ -425,10 +448,18 @@ pub async fn container_needs_recreation( } } - // ── Project path ───────────────────────────────────────────────────── - if let Some(container_path) = get_label("triple-c.project-path") { - if container_path != project.path { - log::info!("Project path mismatch (container={:?}, project={:?})", container_path, project.path); + // ── Project paths fingerprint ────────────────────────────────────────── + let expected_paths_fp = compute_paths_fingerprint(&project.paths); + match get_label("triple-c.paths-fingerprint") { + Some(container_fp) => { + if container_fp != expected_paths_fp { + log::info!("Paths fingerprint mismatch (container={:?}, expected={:?})", container_fp, expected_paths_fp); + return Ok(true); + } + } + None => { + // Old container without paths-fingerprint label -> force recreation for migration + log::info!("Container missing paths-fingerprint label, triggering recreation for migration"); return Ok(true); } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 1736b00..db8946b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -63,6 +63,9 @@ pub fn run() { commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_resize, commands::terminal_commands::close_terminal_session, + // Updates + commands::update_commands::get_app_version, + commands::update_commands::check_for_updates, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 661c2ec..79ab006 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -1,5 +1,9 @@ use serde::{Deserialize, Serialize}; +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ImageSource { @@ -52,6 +56,10 @@ pub struct AppSettings { pub global_aws: GlobalAwsSettings, #[serde(default)] pub global_claude_instructions: Option, + #[serde(default = "default_true")] + pub auto_check_updates: bool, + #[serde(default)] + pub dismissed_update_version: Option, } impl Default for AppSettings { @@ -65,6 +73,8 @@ impl Default for AppSettings { custom_image_name: None, global_aws: GlobalAwsSettings::default(), global_claude_instructions: None, + auto_check_updates: true, + dismissed_update_version: None, } } } diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 53645a8..5abbf24 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -1,7 +1,9 @@ pub mod project; pub mod container_config; pub mod app_settings; +pub mod update_info; pub use project::*; pub use container_config::*; pub use app_settings::*; +pub use update_info::*; diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index ea8b5d6..8ceac14 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -6,11 +6,17 @@ pub struct EnvVar { pub value: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectPath { + pub host_path: String, + pub mount_name: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub id: String, pub name: String, - pub path: String, + pub paths: Vec, pub container_id: Option, pub status: ProjectStatus, pub auth_mode: AuthMode, @@ -91,12 +97,12 @@ pub struct BedrockConfig { } impl Project { - pub fn new(name: String, path: String) -> Self { + pub fn new(name: String, paths: Vec) -> Self { let now = chrono::Utc::now().to_rfc3339(); Self { id: uuid::Uuid::new_v4().to_string(), name, - path, + paths, container_id: None, status: ProjectStatus::Stopped, auth_mode: AuthMode::default(), @@ -116,4 +122,29 @@ impl Project { pub fn container_name(&self) -> String { format!("triple-c-{}", self.id) } + + /// Migrate a project JSON value from old single-`path` format to new `paths` format. + /// If the value already has `paths`, it is returned unchanged. + pub fn migrate_from_value(mut val: serde_json::Value) -> serde_json::Value { + if let Some(obj) = val.as_object_mut() { + if obj.contains_key("paths") { + return val; + } + if let Some(path_val) = obj.remove("path") { + let path_str = path_val.as_str().unwrap_or("").to_string(); + let mount_name = path_str + .trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .next() + .unwrap_or("workspace") + .to_string(); + let project_path = serde_json::json!([{ + "host_path": path_str, + "mount_name": if mount_name.is_empty() { "workspace".to_string() } else { mount_name }, + }]); + obj.insert("paths".to_string(), project_path); + } + } + val + } } diff --git a/app/src-tauri/src/models/update_info.rs b/app/src-tauri/src/models/update_info.rs new file mode 100644 index 0000000..87d5bd1 --- /dev/null +++ b/app/src-tauri/src/models/update_info.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Info returned to the frontend about an available update. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateInfo { + pub version: String, + pub tag_name: String, + pub release_url: String, + pub body: String, + pub assets: Vec, + pub published_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub size: u64, +} + +/// Gitea API release response (internal). +#[derive(Debug, Clone, Deserialize)] +pub struct GiteaRelease { + pub tag_name: String, + pub html_url: String, + pub body: String, + pub assets: Vec, + pub published_at: String, +} + +/// Gitea API asset response (internal). +#[derive(Debug, Clone, Deserialize)] +pub struct GiteaAsset { + pub name: String, + pub browser_download_url: String, + pub size: u64, +} diff --git a/app/src-tauri/src/storage/projects_store.rs b/app/src-tauri/src/storage/projects_store.rs index d3706d2..de6f3d5 100644 --- a/app/src-tauri/src/storage/projects_store.rs +++ b/app/src-tauri/src/storage/projects_store.rs @@ -19,33 +19,72 @@ impl ProjectsStore { let file_path = data_dir.join("projects.json"); - let projects = if file_path.exists() { + let (projects, needs_save) = if file_path.exists() { match fs::read_to_string(&file_path) { - Ok(data) => match serde_json::from_str(&data) { - Ok(parsed) => parsed, - Err(e) => { - log::error!("Failed to parse projects.json: {}. Starting with empty list.", e); - // Back up the corrupted file - let backup = file_path.with_extension("json.bak"); - if let Err(be) = fs::copy(&file_path, &backup) { - log::error!("Failed to back up corrupted projects.json: {}", be); + Ok(data) => { + // First try to parse as Vec to run migration + match serde_json::from_str::>(&data) { + Ok(raw_values) => { + let mut migrated = false; + let migrated_values: Vec = raw_values + .into_iter() + .map(|v| { + let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths")); + if has_path { + migrated = true; + } + crate::models::Project::migrate_from_value(v) + }) + .collect(); + + // Now deserialize the migrated values + let json_str = serde_json::to_string(&migrated_values).unwrap_or_default(); + match serde_json::from_str::>(&json_str) { + Ok(parsed) => (parsed, migrated), + Err(e) => { + log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e); + let backup = file_path.with_extension("json.bak"); + if let Err(be) = fs::copy(&file_path, &backup) { + log::error!("Failed to back up corrupted projects.json: {}", be); + } + (Vec::new(), false) + } + } + } + Err(e) => { + log::error!("Failed to parse projects.json: {}. Starting with empty list.", e); + let backup = file_path.with_extension("json.bak"); + if let Err(be) = fs::copy(&file_path, &backup) { + log::error!("Failed to back up corrupted projects.json: {}", be); + } + (Vec::new(), false) } - Vec::new() } - }, + } Err(e) => { log::error!("Failed to read projects.json: {}", e); - Vec::new() + (Vec::new(), false) } } } else { - Vec::new() + (Vec::new(), false) }; - Ok(Self { + let store = Self { projects: Mutex::new(projects), file_path, - }) + }; + + // Persist migrated format back to disk + if needs_save { + log::info!("Migrated projects.json from single-path to multi-path format"); + let projects = store.lock(); + if let Err(e) = store.save(&projects) { + log::error!("Failed to save migrated projects: {}", e); + } + } + + Ok(store) } fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { diff --git a/app/src/App.tsx b/app/src/App.tsx index 2c922b1..29eb402 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,12 +7,14 @@ import TerminalView from "./components/terminal/TerminalView"; import { useDocker } from "./hooks/useDocker"; import { useSettings } from "./hooks/useSettings"; import { useProjects } from "./hooks/useProjects"; +import { useUpdates } from "./hooks/useUpdates"; import { useAppState } from "./store/appState"; export default function App() { const { checkDocker, checkImage } = useDocker(); const { checkApiKey, loadSettings } = useSettings(); const { refresh } = useProjects(); + const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates(); const { sessions, activeSessionId } = useAppState( useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId })) ); @@ -25,6 +27,15 @@ export default function App() { }); checkApiKey(); refresh(); + + // Update detection + loadVersion(); + const updateTimer = setTimeout(() => checkForUpdates(), 3000); + const cleanup = startPeriodicCheck(); + return () => { + clearTimeout(updateTimer); + cleanup?.(); + }; }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/app/src/components/layout/TopBar.tsx b/app/src/components/layout/TopBar.tsx index 33b8e4e..a417f88 100644 --- a/app/src/components/layout/TopBar.tsx +++ b/app/src/components/layout/TopBar.tsx @@ -1,22 +1,62 @@ +import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import TerminalTabs from "../terminal/TerminalTabs"; import { useAppState } from "../../store/appState"; +import { useSettings } from "../../hooks/useSettings"; +import UpdateDialog from "../settings/UpdateDialog"; export default function TopBar() { - const { dockerAvailable, imageExists } = useAppState( - useShallow(s => ({ dockerAvailable: s.dockerAvailable, imageExists: s.imageExists })) + const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState( + useShallow(s => ({ + dockerAvailable: s.dockerAvailable, + imageExists: s.imageExists, + updateInfo: s.updateInfo, + appVersion: s.appVersion, + setUpdateInfo: s.setUpdateInfo, + })) ); + const { appSettings, saveSettings } = useSettings(); + const [showUpdateDialog, setShowUpdateDialog] = useState(false); + + const handleDismiss = async () => { + if (appSettings && updateInfo) { + await saveSettings({ + ...appSettings, + dismissed_update_version: updateInfo.version, + }); + } + setUpdateInfo(null); + setShowUpdateDialog(false); + }; return ( -
-
- + <> +
+
+ +
+
+ {updateInfo && ( + + )} + + +
-
- - -
-
+ {showUpdateDialog && updateInfo && ( + setShowUpdateDialog(false)} + /> + )} + ); } diff --git a/app/src/components/projects/AddProjectDialog.tsx b/app/src/components/projects/AddProjectDialog.tsx index 9b4719a..856fb63 100644 --- a/app/src/components/projects/AddProjectDialog.tsx +++ b/app/src/components/projects/AddProjectDialog.tsx @@ -1,67 +1,111 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { useProjects } from "../../hooks/useProjects"; +import type { ProjectPath } from "../../lib/types"; interface Props { onClose: () => void; } +interface PathEntry { + host_path: string; + mount_name: string; +} + +function basenameFromPath(p: string): string { + return p.replace(/[/\\]$/, "").split(/[/\\]/).pop() || ""; +} + export default function AddProjectDialog({ onClose }: Props) { const { add } = useProjects(); const [name, setName] = useState(""); - const [path, setPath] = useState(""); + const [pathEntries, setPathEntries] = useState([ + { host_path: "", mount_name: "" }, + ]); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const nameInputRef = useRef(null); const overlayRef = useRef(null); - // Auto-focus the first input when the dialog opens useEffect(() => { nameInputRef.current?.focus(); }, []); - // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } + if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); - // Close on click outside (click on overlay backdrop) const handleOverlayClick = useCallback( (e: React.MouseEvent) => { - if (e.target === overlayRef.current) { - onClose(); - } + if (e.target === overlayRef.current) onClose(); }, [onClose], ); - const handleBrowse = async () => { + const handleBrowse = async (index: number) => { const selected = await open({ directory: true, multiple: false }); if (typeof selected === "string") { - setPath(selected); - if (!name) { - const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/); - setName(parts[parts.length - 1]); + const basename = basenameFromPath(selected); + const entries = [...pathEntries]; + entries[index] = { + host_path: selected, + mount_name: entries[index].mount_name || basename, + }; + setPathEntries(entries); + // Auto-fill project name from first folder + if (!name && index === 0) { + setName(basename); } } }; + const updateEntry = ( + index: number, + field: keyof PathEntry, + value: string, + ) => { + const entries = [...pathEntries]; + entries[index] = { ...entries[index], [field]: value }; + setPathEntries(entries); + }; + + const removeEntry = (index: number) => { + setPathEntries(pathEntries.filter((_, i) => i !== index)); + }; + + const addEntry = () => { + setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]); + }; + const handleSubmit = async (e?: React.FormEvent) => { if (e) e.preventDefault(); - if (!name.trim() || !path.trim()) { - setError("Name and path are required"); + if (!name.trim()) { + setError("Project name is required"); + return; + } + const validPaths: ProjectPath[] = pathEntries + .filter((p) => p.host_path.trim()) + .map((p) => ({ + host_path: p.host_path.trim(), + mount_name: p.mount_name.trim() || basenameFromPath(p.host_path), + })); + if (validPaths.length === 0) { + setError("At least one folder path is required"); + return; + } + const mountNames = validPaths.map((p) => p.mount_name); + if (new Set(mountNames).size !== mountNames.length) { + setError("Mount names must be unique"); return; } setLoading(true); setError(null); try { - await add(name.trim(), path.trim()); + await add(name.trim(), validPaths); onClose(); } catch (err) { setError(String(err)); @@ -76,7 +120,7 @@ export default function AddProjectDialog({ onClose }: Props) { onClick={handleOverlayClick} className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" > -
+

Add Project

@@ -92,23 +136,54 @@ export default function AddProjectDialog({ onClose }: Props) { /> -
- setPath(e.target.value)} - placeholder="/path/to/project" - className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" - /> - +
+ {pathEntries.map((entry, i) => ( +
+
+ updateEntry(i, "host_path", e.target.value)} + placeholder="/path/to/folder" + className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" + /> + + {pathEntries.length > 1 && ( + + )} +
+
+ /workspace/ + updateEntry(i, "mount_name", e.target.value)} + placeholder="mount-name" + className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono" + /> +
+
+ ))}
+ {error && (
{error}
diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index ef6c56d..6333e98 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { open } from "@tauri-apps/plugin-dialog"; -import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; +import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; @@ -21,6 +21,7 @@ export default function ProjectCard({ project }: Props) { const isStopped = project.status === "stopped" || project.status === "error"; // Local state for text fields (save on blur, not on every keystroke) + const [paths, setPaths] = useState(project.paths ?? []); const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? ""); const [gitName, setGitName] = useState(project.git_user_name ?? ""); const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); @@ -39,6 +40,7 @@ export default function ProjectCard({ project }: Props) { // Sync local state when project prop changes (e.g., after save or external update) useEffect(() => { + setPaths(project.paths ?? []); setSshKeyPath(project.ssh_key_path ?? ""); setGitName(project.git_user_name ?? ""); setGitEmail(project.git_user_email ?? ""); @@ -263,8 +265,14 @@ export default function ProjectCard({ project }: Props) { {project.name}
-
- {project.path} +
+ {project.paths.map((pp, i) => ( +
+ /workspace/{pp.mount_name} + + {pp.host_path} +
+ ))}
{isSelected && ( @@ -352,6 +360,91 @@ export default function ProjectCard({ project }: Props) { {/* Config panel */} {showConfig && (
e.stopPropagation()}> + {/* Folder paths */} +
+ + {paths.map((pp, i) => ( +
+ { + const updated = [...paths]; + updated[i] = { ...updated[i], host_path: e.target.value }; + setPaths(updated); + }} + onBlur={async () => { + try { await update({ ...project, paths }); } catch (err) { + console.error("Failed to update paths:", err); + } + }} + placeholder="/path/to/folder" + disabled={!isStopped} + className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50" + /> + + /workspace/ + { + const updated = [...paths]; + updated[i] = { ...updated[i], mount_name: e.target.value }; + setPaths(updated); + }} + onBlur={async () => { + try { await update({ ...project, paths }); } catch (err) { + console.error("Failed to update paths:", err); + } + }} + placeholder="name" + disabled={!isStopped} + className="w-20 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono" + /> + {paths.length > 1 && ( + + )} +
+ ))} + +
+ {/* SSH Key */}
diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 5737cdf..698c744 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -3,10 +3,13 @@ import ApiKeyInput from "./ApiKeyInput"; import DockerSettings from "./DockerSettings"; import AwsSettings from "./AwsSettings"; import { useSettings } from "../../hooks/useSettings"; +import { useUpdates } from "../../hooks/useUpdates"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); + const { appVersion, checkForUpdates } = useUpdates(); const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); + const [checkingUpdates, setCheckingUpdates] = useState(false); // Sync local state when appSettings change useEffect(() => { @@ -18,6 +21,20 @@ export default function SettingsPanel() { await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null }); }; + const handleCheckNow = async () => { + setCheckingUpdates(true); + try { + await checkForUpdates(); + } finally { + setCheckingUpdates(false); + } + }; + + const handleAutoCheckToggle = async () => { + if (!appSettings) return; + await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates }); + }; + return (

@@ -40,6 +57,38 @@ export default function SettingsPanel() { className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)] resize-y font-mono" />

+ + {/* Updates section */} +
+ +
+ {appVersion && ( +

+ Current version: {appVersion} +

+ )} +
+ + +
+ +
+
); } diff --git a/app/src/components/settings/UpdateDialog.tsx b/app/src/components/settings/UpdateDialog.tsx new file mode 100644 index 0000000..37e395f --- /dev/null +++ b/app/src/components/settings/UpdateDialog.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useCallback } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import type { UpdateInfo } from "../../lib/types"; + +interface Props { + updateInfo: UpdateInfo; + currentVersion: string; + onDismiss: () => void; + onClose: () => void; +} + +export default function UpdateDialog({ + updateInfo, + currentVersion, + onDismiss, + onClose, +}: Props) { + const overlayRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose], + ); + + const handleDownload = async (url: string) => { + try { + await openUrl(url); + } catch (e) { + console.error("Failed to open URL:", e); + } + }; + + const formatSize = (bytes: number) => { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+
+

Update Available

+ +
+ {currentVersion} + + + {updateInfo.version} + +
+ + {updateInfo.body && ( +
+

+ Release Notes +

+
+ {updateInfo.body} +
+
+ )} + + {updateInfo.assets.length > 0 && ( +
+

+ Downloads +

+ {updateInfo.assets.map((asset) => ( + + ))} +
+ )} + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/app/src/hooks/useProjects.ts b/app/src/hooks/useProjects.ts index b7c7cc5..98bfcac 100644 --- a/app/src/hooks/useProjects.ts +++ b/app/src/hooks/useProjects.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppState } from "../store/appState"; import * as commands from "../lib/tauri-commands"; +import type { ProjectPath } from "../lib/types"; export function useProjects() { const { @@ -30,8 +31,8 @@ export function useProjects() { }, [setProjects]); const add = useCallback( - async (name: string, path: string) => { - const project = await commands.addProject(name, path); + async (name: string, paths: ProjectPath[]) => { + const project = await commands.addProject(name, paths); // Refresh from backend to avoid stale closure issues const list = await commands.listProjects(); setProjects(list); diff --git a/app/src/hooks/useUpdates.ts b/app/src/hooks/useUpdates.ts new file mode 100644 index 0000000..4e83f30 --- /dev/null +++ b/app/src/hooks/useUpdates.ts @@ -0,0 +1,72 @@ +import { useCallback, useRef } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useAppState } from "../store/appState"; +import * as commands from "../lib/tauri-commands"; + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export function useUpdates() { + const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } = + useAppState( + useShallow((s) => ({ + updateInfo: s.updateInfo, + setUpdateInfo: s.setUpdateInfo, + appVersion: s.appVersion, + setAppVersion: s.setAppVersion, + appSettings: s.appSettings, + })), + ); + + const intervalRef = useRef | null>(null); + + const loadVersion = useCallback(async () => { + try { + const version = await commands.getAppVersion(); + setAppVersion(version); + } catch (e) { + console.error("Failed to load app version:", e); + } + }, [setAppVersion]); + + const checkForUpdates = useCallback(async () => { + try { + const info = await commands.checkForUpdates(); + if (info) { + // Respect dismissed version + const dismissed = appSettings?.dismissed_update_version; + if (dismissed && dismissed === info.version) { + setUpdateInfo(null); + return null; + } + } + setUpdateInfo(info); + return info; + } catch (e) { + console.error("Failed to check for updates:", e); + return null; + } + }, [setUpdateInfo, appSettings?.dismissed_update_version]); + + const startPeriodicCheck = useCallback(() => { + if (intervalRef.current) return; + intervalRef.current = setInterval(() => { + if (appSettings?.auto_check_updates !== false) { + checkForUpdates(); + } + }, CHECK_INTERVAL_MS); + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [checkForUpdates, appSettings?.auto_check_updates]); + + return { + updateInfo, + appVersion, + loadVersion, + checkForUpdates, + startPeriodicCheck, + }; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 6362d74..737dde3 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ContainerInfo, SiblingContainer, AppSettings } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -12,8 +12,8 @@ export const listSiblingContainers = () => // Projects export const listProjects = () => invoke("list_projects"); -export const addProject = (name: string, path: string) => - invoke("add_project", { name, path }); +export const addProject = (name: string, paths: ProjectPath[]) => + invoke("add_project", { name, paths }); export const removeProject = (projectId: string) => invoke("remove_project", { projectId }); export const updateProject = (project: Project) => @@ -49,3 +49,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) => invoke("terminal_resize", { sessionId, cols, rows }); export const closeTerminalSession = (sessionId: string) => invoke("close_terminal_session", { sessionId }); + +// Updates +export const getAppVersion = () => invoke("get_app_version"); +export const checkForUpdates = () => + invoke("check_for_updates"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 74d06c6..313408a 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -3,10 +3,15 @@ export interface EnvVar { value: string; } +export interface ProjectPath { + host_path: string; + mount_name: string; +} + export interface Project { id: string; name: string; - path: string; + paths: ProjectPath[]; container_id: string | null; status: ProjectStatus; auth_mode: AuthMode; @@ -83,4 +88,21 @@ export interface AppSettings { custom_image_name: string | null; global_aws: GlobalAwsSettings; global_claude_instructions: string | null; + auto_check_updates: boolean; + dismissed_update_version: string | null; +} + +export interface UpdateInfo { + version: string; + tag_name: string; + release_url: string; + body: string; + assets: ReleaseAsset[]; + published_at: string; +} + +export interface ReleaseAsset { + name: string; + browser_download_url: string; + size: number; } diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index a403248..b6b9b20 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { Project, TerminalSession, AppSettings } from "../lib/types"; +import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types"; interface AppState { // Projects @@ -30,6 +30,12 @@ interface AppState { // App settings appSettings: AppSettings | null; setAppSettings: (settings: AppSettings) => void; + + // Update info + updateInfo: UpdateInfo | null; + setUpdateInfo: (info: UpdateInfo | null) => void; + appVersion: string; + setAppVersion: (version: string) => void; } export const useAppState = create((set) => ({ @@ -85,4 +91,10 @@ export const useAppState = create((set) => ({ // App settings appSettings: null, setAppSettings: (settings) => set({ appSettings: settings }), + + // Update info + updateInfo: null, + setUpdateInfo: (info) => set({ updateInfo: info }), + appVersion: "", + setAppVersion: (version) => set({ appVersion: version }), }));