fix(improvise-2mqz): export the requesting session view
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
This commit is contained in:
@@ -1275,10 +1275,8 @@ impl Cmd for CommitSave {
|
|||||||
effect::change_mode(AppMode::Normal),
|
effect::change_mode(AppMode::Normal),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// The mode change and buffer clear come **first**, or a failed `SaveAs`
|
// Clear before `SaveAs`, whose failure aborts; this command owns the clear
|
||||||
// aborts with the minibuffer still drawn over its error (Copilot, PR #27).
|
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
|
||||||
// Buffer clear added for improvise-2mqz: the keymap uses rebind instead of
|
|
||||||
// commit (because LocalThenRequest can't be in a Binding::Sequence).
|
|
||||||
vec![
|
vec![
|
||||||
Box::new(effect::SetBuffer {
|
Box::new(effect::SetBuffer {
|
||||||
name: "save".into(),
|
name: "save".into(),
|
||||||
@@ -1305,10 +1303,8 @@ impl Cmd for CommitExport {
|
|||||||
effect::change_mode(AppMode::Normal),
|
effect::change_mode(AppMode::Normal),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// The mode change and buffer clear come **first**, or a failed export
|
// Clear before export, whose failure aborts; this command owns the clear
|
||||||
// aborts with the minibuffer still drawn over its error (gotcha 21).
|
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
|
||||||
// Buffer clear added for improvise-2mqz: the keymap uses rebind instead of
|
|
||||||
// commit (because LocalThenRequest can't be in a Binding::Sequence).
|
|
||||||
vec![
|
vec![
|
||||||
Box::new(effect::SetBuffer {
|
Box::new(effect::SetBuffer {
|
||||||
name: "export".into(),
|
name: "export".into(),
|
||||||
|
|||||||
@@ -118,19 +118,25 @@ impl Host {
|
|||||||
/// Serialize the document to `.improv` format and return the bytes for download
|
/// Serialize the document to `.improv` format and return the bytes for download
|
||||||
/// (improvise-2mqz). Silent on success, Rejected on failure; no session state moves.
|
/// (improvise-2mqz). Silent on success, Rejected on failure; no session state moves.
|
||||||
fn download_document(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
fn download_document(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
||||||
let bytes = improvise_io::persistence::format_md(&self.app.model_state.workbook)
|
let bytes =
|
||||||
.into_bytes();
|
improvise_io::persistence::format_md(&self.app.model_state.workbook).into_bytes();
|
||||||
vec![Addressed {
|
vec![Addressed {
|
||||||
to: id,
|
to: id,
|
||||||
msg: ServerFrame::DocumentBytes { filename, bytes },
|
msg: ServerFrame::DocumentBytes { filename, bytes },
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the active view as CSV and return the bytes for download
|
/// Render the requesting session's view as downloadable CSV bytes.
|
||||||
/// (improvise-2mqz). Silent on success, Rejected on failure; no session state moves.
|
/// Silent on success, Rejected on failure (improvise-2mqz).
|
||||||
fn download_csv(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
fn download_csv(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
||||||
let view_name = &self.app.model_state.workbook.active_view;
|
let Some(view_name) = self
|
||||||
match improvise_io::persistence::render_csv(&self.app.model_state.workbook, view_name) {
|
.sessions
|
||||||
|
.get(id)
|
||||||
|
.map(|session| session.view().active_view.clone())
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
match improvise_io::persistence::render_csv(&self.app.model_state.workbook, &view_name) {
|
||||||
Ok(bytes) => vec![Addressed {
|
Ok(bytes) => vec![Addressed {
|
||||||
to: id,
|
to: id,
|
||||||
msg: ServerFrame::CsvBytes { filename, bytes },
|
msg: ServerFrame::CsvBytes { filename, bytes },
|
||||||
@@ -464,9 +470,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Neither answer carries state: one applied nothing, and what
|
// These carry no foldable state: downloads are consumed by the
|
||||||
// the other applied arrived as projections before it.
|
// browser, while other results arrived as projections or did nothing.
|
||||||
// DocumentBytes and CsvBytes are for downloads and carry no state.
|
|
||||||
ServerFrame::Rejected(_)
|
ServerFrame::Rejected(_)
|
||||||
| ServerFrame::Accepted
|
| ServerFrame::Accepted
|
||||||
| ServerFrame::DocumentBytes { .. }
|
| ServerFrame::DocumentBytes { .. }
|
||||||
@@ -2746,6 +2751,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 ──────────────────────────────────────────────────────
|
// ── disconnect ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// AC 4: a dropped client stops being projected to, and a late frame
|
/// AC 4: a dropped client stops being projected to, and a late frame
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ enum Verdict {
|
|||||||
Frame(&'static str),
|
Frame(&'static str),
|
||||||
/// Deliberately off the wire, and the argument.
|
/// Deliberately off the wire, and the argument.
|
||||||
Off(&'static str),
|
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
|
/// 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).
|
// Its text; the refusal flag is one host batch's outcome (context/code/cli.md §10).
|
||||||
("Refuse", Verdict::Command("SetStatus")),
|
("Refuse", Verdict::Command("SetStatus")),
|
||||||
("Save", Verdict::Frame("Save")),
|
("Save", Verdict::Frame("Save")),
|
||||||
(
|
("SaveAs", Verdict::Frame("DownloadDocument")),
|
||||||
"SaveAs",
|
("ExportCsv", Verdict::Frame("DownloadCsv")),
|
||||||
Verdict::Frame("DownloadDocument"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ExportCsv",
|
|
||||||
Verdict::Frame("DownloadCsv"),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"ApplyAndClearDrill",
|
"ApplyAndClearDrill",
|
||||||
Verdict::Off("operand is one session's staged drill edits (gotcha 38, improvise-3x3u)"),
|
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),
|
frames.iter().any(|f| f == variant),
|
||||||
"{name}'s verdict names ClientFrame::{variant}, which does not exist"
|
"{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")
|
assert!(!why.is_empty(), "{name}'s verdict gives no argument")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ pub fn export_csv_to(
|
|||||||
storage.write(path, &render_csv(workbook, view_name)?)
|
storage.write(path, &render_csv(workbook, view_name)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a view as CSV bytes. Separated from writing so the bytes can go to
|
/// Render a view as CSV bytes for any [`Storage`] backend or a download frame
|
||||||
/// any [`Storage`] backend, not just a file (improvise-fuj). Public for the
|
/// (improvise-fuj, improvise-2mqz).
|
||||||
/// download frame handler (improvise-2mqz).
|
|
||||||
pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||||
let view = workbook
|
let view = workbook
|
||||||
.views
|
.views
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ pub enum ClientFrame {
|
|||||||
code: KeyCode,
|
code: KeyCode,
|
||||||
modifiers: KeyModifiers,
|
modifiers: KeyModifiers,
|
||||||
},
|
},
|
||||||
/// Ask for the document serialized as bytes. The browser downloads these
|
/// Ask for document bytes named by SavePrompt; the browser downloads them
|
||||||
/// via Blob + `<a download>`. The filename is what the user typed in the
|
/// via Blob + `<a download>` (improvise-2mqz).
|
||||||
/// SavePrompt minibuffer — improvise-2mqz.
|
|
||||||
DownloadDocument { filename: String },
|
DownloadDocument { filename: String },
|
||||||
/// Ask for the active view as CSV bytes. The browser downloads these
|
/// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads
|
||||||
/// via Blob + `<a download>`. The filename is what the user typed in the
|
/// them via Blob + `<a download>` (improvise-2mqz).
|
||||||
/// ExportPrompt minibuffer — improvise-2mqz.
|
|
||||||
DownloadCsv { filename: String },
|
DownloadCsv { filename: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +61,11 @@ pub enum ServerFrame {
|
|||||||
/// The asker's mutating frame landed whole. Carries no state: everything
|
/// The asker's mutating frame landed whole. Carries no state: everything
|
||||||
/// it moved arrived as projections first — `context/code/protocol.md` §4.
|
/// it moved arrived as projections first — `context/code/protocol.md` §4.
|
||||||
Accepted,
|
Accepted,
|
||||||
/// The document serialized as `.improv` format (improvise-2mqz). Addressed
|
/// Downloadable `.improv` bytes addressed to the asker alone; the filename
|
||||||
/// to the asker alone; the browser turns these bytes into a download.
|
/// is echoed from its request (improvise-2mqz).
|
||||||
/// The filename is echoed from the request.
|
|
||||||
DocumentBytes { filename: String, bytes: Vec<u8> },
|
DocumentBytes { filename: String, bytes: Vec<u8> },
|
||||||
/// The active view rendered as CSV (improvise-2mqz). Addressed to the
|
/// Downloadable active-view CSV bytes addressed to the asker alone; the
|
||||||
/// asker alone; the browser turns these bytes into a download.
|
/// filename is echoed from its request (improvise-2mqz).
|
||||||
/// The filename is echoed from the request.
|
|
||||||
CsvBytes { filename: String, bytes: Vec<u8> },
|
CsvBytes { filename: String, bytes: Vec<u8> },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +95,12 @@ mod tests {
|
|||||||
)),
|
)),
|
||||||
ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||||
ClientFrame::Save,
|
ClientFrame::Save,
|
||||||
ClientFrame::DownloadDocument { filename: "doc.improv".into() },
|
ClientFrame::DownloadDocument {
|
||||||
ClientFrame::DownloadCsv { filename: "export.csv".into() },
|
filename: "doc.improv".into(),
|
||||||
|
},
|
||||||
|
ClientFrame::DownloadCsv {
|
||||||
|
filename: "export.csv".into(),
|
||||||
|
},
|
||||||
] {
|
] {
|
||||||
let json = serde_json::to_string(&frame).expect("serializes");
|
let json = serde_json::to_string(&frame).expect("serializes");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -927,10 +927,8 @@ impl Keymaps {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ExportPrompt and SavePrompt use rebind instead of commit because
|
// LocalThenRequest cannot live in a sequence, so these commands own
|
||||||
// commit-save and commit-export return LocalThenRequest (improvise-2mqz),
|
// their buffer clears and are rebound directly (improvise-2mqz).
|
||||||
// which cannot be part of a Binding::Sequence. The buffer clear is
|
|
||||||
// handled inside download_request instead.
|
|
||||||
set.insert(
|
set.insert(
|
||||||
ModeKey::ExportPrompt,
|
ModeKey::ExportPrompt,
|
||||||
Arc::new(
|
Arc::new(
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ pub enum KeyOutcome {
|
|||||||
/// Resolved here as a **request**: the caller sends the frame and folds
|
/// Resolved here as a **request**: the caller sends the frame and folds
|
||||||
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
|
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
|
||||||
Request(Request),
|
Request(Request),
|
||||||
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
|
/// Fold commands locally, then send a request: mode changes here and
|
||||||
/// the mode change folds here, the download request goes to the server.
|
/// download bytes come from the server (improvise-2mqz).
|
||||||
/// (improvise-2mqz)
|
LocalThenRequest {
|
||||||
LocalThenRequest { cmds: Vec<Command>, request: Request },
|
cmds: Vec<Command>,
|
||||||
|
request: Request,
|
||||||
|
},
|
||||||
/// Bound to a command this client cannot compose. Forwarded to the host,
|
/// Bound to a command this client cannot compose. Forwarded to the host,
|
||||||
/// which today answers nothing — `context/code/clients.md`.
|
/// which today answers nothing — `context/code/clients.md`.
|
||||||
Upstream(KeyEvent),
|
Upstream(KeyEvent),
|
||||||
@@ -323,7 +325,10 @@ enum Step {
|
|||||||
Request(Request),
|
Request(Request),
|
||||||
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
|
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
|
||||||
/// the mode change folds here, the download request goes to the server.
|
/// the mode change folds here, the download request goes to the server.
|
||||||
LocalThenRequest { cmds: Vec<Command>, request: Request },
|
LocalThenRequest {
|
||||||
|
cmds: Vec<Command>,
|
||||||
|
request: Request,
|
||||||
|
},
|
||||||
Upstream,
|
Upstream,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,11 +403,14 @@ fn download_request(state: &ClientState, name: &str) -> Option<Step> {
|
|||||||
if filename.is_empty() {
|
if filename.is_empty() {
|
||||||
return Some(Step::Declined(vec![Command::SetStatus(format!(
|
return Some(Step::Declined(vec![Command::SetStatus(format!(
|
||||||
"No filename given — not {}",
|
"No filename given — not {}",
|
||||||
if name == "commit-save" { "saved" } else { "exported" }
|
if name == "commit-save" {
|
||||||
|
"saved"
|
||||||
|
} else {
|
||||||
|
"exported"
|
||||||
|
}
|
||||||
))]));
|
))]));
|
||||||
}
|
}
|
||||||
// Clear the buffer and return to Normal mode. The buffer clear is here
|
// This owns the clear and mode change because LocalThenRequest cannot be
|
||||||
// rather than in the keymap sequence because LocalThenRequest cannot be
|
|
||||||
// part of a Binding::Sequence (improvise-2mqz).
|
// part of a Binding::Sequence (improvise-2mqz).
|
||||||
Some(Step::LocalThenRequest {
|
Some(Step::LocalThenRequest {
|
||||||
cmds: vec![
|
cmds: vec![
|
||||||
@@ -2710,8 +2718,14 @@ mod tests {
|
|||||||
state.view.buffers.insert("item".into(), "Shirts".into());
|
state.view.buffers.insert("item".into(), "Shirts".into());
|
||||||
state.view.buffers.insert("edit".into(), "42".into());
|
state.view.buffers.insert("edit".into(), "42".into());
|
||||||
// commit-save and commit-export need their buffers filled (improvise-2mqz).
|
// commit-save and commit-export need their buffers filled (improvise-2mqz).
|
||||||
state.view.buffers.insert("save".into(), "data.improv".into());
|
state
|
||||||
state.view.buffers.insert("export".into(), "data.csv".into());
|
.view
|
||||||
|
.buffers
|
||||||
|
.insert("save".into(), "data.improv".into());
|
||||||
|
state
|
||||||
|
.view
|
||||||
|
.buffers
|
||||||
|
.insert("export".into(), "data.csv".into());
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2877,8 +2891,6 @@ mod tests {
|
|||||||
// Reached only from a mode `declined` refuses at the door, so a
|
// Reached only from a mode `declined` refuses at the door, so a
|
||||||
// browser is never standing in front of these.
|
// browser is never standing in front of these.
|
||||||
"COMMAND: Enter execute-command Silent",
|
"COMMAND: Enter execute-command 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.
|
// Honestly unavailable, and saying so in `declined`'s words.
|
||||||
"NORMAL: > drill-into-cell Declines",
|
"NORMAL: > drill-into-cell Declines",
|
||||||
"NORMAL: : enter-mode Declines",
|
"NORMAL: : enter-mode Declines",
|
||||||
@@ -3533,35 +3545,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The refusal above is only half the property: the bug guarded is
|
/// A client must not enter a room with no exit. SavePrompt and ExportPrompt
|
||||||
/// *entering a room with no exit*. See `context/test-evidence/clients.md`.
|
/// now have download frames; see `context/test-evidence/clients.md`.
|
||||||
/// Note: SavePrompt and ExportPrompt are no longer refused after improvise-2mqz
|
|
||||||
/// added DownloadDocument and DownloadCsv frames.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_prompt_this_client_cannot_commit_is_never_entered() {
|
fn a_prompt_this_client_cannot_commit_is_never_entered() {
|
||||||
let state = in_mode(AppMode::Normal);
|
let state = in_mode(AppMode::Normal);
|
||||||
// Only CommandMode is still stranded (improvise-2mqz removed SavePrompt/ExportPrompt)
|
let mode = "command-mode";
|
||||||
for (key, mode, subject) in [(ev(KeyCode::Char(':')), "command-mode", "command line")] {
|
let KeyOutcome::Local(said) = resolve(&state, ev(KeyCode::Char(':'))) else {
|
||||||
let KeyOutcome::Local(said) = resolve(&state, key) else {
|
panic!("{mode} must be refused *out loud*, not in silence");
|
||||||
panic!("{mode} must be refused *out loud*, not in silence");
|
};
|
||||||
};
|
let [Command::SetStatus(msg)] = &said[..] else {
|
||||||
let [Command::SetStatus(msg)] = &said[..] else {
|
panic!("{mode}: expected one status, got {said:?}");
|
||||||
panic!("{mode}: expected one status, got {said:?}");
|
};
|
||||||
};
|
assert!(
|
||||||
assert!(
|
msg.contains("command line"),
|
||||||
msg.contains(subject),
|
"{mode}: the refusal must name its own subject, got {msg}"
|
||||||
"{mode}: the refusal must name its own subject, got {msg}"
|
);
|
||||||
);
|
assert!(
|
||||||
assert!(
|
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||||
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
"{mode} must not be entered by a client that cannot commit it"
|
||||||
"{mode} must not be entered by a client that cannot commit it"
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz).
|
// SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz).
|
||||||
for (key, mode_name) in [
|
for (key, mode_name) in [
|
||||||
(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), "save-prompt"),
|
(
|
||||||
(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), "export-prompt"),
|
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||||||
|
"save-prompt",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
||||||
|
"export-prompt",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let outcome = resolve(&state, key);
|
let outcome = resolve(&state, key);
|
||||||
let KeyOutcome::Local(cmds) = outcome else {
|
let KeyOutcome::Local(cmds) = outcome else {
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ use improvise_protocol::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A download the browser needs to trigger (improvise-2mqz). Stored after a
|
/// A `DocumentBytes` or `CsvBytes` download held until JavaScript retrieves it
|
||||||
/// `DocumentBytes` or `CsvBytes` frame arrives; retrieved by JavaScript via
|
/// through `Client::take_pending_download` (improvise-2mqz).
|
||||||
/// `Client::take_pending_download`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PendingDownload {
|
pub struct PendingDownload {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
@@ -102,9 +101,8 @@ impl Client {
|
|||||||
ClientFrame::Viewport { rows, cols }
|
ClientFrame::Viewport { rows, cols }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold one server frame, in arrival order. Returns the new revision for
|
/// Fold one server frame in order. Downloads leave the revision unchanged
|
||||||
/// most frames; for download frames, returns the revision unchanged (no
|
/// and wait for JavaScript; other frames return the new revision.
|
||||||
/// state to fold) and stores the download for JavaScript to retrieve.
|
|
||||||
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
|
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
|
||||||
match frame {
|
match frame {
|
||||||
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
|
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
|
||||||
|
|||||||
@@ -353,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
|
// The third column is `Profit`, which a formula owns: the page must not
|
||||||
// offer it, so it carries no surrogate to offer (improvise-ir7l).
|
// 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!(
|
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 {:?}",
|
"row {row}'s Profit cell must be Derived, got {:?}",
|
||||||
main.grid.cells[row][2].id
|
cells[2].id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ test("the page still reaches the client through a handful of calls", () => {
|
|||||||
"on_key_event",
|
"on_key_event",
|
||||||
"render_model",
|
"render_model",
|
||||||
"set_viewport",
|
"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",
|
"typed_cell",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user