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:
@@ -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))),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user