feat: unify object observation API
This commit is contained in:
@@ -80,7 +80,6 @@ All required actions are keyboard-accessible.
|
||||
| `←`/`h` | Collapse a node or move to its parent |
|
||||
| `Tab` | Cycle Tree → Selected Entry → Operations → History |
|
||||
| `r` | Reobserve the selected Entry and its direct subtree |
|
||||
| `+` | Double the selected node limit and retry an incomplete/truncated tree |
|
||||
| `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled |
|
||||
| `R` | Reconnect after connection failure |
|
||||
| `?` | Open help |
|
||||
@@ -94,9 +93,9 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
|
||||
## Screen model
|
||||
|
||||
- **Worldspace tree** starts at `/` and materializes direct children only when a
|
||||
node is expanded. Text labels distinguish unloaded, loading, empty, stale,
|
||||
failed, and truncated state; color is supplemental. A truncated node is never
|
||||
presented as a complete tree.
|
||||
node is expanded. Text labels distinguish unloaded, loading, empty, stale, and
|
||||
failed state; color is supplemental. Every successful expansion is complete for
|
||||
its requested depth.
|
||||
- **Selected Entry** shows only public WIP path, object name, description, ordered
|
||||
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||
|
||||
+10
-50
@@ -31,8 +31,6 @@ pub enum InputKey {
|
||||
Backspace,
|
||||
/// Refresh the selected Entry.
|
||||
Refresh,
|
||||
/// Retry a truncated tree with a larger limit.
|
||||
IncreaseLimit,
|
||||
/// Cancel the newest cancel-safe retrieval.
|
||||
CancelRead,
|
||||
/// Show key help.
|
||||
@@ -123,14 +121,10 @@ pub struct EntryView {
|
||||
pub tree_state: ViewState,
|
||||
/// Whether the user expanded this row.
|
||||
pub expanded: bool,
|
||||
/// Whether the observed child list is incomplete.
|
||||
pub truncated: bool,
|
||||
/// Current bounded expansion limit.
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl EntryView {
|
||||
fn placeholder(path: impl Into<String>, default_limit: u32) -> Self {
|
||||
fn placeholder(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
object: None,
|
||||
@@ -138,8 +132,6 @@ impl EntryView {
|
||||
children: Vec::new(),
|
||||
tree_state: ViewState::Unloaded,
|
||||
expanded: false,
|
||||
truncated: false,
|
||||
limit: default_limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,15 +234,11 @@ pub enum Action {
|
||||
Expand {
|
||||
/// Tree root path.
|
||||
path: String,
|
||||
/// Maximum returned nodes.
|
||||
limit: u32,
|
||||
},
|
||||
/// Reobserve an Entry and its direct children.
|
||||
Refresh {
|
||||
/// Selected path.
|
||||
path: String,
|
||||
/// Current tree limit.
|
||||
limit: u32,
|
||||
},
|
||||
/// Retrieve one interface descriptor.
|
||||
FetchInterface(String),
|
||||
@@ -267,7 +255,7 @@ pub enum Action {
|
||||
},
|
||||
/// Cancel the newest safe retrieval.
|
||||
CancelRead,
|
||||
/// Reconnect and fetch the root.
|
||||
/// Reconnect and observe the root.
|
||||
Reconnect,
|
||||
/// Navigate to a descriptor-declared Entry result.
|
||||
NavigateEntry(String),
|
||||
@@ -302,8 +290,6 @@ pub struct App {
|
||||
pub history: Vec<HistoryItem>,
|
||||
/// Maximum retained presentation history.
|
||||
pub history_limit: usize,
|
||||
/// Default node limit for lazy expansion.
|
||||
pub default_tree_limit: u32,
|
||||
/// One-line status for local action feedback.
|
||||
pub status: String,
|
||||
}
|
||||
@@ -312,9 +298,8 @@ impl App {
|
||||
/// Creates a disconnected model with `/` selected.
|
||||
#[must_use]
|
||||
pub fn new(endpoint: impl Into<String>) -> Self {
|
||||
let default_tree_limit = 64;
|
||||
let mut entries = BTreeMap::new();
|
||||
entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit));
|
||||
entries.insert("/".into(), EntryView::placeholder("/"));
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
connection: ConnectionState::Disconnected,
|
||||
@@ -328,7 +313,6 @@ impl App {
|
||||
in_flight: BTreeMap::new(),
|
||||
history: Vec::new(),
|
||||
history_limit: 64,
|
||||
default_tree_limit,
|
||||
status: "press ? for help".into(),
|
||||
}
|
||||
}
|
||||
@@ -441,14 +425,11 @@ impl App {
|
||||
let node = self
|
||||
.entries
|
||||
.entry(self.selected_path.clone())
|
||||
.or_insert_with(|| {
|
||||
EntryView::placeholder(&self.selected_path, self.default_tree_limit)
|
||||
});
|
||||
.or_insert_with(|| EntryView::placeholder(&self.selected_path));
|
||||
node.expanded = true;
|
||||
if !matches!(node.tree_state, ViewState::Fresh) || node.truncated {
|
||||
if !matches!(node.tree_state, ViewState::Fresh) {
|
||||
return vec![Action::Expand {
|
||||
path: node.path.clone(),
|
||||
limit: node.limit,
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -464,18 +445,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
InputKey::Refresh => return vec![self.refresh_action()],
|
||||
InputKey::IncreaseLimit => {
|
||||
let node = self
|
||||
.entries
|
||||
.get_mut(&self.selected_path)
|
||||
.expect("selected exists");
|
||||
node.limit = node.limit.saturating_mul(2).max(1);
|
||||
node.expanded = true;
|
||||
return vec![Action::Expand {
|
||||
path: node.path.clone(),
|
||||
limit: node.limit,
|
||||
}];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.operation_cursor = 0;
|
||||
@@ -537,13 +506,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn refresh_action(&self) -> Action {
|
||||
let limit = self
|
||||
.entries
|
||||
.get(&self.selected_path)
|
||||
.map_or(self.default_tree_limit, |entry| entry.limit);
|
||||
Action::Refresh {
|
||||
path: self.selected_path.clone(),
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,7 +555,6 @@ impl App {
|
||||
let children = observation.children.clone();
|
||||
let entry = self.ensure_entry(&observation.path);
|
||||
entry.children = children.clone();
|
||||
entry.truncated = observation.truncated;
|
||||
entry.tree_state = ViewState::from(&observation.state);
|
||||
entry.expanded = true;
|
||||
for path in children {
|
||||
@@ -725,7 +688,7 @@ impl App {
|
||||
}
|
||||
self.entries
|
||||
.entry(path.to_owned())
|
||||
.or_insert_with(|| EntryView::placeholder(path, self.default_tree_limit))
|
||||
.or_insert_with(|| EntryView::placeholder(path))
|
||||
}
|
||||
|
||||
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
|
||||
@@ -799,23 +762,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_navigation_expansion_parent_and_limit_are_pure() {
|
||||
fn tree_navigation_and_lazy_expansion_are_pure() {
|
||||
let mut app = App::new("https://example.test/wip");
|
||||
app.apply_tree(TreeObservation {
|
||||
path: "/".into(),
|
||||
children: vec!["/a".into(), "/b".into()],
|
||||
truncated: true,
|
||||
revision: request_id(),
|
||||
state: ObservationState::Fresh,
|
||||
});
|
||||
assert_eq!(app.visible_paths(), ["/", "/a", "/b"]);
|
||||
app.reduce(InputKey::Down);
|
||||
assert_eq!(app.selected_path, "/a");
|
||||
app.reduce(InputKey::Left);
|
||||
assert_eq!(app.selected_path, "/");
|
||||
assert!(matches!(
|
||||
app.reduce(InputKey::IncreaseLimit).as_slice(),
|
||||
[Action::Expand { path, limit: 128 }] if path == "/"
|
||||
app.reduce(InputKey::Right).as_slice(),
|
||||
[Action::Expand { path }] if path == "/a"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -916,6 +876,6 @@ mod tests {
|
||||
wip_client::SecurityContext::new("test"),
|
||||
)
|
||||
.unwrap();
|
||||
client.prepare_fetch(&session, "/").unwrap().id
|
||||
client.prepare_observe(&session, "/", 0).unwrap().id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,13 +113,8 @@ fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
ViewState::Fresh if node.children.is_empty() => " [empty]",
|
||||
_ => "",
|
||||
};
|
||||
let incomplete = if node.truncated {
|
||||
" [truncated: + to increase limit]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let object = state_suffix(&node.state);
|
||||
format!("{object}{tree}{incomplete}")
|
||||
format!("{object}{tree}")
|
||||
},
|
||||
);
|
||||
suffix = sanitize(&suffix);
|
||||
@@ -178,14 +173,6 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||
"No current object metadata; refresh required (old metadata hidden).",
|
||||
));
|
||||
}
|
||||
if entry.truncated {
|
||||
lines.push(Line::styled(
|
||||
"TRUNCATED: this is not a complete subtree.",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
@@ -355,7 +342,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\n+ increase truncated tree limit and retry\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
|
||||
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
|
||||
)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Help "))
|
||||
.wrap(Wrap { trim: false }),
|
||||
@@ -625,7 +612,6 @@ mod tests {
|
||||
fn snapshot_contains_non_color_state_markers_and_hides_validators() {
|
||||
let mut app = App::new("https://example.test/wip/");
|
||||
app.connection = ConnectionState::Connected;
|
||||
app.entries.get_mut("/").unwrap().truncated = true;
|
||||
app.entries.get_mut("/").unwrap().state = ViewState::Stale;
|
||||
app.push_history(HistoryItem {
|
||||
kind: HistoryKind::OutcomeUnknown,
|
||||
@@ -634,7 +620,6 @@ mod tests {
|
||||
entry_target: None,
|
||||
});
|
||||
let snapshot = rendered(&app, 110, 30);
|
||||
assert!(snapshot.contains("truncated"));
|
||||
assert!(snapshot.contains("stale"));
|
||||
assert!(snapshot.contains("OUTCOME UNKNOWN"));
|
||||
assert!(!snapshot.contains("validator"));
|
||||
@@ -740,8 +725,6 @@ mod tests {
|
||||
children: vec![],
|
||||
tree_state: ViewState::Unloaded,
|
||||
expanded: false,
|
||||
truncated: false,
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -832,8 +815,6 @@ mod tests {
|
||||
children: vec![],
|
||||
tree_state: ViewState::Unloaded,
|
||||
expanded: false,
|
||||
truncated: false,
|
||||
limit: 1,
|
||||
};
|
||||
assert_eq!(entry.path, "/");
|
||||
}
|
||||
|
||||
@@ -304,8 +304,7 @@ impl<T: Transport> Runtime<T> {
|
||||
/// Starts or restarts observation at Worldspace `/`.
|
||||
pub fn connect(&mut self) {
|
||||
self.app.connection = ConnectionState::Connecting;
|
||||
let limit = self.app.default_tree_limit;
|
||||
match self.client.prepare_tree(&self.session, "/", 1, limit) {
|
||||
match self.client.prepare_observe(&self.session, "/", 1) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false),
|
||||
Err(error) => self.local_error("connect", error),
|
||||
}
|
||||
@@ -348,20 +347,12 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn perform(&mut self, action: Action) -> bool {
|
||||
match action {
|
||||
Action::Expand { path, limit } => {
|
||||
match self.client.prepare_tree(&self.session, &path, 1, limit) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("tree expansion", error),
|
||||
}
|
||||
}
|
||||
Action::Refresh { path, limit } => {
|
||||
match self.client.prepare_fetch(&self.session, &path) {
|
||||
Ok(request) => {
|
||||
self.dispatch(request, InFlightKind::Object(path.clone()), false)
|
||||
}
|
||||
Err(error) => self.local_error("Entry refresh", error),
|
||||
}
|
||||
match self.client.prepare_tree(&self.session, &path, 1, limit) {
|
||||
Action::Expand { path } => match self.client.prepare_observe(&self.session, &path, 1) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("tree expansion", error),
|
||||
},
|
||||
Action::Refresh { path } => {
|
||||
match self.client.prepare_observe(&self.session, &path, 1) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
|
||||
Err(error) => self.local_error("subtree refresh", error),
|
||||
}
|
||||
@@ -402,7 +393,7 @@ impl<T: Transport> Runtime<T> {
|
||||
Action::Reconnect => self.connect(),
|
||||
Action::NavigateEntry(path) => {
|
||||
self.app.navigate_entry(path.clone());
|
||||
match self.client.prepare_fetch(&self.session, &path) {
|
||||
match self.client.prepare_observe(&self.session, &path, 0) {
|
||||
Ok(request) => self.dispatch(request, InFlightKind::Object(path), false),
|
||||
Err(error) => self.local_error("Entry navigation", error),
|
||||
}
|
||||
@@ -580,13 +571,17 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) {
|
||||
match completion {
|
||||
Completion::Object(observation) => self.app.apply_object(observation),
|
||||
Completion::Tree(observation) => {
|
||||
let root = observation.path.clone();
|
||||
self.app.apply_tree(observation);
|
||||
self.sync_all_objects();
|
||||
if root == "/" {
|
||||
self.app.connection = ConnectionState::Connected;
|
||||
Completion::Object(observation) => {
|
||||
let path = observation.path.clone();
|
||||
self.app.apply_object(observation);
|
||||
if matches!(kind, Some(InFlightKind::Tree(_))) {
|
||||
if let Some(observation) = self.client.tree(&self.session, &path).cloned() {
|
||||
self.app.apply_tree(observation);
|
||||
}
|
||||
self.sync_all_objects();
|
||||
if path == "/" {
|
||||
self.app.connection = ConnectionState::Connected;
|
||||
}
|
||||
}
|
||||
}
|
||||
Completion::Interface(observation) => self.app.apply_interface(observation),
|
||||
@@ -708,7 +703,7 @@ impl<T: Transport> Runtime<T> {
|
||||
|
||||
fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) {
|
||||
let title = match kind {
|
||||
Some(InFlightKind::Object(path)) => format!("fetch {path}"),
|
||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
|
||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
||||
@@ -1029,7 +1024,7 @@ mod tests {
|
||||
let transport = HttpTransport::new(&config, 1024).unwrap();
|
||||
let off_origin = Request::builder()
|
||||
.method("POST")
|
||||
.uri("https://attacker.test/wip/v1/fetch")
|
||||
.uri("https://attacker.test/wip/v1/observe")
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
let error = transport.prepare(off_origin).unwrap_err();
|
||||
@@ -1039,7 +1034,7 @@ mod tests {
|
||||
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap();
|
||||
let valid = Request::builder()
|
||||
.method("POST")
|
||||
.uri(endpoint.route(wip_https::Route::Fetch).as_str())
|
||||
.uri(endpoint.route(wip_https::Route::Observe).as_str())
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
let prepared = transport.prepare(valid).unwrap();
|
||||
|
||||
@@ -97,7 +97,6 @@ pub fn translate_key(event: KeyEvent, editing: bool) -> Option<InputKey> {
|
||||
KeyCode::Char('l') => InputKey::Right,
|
||||
KeyCode::Char('r') => InputKey::Refresh,
|
||||
KeyCode::Char('R') => InputKey::Reconnect,
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit,
|
||||
KeyCode::Char('c') => InputKey::CancelRead,
|
||||
KeyCode::Char('?') => InputKey::Help,
|
||||
KeyCode::Char('q') => InputKey::Quit,
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Transport for ScriptedTransport {
|
||||
detail: "scripted local conversion failure".into(),
|
||||
});
|
||||
}
|
||||
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") {
|
||||
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/observe") {
|
||||
return Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "scripted tree conversion failure".into(),
|
||||
@@ -61,7 +61,7 @@ impl Transport for ScriptedTransport {
|
||||
|
||||
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")
|
||||
if request.uri().path().ends_with("/v1/observe")
|
||||
&& self
|
||||
.root_tree_protocol_failures
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
|
||||
@@ -70,7 +70,7 @@ impl Transport for ScriptedTransport {
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(encode_protocol_error_response(
|
||||
ProtocolInteraction::FetchTree,
|
||||
ProtocolInteraction::Observe,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "scripted startup failure".into(),
|
||||
@@ -80,9 +80,9 @@ impl Transport for ScriptedTransport {
|
||||
.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.
|
||||
let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 };
|
||||
// Delay the first refresh so the second wins and exercises exact
|
||||
// RequestId ownership.
|
||||
let delay = if sequence == 1 { 80 } else { 5 };
|
||||
thread::sleep(Duration::from_millis(delay));
|
||||
}
|
||||
if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {
|
||||
|
||||
Reference in New Issue
Block a user