merge: workspace panel nonblocking transitions

This commit is contained in:
Keisuke Hirata 2026-06-08 11:27:22 +09:00
commit 86198d85c3
No known key found for this signature in database
2 changed files with 133 additions and 45 deletions

View File

@ -102,18 +102,26 @@ pub(crate) struct OpenPodRequest {
pub(crate) async fn load_app( pub(crate) async fn load_app(
runtime_command: PodRuntimeCommand, runtime_command: PodRuntimeCommand,
) -> Result<MultiPodApp, MultiPodError> { ) -> Result<MultiPodApp, MultiPodError> {
MultiPodApp::load(None, runtime_command).await Ok(MultiPodApp::loading(runtime_command))
} }
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, app: &mut MultiPodApp,
) -> Result<MultiPodOutcome, MultiPodError> { ) -> Result<MultiPodOutcome, MultiPodError> {
if app.panel.rows.is_empty() && app.panel.header.diagnostics.is_empty() { if app.panel.rows.is_empty()
&& app.panel.header.diagnostics.is_empty()
&& app.enter_reload.is_none()
{
return Err(MultiPodError::NoPods); return Err(MultiPodError::NoPods);
} }
let mut pending_reload = PendingReload::default(); let mut pending_reload = PendingReload::default();
if let Some(mode) = app.enter_reload.take() {
if pending_reload.start(mode) {
app.refreshing = true;
}
}
let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
loop { loop {
@ -125,7 +133,7 @@ pub(crate) async fn run(
let now = Instant::now(); let now = Instant::now();
if now >= next_poll { if now >= next_poll {
pending_reload.start(); pending_reload.start(OrchestratorLifecycleMode::Observe);
next_poll = now + MULTI_POD_POLL_INTERVAL; next_poll = now + MULTI_POD_POLL_INTERVAL;
continue; continue;
} }
@ -141,6 +149,7 @@ pub(crate) async fn run(
MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit), MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit),
MultiPodAction::Open => { MultiPodAction::Open => {
if let Some(request) = app.prepare_open() { if let Some(request) = app.prepare_open() {
terminal.draw(|f| draw(f, app))?;
return Ok(MultiPodOutcome::Open(request)); return Ok(MultiPodOutcome::Open(request));
} }
} }
@ -149,7 +158,9 @@ pub(crate) async fn run(
terminal.draw(|f| draw(f, app))?; terminal.draw(|f| draw(f, app))?;
let result = dispatch_ticket_action(request).await; let result = dispatch_ticket_action(request).await;
app.finish_ticket_action_dispatch(result); app.finish_ticket_action_dispatch(result);
app.reload_or_notice().await; if pending_reload.start(OrchestratorLifecycleMode::Observe) {
app.refreshing = true;
}
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
} }
MultiPodAction::LaunchIntake(request) => { MultiPodAction::LaunchIntake(request) => {
@ -157,7 +168,9 @@ pub(crate) async fn run(
terminal.draw(|f| draw(f, app))?; terminal.draw(|f| draw(f, app))?;
let result = launch_intake_with_handoff(request).await; let result = launch_intake_with_handoff(request).await;
app.finish_intake_launch(result); app.finish_intake_launch(result);
app.reload_or_notice().await; if pending_reload.start(OrchestratorLifecycleMode::Observe) {
app.refreshing = true;
}
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
} }
MultiPodAction::SendCompanion(request) => { MultiPodAction::SendCompanion(request) => {
@ -165,7 +178,9 @@ pub(crate) async fn run(
terminal.draw(|f| draw(f, app))?; terminal.draw(|f| draw(f, app))?;
let result = dispatch_companion_message(request).await; let result = dispatch_companion_message(request).await;
app.finish_companion_send(result); app.finish_companion_send(result);
app.reload_or_notice().await; if pending_reload.start(OrchestratorLifecycleMode::Observe) {
app.refreshing = true;
}
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
} }
}, },
@ -181,12 +196,12 @@ struct PendingReload {
} }
impl PendingReload { impl PendingReload {
fn start(&mut self) -> bool { fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode) -> bool {
if self.handle.is_some() { if self.handle.is_some() {
return false; return false;
} }
self.handle = Some(tokio::spawn(async { self.handle = Some(tokio::spawn(async move {
load_multi_pod_snapshot(None, OrchestratorLifecycleMode::Observe).await load_multi_pod_snapshot(None, lifecycle_mode).await
})); }));
true true
} }
@ -486,50 +501,47 @@ pub(crate) struct MultiPodApp {
composer_target: ComposerTarget, composer_target: ComposerTarget,
notice: Option<String>, notice: Option<String>,
sending: bool, sending: bool,
refreshing: bool,
enter_reload: Option<OrchestratorLifecycleMode>,
runtime_command: PodRuntimeCommand, runtime_command: PodRuntimeCommand,
last_companion_lifecycle_failure: Option<CompanionPanelState>, last_companion_lifecycle_failure: Option<CompanionPanelState>,
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>, last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
} }
impl MultiPodApp { impl MultiPodApp {
async fn load( fn loading(runtime_command: PodRuntimeCommand) -> Self {
selected_name: Option<String>, let workspace_root = current_workspace_root();
runtime_command: PodRuntimeCommand, let mut panel = WorkspacePanelViewModel::empty(&workspace_root);
) -> Result<Self, MultiPodError> { panel
let snapshot = load_multi_pod_snapshot( .header
selected_name, .diagnostics
OrchestratorLifecycleMode::Ensure { .push("Loading workspace dashboard…".to_string());
runtime_command: runtime_command.clone(), Self {
}, list: PodList::from_sources(
) PodVisibilitySource::ResumePicker,
.await?; Vec::new(),
let last_companion_lifecycle_failure = Vec::new(),
companion_lifecycle_failure_from_panel(&snapshot.panel); None,
let last_orchestrator_lifecycle_failure = MAX_ENTRIES,
orchestrator_lifecycle_failure_from_panel(&snapshot.panel); ),
let mut app = Self { panel,
list: snapshot.list,
panel: snapshot.panel,
input: InputBuffer::new(), input: InputBuffer::new(),
selected_row: None, selected_row: None,
composer_target: ComposerTarget::Companion, composer_target: ComposerTarget::Companion,
notice: None, notice: None,
sending: false, sending: false,
refreshing: true,
enter_reload: Some(OrchestratorLifecycleMode::Ensure {
runtime_command: runtime_command.clone(),
}),
runtime_command, runtime_command,
last_companion_lifecycle_failure, last_companion_lifecycle_failure: None,
last_orchestrator_lifecycle_failure, last_orchestrator_lifecycle_failure: None,
}; }
app.ensure_selection_visible();
app.ensure_composer_target_available();
Ok(app)
}
pub(crate) async fn reload_or_notice(&mut self) {
let result = load_multi_pod_snapshot(None, OrchestratorLifecycleMode::Observe).await;
self.apply_reload_result(result);
} }
fn apply_reload_result(&mut self, result: Result<MultiPodSnapshot, MultiPodError>) { fn apply_reload_result(&mut self, result: Result<MultiPodSnapshot, MultiPodError>) {
self.refreshing = false;
match result { match result {
Ok(snapshot) => self.apply_reloaded_snapshot(snapshot), Ok(snapshot) => self.apply_reloaded_snapshot(snapshot),
Err(error) => { Err(error) => {
@ -799,7 +811,7 @@ impl MultiPodApp {
} }
pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> { pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> {
let (pod_name, socket_override) = { let (pod_name, socket_override, progress) = {
let entry = match self.selected_pod_entry() { let entry = match self.selected_pod_entry() {
Some(entry) => entry, Some(entry) => entry,
None => { None => {
@ -811,12 +823,20 @@ impl MultiPodApp {
self.notice = Some("Selected Pod cannot be opened from this view.".to_string()); self.notice = Some("Selected Pod cannot be opened from this view.".to_string());
return None; return None;
} }
let progress = if entry.live.as_ref().is_some_and(|live| live.reachable) {
"Attaching to"
} else if entry.stored.is_some() {
"Restoring/opening"
} else {
"Opening"
};
( (
entry.name.clone(), entry.name.clone(),
entry.attach_socket_path().map(PathBuf::from), entry.attach_socket_path().map(PathBuf::from),
progress,
) )
}; };
self.notice = Some(format!("Opening {pod_name}")); self.notice = Some(format!("{progress} {pod_name}"));
Some(OpenPodRequest { Some(OpenPodRequest {
pod_name, pod_name,
socket_override, socket_override,
@ -830,12 +850,16 @@ impl MultiPodApp {
) { ) {
match result { match result {
Ok(()) => { Ok(()) => {
self.notice = Some(format!("Returned from {pod_name}.")); self.notice = Some(format!("Returned from {pod_name}. Refreshing workspace…"));
} }
Err(error) => { Err(error) => {
self.notice = Some(format!("Open failed for {pod_name}: {error}")); self.notice = Some(format!(
"Open failed for {pod_name}: {error}. Refreshing workspace…"
));
} }
} }
self.refreshing = true;
self.enter_reload = Some(OrchestratorLifecycleMode::Observe);
} }
fn composer_is_blank(&self) -> bool { fn composer_is_blank(&self) -> bool {
@ -2797,6 +2821,14 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
"launching Ticket Intake…".to_string() "launching Ticket Intake…".to_string()
} else if app.sending { } else if app.sending {
"working…".to_string() "working…".to_string()
} else if app.refreshing {
match app.notice.as_deref() {
Some(notice) if notice.contains("Refreshing") || notice.contains("refreshing") => {
notice.to_string()
}
Some(notice) => format!("{notice} Refreshing workspace…"),
None => "Refreshing workspace…".to_string(),
}
} else if let Some(notice) = app.notice.as_deref() { } else if let Some(notice) = app.notice.as_deref() {
notice.to_string() notice.to_string()
} else if let Some(reason) = app.selected_open_disabled_reason() { } else if let Some(reason) = app.selected_open_disabled_reason() {
@ -3966,7 +3998,12 @@ mod tests {
); );
assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert_eq!(input_text(&app), "draft survives open"); assert_eq!(input_text(&app), "draft survives open");
assert!(app.notice.as_deref().unwrap().contains("Opening alpha")); assert!(
app.notice
.as_deref()
.unwrap()
.contains("Attaching to alpha")
);
} }
#[test] #[test]
@ -3986,6 +4023,51 @@ mod tests {
.unwrap() .unwrap()
.contains("Open failed for alpha") .contains("Open failed for alpha")
); );
assert!(app.refreshing);
assert!(matches!(
app.enter_reload,
Some(OrchestratorLifecycleMode::Observe)
));
}
#[test]
fn multi_loading_app_defers_initial_snapshot_to_enter_reload() {
let app = MultiPodApp::loading(PodRuntimeCommand::for_executable("/tmp/yoi"));
assert!(app.panel.rows.is_empty());
assert!(
app.panel
.header
.diagnostics
.iter()
.any(|diagnostic| diagnostic.contains("Loading workspace dashboard"))
);
assert!(app.refreshing);
assert!(matches!(
app.enter_reload,
Some(OrchestratorLifecycleMode::Ensure { .. })
));
}
#[test]
fn multi_open_success_requests_background_reload_without_dropping_state() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.input.insert_str("keep this draft");
app.finish_open("alpha", Ok(()));
assert_eq!(input_text(&app), "keep this draft");
assert!(
app.notice
.as_deref()
.unwrap()
.contains("Refreshing workspace")
);
assert!(app.refreshing);
assert!(matches!(
app.enter_reload,
Some(OrchestratorLifecycleMode::Observe)
));
} }
#[test] #[test]
@ -4015,7 +4097,12 @@ mod tests {
Some(PathBuf::from("/tmp/alpha.sock")) Some(PathBuf::from("/tmp/alpha.sock"))
); );
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
assert!(app.notice.as_deref().unwrap().contains("Opening alpha")); assert!(
app.notice
.as_deref()
.unwrap()
.contains("Attaching to alpha")
);
} }
#[test] #[test]
@ -4310,6 +4397,8 @@ mod tests {
composer_target: ComposerTarget::Companion, composer_target: ComposerTarget::Companion,
notice: None, notice: None,
sending: false, sending: false,
refreshing: false,
enter_reload: None,
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
last_companion_lifecycle_failure, last_companion_lifecycle_failure,
last_orchestrator_lifecycle_failure, last_orchestrator_lifecycle_failure,

View File

@ -203,7 +203,6 @@ pub(crate) async fn run_panel(
return Err(error); return Err(error);
} }
} }
app.reload_or_notice().await;
} }
} }
} }