import { useState, useEffect } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; import { useMcpServers } from "../../hooks/useMcpServers"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; import PortMappingsModal from "./PortMappingsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; import ContainerProgressModal from "./ContainerProgressModal"; interface Props { project: Project; } export default function ProjectCard({ project }: Props) { const selectedProjectId = useAppState(s => s.selectedProjectId); const setSelectedProject = useAppState(s => s.setSelectedProject); const { start, stop, rebuild, remove, update } = useProjects(); const { mcpServers } = useMcpServers(); const { open: openTerminal } = useTerminal(); const [loading, setLoading] = useState(false); 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 [progressMsg, setProgressMsg] = useState(null); const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); const [operationCompleted, setOperationCompleted] = useState(false); const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(project.name); const isSelected = selectedProjectId === project.id; const isStopped = project.status === "stopped" || project.status === "error"; // Local state for text fields (save on blur, not on every keystroke) const [paths, setPaths] = useState(project.paths ?? []); const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? ""); const [gitName, setGitName] = useState(project.git_user_name ?? ""); const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); 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"); const [bedrockAccessKeyId, setBedrockAccessKeyId] = useState(project.bedrock_config?.aws_access_key_id ?? ""); const [bedrockSecretKey, setBedrockSecretKey] = useState(project.bedrock_config?.aws_secret_access_key ?? ""); const [bedrockSessionToken, setBedrockSessionToken] = useState(project.bedrock_config?.aws_session_token ?? ""); const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? ""); const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? ""); const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? ""); // Sync local state when project prop changes (e.g., after save or external update) useEffect(() => { setEditName(project.name); setPaths(project.paths ?? []); setSshKeyPath(project.ssh_key_path ?? ""); setGitName(project.git_user_name ?? ""); setGitEmail(project.git_user_email ?? ""); 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 ?? ""); setBedrockSessionToken(project.bedrock_config?.aws_session_token ?? ""); setBedrockProfile(project.bedrock_config?.aws_profile ?? ""); setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? ""); }, [project]); // Listen for container progress events useEffect(() => { const unlisten = listen<{ project_id: string; message: string }>( "container-progress", (event) => { if (event.payload.project_id === project.id) { setProgressMsg(event.payload.message); } } ); return () => { unlisten.then((f) => f()); }; }, [project.id]); // Mark operation completed when status settles useEffect(() => { if (project.status === "running" || project.status === "stopped" || project.status === "error") { if (activeOperation) { setOperationCompleted(true); } // Clear progress if no modal is managing it if (!activeOperation) { setProgressMsg(null); } } }, [project.status, activeOperation]); const handleStart = async () => { setLoading(true); setError(null); setProgressMsg(null); setOperationCompleted(false); setActiveOperation("starting"); try { await start(project.id); } catch (e) { setError(String(e)); } finally { setLoading(false); } }; const handleStop = async () => { setLoading(true); setError(null); setProgressMsg(null); setOperationCompleted(false); setActiveOperation("stopping"); try { await stop(project.id); } catch (e) { setError(String(e)); } finally { setLoading(false); } }; const handleOpenTerminal = async () => { try { await openTerminal(project.id, project.name); } catch (e) { setError(String(e)); } }; const handleForceStop = async () => { try { await stop(project.id); } catch (e) { setError(String(e)); } }; const closeModal = () => { setActiveOperation(null); setOperationCompleted(false); setProgressMsg(null); setError(null); }; const defaultBedrockConfig: BedrockConfig = { auth_method: "static_credentials", aws_region: "us-east-1", aws_access_key_id: null, aws_secret_access_key: null, aws_session_token: null, aws_profile: null, aws_bearer_token: null, model_id: null, disable_prompt_caching: false, }; const handleAuthModeChange = async (mode: AuthMode) => { try { const updates: Partial = { auth_mode: mode }; if (mode === "bedrock" && !project.bedrock_config) { updates.bedrock_config = defaultBedrockConfig; } await update({ ...project, ...updates }); } catch (e) { setError(String(e)); } }; const updateBedrockConfig = async (patch: Partial) => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, ...patch } }); } catch (err) { console.error("Failed to update Bedrock config:", err); } }; const handleBrowseSSH = async () => { const selected = await open({ directory: true, multiple: false }); if (selected) { try { await update({ ...project, ssh_key_path: selected as string }); } catch (e) { setError(String(e)); } } }; // Blur handlers for text fields const handleSshKeyPathBlur = async () => { try { await update({ ...project, ssh_key_path: sshKeyPath || null }); } catch (err) { console.error("Failed to update SSH key path:", err); } }; const handleGitNameBlur = async () => { try { await update({ ...project, git_user_name: gitName || null }); } catch (err) { console.error("Failed to update Git name:", err); } }; const handleGitEmailBlur = async () => { try { await update({ ...project, git_user_email: gitEmail || null }); } catch (err) { console.error("Failed to update Git email:", err); } }; const handleGitTokenBlur = async () => { try { await update({ ...project, git_token: gitToken || null }); } catch (err) { console.error("Failed to update Git token:", err); } }; const handleBedrockRegionBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, aws_region: bedrockRegion } }); } catch (err) { console.error("Failed to update Bedrock region:", err); } }; const handleBedrockAccessKeyIdBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, aws_access_key_id: bedrockAccessKeyId || null } }); } catch (err) { console.error("Failed to update Bedrock access key:", err); } }; const handleBedrockSecretKeyBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, aws_secret_access_key: bedrockSecretKey || null } }); } catch (err) { console.error("Failed to update Bedrock secret key:", err); } }; const handleBedrockSessionTokenBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, aws_session_token: bedrockSessionToken || null } }); } catch (err) { console.error("Failed to update Bedrock session token:", err); } }; const handleBedrockProfileBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, aws_profile: bedrockProfile || null } }); } catch (err) { console.error("Failed to update Bedrock profile:", err); } }; const handleBedrockBearerTokenBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, aws_bearer_token: bedrockBearerToken || null } }); } catch (err) { console.error("Failed to update Bedrock bearer token:", err); } }; const handleBedrockModelIdBlur = async () => { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, model_id: bedrockModelId || null } }); } catch (err) { console.error("Failed to update Bedrock model ID:", err); } }; const statusColor = { stopped: "bg-[var(--text-secondary)]", starting: "bg-[var(--warning)]", running: "bg-[var(--success)]", stopping: "bg-[var(--warning)]", error: "bg-[var(--error)]", }[project.status]; return (
setSelectedProject(project.id)} className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${ isSelected ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]" }`} >
{isEditingName ? ( setEditName(e.target.value)} onBlur={async () => { setIsEditingName(false); const trimmed = editName.trim(); if (trimmed && trimmed !== project.name) { try { await update({ ...project, name: trimmed }); } catch (err) { console.error("Failed to rename project:", err); setEditName(project.name); } } else { setEditName(project.name); } }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); } }} onClick={(e) => e.stopPropagation()} className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none" /> ) : ( { e.stopPropagation(); setIsEditingName(true); }} > {project.name} )}
{project.paths.map((pp, i) => (
/workspace/{pp.mount_name}
))}
{isSelected && (
{/* Auth mode selector */}
Auth:
{/* Action buttons */}
{isStopped ? ( <> { setLoading(true); setError(null); setProgressMsg(null); setOperationCompleted(false); setActiveOperation("resetting"); try { await rebuild(project.id); } catch (e) { setError(String(e)); } setLoading(false); }} disabled={loading} label="Reset" /> ) : project.status === "running" ? ( <> ) : ( <> {progressMsg ?? `${project.status}...`} )} { e?.stopPropagation?.(); setShowConfig(!showConfig); }} disabled={false} label={showConfig ? "Hide" : "Config"} /> {showRemoveConfirm ? ( Remove? ) : ( setShowRemoveConfirm(true)} disabled={loading} label="Remove" danger /> )}
{/* Config panel */} {showConfig && (
e.stopPropagation()}> {!isStopped && (
Container must be stopped to change settings.
)} {/* Folder paths */}
{paths.map((pp, i) => (
{ const updated = [...paths]; updated[i] = { ...updated[i], host_path: e.target.value }; setPaths(updated); }} onBlur={async () => { try { await update({ ...project, paths }); } catch (err) { console.error("Failed to update paths:", err); } }} placeholder="/path/to/folder" disabled={!isStopped} className="flex-1 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" /> {paths.length > 1 && ( )}
/workspace/ { const updated = [...paths]; updated[i] = { ...updated[i], mount_name: e.target.value }; setPaths(updated); }} onBlur={async () => { try { await update({ ...project, paths }); } catch (err) { console.error("Failed to update paths:", err); } }} placeholder="name" disabled={!isStopped} className="flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono" />
))}
{/* SSH Key */}
setSshKeyPath(e.target.value)} onBlur={handleSshKeyPathBlur} placeholder="~/.ssh" 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" />
{/* Git Name */}
setGitName(e.target.value)} onBlur={handleGitNameBlur} placeholder="Your Name" disabled={!isStopped} 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" />
{/* Git Email */}
setGitEmail(e.target.value)} onBlur={handleGitEmailBlur} placeholder="you@example.com" disabled={!isStopped} 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" />
{/* Git Token (HTTPS) */}
setGitToken(e.target.value)} onBlur={handleGitTokenBlur} placeholder="ghp_..." disabled={!isStopped} 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" />
{/* Docker access toggle */}
{/* Environment Variables */}
{/* Port Mappings */}
{/* Claude Instructions */}
{/* MCP Servers */} {mcpServers.length > 0 && (
{mcpServers.map((server) => { const enabled = project.enabled_mcp_servers.includes(server.id); const isDocker = !!server.docker_image; return ( ); })}
{mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && (

Docker access will be auto-enabled for stdio+Docker MCP servers.

)}
)} {/* Bedrock config */} {project.auth_mode === "bedrock" && (() => { const bc = project.bedrock_config ?? defaultBedrockConfig; const inputCls = "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"; return (
{/* Sub-method selector */}
Method: {(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => ( ))}
{/* AWS Region (always shown) */}
setBedrockRegion(e.target.value)} onBlur={handleBedrockRegionBlur} placeholder="us-east-1" disabled={!isStopped} className={inputCls} />
{/* Static credentials fields */} {bc.auth_method === "static_credentials" && ( <>
setBedrockAccessKeyId(e.target.value)} onBlur={handleBedrockAccessKeyIdBlur} placeholder="AKIA..." disabled={!isStopped} className={inputCls} />
setBedrockSecretKey(e.target.value)} onBlur={handleBedrockSecretKeyBlur} disabled={!isStopped} className={inputCls} />
setBedrockSessionToken(e.target.value)} onBlur={handleBedrockSessionTokenBlur} disabled={!isStopped} className={inputCls} />
)} {/* Profile field */} {bc.auth_method === "profile" && (
setBedrockProfile(e.target.value)} onBlur={handleBedrockProfileBlur} placeholder="default" disabled={!isStopped} className={inputCls} />
)} {/* Bearer token field */} {bc.auth_method === "bearer_token" && (
setBedrockBearerToken(e.target.value)} onBlur={handleBedrockBearerTokenBlur} disabled={!isStopped} className={inputCls} />
)} {/* Model override */}
setBedrockModelId(e.target.value)} onBlur={handleBedrockModelIdBlur} placeholder="anthropic.claude-sonnet-4-20250514-v1:0" disabled={!isStopped} className={inputCls} />
); })()}
)}
)} {error && (
{error}
)} {showEnvVarsModal && ( { setEnvVars(vars); await update({ ...project, custom_env_vars: vars }); }} onClose={() => setShowEnvVarsModal(false)} /> )} {showPortMappingsModal && ( { setPortMappings(mappings); await update({ ...project, port_mappings: mappings }); }} onClose={() => setShowPortMappingsModal(false)} /> )} {showClaudeInstructionsModal && ( { setClaudeInstructions(instructions); await update({ ...project, claude_instructions: instructions || null }); }} onClose={() => setShowClaudeInstructionsModal(false)} /> )} {activeOperation && ( )}
); } function ActionButton({ onClick, disabled, label, accent, danger, }: { onClick: (e?: React.MouseEvent) => void; disabled: boolean; label: string; accent?: boolean; danger?: boolean; }) { let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"; if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]"; if (danger) color = "text-[var(--error)] hover:text-[var(--error)]"; return ( ); }