From 8f19866b7ddcaf06437916ea9a172cc12c337a6b Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Sun, 28 Dec 2025 16:35:43 +0530 Subject: [PATCH 1/3] editor: Implement undo/redo actions Includes time-based transaction grouping for natural typing behavior. Text edits typed rapidly are grouped into single undo units. Selections are tracked and restored for each transaction. Format operations behave as independent transactions. --- crates/buffer/src/buffer.rs | 253 +++++++++++++++++++++++-- crates/editor/Cargo.toml | 1 + crates/editor/src/actions.rs | 8 + crates/editor/src/editor.rs | 223 ++++++++++++++++++---- crates/editor/src/tests.rs | 291 ++++++++++++++++++++++++++--- crates/editor/src/tests/context.rs | 14 +- crates/ryuk/src/main.rs | 4 +- 7 files changed, 717 insertions(+), 77 deletions(-) diff --git a/crates/buffer/src/buffer.rs b/crates/buffer/src/buffer.rs index 24d03e0..94b53ba 100644 --- a/crates/buffer/src/buffer.rs +++ b/crates/buffer/src/buffer.rs @@ -4,14 +4,65 @@ mod selection; pub use format_span::*; pub use selection::*; -use std::ops::Range; - +use std::{ + collections::VecDeque, + ops::Range, + time::{Duration, Instant}, +}; use text::{TextBuffer, TextPoint}; +pub type TransactionId = usize; + +pub struct TransactionContext { + texts: Vec, + format: Option, +} + +#[derive(Clone, Debug)] +pub struct TextOperation { + pub range: Range, + pub before: String, + pub after: String, +} + +impl TextOperation { + pub fn invert(&self) -> Self { + TextOperation { + range: self.range.start..(self.range.start + self.after.len()), + before: self.after.clone(), + after: self.before.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub enum FormatOperation { + ToggleBold(Range), + ToggleItalic(Range), + ToggleUnderline(Range), +} + +#[derive(Clone, Debug)] +pub enum TransactionKind { + Text(Vec), + Format(FormatOperation), +} + +#[derive(Clone, Debug)] +struct Transaction { + id: TransactionId, + timestamp: Instant, + kind: TransactionKind, +} + #[derive(Clone, Debug)] pub struct Buffer { text: TextBuffer, format_spans: Vec, + next_transaction_id: usize, + undo_stack: VecDeque, + redo_stack: VecDeque, + group_interval: Duration, } impl Buffer { @@ -19,6 +70,10 @@ impl Buffer { Self { text: TextBuffer::new(), format_spans: Vec::new(), + next_transaction_id: 0, + undo_stack: VecDeque::new(), + redo_stack: VecDeque::new(), + group_interval: Duration::from_millis(300), } } @@ -26,6 +81,10 @@ impl Buffer { Self { text: TextBuffer::from(text.into().as_str()), format_spans: Vec::new(), + next_transaction_id: 0, + undo_stack: VecDeque::new(), + redo_stack: VecDeque::new(), + group_interval: Duration::from_millis(300), } } @@ -82,34 +141,53 @@ impl Buffer { &self.format_spans } - pub fn insert(&mut self, offset: usize, text: &str) { - let len = text.len(); + pub fn set_format_spans(&mut self, spans: Vec) { + self.format_spans = spans; + } + + pub fn insert(&mut self, tx: &mut TransactionContext, offset: usize, text: &str) { + tx.texts.push(TextOperation { + range: offset..offset, + before: String::new(), + after: text.to_string(), + }); + self.text.insert(offset, text); - let delta = len as isize; - for span in &mut self.format_spans { - span.shift_by_delta(offset, delta); + let delta = text.len() as isize; + for format_span in &mut self.format_spans { + format_span.shift_by_delta(offset, delta); } } - pub fn remove(&mut self, range: Range) { - let len = range.len(); + pub fn remove(&mut self, tx: &mut TransactionContext, range: Range) { + tx.texts.push(TextOperation { + range: range.clone(), + before: self.text.slice(range.clone()).to_string(), + after: String::new(), + }); + self.text.remove(range.clone()); - let delta = -(len as isize); - for span in &mut self.format_spans { - span.shift_by_delta(range.start, delta); + let delta = -(range.len() as isize); + for format_span in &mut self.format_spans { + format_span.shift_by_delta(range.start, delta); } + self.format_spans.retain(|s| !s.range.is_empty()); + } - self.format_spans.retain(|span| !span.range.is_empty()); + pub fn replace(&mut self, tx: &mut TransactionContext, range: Range, text: &str) { + self.remove(tx, range.clone()); + self.insert(tx, range.start, text); } - pub fn replace(&mut self, range: Range, text: &str) { - self.remove(range.clone()); - self.insert(range.start, text); + pub fn toggle_bold(&mut self, tx: &mut TransactionContext, range: Range) { + tx.format = Some(FormatOperation::ToggleBold(range.clone())); + self.toggle_bold_unchecked(range); } - pub fn toggle_bold(&mut self, range: Range) { + /// Toggles bold without recording in transaction history. + fn toggle_bold_unchecked(&mut self, range: Range) { let is_fully_bold = self.is_formatted_with(&range, |span| span.bold); let should_split = |span: &FormatSpan| { @@ -159,7 +237,13 @@ impl Buffer { } } - pub fn toggle_italic(&mut self, range: Range) { + pub fn toggle_italic(&mut self, tx: &mut TransactionContext, range: Range) { + tx.format = Some(FormatOperation::ToggleItalic(range.clone())); + self.toggle_italic_unchecked(range); + } + + /// Toggles italic without recording in transaction history. + fn toggle_italic_unchecked(&mut self, range: Range) { let is_fully_italic = self.is_formatted_with(&range, |span| span.italic); let should_split = |span: &FormatSpan| { @@ -209,7 +293,13 @@ impl Buffer { } } - pub fn toggle_underline(&mut self, range: Range) { + pub fn toggle_underline(&mut self, tx: &mut TransactionContext, range: Range) { + tx.format = Some(FormatOperation::ToggleUnderline(range.clone())); + self.toggle_underline_unchecked(range); + } + + /// Toggles underline without recording in transaction history. + fn toggle_underline_unchecked(&mut self, range: Range) { let is_fully_underline = self.is_formatted_with(&range, |span| span.underline); let should_split = |span: &FormatSpan| { @@ -288,6 +378,131 @@ impl Buffer { }) .is_some_and(|cursor| cursor >= range.end) } + + pub fn transaction(&mut self, now: Instant, f: F) -> TransactionId + where + F: FnOnce(&mut Self, &mut TransactionContext), + { + let transaction_id = self.next_transaction_id; + let mut tx = TransactionContext { + texts: Vec::new(), + format: None, + }; + + f(self, &mut tx); + + if !tx.texts.is_empty() { + self.commit_transaction(TransactionKind::Text(tx.texts), now) + } else if let Some(format_operation) = tx.format { + self.commit_transaction(TransactionKind::Format(format_operation), now) + } else { + transaction_id + } + } + + fn commit_transaction(&mut self, kind: TransactionKind, now: Instant) -> TransactionId { + let transaction_id = self.next_transaction_id; + self.next_transaction_id += 1; + + if let TransactionKind::Text(ref new_text_operations) = kind + && let Some(last) = self.undo_stack.back_mut() + && now.saturating_duration_since(last.timestamp) < self.group_interval + && let TransactionKind::Text(ref mut last_text_operations) = last.kind + { + last_text_operations.extend_from_slice(new_text_operations); + last.timestamp = now; + self.redo_stack.clear(); + return last.id; + } + + self.undo_stack.push_back(Transaction { + id: transaction_id, + timestamp: now, + kind, + }); + self.redo_stack.clear(); + transaction_id + } + + fn exec_format_operation(&mut self, format_operation: &FormatOperation) { + match format_operation { + FormatOperation::ToggleBold(range) => self.toggle_bold_unchecked(range.clone()), + FormatOperation::ToggleItalic(range) => self.toggle_italic_unchecked(range.clone()), + FormatOperation::ToggleUnderline(range) => { + self.toggle_underline_unchecked(range.clone()) + } + } + } + + fn exec_text_operation(&mut self, text_operation: &TextOperation) { + if text_operation.before.is_empty() && !text_operation.after.is_empty() { + self.text + .insert(text_operation.range.start, &text_operation.after); + let delta = text_operation.after.len() as isize; + for span in &mut self.format_spans { + span.shift_by_delta(text_operation.range.start, delta); + } + } else if !text_operation.before.is_empty() && text_operation.after.is_empty() { + self.text.remove(text_operation.range.clone()); + let delta = -(text_operation.before.len() as isize); + for span in &mut self.format_spans { + span.shift_by_delta(text_operation.range.start, delta); + } + self.format_spans.retain(|s| !s.range.is_empty()); + } else { + self.text.remove(text_operation.range.clone()); + if !text_operation.after.is_empty() { + self.text + .insert(text_operation.range.start, &text_operation.after); + } + let delta = text_operation.after.len() as isize - text_operation.before.len() as isize; + for span in &mut self.format_spans { + span.shift_by_delta(text_operation.range.start, delta); + } + self.format_spans.retain(|span| !span.range.is_empty()); + } + } + + pub fn undo(&mut self) -> Option { + let tx = self.undo_stack.pop_back()?; + + match &tx.kind { + TransactionKind::Text(text_operations) => { + for text_operation in text_operations.iter().rev() { + self.exec_text_operation(&text_operation.invert()); + } + } + TransactionKind::Format(format_operation) => { + self.exec_format_operation(format_operation); + } + } + + self.redo_stack.push_back(tx.clone()); + Some(tx.id) + } + + pub fn redo(&mut self) -> Option { + let tx = self.redo_stack.pop_back()?; + + match &tx.kind { + TransactionKind::Text(text_operations) => { + for text_operation in text_operations { + self.exec_text_operation(text_operation); + } + } + TransactionKind::Format(format_operation) => { + self.exec_format_operation(format_operation); + } + } + + self.undo_stack.push_back(tx.clone()); + Some(tx.id) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_group_interval(&mut self, interval: Duration) { + self.group_interval = interval; + } } impl Default for Buffer { diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 2bae424..02fae3d 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -23,6 +23,7 @@ text = { workspace = true } ui = { workspace = true } [dev-dependencies] +buffer = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc = { workspace = true } pretty_assertions = { workspace = true } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7968666..c3c349d 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -59,3 +59,11 @@ pub struct MoveLeft; #[derive(PartialEq, Clone, Default, Action)] #[action(namespace = editor)] pub struct MoveRight; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Undo; + +#[derive(PartialEq, Clone, Default, Action)] +#[action(namespace = editor)] +pub struct Redo; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 36b2ddf..9859fe9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12,18 +12,27 @@ use gpui::{ InteractiveElement, MouseDownEvent, MouseMoveEvent, Pixels, Point, UTF16Selection, Window, prelude::*, }; -use std::ops::Range; +use std::{collections::VecDeque, ops::Range, time::Instant}; -use buffer::{Buffer, Selection, SelectionGoal}; +use buffer::{Buffer, Selection, SelectionGoal, TransactionId}; use text::TextPoint; use crate::element::{EditorElement, PositionMap}; +#[derive(Clone, Debug)] +struct Transaction { + id: TransactionId, + selection_before: Selection, + selection_after: Selection, +} + pub struct Editor { focus_handle: FocusHandle, buffer: Entity, selection: Selection, marked_range: Option, + undo_stack: VecDeque, + redo_stack: VecDeque, } impl Editor { @@ -34,6 +43,8 @@ impl Editor { buffer, selection: Selection::cursor(0), marked_range: None, + undo_stack: VecDeque::new(), + redo_stack: VecDeque::new(), } } @@ -87,13 +98,29 @@ impl Editor { /// Inserts text at cursor, replacing any selected text. pub fn handle_input(&mut self, text: &str, _window: &mut Window, cx: &mut Context) { let range = self.selection.range(); - - self.buffer.update(cx, |buffer, _| { - buffer.replace(range.clone(), text); + let selection_before = self.selection; + let selection_after = Selection::cursor(range.start + text.len()); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.replace(tx, range.clone(), text); + }) }); - let new_offset = range.start + text.len(); - self.selection = Selection::cursor(new_offset); + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } cx.notify(); } @@ -141,11 +168,30 @@ impl Editor { } if !self.selection.is_empty() { - self.buffer.update(cx, |buffer, _| { - buffer.remove(self.selection.range()); + let range = self.selection.range(); + let selection_before = selection; + let selection_after = Selection::cursor(self.selection.start); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.remove(tx, range); + }) }); - self.selection = Selection::cursor(self.selection.start) + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } } self.selection.goal = SelectionGoal::None; @@ -177,11 +223,30 @@ impl Editor { } if !self.selection.is_empty() { - self.buffer.update(cx, |buffer, _| { - buffer.remove(self.selection.range()); + let range = self.selection.range(); + let selection_before = selection; + let selection_after = Selection::cursor(self.selection.start); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.remove(tx, range); + }) }); - self.selection = Selection::cursor(self.selection.start) + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } } self.selection.goal = SelectionGoal::None; @@ -207,18 +272,32 @@ impl Editor { /// Inserts a newline character at the cursor position. pub fn newline(&mut self, _window: &mut Window, cx: &mut Context) { let selection = self.selection; - if !selection.is_empty() { - self.buffer.update(cx, |buffer, _| { - buffer.remove(selection.range()); - }); - } - - let cursor = selection.start; - self.buffer.update(cx, |buffer, _| { - buffer.insert(cursor, "\n"); + let selection_before = selection; + let selection_after = Selection::cursor(selection.start + 1); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + if !selection.is_empty() { + buffer.remove(tx, selection.range()); + } + buffer.insert(tx, selection.start, "\n"); + }) }); - self.selection = Selection::cursor(cursor + 1); + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } self.selection.goal = SelectionGoal::None; cx.notify(); } @@ -286,10 +365,20 @@ impl Editor { return; } - self.buffer.update(cx, |buffer, _cx| { - buffer.toggle_bold(self.selection.range()); + let range = self.selection.range(); + let selection = self.selection; + let transaction_id = self.buffer.update(cx, |buffer, _cx| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.toggle_bold(tx, range.clone()); + }) }); + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before: selection, + selection_after: selection, + }); + self.redo_stack.clear(); cx.notify(); } @@ -299,10 +388,20 @@ impl Editor { return; } - self.buffer.update(cx, |buffer, _cx| { - buffer.toggle_italic(self.selection.range()); + let range = self.selection.range(); + let selection = self.selection; + let transaction_id = self.buffer.update(cx, |buffer, _cx| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.toggle_italic(tx, range.clone()); + }) }); + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before: selection, + selection_after: selection, + }); + self.redo_stack.clear(); cx.notify(); } @@ -312,12 +411,46 @@ impl Editor { return; } - self.buffer.update(cx, |buffer, _cx| { - buffer.toggle_underline(self.selection.range()); + let range = self.selection.range(); + let selection = self.selection; + let transaction_id = self.buffer.update(cx, |buffer, _cx| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.toggle_underline(tx, range.clone()); + }) }); + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before: selection, + selection_after: selection, + }); + self.redo_stack.clear(); cx.notify(); } + + pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context) { + if let Some(transaction) = self.undo_stack.pop_back() { + self.buffer.update(cx, |buffer, _| { + buffer.undo(); + }); + + self.selection = transaction.selection_before; + self.redo_stack.push_back(transaction); + cx.notify(); + } + } + + pub fn redo(&mut self, _: &Redo, _window: &mut Window, cx: &mut Context) { + if let Some(transaction) = self.redo_stack.pop_back() { + self.buffer.update(cx, |buffer, _| { + buffer.redo(); + }); + + self.selection = transaction.selection_after; + self.undo_stack.push_back(transaction); + cx.notify(); + } + } } impl Render for Editor { @@ -370,6 +503,12 @@ impl Render for Editor { .on_action(cx.listener(|editor, _action: &MoveRight, window, cx| { editor.move_right(window, cx); })) + .on_action(cx.listener(|editor, action: &Undo, window, cx| { + editor.undo(action, window, cx); + })) + .on_action(cx.listener(|editor, action: &Redo, window, cx| { + editor.redo(action, window, cx); + })) .child(EditorElement::new(cx.entity().clone())) } } @@ -455,12 +594,30 @@ impl EntityInputHandler for Editor { .map(|range| self.range_from_utf16(range, cx)) .unwrap_or_else(|| self.selection.range()); - self.buffer.update(cx, |buffer, _| { - buffer.replace(range.clone(), new_text); + let selection_before = self.selection; + let selection_after = Selection::cursor(range.start + new_text.len()); + let text = new_text.to_string(); + let transaction_id = self.buffer.update(cx, |buffer, _| { + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.replace(tx, range.clone(), &text); + }) }); - let new_cursor = range.start + new_text.len(); - self.selection = Selection::cursor(new_cursor); + self.selection = selection_after; + if let Some(transaction) = self + .undo_stack + .iter_mut() + .find(|entry| entry.id == transaction_id) + { + transaction.selection_after = selection_after; + } else { + self.undo_stack.push_back(Transaction { + id: transaction_id, + selection_before, + selection_after, + }); + self.redo_stack.clear(); + } let new_marked_range = if let Some(marked_range) = new_selection { let start = range.start + marked_range.start; diff --git a/crates/editor/src/tests.rs b/crates/editor/src/tests.rs index 4e4d454..dd5dc18 100644 --- a/crates/editor/src/tests.rs +++ b/crates/editor/src/tests.rs @@ -5,8 +5,35 @@ pub use context::EditorTestContext; use gpui::TestAppContext; use indoc::indoc; +use crate::{Redo, Undo}; use buffer::{Buffer, FormatSpan, Selection}; +/// Returns the effective formatting at an offset. +fn formatting_at(buffer: &Buffer, offset: usize) -> FormatSpan { + let mut result = FormatSpan { + range: offset..offset, + bold: None, + italic: None, + underline: None, + }; + + for span in buffer.format_spans() { + if span.range.contains(&offset) { + if span.bold.is_some() { + result.bold = span.bold; + } + if span.italic.is_some() { + result.italic = span.italic; + } + if span.underline.is_some() { + result.underline = span.underline; + } + } + } + + result +} + #[gpui::test] fn test_backspace(cx: &mut TestAppContext) { let mut cx = EditorTestContext::new(cx); @@ -430,28 +457,248 @@ fn test_selection_movement_collapses(cx: &mut TestAppContext) { "}); } -/// Returns the effective formatting at an offset. -fn formatting_at(buffer: &Buffer, offset: usize) -> FormatSpan { - let mut result = FormatSpan { - range: offset..offset, - bold: None, - italic: None, - underline: None, - }; +#[gpui::test] +fn test_undo_redo_input(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); - for span in buffer.format_spans() { - if span.range.contains(&offset) { - if span.bold.is_some() { - result.bold = span.bold; - } - if span.italic.is_some() { - result.italic = span.italic; - } - if span.underline.is_some() { - result.underline = span.underline; - } - } - } + cx.set_state(indoc! {" + The quick brown foxˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\njumps over the lazy dog", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumps over the lazy dogˇ + "}); - result + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown foxˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumps over the lazy dogˇ + "}); +} + +#[gpui::test] +fn test_undo_redo_backspace(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The quick brown fox + jumpsˇ over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.backspace(window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumpˇ over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumpsˇ over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown fox + jumpˇ over the lazy dog + "}); +} + +#[gpui::test] +fn test_undo_redo_delete(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The quˇick brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.delete(window, cx); + }); + cx.assert_editor_state(indoc! {" + The quˇck brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quˇick brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quˇck brown fox + jumps over the lazy dog + "}); +} + +#[gpui::test] +fn test_undo_redo_selection(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + The «quickˇ» brown fox + jumps over the lazy dog + "}); + cx.update_editor(|editor, window, cx| { + editor.backspace(window, cx); + }); + cx.assert_editor_state(indoc! {" + The ˇ brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(window, cx, |s| { + *s = Selection::new(30, 34); + }); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the «lazyˇ» dog + "}); + cx.update_editor(|editor, window, cx| { + editor.delete(window, cx); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the ˇ dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the «lazyˇ» dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The «quickˇ» brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The ˇ brown fox + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The brown fox + jumps over the ˇ dog + "}); +} + +#[gpui::test] +fn test_undo_redo_multiple(cx: &mut TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + ˇ + jumps over the lazy dog + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("The quick", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(" brown", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brownˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input(" brown fox", window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown foxˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + ˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(indoc! {" + ˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quickˇ + jumps over the lazy dog + "}); + + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + }); + cx.assert_editor_state(indoc! {" + The quick brown foxˇ + jumps over the lazy dog + "}); } diff --git a/crates/editor/src/tests/context.rs b/crates/editor/src/tests/context.rs index 12c2095..7bf9353 100644 --- a/crates/editor/src/tests/context.rs +++ b/crates/editor/src/tests/context.rs @@ -1,5 +1,5 @@ use gpui::{Context, Entity, TestAppContext, VisualTestContext, Window}; -use std::ops::Deref; +use std::{ops::Deref, time::Instant}; use buffer::Selection; use util::test::{generate_marked_text, marked_text_ranges}; @@ -16,6 +16,14 @@ impl EditorTestContext { let window = cx.add_window(|_, cx| Editor::new(cx)); let editor = window.root(cx).unwrap(); + // Disable transaction grouping for deterministic test behavior + // Each operation should be a separate transaction for testing undo/redo + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, _| { + buffer.set_group_interval(std::time::Duration::ZERO); + }); + }); + Self { cx: VisualTestContext::from_window(*window.deref(), cx), editor, @@ -28,7 +36,9 @@ impl EditorTestContext { self.editor.update_in(&mut self.cx, |editor, window, cx| { editor.buffer().update(cx, |buffer, _| { - buffer.replace(0..buffer.len(), &unmarked_text); + buffer.transaction(Instant::now(), |buffer, tx| { + buffer.replace(tx, 0..buffer.len(), &unmarked_text); + }); }); if let Some(range) = selection_ranges.first() { diff --git a/crates/ryuk/src/main.rs b/crates/ryuk/src/main.rs index be56499..17311ac 100644 --- a/crates/ryuk/src/main.rs +++ b/crates/ryuk/src/main.rs @@ -5,7 +5,7 @@ use gpui::{ use editor::{ Backspace, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, MoveDown, MoveLeft, MoveRight, - MoveUp, Newline, ToggleBold, ToggleItalic, ToggleUnderline, + MoveUp, Newline, Redo, ToggleBold, ToggleItalic, ToggleUnderline, Undo, }; use workspace::Workspace; @@ -33,6 +33,8 @@ fn main() { KeyBinding::new("delete", Delete, None), KeyBinding::new("cmd-delete", DeleteToEndOfLine, None), KeyBinding::new("ctrl-k", DeleteToEndOfLine, None), + KeyBinding::new("cmd-z", Undo, None), + KeyBinding::new("cmd-shift-z", Redo, None), // Format KeyBinding::new("cmd-b", ToggleBold, None), KeyBinding::new("cmd-i", ToggleItalic, None), From acb7cb86a94444a3a9779f086bd7ed35c9336950 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Sun, 28 Dec 2025 16:54:25 +0530 Subject: [PATCH 2/3] Merge text crate into buffer --- Cargo.lock | 11 +---------- Cargo.toml | 1 - crates/buffer/Cargo.toml | 4 ++-- crates/buffer/src/buffer.rs | 9 +++++---- crates/{text => buffer}/src/text.rs | 10 +++++----- crates/editor/Cargo.toml | 3 +-- crates/editor/src/editor.rs | 3 +-- crates/editor/src/element.rs | 3 +-- crates/editor/src/movement.rs | 3 +-- crates/text/Cargo.toml | 21 --------------------- 10 files changed, 17 insertions(+), 51 deletions(-) rename crates/{text => buffer}/src/text.rs (95%) delete mode 100644 crates/text/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index df1070b..709ac5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,8 +706,8 @@ version = "0.0.0" dependencies = [ "anyhow", "gpui", + "ropey", "serde", - "text", ] [[package]] @@ -1465,7 +1465,6 @@ dependencies = [ "gpui", "indoc", "pretty_assertions", - "text", "ui", "util 0.0.0", ] @@ -5327,14 +5326,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "text" -version = "0.0.0" -dependencies = [ - "ropey", - "serde", -] - [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 41a8bab..2b12efe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ members = [ "crates/editor", "crates/icons", "crates/ryuk", - "crates/text", "crates/ui", "crates/util", "crates/workspace", diff --git a/crates/buffer/Cargo.toml b/crates/buffer/Cargo.toml index 8fe4193..bb39da0 100644 --- a/crates/buffer/Cargo.toml +++ b/crates/buffer/Cargo.toml @@ -14,10 +14,10 @@ name = "buffer" path = "src/buffer.rs" [features] -test-support = ["text/test-support"] +test-support = [] [dependencies] anyhow = { workspace = true } gpui = { workspace = true } -text = { workspace = true } +ropey = { workspace = true } serde = { workspace = true } diff --git a/crates/buffer/src/buffer.rs b/crates/buffer/src/buffer.rs index 94b53ba..4d6a1d9 100644 --- a/crates/buffer/src/buffer.rs +++ b/crates/buffer/src/buffer.rs @@ -1,15 +1,16 @@ mod format_span; mod selection; +pub mod text; pub use format_span::*; pub use selection::*; +pub use text::*; use std::{ collections::VecDeque, ops::Range, time::{Duration, Instant}, }; -use text::{TextBuffer, TextPoint}; pub type TransactionId = usize; @@ -57,7 +58,7 @@ struct Transaction { #[derive(Clone, Debug)] pub struct Buffer { - text: TextBuffer, + text: Text, format_spans: Vec, next_transaction_id: usize, undo_stack: VecDeque, @@ -68,7 +69,7 @@ pub struct Buffer { impl Buffer { pub fn new() -> Self { Self { - text: TextBuffer::new(), + text: Text::new(), format_spans: Vec::new(), next_transaction_id: 0, undo_stack: VecDeque::new(), @@ -79,7 +80,7 @@ impl Buffer { pub fn from_text(text: impl Into) -> Self { Self { - text: TextBuffer::from(text.into().as_str()), + text: Text::from(text.into().as_str()), format_spans: Vec::new(), next_transaction_id: 0, undo_stack: VecDeque::new(), diff --git a/crates/text/src/text.rs b/crates/buffer/src/text.rs similarity index 95% rename from crates/text/src/text.rs rename to crates/buffer/src/text.rs index dcd910b..18c1d22 100644 --- a/crates/text/src/text.rs +++ b/crates/buffer/src/text.rs @@ -5,11 +5,11 @@ use std::{ }; #[derive(Clone, Debug)] -pub struct TextBuffer { +pub struct Text { rope: Rope, } -impl TextBuffer { +impl Text { pub fn new() -> Self { Self { rope: Rope::new() } } @@ -102,13 +102,13 @@ impl TextBuffer { } } -impl Default for TextBuffer { +impl Default for Text { fn default() -> Self { Self::new() } } -impl From<&str> for TextBuffer { +impl From<&str> for Text { fn from(text: &str) -> Self { Self { rope: Rope::from_str(text), @@ -116,7 +116,7 @@ impl From<&str> for TextBuffer { } } -impl Display for TextBuffer { +impl Display for Text { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{}", self.rope) } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 02fae3d..4226e10 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -14,12 +14,11 @@ name = "editor" path = "src/editor.rs" [features] -test-support = ["text/test-support", "buffer/test-support", "gpui/test-support"] +test-support = ["buffer/test-support", "gpui/test-support"] [dependencies] buffer = { workspace = true } gpui = { workspace = true } -text = { workspace = true } ui = { workspace = true } [dev-dependencies] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9859fe9..84410e9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -14,8 +14,7 @@ use gpui::{ }; use std::{collections::VecDeque, ops::Range, time::Instant}; -use buffer::{Buffer, Selection, SelectionGoal, TransactionId}; -use text::TextPoint; +use buffer::{Buffer, Selection, SelectionGoal, TextPoint, TransactionId}; use crate::element::{EditorElement, PositionMap}; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f281d76..ef2f84f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5,8 +5,7 @@ use gpui::{ }; use std::collections::BTreeSet; -use buffer::{Buffer, FormatSpan}; -use text::TextPoint; +use buffer::{Buffer, FormatSpan, TextPoint}; use crate::Editor; diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index a65cae3..d3a36b1 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,5 +1,4 @@ -use buffer::{Buffer, SelectionGoal}; -use text::TextPoint; +use buffer::{Buffer, SelectionGoal, TextPoint}; /// Move cursor left one character, wrapping to previous line if at start of line. pub fn left(buffer: &Buffer, offset: usize) -> Option { diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml deleted file mode 100644 index 6e8aec8..0000000 --- a/crates/text/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "text" -version.workspace = true -edition.workspace = true -publish.workspace = true -license.workspace = true -authors.workspace = true - -[lints] -workspace = true - -[lib] -name = "text" -path = "src/text.rs" - -[features] -test-support = [] - -[dependencies] -ropey = { workspace = true } -serde = { workspace = true } From 156585dde86bd791fc03f88a9b447b3d2ab5b5b5 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Sun, 28 Dec 2025 23:30:13 +0530 Subject: [PATCH 3/3] Remove --- crates/buffer/src/buffer.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/buffer/src/buffer.rs b/crates/buffer/src/buffer.rs index 4d6a1d9..2bc578e 100644 --- a/crates/buffer/src/buffer.rs +++ b/crates/buffer/src/buffer.rs @@ -142,10 +142,6 @@ impl Buffer { &self.format_spans } - pub fn set_format_spans(&mut self, spans: Vec) { - self.format_spans = spans; - } - pub fn insert(&mut self, tx: &mut TransactionContext, offset: usize, text: &str) { tx.texts.push(TextOperation { range: offset..offset,