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<ProjectPath>` (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 <noreply@anthropic.com>
This commit is contained in:
278
app/src-tauri/Cargo.lock
generated
278
app/src-tauri/Cargo.lock
generated
@@ -523,6 +523,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -1333,8 +1339,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1344,9 +1352,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1649,6 +1659,23 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2153,6 +2180,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2985,6 +3018,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@@ -3025,6 +3113,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3045,6 +3143,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3063,6 +3171,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3165,6 +3282,44 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3223,6 +3378,26 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3245,6 +3420,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3709,6 +3919,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3873,7 +4089,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4258,6 +4474,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.49.0"
|
||||||
@@ -4286,6 +4517,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -4504,6 +4745,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tar",
|
"tar",
|
||||||
@@ -4604,6 +4846,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4856,6 +5104,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -4900,6 +5158,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -5130,6 +5397,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ dirs = "6"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod docker_commands;
|
|||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
|
pub mod update_commands;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::docker;
|
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::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -51,10 +51,26 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_project(
|
pub async fn add_project(
|
||||||
name: String,
|
name: String,
|
||||||
path: String,
|
paths: Vec<ProjectPath>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
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)?;
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.add(project)
|
state.projects_store.add(project)
|
||||||
}
|
}
|
||||||
|
|||||||
117
app/src-tauri/src/commands/update_commands.rs
Normal file
117
app/src-tauri/src/commands/update_commands.rs
Normal file
@@ -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<Option<UpdateInfo>, 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<GiteaRelease> = 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<u32> {
|
||||||
|
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<u32> {
|
||||||
|
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<String> {
|
||||||
|
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::<u32>().is_ok()) {
|
||||||
|
Some(clean.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use std::collections::hash_map::DefaultHasher;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use super::client::get_docker;
|
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.
|
/// Compute a fingerprint string for the custom environment variables.
|
||||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
/// 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<String> = 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<Option<String>, String> {
|
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
@@ -231,24 +245,27 @@ pub async fn create_container(
|
|||||||
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts = vec![
|
let mut mounts: Vec<Mount> = Vec::new();
|
||||||
// Project directory -> /workspace
|
|
||||||
Mount {
|
// Project directories -> /workspace/{mount_name}
|
||||||
target: Some("/workspace".to_string()),
|
for pp in &project.paths {
|
||||||
source: Some(project.path.clone()),
|
mounts.push(Mount {
|
||||||
|
target: Some(format!("/workspace/{}", pp.mount_name)),
|
||||||
|
source: Some(pp.host_path.clone()),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(false),
|
read_only: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Named volume for claude config persistence
|
// Named volume for claude config persistence
|
||||||
Mount {
|
mounts.push(Mount {
|
||||||
target: Some("/home/claude/.claude".to_string()),
|
target: Some("/home/claude/.claude".to_string()),
|
||||||
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
||||||
typ: Some(MountTypeEnum::VOLUME),
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
read_only: Some(false),
|
read_only: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
});
|
||||||
];
|
|
||||||
|
|
||||||
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
||||||
if let Some(ref ssh_path) = project.ssh_key_path {
|
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-id".to_string(), project.id.clone());
|
||||||
labels.insert("triple-c.project-name".to_string(), project.name.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.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.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
|
|
||||||
@@ -324,12 +341,18 @@ pub async fn create_container(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let working_dir = if project.paths.len() == 1 {
|
||||||
|
format!("/workspace/{}", project.paths[0].mount_name)
|
||||||
|
} else {
|
||||||
|
"/workspace".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
image: Some(image_name.to_string()),
|
image: Some(image_name.to_string()),
|
||||||
hostname: Some("triple-c".to_string()),
|
hostname: Some("triple-c".to_string()),
|
||||||
env: Some(env_vars),
|
env: Some(env_vars),
|
||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
working_dir: Some("/workspace".to_string()),
|
working_dir: Some(working_dir),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
tty: Some(true),
|
tty: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -425,10 +448,18 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Project path ─────────────────────────────────────────────────────
|
// ── Project paths fingerprint ──────────────────────────────────────────
|
||||||
if let Some(container_path) = get_label("triple-c.project-path") {
|
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
|
||||||
if container_path != project.path {
|
match get_label("triple-c.paths-fingerprint") {
|
||||||
log::info!("Project path mismatch (container={:?}, project={:?})", container_path, project.path);
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ pub fn run() {
|
|||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
|
// Updates
|
||||||
|
commands::update_commands::get_app_version,
|
||||||
|
commands::update_commands::check_for_updates,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ImageSource {
|
pub enum ImageSource {
|
||||||
@@ -52,6 +56,10 @@ pub struct AppSettings {
|
|||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_claude_instructions: Option<String>,
|
pub global_claude_instructions: Option<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub auto_check_updates: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dismissed_update_version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -65,6 +73,8 @@ impl Default for AppSettings {
|
|||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
global_claude_instructions: None,
|
global_claude_instructions: None,
|
||||||
|
auto_check_updates: true,
|
||||||
|
dismissed_update_version: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod container_config;
|
pub mod container_config;
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
pub use container_config::*;
|
pub use container_config::*;
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ pub struct EnvVar {
|
|||||||
pub value: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub paths: Vec<ProjectPath>,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
pub auth_mode: AuthMode,
|
||||||
@@ -91,12 +97,12 @@ pub struct BedrockConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Project {
|
impl Project {
|
||||||
pub fn new(name: String, path: String) -> Self {
|
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
Self {
|
Self {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
name,
|
name,
|
||||||
path,
|
paths,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
auth_mode: AuthMode::default(),
|
||||||
@@ -116,4 +122,29 @@ impl Project {
|
|||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/src-tauri/src/models/update_info.rs
Normal file
37
app/src-tauri/src/models/update_info.rs
Normal file
@@ -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<ReleaseAsset>,
|
||||||
|
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<GiteaAsset>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -19,33 +19,72 @@ impl ProjectsStore {
|
|||||||
|
|
||||||
let file_path = data_dir.join("projects.json");
|
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) {
|
match fs::read_to_string(&file_path) {
|
||||||
Ok(data) => match serde_json::from_str(&data) {
|
Ok(data) => {
|
||||||
Ok(parsed) => parsed,
|
// First try to parse as Vec<Value> to run migration
|
||||||
|
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||||
|
Ok(raw_values) => {
|
||||||
|
let mut migrated = false;
|
||||||
|
let migrated_values: Vec<serde_json::Value> = 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::<Vec<crate::models::Project>>(&json_str) {
|
||||||
|
Ok(parsed) => (parsed, migrated),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
||||||
// Back up the corrupted file
|
|
||||||
let backup = file_path.with_extension("json.bak");
|
let backup = file_path.with_extension("json.bak");
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||||
}
|
}
|
||||||
Vec::new()
|
(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to read projects.json: {}", e);
|
log::error!("Failed to read projects.json: {}", e);
|
||||||
Vec::new()
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
(Vec::new(), false)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
let store = Self {
|
||||||
projects: Mutex::new(projects),
|
projects: Mutex::new(projects),
|
||||||
file_path,
|
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<Project>> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import TerminalView from "./components/terminal/TerminalView";
|
|||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
import { useSettings } from "./hooks/useSettings";
|
import { useSettings } from "./hooks/useSettings";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage } = useDocker();
|
||||||
const { checkApiKey, loadSettings } = useSettings();
|
const { checkApiKey, loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId } = useAppState(
|
const { sessions, activeSessionId } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||||
);
|
);
|
||||||
@@ -25,6 +27,15 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
checkApiKey();
|
checkApiKey();
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
|
// Update detection
|
||||||
|
loadVersion();
|
||||||
|
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
||||||
|
const cleanup = startPeriodicCheck();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(updateTimer);
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,22 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import TerminalTabs from "../terminal/TerminalTabs";
|
import TerminalTabs from "../terminal/TerminalTabs";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists } = useAppState(
|
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
||||||
useShallow(s => ({ dockerAvailable: s.dockerAvailable, imageExists: s.imageExists }))
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
<div className="flex-1 overflow-x-auto pl-2">
|
<div className="flex-1 overflow-x-auto pl-2">
|
||||||
<TerminalTabs />
|
<TerminalTabs />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
|
{updateInfo && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdateDialog(true)}
|
||||||
|
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
<StatusDot ok={imageExists === true} label="Image" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showUpdateDialog && updateInfo && (
|
||||||
|
<UpdateDialog
|
||||||
|
updateInfo={updateInfo}
|
||||||
|
currentVersion={appVersion}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,111 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import type { ProjectPath } from "../../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
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) {
|
export default function AddProjectDialog({ onClose }: Props) {
|
||||||
const { add } = useProjects();
|
const { add } = useProjects();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [path, setPath] = useState("");
|
const [pathEntries, setPathEntries] = useState<PathEntry[]>([
|
||||||
|
{ host_path: "", mount_name: "" },
|
||||||
|
]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Auto-focus the first input when the dialog opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
nameInputRef.current?.focus();
|
nameInputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close on Escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") onClose();
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Close on click outside (click on overlay backdrop)
|
|
||||||
const handleOverlayClick = useCallback(
|
const handleOverlayClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.target === overlayRef.current) {
|
if (e.target === overlayRef.current) onClose();
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onClose],
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
const handleBrowse = async (index: number) => {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (typeof selected === "string") {
|
if (typeof selected === "string") {
|
||||||
setPath(selected);
|
const basename = basenameFromPath(selected);
|
||||||
if (!name) {
|
const entries = [...pathEntries];
|
||||||
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/);
|
entries[index] = {
|
||||||
setName(parts[parts.length - 1]);
|
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) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (!name.trim() || !path.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Name and path are required");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await add(name.trim(), path.trim());
|
await add(name.trim(), validPaths);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
setError(String(err));
|
||||||
@@ -76,7 +120,7 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -92,23 +136,54 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
Project Path
|
Folders
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="space-y-2 mb-3">
|
||||||
|
{pathEntries.map((entry, i) => (
|
||||||
|
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
||||||
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
value={path}
|
value={entry.host_path}
|
||||||
onChange={(e) => setPath(e.target.value)}
|
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||||
placeholder="/path/to/project"
|
placeholder="/path/to/folder"
|
||||||
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)]"
|
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)]"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowse}
|
onClick={() => handleBrowse(i)}
|
||||||
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
|
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
>
|
>
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</button>
|
||||||
|
{pathEntries.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEntry(i)}
|
||||||
|
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
||||||
|
<input
|
||||||
|
value={entry.mount_name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEntry}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add folder
|
||||||
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
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 { useProjects } from "../../hooks/useProjects";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
@@ -21,6 +21,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
// Local state for text fields (save on blur, not on every keystroke)
|
// Local state for text fields (save on blur, not on every keystroke)
|
||||||
|
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||||
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
||||||
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
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)
|
// Sync local state when project prop changes (e.g., after save or external update)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setPaths(project.paths ?? []);
|
||||||
setSshKeyPath(project.ssh_key_path ?? "");
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
setGitName(project.git_user_name ?? "");
|
setGitName(project.git_user_name ?? "");
|
||||||
setGitEmail(project.git_user_email ?? "");
|
setGitEmail(project.git_user_email ?? "");
|
||||||
@@ -263,8 +265,14 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4">
|
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||||
{project.path}
|
{project.paths.map((pp, i) => (
|
||||||
|
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||||
|
<span className="mx-1">←</span>
|
||||||
|
<span>{pp.host_path}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
@@ -352,6 +360,91 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Config panel */}
|
{/* Config panel */}
|
||||||
{showConfig && (
|
{showConfig && (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Folder paths */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
||||||
|
{paths.map((pp, i) => (
|
||||||
|
<div key={i} className="flex gap-1 mb-1 items-center">
|
||||||
|
<input
|
||||||
|
value={pp.host_path}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
const updated = [...paths];
|
||||||
|
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||||
|
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
|
||||||
|
setPaths(updated);
|
||||||
|
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||||
|
console.error("Failed to update paths:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
||||||
|
<input
|
||||||
|
value={pp.mount_name}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const updated = paths.filter((_, j) => j !== i);
|
||||||
|
setPaths(updated);
|
||||||
|
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||||
|
console.error("Failed to remove path:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const updated = [...paths, { host_path: "", mount_name: "" }];
|
||||||
|
setPaths(updated);
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SSH Key */}
|
{/* SSH Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import ApiKeyInput from "./ApiKeyInput";
|
|||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const { appVersion, checkForUpdates } = useUpdates();
|
||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
|
|
||||||
// Sync local state when appSettings change
|
// Sync local state when appSettings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -18,6 +21,20 @@ export default function SettingsPanel() {
|
|||||||
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
|
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 (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
@@ -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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Updates section */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Updates</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{appVersion && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Current version: <span className="text-[var(--text-primary)] font-mono">{appVersion}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">Auto-check for updates</label>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoCheckToggle}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||||
|
appSettings?.auto_check_updates !== false
|
||||||
|
? "bg-[var(--success)] text-white"
|
||||||
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{appSettings?.auto_check_updates !== false ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckNow}
|
||||||
|
disabled={checkingUpdates}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{checkingUpdates ? "Checking..." : "Check now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
app/src/components/settings/UpdateDialog.tsx
Normal file
121
app/src/components/settings/UpdateDialog.tsx
Normal file
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Update Available</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">{currentVersion}</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">→</span>
|
||||||
|
<span className="text-[var(--accent)] font-semibold">
|
||||||
|
{updateInfo.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateInfo.body && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
||||||
|
Release Notes
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-[var(--text-primary)] whitespace-pre-wrap bg-[var(--bg-primary)] rounded p-3 max-h-48 overflow-y-auto border border-[var(--border-color)]">
|
||||||
|
{updateInfo.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateInfo.assets.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
||||||
|
Downloads
|
||||||
|
</h3>
|
||||||
|
{updateInfo.assets.map((asset) => (
|
||||||
|
<button
|
||||||
|
key={asset.name}
|
||||||
|
onClick={() => handleDownload(asset.browser_download_url)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="truncate">{asset.name}</span>
|
||||||
|
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
||||||
|
{formatSize(asset.size)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(updateInfo.release_url)}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
View on Gitea
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { ProjectPath } from "../lib/types";
|
||||||
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const {
|
const {
|
||||||
@@ -30,8 +31,8 @@ export function useProjects() {
|
|||||||
}, [setProjects]);
|
}, [setProjects]);
|
||||||
|
|
||||||
const add = useCallback(
|
const add = useCallback(
|
||||||
async (name: string, path: string) => {
|
async (name: string, paths: ProjectPath[]) => {
|
||||||
const project = await commands.addProject(name, path);
|
const project = await commands.addProject(name, paths);
|
||||||
// Refresh from backend to avoid stale closure issues
|
// Refresh from backend to avoid stale closure issues
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
|
|||||||
72
app/src/hooks/useUpdates.ts
Normal file
72
app/src/hooks/useUpdates.ts
Normal file
@@ -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<ReturnType<typeof setInterval> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -12,8 +12,8 @@ export const listSiblingContainers = () =>
|
|||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const listProjects = () => invoke<Project[]>("list_projects");
|
export const listProjects = () => invoke<Project[]>("list_projects");
|
||||||
export const addProject = (name: string, path: string) =>
|
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||||
invoke<Project>("add_project", { name, path });
|
invoke<Project>("add_project", { name, paths });
|
||||||
export const removeProject = (projectId: string) =>
|
export const removeProject = (projectId: string) =>
|
||||||
invoke<void>("remove_project", { projectId });
|
invoke<void>("remove_project", { projectId });
|
||||||
export const updateProject = (project: Project) =>
|
export const updateProject = (project: Project) =>
|
||||||
@@ -49,3 +49,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
|||||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||||
export const closeTerminalSession = (sessionId: string) =>
|
export const closeTerminalSession = (sessionId: string) =>
|
||||||
invoke<void>("close_terminal_session", { sessionId });
|
invoke<void>("close_terminal_session", { sessionId });
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
|
export const checkForUpdates = () =>
|
||||||
|
invoke<UpdateInfo | null>("check_for_updates");
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ export interface EnvVar {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectPath {
|
||||||
|
host_path: string;
|
||||||
|
mount_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
paths: ProjectPath[];
|
||||||
container_id: string | null;
|
container_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
auth_mode: AuthMode;
|
auth_mode: AuthMode;
|
||||||
@@ -83,4 +88,21 @@ export interface AppSettings {
|
|||||||
custom_image_name: string | null;
|
custom_image_name: string | null;
|
||||||
global_aws: GlobalAwsSettings;
|
global_aws: GlobalAwsSettings;
|
||||||
global_claude_instructions: string | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -30,6 +30,12 @@ interface AppState {
|
|||||||
// App settings
|
// App settings
|
||||||
appSettings: AppSettings | null;
|
appSettings: AppSettings | null;
|
||||||
setAppSettings: (settings: AppSettings) => void;
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
updateInfo: UpdateInfo | null;
|
||||||
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
||||||
|
appVersion: string;
|
||||||
|
setAppVersion: (version: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set) => ({
|
export const useAppState = create<AppState>((set) => ({
|
||||||
@@ -85,4 +91,10 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// App settings
|
// App settings
|
||||||
appSettings: null,
|
appSettings: null,
|
||||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
updateInfo: null,
|
||||||
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
||||||
|
appVersion: "",
|
||||||
|
setAppVersion: (version) => set({ appVersion: version }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user