From a3d9f089a38b804af90efe423b43b99fabd54b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 04:28:33 -0400 Subject: [PATCH] documents: undo and redo leave a block only past its own history; typing lands after undo empties the document Co-Authored-By: Claude Opus 5.5 (1M context) --- src/documents/document/edit.rs | 17 ++-- src/documents/document/tests/history.rs | 115 ++++++++++++++++++++++++ src/documents/document/tests/mod.rs | 78 +--------------- src/documents/document/visual.rs | 6 +- src/forms/text/mod.rs | 8 ++ 5 files changed, 137 insertions(+), 87 deletions(-) create mode 100644 src/documents/document/tests/history.rs diff --git a/src/documents/document/edit.rs b/src/documents/document/edit.rs index 3dcd3f0..eee799a 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -23,19 +23,19 @@ fn moved(at: usize, edit: &Option<(Range, usize)>) -> Option { } impl MarkdownDocument { - /// An empty visual document takes typing at once. + /// An empty, read visual document takes typing at once. pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context) { - let empty = self.editor.read(cx).text().trim().is_empty(); + let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx); if self.mode == MarkdownMode::Visual && self.open.is_none() && empty { self.open(None, false, window, cx); } } - /// The open block still holds what it opened with. - pub(super) fn untouched(&self, cx: &App) -> bool { - self.open.as_ref().is_some_and(|open| { - open.input.read(cx).text() == &self.editor.read(cx).text()[open.range.clone()] - }) + /// The open block has no step left that way. + pub(super) fn at_edge_of_history(&self, redo: bool, cx: &App) -> bool { + self.open + .as_ref() + .is_some_and(|open| !open.input.read(cx).can_step(redo)) } /// Undo or redo in the document's one history. @@ -46,7 +46,8 @@ impl MarkdownDocument { false => editor.undo(cx), }); window.focus(&self.focus, cx); - self.focused(window, cx); + // Once the change is read, an empty end opens. + cx.defer_in(window, |document, window, cx| document.focused(window, cx)); } /// Opens block `ix` as shown, or one past the end. diff --git a/src/documents/document/tests/history.rs b/src/documents/document/tests/history.rs new file mode 100644 index 0000000..133e3b0 --- /dev/null +++ b/src/documents/document/tests/history.rs @@ -0,0 +1,115 @@ +use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext}; + +use super::{MarkdownDocument, MarkdownMode, TEXT, open, opened, press, text}; + +/// Focus on `document`'s own handle, as an owner gives it. +fn focus(document: &Entity, cx: &mut VisualTestContext) { + let handle = document.read_with(cx, |document, cx| { + gpui::Focusable::focus_handle(document, cx) + }); + cx.update(|window, cx| window.focus(&handle, cx)); + cx.run_until_parked(); +} + +#[gpui::test] +fn an_empty_visual_document_takes_typing_on_focus(cx: &mut TestAppContext) { + let (document, cx) = open("", MarkdownMode::Visual, cx); + focus(&document, cx); + cx.simulate_input("Hi"); + assert_eq!(text(&document, cx), "Hi", "typing lands with no press"); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert_eq!(text(&document, cx), "Hi", "escape keeps it"); +} + +#[gpui::test] +fn focus_opens_no_block_in_a_written_document(cx: &mut TestAppContext) { + let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); + focus(&document, cx); + cx.simulate_input("x"); + assert_eq!(text(&document, cx), TEXT); +} + +fn mode(document: &Entity, mode: MarkdownMode, cx: &mut VisualTestContext) { + document.update_in(cx, |document, window, cx| { + document.set_mode(mode, window, cx) + }); + cx.run_until_parked(); +} + +#[gpui::test] +fn preview_and_back_still_take_typing_in_an_empty_document(cx: &mut TestAppContext) { + let (document, cx) = open("", MarkdownMode::Visual, cx); + focus(&document, cx); + cx.simulate_keystrokes("escape"); + mode(&document, MarkdownMode::Preview, cx); + mode(&document, MarkdownMode::Visual, cx); + cx.simulate_input("Hi"); + assert_eq!(text(&document, cx), "Hi"); +} + +#[gpui::test] +fn undo_in_an_untouched_block_steps_the_document(cx: &mut TestAppContext) { + let (document, cx) = open("Seed", MarkdownMode::Source, cx); + document.update(cx, |document, cx| document.set_text("", cx)); + mode(&document, MarkdownMode::Visual, cx); + cx.simulate_keystrokes("secondary-z"); + cx.run_until_parked(); + assert_eq!(text(&document, cx), "Seed", "the source edit is undone"); + cx.simulate_keystrokes("secondary-shift-z"); + cx.run_until_parked(); + assert_eq!(text(&document, cx), "", "and redone"); +} + +#[gpui::test] +fn undo_in_a_typed_block_stays_in_the_block(cx: &mut TestAppContext) { + let (document, cx) = open("Seed", MarkdownMode::Source, cx); + document.update(cx, |document, cx| document.set_text(TEXT, cx)); + mode(&document, MarkdownMode::Visual, cx); + press(1, Modifiers::none(), cx); + cx.simulate_input(" x"); + cx.simulate_keystrokes("secondary-z"); + cx.run_until_parked(); + assert_eq!( + text(&document, cx), + TEXT, + "the typing went, not the edit before" + ); + assert_eq!(opened(&document, cx), "Blend toward white.", "still open"); +} + +#[gpui::test] +fn redo_after_undo_stays_in_the_block(cx: &mut TestAppContext) { + let (document, cx) = open("", MarkdownMode::Visual, cx); + focus(&document, cx); + cx.simulate_input("Hi"); + cx.simulate_keystrokes("secondary-z"); + assert_eq!(text(&document, cx), ""); + cx.simulate_keystrokes("secondary-shift-z"); + assert_eq!(text(&document, cx), "Hi", "the block's own redo"); + assert_eq!(opened(&document, cx), "Hi", "the block stays open"); +} + +#[gpui::test] +fn redo_after_undo_in_a_written_block_stays_in_it(cx: &mut TestAppContext) { + let (document, cx) = open("Seed", MarkdownMode::Visual, cx); + press(0, Modifiers::none(), cx); + cx.simulate_input("!"); + cx.simulate_keystrokes("secondary-z secondary-shift-z"); + assert_eq!(text(&document, cx), "Seed!"); + assert_eq!(opened(&document, cx), "Seed!", "the block stays open"); +} + +#[gpui::test] +fn typing_lands_after_undo_empties_the_document(cx: &mut TestAppContext) { + let (document, cx) = open("", MarkdownMode::Visual, cx); + focus(&document, cx); + cx.simulate_input("Hi"); + mode(&document, MarkdownMode::Source, cx); + mode(&document, MarkdownMode::Visual, cx); + cx.simulate_keystrokes("secondary-z"); + cx.run_until_parked(); + assert_eq!(text(&document, cx), ""); + cx.simulate_input("Next"); + assert_eq!(text(&document, cx), "Next"); +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index bcbf851..be20f47 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -22,7 +22,7 @@ pub(super) fn open<'a>( } /// Presses block `ix` where it is drawn. -fn press(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) { +pub(super) fn press(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) { let bounds = cx .debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str())) .expect("the block is drawn"); @@ -365,6 +365,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) { assert_eq!(opened(&document, cx), "B", "once read, B opens whole"); } +mod history; mod place; #[gpui::test] @@ -397,78 +398,3 @@ fn a_text_past_the_largest_read_is_never_read(cx: &mut TestAppContext) { }); assert_eq!((blocks, outline), (0, 0), "the old read is gone"); } - -/// Focus on `document`'s own handle, as an owner gives it. -fn focus(document: &Entity, cx: &mut VisualTestContext) { - let handle = document.read_with(cx, |document, cx| { - gpui::Focusable::focus_handle(document, cx) - }); - cx.update(|window, cx| window.focus(&handle, cx)); - cx.run_until_parked(); -} - -#[gpui::test] -fn an_empty_visual_document_takes_typing_on_focus(cx: &mut TestAppContext) { - let (document, cx) = open("", MarkdownMode::Visual, cx); - focus(&document, cx); - cx.simulate_input("Hi"); - assert_eq!(text(&document, cx), "Hi", "typing lands with no press"); - cx.simulate_keystrokes("escape"); - cx.run_until_parked(); - assert_eq!(text(&document, cx), "Hi", "escape keeps it"); -} - -#[gpui::test] -fn focus_opens_no_block_in_a_written_document(cx: &mut TestAppContext) { - let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); - focus(&document, cx); - cx.simulate_input("x"); - assert_eq!(text(&document, cx), TEXT); -} - -fn mode(document: &Entity, mode: MarkdownMode, cx: &mut VisualTestContext) { - document.update_in(cx, |document, window, cx| { - document.set_mode(mode, window, cx) - }); - cx.run_until_parked(); -} - -#[gpui::test] -fn preview_and_back_still_take_typing_in_an_empty_document(cx: &mut TestAppContext) { - let (document, cx) = open("", MarkdownMode::Visual, cx); - focus(&document, cx); - cx.simulate_keystrokes("escape"); - mode(&document, MarkdownMode::Preview, cx); - mode(&document, MarkdownMode::Visual, cx); - cx.simulate_input("Hi"); - assert_eq!(text(&document, cx), "Hi"); -} - -#[gpui::test] -fn undo_in_an_untouched_block_steps_the_document(cx: &mut TestAppContext) { - let (document, cx) = open("Seed", MarkdownMode::Source, cx); - document.update(cx, |document, cx| document.set_text("", cx)); - mode(&document, MarkdownMode::Visual, cx); - cx.simulate_keystrokes("secondary-z"); - cx.run_until_parked(); - assert_eq!(text(&document, cx), "Seed", "the source edit is undone"); - cx.simulate_keystrokes("secondary-shift-z"); - cx.run_until_parked(); - assert_eq!(text(&document, cx), "", "and redone"); -} - -#[gpui::test] -fn undo_in_a_typed_block_stays_in_the_block(cx: &mut TestAppContext) { - let (document, cx) = open("Seed", MarkdownMode::Source, cx); - document.update(cx, |document, cx| document.set_text(TEXT, cx)); - mode(&document, MarkdownMode::Visual, cx); - press(1, Modifiers::none(), cx); - cx.simulate_input(" x"); - cx.simulate_keystrokes("secondary-z"); - cx.run_until_parked(); - assert_eq!( - text(&document, cx), - TEXT, - "the typing went, not the edit before" - ); -} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 00c4b0e..7074953 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -164,15 +164,15 @@ impl Render for MarkdownDocument { .on_action(cx.listener(|document, _: &Redo, window, cx| { document.history(true, window, cx); })) - // An untouched block leaves undo to the document. + // Past the block's own history, the document's. .capture_action(cx.listener(|document, _: &forms::Undo, window, cx| { - if document.untouched(cx) { + if document.at_edge_of_history(false, cx) { cx.stop_propagation(); document.history(false, window, cx); } })) .capture_action(cx.listener(|document, _: &forms::Redo, window, cx| { - if document.untouched(cx) { + if document.at_edge_of_history(true, cx) { cx.stop_propagation(); document.history(true, window, cx); } diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 5655c9a..91b76fe 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -220,6 +220,14 @@ impl TextInput { &self.label } + /// A step left in that direction of its history. + pub(crate) fn can_step(&self, redo: bool) -> bool { + match redo { + true => self.history.can_redo(), + false => self.history.can_undo(), + } + } + pub fn selection(&self) -> Range { self.selection.clone() }