diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 8e394f7..df6c847 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -34,7 +34,7 @@ use crate::input::{ use crate::keymap::Mode; use crate::overlays::HelpOverlay; use crate::pane::{Pane, PaneStatus}; -use crate::render::{draw, RegionMap, RenderInputs}; +use crate::render::{draw, PanePlaceholder, RegionMap, RenderInputs}; use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind, SidebarItem}; @@ -190,6 +190,10 @@ pub enum Effect { id: Uuid, name: String, }, + /// Resume a dormant session (#166, #169): respawns it under its own + /// uuid and sidecar. The catalog picks up the resulting `Running` status + /// from the daemon's own `SessionUpdated` broadcast. + Resume(Uuid), } impl App { @@ -263,6 +267,11 @@ impl App { } _ = self.catalog.changed() => { // Picked up by the frame key's catalog version. + let effects = self.poll_resumed_attach(); + if !effects.is_empty() { + self.epoch = self.epoch.wrapping_add(1); + } + self.perform(effects).await; } _ = self.link.changed() => { // The status indicator and the restart overlay both read @@ -414,6 +423,7 @@ impl App { Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path), Effect::LoadBranches => self.spawn_load_branches(), Effect::Rename { id, name } => self.submit_rename(id, &name).await, + Effect::Resume(id) => self.submit_resume(id).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -587,7 +597,10 @@ impl App { } // A previewed session is already attached: committing // to it keeps that stream rather than reopening one. - if self.emulators.get(sid).is_none() { + // A session that isn't Running has no bridge to + // attach to (`docs/PREVIEW.md`) — the pane falls back + // to its dormant/exited placeholder instead. + if self.emulators.get(sid).is_none() && self.session_running(sid) { effects.push(Effect::Attach(sid)); } } @@ -607,6 +620,11 @@ impl App { self.prompt_rename(sid); } } + Action::ResumeHighlighted => { + if let Some(sid) = self.pick_sidebar_target() { + effects.push(Effect::Resume(sid)); + } + } } // Every action can move the highlight or the focus, and the preview is // a function of both (`docs/PREVIEW.md`). @@ -663,13 +681,41 @@ impl App { self.state.preview = Some(sid); // Set outside the input path, so nothing else marks the frame stale. self.epoch = self.epoch.wrapping_add(1); - let fresh = self.emulators.get(sid).is_none(); - if fresh { + // A session that isn't Running has no bridge to preview-attach to + // (`docs/PREVIEW.md`): the pane falls back to its dormant/exited + // placeholder, and the preview budget has nothing of its own to track. + let attach = self.emulators.get(sid).is_none() && self.session_running(sid); + if attach { self.preview_lru.retain(|s| *s != sid); self.preview_lru.push(sid); } self.trim_preview_budget(); - if fresh { + if attach { + vec![Effect::Attach(sid)] + } else { + Vec::new() + } + } + + /// Whether `sid` is currently `Running`, per the catalog — the only + /// status a live pane attach is allowed for (`docs/PREVIEW.md`). + fn session_running(&self, sid: Uuid) -> bool { + self.catalog + .snapshot() + .sessions + .iter() + .any(|s| s.id == sid && s.status == maestro_protocol::SessionStatus::Running) + } + + /// A previewed or committed session gates its attach on `Running` + /// (`docs/PREVIEW.md`) and may only become `Running` later — most + /// commonly a resume completing. Catch that up on a catalog change + /// rather than waiting on an unrelated highlight move. + fn poll_resumed_attach(&mut self) -> Vec { + let Some(sid) = self.state.displayed_session() else { + return Vec::new(); + }; + if self.emulators.get(sid).is_none() && self.session_running(sid) { vec![Effect::Attach(sid)] } else { Vec::new() @@ -1327,6 +1373,15 @@ impl App { } } + /// Resume a dormant session (#166, #169). The catalog's own + /// `SessionUpdated` broadcast carries the `Running` status back; nothing + /// is patched locally here. + async fn submit_resume(&mut self, sid: Uuid) { + if let Err(e) = self.daemon.resume_session(sid).await { + self.state.toast(format!("resume failed: {e}")); + } + } + /// Report a pane-focus change to the daemon (docs/SIDEBAR.md's /// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's /// session counts — the sidebar highlight is never a "focused pane". @@ -1416,21 +1471,32 @@ impl App { focused: matches!(self.state.focus, Focus::Pane) && !previewing, }) }); - let pane_title = match shown_sid { - Some(sid) => { - let name = snap - .sessions - .iter() - .find(|session| session.id == sid) - .map(crate::sidebar::name_for) - .unwrap_or_else(|| "session".into()); - if previewing { - format!("{name} · preview") - } else { - name - } - } - None => "session".into(), + let shown_session = shown_sid.and_then(|sid| snap.sessions.iter().find(|s| s.id == sid)); + let shown_name = shown_session + .map(crate::sidebar::name_for) + .unwrap_or_else(|| "session".into()); + let pane_title = if previewing && shown_sid.is_some() { + format!("{shown_name} · preview") + } else { + shown_name.clone() + }; + // docs/PREVIEW.md: a session that isn't Running never gets a live + // attach, so an empty pane here means "nothing to attach to", not + // merely "not yet connected" — the pane names it and offers resume + // instead of a terminal that would die on the daemon's 409. + let shown_cwd = shown_session + .map(|s| s.cwd.display().to_string()) + .unwrap_or_default(); + let pane_placeholder = if pane.is_none() { + shown_session + .filter(|s| s.status != maestro_protocol::SessionStatus::Running) + .map(|s| PanePlaceholder { + name: &shown_name, + cwd: &shown_cwd, + dormant: s.status == maestro_protocol::SessionStatus::Dormant, + }) + } else { + None }; let link_status = self.link.status(); @@ -1439,6 +1505,7 @@ impl App { home_dir: &self.home_dir, pane_title: &pane_title, pane: pane.as_ref(), + pane_placeholder, state: &self.state, draft: self.draft.as_ref(), help: &self.help, @@ -2124,6 +2191,82 @@ mod tests { assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid))); } + /// #169, docs/PREVIEW.md: a session that isn't `Running` never gets a + /// live attach — the daemon has nothing to answer a 409 with. Committing + /// to it still moves the tab, so the pane can show its placeholder. + #[test] + fn attach_highlighted_never_attaches_a_dormant_session() { + use maestro_protocol::{CatalogEvent, SessionStatus}; + let sid = Uuid::from_u128(11); + let mut app = make_app(); + let mut s = test_session(sid, "/repo"); + s.status = SessionStatus::Dormant; + app.catalog + .apply_event(CatalogEvent::Snapshot { sessions: vec![s] }); + app.dispatch(Action::SidebarNext); + + assert_eq!(app.dispatch(Action::AttachHighlighted), Vec::new()); + assert_eq!(app.state.active_tab().session(), Some(sid)); + } + + /// #169, docs/PREVIEW.md: moving the highlight across dormant sessions — + /// the sharp case after a reboot — must never try a live preview attach. + #[test] + fn preview_never_attaches_a_dormant_session() { + use maestro_protocol::{CatalogEvent, SessionStatus}; + let sid = Uuid::from_u128(12); + let mut app = make_app(); + let mut s = test_session(sid, "/repo"); + s.status = SessionStatus::Dormant; + app.catalog + .apply_event(CatalogEvent::Snapshot { sessions: vec![s] }); + app.dispatch(Action::SidebarNext); + + assert_eq!(app.poll_due_preview_at(after_gap()), Vec::new()); + assert_eq!(app.state.preview, Some(sid)); + } + + /// #169: a session that becomes `Running` while already shown — a resume + /// completing, most commonly — attaches without waiting on an unrelated + /// highlight move. + #[test] + fn a_shown_session_attaches_once_it_turns_running() { + use maestro_protocol::{CatalogEvent, SessionStatus}; + let sid = Uuid::from_u128(13); + let mut app = make_app(); + let mut dormant = test_session(sid, "/repo"); + dormant.status = SessionStatus::Dormant; + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![dormant.clone()], + }); + app.state.attach_to_focused(sid); + assert_eq!(app.poll_resumed_attach(), Vec::new()); + + let mut running = dormant; + running.status = SessionStatus::Running; + app.catalog + .apply_event(CatalogEvent::SessionUpdated { session: running }); + assert_eq!(app.poll_resumed_attach(), vec![Effect::Attach(sid)]); + } + + // -- resume (#166, #169) ------------------------------------------------- + + #[test] + fn resume_highlighted_asks_for_a_resume() { + use maestro_protocol::SessionStatus; + let mut app = make_app(); + let sid = Uuid::from_u128(14); + let mut s = test_session(sid, "/repo"); + s.status = SessionStatus::Dormant; + app.catalog + .apply_event(maestro_protocol::CatalogEvent::Snapshot { sessions: vec![s] }); + + assert_eq!( + app.dispatch(Action::ResumeHighlighted), + vec![Effect::Resume(sid)] + ); + } + #[test] fn enter_on_a_group_toggles_it_without_targeting_a_session() { use maestro_protocol::CatalogEvent; diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 7d697f3..73058e3 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -72,6 +72,18 @@ fn inside(r: Rect, col: u16, row: u16) -> bool { col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height } +/// What the pane shows in place of a live attach when the session behind it +/// isn't `Running` (`docs/PREVIEW.md`): a session that is not `Running` never +/// gets a live attach, since the daemon has no bridge to answer one. +#[derive(Debug, Clone, Copy)] +pub struct PanePlaceholder<'a> { + pub name: &'a str, + pub cwd: &'a str, + /// `true` for a dormant session (offers resume), `false` for a dead one + /// (`docs/SIDEBAR.md`'s `exited`/`dormant` fragments never both show). + pub dormant: bool, +} + /// Bundle of references the renderer reads from. Built by `App::redraw` each /// frame. #[derive(Debug)] @@ -80,6 +92,9 @@ pub struct RenderInputs<'a> { pub home_dir: &'a str, pub pane_title: &'a str, pub pane: Option<&'a Pane>, + /// What to show instead, when `pane` is `None` because the shown session + /// isn't `Running` rather than merely not yet connected. + pub pane_placeholder: Option>, /// The spawn form behind [`OverlayKind::Spawn`], when one is up. pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, @@ -147,6 +162,8 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { render_title_bar(f, title_area, inputs.pane_title, pane_focused); if let Some(p) = inputs.pane { p.render(f, pane_area); + } else if let Some(ph) = inputs.pane_placeholder { + render_pane_placeholder(f, pane_area, ph); } // `RegionMap::pane` is also the PTY resize source. The title bar is // chrome; every remaining cell belongs to the emulator. @@ -239,6 +256,44 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { map } +/// docs/PREVIEW.md / docs/SIDEBAR.md: what the pane can honestly show for a +/// session with no bridge behind it — its metadata and a resume affordance +/// for a dormant one, never a terminal that would die on the daemon's 409. +fn render_pane_placeholder(f: &mut Frame, area: Rect, ph: PanePlaceholder<'_>) { + let palette = palette::active(); + let surface = palette.surface(false); + let glyph = if ph.dormant { "◌" } else { "✗" }; + let status = if ph.dormant { + "dormant — resume from the sidebar" + } else { + "exited" + }; + let mid = area.y + area.height / 2; + let lines = [ + (format!("{glyph} {}", ph.name), surface.fg), + (ph.cwd.to_string(), surface.muted), + (status.to_string(), surface.muted), + ]; + for (i, (text, colour)) in lines.iter().enumerate() { + let Some(y) = mid.checked_sub(1).map(|y| y + i as u16) else { + continue; + }; + if y >= area.y + area.height { + continue; + } + let x = area.x + (area.width.saturating_sub(text.chars().count() as u16)) / 2; + f.render_widget( + Paragraph::new(text.as_str()).style(Style::default().bg(surface.bg).fg(*colour)), + Rect::new( + x, + y, + text.chars().count().min(area.width as usize) as u16, + 1, + ), + ); + } +} + fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) { let palette = palette::active(); let style = if focused { @@ -435,6 +490,7 @@ mod tests { home_dir: "", pane_title: "session", pane: None, + pane_placeholder: None, draft: None, state: &state, help: &help, @@ -532,6 +588,7 @@ mod tests { home_dir: "", pane_title: "session", pane: None, + pane_placeholder: None, draft: Some(&draft), state: &state, help: &help, @@ -587,6 +644,7 @@ mod tests { home_dir: "", pane_title: "session", pane: None, + pane_placeholder: None, draft: None, state: &state, help: &help, @@ -629,6 +687,7 @@ mod tests { home_dir: "", pane_title: "session", pane: None, + pane_placeholder: None, draft: None, state: &state, help: &help, @@ -656,4 +715,52 @@ mod tests { let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect(); assert!(title.contains("session"), "missing title: {title:?}"); } + + /// docs/PREVIEW.md: a session that isn't `Running` never gets a live + /// attach — the pane names it and offers resume instead of a terminal + /// that would die on the daemon's 409. + #[test] + fn a_dormant_pane_placeholder_names_the_session_and_offers_resume() { + use crate::overlays::HelpOverlay; + use std::collections::HashSet; + + let sidebar = Sidebar { + sessions: Vec::new(), + highlight: None, + attached: HashSet::new(), + pane_session: None, + }; + let help = HelpOverlay { lines: Vec::new() }; + let state = AppState::new(); + let inputs = RenderInputs { + sidebar: &sidebar, + home_dir: "", + pane_title: "worker", + pane: None, + pane_placeholder: Some(PanePlaceholder { + name: "worker", + cwd: "/home/n/work", + dormant: true, + }), + draft: None, + state: &state, + help: &help, + rename_input: &crate::text_input::TextInput::default(), + command_bar: None, + link: &online_link(), + }; + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw(f, inputs); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let text: String = buf.content().iter().map(|c| c.symbol()).collect(); + assert!(text.contains("worker"), "{text}"); + assert!(text.contains("/home/n/work"), "{text}"); + assert!(text.contains("dormant"), "{text}"); + assert!(text.contains("resume"), "{text}"); + } }