From 96407899f7f054d987903b9ba3af839f8ced8044 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 29 May 2026 08:45:15 +0900 Subject: [PATCH] tui: return to multi dashboard after opening pod --- crates/tui/src/main.rs | 166 ++++++++++++++++++++++++++++-------- crates/tui/src/multi_pod.rs | 111 +++++++++++++++++++++--- 2 files changed, 230 insertions(+), 47 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 4925d3a5..8269a3b2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -44,6 +44,8 @@ use crate::app::App; use crate::picker::PickerOutcome; use crate::spawn::{SpawnOutcome, SpawnReady}; +type FullscreenTerminal = Terminal>; + fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { if let Some(p) = override_path { return p; @@ -289,33 +291,95 @@ async fn run_pod_name( pod_name: String, socket_override: Option, ) -> Result<(), Box> { - let preferred_socket = resolve_socket(&pod_name, socket_override.clone()); - if let Some((_socket_path, client)) = - connect_live_pod(&pod_name, preferred_socket, socket_override.is_none()).await - { + if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { let mut terminal = enter_fullscreen()?; - let mut app = App::new(pod_name); - app.connected = true; - return run_loop(&mut terminal, &mut app, client).await; + run_connected_pod(&mut terminal, pod_name, client).await?; + return Ok(()); } let ready = match spawn::run_pod_name(pod_name).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; + let mut terminal = enter_fullscreen()?; + terminal.clear()?; + let result = run_ready_pod(&mut terminal, ready).await; + let _ = leave_fullscreen(&mut terminal); + result +} + +async fn run_connected_pod( + terminal: &mut FullscreenTerminal, + pod_name: String, + client: PodClient, +) -> Result<(), Box> { + let mut app = App::new(pod_name); + app.connected = true; + run_loop(terminal, &mut app, client).await +} + +async fn run_pod_name_nested( + terminal: &mut FullscreenTerminal, + request: multi_pod::OpenPodRequest, +) -> Result<(), Box> { + let multi_pod::OpenPodRequest { + pod_name, + socket_override, + } = request; + + if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await { + return run_connected_pod(terminal, pod_name, client).await; + } + + let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name).await?; + run_ready_pod(terminal, ready).await +} + +async fn spawn_pod_name_from_fullscreen( + terminal: &mut FullscreenTerminal, + pod_name: &str, +) -> Result> { + leave_fullscreen(terminal)?; + let outcome = spawn::run_pod_name(pod_name.to_string()).await; + enter_fullscreen_existing(terminal)?; + terminal.clear()?; + + match outcome? { + SpawnOutcome::Ready(ready) => Ok(ready), + SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)), + } +} + +async fn try_connect_live_pod( + pod_name: &str, + socket_override: Option, +) -> Option { + let preferred_socket = resolve_socket(pod_name, socket_override.clone()); + connect_live_pod(pod_name, preferred_socket, socket_override.is_none()) + .await + .map(|(_, client)| client) +} + +#[derive(Debug)] +struct NestedOpenCancelled; + +impl std::fmt::Display for NestedOpenCancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Pod open was cancelled") + } +} + +impl std::error::Error for NestedOpenCancelled {} + +async fn run_ready_pod( + terminal: &mut FullscreenTerminal, + ready: SpawnReady, +) -> Result<(), Box> { let SpawnReady { pod_name, socket_path, } = ready; - - let mut terminal = enter_fullscreen()?; - let result = run(&mut terminal, pod_name, &socket_path).await; - let _ = execute!( - terminal.backend_mut(), - DisableMouseCapture, - LeaveAlternateScreen - ); - result + run(terminal, pod_name, &socket_path).await } async fn connect_live_pod( @@ -354,24 +418,37 @@ async fn run_resume() -> Result<(), Box> { } async fn run_multi() -> Result<(), Box> { + let mut app = multi_pod::load_app().await?; let mut terminal = enter_fullscreen()?; - let outcome = multi_pod::run(&mut terminal).await; - let _ = execute!( - terminal.backend_mut(), - DisableMouseCapture, - LeaveAlternateScreen - ); - - match outcome? { - multi_pod::MultiPodOutcome::Quit => Ok(()), - multi_pod::MultiPodOutcome::Open { - pod_name, - socket_override, - } => run_pod_name(pod_name, socket_override).await, + loop { + match multi_pod::run(&mut terminal, &mut app).await? { + multi_pod::MultiPodOutcome::Quit => { + let _ = leave_fullscreen(&mut terminal); + return Ok(()); + } + multi_pod::MultiPodOutcome::Open(request) => { + let pod_name = request.pod_name.clone(); + match run_pod_name_nested(&mut terminal, request).await { + Ok(()) => app.finish_open(&pod_name, Ok(())), + Err(error) if is_recoverable_multi_open_error(error.as_ref()) => { + app.finish_open(&pod_name, Err(error.as_ref())); + } + Err(error) => { + let _ = leave_fullscreen(&mut terminal); + return Err(error); + } + } + app.reload().await?; + } + } } } +fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool { + error.is::() || error.is::() +} + async fn run_spawn(resume_from: Option) -> Result<(), Box> { let ready = match spawn::run(resume_from).await? { SpawnOutcome::Ready(r) => r, @@ -396,16 +473,34 @@ async fn run_spawn(resume_from: Option) -> Result<(), Box Result>, Box> -{ +fn enter_fullscreen() -> Result> { let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } +fn enter_fullscreen_existing( + terminal: &mut FullscreenTerminal, +) -> Result<(), Box> { + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + )?; + Ok(()) +} + +fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> { + execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + ) +} + async fn run( - terminal: &mut Terminal>, + terminal: &mut FullscreenTerminal, pod_name: String, socket_path: &std::path::Path, ) -> Result<(), Box> { @@ -438,7 +533,7 @@ const POD_EVENT_DRAIN_LIMIT: usize = 32; struct TerminalEventReader { stop: Arc, - _thread: thread::JoinHandle<()>, + thread: Option>, } impl TerminalEventReader { @@ -453,7 +548,7 @@ impl TerminalEventReader { Ok(( Self { stop, - _thread: thread, + thread: Some(thread), }, rx, )) @@ -463,6 +558,9 @@ impl TerminalEventReader { impl Drop for TerminalEventReader { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } } } diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index a866adef..d0208d7d 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -62,37 +62,41 @@ impl From for MultiPodError { pub(crate) enum MultiPodOutcome { Quit, - Open { - pod_name: String, - socket_override: Option, - }, + Open(OpenPodRequest), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OpenPodRequest { + pub(crate) pod_name: String, + pub(crate) socket_override: Option, +} + +pub(crate) async fn load_app() -> Result { + MultiPodApp::load(None).await } pub(crate) async fn run( terminal: &mut Terminal>, + app: &mut MultiPodApp, ) -> Result { - let mut app = MultiPodApp::load(None).await?; if app.list.entries.is_empty() { return Err(MultiPodError::NoPods); } loop { - terminal.draw(|f| draw(f, &mut app))?; + terminal.draw(|f| draw(f, app))?; match read()? { TermEvent::Key(key) => match app.handle_key(key) { MultiPodAction::None => {} MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit), MultiPodAction::Open => { - if let Some(entry) = app.list.selected_entry() { - return Ok(MultiPodOutcome::Open { - pod_name: entry.name.clone(), - socket_override: entry.attach_socket_path().map(PathBuf::from), - }); + if let Some(request) = app.prepare_open() { + return Ok(MultiPodOutcome::Open(request)); } } MultiPodAction::Refresh => app.reload().await?, MultiPodAction::Send(request) => { - terminal.draw(|f| draw(f, &mut app))?; + terminal.draw(|f| draw(f, app))?; let result = send_run_and_confirm(&request.socket_path, request.segments).await; app.finish_send(result); let _ = app.reload().await; @@ -147,7 +151,7 @@ impl MultiPodApp { Ok(app) } - async fn reload(&mut self) -> Result<(), MultiPodError> { + pub(crate) async fn reload(&mut self) -> Result<(), MultiPodError> { self.list = load_pod_list(self.list.selected_name.clone()).await?; self.ensure_selection_visible(); Ok(()) @@ -211,6 +215,40 @@ impl MultiPodApp { } } + pub(crate) fn prepare_open(&mut self) -> Option { + let entry = match self.list.selected_entry() { + Some(entry) => entry, + None => { + self.notice = Some("No Pod is selected.".to_string()); + return None; + } + }; + if !entry.actions.can_open { + self.notice = Some("Selected Pod cannot be opened from this view.".to_string()); + return None; + } + self.notice = Some(format!("Opening {}…", entry.name)); + Some(OpenPodRequest { + pod_name: entry.name.clone(), + socket_override: entry.attach_socket_path().map(PathBuf::from), + }) + } + + pub(crate) fn finish_open( + &mut self, + pod_name: &str, + result: Result<(), &dyn std::fmt::Display>, + ) { + match result { + Ok(()) => { + self.notice = Some(format!("Returned from {pod_name}.")); + } + Err(error) => { + self.notice = Some(format!("Open failed for {pod_name}: {error}")); + } + } + } + pub(crate) fn prepare_send(&mut self) -> Option { let entry = match self.list.selected_entry() { Some(entry) => entry, @@ -1114,6 +1152,53 @@ mod tests { assert!(app.notice.as_deref().unwrap().contains("Delivered")); } + #[test] + fn multi_open_request_keeps_dashboard_state_for_nested_single_pod() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("draft survives open"); + + let request = app.prepare_open().unwrap(); + + assert_eq!(request.pod_name, "alpha"); + assert_eq!( + request.socket_override, + Some(PathBuf::from("/tmp/alpha.sock")) + ); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert_eq!(input_text(&app), "draft survives open"); + assert!(app.notice.as_deref().unwrap().contains("Opening alpha")); + } + + #[test] + fn multi_open_failure_keeps_composer_and_sets_notice() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("keep this draft"); + let before = input_text(&app); + let error = io::Error::other("boom"); + + app.finish_open("alpha", Err(&error)); + + assert_eq!(input_text(&app), before); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Open failed for alpha") + ); + } + + #[test] + fn multi_open_disabled_target_stays_in_dashboard() { + let mut live = live_info("unreachable", PodStatus::Idle); + live.reachable = false; + live.status = None; + let mut app = test_app(vec![live]); + + assert!(app.prepare_open().is_none()); + assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); + } + fn test_app(live: Vec) -> MultiPodApp { app_with_list(PodList::from_sources( PodVisibilitySource::ResumePicker,