fix: align tui status and list viewports

This commit is contained in:
2026-09-30 10:51:35 +09:00
parent db2a51c824
commit de82b66de4
4 changed files with 265 additions and 34 deletions
+3 -1
View File
@@ -381,7 +381,8 @@ impl App {
operation: editor.operation.name.clone(), operation: editor.operation.name.clone(),
arguments, arguments,
}; };
self.status = "operation dispatched; automatic retry is disabled".into(); self.status =
"operation confirmed; preparing request (not yet dispatched)".into();
return vec![action]; return vec![action];
} }
_ => { _ => {
@@ -569,6 +570,7 @@ impl App {
pub fn request_dispatched(&mut self, id: u64) { pub fn request_dispatched(&mut self, id: u64) {
if let Some(InFlightKind::Operation { dispatched, .. }) = self.in_flight.get_mut(&id) { if let Some(InFlightKind::Operation { dispatched, .. }) = self.in_flight.get_mut(&id) {
*dispatched = true; *dispatched = true;
self.status = "operation dispatched once; automatic retry is disabled".into();
} }
} }
+155 -30
View File
@@ -78,7 +78,13 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) {
} }
fn render_tree(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 node = app.entries.get(&path);
let selected = path == app.selected_path; let selected = path == app.selected_path;
let depth = if 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) { fn render_operations(frame: &mut Frame<'_>, area: Rect, app: &App) {
let rows = app.operation_rows(); 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 (text, style) = operation_line(app, row);
let selected = app.focus == Focus::Operations && index == app.operation_cursor; let selected = app.focus == Focus::Operations && index == app.operation_cursor;
ListItem::new(text).style(if selected { 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) { fn render_history(frame: &mut Frame<'_>, area: Rect, app: &App) {
let items = app.history.iter().enumerate().map(|(index, item)| { let selected_index = app.history_cursor.min(app.history.len().saturating_sub(1));
let label = match item.kind { let (start, end) = list_window(
HistoryKind::Success => "SUCCESS", app.history.len(),
HistoryKind::DomainFailure => "HOST FAILURE", selected_index,
HistoryKind::ProtocolError => "PROTOCOL ERROR", area.height.saturating_sub(2),
HistoryKind::TransportFailure => "TRANSPORT FAILURE", );
HistoryKind::ClientFailure => "CLIENT FAILURE", let items = app.history[start..end]
HistoryKind::OutcomeUnknown => "OUTCOME UNKNOWN", .iter()
HistoryKind::Information => "INFO", .enumerate()
}; .map(|(offset, item)| {
let link = item let index = start + offset;
.entry_target let label = match item.kind {
.as_ref() HistoryKind::Success => "SUCCESS",
.map_or("", |_| " [Enter: go to Entry]"); HistoryKind::DomainFailure => "HOST FAILURE",
let text = format!( HistoryKind::ProtocolError => "PROTOCOL ERROR",
"[{label}] {}: {}{link}", HistoryKind::TransportFailure => "TRANSPORT FAILURE",
sanitize(&item.title), HistoryKind::ClientFailure => "CLIENT FAILURE",
sanitize(&item.detail) HistoryKind::OutcomeUnknown => "OUTCOME UNKNOWN",
); HistoryKind::Information => "INFO",
let selected = app.focus == Focus::History && index == app.history_cursor; };
ListItem::new(text).style(if selected { let link = item
Style::default().fg(Color::Black).bg(Color::Cyan) .entry_target
} else if item.kind == HistoryKind::OutcomeUnknown { .as_ref()
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) .map_or("", |_| " [Enter: go to Entry]");
} else { let text = format!(
Style::default() "[{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( let block = focused_block(
" Recent results / diagnostics ", " Recent results / diagnostics ",
app.focus == Focus::History, 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> { fn focused_block(title: &'static str, focused: bool) -> Block<'static> {
let style = if focused { let style = if focused {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
@@ -684,6 +716,99 @@ mod tests {
assert!(snapshot.contains("ERROR: expected integer")); 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::<Vec<_>>();
{
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::<String>())
.collect::<Vec<_>>()
.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::<Vec<_>>();
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] #[test]
fn tiny_terminal_has_a_safe_fallback() { fn tiny_terminal_has_a_safe_fallback() {
let snapshot = rendered(&App::new("https://example.test/"), 30, 8); let snapshot = rendered(&App::new("https://example.test/"), 30, 8);
+24 -2
View File
@@ -419,6 +419,7 @@ impl<T: Transport> Runtime<T> {
let prepared = match self.transport.prepare(request.request) { let prepared = match self.transport.prepare(request.request) {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(error) => { Err(error) => {
let failure_detail = sanitize(&error.detail);
let result = self.client.fail_transport(id, error.detail); let result = self.client.fail_transport(id, error.detail);
self.app.request_finished(id.get()); self.app.request_finished(id.get());
match result { match result {
@@ -428,6 +429,9 @@ impl<T: Transport> Runtime<T> {
self.sync_kind(Some(&kind)); self.sync_kind(Some(&kind));
} }
} }
if matches!(&kind, InFlightKind::Tree(path) if path == "/") {
self.fail_connection(&failure_detail);
}
self.reconcile_authoritative(); self.reconcile_authoritative();
return; return;
} }
@@ -567,7 +571,7 @@ impl<T: Transport> Runtime<T> {
self.push_client_error(kind.as_ref(), &error); self.push_client_error(kind.as_ref(), &error);
self.sync_kind(kind.as_ref()); self.sync_kind(kind.as_ref());
if matches!(kind, Some(InFlightKind::Tree(ref path)) if path == "/") { 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<T: Transport> Runtime<T> {
entry_target: None, entry_target: None,
}); });
self.sync_kind(kind); self.sync_kind(kind);
if matches!(kind, Some(InFlightKind::Tree(path)) if path == "/") {
self.fail_connection(&sanitize(&error.message));
}
} }
Completion::StaleResponseRejected(id) => { Completion::StaleResponseRejected(id) => {
self.app.push_history(HistoryItem { self.app.push_history(HistoryItem {
@@ -624,6 +631,16 @@ impl<T: Transport> Runtime<T> {
record.context.operation().name, record.context.operation().name,
record.context.object().path 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 { match &record.outcome {
CallOutcome::Success(response) => { CallOutcome::Success(response) => {
let entries = collect_entries( let entries = collect_entries(
@@ -705,6 +722,11 @@ impl<T: Transport> Runtime<T> {
}); });
} }
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) { fn local_error(&mut self, title: &str, error: ClientError) {
self.app.push_history(HistoryItem { self.app.push_history(HistoryItem {
kind: error_history_kind(&error), kind: error_history_kind(&error),
@@ -714,7 +736,7 @@ impl<T: Transport> Runtime<T> {
}); });
self.app.status = format!("{title} failed; see diagnostics"); self.app.status = format!("{title} failed; see diagnostics");
if title == "connect" { if title == "connect" {
self.app.connection = ConnectionState::Failed(sanitize(&error.to_string())); self.fail_connection(&sanitize(&error.to_string()));
} }
} }
+83 -1
View File
@@ -8,7 +8,7 @@ use wip_fs_server::{ServerConfig, WipFsServer};
use wip_https::encode_protocol_error_response; use wip_https::encode_protocol_error_response;
use wip_https::http::{Request, Response}; use wip_https::http::{Request, Response};
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; 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::config::Config;
use wip_tui::runtime::{ use wip_tui::runtime::{
Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits, Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits,
@@ -20,7 +20,9 @@ struct ScriptedTransport {
delayed_after_startup: bool, delayed_after_startup: bool,
unknown_calls: bool, unknown_calls: bool,
fail_call_prepare: bool, fail_call_prepare: bool,
fail_tree_prepare: bool,
panic_calls: bool, panic_calls: bool,
root_tree_protocol_failures: Arc<AtomicUsize>,
} }
impl ScriptedTransport { impl ScriptedTransport {
@@ -31,7 +33,9 @@ impl ScriptedTransport {
delayed_after_startup: false, delayed_after_startup: false,
unknown_calls: false, unknown_calls: false,
fail_call_prepare: false, fail_call_prepare: false,
fail_tree_prepare: false,
panic_calls: 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(), 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) Ok(request)
} }
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> { fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel); 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 { if self.delayed_after_startup && sequence > 0 {
// Each refresh starts object then tree. Make the first pair lose to // Each refresh starts object then tree. Make the first pair lose to
// the second pair to exercise exact RequestId ownership. // 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()); 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] #[test]
fn local_transport_preparation_failure_is_definitely_not_dispatched() { fn local_transport_preparation_failure_is_definitely_not_dispatched() {
let temporary = tempdir().expect("temporary root"); 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!(runtime.app().in_flight.is_empty());
assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure); assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure);
assert!(runtime.app().history[0].detail.contains("not dispatched")); 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); assert_eq!(runtime.app().entries["/file"].state, ViewState::Fresh);
} }