diff --git a/app/src-tauri/icons/128x128.png b/app/src-tauri/icons/128x128.png index 0549ced..5bbaddc 100644 Binary files a/app/src-tauri/icons/128x128.png and b/app/src-tauri/icons/128x128.png differ diff --git a/app/src-tauri/icons/128x128@2x.png b/app/src-tauri/icons/128x128@2x.png index 3235a80..2d4476a 100644 Binary files a/app/src-tauri/icons/128x128@2x.png and b/app/src-tauri/icons/128x128@2x.png differ diff --git a/app/src-tauri/icons/32x32.png b/app/src-tauri/icons/32x32.png index 286c604..8addceb 100644 Binary files a/app/src-tauri/icons/32x32.png and b/app/src-tauri/icons/32x32.png differ diff --git a/app/src-tauri/icons/icon.ico b/app/src-tauri/icons/icon.ico index 88e60c3..b416517 100644 Binary files a/app/src-tauri/icons/icon.ico and b/app/src-tauri/icons/icon.ico differ diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png index cf3f6b7..9d4faa5 100644 Binary files a/app/src-tauri/icons/icon.png and b/app/src-tauri/icons/icon.png differ diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index b4cf3fb..06d2237 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -2,13 +2,13 @@ use bollard::container::{ Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions, }; -use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum}; +use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding}; use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use super::client::get_docker; -use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath}; +use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath}; /// Compute a fingerprint string for the custom environment variables. /// Sorted alphabetically so order changes do not cause spurious recreation. @@ -95,6 +95,20 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String { 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 = 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, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -255,11 +269,27 @@ pub async fn create_container( 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) - let combined_instructions = merge_claude_instructions( + // Claude instructions (global + per-project, plus port mapping info) + let mut combined_instructions = merge_claude_instructions( global_claude_instructions, project.claude_instructions.as_deref(), ); + if !project.port_mappings.is_empty() { + let mut port_lines: Vec = 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, + }); + } if let Some(ref instructions) = combined_instructions { env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); } @@ -346,6 +376,21 @@ pub async fn create_container( }); } + // Port mappings + let mut exposed_ports: HashMap> = HashMap::new(); + let mut port_bindings: HashMap>> = 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(); labels.insert("triple-c.managed".to_string(), "true".to_string()); labels.insert("triple-c.project-id".to_string(), project.id.clone()); @@ -353,10 +398,12 @@ pub async fn create_container( 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.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()); let host_config = HostConfig { mounts: Some(mounts), + port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, ..Default::default() }; @@ -373,6 +420,7 @@ pub async fn create_container( labels: Some(labels), working_dir: Some(working_dir), host_config: Some(host_config), + exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) }, tty: Some(true), ..Default::default() }; @@ -488,6 +536,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 ─────────────────────────────────────── let expected_bedrock_fp = compute_bedrock_fingerprint(project); let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default(); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index ddc1e81..4f99e60 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -7,7 +7,7 @@ fn default_true() -> bool { } 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()) + 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)] diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 7769d7b..a2dd912 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -12,6 +12,18 @@ pub struct ProjectPath { 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)] pub struct Project { pub id: String, @@ -30,6 +42,8 @@ pub struct Project { #[serde(default)] pub custom_env_vars: Vec, #[serde(default)] + pub port_mappings: Vec, + #[serde(default)] pub claude_instructions: Option, pub created_at: String, pub updated_at: String, @@ -114,6 +128,7 @@ impl Project { git_user_name: None, git_user_email: None, custom_env_vars: Vec::new(), + port_mappings: Vec::new(), claude_instructions: None, created_at: now.clone(), updated_at: now, diff --git a/app/src/components/projects/PortMappingsModal.tsx b/app/src/components/projects/PortMappingsModal.tsx new file mode 100644 index 0000000..b8b381e --- /dev/null +++ b/app/src/components/projects/PortMappingsModal.tsx @@ -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; + onClose: () => void; +} + +export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) { + const [mappings, setMappings] = useState(initial); + const overlayRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose], + ); + + const 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 ( +
+
+

Port Mappings

+

+ Map host ports to container ports. Services can be started after the container is running. +

+ + {disabled && ( +
+ Container must be stopped to change port mappings. +
+ )} + +
+ {mappings.length === 0 && ( +

No port mappings configured.

+ )} + {mappings.length > 0 && ( +
+ Host Port + Container Port + Protocol + +
+ )} + {mappings.map((pm, i) => ( +
+ 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" + /> + 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" + /> + + +
+ ))} +
+ +
+ + +
+
+
+ ); +} diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index 68a0c2d..4c93a1d 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; +import PortMappingsModal from "./PortMappingsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; interface Props { @@ -20,6 +21,7 @@ export default function ProjectCard({ project }: Props) { const [error, setError] = useState(null); 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 isStopped = project.status === "stopped" || project.status === "error"; @@ -32,6 +34,7 @@ export default function ProjectCard({ project }: Props) { const [gitToken, setGitToken] = useState(project.git_token ?? ""); const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? ""); const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []); + const [portMappings, setPortMappings] = useState(project.port_mappings ?? []); // Bedrock local state for text fields const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1"); @@ -51,6 +54,7 @@ export default function ProjectCard({ project }: Props) { setGitToken(project.git_token ?? ""); setClaudeInstructions(project.claude_instructions ?? ""); setEnvVars(project.custom_env_vars ?? []); + setPortMappings(project.port_mappings ?? []); setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1"); setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? ""); setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? ""); @@ -524,6 +528,19 @@ export default function ProjectCard({ project }: Props) { + {/* Port Mappings */} +
+ + +
+ {/* Claude Instructions */}