Compare commits

...

8 Commits

Author SHA1 Message Date
01ea581f8a Fix type inference error for api_key after removing ApiKey auth mode
All checks were successful
Build App / build-linux (push) Successful in 2m42s
Build App / build-windows (push) Successful in 2m44s
Both match arms now return None, so Rust needs an explicit type
annotation for the Option<String>.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:15:50 +00:00
552aaebf16 Simplify auth modes to Anthropic and Bedrock, fix Windows taskbar icon
Some checks failed
Build App / build-linux (push) Failing after 1m40s
Build App / build-windows (push) Failing after 1m43s
Replace the three auth modes (Login, API Key, Bedrock) with two
(Anthropic, Bedrock). The Anthropic mode uses OAuth via `claude login`
inside the terminal, which generates and stores its own API key in the
persistent config volume. The separate API Key mode is removed because
Claude Code now requires interactive approval of externally-provided
keys, making the injected ANTHROPIC_API_KEY approach unreliable.

Old projects stored as "login" or "api_key" are automatically migrated
to "anthropic" via serde aliases.

Also fix the Windows taskbar icon showing as a black square by loading
icon.png instead of icon.ico for the runtime window icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:10:57 +00:00
c2736ace90 Fix API key changes not triggering container recreation
All checks were successful
Build App / build-linux (push) Successful in 2m45s
Build App / build-windows (push) Successful in 4m15s
The container was only recreated when the auth mode changed, not when
the API key value itself changed. This meant saving a new key required
a manual container rebuild. Now we store a hash of the API key as a
Docker label and compare it on start, so a key change automatically
recreates the container (preserving the claude config volume).

