tui: make panel transitions nonblocking
This commit is contained in:
parent
e3df2231cb
commit
12a4f39328
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,6 @@ pub(crate) async fn run_panel(
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.reload_or_notice().await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user