From 1aced2d860cf420a003d1356e40dedcb468fbe7d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 4 Mar 2026 07:43:01 -0800 Subject: [PATCH] feat: add progress feedback during slow container starts Emit container-progress events from Rust at key milestones (checking image, saving state, recreating, starting, stopping) and display them in ProjectCard instead of the static "starting.../stopping..." text. Co-Authored-By: Claude Opus 4.6 --- .../src/commands/project_commands.rs | 25 +++++++++++++++++-- app/src/components/projects/ProjectCard.tsx | 24 +++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index b30040d..cdcf1ce 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1,10 +1,20 @@ -use tauri::State; +use tauri::{Emitter, State}; use crate::docker; use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; +fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) { + let _ = app_handle.emit( + "container-progress", + serde_json::json!({ + "project_id": project_id, + "message": message, + }), + ); +} + /// Extract secret fields from a project and store them in the OS keychain. fn store_secrets_for_project(project: &Project) -> Result<(), String> { if let Some(ref token) = project.git_token { @@ -116,6 +126,7 @@ pub async fn update_project( #[tauri::command] pub async fn start_project_container( project_id: String, + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { let mut project = state @@ -147,6 +158,7 @@ pub async fn start_project_container( // Wrap container operations so that any failure resets status to Stopped. let result: Result = async { // Ensure image exists + emit_progress(&app_handle, &project_id, "Checking image..."); if !docker::image_exists(&image_name).await? { return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); } @@ -173,9 +185,11 @@ pub async fn start_project_container( if needs_recreate { log::info!("Container config changed for project {} — committing snapshot and recreating", project.id); // Snapshot the filesystem before destroying + emit_progress(&app_handle, &project_id, "Saving container state..."); if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await { log::warn!("Failed to snapshot container before recreation: {}", e); } + emit_progress(&app_handle, &project_id, "Recreating container..."); let _ = docker::stop_container(&existing_id).await; docker::remove_container(&existing_id).await?; @@ -197,9 +211,11 @@ pub async fn start_project_container( &settings.global_custom_env_vars, settings.timezone.as_deref(), ).await?; + emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; new_id } else { + emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&existing_id).await?; existing_id } @@ -215,6 +231,7 @@ pub async fn start_project_container( image_name.clone() }; + emit_progress(&app_handle, &project_id, "Creating container..."); let new_id = docker::create_container( &project, &docker_socket, @@ -225,6 +242,7 @@ pub async fn start_project_container( &settings.global_custom_env_vars, settings.timezone.as_deref(), ).await?; + emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; new_id }; @@ -252,6 +270,7 @@ pub async fn start_project_container( #[tauri::command] pub async fn stop_project_container( project_id: String, + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let project = state @@ -263,6 +282,7 @@ pub async fn stop_project_container( if let Some(ref container_id) = project.container_id { // Close exec sessions for this project + emit_progress(&app_handle, &project_id, "Stopping container..."); state.exec_manager.close_sessions_for_container(container_id).await; if let Err(e) = docker::stop_container(container_id).await { @@ -277,6 +297,7 @@ pub async fn stop_project_container( #[tauri::command] pub async fn rebuild_project_container( project_id: String, + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { let project = state @@ -301,7 +322,7 @@ pub async fn rebuild_project_container( } // Start fresh - start_project_container(project_id, state).await + start_project_container(project_id, app_handle, state).await } fn default_docker_socket() -> String { diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index 306d990..b78ecde 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -1,5 +1,6 @@ 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"; @@ -23,6 +24,7 @@ export default function ProjectCard({ project }: Props) { 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"; @@ -64,6 +66,26 @@ export default function ProjectCard({ project }: Props) { 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); @@ -317,7 +339,7 @@ export default function ProjectCard({ project }: Props) { ) : ( <> - {project.status}... + {progressMsg ?? `${project.status}...`}