Also adds a note to the global AWS settings UI that changes require a
container rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:22:34 +00:00
2ff270ebfe Fix Windows crash from missing ICO decoder and add file logging
All checks were successful
Build App / build-linux (push) Successful in 3m7s
Build App / build-windows (push) Successful in 4m19s
The app crashed on startup because the image-ico Tauri feature was
missing, causing Image::from_bytes to panic when decoding icon.ico.
Added the feature flag and replaced env_logger with fern to log to both
stderr and <data_dir>/triple-c/logs/triple-c.log. A panic hook captures
crash details with backtraces. Store init and icon loading errors are now
logged before failing so future issues are diagnosable from the log file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:45:59 +00:00
5a59fdb64b Add UX enhancements: modals for env vars and instructions, global env vars, taskbar icon fix
All checks were successful
Build App / build-linux (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m5s
- Fix Windows taskbar icon by loading icon.ico instead of icon.png (ICO contains
  multiple sizes native to Windows taskbar/title bar/alt-tab)
- Add "Container must be stopped to change settings" warning banner in config panel
- Move per-project Environment Variables and Claude Instructions into modal dialogs
  for more editing space, with buttons in the config panel to open them
- Move global Claude Instructions into a modal in Settings panel
- Add default global Claude instruction recommending git initialization
- Add global environment variables support (full stack: Rust model, TS types,
  container creation with merge logic where project overrides global for same key,
  fingerprinting for recreation checks, and Settings UI with modal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:21:33 +00:00
1ce5151e59 Fix Rust build errors: use setup hook for window icon, enable image-png feature
All checks were successful
Build App / build-linux (push) Successful in 3m1s
Build App / build-windows (push) Successful in 3m49s
The previous approach used Builder::default_window_icon() which doesn't
exist in Tauri 2.10. Instead, set the icon via window.set_icon() in the
setup hook, and enable the "image-png" feature flag so Image::from_bytes
can decode the PNG icon at runtime.

Also change bundle identifier from "com.triple-c.app" to
"com.triple-c.desktop" to avoid conflicting with the .app bundle
extension on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:49:08 +00:00
66ddc182c9 Exclude test files from tsc build to fix CI
Some checks failed
Build App / build-linux (push) Failing after 1m30s
Build App / build-windows (push) Failing after 2m25s
The build step runs `tsc && vite build`, and the test files that import
Node.js built-ins (fs, path) and use __dirname were causing TS2307 and
TS2304 errors during compilation. Excluding test files from tsconfig
keeps the build clean while vitest handles its own TypeScript resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:42:35 +00:00
1524ec4a98 Fix sidebar content overflow and set correct window taskbar icon
Some checks failed
Build App / build-windows (push) Failing after 41s
Build App / build-linux (push) Failing after 1m25s
The sidebar config panel content was overflowing its container width,
causing project names and directory paths to be clipped. Added min-w-0
and overflow-hidden to flex containers, and restructured folder path
config rows to stack vertically instead of cramming into one line.

The Windows taskbar was showing a black square because no default window
icon was set at runtime. Added default_window_icon() call in the Tauri
builder using the app's icon.png.

Also adds vitest test infrastructure with tests verifying both fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:53:30 +00:00
25 changed files with 2066 additions and 341 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

208
app/src-tauri/Cargo.lock generated
View File

@@ -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,7 +4673,7 @@ dependencies = [
"bollard", "bollard",
"chrono", "chrono",
"dirs", "dirs",
"env_logger", "fern",
"futures-util", "futures-util",
"keyring", "keyring",
"log", "log",
@@ -4889,12 +4821,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"

View File

@@ -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,7 +26,7 @@ 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"] }

View File

@@ -125,13 +125,8 @@ pub async fn start_project_container(
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 // Get API key only if auth mode requires it
let api_key = match project.auth_mode { let api_key: Option<String> = match project.auth_mode {
AuthMode::ApiKey => { AuthMode::Anthropic => {
let key = secure::get_api_key()?
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
Some(key)
}
AuthMode::Login => {
None None
} }
AuthMode::Bedrock => { AuthMode::Bedrock => {
@@ -169,7 +164,9 @@ pub async fn start_project_container(
let needs_recreation = docker::container_needs_recreation( let needs_recreation = docker::container_needs_recreation(
&existing_id, &existing_id,
&project, &project,
api_key.as_deref(),
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
) )
.await .await
.unwrap_or(false); .unwrap_or(false);
@@ -185,6 +182,7 @@ pub async fn start_project_container(
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,
).await?; ).await?;
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id
@@ -201,6 +199,7 @@ pub async fn start_project_container(
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,
).await?; ).await?;
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id

View File

@@ -10,6 +10,19 @@ 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, Project, ProjectPath};
/// Compute a fingerprint for the API key so we can detect when it changes
/// without storing the actual key in Docker labels.
fn compute_api_key_fingerprint(api_key: Option<&str>) -> String {
match api_key {
Some(key) => {
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
None => String::new(),
}
}
/// 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.
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
@@ -30,6 +43,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>,
@@ -114,6 +146,7 @@ pub async fn create_container(
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],
) -> 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();
@@ -222,9 +255,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;
@@ -236,7 +270,7 @@ 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) // Claude instructions (global + per-project)
@@ -335,6 +369,7 @@ pub async fn create_container(
labels.insert("triple-c.project-id".to_string(), project.id.clone()); labels.insert("triple-c.project-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone()); labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode)); labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
labels.insert("triple-c.api-key-fingerprint".to_string(), compute_api_key_fingerprint(api_key));
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.image".to_string(), image_name.to_string()); labels.insert("triple-c.image".to_string(), image_name.to_string());
@@ -418,7 +453,9 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
pub async fn container_needs_recreation( pub async fn container_needs_recreation(
container_id: &str, container_id: &str,
project: &Project, project: &Project,
api_key: Option<&str>,
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
) -> Result<bool, String> { ) -> Result<bool, String> {
let docker = get_docker()?; let docker = get_docker()?;
let info = docker let info = docker
@@ -455,6 +492,14 @@ pub async fn container_needs_recreation(
} }
} }
// ── API key fingerprint ─────────────────────────────────────────────
let expected_api_key_fp = compute_api_key_fingerprint(api_key);
let container_api_key_fp = get_label("triple-c.api-key-fingerprint").unwrap_or_default();
if container_api_key_fp != expected_api_key_fp {
log::info!("API key fingerprint mismatch, triggering recreation");
return Ok(true);
}
// ── Project paths fingerprint ────────────────────────────────────────── // ── Project paths fingerprint ──────────────────────────────────────────
let expected_paths_fp = compute_paths_fingerprint(&project.paths); let expected_paths_fp = compute_paths_fingerprint(&project.paths);
match get_label("triple-c.paths-fingerprint") { match get_label("triple-c.paths-fingerprint") {
@@ -547,7 +592,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);

View File

@@ -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>();

View 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());
}
}

View File

@@ -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.".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,8 +60,10 @@ 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)]
@@ -72,7 +80,8 @@ 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,
} }

View File

@@ -46,20 +46,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
} }
} }

