diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index c2bf53f..b74a626 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -404,6 +404,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -1745,7 +1751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1862,6 +1868,19 @@ dependencies = [ "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]] name = "indexmap" version = "1.9.3" @@ -2265,6 +2284,16 @@ dependencies = [ "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]] name = "muda" version = "0.17.1" @@ -2280,7 +2309,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -2872,6 +2901,19 @@ dependencies = [ "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]] name = "polling" version = "3.11.0" @@ -3009,6 +3051,15 @@ dependencies = [ "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]] name = "quick-xml" version = "0.38.4" @@ -4076,6 +4127,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", @@ -4143,7 +4195,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4728,7 +4780,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 6da0e21..006a1ed 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -170,6 +170,7 @@ pub async fn start_project_container( &existing_id, &project, settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, ) .await .unwrap_or(false); @@ -185,6 +186,7 @@ pub async fn start_project_container( aws_config_path.as_deref(), &settings.global_aws, settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, ).await?; docker::start_container(&new_id).await?; new_id @@ -201,6 +203,7 @@ pub async fn start_project_container( aws_config_path.as_deref(), &settings.global_aws, settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, ).await?; docker::start_container(&new_id).await?; new_id diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 27955ff..c4264ef 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -30,6 +30,25 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { 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 { + let mut merged: std::collections::HashMap = 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. fn merge_claude_instructions( global_instructions: Option<&str>, @@ -114,6 +133,7 @@ pub async fn create_container( aws_config_path: Option<&str>, global_aws: &GlobalAwsSettings, global_claude_instructions: Option<&str>, + global_custom_env_vars: &[EnvVar], ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); @@ -222,9 +242,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_"]; - for env_var in &project.custom_env_vars { + for env_var in &merged_env { let key = env_var.key.trim(); if key.is_empty() { continue; @@ -236,7 +257,7 @@ pub async fn create_container( } 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)); // Claude instructions (global + per-project) @@ -419,6 +440,7 @@ pub async fn container_needs_recreation( container_id: &str, project: &Project, global_claude_instructions: Option<&str>, + global_custom_env_vars: &[EnvVar], ) -> Result { let docker = get_docker()?; let info = docker @@ -547,7 +569,8 @@ pub async fn container_needs_recreation( } // ── 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(); if container_fingerprint != expected_fingerprint { log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint); diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c01435c..80bdcab 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -27,7 +27,7 @@ pub fn run() { exec_manager: ExecSessionManager::new(), }) .setup(|app| { - let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) + let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/icon.ico")) .expect("Failed to load window icon"); if let Some(window) = app.get_webview_window("main") { let _ = window.set_icon(icon); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 79ab006..ddc1e81 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -1,9 +1,15 @@ use serde::{Deserialize, Serialize}; +use super::project::EnvVar; + fn default_true() -> bool { true } +fn default_global_instructions() -> Option { + 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)] #[serde(rename_all = "snake_case")] pub enum ImageSource { @@ -54,8 +60,10 @@ pub struct AppSettings { pub custom_image_name: Option, #[serde(default)] pub global_aws: GlobalAwsSettings, - #[serde(default)] + #[serde(default = "default_global_instructions")] pub global_claude_instructions: Option, + #[serde(default)] + pub global_custom_env_vars: Vec, #[serde(default = "default_true")] pub auto_check_updates: bool, #[serde(default)] @@ -72,7 +80,8 @@ impl Default for AppSettings { image_source: ImageSource::default(), custom_image_name: None, global_aws: GlobalAwsSettings::default(), - global_claude_instructions: None, + global_claude_instructions: default_global_instructions(), + global_custom_env_vars: Vec::new(), auto_check_updates: true, dismissed_update_version: None, } diff --git a/app/src/components/projects/ClaudeInstructionsModal.tsx b/app/src/components/projects/ClaudeInstructionsModal.tsx new file mode 100644 index 0000000..7c1c516 --- /dev/null +++ b/app/src/components/projects/ClaudeInstructionsModal.tsx @@ -0,0 +1,80 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +interface Props { + instructions: string; + disabled: boolean; + onSave: (instructions: string) => Promise; + onClose: () => void; +} + +export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) { + const [instructions, setInstructions] = useState(initial); + const overlayRef = useRef(null); + const textareaRef = useRef(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) => { + 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 ( +
+
+

Claude Instructions

+

+ Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container) +

+ + {disabled && ( +
+ Container must be stopped to change Claude instructions. +
+ )} + +