Merge pull request #329 from fiddlerwoaroof/cursor/improvise-2mqz-download-frames
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / Nix build (crate2nix) and the repo-stats pipeline (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled

feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads
This commit is contained in:
Ed Langley
2026-09-27 10:59:41 -07:00
committed by GitHub
18 changed files with 519 additions and 72 deletions
+16
View File
@@ -6605,3 +6605,19 @@ commits.
`target.view`), run across the whole workspace: fails **only** this test.
- A composer that hard-codes `"Default"` fails here as well. Neither view in
this test is `Default`, so the default cannot satisfy it by accident.
### a_download_commit_keeps_the_host_session_mirror_in_lockstep
Codex on PR #329: `LocalThenRequest` closed and cleared the browser's download
prompt, but the bytes-only request moved no corresponding state in the host's
session mirror. The test opens SavePrompt through the real browser key path,
types a nonempty filename, and commits it. It asserts both that the response is
still `DocumentBytes` and that the host mirror is Normal with an empty save
buffer. Another session then writes a cell; the requester folds that later
projection and must still equal its host mirror.
- **Rival** (the pre-fix download handlers, which returned bytes without
closing the mirrored prompt): fails at the host-mode assertion with
`SavePrompt` versus `Normal`, before the later projection.
- The nonempty filename proves Enter commits rather than taking the empty-name
refusal, and matching `DocumentBytes` preserves the bytes-download ruling.
@@ -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,13 @@ 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).
// Clear before `SaveAs`, whose failure aborts; this command owns the clear
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
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 +1303,13 @@ 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).
// Clear before export, whose failure aborts; this command owns the clear
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
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))),
]
+128 -4
View File
@@ -5,7 +5,7 @@ use crate::ui::app::App;
use crate::ui::effect;
use crate::ui::project::{Applied, Broadcast, Dirty, SessionId, Sessions};
use crate::ui::reduce::{self, Checked, CheckedBatch};
use improvise_protocol::{Command, Rejection};
use improvise_protocol::{AppMode, Command, Rejection};
use improvise_protocol::{ClientFrame, ServerFrame};
@@ -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,67 @@ impl Host {
}
}
/// Serialize the document to `.improv` format and return the bytes for download
/// after closing the prompt in the session mirror (improvise-2mqz).
fn download_document(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
let Some(mut answer) = self.close_download_prompt(id, "save") else {
return Vec::new();
};
let bytes =
improvise_io::persistence::format_md(&self.app.model_state.workbook).into_bytes();
answer.push(Addressed {
to: id,
msg: ServerFrame::DocumentBytes { filename, bytes },
});
answer
}
/// Render the requesting session's view as downloadable CSV bytes.
/// Close its prompt mirror first; reject only if rendering fails (improvise-2mqz).
fn download_csv(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
let Some(view_name) = self
.sessions
.get(id)
.map(|session| session.view().active_view.clone())
else {
return Vec::new();
};
let Some(mut answer) = self.close_download_prompt(id, "export") else {
return Vec::new();
};
match improvise_io::persistence::render_csv(&self.app.model_state.workbook, &view_name) {
Ok(bytes) => answer.push(Addressed {
to: id,
msg: ServerFrame::CsvBytes { filename, bytes },
}),
Err(_) => answer.push(Addressed {
to: id,
msg: ServerFrame::Rejected(Rejection::ExportFailed),
}),
}
answer
}
/// The browser folded these before sending the download request; mirror
/// that fold so a later projection cannot restore the prompt.
fn close_download_prompt(
&mut self,
id: SessionId,
buffer: &'static str,
) -> Option<Vec<Addressed>> {
self.sessions.get(id)?;
Some(self.commands(
id,
&[
Command::SetBuffer {
name: buffer.into(),
value: String::new(),
},
Command::ChangeMode(AppMode::Normal),
],
))
}
/// 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> {
@@ -435,9 +498,12 @@ mod tests {
}
}
}
// Neither answer carries state: one applied nothing, and what
// the other applied arrived as projections before it.
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
// These carry no foldable state: downloads are consumed by the
// browser, while other results arrived as projections or did nothing.
ServerFrame::Rejected(_)
| ServerFrame::Accepted
| ServerFrame::DocumentBytes { .. }
| ServerFrame::CsvBytes { .. } => {}
}
}
grid
@@ -2713,6 +2779,64 @@ mod tests {
);
}
/// improvise-2mqz: view selection belongs to a session, so an export must
/// not silently use the document's default view or another client's view.
#[test]
fn a_csv_download_exports_the_requesting_sessions_active_view() {
let mut host = host();
let second = host.app.model_state.workbook.create_view("Second");
second.set_axis("Region", Axis::Row);
second.set_axis("_Measure", Axis::Column);
second.hide_item("Region", "South");
let (requester, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
host.handle(
requester,
ClientFrame::command(Command::SwitchView("Second".into())),
);
assert_eq!(
host.sessions().get(requester).unwrap().view().active_view,
"Second",
"setup: the requester must be on its session-local view"
);
assert_eq!(
host.sessions().get(bystander).unwrap().view().active_view,
"Default",
"setup: the second session must remain on the default view"
);
assert_eq!(
host.app().model_state.workbook.active_view,
"Default",
"setup: the document's active view must differ from the requester"
);
let answer = host.handle(
requester,
ClientFrame::DownloadCsv {
filename: "second.csv".into(),
},
);
let expected =
improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Second")
.unwrap();
let default =
improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Default")
.unwrap();
assert_ne!(expected, default, "setup: the two exports must differ");
assert_eq!(
to(&answer, requester),
vec![ServerFrame::CsvBytes {
filename: "second.csv".into(),
bytes: expected,
}]
);
assert!(
to(&answer, bystander).is_empty(),
"the download is owed only to its requester: {answer:?}"
);
}
// ── disconnect ──────────────────────────────────────────────────────
/// AC 4: a dropped client stops being projected to, and a late frame
@@ -16,8 +16,6 @@ enum Verdict {
Frame(&'static str),
/// Deliberately off the wire, and the argument.
Off(&'static str),
/// Missing, and the ticket that owns closing it.
Gap(&'static str),
}
/// Checked in all directions: an entry must name a real effect, must not be on
@@ -29,14 +27,8 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[
// Its text; the refusal flag is one host batch's outcome (context/code/cli.md §10).
("Refuse", Verdict::Command("SetStatus")),
("Save", Verdict::Frame("Save")),
(
"SaveAs",
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
),
(
"ExportCsv",
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
),
("SaveAs", Verdict::Frame("DownloadDocument")),
("ExportCsv", Verdict::Frame("DownloadCsv")),
(
"ApplyAndClearDrill",
Verdict::Off("operand is one session's staged drill edits (gotcha 38, improvise-3x3u)"),
@@ -134,7 +126,7 @@ fn the_verdict_list_names_only_real_effects_and_real_variants() {
frames.iter().any(|f| f == variant),
"{name}'s verdict names ClientFrame::{variant}, which does not exist"
),
Verdict::Off(why) | Verdict::Gap(why) => {
Verdict::Off(why) => {
assert!(!why.is_empty(), "{name}'s verdict gives no argument")
}
}
@@ -22,9 +22,9 @@ pub fn export_csv_to(
storage.write(path, &render_csv(workbook, view_name)?)
}
/// 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>> {
/// Render a view as CSV bytes for any [`Storage`] backend or a download frame
/// (improvise-fuj, 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,12 @@ pub enum ClientFrame {
code: KeyCode,
modifiers: KeyModifiers,
},
/// Ask for document bytes named by SavePrompt; the browser downloads them
/// via Blob + `<a download>` (improvise-2mqz).
DownloadDocument { filename: String },
/// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads
/// them via Blob + `<a download>` (improvise-2mqz).
DownloadCsv { filename: String },
}
impl ClientFrame {
@@ -55,6 +61,12 @@ 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,
/// Downloadable `.improv` bytes addressed to the asker alone; the filename
/// is echoed from its request (improvise-2mqz).
DocumentBytes { filename: String, bytes: Vec<u8> },
/// Downloadable active-view CSV bytes addressed to the asker alone; the
/// filename is echoed from its request (improvise-2mqz).
CsvBytes { filename: String, bytes: Vec<u8> },
}
impl ServerFrame {
@@ -83,6 +95,12 @@ 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!(
+4 -2
View File
@@ -927,11 +927,13 @@ impl Keymaps {
),
);
// LocalThenRequest cannot live in a sequence, so these commands own
// their buffer clears and are rebound directly (improvise-2mqz).
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 +942,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(),
),
);
+106 -35
View File
@@ -27,6 +27,12 @@ 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: mode changes here and
/// download bytes come from 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 +43,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 +61,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 +106,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 +148,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 +311,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 +323,31 @@ 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 +391,36 @@ 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"
}
))]));
}
// This owns the clear and mode change 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 +737,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,
}
@@ -2670,6 +2726,15 @@ 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
}
@@ -2687,7 +2752,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 {
@@ -2835,8 +2900,6 @@ 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",
// Honestly unavailable, and saying so in `declined`'s words.
"NORMAL: > drill-into-cell Declines",
"NORMAL: : enter-mode Declines",
@@ -3491,37 +3554,45 @@ 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`.
/// A client must not enter a room with no exit. SavePrompt and ExportPrompt
/// now have download frames; see `context/test-evidence/clients.md`.
#[test]
fn a_prompt_this_client_cannot_commit_is_never_entered() {
let state = in_mode(AppMode::Normal);
for (key, mode, subject) in [
let mode = "command-mode";
let KeyOutcome::Local(said) = resolve(&state, ev(KeyCode::Char(':'))) else {
panic!("{mode} must be refused *out loud*, not in silence");
};
let [Command::SetStatus(msg)] = &said[..] else {
panic!("{mode}: expected one status, got {said:?}");
};
assert!(
msg.contains("command line"),
"{mode}: the refusal must name its own subject, got {msg}"
);
assert!(
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
"{mode} must not be entered by a client that cannot commit it"
);
// SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz).
for (key, mode_name) 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"),
] {
let KeyOutcome::Local(said) = resolve(&state, key) else {
panic!("{mode} must be refused *out loud*, not in silence");
};
let [Command::SetStatus(msg)] = &said[..] else {
panic!("{mode}: expected one status, got {said:?}");
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!(
msg.contains(subject),
"{mode}: the refusal must name its own subject, got {msg}"
);
assert!(
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
"{mode} must not be entered by a client that cannot commit it"
cmds.iter().any(|c| matches!(c, Command::ChangeMode(_))),
"{mode_name} should be entered now that commit-save/commit-export have wire forms"
);
}
+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,
}
+43 -5
View File
@@ -8,6 +8,24 @@ use improvise_protocol::{
};
use serde::{Deserialize, Serialize};
/// A `DocumentBytes` or `CsvBytes` download held until JavaScript retrieves it
/// through `Client::take_pending_download` (improvise-2mqz).
#[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 +90,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 +101,8 @@ impl Client {
ClientFrame::Viewport { rows, cols }
}
/// Fold one server frame, in arrival order.
/// Fold one server frame in order. Downloads leave the revision unchanged
/// and wait for JavaScript; other frames return the new revision.
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
match frame {
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
@@ -100,6 +120,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 +259,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 +274,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"
),
}
}
@@ -621,6 +621,103 @@ fn ctrl_s_in_the_browser_writes_the_hosts_document() {
);
}
/// Codex on PR #329: committing a download closed the browser's prompt but
/// left the host mirror in it, so a later projection could restore stale state.
#[test]
fn a_download_commit_keeps_the_host_session_mirror_in_lockstep() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{AppMode, ModelCommand, ServerFrame};
let mut host = host();
let mut client = Client::new();
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let open = client
.key(&BrowserKey {
key: "w".into(),
ctrl: true,
alt: false,
shift: false,
meta: false,
})
.expect("Ctrl-W opens the save prompt");
let opened = host.handle(id, open);
deliver(&mut client, &opened);
for key in ["d", "o", "c", ".", "i", "m", "p", "r", "o", "v"] {
press(&mut host, id, &mut client, key);
}
assert!(matches!(
client.state().view.mode,
AppMode::SavePrompt { .. }
));
assert_eq!(
host.sessions().get(id).expect("attached").view().mode,
client.state().view.mode,
"setup: both ends must be in the prompt before Enter"
);
let commit = client
.key(&BrowserKey::new("Enter"))
.expect("Enter composes a download request");
assert!(
matches!(
&commit,
ClientFrame::DownloadDocument { filename } if filename == "doc.improv"
),
"the USER RULING is a bytes request, got {commit:?}"
);
let answer = host.handle(id, commit);
assert!(
answer
.iter()
.any(|frame| matches!(frame.msg, ServerFrame::DocumentBytes { .. })),
"the request must still return bytes: {answer:?}"
);
deliver(&mut client, &answer);
let mirrored = host.sessions().get(id).expect("attached").view();
assert_eq!(
mirrored.mode,
AppMode::Normal,
"the host must close the prompt in its session mirror"
);
assert_eq!(
mirrored.buffers.get("save").map(String::as_str),
Some(""),
"the host must clear the same minibuffer as the client"
);
// Force a subsequent document projection from another session, then fold
// what it addresses to the requester.
let (bystander, _) = host.connect(10, 10);
let write = caught_up(
host.app(),
ModelCommand::SetCell {
view: "Default".into(),
cell: client
.record_cell_at(0, 0)
.expect("North/Revenue is stored"),
value: CellValue::Number(77.0),
},
);
let projected = host.handle(bystander, ClientFrame::command(write));
let for_requester: Vec<Addressed> = projected
.into_iter()
.filter(|frame| frame.to == id)
.collect();
assert!(
!for_requester.is_empty(),
"the regression must process a later projection"
);
deliver(&mut client, &for_requester);
assert_eq!(
&client.state().view,
host.sessions().get(id).expect("attached").view(),
"the later projection must leave the browser and host mirror in lockstep"
);
}
/// A refused frame must leave this client where the **host** says it is, and
/// the host says so in its own mirror. See `context/test-evidence/clients.md`.
#[test]
@@ -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 { .. } => {}
}
}
@@ -351,11 +353,17 @@ fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
}
// The third column is `Profit`, which a formula owns: the page must not
// offer it, so it carries no surrogate to offer (improvise-ir7l).
for row in 0..main.grid.row_frame.len() {
for (row, cells) in main
.grid
.cells
.iter()
.enumerate()
.take(main.grid.row_frame.len())
{
assert!(
matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })),
matches!(cells[2].id, Some(CellId::Derived { .. })),
"row {row}'s Profit cell must be Derived, got {:?}",
main.grid.cells[row][2].id
cells[2].id
);
}
+26
View File
@@ -155,9 +155,35 @@ function onFrame(text) {
console.error("improvise: frame rejected", text, err);
return;
}
// Check for pending downloads (improvise-2mqz). The WASM client stores
// download bytes from DocumentBytes/CsvBytes frames; we trigger the download
// here via Blob + <a download>.
try {
const downloadJson = client.take_pending_download();
if (downloadJson) {
const download = JSON.parse(downloadJson);
triggerDownload(download.filename, download.bytes, download.kind);
}
} catch (err) {
console.error("improvise: download failed", err);
}
repaint();
}
/** Trigger a browser download from bytes (improvise-2mqz). */
function triggerDownload(filename, bytes, kind) {
const mimeType = kind === "Document" ? "text/plain" : "text/csv";
const blob = new Blob([new Uint8Array(bytes)], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Transports.
/** The in-page worker: the standalone deployment, no server process. */
+3
View File
@@ -91,6 +91,9 @@ test("the page still reaches the client through a handful of calls", () => {
"on_key_event",
"render_model",
"set_viewport",
// improvise-2mqz: download frames stay in the Rust fold until the page
// takes the bytes and hands them to Blob + <a download>.
"take_pending_download",
"typed_cell",
],
);