From de82b66de45e504ca71299df7cbd233903da5a33 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 30 Sep 2026 10:51:35 +0900 Subject: [PATCH] fix: align tui status and list viewports --- crates/wip-tui/src/app.rs | 4 +- crates/wip-tui/src/render.rs | 185 +++++++++++++++++++++----- crates/wip-tui/src/runtime.rs | 26 +++- crates/wip-tui/tests/runtime_async.rs | 84 +++++++++++- 4 files changed, 265 insertions(+), 34 deletions(-) diff --git a/crates/wip-tui/src/app.rs b/crates/wip-tui/src/app.rs index fa8be24..ebf008c 100644 --- a/crates/wip-tui/src/app.rs +++ b/crates/wip-tui/src/app.rs @@ -381,7 +381,8 @@ impl App { operation: editor.operation.name.clone(), arguments, }; - self.status = "operation dispatched; automatic retry is disabled".into(); + self.status = + "operation confirmed; preparing request (not yet dispatched)".into(); return vec![action]; } _ => { @@ -569,6 +570,7 @@ impl App { pub fn request_dispatched(&mut self, id: u64) { if let Some(InFlightKind::Operation { dispatched, .. }) = self.in_flight.get_mut(&id) { *dispatched = true; + self.status = "operation dispatched once; automatic retry is disabled".into(); } } diff --git a/crates/wip-tui/src/render.rs b/crates/wip-tui/src/render.rs index 1089295..5408324 100644 --- a/crates/wip-tui/src/render.rs +++ b/crates/wip-tui/src/render.rs @@ -78,7 +78,13 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) { } fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) { - let rows = app.visible_paths().into_iter().map(|path| { + let mut paths = app.visible_paths(); + let selected_index = paths + .iter() + .position(|path| path == &app.selected_path) + .unwrap_or(0); + let (start, end) = list_window(paths.len(), selected_index, area.height.saturating_sub(2)); + let rows = paths.drain(start..end).map(|path| { let node = app.entries.get(&path); let selected = path == app.selected_path; let depth = if path == "/" { @@ -191,7 +197,10 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) { fn render_operations(frame: &mut Frame<'_>, area: Rect, app: &App) { let rows = app.operation_rows(); - let items = rows.iter().enumerate().map(|(index, row)| { + let selected_index = app.operation_cursor.min(rows.len().saturating_sub(1)); + let (start, end) = list_window(rows.len(), selected_index, area.height.saturating_sub(2)); + let items = rows[start..end].iter().enumerate().map(|(offset, row)| { + let index = start + offset; let (text, style) = operation_line(app, row); let selected = app.focus == Focus::Operations && index == app.operation_cursor; ListItem::new(text).style(if selected { @@ -280,34 +289,44 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) { } fn render_history(frame: &mut Frame<'_>, area: Rect, app: &App) { - let items = app.history.iter().enumerate().map(|(index, item)| { - let label = match item.kind { - HistoryKind::Success => "SUCCESS", - HistoryKind::DomainFailure => "HOST FAILURE", - HistoryKind::ProtocolError => "PROTOCOL ERROR", - HistoryKind::TransportFailure => "TRANSPORT FAILURE", - HistoryKind::ClientFailure => "CLIENT FAILURE", - HistoryKind::OutcomeUnknown => "OUTCOME UNKNOWN", - HistoryKind::Information => "INFO", - }; - let link = item - .entry_target - .as_ref() - .map_or("", |_| " [Enter: go to Entry]"); - let text = format!( - "[{label}] {}: {}{link}", - sanitize(&item.title), - sanitize(&item.detail) - ); - let selected = app.focus == Focus::History && index == app.history_cursor; - ListItem::new(text).style(if selected { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else if item.kind == HistoryKind::OutcomeUnknown { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - } else { - Style::default() - }) - }); + let selected_index = app.history_cursor.min(app.history.len().saturating_sub(1)); + let (start, end) = list_window( + app.history.len(), + selected_index, + area.height.saturating_sub(2), + ); + let items = app.history[start..end] + .iter() + .enumerate() + .map(|(offset, item)| { + let index = start + offset; + let label = match item.kind { + HistoryKind::Success => "SUCCESS", + HistoryKind::DomainFailure => "HOST FAILURE", + HistoryKind::ProtocolError => "PROTOCOL ERROR", + HistoryKind::TransportFailure => "TRANSPORT FAILURE", + HistoryKind::ClientFailure => "CLIENT FAILURE", + HistoryKind::OutcomeUnknown => "OUTCOME UNKNOWN", + HistoryKind::Information => "INFO", + }; + let link = item + .entry_target + .as_ref() + .map_or("", |_| " [Enter: go to Entry]"); + let text = format!( + "[{label}] {}: {}{link}", + sanitize(&item.title), + sanitize(&item.detail) + ); + let selected = app.focus == Focus::History && index == app.history_cursor; + ListItem::new(text).style(if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else if item.kind == HistoryKind::OutcomeUnknown { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + Style::default() + }) + }); let block = focused_block( " Recent results / diagnostics ", app.focus == Focus::History, @@ -425,6 +444,19 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) { } } +fn list_window(total: usize, selected: usize, area_height: u16) -> (usize, usize) { + let capacity = usize::from(area_height); + if total == 0 || capacity == 0 { + return (0, 0); + } + let selected = selected.min(total - 1); + let start = selected + .saturating_add(1) + .saturating_sub(capacity) + .min(total.saturating_sub(capacity)); + (start, start.saturating_add(capacity).min(total)) +} + fn focused_block(title: &'static str, focused: bool) -> Block<'static> { let style = if focused { Style::default().fg(Color::Cyan) @@ -684,6 +716,99 @@ mod tests { assert!(snapshot.contains("ERROR: expected integer")); } + #[test] + fn list_panes_keep_the_active_row_inside_the_bounded_viewport() { + let mut app = App::new("https://example.test/wip/"); + app.connection = ConnectionState::Connected; + + let children = (0..40) + .map(|index| format!("/tree-row-{index:02}")) + .collect::>(); + { + let root = app.entries.get_mut("/").unwrap(); + root.children = children.clone(); + root.tree_state = ViewState::Fresh; + root.expanded = true; + } + for child in &children { + app.entries.insert( + child.clone(), + EntryView { + path: child.clone(), + object: None, + state: ViewState::Unloaded, + children: vec![], + tree_state: ViewState::Unloaded, + expanded: false, + truncated: false, + limit: 1, + }, + ); + } + app.selected_path = children.last().unwrap().clone(); + app.focus = Focus::Tree; + let snapshot = rendered(&app, 100, 30); + let tree_pane = snapshot + .lines() + .map(|line| line.chars().take(38).collect::()) + .collect::>() + .join("\n"); + assert!(tree_pane.contains("tree-row-39")); + + app.selected_path = "/".into(); + app.apply_object(ObjectObservation { + path: "/".into(), + object: Some(Object { + name: String::new(), + description: None, + interfaces: vec!["many".into()], + r#ref: None, + validator: None, + }), + validator: None, + state: ObservationState::Fresh, + }); + let operations = (0..20) + .map(|index| OperationDeclaration { + name: format!("operation-{index:02}"), + documentation: None, + parameters: vec![], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Unit, + }, + }) + .collect::>(); + app.apply_interface(InterfaceObservation { + reference: "many".into(), + descriptor: Some(InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![], + operations, + }), + validator: None, + state: ObservationState::Fresh, + }); + app.focus = Focus::Operations; + app.operation_cursor = app.operation_rows().len() - 1; + let snapshot = rendered(&app, 100, 30); + assert!(snapshot.contains("(many, operation-19)")); + + for index in 0..20 { + app.push_history(HistoryItem { + kind: HistoryKind::Information, + title: format!("history-{index:02}"), + detail: "diagnostic".into(), + entry_target: None, + }); + } + app.focus = Focus::History; + app.history_cursor = app.history.len() - 1; + let snapshot = rendered(&app, 100, 30); + assert!(snapshot.contains("[INFO] history-00")); + } + #[test] fn tiny_terminal_has_a_safe_fallback() { let snapshot = rendered(&App::new("https://example.test/"), 30, 8); diff --git a/crates/wip-tui/src/runtime.rs b/crates/wip-tui/src/runtime.rs index fafe15d..c392a48 100644 --- a/crates/wip-tui/src/runtime.rs +++ b/crates/wip-tui/src/runtime.rs @@ -419,6 +419,7 @@ impl Runtime { let prepared = match self.transport.prepare(request.request) { Ok(prepared) => prepared, Err(error) => { + let failure_detail = sanitize(&error.detail); let result = self.client.fail_transport(id, error.detail); self.app.request_finished(id.get()); match result { @@ -428,6 +429,9 @@ impl Runtime { self.sync_kind(Some(&kind)); } } + if matches!(&kind, InFlightKind::Tree(path) if path == "/") { + self.fail_connection(&failure_detail); + } self.reconcile_authoritative(); return; } @@ -567,7 +571,7 @@ impl Runtime { self.push_client_error(kind.as_ref(), &error); self.sync_kind(kind.as_ref()); if matches!(kind, Some(InFlightKind::Tree(ref path)) if path == "/") { - self.app.connection = ConnectionState::Failed(sanitize(&error.to_string())); + self.fail_connection(&sanitize(&error.to_string())); } } } @@ -604,6 +608,9 @@ impl Runtime { entry_target: None, }); self.sync_kind(kind); + if matches!(kind, Some(InFlightKind::Tree(path)) if path == "/") { + self.fail_connection(&sanitize(&error.message)); + } } Completion::StaleResponseRejected(id) => { self.app.push_history(HistoryItem { @@ -624,6 +631,16 @@ impl Runtime { record.context.operation().name, record.context.object().path ); + self.app.status = match &record.outcome { + CallOutcome::Success(_) => "operation completed".into(), + CallOutcome::ProtocolFailure(_) => { + "operation failed; automatic retry was not attempted".into() + } + CallOutcome::Unknown { .. } => { + "operation outcome unknown; refresh before deciding again".into() + } + CallOutcome::NotDispatched(_) => "operation not dispatched; not executed".into(), + }; match &record.outcome { CallOutcome::Success(response) => { let entries = collect_entries( @@ -705,6 +722,11 @@ impl Runtime { }); } + fn fail_connection(&mut self, detail: &str) { + self.app.connection = ConnectionState::Failed(detail.into()); + self.app.status = "connection failed; press R to reconnect".into(); + } + fn local_error(&mut self, title: &str, error: ClientError) { self.app.push_history(HistoryItem { kind: error_history_kind(&error), @@ -714,7 +736,7 @@ impl Runtime { }); self.app.status = format!("{title} failed; see diagnostics"); if title == "connect" { - self.app.connection = ConnectionState::Failed(sanitize(&error.to_string())); + self.fail_connection(&sanitize(&error.to_string())); } } diff --git a/crates/wip-tui/tests/runtime_async.rs b/crates/wip-tui/tests/runtime_async.rs index adf63b4..b762aed 100644 --- a/crates/wip-tui/tests/runtime_async.rs +++ b/crates/wip-tui/tests/runtime_async.rs @@ -8,7 +8,7 @@ use wip_fs_server::{ServerConfig, WipFsServer}; use wip_https::encode_protocol_error_response; use wip_https::http::{Request, Response}; use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; -use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState}; +use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState}; use wip_tui::config::Config; use wip_tui::runtime::{ Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits, @@ -20,7 +20,9 @@ struct ScriptedTransport { delayed_after_startup: bool, unknown_calls: bool, fail_call_prepare: bool, + fail_tree_prepare: bool, panic_calls: bool, + root_tree_protocol_failures: Arc, } impl ScriptedTransport { @@ -31,7 +33,9 @@ impl ScriptedTransport { delayed_after_startup: false, unknown_calls: false, fail_call_prepare: false, + fail_tree_prepare: false, panic_calls: false, + root_tree_protocol_failures: Arc::new(AtomicUsize::new(0)), } } } @@ -46,11 +50,35 @@ impl Transport for ScriptedTransport { detail: "scripted local conversion failure".into(), }); } + if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") { + return Err(TransportError { + kind: TransportErrorKind::Disconnect, + detail: "scripted tree conversion failure".into(), + }); + } Ok(request) } fn execute(&self, request: Self::Prepared) -> Result>, TransportError> { let sequence = self.sequence.fetch_add(1, Ordering::AcqRel); + if request.uri().path().ends_with("/v1/fetch_tree") + && self + .root_tree_protocol_failures + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Ok(encode_protocol_error_response( + ProtocolInteraction::FetchTree, + &ProtocolError { + code: ProtocolErrorCode::Internal, + message: "scripted startup failure".into(), + }, + default_wire_limits(), + ) + .expect("encode tree failure")); + } if self.delayed_after_startup && sequence > 0 { // Each refresh starts object then tree. Make the first pair lose to // the second pair to exercise exact RequestId ownership. @@ -123,6 +151,56 @@ fn out_of_order_refresh_and_cancel_keep_request_identity_safe() { assert!(runtime.app().operation_rows().is_empty()); } +#[test] +fn startup_transport_preparation_failure_enters_failed_state() { + let temporary = tempdir().expect("temporary root"); + let server = Arc::new( + WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"), + ); + let mut transport = ScriptedTransport::new(server); + transport.fail_tree_prepare = true; + let config = config(); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + + runtime.connect(); + settle(&mut runtime); + assert!(matches!( + runtime.app().connection, + ConnectionState::Failed(ref detail) if detail.contains("scripted tree conversion failure") + )); + assert!(runtime.app().status.contains("press R to reconnect")); + assert!(runtime.app().in_flight.is_empty()); +} + +#[test] +fn startup_protocol_failure_enters_failed_state_and_reconnects() { + let temporary = tempdir().expect("temporary root"); + let server = Arc::new( + WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"), + ); + let transport = ScriptedTransport::new(server); + transport + .root_tree_protocol_failures + .store(1, Ordering::Release); + let config = config(); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + + runtime.connect(); + settle(&mut runtime); + assert!(matches!( + runtime.app().connection, + ConnectionState::Failed(ref detail) if detail.contains("scripted startup failure") + )); + assert!(runtime.app().status.contains("press R to reconnect")); + assert!(runtime.app().history.iter().any(|item| { + item.kind == HistoryKind::DomainFailure && item.detail.contains("scripted startup failure") + })); + + runtime.handle_key(InputKey::Reconnect); + settle(&mut runtime); + assert_eq!(runtime.app().connection, ConnectionState::Connected); +} + #[test] fn local_transport_preparation_failure_is_definitely_not_dispatched() { let temporary = tempdir().expect("temporary root"); @@ -150,6 +228,10 @@ fn local_transport_preparation_failure_is_definitely_not_dispatched() { assert!(runtime.app().in_flight.is_empty()); assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure); assert!(runtime.app().history[0].detail.contains("not dispatched")); + assert_eq!( + runtime.app().status, + "operation not dispatched; not executed" + ); assert_eq!(runtime.app().entries["/file"].state, ViewState::Fresh); }