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(),
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();
}
}
+128 -3
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) {
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,7 +289,17 @@ 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 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",
@@ -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::<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]
fn tiny_terminal_has_a_safe_fallback() {
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) {
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<T: Transport> Runtime<T> {
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<T: Transport> Runtime<T> {
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<T: Transport> Runtime<T> {
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<T: Transport> Runtime<T> {
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<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) {
self.app.push_history(HistoryItem {
kind: error_history_kind(&error),
@@ -714,7 +736,7 @@ impl<T: Transport> Runtime<T> {
});
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()));
}
}
+83 -1
View File
@@ -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<AtomicUsize>,
}
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<Response<Vec<u8>>, 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);
}