From e0f04ee3343f4852ee23692e7a0230dc21c2d8be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 19:15:40 +0000 Subject: [PATCH] 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 + 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 + - 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 --- .../src/command/cmd/commit.rs | 27 +++- crates/improvise-command/src/ui/host.rs | 35 +++++- .../src/ui/reduce/vocabulary_is_complete.rs | 4 +- crates/improvise-io/src/persistence/export.rs | 5 +- crates/improvise-io/src/persistence/mod.rs | 2 +- crates/improvise-protocol/src/basis.rs | 8 +- crates/improvise-protocol/src/frame.rs | 26 ++++ crates/improvise-protocol/src/keymap.rs | 8 +- crates/improvise-protocol/src/resolve.rs | 119 +++++++++++++----- crates/improvise-wasm-client/src/lib.rs | 21 +++- crates/improvise-wasm-client/src/socket.rs | 50 +++++++- .../improvise-wasm-client/tests/lockstep.rs | 6 + crates/improvise-worker-server/src/worker.rs | 2 + .../tests/post_message.rs | 2 + web/app.js | 26 ++++ 15 files changed, 289 insertions(+), 52 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index 802c72a1..59f88a70 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -204,10 +204,15 @@ mod tests { let mut ctx = make_ctx(&m, &layout, ®); 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))), ] diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 5e0fd69b..42ac5193 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -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 { + 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 { + 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 { @@ -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 diff --git a/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs b/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs index 1f8fa1f8..c08c668d 100644 --- a/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs +++ b/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs @@ -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", diff --git a/crates/improvise-io/src/persistence/export.rs b/crates/improvise-io/src/persistence/export.rs index be4d1557..9269f6fb 100644 --- a/crates/improvise-io/src/persistence/export.rs +++ b/crates/improvise-io/src/persistence/export.rs @@ -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> { +/// 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> { let view = workbook .views .get(view_name) diff --git a/crates/improvise-io/src/persistence/mod.rs b/crates/improvise-io/src/persistence/mod.rs index ee8d579a..4baf0d11 100644 --- a/crates/improvise-io/src/persistence/mod.rs +++ b/crates/improvise-io/src/persistence/mod.rs @@ -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; diff --git a/crates/improvise-protocol/src/basis.rs b/crates/improvise-protocol/src/basis.rs index 4884e5be..aa12a7ff 100644 --- a/crates/improvise-protocol/src/basis.rs +++ b/crates/improvise-protocol/src/basis.rs @@ -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, } } } diff --git a/crates/improvise-protocol/src/frame.rs b/crates/improvise-protocol/src/frame.rs index 8b793750..e1a89863 100644 --- a/crates/improvise-protocol/src/frame.rs +++ b/crates/improvise-protocol/src/frame.rs @@ -23,6 +23,14 @@ pub enum ClientFrame { code: KeyCode, modifiers: KeyModifiers, }, + /// Ask for the document serialized as bytes. The browser downloads these + /// via Blob + ``. 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 + ``. 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 }, + /// 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 }, } 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!( diff --git a/crates/improvise-protocol/src/keymap.rs b/crates/improvise-protocol/src/keymap.rs index c16f5a3a..65788f31 100644 --- a/crates/improvise-protocol/src/keymap.rs +++ b/crates/improvise-protocol/src/keymap.rs @@ -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(), ), ); diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 1f9113d4..6af95053 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -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, 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), Declined(Vec), 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, 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 { } } +/// 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 { + 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 { @@ -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!( diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index 88ae7c0e..60323736 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -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, } /// 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, 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, } diff --git a/crates/improvise-wasm-client/src/socket.rs b/crates/improvise-wasm-client/src/socket.rs index 611d8f19..b15126e4 100644 --- a/crates/improvise-wasm-client/src/socket.rs +++ b/crates/improvise-wasm-client/src/socket.rs @@ -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, + 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" ), } } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 93c2d789..acc90118 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -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 { .. } => {} } } } diff --git a/crates/improvise-worker-server/src/worker.rs b/crates/improvise-worker-server/src/worker.rs index cafd0607..2f64889e 100644 --- a/crates/improvise-worker-server/src/worker.rs +++ b/crates/improvise-worker-server/src/worker.rs @@ -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() diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs index db6832d6..44ebfa65 100644 --- a/crates/improvise-worker-server/tests/post_message.rs +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -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 { .. } => {} } } diff --git a/web/app.js b/web/app.js index 5de93091..4236c026 100644 --- a/web/app.js +++ b/web/app.js @@ -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 + . + 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. */