fix(maestro): gate the sidebar preview attach on Running
The TUI's preview attached without checking status, so after a host reboot — every session dormant — moving the sidebar highlight tried a live attach on each one in turn; the daemon has no bridge to answer, so it 404/409s. Gate every attach (preview, committed AttachHighlighted, and the reconciling poll after a resume completes) on status == Running, and show a metadata + resume placeholder in the pane instead, per docs/PREVIEW.md.
This commit is contained in:
+163
-20
@@ -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<Effect> {
|
||||
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;
|
||||
|
||||
@@ -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<PanePlaceholder<'a>>,
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user