From 246bc3e6e4a969502d1d2a262f45cb9f06bd66ca Mon Sep 17 00:00:00 2001 From: temportalflux Date: Thu, 25 Jun 2026 11:18:08 -0400 Subject: [PATCH] implement undo/redo history tracking --- crates/gpui_elements/src/editable_text.rs | 2 +- .../src/editable_text/history.rs | 156 ++++++++++++++++++ .../gpui_elements/src/editable_text/state.rs | 73 ++++++-- 3 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 crates/gpui_elements/src/editable_text/history.rs diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 249bc960cc..609e852431 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -1,5 +1,6 @@ pub mod actions; mod element; +mod history; pub mod notify; mod state; mod storage; @@ -13,7 +14,6 @@ pub use storage::*; - auto-scroll when cursor moves - cursor blinking - color styling configs -- undo/redo - text sanitation - test IME (char palette only available on macos) - unit tests diff --git a/crates/gpui_elements/src/editable_text/history.rs b/crates/gpui_elements/src/editable_text/history.rs new file mode 100644 index 0000000000..56bc2c772a --- /dev/null +++ b/crates/gpui_elements/src/editable_text/history.rs @@ -0,0 +1,156 @@ +use smallvec::SmallVec; +use std::{ + ops::Range, + time::{Duration, Instant}, +}; + +/// Maximum number of history entries to keep. +pub const MAX_HISTORY_LEN: usize = 1000; + +/// Default interval for grouping consecutive edits into a single undo entry. +pub const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); + +// TODO: Should history get attached directly to storage? currently its per text field and operate both on storage and selection +pub struct EditableTextHistory { + /// The maximum duration between changes to `content` that can be grouped together as a single entry in the history log. + grouping_interval: Duration, + /// Stack of previous states for undo. + undo_stack: SmallVec<[HistoryEntry; MAX_HISTORY_LEN]>, + /// Stack of undone states for redo. + redo_stack: SmallVec<[HistoryEntry; MAX_HISTORY_LEN]>, +} +impl Default for EditableTextHistory { + fn default() -> Self { + Self { + grouping_interval: DEFAULT_GROUP_INTERVAL, + undo_stack: Default::default(), + redo_stack: Default::default(), + } + } +} + +/// A patch-based history entry for memory-efficient undo/redo operations. +/// Instead of storing the full content, we store only the change needed to reverse the edit. +#[derive(Clone, Debug)] +pub struct HistoryEntry { + /// The byte range that was modified (after the edit, for undo; before the edit, for redo). + pub range: Range, + /// The text that was replaced (to restore on undo). + pub old_text: String, + /// The length of the new text that replaced old_text (to know how much to remove on undo). + pub new_text_len: usize, + /// The selection range before the edit. + pub selected_range: Range, + /// Timestamp for grouping consecutive edits. + pub timestamp: Instant, +} + +#[derive(Clone, Debug)] +pub enum HistoryKind { + Undo, + Redo, +} + +impl EditableTextHistory { + pub fn set_grouping_interval(&mut self, interval: Duration) { + self.grouping_interval = interval; + } + + pub fn record( + &mut self, + range: Range, + old_text: &str, + new_text_len: usize, + selected_range: Range, + ) { + let now = Instant::now(); + + // Check if we should group with the last entry + if let Some(last) = self.undo_stack.last_mut() { + if now.duration_since(last.timestamp) < self.grouping_interval { + // Within group interval - extend the existing patch + if last.extend(&range, new_text_len) { + return; + } + } + } + + // Limit history size + if self.undo_stack.len() >= MAX_HISTORY_LEN { + self.undo_stack.remove(0); + } + + self.push( + HistoryKind::Undo, + HistoryEntry { + range: range.start..range.start + new_text_len, + old_text: old_text.to_string(), + new_text_len, + selected_range, + timestamp: now, + }, + ); + + // New edit invalidates redo stack + self.redo_stack.clear(); + } + + fn stack(&self, kind: HistoryKind) -> &SmallVec<[HistoryEntry; MAX_HISTORY_LEN]> { + // NOTE: Could be an internal map + match kind { + HistoryKind::Undo => &self.undo_stack, + HistoryKind::Redo => &self.redo_stack, + } + } + + fn stack_mut(&mut self, kind: HistoryKind) -> &mut SmallVec<[HistoryEntry; MAX_HISTORY_LEN]> { + match kind { + HistoryKind::Undo => &mut self.undo_stack, + HistoryKind::Redo => &mut self.redo_stack, + } + } + + pub fn has_next(&self, kind: HistoryKind) -> bool { + !self.stack(kind).is_empty() + } + + pub fn push(&mut self, kind: HistoryKind, entry: HistoryEntry) { + self.stack_mut(kind).push(entry); + } + + pub fn take(&mut self, kind: HistoryKind) -> Option { + self.stack_mut(kind).pop() + } +} + +impl HistoryEntry { + fn extend(&mut self, range: &Range, new_text_len: usize) -> bool { + // NOTE: Could be more robust. Currently only supports human-written extensions from start towards end. + + // ranges must be contiguous in order to integrate/extend + if self.range.end != range.start { + return false; + } + + self.range.end = range.start + new_text_len; + self.new_text_len += new_text_len; + + true + } + + pub fn char_range(&self, max_len: usize) -> Range { + let undo_start = self.range.start; + let undo_end = (self.range.start + self.new_text_len).min(max_len); + undo_start..undo_end + } + + pub fn as_inverted(self, prev_text_at_range: String) -> Self { + HistoryEntry { + range: self.range.start..self.range.start + self.old_text.len(), + old_text: prev_text_at_range, + new_text_len: self.old_text.len(), + selected_range: self.selected_range.clone(), + timestamp: self.timestamp, + } + } +} diff --git a/crates/gpui_elements/src/editable_text/state.rs b/crates/gpui_elements/src/editable_text/state.rs index 1f6a332000..61a246b687 100644 --- a/crates/gpui_elements/src/editable_text/state.rs +++ b/crates/gpui_elements/src/editable_text/state.rs @@ -1,6 +1,7 @@ use crate::editable_text::{ TextBoundary, UnicodeTextStorage, actions::EditableTextActionHandler, + history::EditableTextHistory, notify::{TextChanged, TextHistoryPushed}, }; use gpui::{ @@ -31,6 +32,7 @@ pub struct EditableTextState { click_count: usize, focus_handle: FocusHandle, + history: Option, pub(super) layout_data: TextInputLayoutData, } @@ -94,6 +96,8 @@ impl EditableTextState { click_count: 0, focus_handle: cx.focus_handle(), + // TODO: what is the best way to give users access to configure this via element + history: Some(EditableTextHistory::default()), layout_data: TextInputLayoutData::default(), } @@ -187,20 +191,11 @@ impl EditableTextState { self.selected_range = new_caret..new_caret; } - fn emit_change_for_undo(&self, cx: &mut Context, range: Range, length: usize) { - cx.emit(TextHistoryPushed::new( - range.clone(), - length, - &*self.storage, - self.selected_range.clone(), - )); - } - pub fn replace_text_in_range_bytes( &mut self, range: Range, mut text_to_insert: &str, - cx: &mut Context, + _cx: &mut Context, ) { // TODO: Apply text sanitization // single-line fields should prune \n and \r @@ -218,7 +213,7 @@ impl EditableTextState { let end_pos = range.start + text_to_insert.len(); - self.emit_change_for_undo(cx, range.clone(), text_to_insert.len()); + self.record_history(range.clone(), text_to_insert.len()); self.storage.replace_range(range, text_to_insert); self.selected_range = end_pos..end_pos; self.marked_range = None; @@ -287,7 +282,7 @@ impl EditableTextState { .range_from_caret(self.caret_pos(), direction, boundary), }; - self.emit_change_for_undo(cx, range.clone(), 0); + self.record_history(range.clone(), 0); self.replace_text(&range, ""); self.marked_range = None; @@ -408,6 +403,50 @@ impl EditableTextState { } } +impl EditableTextState { + pub fn history(&self) -> Option<&EditableTextHistory> { + self.history.as_ref() + } + + fn record_history(&mut self, range: Range, new_text_len: usize) { + // Don't record during IME composition + if self.marked_range.is_some() { + return; + } + + let Some(history) = &mut self.history else { + return; + }; + + // Capture the text that will be replaced + let old_text = &self.storage.content_utf8()[range.clone()]; + history.record(range, old_text, new_text_len, self.selected_range.clone()); + } + + fn apply_from_history(&mut self, src: HistoryKind, dst: HistoryKind, cx: &mut Context) { + let Some(history) = &mut self.history else { + return; + }; + let Some(entry) = history.take(src) else { + return; + }; + + let range = entry.char_range(self.storage.content_utf8().len()); + // Snapshot the sub-slice that is being replaced + let removed_text = self.storage.content_utf8()[range.clone()].to_string(); + + // Replace the slice with the history value + self.storage.replace_range(range, &entry.old_text); + self.selected_range = entry.selected_range.clone(); + + // Push the entry onto the redo stack so the undo can be undone + history.push(dst, entry.as_inverted(removed_text)); + + self.scroll_to_caret(); + cx.notify(); + } +} + impl EntityInputHandler for EditableTextState { fn text_for_range( &mut self, @@ -547,7 +586,7 @@ impl EntityInputHandler for EditableTextState { } } -use super::actions::*; +use super::{actions::*, history::HistoryKind}; impl<'app> EditableTextActionHandler> for EditableTextState { fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context<'app, Self>) { self.set_selected_range(0..0); @@ -814,12 +853,12 @@ impl<'app> EditableTextActionHandler> for EditableTextState cx.notify(); } - fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut Context<'app, Self>) { - // TODO: STUB + fn undo(&mut self, _: &Undo, _w: &mut Window, cx: &mut Context<'app, Self>) { + self.apply_from_history(HistoryKind::Undo, HistoryKind::Redo, cx); } - fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut Context<'app, Self>) { - // TODO: STUB + fn redo(&mut self, _: &Redo, _w: &mut Window, cx: &mut Context<'app, Self>) { + self.apply_from_history(HistoryKind::Redo, HistoryKind::Undo, cx); } fn on_mouse_down(