feat(tui): reconcile scoped selection and publication lifetimes

Reuse canonical escaped absolute reference labels; retain structured operation selection across loading and row movement. Freeze editor targets and retire confirmation on Client observation invalidation. Mirror retained stale/loading Object, tree and Interface state without ref fabrication or automatic execution.

Validation: 36 TUI unit tests, real filesystem HTTP workflow, 8 formatter and 6 asynchronous runtime tests, and 2 doc-tests pass. Full workspace fmt/check/all-target tests/doc-tests, Nix clippy -D warnings, rustdoc -D warnings, public example and diff check pass. Scoped regressions cover same-name dispatch, stale/ref-less/replaced/deleted scopes, old in-flight contexts and selective typed-entry observation.
This commit is contained in:
2026-10-08 04:03:51 +09:00
parent fc52d72a01
commit 84cb5904df
7 changed files with 933 additions and 57 deletions
+45 -8
View File
@@ -91,10 +91,29 @@ these labels. The output has no extra outer signature string quoting.
Keep selection, cache and calls keyed by structured references and operation Keep selection, cache and calls keyed by structured references and operation
names, never display text. No parser, scope inference, identity reconstruction, names, never display text. No parser, scope inference, identity reconstruction,
Object/Interface signature renderer or new crate is introduced. Membership, Object/Interface signature renderer or new crate is introduced. Membership,
ancestry, authorization and freshness remain runtime concerns. Screen integration ancestry, authorization and freshness remain runtime concerns. Normal Entry,
is follow-up T-23; the current bounded screen/value/diagnostic rendering is Interface/Operation, editor, confirmation and operation-history screens use these
unchanged. At integration, apply existing UI layout bounds to display labels qualified labels with existing bounded layout. Shortened labels never feed back
without feeding shortened labels back into identity or calls. into identity or calls.
### Rust API and wire migration
Interface identities in the presentation map, action/selection rows, in-flight
subjects and editors are now `InterfaceReference { scope, name }`, not strings.
This is a breaking Rust API change; consumers must construct an explicit canonical
absolute scope rather than parse a label or infer a scope from a target Entry.
`ArgumentEditor` also retains its Entry `path`; `new` defaults to `/`, and
`for_target(path)` fixes the chosen target before editing. Normal UI selection
sets it automatically. Confirmation never retargets an existing editor.
`App::operation_selection` retains the Entry and structured row through temporary
loading gaps; the numeric cursor is only a layout position. Retaining this subject
does not expose stale operations or authorize execution.
The TUI uses the scoped Protocol/HTTP Client wire: Interface references are
structured, and calls use an Interface target carrying the reference and optional
`scope_ref`/validator preconditions. The optional identity is copied by the Client,
not invented by the UI; no additional required ref is introduced. This wire is not
compatible with the former string-reference request/response shape.
## Keyboard bindings ## Keyboard bindings
@@ -128,10 +147,12 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
interfaces, and freshness. Validators, credentials, Host internals, and backend interfaces, and freshness. Validators, credentials, Host internals, and backend
paths are deliberately hidden. If current metadata disappears, old text is not paths are deliberately hidden. If current metadata disappears, old text is not
presented as current. presented as current.
- **Interfaces / Operations** identifies every operation by `(interface scope, - **Interfaces / Operations** identifies every operation by structured `(scope,
interface name, operation name)` and explicitly displays `scope=…` and `name=…`. interface name, operation name)`, displaying `scope::interface#operation` with
Structured references remain intact in selection, acquisition, and call state; explicit absolute scope and safely quoted components. Structured references
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the remain intact in selection, acquisition, and call state; display labels are
never parsed back into references. Descriptor lookahead follows fresh visible
Objects, shares the
client's in-flight/cache bounds, and never retries retained failures implicitly. client's in-flight/cache bounds, and never retries retained failures implicitly.
The pane shows descriptor documentation, ordered parameters, required/optional The pane shows descriptor documentation, ordered parameters, required/optional
status, and result types. status, and result types.
@@ -158,6 +179,22 @@ Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
the runtime's authoritative transition. the runtime's authoritative transition.
Every Client transition is mirrored into retained UI Object, tree and Interface
state, including scope retirement and cache removal. An open editor/confirmation
is bound to private Object/Interface snapshots (not displayed refs/validators).
Changed, stale, loading, removed or retargeted observations retire the editor;
reobservation never recreates or executes it. Descriptor rows remain identified
by the structured reference when another Interface gains or loses operation rows.
Explicitly select/load an Interface header to refresh a retained stale descriptor,
then select an operation and confirm a new call. Client calls already dispatched
retain their original immutable context and are never moved to a new scope.
Without refs, a same-path replacement with indistinguishable public metadata
cannot always be detected; the UI does not synthesize identity or infer deletion
from missing tree edges. Optional refs remain optional. Typed `Entry` results are
only observed when individually selected; the UI neither bulk-fetches result
paths nor adds guessed parent/indexable edges.
For an unknown outcome, the UI states that execution or side effects may have For an unknown outcome, the UI states that execution or side effects may have
occurred, marks the related Entry stale, and tells the user to refresh. Refreshing occurred, marks the related Entry stale, and tells the user to refresh. Refreshing
does not recreate the call: the user must select the operation, review fresh input, does not recreate the call: the user must select the operation, review fresh input,
+49 -6
View File
@@ -280,6 +280,9 @@ pub struct App {
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>, pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
/// Selected row in the flattened interface/operation view. /// Selected row in the flattened interface/operation view.
pub operation_cursor: usize, pub operation_cursor: usize,
/// Retained Entry and structured row while current operation rows are hidden.
/// This is selection identity only, never authority to execute a stale row.
pub operation_selection: Option<(String, OperationRow)>,
/// Selected recent-history row. /// Selected recent-history row.
pub history_cursor: usize, pub history_cursor: usize,
/// Active overlay. /// Active overlay.
@@ -310,6 +313,7 @@ impl App {
focus: Focus::Tree, focus: Focus::Tree,
interfaces: BTreeMap::new(), interfaces: BTreeMap::new(),
operation_cursor: 0, operation_cursor: 0,
operation_selection: None,
history_cursor: 0, history_cursor: 0,
modal: Modal::None, modal: Modal::None,
in_flight: BTreeMap::new(), in_flight: BTreeMap::new(),
@@ -362,7 +366,7 @@ impl App {
} }
InputKey::Enter | InputKey::Character('e') => { InputKey::Enter | InputKey::Character('e') => {
let action = Action::Execute { let action = Action::Execute {
path: self.selected_path.clone(), path: editor.path.clone(),
interface: editor.interface.clone(), interface: editor.interface.clone(),
operation: editor.operation.name.clone(), operation: editor.operation.name.clone(),
arguments, arguments,
@@ -464,17 +468,21 @@ impl App {
if let Some(row) = rows.get(self.operation_cursor) { if let Some(row) = rows.get(self.operation_cursor) {
if let Some(operation_name) = &row.operation { if let Some(operation_name) = &row.operation {
if let Some(interface) = self.interfaces.get(&row.interface) if let Some(interface) = self.interfaces.get(&row.interface)
&& interface.state == ViewState::Fresh
&& let Some(descriptor) = &interface.descriptor && let Some(descriptor) = &interface.descriptor
&& let Some(operation) = descriptor && let Some(operation) = descriptor
.operations .operations
.iter() .iter()
.find(|operation| operation.name == *operation_name) .find(|operation| operation.name == *operation_name)
{ {
self.modal = Modal::Editor(ArgumentEditor::new( self.modal = Modal::Editor(
row.interface.clone(), ArgumentEditor::new(
descriptor.clone(), row.interface.clone(),
operation.clone(), descriptor.clone(),
)); operation.clone(),
)
.for_target(self.selected_path.clone()),
);
} }
} else { } else {
return vec![Action::FetchInterface(row.interface.clone())]; return vec![Action::FetchInterface(row.interface.clone())];
@@ -515,6 +523,10 @@ impl App {
/// Records a pending request separately by interaction type. /// Records a pending request separately by interaction type.
pub fn request_started(&mut self, id: u64, kind: InFlightKind) { pub fn request_started(&mut self, id: u64, kind: InFlightKind) {
// Object/Interface loading can remove all rows before completion. Keep
// the exact subject now rather than recovering identity from a cursor.
let selected = self.operation_rows().get(self.operation_cursor).cloned();
self.restore_operation_selection(selected);
match &kind { match &kind {
InFlightKind::Object(path) => { InFlightKind::Object(path) => {
self.ensure_entry(path).state = ViewState::Loading(id); self.ensure_entry(path).state = ViewState::Loading(id);
@@ -683,6 +695,37 @@ impl App {
rows rows
} }
/// Preserves a selected structured subject across descriptor/row movement.
pub(crate) fn restore_operation_selection(&mut self, selected: Option<OperationRow>) {
if let Some(selected) = selected {
self.operation_selection = Some((self.selected_path.clone(), selected));
} else if self
.operation_selection
.as_ref()
.is_some_and(|(path, _)| path != &self.selected_path)
{
self.operation_selection = None;
}
let rows = self.operation_rows();
self.operation_cursor = self
.operation_selection
.as_ref()
.and_then(|(_, selected)| {
rows.iter().position(|row| row == selected).or_else(|| {
rows.iter().position(|row| {
row.interface == selected.interface && row.operation.is_none()
})
})
})
.unwrap_or(0);
// Keep identity through a temporarily empty (loading/stale) view. Once
// rows are current, the retained identity follows the actual selection,
// falling back to its own Interface header if the Operation was retired.
if let Some(row) = rows.get(self.operation_cursor) {
self.operation_selection = Some((self.selected_path.clone(), row.clone()));
}
}
fn ensure_entry(&mut self, path: &str) -> &mut EntryView { fn ensure_entry(&mut self, path: &str) -> &mut EntryView {
if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES { if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES {
let candidate = self let candidate = self
+10
View File
@@ -32,6 +32,8 @@ pub struct InvalidArguments;
/// Pure, terminal-independent editor built from an operation descriptor. /// Pure, terminal-independent editor built from an operation descriptor.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArgumentEditor { pub struct ArgumentEditor {
/// Exact Entry target selected when editing began; never read from display text.
pub path: String,
/// Structured interface reference selected for the call. /// Structured interface reference selected for the call.
pub interface: InterfaceReference, pub interface: InterfaceReference,
/// Selected operation declaration. /// Selected operation declaration.
@@ -68,6 +70,7 @@ impl ArgumentEditor {
}) })
.collect(); .collect();
Self { Self {
path: "/".into(),
interface, interface,
operation, operation,
descriptor, descriptor,
@@ -76,6 +79,13 @@ impl ArgumentEditor {
} }
} }
/// Fixes the Entry target for the editor and subsequent confirmation.
#[must_use]
pub fn for_target(mut self, path: impl Into<String>) -> Self {
self.path = path.into();
self
}
/// Moves to the preceding field without wrapping. /// Moves to the preceding field without wrapping.
pub fn previous_field(&mut self) { pub fn previous_field(&mut self) {
self.cursor = self.cursor.saturating_sub(1); self.cursor = self.cursor.saturating_sub(1);
+8
View File
@@ -1,4 +1,12 @@
//! Presentation and terminal interaction for stateful WIP client sessions. //! Presentation and terminal interaction for stateful WIP client sessions.
//!
//! Interface identity is a structured `wip_protocol::InterfaceReference` with an
//! explicit absolute scope. Display-only [`mod@reference`] labels safely quote
//! `scope::interface#operation`; shortened labels are never parsed for calls.
//! This breaks the former string-reference Rust API and follows the scoped HTTP
//! wire format. Ref and validator preconditions remain private Client metadata.
//! Editors retain their selected Entry target and are retired when the Client's
//! Object/Interface snapshots change; reobservation never executes an operation.
#![deny(missing_docs)] #![deny(missing_docs)]
+118 -18
View File
@@ -11,11 +11,22 @@ use crate::editor::type_name;
const MAX_RENDER_TEXT: usize = 512; const MAX_RENDER_TEXT: usize = 512;
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String { /// Bounded presentation only; invalid references are never repaired or inferred.
format!( pub(crate) fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
"scope={}, name={}", sanitize(
sanitize(&reference.scope), &crate::reference::format_interface_reference(reference)
sanitize(&reference.name) .unwrap_or_else(|_| "(invalid interface reference)".into()),
)
}
/// The exact structured subject remains separate from this shortened label.
pub(crate) fn operation_label(
reference: &wip_protocol::InterfaceReference,
operation: &str,
) -> String {
sanitize(
&crate::reference::format_qualified_operation(reference, operation)
.unwrap_or_else(|_| "(invalid operation reference)".into()),
) )
} }
@@ -232,7 +243,7 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
.find(|operation| operation.name == *name) .find(|operation| operation.name == *name)
}); });
let signature = declaration.map_or_else( let signature = declaration.map_or_else(
|| format!(" ({}, {})", interface_label(&row.interface), name), || format!(" {}", operation_label(&row.interface, name)),
|operation| { |operation| {
let parameters = operation let parameters = operation
.parameters .parameters
@@ -253,9 +264,8 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
.map(|documentation| format!(" — {}", documentation.summary)) .map(|documentation| format!(" — {}", documentation.summary))
.unwrap_or_default(); .unwrap_or_default();
format!( format!(
" ({}, {}) ({parameters}) -> {}{}", " {} ({parameters}) -> {}{}",
interface_label(&row.interface), operation_label(&row.interface, name),
name,
type_name(&operation.returns.r#type), type_name(&operation.returns.r#type),
documentation documentation
) )
@@ -361,10 +371,9 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
let area = centered(frame.area(), 86, 78); let area = centered(frame.area(), 86, 78);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let mut lines = vec![Line::from(format!( let mut lines = vec![Line::from(format!(
"Target: {} Operation: ({}, {})", "Target: {} Operation: {}",
sanitize(&app.selected_path), sanitize(&editor.path),
interface_label(&editor.interface), operation_label(&editor.interface, &editor.operation.name)
sanitize(&editor.operation.name)
))]; ))];
if editor.fields.is_empty() { if editor.fields.is_empty() {
lines.push(Line::from("(no parameters)")); lines.push(Line::from("(no parameters)"));
@@ -412,9 +421,12 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Line::from(format!("Entry: {}", sanitize(&app.selected_path))), Line::from(format!("Entry: {}", sanitize(&editor.path))),
Line::from(format!("Interface: {}", interface_label(&editor.interface))), Line::from(format!("Interface: {}", interface_label(&editor.interface))),
Line::from(format!("Operation: {}", sanitize(&editor.operation.name))), Line::from(format!(
"Operation: {}",
operation_label(&editor.interface, &editor.operation.name)
)),
Line::from("Input values:"), Line::from("Input values:"),
]; ];
for (name, value) in arguments { for (name, value) in arguments {
@@ -726,7 +738,7 @@ mod tests {
} }
let snapshot = rendered(&app, 140, 40); let snapshot = rendered(&app, 140, 40);
assert!(snapshot.contains("public root description")); assert!(snapshot.contains("public root description"));
assert!(snapshot.contains("(scope=/, name=demo-interface, run)")); assert!(snapshot.contains("/::demo-interface#run"));
assert!(snapshot.contains("SUCCESS")); assert!(snapshot.contains("SUCCESS"));
assert!(snapshot.contains("PROTOCOL ERROR")); assert!(snapshot.contains("PROTOCOL ERROR"));
assert!(snapshot.contains("TRANSPORT FAILURE")); assert!(snapshot.contains("TRANSPORT FAILURE"));
@@ -822,7 +834,7 @@ mod tests {
app.focus = Focus::Operations; app.focus = Focus::Operations;
app.operation_cursor = app.operation_rows().len() - 1; app.operation_cursor = app.operation_rows().len() - 1;
let snapshot = rendered(&app, 100, 30); let snapshot = rendered(&app, 100, 30);
assert!(snapshot.contains("(scope=/, name=many, operation-19)")); assert!(snapshot.contains("/::many#operation-19"));
for index in 0..20 { for index in 0..20 {
app.push_history(HistoryItem { app.push_history(HistoryItem {
@@ -850,13 +862,101 @@ mod tests {
scope: "/nested".into(), scope: "/nested".into(),
name: "#::操作".into(), name: "#::操作".into(),
}; };
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作"); assert_eq!(interface_label(&reference), "/nested::\"#::操作\"");
assert_ne!( assert_ne!(
interface_label(&reference), interface_label(&reference),
interface_label(&root_reference(&reference.name)) interface_label(&root_reference(&reference.name))
); );
} }
#[test]
fn qualified_labels_are_safely_quoted_on_normal_screens_and_modals() {
let mut app = App::new("https://example.test/wip/");
let local = wip_protocol::InterfaceReference {
scope: "/a::b".into(),
name: "same#name".into(),
};
let root = root_reference(&local.name);
let operation = "run#\n\u{202e}";
let descriptor = InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: operation.into(),
documentation: None,
parameters: vec![],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Unit,
},
}],
};
app.apply_object(ObjectObservation {
path: "/".into(),
object: Some(Object {
name: String::new(),
description: None,
interfaces: vec![root.clone(), local.clone()],
r#ref: Some("hidden-object-ref".into()),
validator: Some(b"hidden-object-validator".to_vec()),
}),
validator: Some(b"hidden-object-validator".to_vec()),
state: ObservationState::Fresh,
});
for reference in [&root, &local] {
app.apply_interface(InterfaceObservation {
reference: reference.clone(),
descriptor: Some(descriptor.clone()),
validator: Some(b"hidden-interface-validator".to_vec()),
scope_ref: Some("hidden-scope-ref".into()),
state: ObservationState::Fresh,
});
}
let root_label = "/::\"same#name\"";
let local_label = "\"/a::b\"::\"same#name\"";
let operation_label = "\"/a::b\"::\"same#name\"#\"run#\\n\\u202e\"";
let normal = rendered(&app, 220, 55);
assert!(normal.contains(root_label));
assert!(normal.contains(local_label));
assert!(normal.contains(operation_label));
app.focus = Focus::Operations;
app.operation_cursor = 3;
app.reduce(crate::app::InputKey::Enter);
let editor = rendered(&app, 220, 55);
assert!(editor.contains(operation_label));
assert!(editor.contains("Argument editor"));
app.reduce(crate::app::InputKey::Enter);
let confirmation = rendered(&app, 220, 55);
assert!(confirmation.contains(operation_label));
assert!(confirmation.contains("EXPLICIT EXECUTION CONFIRMATION"));
for snapshot in [normal, editor, confirmation] {
assert!(!snapshot.contains("hidden-"));
assert!(!snapshot.contains('\u{202e}'));
}
assert!(matches!(
app.reduce(crate::app::InputKey::Enter).as_slice(),
[crate::app::Action::Execute { interface, operation: name, .. }]
if interface == &local && name == operation
));
}
#[test]
fn shortened_and_invalid_labels_never_infer_a_reference() {
let reference = root_reference(&"x".repeat(1000));
assert!(interface_label(&reference).ends_with('…'));
assert_eq!(reference.name.len(), 1000);
let invalid = wip_protocol::InterfaceReference {
scope: "relative".into(),
name: "name".into(),
};
assert_eq!(interface_label(&invalid), "(invalid interface reference)");
assert_eq!(
operation_label(&invalid, "run"),
"(invalid operation reference)"
);
}
#[test] #[test]
fn control_characters_and_large_bytes_are_bounded() { fn control_characters_and_large_bytes_are_bounded() {
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m"); assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
+136 -25
View File
@@ -21,7 +21,7 @@ use crate::app::{
Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal, Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal,
}; };
use crate::config::{Config, ConfigError}; use crate::config::{Config, ConfigError};
use crate::render::{bounded_value, draw, sanitize}; use crate::render::{bounded_value, draw, interface_label, operation_label, sanitize};
use crate::terminal::{TerminalGuard, translate_key}; use crate::terminal::{TerminalGuard, translate_key};
/// Stable transport failure category. /// Stable transport failure category.
@@ -205,6 +205,12 @@ pub struct Runtime<T: Transport> {
sender: mpsc::Sender<WorkerEvent>, sender: mpsc::Sender<WorkerEvent>,
receiver: mpsc::Receiver<WorkerEvent>, receiver: mpsc::Receiver<WorkerEvent>,
ignored: BTreeSet<u64>, ignored: BTreeSet<u64>,
// Private Client snapshots bind an open editor to the observed lifetime,
// including optional refs/validators that must never appear on normal screens.
editor_context: Option<(
wip_client::ObjectObservation,
wip_client::InterfaceObservation,
)>,
} }
impl<T: Transport> Runtime<T> { impl<T: Transport> Runtime<T> {
@@ -231,6 +237,7 @@ impl<T: Transport> Runtime<T> {
sender, sender,
receiver, receiver,
ignored: BTreeSet::new(), ignored: BTreeSet::new(),
editor_context: None,
}) })
} }
@@ -256,7 +263,15 @@ impl<T: Transport> Runtime<T> {
/// Applies a key through the reducer and starts any resulting effects. /// Applies a key through the reducer and starts any resulting effects.
pub fn handle_key(&mut self, key: InputKey) -> bool { pub fn handle_key(&mut self, key: InputKey) -> bool {
let was_editing = matches!(self.app.modal, Modal::Editor(_) | Modal::Confirm { .. });
self.reconcile_authoritative();
if was_editing && matches!(self.app.modal, Modal::None) {
// Do not reinterpret a stale confirmation keystroke as selection
// of another row/scope after the old editor has been retired.
return false;
}
let actions = self.app.reduce(key); let actions = self.app.reduce(key);
self.capture_editor_context();
let mut quit = false; let mut quit = false;
for action in actions { for action in actions {
quit |= self.perform(action); quit |= self.perform(action);
@@ -387,9 +402,14 @@ impl<T: Transport> Runtime<T> {
} }
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) { fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
let selected = self
.app
.operation_rows()
.get(self.app.operation_cursor)
.cloned();
let id = request.id; let id = request.id;
self.app.request_started(id.get(), kind.clone()); self.app.request_started(id.get(), kind.clone());
self.reconcile_authoritative(); self.reconcile_authoritative_with_selection(selected);
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) => {
@@ -511,6 +531,11 @@ impl<T: Transport> Runtime<T> {
} }
fn finish(&mut self, event: WorkerEvent) { fn finish(&mut self, event: WorkerEvent) {
let selected = self
.app
.operation_rows()
.get(self.app.operation_cursor)
.cloned();
let kind = self.app.in_flight.get(&event.id.get()).cloned(); let kind = self.app.in_flight.get(&event.id.get()).cloned();
if kind.is_none() { if kind.is_none() {
return; return;
@@ -549,7 +574,7 @@ impl<T: Transport> Runtime<T> {
} }
} }
} }
self.reconcile_authoritative(); self.reconcile_authoritative_with_selection(selected);
self.reconcile_visible_lookahead(); self.reconcile_visible_lookahead();
} }
@@ -580,9 +605,15 @@ impl<T: Transport> Runtime<T> {
} }
} }
Completion::ProtocolFailure(error) => { Completion::ProtocolFailure(error) => {
let subject = match kind {
Some(InFlightKind::Interface(reference)) => {
format!(" for {}", interface_label(reference))
}
_ => String::new(),
};
self.app.push_history(HistoryItem { self.app.push_history(HistoryItem {
kind: HistoryKind::DomainFailure, kind: HistoryKind::DomainFailure,
title: format!("Host {:?}", error.code), title: format!("Host {:?}{subject}", error.code),
detail: sanitize(&error.message), detail: sanitize(&error.message),
entry_target: None, entry_target: None,
}); });
@@ -605,11 +636,12 @@ impl<T: Transport> Runtime<T> {
fn push_call_history(&mut self, record: &wip_client::CallRecord) { fn push_call_history(&mut self, record: &wip_client::CallRecord) {
let key = format!( let key = format!(
"(scope={}, name={}, {}) on {}", "{} on {}",
sanitize(&record.context.interface().reference.scope), operation_label(
sanitize(&record.context.interface().reference.name), &record.context.interface().reference,
record.context.operation().name, &record.context.operation().name
record.context.object().path ),
sanitize(&record.context.object().path)
); );
self.app.status = match &record.outcome { self.app.status = match &record.outcome {
CallOutcome::Success(_) => "operation completed".into(), CallOutcome::Success(_) => "operation completed".into(),
@@ -690,12 +722,14 @@ impl<T: Transport> Runtime<T> {
let title = match kind { let title = match kind {
Some(InFlightKind::Object(path)) => format!("observe {path}"), Some(InFlightKind::Object(path)) => format!("observe {path}"),
Some(InFlightKind::Tree(path)) => format!("tree {path}"), Some(InFlightKind::Tree(path)) => format!("tree {path}"),
Some(InFlightKind::Interface(reference)) => format!( Some(InFlightKind::Interface(reference)) => {
"interface scope={}, name={}", format!("interface {}", interface_label(reference))
sanitize(&reference.scope), }
sanitize(&reference.name) Some(InFlightKind::Operation {
), interface,
Some(InFlightKind::Operation { operation, .. }) => operation.clone(), operation,
..
}) => operation_label(interface, operation),
None => "request".into(), None => "request".into(),
}; };
self.app.push_history(HistoryItem { self.app.push_history(HistoryItem {
@@ -752,16 +786,87 @@ impl<T: Transport> Runtime<T> {
} }
fn reconcile_authoritative(&mut self) { fn reconcile_authoritative(&mut self) {
self.sync_all_objects(); let selected = self
let evicted_interfaces = self
.app .app
.interfaces .operation_rows()
.keys() .get(self.app.operation_cursor)
.filter(|reference| self.client.interface(&self.session, reference).is_none()) .cloned();
.cloned() self.reconcile_authoritative_with_selection(selected);
.collect::<Vec<_>>(); }
for reference in evicted_interfaces {
self.app.interfaces.remove(&reference); fn reconcile_authoritative_with_selection(
&mut self,
selected: Option<crate::app::OperationRow>,
) {
self.sync_all_objects();
let references = self.app.interfaces.keys().cloned().collect::<Vec<_>>();
for reference in references {
match self.client.interface(&self.session, &reference).cloned() {
Some(observation) => self.app.apply_interface(observation),
None => {
self.app.interfaces.remove(&reference);
}
}
}
let paths = self.app.entries.keys().cloned().collect::<Vec<_>>();
for path in paths {
if let Some(tree) = self.client.tree(&self.session, &path).cloned() {
self.app.apply_tree(tree);
} else if let Some(entry) = self.app.entries.get_mut(&path)
&& !matches!(entry.tree_state, crate::app::ViewState::Unloaded)
{
entry.tree_state = crate::app::ViewState::Stale;
entry.children.clear();
}
}
self.app.restore_operation_selection(selected);
self.reconcile_editor_context();
}
fn capture_editor_context(&mut self) {
let editor = match &self.app.modal {
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
_ => {
self.editor_context = None;
return;
}
};
if self.editor_context.is_none() {
self.editor_context = self.client.object(&self.session, &editor.path).zip(
self.client
.interface(&self.session, &editor.interface)
.cloned(),
);
}
self.reconcile_editor_context();
}
fn reconcile_editor_context(&mut self) {
let editor = match &self.app.modal {
Modal::Editor(editor) | Modal::Confirm { editor, .. } => editor,
_ => {
self.editor_context = None;
return;
}
};
let valid = self
.editor_context
.as_ref()
.is_some_and(|(object, interface)| {
object.path == editor.path
&& self.app.selected_path == editor.path
&& interface.reference == editor.interface
&& interface.descriptor.as_ref() == Some(&editor.descriptor)
&& object.state == wip_client::ObservationState::Fresh
&& interface.state == wip_client::ObservationState::Fresh
&& self.client.object(&self.session, &editor.path).as_ref() == Some(object)
&& self.client.interface(&self.session, &editor.interface) == Some(interface)
});
if !valid {
self.app.modal = Modal::None;
self.editor_context = None;
self.app.status =
"editor retired: target or interface changed; reobserve and select again".into();
} }
} }
@@ -771,7 +876,9 @@ impl<T: Transport> Runtime<T> {
.entries .entries
.iter() .iter()
.filter(|(_, entry)| { .filter(|(_, entry)| {
entry.object.is_some() || self.client.object(&self.session, &entry.path).is_some() entry.object.is_some()
|| entry.state != crate::app::ViewState::Unloaded
|| self.client.object(&self.session, &entry.path).is_some()
}) })
.map(|(path, _)| path.clone()) .map(|(path, _)| path.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -930,6 +1037,10 @@ impl Display for RuntimeError {
impl Error for RuntimeError {} impl Error for RuntimeError {}
#[cfg(test)]
#[path = "runtime_scoped_tests.rs"]
mod scoped_tests;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
+567
View File
@@ -0,0 +1,567 @@
//! UI lifetime regressions driven by real Client/HTTP codec completions.
use super::*;
use std::sync::Mutex;
use wip_client::ObservationState;
use wip_http::{
decode_call_operation_request, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceReference, Object,
ObjectObservation, ObserveRequest, OperationDeclaration, ProtocolError, ProtocolErrorCode,
ProtocolInteraction, ReturnDeclaration,
};
#[derive(Clone, Default)]
struct RecordingTransport(Arc<Mutex<Vec<Request<Vec<u8>>>>>);
impl Transport for RecordingTransport {
type Prepared = Request<Vec<u8>>;
fn prepare(&self, request: Request<Vec<u8>>) -> Result<Self::Prepared, TransportError> {
self.0.lock().unwrap().push(request);
Err(TransportError {
kind: TransportErrorKind::Disconnect,
detail: "deliberate pre-dispatch failure".into(),
})
}
fn execute(&self, _: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
panic!("test transport must never dispatch");
}
}
fn reference(scope: &str) -> InterfaceReference {
InterfaceReference {
scope: scope.into(),
name: "same#::名前".into(),
}
}
fn descriptor() -> InterfaceDescriptor {
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "run#::\n\u{202e}".into(),
documentation: None,
parameters: vec![],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Unit,
},
}],
}
}
fn runtime() -> Runtime<RecordingTransport> {
let config = Config::parse(["https://example.test/wip"], None, None, |_| None).unwrap();
Runtime::new(&config, RecordingTransport::default()).unwrap()
}
fn observe(runtime: &mut Runtime<RecordingTransport>, identity: Option<&str>) {
let request = runtime
.client
.refresh_object(&runtime.session, "/item")
.unwrap();
let response = encode_observe_response(
&ObserveRequest {
path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: Object {
name: "item".into(),
description: None,
interfaces: vec![reference("/"), reference("/item")],
r#ref: identity.map(str::to_owned),
validator: None,
},
children: None,
},
default_wire_limits(),
)
.unwrap();
runtime
.app
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
// Mirror the actual dispatch-time loading transition before the response.
runtime.reconcile_authoritative();
runtime.finish(WorkerEvent {
id: request.id,
response: Ok(response),
});
}
fn fetch(runtime: &mut Runtime<RecordingTransport>, scope: &str, identity: Option<&str>) {
let request = runtime
.client
.prepare_interface(&runtime.session, reference(scope))
.unwrap();
runtime
.app
.request_started(request.id.get(), InFlightKind::Interface(reference(scope)));
runtime.finish(WorkerEvent {
id: request.id,
response: Ok(interface_response(scope, identity)),
});
}
fn interface_response(scope: &str, identity: Option<&str>) -> Response<Vec<u8>> {
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: reference(scope),
},
&FetchInterfaceResponse {
interface: reference(scope),
scope_ref: identity.map(str::to_owned),
descriptor: descriptor(),
validator: None,
},
default_wire_limits(),
)
.unwrap()
}
fn populated() -> Runtime<RecordingTransport> {
let mut runtime = runtime();
observe(&mut runtime, Some("old-scope"));
fetch(&mut runtime, "/", Some("root-scope"));
fetch(&mut runtime, "/item", Some("old-scope"));
runtime.app.navigate_entry("/item");
runtime.app.focus = crate::app::Focus::Operations;
runtime.app.operation_cursor = 3;
runtime
}
fn confirm_local(runtime: &mut Runtime<RecordingTransport>) {
runtime.handle_key(InputKey::Enter);
assert!(matches!(&runtime.app.modal, Modal::Editor(editor)
if editor.interface == reference("/item") && editor.path == "/item"));
runtime.handle_key(InputKey::Enter);
assert!(matches!(runtime.app.modal, Modal::Confirm { .. }));
assert!(runtime.transport.0.lock().unwrap().is_empty());
}
#[test]
fn equal_names_dispatch_exact_structured_scope_only_after_confirmation() {
let mut runtime = populated();
confirm_local(&mut runtime);
runtime.handle_key(InputKey::Enter);
let requests = runtime.transport.0.lock().unwrap();
assert_eq!(requests.len(), 1);
let call =
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
.unwrap();
assert_eq!(call.target.path, "/item");
assert_eq!(call.interface.reference, reference("/item"));
assert_eq!(call.interface.scope_ref.as_deref(), Some("old-scope"));
assert_eq!(call.operation, descriptor().operations[0].name);
assert!(
runtime.app.history[0]
.title
.contains("/item::\"same#::名前\"#")
);
assert!(runtime.app.history[0].title.contains("\\n\\u202e"));
assert!(!runtime.app.history[0].title.contains("old-scope"));
}
#[test]
fn object_loading_preserves_scoped_operation_selection_until_reobservation() {
let mut runtime = populated();
observe(&mut runtime, Some("old-scope"));
let selected = &runtime.app.operation_rows()[runtime.app.operation_cursor];
assert_eq!(selected.interface, reference("/item"));
assert_eq!(
selected.operation.as_deref(),
Some(descriptor().operations[0].name.as_str())
);
assert!(matches!(&runtime.app.operation_selection, Some((path, row))
if path == "/item" && row == selected));
assert!(runtime.transport.0.lock().unwrap().is_empty());
}
#[test]
fn scope_replacement_retires_confirmation_and_requires_explicit_reobservation() {
let mut runtime = populated();
confirm_local(&mut runtime);
observe(&mut runtime, Some("new-scope"));
let local = &runtime.app.interfaces[&reference("/item")];
assert_eq!(local.state, crate::app::ViewState::Stale);
assert!(local.descriptor.is_none());
assert_eq!(
runtime.app.interfaces[&reference("/")].state,
crate::app::ViewState::Fresh
);
assert!(matches!(runtime.app.modal, Modal::None));
assert!(runtime.app.status.contains("editor retired"));
assert_eq!(
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
reference("/item")
);
assert!(
runtime
.client
.ensure_interface(&runtime.session, reference("/item"))
.unwrap()
.is_none()
);
// Refetch without any execution; selecting and confirming again is required.
fetch(&mut runtime, "/item", Some("new-scope"));
assert!(matches!(runtime.app.modal, Modal::None));
assert!(runtime.transport.0.lock().unwrap().is_empty());
runtime.app.operation_cursor = 3;
confirm_local(&mut runtime);
runtime.handle_key(InputKey::Enter);
let requests = runtime.transport.0.lock().unwrap();
let call =
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
.unwrap();
assert_eq!(call.interface.scope_ref.as_deref(), Some("new-scope"));
}
#[test]
fn late_old_fetch_cannot_revive_retired_editor_or_descriptor() {
let mut runtime = populated();
confirm_local(&mut runtime);
let request = runtime
.client
.prepare_interface(&runtime.session, reference("/item"))
.unwrap();
runtime.app.request_started(
request.id.get(),
InFlightKind::Interface(reference("/item")),
);
observe(&mut runtime, Some("new-scope"));
runtime.finish(WorkerEvent {
id: request.id,
response: Ok(interface_response("/item", Some("old-scope"))),
});
assert!(matches!(runtime.app.modal, Modal::None));
assert_ne!(
runtime.app.interfaces[&reference("/item")].state,
crate::app::ViewState::Fresh
);
assert!(
runtime.app.interfaces[&reference("/item")]
.descriptor
.is_none()
);
assert!(runtime.transport.0.lock().unwrap().is_empty());
}
#[test]
fn scope_deletion_and_republication_remove_ui_cache_without_switching_to_root() {
let mut runtime = populated();
confirm_local(&mut runtime);
let request = runtime
.client
.refresh_object(&runtime.session, "/item")
.unwrap();
runtime
.app
.request_started(request.id.get(), InFlightKind::Object("/item".into()));
runtime.finish(WorkerEvent {
id: request.id,
response: Ok(encode_protocol_error_response(
ProtocolInteraction::Observe,
&ProtocolError {
code: ProtocolErrorCode::NotFound,
message: "scope deleted".into(),
},
default_wire_limits(),
)
.unwrap()),
});
assert!(!runtime.app.interfaces.contains_key(&reference("/item")));
assert!(runtime.app.interfaces.contains_key(&reference("/")));
assert!(runtime.app.operation_rows().is_empty());
assert!(matches!(runtime.app.modal, Modal::None));
observe(&mut runtime, Some("republished-scope"));
fetch(&mut runtime, "/item", Some("republished-scope"));
assert!(runtime.transport.0.lock().unwrap().is_empty());
assert!(matches!(runtime.app.modal, Modal::None));
}
#[test]
fn lost_ref_and_retained_stale_descriptor_are_mirrored_without_synthetic_binding() {
let mut runtime = populated();
confirm_local(&mut runtime);
observe(&mut runtime, None);
assert!(matches!(runtime.app.modal, Modal::None));
assert_eq!(
runtime.app.interfaces[&reference("/item")].state,
crate::app::ViewState::Stale
);
fetch(&mut runtime, "/item", None);
runtime.app.operation_cursor = 3;
confirm_local(&mut runtime);
runtime.handle_key(InputKey::Enter);
let requests = runtime.transport.0.lock().unwrap();
let call =
decode_call_operation_request(requests[0].body(), &descriptor(), default_wire_limits())
.unwrap();
assert_eq!(call.interface.scope_ref, None);
}
#[test]
fn ui_mirrors_retained_state_and_preserves_selected_reference_as_rows_shift() {
let mut runtime = populated();
// Fetch of the first Interface temporarily removes its Operation row. The
// selected operation on the second Interface must stay selected by identity.
let request = runtime
.client
.prepare_interface(&runtime.session, reference("/"))
.unwrap();
runtime.reconcile_authoritative();
assert_eq!(
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
reference("/item")
);
assert!(
runtime.app.operation_rows()[runtime.app.operation_cursor]
.operation
.is_some()
);
let error = runtime
.client
.fail_transport(request.id, "test failure")
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Transport);
runtime.reconcile_authoritative();
assert!(matches!(
runtime.app.interfaces[&reference("/")].state,
crate::app::ViewState::Error(_)
));
assert_eq!(
runtime.app.operation_rows()[runtime.app.operation_cursor].interface,
reference("/item")
);
}
#[test]
fn already_dispatched_call_keeps_original_context_across_scope_republication() {
let mut runtime = populated();
let call = runtime
.client
.prepare_call(
&runtime.session,
"/item",
&reference("/item"),
&descriptor().operations[0].name,
std::collections::BTreeMap::new(),
)
.unwrap();
let logical =
decode_call_operation_request(call.request.body(), &descriptor(), default_wire_limits())
.unwrap();
runtime.client.mark_dispatched(call.id).unwrap();
runtime.app.request_started(
call.id.get(),
InFlightKind::Operation {
path: "/item".into(),
interface: reference("/item"),
operation: logical.operation.clone(),
dispatched: true,
},
);
observe(&mut runtime, Some("new-scope"));
let mut new_descriptor = descriptor();
new_descriptor.operations[0].returns.r#type = TypeExpr::String;
let fetch = runtime
.client
.prepare_interface(&runtime.session, reference("/item"))
.unwrap();
runtime
.app
.request_started(fetch.id.get(), InFlightKind::Interface(reference("/item")));
runtime.finish(WorkerEvent {
id: fetch.id,
response: Ok(encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: reference("/item"),
},
&FetchInterfaceResponse {
interface: reference("/item"),
scope_ref: Some("new-scope".into()),
validator: None,
descriptor: new_descriptor.clone(),
},
default_wire_limits(),
)
.unwrap()),
});
runtime.finish(WorkerEvent {
id: call.id,
response: Ok(wip_http::encode_call_operation_response(
&logical,
&descriptor(),
&wip_protocol::CallOperationResponse {
result: Value::Unit,
validator: None,
},
default_wire_limits(),
)
.unwrap()),
});
assert_eq!(runtime.app.history[0].kind, HistoryKind::Success);
assert_eq!(runtime.app.history[0].detail, "null");
assert_eq!(
runtime.app.interfaces[&reference("/item")]
.descriptor
.as_ref(),
Some(&new_descriptor)
);
assert_eq!(logical.interface.scope_ref.as_deref(), Some("old-scope"));
assert!(
runtime.transport.0.lock().unwrap().is_empty(),
"no redispatch or retry"
);
}
#[test]
fn typed_entry_results_are_only_observed_on_selection_without_guessed_edges() {
let mut runtime = populated();
// The original no-validator descriptor is immutable within its scope
// lifetime. Republish the scope before testing a different result schema.
observe(&mut runtime, Some("entry-result-scope"));
let mut descriptor = descriptor();
descriptor.operations[0].returns.r#type = TypeExpr::Record {
fields: vec![
wip_protocol::FieldDeclaration {
name: "entries".into(),
required: true,
documentation: None,
r#type: TypeExpr::List {
items: Box::new(TypeExpr::Entry),
},
},
wip_protocol::FieldDeclaration {
name: "ordinary".into(),
required: true,
documentation: None,
r#type: TypeExpr::String,
},
],
};
let fetch = runtime
.client
.prepare_interface(&runtime.session, reference("/item"))
.unwrap();
let completion = runtime
.client
.complete(
fetch.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: reference("/item"),
},
&FetchInterfaceResponse {
interface: reference("/item"),
scope_ref: Some("entry-result-scope".into()),
validator: None,
descriptor: descriptor.clone(),
},
default_wire_limits(),
)
.unwrap(),
)
.unwrap();
runtime.apply_completion(completion, None);
let call = runtime
.client
.prepare_call(
&runtime.session,
"/item",
&reference("/item"),
&descriptor.operations[0].name,
std::collections::BTreeMap::new(),
)
.unwrap();
let logical =
decode_call_operation_request(call.request.body(), &descriptor, default_wire_limits())
.unwrap();
runtime.client.mark_dispatched(call.id).unwrap();
runtime.app.request_started(
call.id.get(),
InFlightKind::Operation {
path: "/item".into(),
interface: reference("/item"),
operation: descriptor.operations[0].name.clone(),
dispatched: true,
},
);
runtime.finish(WorkerEvent {
id: call.id,
response: Ok(wip_http::encode_call_operation_response(
&logical,
&descriptor,
&wip_protocol::CallOperationResponse {
result: Value::Record(std::collections::BTreeMap::from([
(
"entries".into(),
Value::List(vec![
Value::String("/hidden/a".into()),
Value::String("/hidden/b".into()),
]),
),
("ordinary".into(), Value::String("/not-an-entry".into())),
])),
validator: None,
},
default_wire_limits(),
)
.unwrap()),
});
assert_eq!(
runtime
.app
.history
.iter()
.filter(|item| item.entry_target.is_some())
.count(),
2
);
assert!(!runtime.app.entries.contains_key("/hidden/a"));
assert!(!runtime.app.entries.contains_key("/hidden/b"));
assert!(!runtime.app.entries.contains_key("/not-an-entry"));
assert!(runtime.app.entries["/"].children.is_empty());
assert!(runtime.transport.0.lock().unwrap().is_empty());
runtime.app.focus = crate::app::Focus::History;
runtime.handle_key(InputKey::Enter);
let requests = runtime.transport.0.lock().unwrap();
assert_eq!(
requests.len(),
1,
"only the selected typed Entry is observed"
);
let observe =
wip_http::decode_observe_request(requests[0].body(), default_wire_limits()).unwrap();
assert_eq!(observe.path, "/hidden/b");
assert_eq!(observe.depth, 0);
assert!(
!runtime.app.entries.contains_key("/hidden"),
"no inferred indexable parent"
);
assert!(!runtime.app.entries.contains_key("/hidden/a"));
}
#[test]
fn selection_change_during_confirmation_never_retargets_the_old_editor() {
let mut runtime = populated();
confirm_local(&mut runtime);
runtime.app.selected_path = "/".into();
runtime.handle_key(InputKey::Enter);
assert!(matches!(runtime.app.modal, Modal::None));
assert!(runtime.transport.0.lock().unwrap().is_empty());
assert_eq!(
runtime
.client
.interface(&runtime.session, &reference("/item"))
.unwrap()
.state,
ObservationState::Fresh
);
}