View File

@@ -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",

View 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");
});
});

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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");
});
});
});

View File

@@ -4,6 +4,8 @@ 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 ClaudeInstructionsModal from "./ClaudeInstructionsModal";
interface Props { interface Props {
project: Project; project: Project;
@@ -17,6 +19,8 @@ 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 [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";
@@ -165,22 +169,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 +243,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)]"
@@ -274,31 +262,20 @@ export default function ProjectCard({ project }: Props) {
</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"); }}
@@ -357,12 +334,18 @@ 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">
<div className="flex gap-1 items-center min-w-0">
<input <input
value={pp.host_path} value={pp.host_path}
onChange={(e) => { onChange={(e) => {
@@ -377,7 +360,7 @@ export default function ProjectCard({ project }: Props) {
}} }}
placeholder="/path/to/folder" placeholder="/path/to/folder"
disabled={!isStopped} 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" 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 <button
onClick={async () => { onClick={async () => {
@@ -393,11 +376,28 @@ export default function ProjectCard({ project }: Props) {
} }
}} }}
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-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"
> >
... ...
</button> </button>
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span> {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 <input
value={pp.mount_name} value={pp.mount_name}
onChange={(e) => { onChange={(e) => {
@@ -412,23 +412,9 @@ export default function ProjectCard({ project }: Props) {
}} }}
placeholder="name" placeholder="name"
disabled={!isStopped} 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" 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"
/> />
{paths.length > 1 && ( </div>
<button
onClick={async () => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
console.error("Failed to remove path:", err);
}
}}
disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
)}
</div> </div>
))} ))}
<button <button
@@ -526,76 +512,29 @@ 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 <button
onClick={async () => { onClick={() => setShowEnvVarsModal(true)}
const vars = envVars.filter((_, j) => j !== i); 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 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 Edit
</button>
</div>
))}
<button
onClick={async () => {
const vars = [...envVars, { key: "", value: "" }];
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
</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 */}
@@ -730,6 +669,30 @@ 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)}
/>
)}
{showClaudeInstructionsModal && (
<ClaudeInstructionsModal
instructions={claudeInstructions}
disabled={!isStopped}
onSave={async (instructions) => {
setClaudeInstructions(instructions);
await update({ ...project, claude_instructions: instructions || null });
}}
onClose={() => setShowClaudeInstructionsModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -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 */}

View File

@@ -4,22 +4,24 @@ 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 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 [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 ?? []);
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]);
const handleInstructionsBlur = async () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
};
const handleCheckNow = async () => { const handleCheckNow = async () => {
setCheckingUpdates(true); setCheckingUpdates(true);
@@ -43,19 +45,43 @@ export default function SettingsPanel() {
<ApiKeyInput /> <ApiKeyInput />
<DockerSettings /> <DockerSettings />
<AwsSettings /> <AwsSettings />
{/* Global Claude Instructions */}
<div> <div>
<label className="block text-sm font-medium mb-2">Claude Instructions</label> <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 +115,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>
); );
} }

View File

@@ -34,7 +34,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,6 +88,7 @@ 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;
} }

View 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
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -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
View 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"],
},
});