WIP: a read-only worker refuses edits before they are applied (improvise-j4qt)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
@@ -54,6 +54,9 @@ pub enum Rejection {
|
||||
/// A mutation in the frame refused to apply at all. The frame is undone;
|
||||
/// the refusal's own explanation is in the host's status line.
|
||||
MutationRefused,
|
||||
/// This session cannot write the document, so it takes no edit at all
|
||||
/// (improvise-j4qt). An edit it could not keep must never be painted.
|
||||
ReadOnly,
|
||||
|
||||
/// A [`ClientFrame::Save`](crate::ClientFrame::Save) reached a host with
|
||||
/// no path (improvise-368h). Decided before anything runs.
|
||||
@@ -109,7 +112,8 @@ impl Rejection {
|
||||
| Self::RecordMoved(_)
|
||||
| Self::RecordUnstated(_)
|
||||
| Self::ChainBroken(_)
|
||||
| Self::MutationRefused => true,
|
||||
| Self::MutationRefused
|
||||
| Self::ReadOnly => true,
|
||||
Self::NoDocumentPath | Self::SaveFailed => false,
|
||||
}
|
||||
}
|
||||
@@ -136,7 +140,8 @@ impl Rejection {
|
||||
Self::RecordMoved(_) => Self::RecordUnstated(sample),
|
||||
Self::RecordUnstated(_) => Self::ChainBroken(sample),
|
||||
Self::ChainBroken(_) => Self::MutationRefused,
|
||||
Self::MutationRefused => Self::NoDocumentPath,
|
||||
Self::MutationRefused => Self::ReadOnly,
|
||||
Self::ReadOnly => Self::NoDocumentPath,
|
||||
Self::NoDocumentPath => Self::SaveFailed,
|
||||
Self::SaveFailed => Self::DocumentReplaced,
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ fn rejection_message(rejection: &Rejection) -> String {
|
||||
Rejection::MutationRefused => {
|
||||
"part of that batch was refused — the batch was not applied".into()
|
||||
}
|
||||
Rejection::ReadOnly => {
|
||||
"this tab is read-only: another tab has the document open — your edit was not applied"
|
||||
.into()
|
||||
}
|
||||
// Not staleness, so neither offers a retry: they are worded as facts
|
||||
// about the host, because that is what they are (improvise-368h).
|
||||
Rejection::NoDocumentPath => {
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn read_only(storage: &OpfsFs, path: PathBuf, why: String) -> Result<Worke
|
||||
let mut app = App::with_storage(opened.workbook, Some(path.clone()), Box::new(fs));
|
||||
app.rebuild_layout();
|
||||
Ok(WorkerServer {
|
||||
worker: Worker::new(app),
|
||||
worker: Worker::read_only(app),
|
||||
storage: None,
|
||||
notice: Some(Notice::new(
|
||||
NoticeKind::ReadOnly,
|
||||
|
||||
@@ -5,7 +5,7 @@ use improvise_command::ui::app::App;
|
||||
use improvise_command::ui::effect;
|
||||
use improvise_command::ui::host::{Addressed, Host};
|
||||
use improvise_command::ui::project::SessionId;
|
||||
use improvise_protocol::{ClientFrame, ServerFrame};
|
||||
use improvise_protocol::{ClientFrame, Command, Rejection, ServerFrame};
|
||||
|
||||
use crate::boot::{Notice, NoticeKind};
|
||||
|
||||
@@ -18,16 +18,50 @@ pub struct Worker {
|
||||
/// Raised by a persist nobody asked for and drained by the shim; see
|
||||
/// [`Worker::take_notice`] and `context/code/clients.md`.
|
||||
notice: Option<Notice>,
|
||||
access: Access,
|
||||
}
|
||||
|
||||
/// Whether this session may change the document. A read-only boot still
|
||||
/// answers every view command; `context/code/clients.md` says why.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Access {
|
||||
Writable,
|
||||
/// Another context holds the handle, so no edit could ever be kept.
|
||||
ReadOnly,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
/// Decided before the host sees the frame, so nothing is applied and
|
||||
/// nothing projected: an edit that cannot be saved is never painted.
|
||||
fn refuses(self, frame: &ClientFrame) -> bool {
|
||||
match (self, frame) {
|
||||
(Self::ReadOnly, ClientFrame::Commands(cmds)) => {
|
||||
cmds.iter().any(|c| matches!(c, Command::Model { .. }))
|
||||
}
|
||||
(Self::ReadOnly, _) | (Self::Writable, _) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// Take an `App` already pointed at its document and storage backend;
|
||||
/// building that is [`crate::vfs`]'s job and the shim's, not this loop's.
|
||||
pub fn new(app: App) -> Self {
|
||||
Self::with_access(app, Access::Writable)
|
||||
}
|
||||
|
||||
/// A session over a document another context holds (improvise-i0v9). It
|
||||
/// paints and navigates, and refuses every edit (improvise-j4qt).
|
||||
pub fn read_only(app: App) -> Self {
|
||||
Self::with_access(app, Access::ReadOnly)
|
||||
}
|
||||
|
||||
fn with_access(app: App, access: Access) -> Self {
|
||||
Self {
|
||||
host: Host::new(app),
|
||||
client: None,
|
||||
notice: None,
|
||||
access,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +82,9 @@ impl Worker {
|
||||
// Anything else before the handshake: composed against a document
|
||||
// the client was never sent, so nothing to apply and nothing to say.
|
||||
(None, _) => Vec::new(),
|
||||
(Some(_), frame) if self.access.refuses(&frame) => {
|
||||
vec![ServerFrame::Rejected(Rejection::ReadOnly)]
|
||||
}
|
||||
(Some(id), frame) => {
|
||||
// A save the client asked for is already answered; retrying it
|
||||
// here double-writes. See `context/code/clients.md`.
|
||||
@@ -272,6 +309,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The read-only boot `bindings::read_only` performs: the document's bytes
|
||||
/// over a backend that refuses every write, because another tab holds it.
|
||||
fn read_only_worker() -> Worker {
|
||||
let fs = MemoryFs::new();
|
||||
let path = PathBuf::from("demo.improv");
|
||||
fs.write(&path, &demo_bytes()).unwrap();
|
||||
let locked = crate::vfs::ReadOnly::new(fs, "another tab or window is holding this document");
|
||||
let wb = vfs::open(&locked, &Opening::named(&path)).unwrap().workbook;
|
||||
let mut app = App::with_storage(wb, Some(path), Box::new(locked));
|
||||
app.rebuild_layout();
|
||||
Worker::read_only(app)
|
||||
}
|
||||
|
||||
/// improvise-j4qt: a read-only tab applied an edit and PAINTED a value the
|
||||
/// document never had. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_read_only_session_refuses_an_edit_before_anything_is_painted() {
|
||||
let mut worker = read_only_worker();
|
||||
let mut grid = connect(&mut worker);
|
||||
let was = grid.cells[0][0].text.clone();
|
||||
let cmd = reduce::caught_up(
|
||||
worker.app(),
|
||||
ModelCommand::SetCell {
|
||||
cell: addressed(&grid, 0, 0),
|
||||
value: CellValue::Number(4242.0),
|
||||
},
|
||||
);
|
||||
|
||||
let out = worker.on_frame(ClientFrame::command(cmd));
|
||||
let _ = folded(&mut grid, &out);
|
||||
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![ServerFrame::Rejected(Rejection::ReadOnly)],
|
||||
"a session that cannot save must refuse the edit, and say only that"
|
||||
);
|
||||
assert_eq!(grid.cells[0][0].text, was, "the refused value was painted");
|
||||
assert_eq!(
|
||||
worker.take_notice(),
|
||||
None,
|
||||
"nothing was applied, so no persist ran and none failed"
|
||||
);
|
||||
assert!(!worker.app().model_state.dirty, "the edit reached the document");
|
||||
}
|
||||
|
||||
/// The control: read-only is about the DOCUMENT. Moving the cursor writes
|
||||
/// only this client's slice, so it must still be answered.
|
||||
#[test]
|
||||
fn a_read_only_session_still_answers_a_view_command() {
|
||||
let mut worker = read_only_worker();
|
||||
let _ = connect(&mut worker);
|
||||
let out = worker.on_frame(ClientFrame::command(Command::SetSelected { row: 1, col: 0 }));
|
||||
assert!(
|
||||
!out.iter().any(|f| matches!(f, ServerFrame::Rejected(_))),
|
||||
"a read-only tab refused a cursor move: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-zre1: the failure stayed in a `status_msg` the worker has no
|
||||
/// UI for, so the client could not tell "save failed" from "save pending".
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user