mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 16:30:05 -06:00
#3692: typed paragraphs break like Enter, still one command and one undo step
Ultrareview finding 2. MCP `type_text` with `paragraphs: true` sent one raw text.insert with `\r` breaks, which skipped Enter: a break could land in a one-line, check-box or drop-down content control (text.newParagraph refuses there), and Enter's AutoFormat (--- borders, +---+ tables) no longer ran. text.insert takes `paragraphs: true`: each line is typed raw and each break runs text.newParagraph's own implementation (`enter`), inside the one command, so there is one checkpoint. Where Enter would refuse, the command fails and Session::run puts the document and history back (all or nothing). Enter's AutoFormat normally records the plain Enter as its own undo step after the fact (push_undo, a whole-document clone); inside typed paragraphs that step is skipped, so AutoFormatted lines add no snapshot each. MCP sends `paragraphs: true` and keeps MAX_TYPED_PARAGRAPHS. Tests: mcp type_text_paragraphs_are_refused_in_a_one_line_control, type_text_paragraphs_autoformat_like_enter_in_one_undo_step. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8a18026a13
commit
3a8ff6b6bc
@@ -10,7 +10,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#),
|
||||
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#),
|
||||
CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"),
|
||||
CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"),
|
||||
CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"),
|
||||
@@ -99,6 +99,9 @@ fn typing_options(s: &mut Session, text: &str) {
|
||||
fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = p::req_str(v, "text")?;
|
||||
let raw = p::bool(v, "raw").unwrap_or(false);
|
||||
if p::bool(v, "paragraphs").unwrap_or(false) {
|
||||
return insert_paragraphs(s, text);
|
||||
}
|
||||
typing_options(s, text);
|
||||
let t = if raw { None } else { autoformat(s, text) };
|
||||
type_text(s, t.as_deref().unwrap_or(text))?;
|
||||
@@ -576,30 +579,65 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if super::controls::refuses_paragraph(s) {
|
||||
return sel_result(s);
|
||||
}
|
||||
enter(s, true)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the
|
||||
/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so
|
||||
/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where
|
||||
/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and,
|
||||
/// like any failed command, nothing of it stays.
|
||||
fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult {
|
||||
typing_options(s, text);
|
||||
let text = text.replace("\r\n", "\n");
|
||||
for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() {
|
||||
if i > 0 {
|
||||
if super::controls::refuses_paragraph(s) {
|
||||
return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into()));
|
||||
}
|
||||
enter(s, false)?;
|
||||
}
|
||||
if !line.is_empty() {
|
||||
type_text(s, line)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a
|
||||
/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step`
|
||||
/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it
|
||||
/// (typed paragraphs, one undo step for all) no extra snapshot is taken.
|
||||
fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> {
|
||||
super::controls::prepare_edit(s)?;
|
||||
if let Some(text) = sample_text(s) {
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end };
|
||||
type_text(s, &text)?;
|
||||
return sel_result(s);
|
||||
return type_text(s, &text);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
|
||||
if let Some(auto) = auto {
|
||||
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
|
||||
// and the Enter stays. Record that plain Enter as its own undo step.
|
||||
let (doc, sel) = (s.doc.clone(), s.sel.clone());
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
let plain_doc = std::mem::replace(&mut s.doc, doc);
|
||||
let plain_sel = std::mem::replace(&mut s.sel, sel);
|
||||
let plain = if undo_step {
|
||||
let (doc, sel) = (s.doc.clone(), s.sel.clone());
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match auto {
|
||||
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
|
||||
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
|
||||
}
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
return sel_result(s);
|
||||
if let Some((plain_doc, plain_sel)) = plain {
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
|
||||
if s.sel.is_collapsed() && at.off > 0 {
|
||||
@@ -610,7 +648,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
s.goal_x = None;
|
||||
sel_result(s)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
|
||||
|
||||
+48
-4
@@ -120,7 +120,7 @@ fn headless_sessions_keep_the_dictionary_in_memory() {
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
}
|
||||
|
||||
/// A backend that keeps every command it is sent and refuses text with a paragraph break.
|
||||
/// A backend that keeps every command it is sent and refuses typed paragraphs.
|
||||
struct RefusesParagraph {
|
||||
calls: std::sync::Arc<std::sync::Mutex<Vec<Value>>>,
|
||||
}
|
||||
@@ -128,8 +128,8 @@ struct RefusesParagraph {
|
||||
impl crate::backend::Backend for RefusesParagraph {
|
||||
fn call(&mut self, _method: &str, params: Value) -> Result<Value, String> {
|
||||
self.calls.lock().unwrap().push(params.clone());
|
||||
match params["params"]["text"].as_str() {
|
||||
Some(t) if t.contains('\r') => Err("paragraph break refused".into()),
|
||||
match params["params"]["paragraphs"].as_bool() {
|
||||
Some(true) => Err("paragraph break refused".into()),
|
||||
_ => Ok(json!({"ok": true})),
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,8 @@ fn type_text_stops_at_a_refused_paragraph_break() {
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "{calls:?}");
|
||||
assert_eq!(calls[0]["command"], "text.insert");
|
||||
assert_eq!(calls[0]["params"]["text"], "one\rtwo");
|
||||
assert_eq!(calls[0]["params"]["text"], "one\ntwo");
|
||||
assert_eq!(calls[0]["params"]["paragraphs"], true);
|
||||
}
|
||||
|
||||
/// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document
|
||||
@@ -179,3 +180,46 @@ fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() {
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert!(calls.lock().unwrap().is_empty(), "refused before any command");
|
||||
}
|
||||
|
||||
/// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's
|
||||
/// implementation), so a one-line content control refuses it, and all of the text with it.
|
||||
#[test]
|
||||
fn type_text_paragraphs_are_refused_in_a_one_line_control() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "Name: "}));
|
||||
tool(&mut s, "execute", json!({"command": "developer.plainText", "params": {"tag": "name"}}));
|
||||
tool(&mut s, "execute", json!({"command": "caret.right"}));
|
||||
let before = tool(&mut s, "get_text", json!({}))["text"].clone();
|
||||
let r = call(&mut s, 13, "tools/call", json!({"name": "type_text", "arguments": {"text": "Ada\nLovelace", "paragraphs": true}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"], before, "nothing typed");
|
||||
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Ultrareview of #3692: Enter's AutoFormat runs on each typed paragraph break ("---" becomes a
|
||||
/// border, "+---+---+" a table), and the lines are still one undo step however many of them
|
||||
/// AutoFormat changes.
|
||||
#[test]
|
||||
fn type_text_paragraphs_autoformat_like_enter_in_one_undo_step() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "Above\n---\nBelow\n+---+---+\nCell", "paragraphs": true}));
|
||||
let blocks = tool(&mut s, "inspect_document", json!({}))["blocks"].clone();
|
||||
let blocks = blocks.as_array().unwrap();
|
||||
assert_eq!(blocks[0]["text"], "Above", "{blocks:?}");
|
||||
assert!(blocks[0]["props"]["borders"]["bottom"].is_object(), "--- is a border: {blocks:?}");
|
||||
assert_eq!(blocks[1]["text"], "Below", "{blocks:?}");
|
||||
assert_eq!(blocks[2]["type"], "table", "+---+---+ is a table: {blocks:?}");
|
||||
assert!(blocks[2].to_string().contains("Cell"), "typing goes on in its first cell: {blocks:?}");
|
||||
tool(&mut s, "execute", json!({"command": "edit.undo"}));
|
||||
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo");
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "");
|
||||
|
||||
// Many AutoFormatted lines: still one undo step (no snapshot per line).
|
||||
let lines: Vec<&str> = (0..500).flat_map(|_| ["Text", "==="]).collect();
|
||||
tool(&mut s, "type_text", json!({"text": lines.join("\n"), "paragraphs": true}));
|
||||
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 501);
|
||||
tool(&mut s, "execute", json!({"command": "edit.undo"}));
|
||||
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo");
|
||||
}
|
||||
|
||||
@@ -232,15 +232,16 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
"type_text" => {
|
||||
let t = st("text").unwrap_or("");
|
||||
if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) {
|
||||
// One `text.insert`: `\r` is a paragraph break there (Enter's split), so the
|
||||
// lines go in as one edit and one undo step, all or nothing, instead of a command
|
||||
// (and a whole-document undo snapshot) per line.
|
||||
// One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat,
|
||||
// and its refusal in a one-line control), but the lines go in as one edit and one
|
||||
// undo step, all or nothing, instead of a command (and a whole-document undo
|
||||
// snapshot) per line.
|
||||
let t = t.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let lines = t.split(['\n', '\u{2029}']).count();
|
||||
if lines > MAX_TYPED_PARAGRAPHS {
|
||||
return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call"));
|
||||
}
|
||||
wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true})))
|
||||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true})))
|
||||
} else {
|
||||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user