Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d947824436 | |||
| c2b21b794c | |||
| 40493ae284 | |||
| 2e81b52205 | |||
| 06be613e36 | |||
| da078af73f | |||
| 01ea581f8a | |||
| 552aaebf16 | |||
| c2736ace90 | |||
| 2ff270ebfe | |||
| 5a59fdb64b | |||
| 1ce5151e59 | |||
| 66ddc182c9 | |||
| 1524ec4a98 | |||
| 4721950eae | |||
| fba4b9442c |
@@ -47,8 +47,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
|
|
||||||
Each project can independently use one of:
|
Each project can independently use one of:
|
||||||
|
|
||||||
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
|
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||||
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
|
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
### Container Spawning (Sibling Containers)
|
||||||
|
|||||||
1185
app/package-lock.json
generated
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -25,13 +27,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.7",
|
"typescript": "^5.7",
|
||||||
"vite": "^6"
|
"vite": "^6",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
209
app/src-tauri/Cargo.lock
generated
@@ -41,56 +41,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "0.6.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-query",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle"
|
|
||||||
version = "1.0.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
|
||||||
dependencies = [
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-query"
|
|
||||||
version = "1.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-wincon"
|
|
||||||
version = "3.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"once_cell_polyfill",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -404,6 +354,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -543,12 +499,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -938,29 +888,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"jiff",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1024,6 +951,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fern"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -1745,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1862,6 +1799,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png 0.18.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -1929,12 +1879,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is_terminal_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1964,30 +1908,6 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2265,6 +2185,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.7.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -2280,7 +2210,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -2583,12 +2513,6 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2872,6 +2796,19 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -2886,21 +2823,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3009,6 +2931,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -4076,6 +4007,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4143,7 +4075,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4728,7 +4660,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -4741,8 +4673,9 @@ dependencies = [
|
|||||||
"bollard",
|
"bollard",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"fern",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"iana-time-zone",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -4889,12 +4822,6 @@ 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 = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8parse"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.21.0"
|
version = "1.21.0"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ name = "triple-c"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
@@ -26,9 +26,10 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
fern = { version = "0.7", features = ["date-based"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
iana-time-zone = "0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 91 KiB |
@@ -124,26 +124,15 @@ pub async fn start_project_container(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
// Get API key only if auth mode requires it
|
// Validate auth mode requirements
|
||||||
let api_key = match project.auth_mode {
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
AuthMode::ApiKey => {
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
let key = secure::get_api_key()?
|
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||||
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
|
// Region can come from per-project or global
|
||||||
Some(key)
|
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
||||||
|
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
||||||
}
|
}
|
||||||
AuthMode::Login => {
|
}
|
||||||
None
|
|
||||||
}
|
|
||||||
AuthMode::Bedrock => {
|
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
|
||||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
|
||||||
// Region can come from per-project or global
|
|
||||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
|
||||||
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update status to starting
|
// Update status to starting
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||||
@@ -170,6 +159,8 @@ pub async fn start_project_container(
|
|||||||
&existing_id,
|
&existing_id,
|
||||||
&project,
|
&project,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -179,12 +170,13 @@ pub async fn start_project_container(
|
|||||||
docker::remove_container(&existing_id).await?;
|
docker::remove_container(&existing_id).await?;
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
@@ -195,12 +187,13 @@ pub async fn start_project_container(
|
|||||||
} else {
|
} else {
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
|
settings.timezone.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
|
|||||||
@@ -2,24 +2,8 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::AppSettings;
|
use crate::models::AppSettings;
|
||||||
use crate::storage::secure;
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
|
||||||
secure::store_api_key(&key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn has_api_key() -> Result<bool, String> {
|
|
||||||
secure::has_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_api_key() -> Result<(), String> {
|
|
||||||
secure::delete_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||||
Ok(state.settings_store.get())
|
Ok(state.settings_store.get())
|
||||||
@@ -45,6 +29,33 @@ pub async fn pull_image(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_host_timezone() -> Result<String, String> {
|
||||||
|
// Try the iana-time-zone crate first (cross-platform)
|
||||||
|
match iana_time_zone::get_timezone() {
|
||||||
|
Ok(tz) => return Ok(tz),
|
||||||
|
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check TZ env var
|
||||||
|
if let Ok(tz) = std::env::var("TZ") {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: read /etc/timezone (Linux)
|
||||||
|
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
|
||||||
|
let tz = tz.trim().to_string();
|
||||||
|
if !tz.is_empty() {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to UTC if detection fails
|
||||||
|
Ok("UTC".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
|||||||
@@ -2,13 +2,43 @@ use bollard::container::{
|
|||||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||||
StartContainerOptions, StopContainerOptions,
|
StartContainerOptions, StopContainerOptions,
|
||||||
};
|
};
|
||||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
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, ProjectPath};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||||
|
|
||||||
|
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
|
||||||
|
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
|
||||||
|
- `triple-c-scheduler list` — List all scheduled tasks
|
||||||
|
- `triple-c-scheduler remove --id ID` — Remove a task
|
||||||
|
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
|
||||||
|
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
|
||||||
|
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately
|
||||||
|
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
|
||||||
|
|
||||||
|
### Cron format
|
||||||
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
|
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
|
||||||
|
|
||||||
|
### One-time tasks
|
||||||
|
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
|
||||||
|
|
||||||
|
### Working directory
|
||||||
|
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
|
||||||
|
|
||||||
|
### Checking results
|
||||||
|
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
|
||||||
|
|
||||||
|
### Timezone
|
||||||
|
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -30,6 +60,25 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
|||||||
parts.join(",")
|
parts.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge global and per-project custom environment variables.
|
||||||
|
/// Per-project variables override global variables with the same key.
|
||||||
|
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||||
|
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
|
||||||
|
for ev in global {
|
||||||
|
let key = ev.key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
merged.insert(key, ev.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ev in project {
|
||||||
|
let key = ev.key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
merged.insert(key, ev.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge global and per-project Claude instructions into a single string.
|
/// Merge global and per-project Claude instructions into a single string.
|
||||||
fn merge_claude_instructions(
|
fn merge_claude_instructions(
|
||||||
global_instructions: Option<&str>,
|
global_instructions: Option<&str>,
|
||||||
@@ -76,6 +125,20 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
|
|||||||
format!("{:x}", hasher.finish())
|
format!("{:x}", hasher.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint for port mappings so we can detect changes.
|
||||||
|
/// Sorted so order changes don't cause spurious recreation.
|
||||||
|
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||||
|
let mut parts: Vec<String> = port_mappings
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
|
||||||
|
.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();
|
||||||
@@ -108,18 +171,22 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
|||||||
|
|
||||||
pub async fn create_container(
|
pub async fn create_container(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
api_key: Option<&str>,
|
|
||||||
docker_socket_path: &str,
|
docker_socket_path: &str,
|
||||||
image_name: &str,
|
image_name: &str,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
timezone: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
|
|
||||||
let mut env_vars: Vec<String> = Vec::new();
|
let mut env_vars: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Tell CLI tools the terminal supports 24-bit RGB color
|
||||||
|
env_vars.push("COLORTERM=truecolor".to_string());
|
||||||
|
|
||||||
// Pass host UID/GID so the entrypoint can remap the container user
|
// Pass host UID/GID so the entrypoint can remap the container user
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -153,10 +220,6 @@ pub async fn create_container(
|
|||||||
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref token) = project.git_token {
|
if let Some(ref token) = project.git_token {
|
||||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||||
}
|
}
|
||||||
@@ -219,9 +282,10 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom environment variables
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||||
for env_var in &project.custom_env_vars {
|
for env_var in &merged_env {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -233,14 +297,44 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
env_vars.push(format!("{}={}", key, env_var.value));
|
env_vars.push(format!("{}={}", key, env_var.value));
|
||||||
}
|
}
|
||||||
let custom_env_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
|
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||||
|
|
||||||
// Claude instructions (global + per-project)
|
// Container timezone
|
||||||
let combined_instructions = merge_claude_instructions(
|
if let Some(tz) = timezone {
|
||||||
|
if !tz.is_empty() {
|
||||||
|
env_vars.push(format!("TZ={}", tz));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude instructions (global + per-project, plus port mapping info)
|
||||||
|
let mut combined_instructions = merge_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
);
|
);
|
||||||
|
if !project.port_mappings.is_empty() {
|
||||||
|
let mut port_lines: Vec<String> = Vec::new();
|
||||||
|
port_lines.push("## Available Port Mappings".to_string());
|
||||||
|
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
|
||||||
|
for pm in &project.port_mappings {
|
||||||
|
port_lines.push(format!(
|
||||||
|
"- Host port {} -> Container port {} ({})",
|
||||||
|
pm.host_port, pm.container_port, pm.protocol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let port_info = port_lines.join("\n");
|
||||||
|
combined_instructions = Some(match combined_instructions {
|
||||||
|
Some(existing) => format!("{}\n\n{}", existing, port_info),
|
||||||
|
None => port_info,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Scheduler instructions (always appended so all containers get scheduling docs)
|
||||||
|
let scheduler_docs = SCHEDULER_INSTRUCTIONS;
|
||||||
|
combined_instructions = Some(match combined_instructions {
|
||||||
|
Some(existing) => format!("{}\n\n{}", existing, scheduler_docs),
|
||||||
|
None => scheduler_docs.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(ref instructions) = combined_instructions {
|
if let Some(ref instructions) = combined_instructions {
|
||||||
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||||
}
|
}
|
||||||
@@ -327,6 +421,21 @@ pub async fn create_container(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Port mappings
|
||||||
|
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
|
||||||
|
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||||
|
for pm in &project.port_mappings {
|
||||||
|
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
|
||||||
|
exposed_ports.insert(container_key.clone(), HashMap::new());
|
||||||
|
port_bindings.insert(
|
||||||
|
container_key,
|
||||||
|
Some(vec![PortBinding {
|
||||||
|
host_ip: Some("0.0.0.0".to_string()),
|
||||||
|
host_port: Some(pm.host_port.to_string()),
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut labels = HashMap::new();
|
let mut labels = HashMap::new();
|
||||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||||
@@ -334,10 +443,14 @@ pub async fn create_container(
|
|||||||
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.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
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.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
|
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
|
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||||
|
init: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -354,6 +467,7 @@ pub async fn create_container(
|
|||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
working_dir: Some(working_dir),
|
working_dir: Some(working_dir),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
|
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
|
||||||
tty: Some(true),
|
tty: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -392,6 +506,10 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
log::info!(
|
||||||
|
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||||
|
container_id
|
||||||
|
);
|
||||||
docker
|
docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
@@ -412,6 +530,8 @@ pub async fn container_needs_recreation(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
timezone: Option<&str>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
@@ -464,6 +584,14 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Port mappings fingerprint ──────────────────────────────────────────
|
||||||
|
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
|
||||||
|
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
|
||||||
|
if container_ports_fp != expected_ports_fp {
|
||||||
|
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bedrock config fingerprint ───────────────────────────────────────
|
// ── Bedrock config fingerprint ───────────────────────────────────────
|
||||||
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
||||||
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
||||||
@@ -491,6 +619,14 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Timezone ─────────────────────────────────────────────────────────
|
||||||
|
let expected_tz = timezone.unwrap_or("");
|
||||||
|
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
|
||||||
|
if container_tz != expected_tz {
|
||||||
|
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── SSH key path mount ───────────────────────────────────────────────
|
// ── SSH key path mount ───────────────────────────────────────────────
|
||||||
let ssh_mount_source = mounts
|
let ssh_mount_source = mounts
|
||||||
.and_then(|m| {
|
.and_then(|m| {
|
||||||
@@ -540,7 +676,8 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom environment variables ──────────────────────────────────────
|
// ── Custom environment variables ──────────────────────────────────────
|
||||||
let expected_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
|
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
||||||
if container_fingerprint != expected_fingerprint {
|
if container_fingerprint != expected_fingerprint {
|
||||||
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use crate::models::container_config;
|
|||||||
|
|
||||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||||
|
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
|
||||||
|
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
||||||
|
|
||||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|||||||
header.set_cksum();
|
header.set_cksum();
|
||||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
||||||
|
|
||||||
|
let scheduler_bytes = SCHEDULER.as_bytes();
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(scheduler_bytes.len() as u64);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
|
||||||
|
|
||||||
|
let task_runner_bytes = TASK_RUNNER.as_bytes();
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(task_runner_bytes.len() as u64);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_cksum();
|
||||||
|
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
|
||||||
|
|
||||||
archive.finish()?;
|
archive.finish()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
@@ -15,17 +16,45 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
env_logger::init();
|
logging::init();
|
||||||
|
|
||||||
|
let projects_store = match ProjectsStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize projects store: {}", e);
|
||||||
|
panic!("Failed to initialize projects store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let settings_store = match SettingsStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize settings store: {}", e);
|
||||||
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store: ProjectsStore::new().expect("Failed to initialize projects store"),
|
projects_store,
|
||||||
settings_store: SettingsStore::new().expect("Failed to initialize settings store"),
|
settings_store,
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
|
Ok(icon) => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.set_icon(icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load window icon: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
let state = window.state::<AppState>();
|
let state = window.state::<AppState>();
|
||||||
@@ -50,14 +79,12 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::set_api_key,
|
|
||||||
commands::settings_commands::has_api_key,
|
|
||||||
commands::settings_commands::delete_api_key,
|
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
commands::settings_commands::detect_aws_config,
|
commands::settings_commands::detect_aws_config,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
|
commands::settings_commands::detect_host_timezone,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
|
|||||||
73
app/src-tauri/src/logging.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||||
|
fn log_dir() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise logging to both stderr and a log file in the app data directory.
|
||||||
|
///
|
||||||
|
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
|
||||||
|
/// A panic hook is also installed so that unexpected crashes are captured in the
|
||||||
|
/// same log file before the process exits.
|
||||||
|
pub fn init() {
|
||||||
|
let log_file_path = log_dir().and_then(|dir| {
|
||||||
|
fs::create_dir_all(&dir).ok()?;
|
||||||
|
let path = dir.join("triple-c.log");
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|file| (path, file))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut dispatch = fern::Dispatch::new()
|
||||||
|
.format(|out, message, record| {
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{} {} {}] {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.chain(std::io::stderr());
|
||||||
|
|
||||||
|
if let Some((_path, file)) = &log_file_path {
|
||||||
|
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = dispatch.apply() {
|
||||||
|
eprintln!("Failed to initialise logger: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install a panic hook that writes to the log file so crashes are captured.
|
||||||
|
let crash_log_dir = log_dir();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let msg = format!(
|
||||||
|
"[{} PANIC] {}\nBacktrace:\n{:?}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
info,
|
||||||
|
std::backtrace::Backtrace::force_capture(),
|
||||||
|
);
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(ref dir) = crash_log_dir {
|
||||||
|
let crash_path = dir.join("triple-c.log");
|
||||||
|
let _ = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&crash_path)
|
||||||
|
.and_then(|mut f| {
|
||||||
|
use std::io::Write;
|
||||||
|
writeln!(f, "{}", msg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Some((ref path, _)) = log_file_path {
|
||||||
|
log::info!("Logging to {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::project::EnvVar;
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_global_instructions() -> Option<String> {
|
||||||
|
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[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 {
|
||||||
@@ -54,12 +60,16 @@ pub struct AppSettings {
|
|||||||
pub custom_image_name: Option<String>,
|
pub custom_image_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
#[serde(default)]
|
#[serde(default = "default_global_instructions")]
|
||||||
pub global_claude_instructions: Option<String>,
|
pub global_claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_custom_env_vars: Vec<EnvVar>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub auto_check_updates: bool,
|
pub auto_check_updates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dismissed_update_version: Option<String>,
|
pub dismissed_update_version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timezone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -72,9 +82,11 @@ impl Default for AppSettings {
|
|||||||
image_source: ImageSource::default(),
|
image_source: ImageSource::default(),
|
||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
global_claude_instructions: None,
|
global_claude_instructions: default_global_instructions(),
|
||||||
|
global_custom_env_vars: Vec::new(),
|
||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
dismissed_update_version: None,
|
dismissed_update_version: None,
|
||||||
|
timezone: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ pub struct ProjectPath {
|
|||||||
pub mount_name: String,
|
pub mount_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PortMapping {
|
||||||
|
pub host_port: u16,
|
||||||
|
pub container_port: u16,
|
||||||
|
#[serde(default = "default_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocol() -> String {
|
||||||
|
"tcp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -30,6 +42,8 @@ pub struct Project {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_env_vars: Vec<EnvVar>,
|
pub custom_env_vars: Vec<EnvVar>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub port_mappings: Vec<PortMapping>,
|
||||||
|
#[serde(default)]
|
||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -46,20 +60,21 @@ pub enum ProjectStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// How the project authenticates with Claude.
|
||||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
/// persisted in the config volume)
|
||||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthMode {
|
pub enum AuthMode {
|
||||||
Login,
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
ApiKey,
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
|
Anthropic,
|
||||||
Bedrock,
|
Bedrock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for AuthMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Login
|
Self::Anthropic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,7 @@ impl Project {
|
|||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
custom_env_vars: Vec::new(),
|
custom_env_vars: Vec::new(),
|
||||||
|
port_mappings: Vec::new(),
|
||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|||||||
@@ -1,42 +1,3 @@
|
|||||||
const SERVICE_NAME: &str = "triple-c";
|
|
||||||
const API_KEY_USER: &str = "anthropic-api-key";
|
|
||||||
|
|
||||||
pub fn store_api_key(key: &str) -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
entry
|
|
||||||
.set_password(key)
|
|
||||||
.map_err(|e| format!("Failed to store API key: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_api_key() -> Result<Option<String>, String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.get_password() {
|
|
||||||
Ok(key) => Ok(Some(key)),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
|
||||||
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_api_key() -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.delete_credential() {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Failed to delete API key: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_api_key() -> Result<bool, String> {
|
|
||||||
match get_api_key() {
|
|
||||||
Ok(Some(_)) => Ok(true),
|
|
||||||
Ok(None) => Ok(false),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
"productName": "Triple-C",
|
"productName": "Triple-C",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.triple-c.app",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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 { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId } = useAppState(
|
const { sessions, activeSessionId } = useAppState(
|
||||||
@@ -25,7 +25,6 @@ export default function App() {
|
|||||||
checkDocker().then((available) => {
|
checkDocker().then((available) => {
|
||||||
if (available) checkImage();
|
if (available) checkImage();
|
||||||
});
|
});
|
||||||
checkApiKey();
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
|
|||||||
54
app/src/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
|
||||||
|
// Mock zustand store
|
||||||
|
vi.mock("../../store/appState", () => ({
|
||||||
|
useAppState: vi.fn((selector) =>
|
||||||
|
selector({
|
||||||
|
sidebarView: "projects",
|
||||||
|
setSidebarView: vi.fn(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock child components to isolate Sidebar layout testing
|
||||||
|
vi.mock("../projects/ProjectList", () => ({
|
||||||
|
default: () => <div data-testid="project-list">ProjectList</div>,
|
||||||
|
}));
|
||||||
|
vi.mock("../settings/SettingsPanel", () => ({
|
||||||
|
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Sidebar", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the sidebar with content area", () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
expect(contentArea).not.toBeNull();
|
||||||
|
expect(contentArea!.className).toContain("min-w-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("content area has overflow-x-hidden to prevent horizontal scroll", () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
expect(contentArea).not.toBeNull();
|
||||||
|
expect(contentArea!.className).toContain("overflow-x-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidebar outer container has overflow-hidden", () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
const sidebar = container.firstElementChild;
|
||||||
|
expect(sidebar).not.toBeNull();
|
||||||
|
expect(sidebar!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
80
app/src/components/projects/ClaudeInstructionsModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instructions: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (instructions: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [instructions, setInstructions] = useState(initial);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 handleBlur = async () => {
|
||||||
|
try { await onSave(instructions); } catch (err) {
|
||||||
|
console.error("Failed to update Claude instructions:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[40rem] shadow-xl max-h-[80vh] flex flex-col">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change Claude instructions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||||
|
disabled={disabled}
|
||||||
|
rows={14}
|
||||||
|
className="w-full 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)] disabled:opacity-50 resize-y font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/src/components/projects/EnvVarsModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
envVars: EnvVar[];
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (vars: EnvVar[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||||
|
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 updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||||
|
const updated = [...vars];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setVars(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVar = async (index: number) => {
|
||||||
|
const updated = vars.filter((_, i) => i !== index);
|
||||||
|
setVars(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to remove environment variable:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addVar = async () => {
|
||||||
|
const updated = [...vars, { key: "", value: "" }];
|
||||||
|
setVars(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to add environment variable:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
try { await onSave(vars); } catch (err) {
|
||||||
|
console.error("Failed to update environment variables:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change environment variables.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{vars.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
|
||||||
|
)}
|
||||||
|
{vars.map((ev, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={ev.key}
|
||||||
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="KEY"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={ev.value}
|
||||||
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="value"
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeVar(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={addVar}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add variable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
app/src/components/projects/PortMappingsModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { PortMapping } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portMappings: PortMapping[];
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (mappings: PortMapping[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||||
|
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 updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProtocol = (index: number, value: string) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[index] = { ...updated[index], protocol: value };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMapping = async (index: number) => {
|
||||||
|
const updated = mappings.filter((_, i) => i !== index);
|
||||||
|
setMappings(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to remove port mapping:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMapping = async () => {
|
||||||
|
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
||||||
|
setMappings(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to add port mapping:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
try { await onSave(mappings); } catch (err) {
|
||||||
|
console.error("Failed to update port mappings:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
Map host ports to container ports. Services can be started after the container is running.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change port mappings.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||||
|
)}
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||||
|
<span className="w-[30%]">Host Port</span>
|
||||||
|
<span className="w-[30%]">Container Port</span>
|
||||||
|
<span className="w-[25%]">Protocol</span>
|
||||||
|
<span className="w-[15%]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mappings.map((pm, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.host_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="8080"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.container_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="8080"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pm.protocol}
|
||||||
|
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => removeMapping(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={addMapping}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add port mapping
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
app/src/components/projects/ProjectCard.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import ProjectCard from "./ProjectCard";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
// Mock Tauri dialog plugin
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
open: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
const mockUpdate = vi.fn();
|
||||||
|
const mockStart = vi.fn();
|
||||||
|
const mockStop = vi.fn();
|
||||||
|
const mockRebuild = vi.fn();
|
||||||
|
const mockRemove = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({
|
||||||
|
useProjects: () => ({
|
||||||
|
start: mockStart,
|
||||||
|
stop: mockStop,
|
||||||
|
rebuild: mockRebuild,
|
||||||
|
remove: mockRemove,
|
||||||
|
update: mockUpdate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useTerminal", () => ({
|
||||||
|
useTerminal: () => ({
|
||||||
|
open: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockSelectedProjectId: string | null = null;
|
||||||
|
vi.mock("../../store/appState", () => ({
|
||||||
|
useAppState: vi.fn((selector) =>
|
||||||
|
selector({
|
||||||
|
selectedProjectId: mockSelectedProjectId,
|
||||||
|
setSelectedProject: vi.fn(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProject: Project = {
|
||||||
|
id: "test-1",
|
||||||
|
name: "Test Project",
|
||||||
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "stopped",
|
||||||
|
auth_mode: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
ssh_key_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ProjectCard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSelectedProjectId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders project name and path", () => {
|
||||||
|
render(<ProjectCard project={mockProject} />);
|
||||||
|
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("card root has min-w-0 and overflow-hidden to contain content", () => {
|
||||||
|
const { container } = render(<ProjectCard project={mockProject} />);
|
||||||
|
const card = container.firstElementChild;
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(card!.className).toContain("min-w-0");
|
||||||
|
expect(card!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when selected and showing config", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSelectedProjectId = "test-1";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expanded area has min-w-0 and overflow-hidden", () => {
|
||||||
|
const { container } = render(<ProjectCard project={mockProject} />);
|
||||||
|
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
|
||||||
|
const expandedSection = container.querySelector(".ml-4.mt-2");
|
||||||
|
expect(expandedSection).not.toBeNull();
|
||||||
|
expect(expandedSection!.className).toContain("min-w-0");
|
||||||
|
expect(expandedSection!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folder path inputs use min-w-0 to allow shrinking", async () => {
|
||||||
|
const { container } = render(<ProjectCard project={mockProject} />);
|
||||||
|
|
||||||
|
// Click Config button to show config panel
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Config"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// After config is shown, check the folder host_path input has min-w-0
|
||||||
|
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
|
||||||
|
expect(hostPathInputs.length).toBeGreaterThan(0);
|
||||||
|
expect(hostPathInputs[0].className).toContain("min-w-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("config panel container has overflow-hidden", async () => {
|
||||||
|
const { container } = render(<ProjectCard project={mockProject} />);
|
||||||
|
|
||||||
|
// Click Config button
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Config"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The config panel has border-t and overflow containment classes
|
||||||
|
const allDivs = container.querySelectorAll("div");
|
||||||
|
const configPanel = Array.from(allDivs).find(
|
||||||
|
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
|
||||||
|
);
|
||||||
|
expect(configPanel).toBeDefined();
|
||||||
|
expect(configPanel!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,9 @@ import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod }
|
|||||||
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";
|
||||||
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -17,6 +20,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||||
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
@@ -28,6 +34,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||||
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
||||||
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
||||||
|
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
|
||||||
|
|
||||||
// Bedrock local state for text fields
|
// Bedrock local state for text fields
|
||||||
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
@@ -47,6 +54,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
setGitToken(project.git_token ?? "");
|
setGitToken(project.git_token ?? "");
|
||||||
setClaudeInstructions(project.claude_instructions ?? "");
|
setClaudeInstructions(project.claude_instructions ?? "");
|
||||||
setEnvVars(project.custom_env_vars ?? []);
|
setEnvVars(project.custom_env_vars ?? []);
|
||||||
|
setPortMappings(project.port_mappings ?? []);
|
||||||
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
||||||
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||||
@@ -165,22 +173,6 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaudeInstructionsBlur = async () => {
|
|
||||||
try {
|
|
||||||
await update({ ...project, claude_instructions: claudeInstructions || null });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update Claude instructions:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnvVarBlur = async () => {
|
|
||||||
try {
|
|
||||||
await update({ ...project, custom_env_vars: envVars });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update environment variables:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBedrockRegionBlur = async () => {
|
const handleBedrockRegionBlur = async () => {
|
||||||
try {
|
try {
|
||||||
const current = project.bedrock_config ?? defaultBedrockConfig;
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
@@ -255,7 +247,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedProject(project.id)}
|
onClick={() => setSelectedProject(project.id)}
|
||||||
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
|
className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-[var(--bg-tertiary)]"
|
? "bg-[var(--bg-tertiary)]"
|
||||||
: "hover:bg-[var(--bg-tertiary)]"
|
: "hover:bg-[var(--bg-tertiary)]"
|
||||||
@@ -269,38 +261,25 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{project.paths.map((pp, i) => (
|
{project.paths.map((pp, i) => (
|
||||||
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||||
<span className="mx-1">←</span>
|
|
||||||
<span>{pp.host_path}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="mt-2 ml-4 space-y-2">
|
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
||||||
{/* Auth mode selector */}
|
{/* Auth mode selector */}
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
className={`px-2 py-0.5 rounded transition-colors ${
|
||||||
project.auth_mode === "login"
|
project.auth_mode === "anthropic"
|
||||||
? "bg-[var(--accent)] text-white"
|
? "bg-[var(--accent)] text-white"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
/login
|
Anthropic
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
|
||||||
project.auth_mode === "api_key"
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
||||||
} disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
API key
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||||
@@ -359,78 +338,87 @@ 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)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{!isStopped && (
|
||||||
|
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change settings.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Folder paths */}
|
{/* Folder paths */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
||||||
{paths.map((pp, i) => (
|
{paths.map((pp, i) => (
|
||||||
<div key={i} className="flex gap-1 mb-1 items-center">
|
<div key={i} className="mb-1">
|
||||||
<input
|
<div className="flex gap-1 items-center min-w-0">
|
||||||
value={pp.host_path}
|
<input
|
||||||
onChange={(e) => {
|
value={pp.host_path}
|
||||||
const updated = [...paths];
|
onChange={(e) => {
|
||||||
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 updated = [...paths];
|
||||||
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||||
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
|
|
||||||
setPaths(updated);
|
setPaths(updated);
|
||||||
try { await update({ ...project, paths: updated }); } catch (err) {
|
}}
|
||||||
|
onBlur={async () => {
|
||||||
|
try { await update({ ...project, paths }); } catch (err) {
|
||||||
console.error("Failed to update paths:", err);
|
console.error("Failed to update paths:", err);
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
placeholder="/path/to/folder"
|
||||||
disabled={!isStopped}
|
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"
|
className="flex-1 min-w-0 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>
|
|
||||||
<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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const updated = paths.filter((_, j) => j !== i);
|
const selected = await open({ directory: true, multiple: false });
|
||||||
setPaths(updated);
|
if (typeof selected === "string") {
|
||||||
try { await update({ ...project, paths: updated }); } catch (err) {
|
const updated = [...paths];
|
||||||
console.error("Failed to remove path:", err);
|
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}
|
disabled={!isStopped}
|
||||||
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
className="flex-shrink-0 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"
|
||||||
>
|
>
|
||||||
x
|
...
|
||||||
</button>
|
</button>
|
||||||
)}
|
{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="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 items-center mt-0.5 min-w-0">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">/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="flex-1 min-w-0 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
@@ -528,76 +516,42 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environment Variables */}
|
{/* Environment Variables */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
{envVars.map((ev, i) => (
|
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
||||||
<div key={i} className="flex gap-1 mb-1">
|
</label>
|
||||||
<input
|
|
||||||
value={ev.key}
|
|
||||||
onChange={(e) => {
|
|
||||||
const vars = [...envVars];
|
|
||||||
vars[i] = { ...vars[i], key: e.target.value };
|
|
||||||
setEnvVars(vars);
|
|
||||||
}}
|
|
||||||
onBlur={handleEnvVarBlur}
|
|
||||||
placeholder="KEY"
|
|
||||||
disabled={!isStopped}
|
|
||||||
className="w-1/3 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"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={ev.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const vars = [...envVars];
|
|
||||||
vars[i] = { ...vars[i], value: e.target.value };
|
|
||||||
setEnvVars(vars);
|
|
||||||
}}
|
|
||||||
onBlur={handleEnvVarBlur}
|
|
||||||
placeholder="value"
|
|
||||||
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 font-mono"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const vars = envVars.filter((_, j) => j !== i);
|
|
||||||
setEnvVars(vars);
|
|
||||||
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
|
|
||||||
console.error("Failed to remove environment variable:", 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
|
<button
|
||||||
onClick={async () => {
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
const vars = [...envVars, { key: "", value: "" }];
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
setEnvVars(vars);
|
|
||||||
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
|
|
||||||
console.error("Failed to add environment variable:", err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
>
|
||||||
+ Add variable
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port Mappings */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPortMappingsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claude Instructions */}
|
{/* Claude Instructions */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
<textarea
|
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
||||||
value={claudeInstructions}
|
</label>
|
||||||
onChange={(e) => setClaudeInstructions(e.target.value)}
|
<button
|
||||||
onBlur={handleClaudeInstructionsBlur}
|
onClick={() => setShowClaudeInstructionsModal(true)}
|
||||||
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
disabled={!isStopped}
|
>
|
||||||
rows={3}
|
Edit
|
||||||
className="w-full 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 resize-y font-mono"
|
</button>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
@@ -732,6 +686,42 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEnvVarsModal && (
|
||||||
|
<EnvVarsModal
|
||||||
|
envVars={envVars}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (vars) => {
|
||||||
|
setEnvVars(vars);
|
||||||
|
await update({ ...project, custom_env_vars: vars });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEnvVarsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPortMappingsModal && (
|
||||||
|
<PortMappingsModal
|
||||||
|
portMappings={portMappings}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (mappings) => {
|
||||||
|
setPortMappings(mappings);
|
||||||
|
await update({ ...project, port_mappings: mappings });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowPortMappingsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showClaudeInstructionsModal && (
|
||||||
|
<ClaudeInstructionsModal
|
||||||
|
instructions={claudeInstructions}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (instructions) => {
|
||||||
|
setClaudeInstructions(instructions);
|
||||||
|
await update({ ...project, claude_instructions: instructions || null });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
|
||||||
|
|
||||||
export default function ApiKeyInput() {
|
export default function ApiKeyInput() {
|
||||||
const { hasKey, saveApiKey, removeApiKey } = useSettings();
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!key.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await saveApiKey(key.trim());
|
|
||||||
setKey("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
|
||||||
API Key (for projects using API key mode)
|
|
||||||
</label>
|
|
||||||
{hasKey ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-[var(--success)]">Key configured</span>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try { await removeApiKey(); } catch (e) { setError(String(e)); }
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--error)] hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
placeholder="sk-ant-..."
|
|
||||||
className="w-full 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)]"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !key.trim()}
|
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save Key"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function AwsSettings() {
|
|||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
||||||
|
Changes here require a container rebuild to take effect.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* AWS Config Path */}
|
{/* AWS Config Path */}
|
||||||
|
|||||||
@@ -4,22 +4,37 @@ 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";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
|
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
const { appVersion, checkForUpdates } = useUpdates();
|
const { appVersion, checkForUpdates } = useUpdates();
|
||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
|
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
|
||||||
|
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
|
||||||
// Sync local state when appSettings change
|
// Sync local state when appSettings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||||
}, [appSettings?.global_claude_instructions]);
|
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||||
|
setTimezone(appSettings?.timezone ?? "");
|
||||||
|
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
|
||||||
|
|
||||||
const handleInstructionsBlur = async () => {
|
// Auto-detect timezone on first load if not yet set
|
||||||
if (!appSettings) return;
|
useEffect(() => {
|
||||||
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
|
if (appSettings && !appSettings.timezone) {
|
||||||
};
|
detectHostTimezone().then((tz) => {
|
||||||
|
setTimezone(tz);
|
||||||
|
saveSettings({ ...appSettings, timezone: tz });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [appSettings?.timezone]);
|
||||||
|
|
||||||
const handleCheckNow = async () => {
|
const handleCheckNow = async () => {
|
||||||
setCheckingUpdates(true);
|
setCheckingUpdates(true);
|
||||||
@@ -43,19 +58,63 @@ export default function SettingsPanel() {
|
|||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
|
{/* Container Timezone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Claude Instructions</label>
|
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
onBlur={async () => {
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, timezone: timezone || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="UTC"
|
||||||
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Claude Instructions */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<div className="flex items-center justify-between">
|
||||||
value={globalInstructions}
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
onChange={(e) => setGlobalInstructions(e.target.value)}
|
{globalInstructions ? "Configured" : "Not set"}
|
||||||
onBlur={handleInstructionsBlur}
|
</span>
|
||||||
placeholder="Instructions for Claude Code in all project containers..."
|
<button
|
||||||
rows={4}
|
onClick={() => setShowInstructionsModal(true)}
|
||||||
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="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
/>
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Applied to all project containers. Per-project variables override global ones with the same key.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Updates section */}
|
{/* Updates section */}
|
||||||
@@ -89,6 +148,34 @@ export default function SettingsPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showInstructionsModal && (
|
||||||
|
<ClaudeInstructionsModal
|
||||||
|
instructions={globalInstructions}
|
||||||
|
disabled={false}
|
||||||
|
onSave={async (instructions) => {
|
||||||
|
setGlobalInstructions(instructions);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, global_claude_instructions: instructions || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowInstructionsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEnvVarsModal && (
|
||||||
|
<EnvVarsModal
|
||||||
|
envVars={globalEnvVars}
|
||||||
|
disabled={false}
|
||||||
|
onSave={async (vars) => {
|
||||||
|
setGlobalEnvVars(vars);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, global_custom_env_vars: vars });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEnvVarsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
@@ -6,11 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
/** Strip ANSI escape sequences from a string. */
|
import UrlToast from "./UrlToast";
|
||||||
function stripAnsi(s: string): string {
|
|
||||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -19,11 +16,15 @@ interface Props {
|
|||||||
|
|
||||||
export default function TerminalView({ sessionId, active }: Props) {
|
export default function TerminalView({ sessionId, active }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
@@ -84,50 +85,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── URL accumulator ──────────────────────────────────────────────
|
|
||||||
// Claude Code login emits a long OAuth URL that gets split across
|
|
||||||
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
|
||||||
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
|
||||||
// truncated and the link fails when clicked.
|
|
||||||
//
|
|
||||||
// Fix: buffer recent output, strip ANSI codes, and after a short
|
|
||||||
// debounce check for a URL that spans multiple lines. When found,
|
|
||||||
// write a single clean clickable copy to the terminal.
|
|
||||||
const textDecoder = new TextDecoder();
|
|
||||||
let outputBuffer = "";
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const flushUrlBuffer = () => {
|
|
||||||
const plain = stripAnsi(outputBuffer);
|
|
||||||
// Reassemble: strip hard newlines and carriage returns to join
|
|
||||||
// fragments that were split across terminal lines.
|
|
||||||
const joined = plain.replace(/[\r\n]+/g, "");
|
|
||||||
// Look for a long OAuth/auth URL (Claude login URLs contain
|
|
||||||
// "oauth" or "console.anthropic.com" or "/authorize").
|
|
||||||
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
|
||||||
if (match) {
|
|
||||||
const url = match[0];
|
|
||||||
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
|
||||||
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
|
||||||
}
|
|
||||||
outputBuffer = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
|
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||||
|
detectorRef.current = detector;
|
||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
detector.feed(data);
|
||||||
// Accumulate for URL detection (data is a Uint8Array, so decode it)
|
|
||||||
outputBuffer += textDecoder.decode(data);
|
|
||||||
// Cap buffer size to avoid memory growth
|
|
||||||
if (outputBuffer.length > 8192) {
|
|
||||||
outputBuffer = outputBuffer.slice(-4096);
|
|
||||||
}
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
if (aborted) unlisten();
|
if (aborted) unlisten();
|
||||||
return unlisten;
|
return unlisten;
|
||||||
@@ -159,7 +126,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
detector.dispose();
|
||||||
|
detectorRef.current = null;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
@@ -204,11 +172,39 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
|
// Auto-dismiss toast after 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detectedUrl) return;
|
||||||
|
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [detectedUrl]);
|
||||||
|
|
||||||
|
const handleOpenUrl = useCallback(() => {
|
||||||
|
if (detectedUrl) {
|
||||||
|
openUrl(detectedUrl).catch((e) =>
|
||||||
|
console.error("Failed to open URL:", e),
|
||||||
|
);
|
||||||
|
setDetectedUrl(null);
|
||||||
|
}
|
||||||
|
}, [detectedUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={terminalContainerRef}
|
||||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||||
style={{ padding: "8px" }}
|
>
|
||||||
/>
|
{detectedUrl && (
|
||||||
|
<UrlToast
|
||||||
|
url={detectedUrl}
|
||||||
|
onOpen={handleOpenUrl}
|
||||||
|
onDismiss={() => setDetectedUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ padding: "8px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/src/components/terminal/UrlToast.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
onOpen: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="animate-slide-down"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 40,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
maxWidth: "min(90%, 600px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Long URL detected
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
background: "var(--accent)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background = "var(--accent-hover)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = "var(--accent)")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.color = "var(--text-primary)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.color = "var(--text-secondary)")
|
||||||
|
}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,39 +5,13 @@ import * as commands from "../lib/tauri-commands";
|
|||||||
import type { AppSettings } from "../lib/types";
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
|
const { appSettings, setAppSettings } = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
hasKey: s.hasKey,
|
|
||||||
setHasKey: s.setHasKey,
|
|
||||||
appSettings: s.appSettings,
|
appSettings: s.appSettings,
|
||||||
setAppSettings: s.setAppSettings,
|
setAppSettings: s.setAppSettings,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkApiKey = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const has = await commands.hasApiKey();
|
|
||||||
setHasKey(has);
|
|
||||||
return has;
|
|
||||||
} catch {
|
|
||||||
setHasKey(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const saveApiKey = useCallback(
|
|
||||||
async (key: string) => {
|
|
||||||
await commands.setApiKey(key);
|
|
||||||
setHasKey(true);
|
|
||||||
},
|
|
||||||
[setHasKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeApiKey = useCallback(async () => {
|
|
||||||
await commands.deleteApiKey();
|
|
||||||
setHasKey(false);
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await commands.getSettings();
|
const settings = await commands.getSettings();
|
||||||
@@ -59,10 +33,6 @@ export function useSettings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasKey,
|
|
||||||
checkApiKey,
|
|
||||||
saveApiKey,
|
|
||||||
removeApiKey,
|
|
||||||
appSettings,
|
appSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
|||||||
@@ -46,3 +46,10 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast slide-down animation */
|
||||||
|
@keyframes slide-down {
|
||||||
|
from { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
invoke<Project>("rebuild_project_container", { projectId });
|
invoke<Project>("rebuild_project_container", { projectId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const setApiKey = (key: string) =>
|
|
||||||
invoke<void>("set_api_key", { key });
|
|
||||||
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
|
||||||
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
invoke<AppSettings>("update_settings", { settings });
|
invoke<AppSettings>("update_settings", { settings });
|
||||||
@@ -39,6 +35,8 @@ export const detectAwsConfig = () =>
|
|||||||
invoke<string | null>("detect_aws_config");
|
invoke<string | null>("detect_aws_config");
|
||||||
export const listAwsProfiles = () =>
|
export const listAwsProfiles = () =>
|
||||||
invoke<string[]>("list_aws_profiles");
|
invoke<string[]>("list_aws_profiles");
|
||||||
|
export const detectHostTimezone = () =>
|
||||||
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface ProjectPath {
|
|||||||
mount_name: string;
|
mount_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PortMapping {
|
||||||
|
host_port: number;
|
||||||
|
container_port: number;
|
||||||
|
protocol: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,6 +28,7 @@ export interface Project {
|
|||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
custom_env_vars: EnvVar[];
|
custom_env_vars: EnvVar[];
|
||||||
|
port_mappings: PortMapping[];
|
||||||
claude_instructions: string | null;
|
claude_instructions: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -34,7 +41,7 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type AuthMode = "login" | "api_key" | "bedrock";
|
export type AuthMode = "anthropic" | "bedrock";
|
||||||
|
|
||||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
@@ -88,8 +95,10 @@ 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;
|
||||||
|
global_custom_env_vars: EnvVar[];
|
||||||
auto_check_updates: boolean;
|
auto_check_updates: boolean;
|
||||||
dismissed_update_version: string | null;
|
dismissed_update_version: string | null;
|
||||||
|
timezone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
|
|||||||
127
app/src/lib/urlDetector.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
|
||||||
|
*
|
||||||
|
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
|
||||||
|
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||||
|
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||||
|
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||||
|
*
|
||||||
|
* When a URL match extends to the end of the flattened buffer, emission is
|
||||||
|
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||||
|
* the pending URL if no further data arrives within 500 ms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANSI_RE =
|
||||||
|
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||||
|
|
||||||
|
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
|
||||||
|
const MIN_URL_LENGTH = 100;
|
||||||
|
|
||||||
|
export type UrlCallback = (url: string) => void;
|
||||||
|
|
||||||
|
export class UrlDetector {
|
||||||
|
private decoder = new TextDecoder();
|
||||||
|
private buffer = "";
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastEmitted = "";
|
||||||
|
private pendingUrl: string | null = null;
|
||||||
|
private callback: UrlCallback;
|
||||||
|
|
||||||
|
constructor(callback: UrlCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed raw PTY output chunks. */
|
||||||
|
feed(data: Uint8Array): void {
|
||||||
|
this.buffer += this.decoder.decode(data, { stream: true });
|
||||||
|
|
||||||
|
// Cap buffer to avoid unbounded growth
|
||||||
|
if (this.buffer.length > MAX_BUFFER) {
|
||||||
|
this.buffer = this.buffer.slice(-MAX_BUFFER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel pending timers — new data arrived, rescan from scratch
|
||||||
|
if (this.timer !== null) clearTimeout(this.timer);
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce — scan after 300 ms of silence
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.scan();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scan(): void {
|
||||||
|
// 1. Strip ANSI escape sequences
|
||||||
|
const clean = this.buffer.replace(ANSI_RE, "");
|
||||||
|
|
||||||
|
// 2. Flatten the buffer:
|
||||||
|
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||||
|
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||||
|
const flat = clean
|
||||||
|
.replace(/(\r?\n){2,}/g, " ")
|
||||||
|
.replace(/[\r\n]/g, "");
|
||||||
|
|
||||||
|
if (!flat) return;
|
||||||
|
|
||||||
|
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||||
|
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = urlRe.exec(flat)) !== null) {
|
||||||
|
const url = m[0];
|
||||||
|
|
||||||
|
// 4. Filter by length
|
||||||
|
if (url.length < MIN_URL_LENGTH) continue;
|
||||||
|
|
||||||
|
// 5. If the match extends to the very end of the flattened string,
|
||||||
|
// more chunks may still be arriving — defer emission.
|
||||||
|
if (m.index + url.length >= flat.length) {
|
||||||
|
this.pendingUrl = url;
|
||||||
|
this.confirmTimer = setTimeout(() => {
|
||||||
|
this.confirmTimer = null;
|
||||||
|
this.emitPending();
|
||||||
|
}, CONFIRM_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||||
|
this.pendingUrl = null;
|
||||||
|
if (url !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = url;
|
||||||
|
this.callback(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan finished without a URL at the buffer end.
|
||||||
|
// If we had a pending URL from a previous scan, it's now confirmed complete.
|
||||||
|
if (this.pendingUrl) {
|
||||||
|
this.emitPending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitPending(): void {
|
||||||
|
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = this.pendingUrl;
|
||||||
|
this.callback(this.pendingUrl);
|
||||||
|
}
|
||||||
|
this.pendingUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,6 @@ interface AppState {
|
|||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
setImageExists: (exists: boolean | null) => void;
|
setImageExists: (exists: boolean | null) => void;
|
||||||
hasKey: boolean | null;
|
|
||||||
setHasKey: (has: boolean | null) => void;
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: AppSettings | null;
|
appSettings: AppSettings | null;
|
||||||
setAppSettings: (settings: AppSettings) => void;
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
@@ -85,9 +82,6 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
imageExists: null,
|
||||||
setImageExists: (exists) => set({ imageExists: exists }),
|
setImageExists: (exists) => set({ imageExists: exists }),
|
||||||
hasKey: null,
|
|
||||||
setHasKey: (has) => set({ hasKey: has }),
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: null,
|
appSettings: null,
|
||||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
|
|||||||
36
app/src/test/icon-config.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
describe("Window icon configuration", () => {
|
||||||
|
const srcTauriDir = resolve(__dirname, "../../src-tauri");
|
||||||
|
|
||||||
|
it("lib.rs sets window icon using set_icon in setup hook", () => {
|
||||||
|
const libRs = readFileSync(resolve(srcTauriDir, "src/lib.rs"), "utf-8");
|
||||||
|
expect(libRs).toContain("set_icon");
|
||||||
|
expect(libRs).toContain("icon.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cargo.toml enables image-png feature for icon loading", () => {
|
||||||
|
const cargoToml = readFileSync(resolve(srcTauriDir, "Cargo.toml"), "utf-8");
|
||||||
|
expect(cargoToml).toContain("image-png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("icon.png exists in the icons directory", () => {
|
||||||
|
const iconPath = resolve(srcTauriDir, "icons/icon.png");
|
||||||
|
expect(existsSync(iconPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("icon.ico exists in the icons directory for Windows", () => {
|
||||||
|
const icoPath = resolve(srcTauriDir, "icons/icon.ico");
|
||||||
|
expect(existsSync(icoPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tauri.conf.json includes icon.ico in bundle icons", () => {
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(resolve(srcTauriDir, "tauri.conf.json"), "utf-8")
|
||||||
|
);
|
||||||
|
expect(config.bundle.icon).toContain("icons/icon.ico");
|
||||||
|
expect(config.bundle.icon).toContain("icons/icon.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
1
app/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -17,5 +17,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
unzip \
|
unzip \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
|
cron \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
||||||
@@ -101,5 +102,9 @@ WORKDIR /workspace
|
|||||||
USER root
|
USER root
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||||
|
RUN chmod +x /usr/local/bin/triple-c-scheduler
|
||||||
|
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
|
||||||
|
RUN chmod +x /usr/local/bin/triple-c-task-runner
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|||||||
@@ -113,6 +113,59 @@ if [ -S /var/run/docker.sock ]; then
|
|||||||
usermod -aG "$DOCKER_GROUP" claude
|
usermod -aG "$DOCKER_GROUP" claude
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Timezone setup ───────────────────────────────────────────────────────────
|
||||||
|
if [ -n "${TZ:-}" ]; then
|
||||||
|
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||||
|
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||||
|
echo "$TZ" > /etc/timezone
|
||||||
|
echo "entrypoint: timezone set to $TZ"
|
||||||
|
else
|
||||||
|
echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Scheduler setup ─────────────────────────────────────────────────────────
|
||||||
|
SCHEDULER_DIR="/home/claude/.claude/scheduler"
|
||||||
|
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
|
||||||
|
chown -R claude:claude "$SCHEDULER_DIR"
|
||||||
|
|
||||||
|
# Start cron daemon (runs as root, executes jobs per user crontab)
|
||||||
|
cron
|
||||||
|
|
||||||
|
# Save environment variables for cron jobs (cron runs with a minimal env)
|
||||||
|
ENV_FILE="$SCHEDULER_DIR/.env"
|
||||||
|
: > "$ENV_FILE"
|
||||||
|
env | while IFS='=' read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM)
|
||||||
|
# Escape single quotes in value and write as KEY='VALUE'
|
||||||
|
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||||
|
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
chown claude:claude "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# Restore crontab from persisted task JSON files (survives container recreation)
|
||||||
|
if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
|
||||||
|
CRON_TMP=$(mktemp)
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP"
|
||||||
|
echo "" >> "$CRON_TMP"
|
||||||
|
for task_file in "$SCHEDULER_DIR/tasks/"*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
[ "$enabled" = "true" ] || continue
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP"
|
||||||
|
done
|
||||||
|
crontab -u claude "$CRON_TMP" 2>/dev/null || true
|
||||||
|
rm -f "$CRON_TMP"
|
||||||
|
echo "entrypoint: restored crontab from persisted tasks"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
||||||
echo "Triple-C container ready."
|
echo "Triple-C container ready."
|
||||||
exec su -s /bin/bash claude -c "exec sleep infinity"
|
exec su -s /bin/bash claude -c "exec sleep infinity"
|
||||||
|
|||||||
436
container/triple-c-scheduler
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
|
||||||
|
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||||
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||||
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||||
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_id() {
|
||||||
|
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_crontab() {
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
# Header
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
|
||||||
|
echo "" >> "$tmp"
|
||||||
|
|
||||||
|
for task_file in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
local enabled schedule id
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
[ "$enabled" = "true" ] || continue
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
|
||||||
|
done
|
||||||
|
|
||||||
|
crontab "$tmp" 2>/dev/null || true
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: triple-c-scheduler <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
add Add a new scheduled task
|
||||||
|
remove Remove a task
|
||||||
|
enable Enable a disabled task
|
||||||
|
disable Disable a task
|
||||||
|
list List all tasks
|
||||||
|
logs Show execution logs
|
||||||
|
run Manually trigger a task now
|
||||||
|
notifications Show or clear completion notifications
|
||||||
|
|
||||||
|
Add options:
|
||||||
|
--name NAME Task name (required)
|
||||||
|
--prompt "TASK" Task prompt for Claude (required)
|
||||||
|
--schedule "CRON" Cron schedule expression (for recurring tasks)
|
||||||
|
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
|
||||||
|
--working-dir DIR Working directory (default: /workspace)
|
||||||
|
|
||||||
|
Remove/Enable/Disable/Run options:
|
||||||
|
--id ID Task ID (required)
|
||||||
|
|
||||||
|
Logs options:
|
||||||
|
--id ID Show logs for a specific task (optional)
|
||||||
|
--tail N Show last N lines (default: 50)
|
||||||
|
|
||||||
|
Notifications options:
|
||||||
|
--clear Clear all notifications
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
|
||||||
|
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
|
||||||
|
triple-c-scheduler list
|
||||||
|
triple-c-scheduler logs --id a1b2c3d4 --tail 20
|
||||||
|
triple-c-scheduler run --id a1b2c3d4
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd_add() {
|
||||||
|
local name="" prompt="" schedule="" at="" working_dir="/workspace"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--name) name="$2"; shift 2 ;;
|
||||||
|
--prompt) prompt="$2"; shift 2 ;;
|
||||||
|
--schedule) schedule="$2"; shift 2 ;;
|
||||||
|
--at) at="$2"; shift 2 ;;
|
||||||
|
--working-dir) working_dir="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
echo "Error: --name is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$prompt" ]; then
|
||||||
|
echo "Error: --prompt is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -z "$schedule" ] && [ -z "$at" ]; then
|
||||||
|
echo "Error: either --schedule or --at is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "$schedule" ] && [ -n "$at" ]; then
|
||||||
|
echo "Error: use either --schedule or --at, not both" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local id task_type cron_expr
|
||||||
|
id=$(generate_id)
|
||||||
|
|
||||||
|
if [ -n "$at" ]; then
|
||||||
|
task_type="once"
|
||||||
|
# Parse "YYYY-MM-DD HH:MM" into cron expression
|
||||||
|
local year month day hour minute
|
||||||
|
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
|
||||||
|
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
year="${BASH_REMATCH[1]}"
|
||||||
|
month="${BASH_REMATCH[2]}"
|
||||||
|
day="${BASH_REMATCH[3]}"
|
||||||
|
hour="${BASH_REMATCH[4]}"
|
||||||
|
minute="${BASH_REMATCH[5]}"
|
||||||
|
# Remove leading zeros for cron
|
||||||
|
month=$((10#$month))
|
||||||
|
day=$((10#$day))
|
||||||
|
hour=$((10#$hour))
|
||||||
|
minute=$((10#$minute))
|
||||||
|
cron_expr="$minute $hour $day $month *"
|
||||||
|
else
|
||||||
|
task_type="recurring"
|
||||||
|
cron_expr="$schedule"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local created_at
|
||||||
|
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
local task_json
|
||||||
|
task_json=$(jq -n \
|
||||||
|
--arg id "$id" \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg prompt "$prompt" \
|
||||||
|
--arg schedule "$cron_expr" \
|
||||||
|
--arg type "$task_type" \
|
||||||
|
--arg at "$at" \
|
||||||
|
--arg created_at "$created_at" \
|
||||||
|
--argjson enabled true \
|
||||||
|
--arg working_dir "$working_dir" \
|
||||||
|
'{
|
||||||
|
id: $id,
|
||||||
|
name: $name,
|
||||||
|
prompt: $prompt,
|
||||||
|
schedule: $schedule,
|
||||||
|
type: $type,
|
||||||
|
at: $at,
|
||||||
|
created_at: $created_at,
|
||||||
|
enabled: $enabled,
|
||||||
|
working_dir: $working_dir
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "$task_json" > "$TASKS_DIR/${id}.json"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
echo "Task created:"
|
||||||
|
echo " ID: $id"
|
||||||
|
echo " Name: $name"
|
||||||
|
echo " Type: $task_type"
|
||||||
|
if [ "$task_type" = "once" ]; then
|
||||||
|
echo " At: $at"
|
||||||
|
fi
|
||||||
|
echo " Schedule: $cron_expr"
|
||||||
|
echo " Prompt: $prompt"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_remove() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
rm -f "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
echo "Removed task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_enable() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Enabled task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_disable() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||||
|
rebuild_crontab
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Disabled task '$name' ($id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local found=false
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
|
||||||
|
|
||||||
|
for task_file in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$task_file" ] || continue
|
||||||
|
found=true
|
||||||
|
local id name type enabled schedule at prompt
|
||||||
|
id=$(jq -r '.id' "$task_file")
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
type=$(jq -r '.type' "$task_file")
|
||||||
|
enabled=$(jq -r '.enabled' "$task_file")
|
||||||
|
schedule=$(jq -r '.schedule' "$task_file")
|
||||||
|
at=$(jq -r '.at // ""' "$task_file")
|
||||||
|
prompt=$(jq -r '.prompt' "$task_file")
|
||||||
|
|
||||||
|
local display_schedule="$schedule"
|
||||||
|
if [ "$type" = "once" ] && [ -n "$at" ]; then
|
||||||
|
display_schedule="at $at"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Truncate long fields for display
|
||||||
|
[ ${#name} -gt 20 ] && name="${name:0:17}..."
|
||||||
|
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
|
||||||
|
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
|
||||||
|
|
||||||
|
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" = "false" ]; then
|
||||||
|
echo "No scheduled tasks."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
local id="" tail_n=50
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
--tail) tail_n="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
local log_dir="$LOGS_DIR/$id"
|
||||||
|
if [ ! -d "$log_dir" ]; then
|
||||||
|
echo "No logs found for task '$id'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Show the most recent log file
|
||||||
|
local latest
|
||||||
|
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
echo "No logs found for task '$id'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "=== Latest log for task $id: $(basename "$latest") ==="
|
||||||
|
tail -n "$tail_n" "$latest"
|
||||||
|
else
|
||||||
|
# Show recent logs across all tasks
|
||||||
|
local all_logs
|
||||||
|
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
|
||||||
|
if [ -z "$all_logs" ]; then
|
||||||
|
echo "No logs found."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for log_file in $all_logs; do
|
||||||
|
local task_id
|
||||||
|
task_id=$(basename "$(dirname "$log_file")")
|
||||||
|
echo "=== Task $task_id: $(basename "$log_file") ==="
|
||||||
|
tail -n 5 "$log_file"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
local id=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--id) id="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "Error: --id is required" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_file="$TASKS_DIR/${id}.json"
|
||||||
|
if [ ! -f "$task_file" ]; then
|
||||||
|
echo "Error: task '$id' not found" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local name
|
||||||
|
name=$(jq -r '.name' "$task_file")
|
||||||
|
echo "Manually triggering task '$name' ($id)..."
|
||||||
|
/usr/local/bin/triple-c-task-runner "$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_notifications() {
|
||||||
|
local clear=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--clear) clear=true; shift ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$clear" = "true" ]; then
|
||||||
|
rm -f "$NOTIFICATIONS_DIR"/*.notify
|
||||||
|
echo "Notifications cleared."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local found=false
|
||||||
|
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
|
||||||
|
[ -f "$notify_file" ] || continue
|
||||||
|
found=true
|
||||||
|
cat "$notify_file"
|
||||||
|
echo "---"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found" = "false" ]; then
|
||||||
|
echo "No notifications."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_dirs
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
add) cmd_add "$@" ;;
|
||||||
|
remove) cmd_remove "$@" ;;
|
||||||
|
enable) cmd_enable "$@" ;;
|
||||||
|
disable) cmd_disable "$@" ;;
|
||||||
|
list) cmd_list ;;
|
||||||
|
logs) cmd_logs "$@" ;;
|
||||||
|
run) cmd_run "$@" ;;
|
||||||
|
notifications) cmd_notifications "$@" ;;
|
||||||
|
help|--help|-h) usage ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $command" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
142
container/triple-c-task-runner
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
|
||||||
|
# Called by cron with a task ID argument. Handles locking, logging,
|
||||||
|
# notifications, one-time task cleanup, and log pruning.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||||
|
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||||
|
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||||
|
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||||
|
ENV_FILE="${SCHEDULER_DIR}/.env"
|
||||||
|
|
||||||
|
TASK_ID="${1:-}"
|
||||||
|
|
||||||
|
if [ -z "$TASK_ID" ]; then
|
||||||
|
echo "Usage: triple-c-task-runner <task-id>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
|
||||||
|
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
|
||||||
|
|
||||||
|
if [ ! -f "$TASK_FILE" ]; then
|
||||||
|
echo "Task file not found: $TASK_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
|
||||||
|
exec 200>"$LOCK_FILE"
|
||||||
|
if ! flock -n 200; then
|
||||||
|
echo "Task $TASK_ID is already running, skipping." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Source saved environment ─────────────────────────────────────────────────
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Read task definition ────────────────────────────────────────────────────
|
||||||
|
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
|
||||||
|
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
|
||||||
|
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
|
||||||
|
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
|
||||||
|
|
||||||
|
# ── Prepare log directory ───────────────────────────────────────────────────
|
||||||
|
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
|
||||||
|
mkdir -p "$TASK_LOG_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
||||||
|
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
|
||||||
|
|
||||||
|
# ── Execute Claude agent ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
|
||||||
|
echo "=== Started: $(date) ==="
|
||||||
|
echo "=== Working dir: $WORKING_DIR ==="
|
||||||
|
echo "=== Prompt: $PROMPT ==="
|
||||||
|
echo ""
|
||||||
|
} > "$LOG_FILE"
|
||||||
|
|
||||||
|
EXIT_CODE=0
|
||||||
|
if [ -d "$WORKING_DIR" ]; then
|
||||||
|
cd "$WORKING_DIR"
|
||||||
|
claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
|
||||||
|
else
|
||||||
|
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "=== Finished: $(date) ==="
|
||||||
|
echo "=== Exit code: $EXIT_CODE ==="
|
||||||
|
} >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# ── Write notification ──────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$NOTIFICATIONS_DIR"
|
||||||
|
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
|
||||||
|
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
STATUS="SUCCESS"
|
||||||
|
else
|
||||||
|
STATUS="FAILED (exit code $EXIT_CODE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract a summary (last 10 meaningful lines before the footer)
|
||||||
|
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
|
||||||
|
|
||||||
|
cat > "$NOTIFY_FILE" <<NOTIFY
|
||||||
|
Task: $TASK_NAME ($TASK_ID)
|
||||||
|
Status: $STATUS
|
||||||
|
Time: $(date)
|
||||||
|
Type: $TASK_TYPE
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
$SUMMARY
|
||||||
|
NOTIFY
|
||||||
|
|
||||||
|
# ── One-time task cleanup ───────────────────────────────────────────────────
|
||||||
|
if [ "$TASK_TYPE" = "once" ]; then
|
||||||
|
rm -f "$TASK_FILE"
|
||||||
|
# Rebuild crontab to remove the completed one-time task
|
||||||
|
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
|
||||||
|
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
|
||||||
|
TMP_CRON=$(mktemp)
|
||||||
|
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
|
||||||
|
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
|
||||||
|
echo "" >> "$TMP_CRON"
|
||||||
|
for tf in "$TASKS_DIR"/*.json; do
|
||||||
|
[ -f "$tf" ] || continue
|
||||||
|
local_enabled=$(jq -r '.enabled' "$tf")
|
||||||
|
[ "$local_enabled" = "true" ] || continue
|
||||||
|
local_schedule=$(jq -r '.schedule' "$tf")
|
||||||
|
local_id=$(jq -r '.id' "$tf")
|
||||||
|
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
|
||||||
|
done
|
||||||
|
crontab "$TMP_CRON" 2>/dev/null || true
|
||||||
|
rm -f "$TMP_CRON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
|
||||||
|
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
|
||||||
|
if [ "$LOG_COUNT" -gt 20 ]; then
|
||||||
|
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
|
||||||
|
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
||||||
|
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
||||||
|
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release lock
|
||||||
|
flock -u 200
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
BIN
triple-c-app-logov2.png
Normal file
|
After Width: | Height: | Size: 191 KiB |