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 { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; import PortMappingsModal from "./PortMappingsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; 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 { 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 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(() => { 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]); // Clear progress when status settles useEffect(() => { if (project.status === "running" || project.status === "stopped" || project.status === "error") { setProgressMsg(null); } }, [project.status]); const handleStart = async () => { setLoading(true); setError(null); try { await start(project.id); } catch (e) { setError(String(e)); } finally { setLoading(false); } }; const handleStop = async () => { setLoading(true); setError(null); 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 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)]" }`} >
{project.name}
{project.paths.map((pp, i) => (
/workspace/{pp.mount_name}
))}
{isSelected && (
{/* Auth mode selector */}
Auth:
{/* Action buttons */}
{isStopped ? ( <> { setLoading(true); 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"} /> { if (confirm(`Remove project "${project.name}"?`)) { await remove(project.id); } }} 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 */}
{/* 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)} /> )}
); } 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 ( ); }