diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index d6e11ef8e8..54e1730a77 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -209,6 +209,8 @@ fn save_exts(name: &str) -> Vec { .extension() .and_then(std::ffi::OsStr::to_str) .map(str::to_ascii_lowercase) + // A .markdown file is under Markdown's one entry in the list. + .map(|e| if e == "markdown" { "md".to_string() } else { e }) .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str())) .unwrap_or_else(|| "docx".to_string()); let mut exts: Vec = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len()); @@ -301,6 +303,8 @@ mod tests { assert_eq!(save_exts("novel.docx")[0], "docx"); assert_eq!(save_exts("report.odt")[0], "odt"); assert_eq!(save_exts("annual report.pdf")[0], "pdf"); + assert_eq!(save_exts("notes.md")[0], "md"); + assert_eq!(save_exts("notes.markdown")[0], "md"); // A suggested name always has an extension; anything unreadable falls back to Word's. assert_eq!(save_exts("no extension")[0], "docx"); assert_eq!(save_exts("mystery.rtf2")[0], "docx"); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index bf71b35d63..ad11a55be6 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -24,6 +24,7 @@ pub mod insert; pub mod inspector; pub mod lists; pub mod mailings; +pub mod md_source; pub mod moves; pub mod objects; pub mod options; @@ -73,6 +74,7 @@ pub fn registry() -> Registry { v.extend(lists::specs()); v.extend(inspector::specs()); v.extend(view::specs()); + v.extend(md_source::specs()); v.extend(outline::specs()); v.extend(insert::specs()); v.extend(equation::specs()); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c676e2c802..0a067b6d4c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -269,6 +269,10 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { super::view::restore_zoom(s, z); } s.path = Some(path.into()); + // A Markdown file's own text, for the view to show and Save to write back as it is. + if super::md_source::is_markdown_file(std::path::Path::new(path)) { + s.md_file = markdown_file_text(path, v); + } // Like Word, a document opened with a password is saved with it again. if encrypted { s.password = password.map(crate::io::Password::new); @@ -278,6 +282,28 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { ) } +/// A Markdown file's own text as `file.open` finds it: the bytes the caller hands over (base64 in +/// `data`, the same ones `read_document` decodes), else the head of the file at `path`. +/// `FileText::from_bytes` keeps it only when it can be written back as it is. +fn markdown_file_text(path: &str, v: &Value) -> super::md_source::FileText { + if let Some(data) = p::str(v, "data") { + // `read_document` has already refused a text it cannot decode. + return super::insert::base64_decode(data).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default(); + } + // Without `data`, the file's own bytes; not reachable on the web, where a file only arrives + // as `data`. + #[cfg(not(target_arch = "wasm32"))] + { + let max = u64::try_from(super::md_source::MAX_SOURCE_BYTES).unwrap_or(u64::MAX).saturating_add(1); + crate::io::read_file_head(path, max).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default() + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + super::md_source::FileText::default() + } +} + /// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only /// naming it), read with `password`, and whether it was encrypted: what `file.open` and /// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes @@ -364,6 +390,8 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"encrypted": s.password.is_some()})) } +/// Executes `file.save`: writes the document or text to disk, preserving exact file text for +/// unedited Markdown files, and updating session path and dirty state. fn save(s: &mut Session, v: &Value) -> CmdResult { let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) { Some(p) => p, @@ -407,20 +435,32 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { let password = s.password.as_ref().map(crate::io::Password::as_str); // A header/footer just made and left empty isn't saved (#960). let copy = super::insert::saved_copy(s); - if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) { + // A Markdown file's own text goes back as it is while it still says what the document holds; + // once it doesn't (an edit in the formatted view), the document is written out again. + let exact = super::md_source::text_to_save(s, &path, opts.markup); + let rewritten = exact.is_none() && !s.md_file.is_empty() && super::md_source::is_markdown_file(&path); + let written = match &exact { + Some(text) => crate::io::save_text_path(&path, text), + None => crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts), + }; + if let Err(e) = written { (s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp; return Err(CmdError::Failed(e)); } let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); // A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a // copy, except over the document's own file: a note.txt opened and saved is saved (#2376). + // A Markdown file saved as another one loses nothing its own save wouldn't: it moves there. let own_file = s.path.as_deref() == Some(path.as_path()); - if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) { + let markdown_to_markdown = super::md_source::is_markdown_file(&path) && s.path.as_deref().is_some_and(super::md_source::is_markdown_file); + if own_file || markdown_to_markdown || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) { s.path = Some(path.clone()); s.dirty = false; s.relayout(); + // What the file holds now. + s.md_file = super::md_source::FileText::saved(exact.filter(|_| super::md_source::is_markdown_file(&path))); } - Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted})) + Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted, "rewritten": rewritten})) } /// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)? diff --git a/crates/engine/src/cmd/md_source.rs b/crates/engine/src/cmd/md_source.rs new file mode 100644 index 0000000000..6d73496124 --- /dev/null +++ b/crates/engine/src/cmd/md_source.rs @@ -0,0 +1,897 @@ +//! View › Markdown Source: a Markdown file's document as editable text. +//! +//! The document holds no Markdown while it is edited, so entering writes it out as text, and the +//! text waits beside the document ([`MdSource`]) while it is edited. Leaving reads a changed text +//! back into a new document; one Undo takes back the whole visit. A visit that changes nothing +//! leaves the document as it was, whatever Markdown can't represent. +//! +//! While the text is open it is the document: commands that edit the document are refused, those +//! that read it (Save, Export) first get the text applied, and those that change the view leave. +//! +//! The file's own text is kept beside the document too ([`FileText`]): while it still stands for +//! the document, the view shows it and Save writes it, unchanged. An edit in the formatted view +//! makes the document something only writing it out again can say. + +use std::cell::RefCell; +use std::sync::Arc; + +use serde_json::{Value, json}; +use wordcraft_doc::{Document, Path, Pos, StoryRef}; + +use crate::doc_state::Selection; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The largest text the view takes, in bytes: its text box lays all of it out after each edit. +pub const MAX_SOURCE_BYTES: usize = 2 * 1024 * 1024; + +const LABEL: &str = "Markdown Source"; + +/// The text of an open Markdown Source view. +#[derive(Clone)] +pub struct MdSource { + /// What the view shows and edits. + pub text: String, + /// The caret in `text`, as a byte offset. The front end keeps it current. + pub caret: usize, + /// The front end still has to put its caret at `caret` and show it. + pub place_caret: bool, + /// The front end still has to reset its typing history from earlier visits. + pub reset_undo: bool, + /// Markdown can't represent all of the document: applying a changed text loses something. + pub loses_content: bool, + /// When the front end last applied the text while it was being typed, on its own clock. + pub applied_at_ms: f64, + /// The text on entry. + entry_text: Arc, + /// The document on entry, which that text stands for: back to it, nothing is lost. + entry_doc: Arc, + /// The line each top-level block of `entry_doc` starts on. + entry_lines: Vec, + /// The text the document now holds. + applied: Arc, + /// The visit has its undo step. + has_step: bool, + /// The undo mark recorded when the visit took its undo step, if one was taken. + step: Option, + /// The line each top-level block starts on, from the last parse. + block_lines: Vec, +} + +impl MdSource { + /// The text holds edits the document doesn't have yet. + pub fn is_pending(&self) -> bool { + self.text != *self.applied + } +} + +/// A Markdown file's own text, which keeps what the document can't (front matter, comments, a +/// code block's language, the author's own spacing): the text the file was opened or last saved +/// with, and the last text the view applied. Either stands for the document while reading it +/// gives the document as it is, so an Undo back to either finds it again. +#[derive(Clone, Default)] +pub struct FileText { + saved: Option, + latest: Option, + cached_exact: RefCell)>>, +} + +impl FileText { + /// A file's bytes as its text, if they can be written back as they are: valid UTF-8, + /// not empty, and no longer than the view takes. + pub fn from_bytes(bytes: &[u8]) -> Self { + let keep = !bytes.is_empty() && bytes.len() <= MAX_SOURCE_BYTES; + let saved = keep.then(|| std::str::from_utf8(bytes).ok()).flatten().map(str::to_string); + Self { saved, latest: None, cached_exact: RefCell::new(None) } + } + + /// The text just written to the file, which is the file's own text now. + pub fn saved(text: Option) -> Self { + Self { saved: text, latest: None, cached_exact: RefCell::new(None) } + } + + /// No text is kept. + pub fn is_empty(&self) -> bool { + self.saved.is_none() && self.latest.is_none() + } +} + +/// The kept text that stands for the document as it is, if one does: the latest first. +fn exact_text(s: &Session) -> Option { + if let Some((cached_rev, ref res)) = *s.md_file.cached_exact.borrow() + && cached_rev == s.rev() + { + return res.clone(); + } + let kept = [s.md_file.latest.as_deref(), s.md_file.saved.as_deref()]; + let res = kept.into_iter().flatten().find(|text| parse(&s.doc, text).same_content(&s.doc)).map(str::to_string); + *s.md_file.cached_exact.borrow_mut() = Some((s.rev(), res.clone())); + res +} + +/// What Save writes to the Markdown file `path`, in place of the document written out again: the +/// file's own text, while it stands for the document. Not for a Print Markup copy, which adds +/// the comments. +pub(crate) fn text_to_save(s: &Session, path: &std::path::Path, markup: bool) -> Option { + if markup || !is_markdown_file(path) { + return None; + } + exact_text(s) +} + +/// Command specifications for Markdown Source view toggling and text inspection. +pub fn specs() -> Vec { + vec![ + // Not `pure`: leaving edits the document. Entering changes nothing, so it adds no undo step. + CommandSpec::new(TOGGLE, LABEL, "View › Views", toggle).params(r#"{"value"?: bool}"#).when(can_toggle), + CommandSpec::new(TEXT, "Markdown Source Text", "View › Views", text) + .params(r#"{"text"?: string (up to 2 MB), "caret"?: integer >= 0}"#) + .when(|s| s.md_source.is_none().then_some("only in Markdown Source")), + ] +} + +const TOGGLE: &str = "view.markdownSource"; +const TEXT: &str = "view.markdownSourceText"; +/// The views that replace this one: they show the document, so the text goes into it first. +const LEAVES: [&str; 5] = ["view.printLayout", "view.webLayout", "view.draft", "view.outline", "view.readMode"]; + +/// Whether a file's name says it is Markdown. +pub fn is_markdown_file(path: &std::path::Path) -> bool { + path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown")) +} + +/// Whether the current session's path points to a Markdown file. +fn is_markdown_path(s: &Session) -> bool { + s.path.as_deref().is_some_and(is_markdown_file) +} + +/// Checks whether the Markdown Source view can currently be toggled in `s`. +fn can_toggle(s: &Session) -> Option<&'static str> { + if s.md_source.is_some() { + None + } else if !is_markdown_path(s) { + Some("only for Markdown (.md) files") + } else if s.view.read_mode { + Some("not in Read Mode") + } else if s.doc.settings.track_changes || !s.doc.revisions.is_empty() { + Some("not with tracked changes") + } else if s.doc.settings.protection.is_some() { + Some("not in a protected document") + } else { + None + } +} + +/// Toggles or sets the Markdown Source view state. +fn toggle(s: &mut Session, v: &Value) -> CmdResult { + let on = p::bool(v, "value").unwrap_or(s.md_source.is_none()); + match (on, s.md_source.is_some()) { + (true, false) => enter(s)?, + (false, true) => leave(s), + _ => {} + } + Ok(json!({"value": s.md_source.is_some()})) +} + +/// Gets or sets the Markdown Source text and caret position. +fn text(s: &mut Session, v: &Value) -> CmdResult { + let new_text = p::str(v, "text"); + if new_text.is_some_and(|t| t.len() > MAX_SOURCE_BYTES) { + return Err(CmdError::Params(too_long())); + } + if let Some(cv) = v.get("caret").filter(|c| !c.is_null()) + && cv.as_u64().is_none() + { + return Err(CmdError::Params("`caret` must be a non-negative integer".into())); + } + let Some(src) = s.md_source.as_mut() else { return Err(CmdError::Disabled("only in Markdown Source".into())) }; + let mut edited = false; + if let Some(t) = new_text { + edited = src.text != t; + src.text = t.to_string(); + src.caret = src.text.floor_char_boundary(src.caret.min(src.text.len())); + } + if let Some(caret) = v.get("caret").and_then(Value::as_u64) { + let caret = usize::try_from(caret).unwrap_or(usize::MAX).min(src.text.len()); + src.caret = src.text.floor_char_boundary(caret); + src.place_caret = true; + } + if edited { + src.place_caret = true; + s.dirty = true; + } + let mut result = json!({"caret": src.caret, "losesContent": src.loses_content}); + if new_text.is_none() { + result["text"] = json!(&src.text); + } + Ok(result) +} + +/// Error message returned when Markdown Source text exceeds [`MAX_SOURCE_BYTES`]. +fn too_long() -> String { + format!("Markdown Source takes up to {} MB of text", MAX_SOURCE_BYTES / (1024 * 1024)) +} + +/// Enters Markdown Source view, preparing source text and mapping caret position. +fn enter(s: &mut Session) -> Result<(), CmdError> { + // The file's own text when it still says what the document holds, so nothing is lost. + let (text, loses_content, block_lines) = match exact_text(s) { + Some(text) => { + let (_, lines) = parse_with_lines(&s.doc, &text); + (text, false, lines) + } + None => { + let text = wordcraft_formats::markdown::export(&s.doc); + if text.len() > MAX_SOURCE_BYTES { + return Err(CmdError::Failed(too_long())); + } + let (doc, lines) = parse_with_lines(&s.doc, &text); + let loses_content = !doc.same_content(&s.doc); + (text, loses_content, lines) + } + }; + let block = if s.sel.focus.story == StoryRef::Body { s.sel.focus.path.0.first().copied().unwrap_or(0) as usize } else { 0 }; + let line = block_lines.get(block).copied().unwrap_or(0); + let caret = line_start_byte(&text, line); + let text_arc: Arc = Arc::from(text.as_str()); + let entry_lines = block_lines.clone(); + s.md_source = Some(MdSource { + caret, + place_caret: true, + reset_undo: true, + loses_content, + applied_at_ms: 0.0, + entry_doc: Arc::new(s.doc.clone()), + entry_lines, + entry_text: text_arc.clone(), + applied: text_arc, + text, + has_step: false, + step: None, + block_lines, + }); + Ok(()) +} + +/// Leaves Markdown Source view, applying changes to the document model. +fn leave(s: &mut Session) { + // `Session::run` took a step for this command: the visit's first, or one too many. + // If the toggle was joined to an earlier edit (`join_next_undo`), it created no checkpoint. + let had_step = s.md_source.as_ref().is_some_and(|src| src.has_step); + let step = s.md_source.as_ref().and_then(|src| src.step); + let mark = s.undo_mark(); + let toggle_created_step = step.is_some_and(|step| mark > step); + if toggle_created_step { + s.drop_last_undo_step(); + } + let mark_after = s.undo_mark(); + if let Some(src) = s.md_source.as_mut() { + src.has_step = true; + if src.step.is_none() { + src.step = Some(mark_after); + } + } + apply(s); + if let Some(src) = s.md_source.take() + && (had_step || src.text != *src.entry_text) + { + let block = block_of_caret(&src.text, src.caret, &s.doc, &src.block_lines); + s.sel = Selection::caret(block); + } +} + +/// The text into the document, if it has edits the document lacks. Its first change in a visit +/// takes the visit's undo step. Save, AutoSave and crash recovery see the text this way. +pub fn apply(s: &mut Session) { + let (text, entry_text, entry_doc, has_step, entry_lines) = { + let Some(src) = s.md_source.as_ref().filter(|src| src.is_pending()) else { return }; + if src.text.len() > MAX_SOURCE_BYTES { + return; + } + (src.text.clone(), src.entry_text.clone(), src.entry_doc.clone(), src.has_step, src.entry_lines.clone()) + }; + let (doc, lines) = if text == *entry_text { ((*entry_doc).clone(), entry_lines) } else { parse_with_lines(&s.doc, &text) }; + let step = if !has_step { + s.checkpoint(LABEL); + Some(s.undo_mark()) + } else { + s.md_source.as_ref().and_then(|src| src.step) + }; + s.doc = doc; + s.note_body_replaced(); + s.math = None; + s.touch(); + s.clamp_selection(); + s.md_file.latest = Some(text.clone()); + *s.md_file.cached_exact.borrow_mut() = Some((s.rev(), Some(text.clone()))); + if let Some(src) = s.md_source.as_mut() { + src.applied = Arc::from(text.as_str()); + src.block_lines = lines; + src.has_step = true; + src.step = step; + } +} + +/// `text` as a document and block lines, with what isn't in Markdown from `current`: its page, settings, +/// properties and language, as opening the file gave it. +fn parse_with_lines(current: &Document, text: &str) -> (Document, Vec) { + let (mut doc, lines) = wordcraft_formats::markdown::import_on_with_lines(text, ¤t.last_section); + doc.ensure_nonempty(); + doc.settings = current.settings.clone(); + doc.core = current.core.clone(); + Arc::make_mut(&mut doc.styles).default_chr.lang = current.styles.default_chr.lang.clone(); + (doc, lines) +} + +/// `text` as a document, with what isn't in Markdown from `current`: its page, settings, +/// properties and language, as opening the file gave it. +fn parse(current: &Document, text: &str) -> Document { + parse_with_lines(current, text).0 +} + +/// The byte offset where `line` starts, or the end of a text with fewer lines. +fn line_start_byte(text: &str, line: usize) -> usize { + if line == 0 { + return 0; + } + let mut lines = 0; + for (i, &b) in text.as_bytes().iter().enumerate() { + if b == b'\n' { + lines += 1; + if lines == line { + return i.saturating_add(1); + } + } + } + text.len() +} + +/// The 0-based line that byte offset `byte` belongs to. +fn line_of_byte(text: &str, byte: usize) -> usize { + let slice = text.as_bytes().get(..byte).unwrap_or(text.as_bytes()); + slice.iter().filter(|&&b| b == b'\n').count() +} + +/// Where the caret at byte offset `caret` of `text` lands in `doc`: at the +/// start of the top-level block its line belongs to. +fn block_of_caret(text: &str, caret: usize, doc: &Document, block_lines: &[usize]) -> Pos { + let caret_line = line_of_byte(text, caret); + let top = if block_lines.is_empty() { 0 } else { block_lines.partition_point(|&l| l <= caret_line).saturating_sub(1) }; + let top = top.min(doc.body.len().saturating_sub(1)); + let top = u32::try_from(top).unwrap_or(0); + let path = doc.para_paths(StoryRef::Body).into_iter().find(|path| path.0.first().is_some_and(|b| *b >= top)); + match path { + Some(path) => Pos { story: StoryRef::Body, path, off: 0 }, + None => Pos { story: StoryRef::Body, path: Path::top(0), off: 0 }, + } +} + +/// Why the open text refuses command `id`, if it does. +pub(crate) fn refusal(s: &Session, id: &str, mutates: bool) -> Option<&'static str> { + (s.md_source.is_some() + && id != TOGGLE + && id != TEXT + && !LEAVES.contains(&id) + && (mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo" | "edit.repeat"))) + .then_some("leave Markdown Source first") +} + +/// What a command does while the text is open, before it runs (see the module's header). +pub(crate) fn before_command(s: &mut Session, id: &str, mutates: bool) -> Result<(), CmdError> { + if s.md_source.is_none() || id == TOGGLE || id == TEXT { + return Ok(()); + } + if LEAVES.contains(&id) { + s.run(TOGGLE, &json!({"value": false}))?; + } else if let Some(why) = refusal(s, id, mutates) { + return Err(CmdError::Disabled(format!("{id}: {why}"))); + } else if !id.starts_with("caret.") && !id.starts_with("select.") && !id.starts_with("view.") { + apply(s); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use wordcraft_layout::ViewMode; + + const SAMPLE: &str = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\nLast **bold** paragraph.\n"; + + /// Creates a test session with a Markdown document and a dummy path. + fn session(md: &str) -> Session { + let mut s = Session::new(crate::io::open_bytes("a.md", md.as_bytes()).unwrap()); + s.path = Some(PathBuf::from("/tmp/wordcraft-md-source-test.md")); + s.dirty = false; + s + } + + /// Runs the Markdown Source text command with `text`. + fn set_text(s: &mut Session, text: &str) { + s.run(TEXT, &json!({"text": text})).unwrap(); + } + + /// Extracts the plain body text of `s`. + fn body_text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) + } + + /// Verifies entering and leaving without changes does not mutate document or add undo step. + #[test] + fn a_visit_that_changes_nothing_is_no_edit() { + let mut s = session(SAMPLE); + let before = s.doc.clone(); + let depth = s.undo_depth(); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.is_some()); + assert!(!s.dirty, "entering is not an edit"); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.is_none()); + assert!(s.doc.same_content(&before)); + assert!(!s.dirty, "a visit without edits leaves no unsaved changes"); + assert_eq!(s.undo_depth(), depth, "and no undo step"); + } + + /// Verifies edited source text becomes document upon leaving and one undo reverts the visit. + #[test] + fn an_edited_text_becomes_the_document_and_one_undo_takes_it_back() { + let mut s = session(SAMPLE); + let before = s.doc.clone(); + s.run(TOGGLE, &json!({})).unwrap(); + let text = s.run(TEXT, &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.starts_with("# Title"), "{text}"); + set_text(&mut s, &text.replace("First paragraph.", "Changed paragraph.")); + assert!(s.dirty, "the edit is an unsaved change"); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(body_text(&s).contains("Changed paragraph.")); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.same_content(&before), "one undo restores the document"); + assert!(s.can_redo()); + } + + static TEST_DIR_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + /// Generates a unique temporary directory for test file isolation. + fn unique_temp_dir(tag: &str) -> PathBuf { + let n = TEST_DIR_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + std::env::temp_dir().join(format!("wordcraft-md-{tag}-{}-{n}-{nanos}", std::process::id())) + } + + /// Verifies saving within source view writes text to disk while keeping view open and preserving undo. + #[test] + fn save_writes_the_text_and_the_visit_stays_one_undo_step() { + let dir = unique_temp_dir("source-save"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("saved.md"); + let mut s = session(SAMPLE); + s.path = Some(path.clone()); + let before = s.doc.clone(); + let depth = s.undo_depth(); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Saved one\n"); + s.run("file.save", &json!({})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("Saved one")); + assert!(!s.dirty); + assert!(s.md_source.is_some(), "saving keeps the view open"); + set_text(&mut s, "# Saved two\n"); + s.run("file.save", &json!({})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("Saved two")); + set_text(&mut s, "# Left three\n"); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(body_text(&s).contains("Left three")); + assert_eq!(s.undo_depth(), depth + 1, "one step for the whole visit"); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.same_content(&before)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Verifies returning to the original entry text restores formatting that Markdown cannot represent and maps the caret using entry block lines. + #[test] + fn going_back_to_the_entry_text_restores_what_markdown_cannot_hold() { + let mut s = session(SAMPLE); + std::sync::Arc::make_mut(&mut s.doc.styles).get_mut("Normal").unwrap().chr.bold = Some(true); + let before = s.doc.clone(); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.as_ref().unwrap().loses_content, "Markdown doesn't keep a changed style"); + let text = s.md_source.as_ref().unwrap().text.clone(); + set_text(&mut s, "# Other\n"); + apply(&mut s); + assert!(!s.doc.same_content(&before)); + set_text(&mut s, &text); + let last_off = text.find("Last ").unwrap_or(0); + s.run(TEXT, &json!({"caret": last_off})).unwrap(); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.doc.same_content(&before)); + assert_eq!(s.sel.focus.path.0.first().copied(), Some(5)); + } + + /// Verifies that leaving Markdown Source with `join_next_undo` after an applied edit keeps the visit's undo checkpoint. + #[test] + fn leaving_joined_after_an_applied_edit_keeps_the_visits_undo_step() { + let mut s = session(SAMPLE); + let before = body_text(&s); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Intermediate\n"); + apply(&mut s); + assert_eq!(s.undo_depth(), 1); + set_text(&mut s, "# Final\n"); + s.join_next_undo(); + s.run(TOGGLE, &json!({"value": false})).unwrap(); + assert!(s.md_source.is_none()); + assert!(body_text(&s).contains("Final")); + assert_eq!(s.undo_depth(), 1); + assert!(s.can_undo()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(body_text(&s), before); + } + + /// Verifies that opening an unedited Markdown file reports no content loss on entry. + #[test] + fn markdown_the_document_came_from_is_not_reported_as_lost() { + let s = &mut session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(!s.md_source.as_ref().unwrap().loses_content); + } + + /// Verifies standard document editing commands are disabled while Markdown Source view is open. + #[test] + fn edits_are_refused_while_the_text_is_open_and_nothing_is_lost() { + let mut s = session(SAMPLE); + let before = s.doc.clone(); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Kept\n"); + for id in ["format.bold", "edit.undo", "edit.redo"] { + assert!(matches!(s.run(id, &json!({})), Err(CmdError::Disabled(_))), "{id}"); + } + assert!(matches!(s.run("text.insert", &json!({"text": "x"})), Err(CmdError::Disabled(_)))); + assert!(s.doc.same_content(&before)); + assert_eq!(s.md_source.as_ref().unwrap().text, "# Kept\n"); + } + + /// Verifies switching view mode leaves Markdown Source view and applies edits. + #[test] + fn changing_the_view_leaves_with_the_edits() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Applied\n"); + s.run("view.webLayout", &json!({})).unwrap(); + assert!(s.md_source.is_none()); + assert!(body_text(&s).contains("Applied")); + assert_eq!(s.view.mode, ViewMode::Web); + } + + /// Verifies only unprotected Markdown files without tracked changes can enter Markdown Source. + #[test] + fn only_markdown_files_without_tracking_or_protection_enter() { + let mut s = Session::new(Document::from_text("Text")); + assert!(s.run(TOGGLE, &json!({})).is_err(), "no file"); + s.path = Some(PathBuf::from("/tmp/a.docx")); + assert!(s.run(TOGGLE, &json!({})).is_err(), "not Markdown"); + s.path = Some(PathBuf::from("/tmp/A.MARKDOWN")); + s.doc.settings.track_changes = true; + assert!(s.run(TOGGLE, &json!({})).is_err(), "tracking"); + s.doc.settings.track_changes = false; + s.doc.settings.protection = Some("readOnly".into()); + assert!(s.run(TOGGLE, &json!({})).is_err(), "protected"); + s.doc.settings.protection = None; + s.view.read_mode = true; + assert!(s.run(TOGGLE, &json!({})).is_err(), "Read Mode"); + s.view.read_mode = false; + s.run(TOGGLE, &json!({})).unwrap(); + } + + /// Verifies source text exceeding limit is refused with CmdError::Failed. + #[test] + fn a_text_too_long_is_refused() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + let long = "x".repeat(MAX_SOURCE_BYTES + 1); + assert!(matches!(s.run(TEXT, &json!({"text": long})), Err(CmdError::Params(_)))); + s.run(TOGGLE, &json!({})).unwrap(); + s.set_document(Document::from_text(&"x".repeat(MAX_SOURCE_BYTES + 1))); + s.path = Some(PathBuf::from("/tmp/a.md")); + assert!(matches!(s.run(TOGGLE, &json!({})), Err(CmdError::Failed(_)))); + assert!(s.md_source.is_none()); + } + + /// Verifies caret stays synchronized with its paragraph when entering and leaving source view. + #[test] + fn the_caret_stays_with_its_paragraph_both_ways() { + let mut s = session(SAMPLE); + let target = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last"))) + .unwrap(); + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target.clone(), off: 2 }); + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect(); + assert!(line.contains("Last"), "caret on {line:?} in {:?}", src.text); + let changed = src.text.replace("First paragraph.", "First changed paragraph."); + let caret = changed.find("Last").unwrap(); + s.run(TEXT, &json!({"text": changed, "caret": caret})).unwrap(); + s.run(TOGGLE, &json!({})).unwrap(); + let para = s.doc.para(StoryRef::Body, &s.sel.focus.path).unwrap(); + assert!(para.plain_text().contains("Last"), "{:?}", para.plain_text()); + } + + /// Verifies that the caret in the trailing paragraph appended after a final table maps to the end of the text. + #[test] + fn caret_in_appended_paragraph_after_final_table_stays_at_end() { + let md = "| Col1 | Col2 |\n|---|---|\n| Val1 | Val2 |\n"; + let mut s = session(md); + let trailing_para = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .find(|path| path.0.first() == Some(&1)) + .expect("document ending in a table must have an appended paragraph"); + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: trailing_para, off: 0 }); + + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + assert_eq!(src.caret, src.text.len(), "caret must land after the table at the end of the text"); + + s.run(TOGGLE, &json!({})).unwrap(); + assert_eq!(s.sel.focus.path.0.first(), Some(&1), "caret must stay in the appended paragraph after table"); + } + + /// Verifies hostile carets and texts do not panic. + #[test] + fn hostile_carets_and_texts_do_not_panic() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + for (text, caret) in [("", u64::MAX), ("é\n\n\n", 2), ("```\nopen fence", 9), ("|\n|-", 1), ("> > > >\n- - - -", 3)] { + s.run(TEXT, &json!({"text": text, "caret": caret})).unwrap(); + s.run(TOGGLE, &json!({})).unwrap(); + s.clamp_selection(); + assert!(s.doc.para(s.sel.focus.story, &s.sel.focus.path).is_some(), "{text:?}"); + s.run(TOGGLE, &json!({})).unwrap(); + } + } + + /// Verifies a parked document preserves its Markdown Source text. + #[test] + fn a_parked_document_keeps_its_text() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + set_text(&mut s, "# Mine\n"); + let first = s.document_id(); + let _second = s.add_opened_document(Document::from_text("Other")); + assert!(s.md_source.is_none(), "the other document has no text open"); + #[cfg(not(target_arch = "wasm32"))] + { + let first_rec = s.recovery_docs().find(|r| r.id == first).unwrap(); + assert!(first_rec.doc.plain_text(StoryRef::Body).contains("Mine"), "pending text was applied before parking"); + } + s.activate(first).unwrap(); + assert_eq!(s.md_source.as_ref().unwrap().text, "# Mine\n"); + } + + /// What the document can't hold, all of which a save without formatted edits keeps. + const OWN: &str = "---\ntitle: Notes\n---\n\nTitle\n=====\n\n\n\n* star bullet\n* another\n\n- [ ] a task\n- [x] done\n\n```bash\necho hi\n```\n\nSome *emphasis* and a [link](https://example.com \"its title\").\nA wrapped\nline.\n\n![a picture](pic.png)\n\nLast paragraph.\n"; + + /// A scratch folder holding `name` with `text`, opened in a new session. + fn open_file(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) { + let dir = unique_temp_dir(&format!("own-{dir}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(Document::from_text("")); + s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + (s, path) + } + + /// Saves the session document via `file.save`. + fn save(s: &mut Session) -> Value { + s.run("file.save", &json!({})).unwrap() + } + + /// A scratch folder holding `name`, opened from the bytes `file.open` is handed (`data`, as a + /// platform hands a file over). The path holds other bytes, so a save that left them alone + /// would show. + fn open_handed_over(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) { + let dir = unique_temp_dir(&format!("handed-{dir}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, b"not the opened text").unwrap(); + let mut s = Session::new(Document::from_text("")); + let data = crate::cmd::insert::base64_encode(text); + s.run("file.open", &json!({"path": path.to_string_lossy(), "data": data})).unwrap(); + (s, path) + } + + /// Verifies a Markdown file opened from handed-over bytes keeps its own text and saves it back + /// byte-for-byte, as one opened from its path does. + #[test] + fn a_file_opened_from_handed_over_bytes_keeps_its_own_text() { + for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n"))] { + let (mut s, path) = open_handed_over(dir, "own.md", text.as_bytes()); + assert!(!s.md_file.is_empty(), "{dir}: the opened text must be kept"); + assert_eq!(save(&mut s)["rewritten"], false, "{dir}"); + assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes(), "{dir}"); + done(&path); + } + } + + /// Removes the temporary test folder created for `path`. + fn done(path: &std::path::Path) { + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// Verifies unedited Markdown files are written back byte-for-byte upon save. + #[test] + fn a_file_saved_without_edits_is_written_back_byte_for_byte() { + for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n")), ("bom", format!("\u{FEFF}{OWN}"))] { + let (mut s, path) = open_file(dir, "own.md", text.as_bytes()); + assert_eq!(save(&mut s)["rewritten"], false, "{dir}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "{dir}"); + done(&path); + } + } + + /// Verifies BOM-prefixed Markdown files retain original bytes when saved without edits. + #[test] + fn a_bom_prefixed_file_saved_without_edits_retains_its_original_bytes() { + let bom = [b"\xEF\xBB\xBF".as_slice(), OWN.as_bytes()].concat(); + let (mut s, path) = open_file("bom-exact", "own.md", &bom); + assert!(!s.md_file.is_empty(), "BOM file text must be kept"); + assert_eq!(save(&mut s)["rewritten"], false); + let read = std::fs::read(&path).unwrap(); + assert_eq!(read, bom, "Saved bytes must match original BOM bytes byte-for-byte"); + done(&path); + } + + /// Verifies Markdown Source view displays the file's own unedited text. + #[test] + fn the_view_shows_the_files_own_text() { + let (mut s, path) = open_file("view", "own.md", OWN.as_bytes()); + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + assert_eq!(src.text, OWN); + assert!(!src.loses_content); + done(&path); + } + + /// Verifies edits made directly in Markdown Source view are saved as typed. + #[test] + fn edits_in_the_view_are_saved_as_typed() { + let (mut s, path) = open_file("typed", "own.md", OWN.as_bytes()); + s.run(TOGGLE, &json!({})).unwrap(); + let typed = OWN.replace("Last paragraph.", "\n\n* Last *changed* paragraph."); + set_text(&mut s, &typed); + s.run(TOGGLE, &json!({})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false); + assert_eq!(std::fs::read_to_string(&path).unwrap(), typed); + // Saved while the view is open: the same. + s.run(TOGGLE, &json!({})).unwrap(); + let again = typed.replace("another", "a third"); + set_text(&mut s, &again); + assert_eq!(save(&mut s)["rewritten"], false); + assert_eq!(std::fs::read_to_string(&path).unwrap(), again); + done(&path); + } + + /// Verifies formatted edits force re-exporting the document on save with a warning. + #[test] + fn an_edit_in_the_formatted_view_writes_the_document_out_again_once_with_a_warning() { + let (mut s, path) = open_file("formatted", "own.md", OWN.as_bytes()); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], true); + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("New "), "{written}"); + assert!(!written.contains(""), "written out again: {written}"); + s.run("text.insert", &json!({"text": "Again "})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false, "warned once: the file holds the document's own text now"); + done(&path); + } + + /// Verifies undoing back to original kept text restores original formatting. + #[test] + fn undoing_back_to_a_kept_text_finds_it_again() { + // A formatted edit undone. + let (mut s, path) = open_file("undo", "own.md", OWN.as_bytes()); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN); + + // A visit to the view undone: back to the opened text, then redone: the typed one. + s.run(TOGGLE, &json!({})).unwrap(); + let typed = OWN.replace("Last paragraph.", "Typed paragraph."); + set_text(&mut s, &typed); + s.run(TOGGLE, &json!({})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + save(&mut s); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], true, "the typed text was given up for the saved one"); + assert!(std::fs::read_to_string(&path).unwrap().contains("Typed paragraph.")); + done(&path); + } + + /// Verifies files that cannot go back as they were are written out without rewriting warnings. + #[test] + fn files_that_cannot_go_back_as_they_were_are_written_out_without_a_warning() { + let latin1 = b"Caf\xE9 au lait.\n".to_vec(); + for (dir, bytes) in [("latin1", latin1), ("empty", Vec::new())] { + let (mut s, path) = open_file(dir, "own.md", &bytes); + assert!(s.md_file.is_empty(), "{dir}"); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + assert_eq!(save(&mut s)["rewritten"], false, "{dir}"); + done(&path); + } + // A Word document saved as Markdown is a copy written out as always. + let mut s = Session::new(Document::from_text("Hello")); + let dir = unique_temp_dir("own-docx"); + std::fs::create_dir_all(&dir).unwrap(); + s.path = Some(dir.join("a.docx")); + let r = s.run("file.saveAs", &json!({"path": dir.join("a.md").to_string_lossy()})).unwrap(); + assert_eq!(r["rewritten"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Verifies markdown source text command sets place_caret flag on explicit carets and edits. + #[test] + fn markdown_source_text_command_sets_place_caret() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + assert!(s.md_source.as_ref().unwrap().place_caret); + s.md_source.as_mut().unwrap().place_caret = false; + s.run(TEXT, &json!({"caret": 3})).unwrap(); + assert!(s.md_source.as_ref().unwrap().place_caret); + s.md_source.as_mut().unwrap().place_caret = false; + s.run(TEXT, &json!({"text": "# Changed\n"})).unwrap(); + assert!(s.md_source.as_ref().unwrap().place_caret); + } + + /// Verifies caret finds its matching line in file's own text. + #[test] + fn the_caret_finds_its_line_in_the_files_own_text() { + let (mut s, path) = open_file("caret", "own.md", OWN.as_bytes()); + let target = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last"))) + .unwrap(); + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target, off: 0 }); + s.run(TOGGLE, &json!({})).unwrap(); + let src = s.md_source.as_ref().unwrap(); + let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect(); + assert_eq!(line, "Last paragraph."); + done(&path); + } + + /// Verifies opening another document over session closes Markdown Source view. + #[test] + fn opening_over_the_document_closes_the_view() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + s.set_document(Document::from_text("New")); + assert!(s.md_source.is_none()); + } + + /// Verifies invalid and negative carets are refused with CmdError::Params. + #[test] + fn invalid_and_negative_carets_are_refused() { + let mut s = session(SAMPLE); + s.run(TOGGLE, &json!({})).unwrap(); + for bad in [json!({"caret": -1}), json!({"caret": -100}), json!({"caret": 2.5}), json!({"caret": "three"})] { + assert!(matches!(s.run(TEXT, &bad), Err(CmdError::Params(_))), "{bad:?}"); + } + } + + /// Verifies markdown source text command is marked as mutating. + #[test] + fn markdown_source_text_is_not_marked_pure() { + let s = session(SAMPLE); + let spec = s.registry.get(TEXT).unwrap(); + assert!(spec.mutates, "view.markdownSourceText mutates session and dirty state"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 6f196c7e4f..b758ec3e77 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -851,11 +851,25 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<& safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) } +/// Saves a document to `path` with optional password and export options on web targets. #[cfg(target_arch = "wasm32")] pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> { Err(format!("{}: files are saved through the browser on the web", path.display())) } +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; + safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(target_arch = "wasm32")] +pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + /// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the /// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see /// [`wordcraft_formats::txt::decode`]). diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f1a839006f..84efd4b5d6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -164,6 +164,10 @@ pub struct Session { typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, + /// View › Markdown Source: its text, while it is open. + pub md_source: Option, + /// A Markdown file's own text, so Save can write it unchanged. + pub md_file: crate::cmd::md_source::FileText, /// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap, /// so the next one replaces them instead of drawing over them (#2052). pub picture_looks: std::collections::HashMap, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index be763af1fd..83344462dc 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -70,6 +70,8 @@ pub struct DocState { pub(super) layout: LayoutSlots, pub(super) typing: Option, pub(super) originals: std::collections::HashMap, + pub(super) md_source: Option, + pub(super) md_file: crate::cmd::md_source::FileText, pub(super) picture_looks: std::collections::HashMap, pub(super) versions: Vec<(String, String, Document)>, pub(super) autosave: bool, @@ -124,6 +126,8 @@ impl DocState { layout: Default::default(), typing: None, originals: Default::default(), + md_source: None, + md_file: Default::default(), picture_looks: Default::default(), versions: Vec::new(), autosave: true, @@ -187,6 +191,7 @@ fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitle impl Session { /// Swap the active document's state with `d`'s: `d` then holds the document that was active. fn swap_doc(&mut self, d: &mut DocState) { + crate::cmd::md_source::apply(self); use std::mem::swap; // Every field is listed, so a new one doesn't build until someone decides whether each // open document has its own (then it goes in `DocState` and is swapped below) or the @@ -224,6 +229,8 @@ impl Session { layout: _, typing: _, originals: _, + md_source: _, + md_file: _, picture_looks: _, versions: _, autosave: _, @@ -318,6 +325,8 @@ impl Session { layout, typing, originals, + md_source, + md_file, picture_looks, versions, autosave, @@ -365,6 +374,8 @@ impl Session { swap(&mut self.layout, layout); swap(&mut self.typing, typing); swap(&mut self.originals, originals); + swap(&mut self.md_source, md_source); + swap(&mut self.md_file, md_file); swap(&mut self.picture_looks, picture_looks); swap(&mut self.versions, versions); swap(&mut self.autosave, autosave); @@ -389,6 +400,7 @@ impl Session { if id == self.document_id { return Ok(()); } + crate::cmd::md_source::apply(self); let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; let mut d = self.others.swap_remove(at); self.swap_doc(&mut d); @@ -469,7 +481,10 @@ impl Session { self.add(doc, 0) } + /// Adds `doc` to the session and makes it active, parking the previously active document + /// and applying any pending Markdown Source text first. fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { + crate::cmd::md_source::apply(self); doc.ensure_nonempty(); let mut view = self.view.clone(); view.outline.collapsed.clear(); diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index eddc81151f..be15c2cd77 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -26,6 +26,11 @@ impl Session { true } + /// Take back the newest undo step: a command that joins an earlier step of its own. + pub(crate) fn drop_last_undo_step(&mut self) { + self.history.pop(); + } + /// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so /// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change. pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) { diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs index d6a03e2490..c6ce42ad0d 100644 --- a/crates/engine/src/session/lifecycle.rs +++ b/crates/engine/src/session/lifecycle.rs @@ -68,6 +68,8 @@ impl Session { layout, typing, originals, + md_source, + md_file, picture_looks, versions, autosave, @@ -136,6 +138,8 @@ impl Session { layout, typing, originals, + md_source, + md_file, picture_looks, last_command: None, recording: None, diff --git a/crates/engine/src/session/run.rs b/crates/engine/src/session/run.rs index 3339e181c5..2d5a08a8de 100644 --- a/crates/engine/src/session/run.rs +++ b/crates/engine/src/session/run.rs @@ -46,13 +46,28 @@ impl Session { /// A command that changes nothing is not an edit: it adds no undo step, marks no /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { - doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending + doc.same_content(&self.doc) + && *sel == self.sel + && *column == self.column + && *pending == self.pending + && !self.md_source.as_ref().is_some_and(|src| src.is_pending()) } /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and /// control channel). Mutating commands are undoable; a failed command leaves the document as /// it was; a panic inside a command becomes an error. pub fn run(&mut self, id: &str, params: &Value) -> Result { + if self.md_source.is_some() + && let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates) + { + crate::cmd::md_source::before_command(self, id, mutates)?; + } + self.run_command(id, params) + } + + /// Dispatches `id` through the command registry with transactional rollback on failure, + /// tracking history, undo checkpoints, and IME outcomes. + fn run_command(&mut self, id: &str, params: &Value) -> Result { // The document the command starts in, and the first id a document it opens can have. let home = self.document_id; let mark = document_id_mark(); @@ -160,7 +175,7 @@ impl Session { // Whether this command pushed an undo step (a no-op command's step is dropped below, // leaving its undo group without one). let mut pushed = false; - if spec.mutates { + if spec.mutates && spec.id != "view.markdownSourceText" { let label = if typing { "Typing" } else { spec.label }; // A joined command merges into its group's step; a group whose first command // changed nothing has none, so the next command in it starts one (#4001). diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 7d9c7c056d..54e8af6b94 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -7,6 +7,8 @@ impl Session { pub fn set_document(&mut self, doc: Document) { self.autocap = None; let replaced = self.document_id; + self.md_source = None; + self.md_file = Default::default(); self.document_id = next_document_id(); if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { *at = self.document_id; @@ -110,6 +112,8 @@ impl Session { // The word being typed, like `column`: valid only while `sel` is where typing left it. typing: _, originals: _, + md_source: _, + md_file: _, picture_looks: _, last_command: _, recording: _, @@ -253,7 +257,9 @@ impl Session { /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). pub fn why_disabled(&self, id: &str) -> Option<&'static str> { let spec = self.registry.get(id)?; - (spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) + (spec.enabled)(self) + .or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates)) + .or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) } /// Put the revision back to `rev` after a command that changed nothing or failed. What the diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6439b3de9e..22ea3b72e7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5623,16 +5623,66 @@ fn saving_a_text_file_over_itself_saves_the_document() { assert!(!s.dirty, "{name}: saved by its own path"); // Plain text somewhere else is still a copy: the document keeps its file and its changes. + // (Markdown saved as Markdown moves there: see the test below.) run(&mut s, "text.insert", json!({"text": "!"})); let copy = dir.join(format!("copy-{name}")); run(&mut s, "file.saveAs", json!({"path": copy.to_string_lossy()})); assert!(copy.exists()); - assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved"); - assert_eq!(s.path.as_deref(), Some(path.as_path())); + if name == "note.txt" { + assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved"); + assert_eq!(s.path.as_deref(), Some(path.as_path())); + } } let _ = std::fs::remove_dir_all(&dir); } +/// A Markdown file saved as another Markdown file is that file from now on: the next save goes +/// there and the first file stays as it was. Other formats keep the #2376 rule. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn markdown_saved_as_markdown_moves_to_the_new_file() { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-md-save-as-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let first = dir.join("a.md"); + std::fs::write(&first, b"First\n").unwrap(); + for name in ["b.md", "c.markdown", "D.MD"] { + let mut s = s(); + std::fs::write(&first, b"First\n").unwrap(); + let second = dir.join(name); + run(&mut s, "file.open", json!({"path": first.to_string_lossy()})); + run(&mut s, "text.insert", json!({"text": "Hello "})); + run(&mut s, "file.saveAs", json!({"path": second.to_string_lossy()})); + assert!(!s.dirty, "{name}: saved"); + assert_eq!(s.path.as_deref(), Some(second.as_path()), "{name}"); + + run(&mut s, "text.insert", json!({"text": "Later "})); + run(&mut s, "file.save", json!({})); + assert!(!s.dirty, "{name}: saved again"); + assert!(std::fs::read_to_string(&second).unwrap().contains("Later"), "{name}: the next save goes to the new file"); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "First\n", "{name}: the first file is left alone"); + } + + // Markdown to plain text is still a copy. + let mut s = s(); + run(&mut s, "file.open", json!({"path": first.to_string_lossy()})); + run(&mut s, "text.insert", json!({"text": "Hello "})); + run(&mut s, "file.saveAs", json!({"path": dir.join("copy.txt").to_string_lossy()})); + assert!(s.dirty, "a text copy leaves it unsaved"); + assert_eq!(s.path.as_deref(), Some(first.as_path())); + + // So is plain text to Markdown. + let note = dir.join("note.txt"); + std::fs::write(¬e, b"Note\n").unwrap(); + let mut text = self::s(); + run(&mut text, "file.open", json!({"path": note.to_string_lossy()})); + run(&mut text, "text.insert", json!({"text": "Hello "})); + run(&mut text, "file.saveAs", json!({"path": dir.join("copy.md").to_string_lossy()})); + assert!(text.dirty, "a Markdown copy of a text file leaves it unsaved"); + assert_eq!(text.path.as_deref(), Some(note.as_path())); + let _ = std::fs::remove_dir_all(&dir); +} + /// A save that fails to write (no permission, disk full, a bad path) leaves the document's own /// save stamps untouched: `stamp_save` runs before the fallible write, so a prior version of this /// left `modified`/`created`/`last_modified_by`/`revision`/`zoom` changed for a save that never diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 046935599e..5f9859074c 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -27,7 +27,7 @@ mod inlines; /// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out. pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18; -pub use blocks::{Refs, import, import_on, parse}; +pub use blocks::{Refs, import, import_on, import_on_with_lines, parse, parse_with_lines}; pub(crate) use export::escape; pub use export::export; #[cfg(test)] @@ -35,6 +35,6 @@ use export::{escape_line_start, inlines_md, table_md, write_blocks}; pub use inlines::parse_inlines; #[cfg(test)] -use blocks::split_row; +use blocks::{remap_original_lines, split_row}; #[cfg(test)] mod tests; diff --git a/crates/formats/src/markdown/blocks.rs b/crates/formats/src/markdown/blocks.rs index ab6294b109..0aeb919dd8 100644 --- a/crates/formats/src/markdown/blocks.rs +++ b/crates/formats/src/markdown/blocks.rs @@ -101,12 +101,18 @@ fn link_definition(line: &str) -> Option<(String, String)> { /// definition. A definition anywhere else stays in the document and reads as text, which is what /// it did before definitions were understood at all; deleting a line that was part of a /// paragraph would lose it silently. -fn take_definitions(lines: &mut Vec) -> Refs { +/// +/// Returns the parsed references and the 0-based indexes of the removed lines. +fn take_definitions(lines: &mut Vec) -> (Refs, Vec) { let mut refs = Refs::new(); let mut open: Option<(char, usize)> = None; // The start of the document begins a block. let mut starts_block = true; + let mut removed = Vec::new(); + let mut at = 0usize; lines.retain(|line| { + let this = at; + at += 1; let t = line.trim_start_matches([' ', '\t']); // Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides. let marker = (indent(line) < 4).then(|| fence(t)).flatten(); @@ -130,26 +136,97 @@ fn take_definitions(lines: &mut Vec) -> Refs { // The first definition of a label wins, as CommonMark says, and definitions may // follow one another. refs.entry(label).or_insert(dest.into()); + removed.push(this); return false; } starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t); true }); - refs + (refs, removed) +} + +/// Maps monotonically non-decreasing line indexes in lines filtered of link definitions back to +/// their original source lines in a single linear pass. +pub(super) fn remap_original_lines(block_lines: &mut [usize], removed: &[usize]) { + let mut rem_iter = removed.iter().copied().peekable(); + let mut shift = 0; + for l in block_lines.iter_mut() { + while let Some(&r) = rem_iter.peek() { + if r <= *l + shift { + shift += 1; + rem_iter.next(); + } else { + break; + } + } + *l += shift; + } +} + +/// Context for block parsing: depth, nesting, reference definitions, cell budget, and line recording. +struct BlockCtx<'a> { + depth: usize, + level: u8, + refs: &'a Refs, + budget: &'a std::cell::Cell, + lines_out: Option<&'a mut Vec>, +} + +impl BlockCtx<'_> { + /// Records the starting line of a top-level block if line tracking is active. + fn record_line(&mut self, line: usize) { + if self.depth == 0 + && let Some(lo) = self.lines_out.as_mut() + { + lo.push(line); + } + } + + /// Records `count` blocks originating at `line` if line tracking is active. + fn record_lines(&mut self, line: usize, count: usize) { + if self.depth == 0 + && let Some(lo) = self.lines_out.as_mut() + { + for _ in 0..count { + lo.push(line); + } + } + } } /// Parse Markdown text into the flow model. pub fn parse(text: &str) -> model::Flow { + parse_with_lines(text).0 +} + +/// [`parse`] returning the flow model and the 0-based starting line of each top-level block. +pub fn parse_with_lines(text: &str) -> (model::Flow, Vec) { let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); let mut lines: Vec = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect(); - let refs = take_definitions(&mut lines); + let (refs, removed) = take_definitions(&mut lines); let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS); - let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget); + let mut block_lines = Vec::new(); + let mut ctx = BlockCtx { depth: 0, level: 0, refs: &refs, budget: &budget, lines_out: Some(&mut block_lines) }; + let mut blocks = parse_blocks(&lines, &mut ctx); // A leading "Title" from front matter isn't supported; a first-level heading stays a heading. if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); + block_lines.push(0); } - model::Flow { blocks, meta: Default::default() } + remap_original_lines(&mut block_lines, &removed); + (model::Flow { blocks, meta: Default::default() }, block_lines) +} + +/// [`import_on`] returning the document and the 0-based starting line of each top-level block. +pub fn import_on_with_lines(text: &str, page: &wordcraft_doc::SectionProps) -> (Document, Vec) { + model::begin_import(); + let (flow, mut lines) = parse_with_lines(text); + let doc = model::to_doc_in(&flow, page.clone()); + if doc.body.len() > lines.len() { + let end_line = text.as_bytes().iter().filter(|&&b| b == b'\n').count(); + lines.resize(doc.body.len(), end_line); + } + (doc, lines) } /// Leading tabs → spaces (tab stops of 4); the rest of the line is kept. @@ -369,18 +446,22 @@ fn interrupts(l: &str) -> bool { || list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))) } -fn flush_para(lines: &mut Vec, out: &mut Vec, depth: usize, refs: &Refs) { +/// Flushes accumulated paragraph text into `out`, recording its starting line if tracked. +fn flush_para(lines: &mut Vec, start_line: &mut Option, out: &mut Vec, ctx: &mut BlockCtx<'_>) { if lines.is_empty() { + *start_line = None; return; } + let line_no = start_line.take().unwrap_or(0); let text = join_lines(lines); lines.clear(); - let inlines = parse_inlines(&text, &Fmt::default(), depth, refs); + let inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs); if inlines.is_empty() { return; } let p = Para { inlines, ..Default::default() }; out.push(FBlock::Para(p)); + ctx.record_line(line_no); } /// Paragraph lines → text with `\n` for hard breaks and spaces for soft ones. @@ -405,32 +486,83 @@ fn join_lines(lines: &[String]) -> String { s } +/// Creates a single code block paragraph for an indented code line. fn code_para(line: &str) -> FBlock { let mut p = Para::new(Kind::Code); p.push_text(line, &Fmt::default()); FBlock::Para(p) } -/// Parse block structure. `level` is the list nesting level for lists found here. -/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]). -fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell) -> Vec { +/// Attempts to parse a table starting at `lines[*i]`. Returns `true` if a table was parsed. +fn parse_table(lines: &[String], i: &mut usize, t: &str, ctx: &mut BlockCtx<'_>, out: &mut Vec) -> bool { + let Some((aligns, width)) = lines.get(*i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) else { return false }; + let (head, head_width) = split_row(t, MAX_COLS); + if head_width != width { + return false; + } + let table_start = *i; + let mut table = FTable::default(); + let cell = |s: &str, header: bool, a: Option| { + let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), ctx.depth, ctx.refs), ..Default::default() }; + Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } + }; + let cols = aligns.len(); + let fits = |rows: usize| rows < MAX_ROWS && ctx.budget.get() >= cols; + let mut cut = head_width > cols; + if fits(0) { + ctx.budget.set(ctx.budget.get() - cols); + table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); + } else { + cut = true; + } + *i += 2; + while let Some(l) = lines.get(*i) { + if is_blank(l) || !l.contains('|') || interrupts(l) { + break; + } + if !table.rows.is_empty() && fits(table.rows.len()) { + ctx.budget.set(ctx.budget.get() - cols); + let (mut cells, _) = split_row(l, cols); + cells.resize(cols, String::new()); + table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); + } else { + cut = true; + } + *i += 1; + } + if cut { + model::dropped(model::Dropped::TableCells); + } + if !table.rows.is_empty() { + out.push(FBlock::Table(table)); + ctx.record_line(table_start); + } + true +} + +/// Parse block structure. +fn parse_blocks(lines: &[String], ctx: &mut BlockCtx<'_>) -> Vec { let mut out = Vec::new(); let mut para: Vec = Vec::new(); + let mut para_start = None; let mut i = 0; while let Some(line) = lines.get(i) { if is_blank(line) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); i += 1; continue; } let ind = indent(line); if ind >= 4 { if !para.is_empty() { + if para_start.is_none() { + para_start = Some(i); + } para.push(line.clone()); i += 1; continue; } - parse_indented_code(lines, &mut i, &mut out); + parse_indented_code(lines, &mut i, &mut out, ctx); continue; } let t = line.trim_start_matches([' ', '\t']); @@ -439,44 +571,50 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: let u = t.trim_end(); if !u.is_empty() && (u.chars().all(|c| c == '=') || (u.chars().all(|c| c == '-'))) { let lvl = if u.starts_with('=') { 1 } else { 2 }; + let line_no = para_start.take().unwrap_or(i); let text = join_lines(¶); para.clear(); let mut p = Para::new(Kind::Heading(lvl)); - p.inlines = parse_inlines(&text, &Fmt::default(), depth, refs); + p.inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs); out.push(FBlock::Para(p)); + ctx.record_line(line_no); i += 1; continue; } } if let Some((n, body)) = atx_heading(t) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); let mut p = Para::new(Kind::Heading(n)); - p.inlines = parse_inlines(body, &Fmt::default(), depth, refs); + p.inlines = parse_inlines(body, &Fmt::default(), ctx.depth, ctx.refs); out.push(FBlock::Para(p)); + ctx.record_line(i); i += 1; continue; } if thematic_break(t) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); out.push(FBlock::Para(Para::new(Kind::Rule))); + ctx.record_line(i); i += 1; continue; } if let Some((fc, fl)) = fence(t) { - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); i += 1; while let Some(l) = lines.get(i) { + let cur = i; i += 1; let lt = l.trim_start_matches([' ', '\t']); if indent(l) < 4 && fence(lt).is_some_and(|(c, n)| c == fc && n >= fl) && lt.trim_start_matches(fc).trim().is_empty() { break; } out.push(code_para(strip_indent(l, ind))); + ctx.record_line(cur); } continue; } if t.starts_with("") { @@ -486,89 +624,58 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: continue; } if t.starts_with('>') { - flush_para(&mut para, &mut out, depth, refs); - parse_quote(lines, &mut i, depth, level, refs, budget, &mut out); + flush_para(&mut para, &mut para_start, &mut out, ctx); + let quote_start = i; + let before = out.len(); + parse_quote(lines, &mut i, ctx, &mut out); + ctx.record_lines(quote_start, out.len() - before); continue; } - if para.is_empty() - && t.contains('|') - && let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) - { - let (head, head_width) = split_row(t, MAX_COLS); - if head_width == width { - let mut table = FTable::default(); - let cell = |s: &str, header: bool, a: Option| { - let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() }; - Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } - }; - // The document keeps no more columns or rows than this, nor cells past its budget, - // so those aren't built; their lines are still the table's. - let cols = aligns.len(); - let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols; - let mut cut = head_width > cols; - if fits(0) { - budget.set(budget.get() - cols); - table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); - } else { - cut = true; - } - i += 2; - while let Some(l) = lines.get(i) { - if is_blank(l) || !l.contains('|') || interrupts(l) { - break; - } - if !table.rows.is_empty() && fits(table.rows.len()) { - budget.set(budget.get() - cols); - let (mut cells, _) = split_row(l, cols); - cells.resize(cols, String::new()); - table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); - } else { - cut = true; - } - i += 1; - } - if cut { - model::dropped(model::Dropped::TableCells); - } - if !table.rows.is_empty() { - out.push(FBlock::Table(table)); - } - continue; - } + if para.is_empty() && t.contains('|') && parse_table(lines, &mut i, t, ctx, &mut out) { + continue; } if let Some(m) = list_marker(t) && (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))) { - flush_para(&mut para, &mut out, depth, refs); - i = parse_list(lines, i, depth, level, &mut out, refs, budget); + flush_para(&mut para, &mut para_start, &mut out, ctx); + i = parse_list(lines, i, ctx, &mut out); continue; } + if para_start.is_none() { + para_start = Some(i); + } para.push(line.clone()); i += 1; } - flush_para(&mut para, &mut out, depth, refs); + flush_para(&mut para, &mut para_start, &mut out, ctx); out } -fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec) { - let mut code: Vec<&str> = Vec::new(); +/// Parses indented code block starting at `lines[*i]`. +fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec, ctx: &mut BlockCtx<'_>) { + let mut code: Vec<(usize, &str)> = Vec::new(); while let Some(line) = lines.get(*i) { + let cur = *i; if is_blank(line) { - code.push(""); + code.push((cur, "")); } else if indent(line) >= 4 { - code.push(strip_indent(line, 4)); + code.push((cur, strip_indent(line, 4))); } else { break; } *i += 1; } - while code.last().is_some_and(|line| line.is_empty()) { + while code.last().is_some_and(|(_, line)| line.is_empty()) { code.pop(); } - out.extend(code.into_iter().map(code_para)); + for (line_idx, text) in code { + out.push(code_para(text)); + ctx.record_line(line_idx); + } } -fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell, out: &mut Vec) { +/// Parses block quote starting at `lines[*i]`. +fn parse_quote(lines: &[String], i: &mut usize, ctx: &BlockCtx<'_>, out: &mut Vec) { let mut inner = Vec::new(); while let Some(line) = lines.get(*i) { let trimmed = line.trim_start_matches([' ', '\t']); @@ -582,13 +689,14 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: & } *i += 1; } - if depth >= MAX_NEST { + if ctx.depth >= MAX_NEST { let mut p = Para::new(Kind::Quote); p.push_text(&inner.join(" "), &Fmt::default()); out.push(FBlock::Para(p)); return; } - for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) { + let mut child = BlockCtx { depth: ctx.depth + 1, level: ctx.level, refs: ctx.refs, budget: ctx.budget, lines_out: None }; + for mut block in parse_blocks(&inner, &mut child) { if let FBlock::Para(p) = &mut block && p.kind == Kind::Normal { @@ -598,6 +706,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: & } } +/// Computes columns and byte count for spaces following a list item marker. fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { let mut cols = 0; let mut bytes = 0; @@ -626,7 +735,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { } /// Parse a list starting at `lines[i]`; returns the index after it. -fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec, refs: &Refs, budget: &std::cell::Cell) -> usize { +fn parse_list(lines: &[String], mut i: usize, ctx: &mut BlockCtx<'_>, out: &mut Vec) -> usize { let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 }; while let Some(line) = lines.get(i) { let ind = indent(line); @@ -638,6 +747,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut if m.ordered != first.ordered || m.ch != first.ch { break; } + let item_start = i; let after = t.get(m.len..).unwrap_or(""); let (spaces, skip) = if indent(after) > 4 { (1, 1) @@ -667,14 +777,17 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut while item.len() > 1 && item.last().is_some_and(String::is_empty) { item.pop(); } - let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start, ..Default::default() }; - let inner = if depth >= MAX_NEST { + let li = ListInfo { ordered: first.ordered, level: ctx.level.min(8), start: first.start, ..Default::default() }; + let inner = if ctx.depth >= MAX_NEST { let mut p = Para::default(); p.push_text(&item.join(" "), &Fmt::default()); vec![FBlock::Para(p)] } else { - parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget) + let mut child = + BlockCtx { depth: ctx.depth + 1, level: ctx.level.saturating_add(1).min(8), refs: ctx.refs, budget: ctx.budget, lines_out: None }; + parse_blocks(&item, &mut child) }; + let before_count = out.len(); let mut placed = false; if inner.is_empty() { out.push(FBlock::Para(Para { list: Some(li), ..Default::default() })); @@ -712,6 +825,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut } } } + ctx.record_lines(item_start, out.len() - before_count); // Blank lines between items. while lines.get(i).is_some_and(|l| is_blank(l)) { let next_is_item = lines.get(i + 1).is_some_and(|l| { diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 24bbea36ee..439166c96e 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -776,6 +776,7 @@ fn exported_link_destinations_read_back_unchanged() { assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)"); } +/// Verifies that trailing escaped hashes on headings survive round-trip export. #[test] fn heading_trailing_hash_survives_round_trip() { let heading = |md: &str| { @@ -797,3 +798,52 @@ fn heading_trailing_hash_survives_round_trip() { assert_eq!(export(&import(&md)), want, "second export of {src:?}"); } } + +/// Verifies that block line mapping tracks source-line indexes accurately across definitions. +#[test] +fn block_line_mapping() { + let md = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\n```\ncode\n```\n\nLast paragraph.\n"; + let (flow, lines) = parse_with_lines(md); + assert_eq!(flow.blocks.len(), lines.len()); + let (doc, doc_lines) = import_on_with_lines(md, &wordcraft_doc::SectionProps::default()); + assert_eq!(doc.body.len(), doc_lines.len()); + assert_eq!(lines, doc_lines); + assert_eq!(lines[0], 0); + assert_eq!(lines[1], 2); + assert_eq!(lines[2], 4); + + // Reference definitions are removed from blocks, but block line numbers must match original source lines. + let md_refs = "[link]: https://example.com\n# Title\n\n[other]: https://example.org\nParagraph with [link][].\n"; + let (flow, lines) = parse_with_lines(md_refs); + assert_eq!(flow.blocks.len(), lines.len()); + assert_eq!(lines[0], 1, "heading on line 1 after definition on line 0"); + assert_eq!(lines[1], 4, "paragraph on line 4 after definition on line 3"); +} + +/// Verifies linear remapping of original lines after removing definitions. +#[test] +fn remap_original_lines_cases() { + let mut lines = vec![0, 1, 2, 3, 4]; + remap_original_lines(&mut lines, &[2, 5]); + assert_eq!(lines, vec![0, 1, 3, 4, 6]); + + let mut lines = vec![0, 1]; + remap_original_lines(&mut lines, &[1, 2, 3]); + assert_eq!(lines, vec![0, 4]); + + let mut lines = vec![0]; + remap_original_lines(&mut lines, &[0]); + assert_eq!(lines, vec![1]); + + let mut lines = vec![1, 1, 2]; + remap_original_lines(&mut lines, &[1]); + assert_eq!(lines, vec![2, 2, 3]); + + let mut lines = vec![0, 2]; + remap_original_lines(&mut lines, &[]); + assert_eq!(lines, vec![0, 2]); + + let mut empty: Vec = Vec::new(); + remap_original_lines(&mut empty, &[1, 2]); + assert!(empty.is_empty()); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b2533e5f1f..77c2b82580 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -22,6 +22,7 @@ mod pictures; mod prefs; pub(crate) use commands::works_without_document; +pub(crate) use frame::STATUS_MS; pub(crate) use input::control_click_as_secondary; impl WordApp { diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 568c7ef839..b3c6628fb8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -28,6 +28,10 @@ impl WordApp { /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). pub fn run(&mut self, id: &str, params: Value) -> Result { + // While Markdown Source is open, Undo and Redo work on its typing, as Ctrl+Z does there. + if matches!(id, "edit.undo" | "edit.redo") && self.session.md_source.is_some() { + return crate::md_source::undo(self, id == "edit.redo"); + } // Comment text still being typed is written first, so a save or export has it and a // close, New or Open counts it as a change (#1348). A draft the document refuses is not // part of it: saving and exporting go on without it (the refusal stays on the status @@ -256,6 +260,13 @@ impl WordApp { { self.autosave_path = self.session.path.clone(); } + // A Markdown file edited in the formatted view was written out again, not kept as it was. + if let Ok(v) = &r + && matches!(id, "file.save" | "file.saveAs") + && v.get("rewritten").and_then(Value::as_bool) == Some(true) + { + self.status_long(tl!("Saved. Edits in the formatted view meant writing the Markdown out again, so its own layout wasn't kept.")); + } // A renamed file is the same file: AutoSave keeps covering it, and Recent drops the old name. if r.is_ok() && id == "file.rename" diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 64c38ee683..2cf1692c90 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -297,16 +297,19 @@ impl WordApp { self.default_save_name() } - /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros), - /// otherwise .docx. A document never saved and without a title of its own is named after its - /// first words, as Word suggests them (#5793), instead of `Document1`. + /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros) or, + /// in a dialog, its own Markdown format (a browser's download keeps the whole document as + /// .docx), otherwise .docx. A document never saved and without a title of its own is named after + /// its first words, as Word suggests them (#5793), instead of `Document1`. pub(crate) fn default_save_name(&self) -> String { let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string()); let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty(); let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t)); let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem()); + let markdown = + self.services.download.is_none() && self.session.path.as_deref().is_some_and(wordcraft_engine::cmd::md_source::is_markdown_file); match ext { - Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"), + Some(e) if markdown || wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"), _ => format!("{stem}.docx"), } } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index cd52541c99..41d5f88111 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -7,6 +7,11 @@ use super::{ use crate::i18n; +/// How long the status bar shows a message, in milliseconds. +pub(crate) const STATUS_MS: f64 = 6000.0; +/// How long [`WordApp::status_long`] shows one: a warning that is easy to miss in six seconds. +const STATUS_LONG_MS: f64 = 20000.0; + impl WordApp { /// A file that wouldn't open (#1103): log it, and say on the status bar which file and why, /// whatever handed it over (the command line, a macOS open event, a drop). Returns the message. @@ -17,10 +22,17 @@ impl WordApp { msg } + /// Sets a temporary status message in the bottom status bar. pub fn status(&mut self, s: impl Into) { self.status_msg = Some((s.into(), now_ms())); } + /// A status message that stays [`STATUS_LONG_MS`] instead of the usual six seconds: the bar + /// shows a message until six seconds after its time, so its time is set that much later. + pub fn status_long(&mut self, s: impl Into) { + self.status_msg = Some((s.into(), now_ms() + STATUS_LONG_MS - STATUS_MS)); + } + /// Write the current preferences out through the host's hook, if it has one (#2716). The /// desktop app saves them at exit; a change that should survive a crash, like taking a /// document off the recent list, needs a save at the moment of the change. `None`: the host @@ -99,7 +111,11 @@ impl WordApp { } let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { - canvas::show(self, ui); + if self.session.md_source.is_some() { + crate::md_source::show(self, ui); + } else { + canvas::show(self, ui); + } }); if self.focus_ribbon && !moved(self) { chrome::focus_ribbon(self, ctx); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b74b63b6d3..837235ef60 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -53,6 +53,8 @@ const QAT_MENU: [&str; 10] = /// Match the toolbar's enabled state for keyboard activation too. pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool { match id { + // The Markdown Source pane's arrows undo its typing. + "edit.undo" | "edit.redo" if app.session.md_source.is_some() => crate::md_source::can_undo(app, id == "edit.redo"), "edit.undo" => app.session.can_undo(), "edit.redo" => app.session.can_redo(), "file.save" => app.has_document(), @@ -82,6 +84,8 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) { } crate::widgets::command_menu(&resp, app, id); if id == "edit.undo" { + // The list is the document's steps, not the Markdown Source pane's typing. + let enabled = enabled && app.session.md_source.is_none(); undo_list_button(ui, app, enabled); } } @@ -962,7 +966,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { } }); if let Some((msg, at)) = &app.status_msg - && crate::now_ms() - at < 6000.0 + && crate::now_ms() - at < crate::app::STATUS_MS { let font = TypeRung::Caption.regular(); let track = TypeRung::Caption.tracking(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 33ccaec873..117349384e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1034,6 +1034,15 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool { pen.line(&[(12.5, 15.5), (17.0, 15.5)]); } "draft" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]), + // A heading mark, then lines of text. + "markdownSource" => { + pen.line_c(&[(4.0, 3.0), (4.0, 9.0)], a); + pen.line_c(&[(7.5, 3.0), (7.5, 9.0)], a); + pen.line_c(&[(2.5, 5.0), (9.0, 5.0)], a); + pen.line_c(&[(2.5, 7.0), (9.0, 7.0)], a); + pen.lines(3.0, 17.0, &[12.0, 15.0]); + pen.lines(11.0, 17.0, &[4.0, 7.0]); + } "focus" => { pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]); pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 1ebd6679a7..085a006013 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -374,6 +374,7 @@ fn controls(tab: &str) -> Vec { c("Read Mode", "view.readMode"), c("Print Layout", "view.printLayout"), c("Web Layout", "view.webLayout"), + c("Markdown Source", "view.markdownSource"), c("Outline", "view.outline"), c("Draft", "view.draft"), c("Focus", "view.focus"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e25e4d1ca..659466d177 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,7 @@ pub mod keys; pub mod keytips; pub(crate) mod language_packs; mod local_time; +pub mod md_source; pub mod mini_toolbar; pub mod objects; pub mod options; diff --git a/crates/ui-egui/src/md_source.rs b/crates/ui-egui/src/md_source.rs new file mode 100644 index 0000000000..c1f49a941e --- /dev/null +++ b/crates/ui-egui/src/md_source.rs @@ -0,0 +1,386 @@ +//! View › Markdown Source: the text pane that takes the page's place. The text itself and what it +//! means for the document are the engine's (`wordcraft_engine::cmd::md_source`). + +use egui::text::{CCursor, CCursorRange}; +use egui::{FontId, Key, KeyboardShortcut, Modifiers, Ui}; +use serde_json::json; + +use crate::WordApp; +use crate::clock::now_ms; +use crate::theme::Tokens; + +/// How often a text with edits reaches the document while it is being typed, in milliseconds, so +/// crash recovery has them. +const APPLY_EVERY_MS: f64 = 5000.0; + +/// Draw the pane in the place of the page. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + // The page isn't drawn, so it can't have the keyboard. + app.canvas.focused = false; + app.canvas.want_focus = false; + // A focused text box turns the global shortcuts off. + if ui.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::S))) { + let _ = app.run("file.save", json!({})); + } + let now = now_ms(); + if app.session.md_source.as_ref().is_some_and(|src| src.is_pending() && now - src.applied_at_ms > APPLY_EVERY_MS) { + wordcraft_engine::cmd::md_source::apply(&mut app.session); + if let Some(src) = app.session.md_source.as_mut() { + src.applied_at_ms = now; + } + } + let doc_id = app.session.document_id(); + let id = box_id(doc_id); + let mut changed = false; + let mut refused = false; + { + let Some(src) = app.session.md_source.as_mut() else { return }; + if src.reset_undo { + src.reset_undo = false; + reset_undo_history(ui.ctx(), doc_id); + } + if src.loses_content { + egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(12, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(tl!( + "This document has content Markdown can't hold. Changing the text here removes it; one Undo afterwards brings it back." + )); + }); + } + let has_input = ui.input(|i| i.events.iter().any(|e| !matches!(e, egui::Event::PointerMoved(_)))); + let prev_text = if has_input { Some(src.text.clone()) } else { None }; + let prev_caret = src.caret; + egui::ScrollArea::vertical().id_salt(id).auto_shrink(false).show(ui, |ui| { + let size = ui.available_size(); + let mut out = egui::TextEdit::multiline(&mut src.text) + .id(id) + .hint_text(tl!("Markdown Source")) + .font(FontId::monospace(14.0)) + .desired_width(f32::INFINITY) + .min_size(size) + .frame(egui::Frame::NONE.inner_margin(16)) + .text_color(t.text) + .char_limit(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES) + .lock_focus(true) + .show(ui); + changed = out.response.changed(); + if changed && src.text.len() > wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES { + if let Some(prev) = prev_text { + src.text = prev; + src.caret = prev_caret; + } else { + let limit = src.text.floor_char_boundary(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES); + src.text.truncate(limit); + src.caret = src.caret.min(limit); + } + changed = false; + refused = true; + src.place_caret = true; + } + let caret_changed = if src.place_caret { + src.place_caret = false; + let char_idx = src.text.get(..src.caret).map_or(0, |s| s.chars().count()); + let caret = CCursor::new(char_idx); + out.state.cursor.set_char_range(Some(CCursorRange::one(caret))); + out.state.store(ui.ctx(), id); + out.response.request_focus(); + let spot = out.galley.pos_from_cursor(caret).translate(out.galley_pos.to_vec2()); + ui.scroll_to_rect(spot, Some(egui::Align::Center)); + true + } else if let Some(range) = out.cursor_range { + let old_caret = src.caret; + let char_idx: usize = range.primary.index.into(); + src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b); + old_caret != src.caret + } else { + false + }; + if changed || caret_changed { + invalidate_undo_cache(ui.ctx(), doc_id); + } + }); + } + if refused { + app.status(tl!("Markdown Source takes up to 2 MB of text")); + } + if changed { + app.session.dirty = true; + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(APPLY_EVERY_MS / 1000.0)); + } +} + +/// The text box of a document's pane, found the same way by Undo and Redo as by the pane. +fn box_id(document: u64) -> egui::Id { + egui::Id::unique("md_source").with(document) +} + +/// The context storage key for caching the pane's undo/redo availability. +fn undo_cache_id(document: u64) -> egui::Id { + egui::Id::unique("md_source_undo_cache").with(document) +} + +/// Cached undo and redo capabilities for a document's Markdown Source text box. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CachedUndoState { + can_undo: bool, + can_redo: bool, +} + +/// Invalidates the cached undo and redo capability for `document`. +pub(crate) fn invalidate_undo_cache(ctx: &egui::Context, document: u64) { + ctx.data_mut(|d| d.remove::(undo_cache_id(document))); +} + +/// Clears the stored typing history for `document`'s Markdown Source text box and invalidates the cache. +pub(crate) fn reset_undo_history(ctx: &egui::Context, document: u64) { + let id = box_id(document); + if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) { + state.clear_undoer(); + state.store(ctx, id); + } + invalidate_undo_cache(ctx, document); +} + +/// A text box's state for its typing history: the selection and the text. +type Typed = (CCursorRange, String); + +/// The text box's typing history, with the state Undo and Redo start from. +fn history(app: &WordApp) -> Option<(egui::Context, egui::text_edit::TextEditState, egui::util::undoer::Undoer, Typed)> { + let ctx = app.ctx.clone()?; + let src = app.session.md_source.as_ref()?; + let mut state = egui::text_edit::TextEditState::load(&ctx, box_id(app.session.document_id()))?; + if src.reset_undo { + state.clear_undoer(); + } + let undoer = state.undoer(); + let caret_chars = src.text[..src.text.floor_char_boundary(src.caret)].chars().count(); + let range = state.cursor.char_range().unwrap_or(CCursorRange::one(CCursor::new(caret_chars))); + Some((ctx, state, undoer, (range, src.text.clone()))) +} + +/// Whether the pane's Undo (or Redo, with `redo`) has a step to take. +pub fn can_undo(app: &WordApp, redo: bool) -> bool { + let Some(ctx) = app.ctx.as_ref() else { return false }; + let doc_id = app.session.document_id(); + if app.session.md_source.as_ref().is_some_and(|s| s.reset_undo) { + reset_undo_history(ctx, doc_id); + } + let cache_key = undo_cache_id(doc_id); + if let Some(cached) = ctx.data(|d| d.get_temp::(cache_key)) { + return if redo { cached.can_redo } else { cached.can_undo }; + } + let Some((ctx, _, undoer, now)) = history(app) else { return false }; + let can_u = undoer.has_undo(&now); + let can_r = undoer.has_redo(&now); + ctx.data_mut(|d| d.insert_temp(cache_key, CachedUndoState { can_undo: can_u, can_redo: can_r })); + if redo { can_r } else { can_u } +} + +/// Undo (or Redo, with `redo`) typing in the pane, as Ctrl+Z (Ctrl+Y) does in it: the toolbar's +/// arrows and the Undo and Redo commands work on the text while it is open, not on the document. +pub fn undo(app: &mut WordApp, redo: bool) -> Result { + let Some((ctx, mut state, mut undoer, now)) = history(app) else { return Err(tl!("Nothing to undo").into()) }; + let step = if redo { undoer.redo(&now) } else { undoer.undo(&now) }.cloned(); + let Some((range, text)) = step else { + return Err(if redo { tl!("Nothing to redo") } else { tl!("Nothing to undo") }.into()); + }; + state.cursor.set_char_range(Some(range)); + state.set_undoer(undoer); + state.store(&ctx, box_id(app.session.document_id())); + if let Some(src) = app.session.md_source.as_mut() { + src.text = text; + let char_idx: usize = range.primary.index.into(); + src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b); + } + invalidate_undo_cache(&ctx, app.session.document_id()); + app.session.dirty = true; + ctx.request_repaint(); + Ok(json!({"text": true})) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + + /// Builds a test harness for Markdown Source UI integration tests. + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ) + } + + /// A saved Markdown file, with its folder for the test to remove. + fn opened(name: &str) -> (WordApp, std::path::PathBuf) { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "wordcraft-ui-md-source-{name}-{}-{}-{}", + std::process::id(), + id, + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("note.md"); + std::fs::write(&path, "# Heading\n\nSome text.\n").unwrap(); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + (a, dir) + } + + /// Verifies Markdown Source pane displays text and captures keyboard focus. + #[test] + fn the_pane_shows_the_text_and_takes_the_keyboard() { + let (mut a, dir) = opened("keys"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + assert!(!h.state().canvas.focused, "the page isn't drawn"); + assert!(h.ctx.text_edit_focused(), "the text box has the keyboard"); + assert!(h.get_all_by_value("# Heading\n\nSome text.\n").next().is_some(), "the Markdown is shown"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies typing in source view marks document dirty and Ctrl+S saves it. + #[test] + fn typing_is_an_unsaved_change_and_ctrl_s_saves_it() { + let (mut a, dir) = opened("save"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + h.state_mut().session.md_source.as_mut().unwrap().text.insert_str(0, "Typed. "); + h.state_mut().session.dirty = true; + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + h.run(); + let saved = std::fs::read_to_string(dir.join("note.md")).unwrap(); + assert!(saved.contains("Typed."), "{saved}"); + assert!(!h.state().session.dirty); + assert!(h.state().session.md_source.is_some(), "saving keeps the pane"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies QAT undo and redo arrows work for typing in the source pane. + #[test] + fn the_arrows_undo_and_redo_typing_in_the_pane() { + let (mut a, dir) = opened("arrows"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "nothing typed yet"); + h.event(egui::Event::Text("Typed. ".into())); + h.run(); + let typed = h.state().session.md_source.as_ref().unwrap().text.clone(); + assert!(typed.contains("Typed. "), "{typed}"); + assert!(crate::chrome::qat_enabled(h.state(), "edit.undo")); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run(); + assert_eq!(h.state().session.md_source.as_ref().unwrap().text, "# Heading\n\nSome text.\n", "Undo takes the typing back"); + assert!(h.state().session.md_source.is_some(), "and stays in the pane"); + assert!(crate::chrome::qat_enabled(h.state(), "edit.redo")); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run(); + assert_eq!(h.state().session.md_source.as_ref().unwrap().text, typed, "Redo puts it back"); + assert!(h.state_mut().run("edit.redo", json!({})).is_err(), "nothing more to redo"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies rewrite warning stays displayed for twenty seconds on the status bar. + #[test] + fn the_rewrite_warning_stays_longer_than_other_messages() { + let (mut a, dir) = opened("warning"); + a.run("text.insert", json!({"text": "Formatted edit. "})).unwrap(); + a.run("file.save", json!({})).unwrap(); + let (msg, at) = a.status_msg.clone().unwrap(); + assert!(msg.contains("writing the Markdown out again"), "{msg}"); + let left = at + crate::app::STATUS_MS - now_ms(); + assert!(left > 15_000.0, "shown for about 20 seconds, {left} ms left"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies formatting ribbon controls are greyed out while Markdown Source pane is active. + #[test] + fn the_ribbon_greys_out_edits_while_the_pane_is_open() { + let (mut a, dir) = opened("grey"); + assert_eq!(a.session.why_disabled("format.bold"), None); + a.run("view.markdownSource", json!({})).unwrap(); + assert_eq!(a.session.why_disabled("format.bold"), Some("leave Markdown Source first")); + assert_eq!(a.session.why_disabled("view.markdownSource"), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies that typing or pasting beyond `MAX_SOURCE_BYTES` is refused, keeping the previous text and warning. + #[test] + fn text_exceeding_max_source_bytes_is_refused_and_warns() { + let (mut a, dir) = opened("oversize"); + a.run("view.markdownSource", json!({})).unwrap(); + let limit = wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES; + let half = limit / 2; + let original = format!("START {} END", "é".repeat(half - 10)); + let original_len = original.len(); + a.session.md_source.as_mut().unwrap().text = original.clone(); + let mut h = harness(a); + h.run(); + h.event(egui::Event::Text("é".repeat(100))); + h.run(); + let current_text = h.state().session.md_source.as_ref().unwrap().text.clone(); + assert_eq!(current_text, original, "over-limit text must be refused without truncating document end"); + assert_eq!(current_text.len(), original_len); + let status = h.state().status_msg.as_ref().map(|(s, _)| s.as_str()); + assert_eq!(status, Some("Markdown Source takes up to 2 MB of text")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies that pointer movement alone does not take a snapshot or alter Markdown Source state. + #[test] + fn pointer_movement_does_not_mutate_or_affect_markdown_source() { + let (mut a, dir) = opened("pointer"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + let before_text = h.state().session.md_source.as_ref().unwrap().text.clone(); + h.event(egui::Event::PointerMoved(egui::pos2(100.0, 200.0))); + h.run(); + assert_eq!(h.state().session.md_source.as_ref().unwrap().text, before_text); + assert!(!h.state().session.dirty); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Verifies that reopening Markdown Source after formatted edits resets undo history + /// so that previous visit edits cannot be restored by Undo. + #[test] + fn reopening_pane_resets_undo_history_from_previous_visit() { + let (mut a, dir) = opened("reopen_undo"); + a.run("view.markdownSource", json!({})).unwrap(); + let mut h = harness(a); + h.run(); + h.event(egui::Event::Text("First visit. ".into())); + h.run(); + assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo enabled after typing"); + + // Leave pane back to formatted view + h.state_mut().run("view.markdownSource", json!({})).unwrap(); + h.run(); + assert!(h.state().session.md_source.is_none(), "left source view"); + + // Make formatted edits in document + h.state_mut().run("text.insert", json!({"text": "Formatted edit. "})).unwrap(); + h.run(); + + // Reopen pane + h.state_mut().run("view.markdownSource", json!({})).unwrap(); + h.run(); + + // Undo must NOT be enabled for the Markdown pane, and edit.undo must return error + assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo must be reset on reopening pane"); + assert!(h.state_mut().run("edit.undo", json!({})).is_err(), "undo command must fail when nothing typed in current visit"); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs index c9226a2c65..5f94be3d42 100644 --- a/crates/ui-egui/src/ribbon/view.rs +++ b/crates/ui-egui/src/ribbon/view.rs @@ -5,6 +5,7 @@ use super::{Ui, WordApp, big, big_toggle, group, json, menu_button, small, vec2} /// and Templates groups. pub(super) fn view(app: &mut WordApp, ui: &mut Ui) { let v = app.session.view.clone(); + let v_source = app.session.md_source.is_some(); group(ui, "Views", None, app, |ui, app| { big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false); big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false); @@ -12,6 +13,8 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) { stack(ui, |ui| { small(ui, app, "outline", Some("Outline"), "Outline", crate::widgets::ButtonAction::new("view.outline", json!({}), false)); small(ui, app, "draft", Some("Draft"), "Draft", crate::widgets::ButtonAction::new("view.draft", json!({}), false)); + let source = crate::widgets::ButtonAction::new("view.markdownSource", json!({}), v_source); + small(ui, app, "markdownSource", Some("Markdown"), "Markdown Source", source); }); }); group(ui, "Immersive", None, app, |ui, app| { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 9d9af6b0d4..e14640a2d2 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -3274,6 +3274,20 @@ fn window_close_save_writes_the_file_and_quits() { let _ = std::fs::remove_dir_all(&dir); } +/// Save As suggests a Markdown file's own format, as it does a Word file's; anything else is .docx. +#[test] +fn save_as_suggests_a_markdown_files_own_format() { + let dir = scratch("save-name"); + for (name, suggested) in [("notes.md", "notes.md"), ("notes.markdown", "notes.markdown"), ("notes.MD", "notes.MD"), ("notes.txt", "notes.docx")] { + let path = dir.join(name); + std::fs::write(&path, b"Hello\n").unwrap(); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(a.default_save_name(), suggested, "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// #2376: a text file opened, edited and saved still asked to save on close, and Save in that /// prompt wrote the file but left the window open; only Don't Save closed it. #[test]