merge: multi-pod open return

This commit is contained in:
Keisuke Hirata 2026-05-29 08:57:24 +09:00
commit d79b5d5cc4
No known key found for this signature in database
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::spawn::{SpawnOutcome, SpawnReady};
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
if let Some(p) = override_path {
return p;
@ -289,33 +291,95 @@ async fn run_pod_name(
pod_name: String,
socket_override: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
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<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 {
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<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 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::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? {
SpawnOutcome::Ready(r) => r,
@ -396,16 +473,34 @@ async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::er
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();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
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(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
terminal: &mut FullscreenTerminal,
pod_name: String,
socket_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
@ -438,7 +533,7 @@ const POD_EVENT_DRAIN_LIMIT: usize = 32;
struct TerminalEventReader {
stop: Arc<AtomicBool>,
_thread: thread::JoinHandle<()>,
thread: Option<thread::JoinHandle<()>>,
}
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();
}
}
}

View File

@ -62,37 +62,41 @@ impl From<session_store::StoreError> for MultiPodError {
pub(crate) enum MultiPodOutcome {
Quit,
Open {
pod_name: String,
socket_override: Option<PathBuf>,
},
Open(OpenPodRequest),
}
#[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(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut MultiPodApp,
) -> Result<MultiPodOutcome, MultiPodError> {
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<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> {
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<LivePodInfo>) -> MultiPodApp {
app_with_list(PodList::from_sources(
PodVisibilitySource::ResumePicker,