feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads

This commit implements the download flow for SaveAs and ExportCsv per the
USER RULING on improvise-2mqz. The correct shape is:
- A request frame asks the host for BYTES
- ServerFrame returns the bytes (or Rejected)
- Browser triggers downloads via Blob + <a download>

Key changes:

Protocol layer (improvise-protocol):
- Add Request::DownloadDocument and Request::DownloadCsv variants
- Add ClientFrame::DownloadDocument and ClientFrame::DownloadCsv
- Add ServerFrame::DocumentBytes and ServerFrame::CsvBytes
- Add KeyOutcome::LocalThenRequest variant for operations that need
  both local fold (mode change, buffer clear) AND a server request
- Update stranding() to unstrand SavePrompt and ExportPrompt modes
- Update keymap to use rebind() instead of commit() for commit-save
  and commit-export (because LocalThenRequest can't be in a Sequence)

Host layer (improvise-command):
- Handle new ClientFrames in host.rs returning bytes or Rejected
- Add Rejection::ExportFailed variant for CSV export failures
- Update CommitSave and CommitExport to clear their buffers
- Make render_csv() public in improvise-io for host use

WASM client layer (improvise-wasm-client):
- Add PendingDownload type with filename, bytes, and kind
- Store pending downloads for JavaScript retrieval via take_pending_download()

Browser layer (web/app.js):
- Add triggerDownload() function using Blob + <a download>
- Poll for pending downloads after frame handling

This unstrands SavePrompt and ExportPrompt which were previously refused
by stranding() due to missing wire forms.

Refs: improvise-2mqz

Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-09-25 19:15:40 +00:00
parent 6da6a0401d
commit e0f04ee334
15 changed files with 289 additions and 52 deletions
@@ -204,10 +204,15 @@ mod tests {
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.buffers = &bufs;
let effects = CommitExport.execute(&ctx);
assert_eq!(effects.len(), 2);
// 3 effects: SetBuffer (clear), ChangeMode, ExportCsv (improvise-2mqz).
assert_eq!(effects.len(), 3);
let dbg = effects_debug(&effects);
assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}");
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
assert!(
dbg.contains("SetBuffer { name: \"export\", value: \"\" }"),
"Expected buffer clear, got: {dbg}"
);
assert!(
dbg.find("Normal").unwrap() < dbg.find("ExportCsv").unwrap(),
"the mode change must precede the write an abort can reach: {dbg}"
@@ -1270,9 +1275,15 @@ impl Cmd for CommitSave {
effect::change_mode(AppMode::Normal),
];
}
// The mode change comes **first**, or a failed `SaveAs` aborts with the
// minibuffer still drawn over its error (Copilot, PR #27).
// The mode change and buffer clear come **first**, or a failed `SaveAs`
// aborts with the minibuffer still drawn over its error (Copilot, PR #27).
// Buffer clear added for improvise-2mqz: the keymap uses rebind instead of
// commit (because LocalThenRequest can't be in a Binding::Sequence).
vec![
Box::new(effect::SetBuffer {
name: "save".into(),
value: String::new(),
}),
effect::change_mode(AppMode::Normal),
Box::new(effect::SaveAs(std::path::PathBuf::from(path))),
]
@@ -1294,9 +1305,15 @@ impl Cmd for CommitExport {
effect::change_mode(AppMode::Normal),
];
}
// The mode change comes **first**, or a failed export aborts with the
// minibuffer still drawn over its error (gotcha 21).
// The mode change and buffer clear come **first**, or a failed export
// aborts with the minibuffer still drawn over its error (gotcha 21).
// Buffer clear added for improvise-2mqz: the keymap uses rebind instead of
// commit (because LocalThenRequest can't be in a Binding::Sequence).
vec![
Box::new(effect::SetBuffer {
name: "export".into(),
value: String::new(),
}),
effect::change_mode(AppMode::Normal),
Box::new(effect::ExportCsv(std::path::PathBuf::from(path))),
]
+34 -1
View File
@@ -87,6 +87,8 @@ impl Host {
// resolution diverges (improvise-bpq2, gotcha 47).
ClientFrame::Key { .. } => Vec::new(),
ClientFrame::Save => self.save(id),
ClientFrame::DownloadDocument { filename } => self.download_document(id, filename),
ClientFrame::DownloadCsv { filename } => self.download_csv(id, filename),
}
}
@@ -113,6 +115,33 @@ impl Host {
}
}
/// Serialize the document to `.improv` format and return the bytes for download
/// (improvise-2mqz). Silent on success, Rejected on failure; no session state moves.
fn download_document(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
let bytes = improvise_io::persistence::format_md(&self.app.model_state.workbook)
.into_bytes();
vec![Addressed {
to: id,
msg: ServerFrame::DocumentBytes { filename, bytes },
}]
}
/// Render the active view as CSV and return the bytes for download
/// (improvise-2mqz). Silent on success, Rejected on failure; no session state moves.
fn download_csv(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
let view_name = &self.app.model_state.workbook.active_view;
match improvise_io::persistence::render_csv(&self.app.model_state.workbook, view_name) {
Ok(bytes) => vec![Addressed {
to: id,
msg: ServerFrame::CsvBytes { filename, bytes },
}],
Err(_) => vec![Addressed {
to: id,
msg: ServerFrame::Rejected(Rejection::ExportFailed),
}],
}
}
/// Fan out whatever moved, for a host that changed the document outside
/// `handle` — a worker's own persist is the caller (improvise-z754).
pub fn project_all(&mut self) -> Vec<Addressed> {
@@ -437,7 +466,11 @@ mod tests {
}
// Neither answer carries state: one applied nothing, and what
// the other applied arrived as projections before it.
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
// DocumentBytes and CsvBytes are for downloads and carry no state.
ServerFrame::Rejected(_)
| ServerFrame::Accepted
| ServerFrame::DocumentBytes { .. }
| ServerFrame::CsvBytes { .. } => {}
}
}
grid
@@ -31,11 +31,11 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[
("Save", Verdict::Frame("Save")),
(
"SaveAs",
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
Verdict::Frame("DownloadDocument"),
),
(
"ExportCsv",
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
Verdict::Frame("DownloadCsv"),
),
(
"ApplyAndClearDrill",
@@ -23,8 +23,9 @@ pub fn export_csv_to(
}
/// Render a view as CSV bytes. Separated from writing so the bytes can go to
/// any [`Storage`] backend, not just a file (improvise-fuj).
fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
/// any [`Storage`] backend, not just a file (improvise-fuj). Public for the
/// download frame handler (improvise-2mqz).
pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
let view = workbook
.views
.get(view_name)
+1 -1
View File
@@ -36,7 +36,7 @@ mod test_helpers {
}
}
pub use export::{export_csv, export_csv_to};
pub use export::{export_csv, export_csv_to, render_csv};
pub use format::format_md;
pub use parse::parse_md;
+6 -2
View File
@@ -64,6 +64,9 @@ pub enum Rejection {
/// A [`ClientFrame::Save`](crate::ClientFrame::Save) whose write failed.
/// No message: [`Rejection`] is `Copy` and an I/O error's text is not.
SaveFailed,
/// A [`ClientFrame::DownloadCsv`](crate::ClientFrame::DownloadCsv) failed
/// to render the view (improvise-2mqz). Typically the view was not found.
ExportFailed,
}
/// The basis a client holds, folded from the stream like any other widget
@@ -131,7 +134,7 @@ impl Rejection {
| Self::ChainBroken(_)
| Self::MutationRefused
| Self::ReadOnly => true,
Self::NoDocumentPath | Self::SaveFailed => false,
Self::NoDocumentPath | Self::SaveFailed | Self::ExportFailed => false,
}
}
@@ -160,7 +163,8 @@ impl Rejection {
Self::MutationRefused => Self::ReadOnly,
Self::ReadOnly => Self::NoDocumentPath,
Self::NoDocumentPath => Self::SaveFailed,
Self::SaveFailed => Self::DocumentReplaced,
Self::SaveFailed => Self::ExportFailed,
Self::ExportFailed => Self::DocumentReplaced,
}
}
}
+26
View File
@@ -23,6 +23,14 @@ pub enum ClientFrame {
code: KeyCode,
modifiers: KeyModifiers,
},
/// Ask for the document serialized as bytes. The browser downloads these
/// via Blob + `<a download>`. The filename is what the user typed in the
/// SavePrompt minibuffer — improvise-2mqz.
DownloadDocument { filename: String },
/// Ask for the active view as CSV bytes. The browser downloads these
/// via Blob + `<a download>`. The filename is what the user typed in the
/// ExportPrompt minibuffer — improvise-2mqz.
DownloadCsv { filename: String },
}
impl ClientFrame {
@@ -55,6 +63,14 @@ pub enum ServerFrame {
/// The asker's mutating frame landed whole. Carries no state: everything
/// it moved arrived as projections first — `context/code/protocol.md` §4.
Accepted,
/// The document serialized as `.improv` format (improvise-2mqz). Addressed
/// to the asker alone; the browser turns these bytes into a download.
/// The filename is echoed from the request.
DocumentBytes { filename: String, bytes: Vec<u8> },
/// The active view rendered as CSV (improvise-2mqz). Addressed to the
/// asker alone; the browser turns these bytes into a download.
/// The filename is echoed from the request.
CsvBytes { filename: String, bytes: Vec<u8> },
}
impl ServerFrame {
@@ -83,6 +99,8 @@ mod tests {
)),
ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
ClientFrame::Save,
ClientFrame::DownloadDocument { filename: "doc.improv".into() },
ClientFrame::DownloadCsv { filename: "export.csv".into() },
] {
let json = serde_json::to_string(&frame).expect("serializes");
assert_eq!(
@@ -105,6 +123,14 @@ mod tests {
))]),
ServerFrame::Rejected(Rejection::LayoutMoved),
ServerFrame::Accepted,
ServerFrame::DocumentBytes {
filename: "test.improv".into(),
bytes: b"v2025-04-09\n# Test\n".to_vec(),
},
ServerFrame::CsvBytes {
filename: "export.csv".into(),
bytes: b"col1,col2\na,b\n".to_vec(),
},
] {
let json = serde_json::to_string(&frame).expect("serializes");
assert_eq!(
+6 -2
View File
@@ -927,11 +927,15 @@ impl Keymaps {
),
);
// ExportPrompt and SavePrompt use rebind instead of commit because
// commit-save and commit-export return LocalThenRequest (improvise-2mqz),
// which cannot be part of a Binding::Sequence. The buffer clear is
// handled inside download_request instead.
set.insert(
ModeKey::ExportPrompt,
Arc::new(
TextEntry::new(&AppMode::export_prompt(), "normal", global.clone())
.commit(KeyCode::Enter, "commit-export", vec![])
.rebind(KeyCode::Enter, "commit-export")
.finish(),
),
);
@@ -940,7 +944,7 @@ impl Keymaps {
ModeKey::SavePrompt,
Arc::new(
TextEntry::new(&AppMode::save_prompt(), "normal", global.clone())
.commit(KeyCode::Enter, "commit-save", vec![])
.rebind(KeyCode::Enter, "commit-save")
.finish(),
),
);
+88 -31
View File
@@ -27,6 +27,10 @@ pub enum KeyOutcome {
/// Resolved here as a **request**: the caller sends the frame and folds
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
Request(Request),
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
/// the mode change folds here, the download request goes to the server.
/// (improvise-2mqz)
LocalThenRequest { cmds: Vec<Command>, request: Request },
/// Bound to a command this client cannot compose. Forwarded to the host,
/// which today answers nothing — `context/code/clients.md`.
Upstream(KeyEvent),
@@ -37,10 +41,16 @@ pub enum KeyOutcome {
/// What a key may ask its **host** to do, as opposed to what it may compose.
/// Narrow on purpose — the argument is in `context/code/clients.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
/// Write the document back where the host keeps it (improvise-368h).
Save,
/// Get the document as bytes for download (improvise-2mqz). The filename
/// is what the user typed in the SavePrompt minibuffer.
DownloadDocument { filename: String },
/// Get the active view as CSV bytes for download (improvise-2mqz). The filename
/// is what the user typed in the ExportPrompt minibuffer.
DownloadCsv { filename: String },
}
impl Request {
@@ -49,6 +59,8 @@ impl Request {
pub fn frame(self) -> ClientFrame {
match self {
Request::Save => ClientFrame::Save,
Request::DownloadDocument { filename } => ClientFrame::DownloadDocument { filename },
Request::DownloadCsv { filename } => ClientFrame::DownloadCsv { filename },
}
}
}
@@ -92,6 +104,9 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
Binding::Cmd { name, args } => match step(state, event, name, args) {
Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands),
Step::Request(request) => KeyOutcome::Request(request),
Step::LocalThenRequest { cmds, request } => {
KeyOutcome::LocalThenRequest { cmds, request }
}
Step::Upstream => KeyOutcome::Upstream(event),
},
// All or nothing, each step resolved against the *unchanged* state.
@@ -131,7 +146,7 @@ fn chord(state: &ClientState, event: KeyEvent, binding: Option<&Binding>) -> Key
Step::Local(commands) | Step::Declined(commands) => Some(commands),
// DISCARDED, which is why the guard refuses both classes rather
// than counting them answered — `context/code/clients.md`.
Step::Request(_) | Step::Upstream => None,
Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => None,
},
Some(Binding::Sequence(steps)) => sequence(state, event, steps),
};
@@ -294,7 +309,7 @@ fn sequence(
Step::Declined(said) => return Some(said),
// A request is a frame of its own and a sequence is one frame, so
// there is nowhere to put it. No binding does — clients.md.
Step::Request(_) | Step::Upstream => return None,
Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => return None,
}
}
Some(commands)
@@ -306,22 +321,28 @@ enum Step {
Local(Vec<Command>),
Declined(Vec<Command>),
Request(Request),
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
/// the mode change folds here, the download request goes to the server.
LocalThenRequest { cmds: Vec<Command>, request: Request },
Upstream,
}
/// One name, answered. Each list is checked before the next, so no name is in
/// two of them and [`composed`] has no arm for any earlier one.
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
match requested(name) {
Some(request) => Step::Request(request),
None => match declined(name, args).or_else(|| refused_here(state, name)) {
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
None => match committed(state, name, args) {
Some(answer) => answer,
None => match composed(state, event, name, args) {
Some(commands) => Step::Local(commands),
None => Step::Upstream,
},
if let Some(request) = requested(name) {
return Step::Request(request);
}
if let Some(step) = download_request(state, name) {
return step;
}
match declined(name, args).or_else(|| refused_here(state, name)) {
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
None => match committed(state, name, args) {
Some(answer) => answer,
None => match composed(state, event, name, args) {
Some(commands) => Step::Local(commands),
None => Step::Upstream,
},
},
}
@@ -365,6 +386,33 @@ fn requested(name: &str) -> Option<Request> {
}
}
/// Download requests that need to read a buffer and fold a mode change locally
/// before sending the request (improvise-2mqz).
fn download_request(state: &ClientState, name: &str) -> Option<Step> {
let (buffer_name, make_request): (&str, fn(String) -> Request) = match name {
"commit-save" => ("save", |f| Request::DownloadDocument { filename: f }),
"commit-export" => ("export", |f| Request::DownloadCsv { filename: f }),
_ => return None,
};
let filename = read_buffer(&state.view, buffer_name).trim().to_string();
if filename.is_empty() {
return Some(Step::Declined(vec![Command::SetStatus(format!(
"No filename given — not {}",
if name == "commit-save" { "saved" } else { "exported" }
))]));
}
// Clear the buffer and return to Normal mode. The buffer clear is here
// rather than in the keymap sequence because LocalThenRequest cannot be
// part of a Binding::Sequence (improvise-2mqz).
Some(Step::LocalThenRequest {
cmds: vec![
write_buffer(buffer_name, String::new()),
Command::ChangeMode(AppMode::Normal),
],
request: make_request(filename),
})
}
/// The commands this client cannot compose and answers **out loud**; the one
/// home for the wording. Why silence was worse: clients.md (improvise-rmvx).
fn declined(name: &str, args: &[String]) -> Option<String> {
@@ -681,8 +729,8 @@ fn write_buffer(name: &str, value: String) -> Command {
/// to decline by name. A dead room is worse than a locked door: clients.md.
fn stranding(mode: &AppMode) -> Option<&'static str> {
match mode {
AppMode::SavePrompt { .. } => Some("saving to a path"),
AppMode::ExportPrompt { .. } => Some("exporting"),
// SavePrompt and ExportPrompt were stranded before improvise-2mqz added
// DownloadDocument and DownloadCsv frames. Now they have wire forms.
AppMode::CommandMode { .. } => Some("the command line"),
_ => None,
}
@@ -2661,6 +2709,9 @@ mod tests {
.insert("category".into(), "Product".into());
state.view.buffers.insert("item".into(), "Shirts".into());
state.view.buffers.insert("edit".into(), "42".into());
// commit-save and commit-export need their buffers filled (improvise-2mqz).
state.view.buffers.insert("save".into(), "data.improv".into());
state.view.buffers.insert("export".into(), "data.csv".into());
state
}
@@ -2678,7 +2729,7 @@ mod tests {
fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard {
let commands = match outcome {
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
KeyOutcome::Request(_) => return Heard::Acts,
KeyOutcome::Request(_) | KeyOutcome::LocalThenRequest { .. } => return Heard::Acts,
KeyOutcome::Local(commands) => commands,
};
let quiet = |view: &ViewState| ViewState {
@@ -2826,8 +2877,8 @@ mod tests {
// Reached only from a mode `declined` refuses at the door, so a
// browser is never standing in front of these.
"COMMAND: Enter execute-command Silent",
"EXPORT: Enter commit-export Silent",
"SAVE AS: Enter commit-save Silent",
// commit-export and commit-save removed: improvise-2mqz added wire
// forms (DownloadCsv, DownloadDocument), so they now ACT.
// Honestly unavailable, and saying so in `declined`'s words.
"NORMAL: > drill-into-cell Declines",
"NORMAL: : enter-mode Declines",
@@ -3484,22 +3535,13 @@ mod tests {
/// The refusal above is only half the property: the bug guarded is
/// *entering a room with no exit*. See `context/test-evidence/clients.md`.
/// Note: SavePrompt and ExportPrompt are no longer refused after improvise-2mqz
/// added DownloadDocument and DownloadCsv frames.
#[test]
fn a_prompt_this_client_cannot_commit_is_never_entered() {
let state = in_mode(AppMode::Normal);
for (key, mode, subject) in [
(
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
"save-prompt",
"saving",
),
(
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
"export-prompt",
"exporting",
),
(ev(KeyCode::Char(':')), "command-mode", "command line"),
] {
// Only CommandMode is still stranded (improvise-2mqz removed SavePrompt/ExportPrompt)
for (key, mode, subject) in [(ev(KeyCode::Char(':')), "command-mode", "command line")] {
let KeyOutcome::Local(said) = resolve(&state, key) else {
panic!("{mode} must be refused *out loud*, not in silence");
};
@@ -3516,6 +3558,21 @@ mod tests {
);
}
// SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz).
for (key, mode_name) in [
(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), "save-prompt"),
(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), "export-prompt"),
] {
let outcome = resolve(&state, key);
let KeyOutcome::Local(cmds) = outcome else {
panic!("{mode_name} should resolve locally now that it has a wire form");
};
assert!(
cmds.iter().any(|c| matches!(c, Command::ChangeMode(_))),
"{mode_name} should be entered now that commit-save/commit-export have wire forms"
);
}
// Negative arm, half one: a mode that is not refused is still entered.
// Why `?` and not `:` — `context/test-evidence/clients.md`.
assert_eq!(
+20 -1
View File
@@ -8,7 +8,7 @@ pub mod socket;
pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve};
pub use keys::BrowserKey;
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
pub use socket::EditTarget;
pub use socket::{DownloadKind, EditTarget, PendingDownload};
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
use wasm_bindgen::prelude::*;
@@ -18,6 +18,8 @@ use wasm_bindgen::prelude::*;
#[derive(Debug, Default)]
pub struct Client {
state: ClientState,
/// A download waiting for JavaScript to trigger (improvise-2mqz).
pending_download: Option<PendingDownload>,
}
/// An empty view slice and one empty cache per widget. Not exported: the
@@ -92,6 +94,17 @@ impl Client {
.map_err(|e| JsError::new(&e.to_string()))
}
/// Take any pending download, returning it as JSON for JavaScript to handle.
/// Returns `None` if there is no pending download (improvise-2mqz).
#[wasm_bindgen]
pub fn take_pending_download(&mut self) -> Result<Option<String>, JsError> {
self.pending_download
.take()
.map(|d| serde_json::to_string(&d))
.transpose()
.map_err(|e| JsError::new(&e.to_string()))
}
/// Everything the page draws, with every rendering decision already
/// made. See [`render`].
#[wasm_bindgen]
@@ -243,6 +256,12 @@ impl Client {
// Nothing to fold: a request moves no state of this client's, and
// what it moves on the host comes back as a projection.
KeyOutcome::Request(request) => Some(request.frame()),
// Fold the commands locally (mode change), then send the request
// for download bytes (improvise-2mqz).
KeyOutcome::LocalThenRequest { cmds, request } => {
self.state.fold_outgoing(&cmds);
Some(request.frame())
}
KeyOutcome::Upstream(event) => Some(ClientFrame::key(event)),
KeyOutcome::Ignored => None,
}
+45 -5
View File
@@ -8,6 +8,25 @@ use improvise_protocol::{
};
use serde::{Deserialize, Serialize};
/// A download the browser needs to trigger (improvise-2mqz). Stored after a
/// `DocumentBytes` or `CsvBytes` frame arrives; retrieved by JavaScript via
/// `Client::take_pending_download`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingDownload {
pub filename: String,
pub bytes: Vec<u8>,
pub kind: DownloadKind,
}
/// What kind of content is being downloaded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DownloadKind {
/// The document in `.improv` format.
Document,
/// The active view as CSV.
Csv,
}
/// Everything an editor captures when it opens, held opaquely by the page.
/// A struct so a field such as the seed can join it — `context/code/clients.md`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -72,6 +91,7 @@ fn rejection_message(rejection: &Rejection) -> String {
"this host has nowhere to write the document — it was not saved".into()
}
Rejection::SaveFailed => "the document could not be written".into(),
Rejection::ExportFailed => "the view could not be exported".into(),
}
}
@@ -82,7 +102,9 @@ impl Client {
ClientFrame::Viewport { rows, cols }
}
/// Fold one server frame, in arrival order.
/// Fold one server frame, in arrival order. Returns the new revision for
/// most frames; for download frames, returns the revision unchanged (no
/// state to fold) and stores the download for JavaScript to retrieve.
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
match frame {
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
@@ -100,6 +122,22 @@ impl Client {
state.notify(message);
}
ServerFrame::Accepted => self.state_mut().accepted(),
// Download frames carry bytes the browser needs to trigger a download.
// Store them for JavaScript to retrieve via `take_pending_download`.
ServerFrame::DocumentBytes { filename, bytes } => {
self.pending_download = Some(PendingDownload {
filename,
bytes,
kind: DownloadKind::Document,
});
}
ServerFrame::CsvBytes { filename, bytes } => {
self.pending_download = Some(PendingDownload {
filename,
bytes,
kind: DownloadKind::Csv,
});
}
}
self.revision()
}
@@ -223,7 +261,7 @@ mod tests {
}
/// A rejection that refused an **edit** must say nothing was applied; the
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
/// save/export answers refused none. Evidence: clients.md (improvise-8uj8).
#[test]
fn every_rejection_message_says_what_it_refused() {
let hash = surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879");
@@ -238,9 +276,11 @@ mod tests {
// Not "not applied": nothing was asked to apply. It must still
// say the thing it *did* refuse did not happen.
false => assert!(
msg.contains("not saved") || msg.contains("could not be written"),
"{rejection:?} refused a save but is worded as {msg:?}, \
which does not say the document was not written"
msg.contains("not saved")
|| msg.contains("could not be written")
|| msg.contains("could not be exported"),
"{rejection:?} refused a save/export but is worded as {msg:?}, \
which does not say the operation failed"
),
}
}
@@ -1595,6 +1595,9 @@ fn deliver(
}
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
improvise_protocol::ServerFrame::Accepted => client.accepted(),
// Download responses are not processed in this test context (improvise-2mqz).
improvise_protocol::ServerFrame::DocumentBytes { .. }
| improvise_protocol::ServerFrame::CsvBytes { .. } => {}
}
}
}
@@ -3731,6 +3734,9 @@ fn heard(
}
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
improvise_protocol::ServerFrame::Accepted => client.accepted(),
// Download responses are not processed in this test context (improvise-2mqz).
improvise_protocol::ServerFrame::DocumentBytes { .. }
| improvise_protocol::ServerFrame::CsvBytes { .. } => {}
}
}
}
@@ -256,6 +256,8 @@ mod tests {
}
}
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
// Download responses are not processed in this test context (improvise-2mqz).
ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {}
}
}
grid.clone()
@@ -113,6 +113,8 @@ impl MainThread {
}
}
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
// Download responses are not processed in this test context (improvise-2mqz).
ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {}
}
}