Merge PR #279 (044e8d2d) into the 2026-09-24 integration wave

# Conflicts:
#	context/test-evidence/clients.md
This commit is contained in:
Edward Langley
2026-09-24 02:47:40 -07:00
12 changed files with 449 additions and 44 deletions
+128 -3
View File
@@ -2,9 +2,9 @@
//! The two properties this dispatch owes: `context/code/clients.md`.
use crate::{
BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection,
Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view,
settled_mode,
BasisCache, CategoryTreeCache, Command, DocumentCache, EditorSession, FormulaCache, GridCache,
Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache,
reduce_view, settled_mode,
};
use serde::Serialize;
@@ -43,6 +43,12 @@ pub struct ClientState {
/// replay the rest. Why a queue: `context/code/clients.md`.
#[serde(skip)]
pub in_flight: std::collections::VecDeque<Sent>,
/// The status text last observed, and the editor it was written in while
/// that editor is still open (improvise-1x9h, `context/code/clients.md`).
#[serde(skip)]
pub status_seen: String,
#[serde(skip)]
pub status_owner: Option<EditorSession>,
}
/// One frame this client has sent. `awaited` is true exactly when the host
@@ -78,6 +84,7 @@ impl ClientState {
}
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
@@ -98,6 +105,7 @@ impl ClientState {
self.replay();
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
@@ -131,6 +139,7 @@ impl ClientState {
}
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
@@ -181,9 +190,18 @@ impl ClientState {
self.last_rejection = Some(rejection);
self.settle_mode();
self.settle_editor();
self.settle_status();
self.revision += 1;
}
/// Whether the status belongs to the editor still open, so a renderer must
/// not expire it (improvise-1x9h). The TUI asks the same `EditorSession`.
pub fn status_stands(&self) -> bool {
!self.view.status_msg.is_empty()
&& self.status_owner.is_some()
&& self.status_owner == EditorSession::of(&self.view)
}
/// The settled slice plus every frame still in flight, in order. What a
/// dropped frame leaves behind is the *rest* of them, not nothing.
fn replay(&mut self) {
@@ -248,6 +266,18 @@ impl ClientState {
}
}
/// A new message is owned by the editor open as it arrives; an old one loses
/// its owner for good once that editor is not the open one.
fn settle_status(&mut self) {
let now = EditorSession::of(&self.view);
if self.view.status_msg != self.status_seen {
self.status_seen = self.view.status_msg.clone();
self.status_owner = now;
} else if self.status_owner != now {
self.status_owner = None;
}
}
/// The widget dispatch: a projection names the widget it is addressed to.
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
fn apply_projection(&mut self, projection: &Projection) {
@@ -662,6 +692,101 @@ mod tests {
);
}
/// improvise-1x9h: a message written while an editor is open stands until
/// that editor closes, and never again after. Evidence file.
#[test]
fn a_message_stands_while_the_editor_it_was_written_in_is_open() {
let mut state = ClientState::default();
state.fold(&Command::ChangeMode(crate::AppMode::formula_edit()));
state.fold(&Command::SetStatus("Formula error".into()));
assert!(
state.status_stands(),
"a refusal beside its editor must stand"
);
state.fold(&Command::SetBuffer {
name: "formula".into(),
value: "Total = ".into(),
});
assert!(state.status_stands(), "typing is the same editor");
state.fold(&Command::ChangeMode(crate::AppMode::FormulaPanel));
assert!(!state.status_stands(), "the refusal outlived its editor");
state.fold(&Command::ChangeMode(crate::AppMode::formula_edit()));
assert_eq!(state.view.status_msg, "Formula error", "nothing cleared it");
assert!(
!state.status_stands(),
"an equal editor opened later is another opening, and the message is not its"
);
}
/// A relay that rebases the view off the editor ends its message's ownership,
/// as a fold would (improvise-0dbk). Evidence file.
#[test]
fn a_rebase_off_the_editor_ends_the_message_standing() {
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands(), "precondition: the note stands");
state.fold_outgoing(&commit_like("unused")[..1]);
assert!(
!state.in_flight.is_empty(),
"the relay must rebase, not apply"
);
state.fold(&Command::ResetCursor);
assert_eq!(
state.view.cursor().selected,
(0, 0),
"the rebase moved the cursor"
);
state.fold_outgoing(&[Command::SetSelected { row: 1, col: 1 }]);
assert!(
!state.status_stands(),
"an equal editor after a rebase took the old message back"
);
}
/// The editor is the one it was WRITTEN in: not any editor, and not the next
/// cell's after a commit advances (improvise-1x9h, sx7h). Evidence file.
#[test]
fn a_message_does_not_stand_beside_an_editor_it_was_not_written_in() {
let mut state = ClientState::default();
state.fold(&Command::SetStatus("saved".into()));
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
assert!(
!state.status_stands(),
"written in Normal, it is the timer's"
);
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands());
state.fold(&Command::SetSelected { row: 2, col: 1 });
assert!(
!state.status_stands(),
"the note followed the editor down a row"
);
let mut state = ClientState::default();
state.fold(&Command::SetSelected { row: 1, col: 1 });
state.fold(&Command::ChangeMode(crate::AppMode::editing()));
state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on(
"4".into(),
))));
state.fold(&Command::SetStatus("a note about this cell".into()));
assert!(state.status_stands());
state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on(
"47".into(),
))));
assert!(
!state.status_stands(),
"a re-opening on the same cell is another editor"
);
}
#[test]
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
let mut state = ClientState::default();
+21
View File
@@ -561,6 +561,27 @@ impl ViewState {
}
}
/// What one opening of a minibuffer sets. Equal at two moments means the same
/// editor is still open; both front ends ask this. `context/code/tui.md`.
#[derive(Debug, Clone, PartialEq)]
pub struct EditorSession {
mode: AppMode,
cell: (usize, usize),
seed: Option<String>,
}
impl EditorSession {
/// The editor open in `view`, or `None` outside a text-entry mode.
pub fn of(view: &ViewState) -> Option<Self> {
view.mode.minibuffer()?;
Some(Self {
mode: view.mode.clone(),
cell: view.cursor().selected,
seed: view.opened_editor.as_ref().map(|o| o.seed.clone()),
})
}
}
#[cfg(test)]
mod boundary_tests {
/// improvise-cqi AC 2: the part of the boundary the compiler can hold.
@@ -16,6 +16,9 @@ pub struct RenderModel {
/// The mode's own label, off `AppMode` — the client does not spell it.
pub mode: String,
pub status: String,
/// Whether `status` belongs to the editor still open, so the page must not
/// expire it on a timer (improvise-1x9h).
pub status_stands: bool,
/// The document's active view, as this client knows it (gotcha 29).
pub view_name: String,
/// The title bar: the document's name, whether it has unsaved changes, and
@@ -239,6 +242,7 @@ pub fn render_model(state: &ClientState) -> RenderModel {
revision: state.revision,
mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
status: state.view.status_msg.clone(),
status_stands: state.status_stands(),
view_name: state.view.active_view.clone(),
doc_name: state.document.name.clone(),
doc_dirty: state.document.dirty,
@@ -195,6 +195,26 @@ mod tests {
);
}
/// A rejection's sentence is written outside a fold, so it must still be owned
/// by the editor it arrived in (improvise-1x9h). Evidence file.
#[test]
fn a_rejection_that_arrives_in_an_open_editor_stands_beside_it() {
let mut client = Client::default();
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.fold_frame(ServerFrame::Rejected(Rejection::NoDocumentPath));
assert!(
client.state().view.mode.is_editing(),
"a refused save closes nothing"
);
assert!(!client.state().view.status_msg.is_empty());
assert!(
client.state().status_stands(),
"the refusal was not observed where it arrived, so it would expire"
);
}
/// A rejection that refused an **edit** must say nothing was applied; the
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
#[test]