tui: return to multi dashboard after opening pod

This commit is contained in:
Keisuke Hirata 2026-05-29 08:45:15 +09:00
parent eb249dae0c
commit be54cb07ea
2 changed files with 230 additions and 47 deletions

View File

@ -44,6 +44,8 @@ use crate::app::App;
use crate::picker::PickerOutcome; use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady}; use crate::spawn::{SpawnOutcome, SpawnReady};
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf { fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
if let Some(p) = override_path { if let Some(p) = override_path {
return p; return p;
@ -289,33 +291,95 @@ async fn run_pod_name(
pod_name: String, pod_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let preferred_socket = resolve_socket(&pod_name, socket_override.clone()); if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
if let Some((_socket_path, client)) =
connect_live_pod(&pod_name, preferred_socket, socket_override.is_none()).await
{
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
let mut app = App::new(pod_name); run_connected_pod(&mut terminal, pod_name, client).await?;
app.connected = true; return Ok(());
return run_loop(&mut terminal, &mut app, client).await;
} }
let ready = match spawn::run_pod_name(pod_name).await? { let ready = match spawn::run_pod_name(pod_name).await? {
SpawnOutcome::Ready(r) => r, SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<SpawnReady, Box<dyn std::error::Error>> {
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<PathBuf>,
) -> Option<PodClient> {
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<dyn std::error::Error>> {
let SpawnReady { let SpawnReady {
pod_name, pod_name,
socket_path, socket_path,
} = ready; } = ready;
run(terminal, pod_name, &socket_path).await
let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, pod_name, &socket_path).await;
let _ = execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
result
} }
async fn connect_live_pod( async fn connect_live_pod(
@ -354,24 +418,37 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
} }
async fn run_multi() -> Result<(), Box<dyn std::error::Error>> { async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
let mut app = multi_pod::load_app().await?;
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
let outcome = multi_pod::run(&mut terminal).await;
let _ = execute!( loop {
terminal.backend_mut(), match multi_pod::run(&mut terminal, &mut app).await? {
DisableMouseCapture, multi_pod::MultiPodOutcome::Quit => {
LeaveAlternateScreen let _ = leave_fullscreen(&mut terminal);
); return Ok(());
}
match outcome? { multi_pod::MultiPodOutcome::Open(request) => {
multi_pod::MultiPodOutcome::Quit => Ok(()), let pod_name = request.pod_name.clone();
multi_pod::MultiPodOutcome::Open { match run_pod_name_nested(&mut terminal, request).await {
pod_name, Ok(()) => app.finish_open(&pod_name, Ok(())),
socket_override, Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
} => run_pod_name(pod_name, socket_override).await, 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::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> { async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? { let ready = match spawn::run(resume_from).await? {
SpawnOutcome::Ready(r) => r, SpawnOutcome::Ready(r) => r,
@ -396,16 +473,34 @@ async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::er
result result
} }
fn enter_fullscreen() -> Result<Terminal<CrosstermBackend<io::Stdout>>, Box<dyn std::error::Error>> fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
{
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?) Ok(Terminal::new(backend)?)
} }
fn enter_fullscreen_existing(
terminal: &mut FullscreenTerminal,
) -> Result<(), Box<dyn std::error::Error>> {
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
)?;
Ok(())
}
fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> {
execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
)
}
async fn run( async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut FullscreenTerminal,
pod_name: String, pod_name: String,
socket_path: &std::path::Path, socket_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
@ -438,7 +533,7 @@ const POD_EVENT_DRAIN_LIMIT: usize = 32;
struct TerminalEventReader { struct TerminalEventReader {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
_thread: thread::JoinHandle<()>, thread: Option<thread::JoinHandle<()>>,
} }
impl TerminalEventReader { impl TerminalEventReader {
@ -453,7 +548,7 @@ impl TerminalEventReader {
Ok(( Ok((
Self { Self {
stop, stop,
_thread: thread, thread: Some(thread),
}, },
rx, rx,
)) ))
@ -463,6 +558,9 @@ impl TerminalEventReader {
impl Drop for TerminalEventReader { impl Drop for TerminalEventReader {
fn drop(&mut self) { fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed); self.stop.store(true, Ordering::Relaxed);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
} }
} }

View File

@ -62,37 +62,41 @@ impl From<session_store::StoreError> for MultiPodError {
pub(crate) enum MultiPodOutcome { pub(crate) enum MultiPodOutcome {
Quit, Quit,
Open { Open(OpenPodRequest),
pod_name: String, }
socket_override: Option<PathBuf>,
}, #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OpenPodRequest {
pub(crate) pod_name: String,
pub(crate) socket_override: Option<PathBuf>,
}
pub(crate) async fn load_app() -> Result<MultiPodApp, MultiPodError> {
MultiPodApp::load(None).await
} }
pub(crate) async fn run( pub(crate) async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut MultiPodApp,
) -> Result<MultiPodOutcome, MultiPodError> { ) -> Result<MultiPodOutcome, MultiPodError> {
let mut app = MultiPodApp::load(None).await?;
if app.list.entries.is_empty() { if app.list.entries.is_empty() {
return Err(MultiPodError::NoPods); return Err(MultiPodError::NoPods);
} }
loop { loop {
terminal.draw(|f| draw(f, &mut app))?; terminal.draw(|f| draw(f, app))?;
match read()? { match read()? {
TermEvent::Key(key) => match app.handle_key(key) { TermEvent::Key(key) => match app.handle_key(key) {
MultiPodAction::None => {} MultiPodAction::None => {}
MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit), MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit),
MultiPodAction::Open => { MultiPodAction::Open => {
if let Some(entry) = app.list.selected_entry() { if let Some(request) = app.prepare_open() {
return Ok(MultiPodOutcome::Open { return Ok(MultiPodOutcome::Open(request));
pod_name: entry.name.clone(),
socket_override: entry.attach_socket_path().map(PathBuf::from),
});
} }
} }
MultiPodAction::Refresh => app.reload().await?, MultiPodAction::Refresh => app.reload().await?,
MultiPodAction::Send(request) => { 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; let result = send_run_and_confirm(&request.socket_path, request.segments).await;
app.finish_send(result); app.finish_send(result);
let _ = app.reload().await; let _ = app.reload().await;
@ -147,7 +151,7 @@ impl MultiPodApp {
Ok(app) 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.list = load_pod_list(self.list.selected_name.clone()).await?;
self.ensure_selection_visible(); self.ensure_selection_visible();
Ok(()) Ok(())
@ -211,6 +215,40 @@ impl MultiPodApp {
} }
} }
pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> {
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<DirectSendRequest> { pub(crate) fn prepare_send(&mut self) -> Option<DirectSendRequest> {
let entry = match self.list.selected_entry() { let entry = match self.list.selected_entry() {
Some(entry) => entry, Some(entry) => entry,
@ -1114,6 +1152,53 @@ mod tests {
assert!(app.notice.as_deref().unwrap().contains("Delivered")); 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<LivePodInfo>) -> MultiPodApp { fn test_app(live: Vec<LivePodInfo>) -> MultiPodApp {
app_with_list(PodList::from_sources( app_with_list(PodList::from_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,