longcipher_leptos_components/components/editor/
history.rs

1//! Undo/Redo history management
2//!
3//! Provides efficient history tracking with coalescing of related edits.
4
5use std::time::Instant;
6
7use serde::{Deserialize, Serialize};
8
9use super::cursor::CursorSet;
10
11/// A single history entry representing an edit operation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct HistoryEntry {
14    /// The content before this edit
15    pub content: String,
16    /// Cursor state before this edit
17    pub cursors: CursorSet,
18    /// Timestamp when this entry was created (for coalescing)
19    #[serde(skip)]
20    pub timestamp: Option<Instant>,
21}
22
23impl HistoryEntry {
24    /// Create a new history entry.
25    #[must_use]
26    pub fn new(content: String, cursors: CursorSet) -> Self {
27        Self {
28            content,
29            cursors,
30            timestamp: Some(Instant::now()),
31        }
32    }
33}
34
35/// Configuration for history behavior.
36#[derive(Debug, Clone)]
37pub struct HistoryConfig {
38    /// Maximum number of undo entries to keep
39    pub max_entries: usize,
40    /// Time window for coalescing edits (milliseconds)
41    pub coalesce_window_ms: u64,
42}
43
44impl Default for HistoryConfig {
45    fn default() -> Self {
46        Self {
47            max_entries: 1000,
48            coalesce_window_ms: 500,
49        }
50    }
51}
52
53/// Manages undo/redo history for the editor.
54#[derive(Debug, Clone, Default)]
55pub struct History {
56    /// Undo stack (most recent at end)
57    undo_stack: Vec<HistoryEntry>,
58    /// Redo stack (most recent at end)
59    redo_stack: Vec<HistoryEntry>,
60    /// Configuration
61    config: HistoryConfig,
62    /// Whether we're currently in the middle of an undo/redo operation
63    is_undoing: bool,
64}
65
66impl History {
67    /// Create a new history manager.
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Create with custom configuration.
74    #[must_use]
75    pub fn with_config(config: HistoryConfig) -> Self {
76        Self {
77            config,
78            ..Default::default()
79        }
80    }
81
82    /// Record a new state in history.
83    ///
84    /// This will clear the redo stack and potentially coalesce with the
85    /// previous entry if the edit happened within the coalesce window.
86    pub fn push(&mut self, content: String, cursors: CursorSet) {
87        if self.is_undoing {
88            return;
89        }
90
91        let entry = HistoryEntry::new(content, cursors);
92
93        // Check if we should coalesce with the previous entry
94        if let Some(last) = self.undo_stack.last()
95            && let (Some(last_ts), Some(entry_ts)) = (last.timestamp, entry.timestamp)
96        {
97            let elapsed =
98                u64::try_from(entry_ts.duration_since(last_ts).as_millis()).unwrap_or(u64::MAX);
99            if elapsed < self.config.coalesce_window_ms {
100                // Coalesce by not adding a new entry, just update the timestamp
101                // The previous state is preserved
102                return;
103            }
104        }
105
106        self.undo_stack.push(entry);
107        self.redo_stack.clear();
108
109        // Trim history if needed
110        if self.undo_stack.len() > self.config.max_entries {
111            self.undo_stack.remove(0);
112        }
113    }
114
115    /// Record a state without coalescing (for explicit save points).
116    pub fn push_checkpoint(&mut self, content: String, cursors: CursorSet) {
117        if self.is_undoing {
118            return;
119        }
120
121        let mut entry = HistoryEntry::new(content, cursors);
122        // Set timestamp to None to prevent coalescing with the next edit
123        entry.timestamp = None;
124
125        self.undo_stack.push(entry);
126        self.redo_stack.clear();
127
128        if self.undo_stack.len() > self.config.max_entries {
129            self.undo_stack.remove(0);
130        }
131    }
132
133    /// Undo the last change.
134    ///
135    /// Returns the previous state if available.
136    pub fn undo(
137        &mut self,
138        current_content: &str,
139        current_cursors: &CursorSet,
140    ) -> Option<HistoryEntry> {
141        let entry = self.undo_stack.pop()?;
142
143        // Save current state to redo stack
144        self.redo_stack.push(HistoryEntry::new(
145            current_content.to_string(),
146            current_cursors.clone(),
147        ));
148
149        Some(entry)
150    }
151
152    /// Redo the last undone change.
153    ///
154    /// Returns the next state if available.
155    pub fn redo(
156        &mut self,
157        current_content: &str,
158        current_cursors: &CursorSet,
159    ) -> Option<HistoryEntry> {
160        let entry = self.redo_stack.pop()?;
161
162        // Save current state to undo stack
163        self.undo_stack.push(HistoryEntry::new(
164            current_content.to_string(),
165            current_cursors.clone(),
166        ));
167
168        Some(entry)
169    }
170
171    /// Check if undo is available.
172    #[must_use]
173    pub fn can_undo(&self) -> bool {
174        !self.undo_stack.is_empty()
175    }
176
177    /// Check if redo is available.
178    #[must_use]
179    pub fn can_redo(&self) -> bool {
180        !self.redo_stack.is_empty()
181    }
182
183    /// Clear all history.
184    pub fn clear(&mut self) {
185        self.undo_stack.clear();
186        self.redo_stack.clear();
187    }
188
189    /// Get the number of undo entries.
190    #[must_use]
191    pub fn undo_count(&self) -> usize {
192        self.undo_stack.len()
193    }
194
195    /// Get the number of redo entries.
196    #[must_use]
197    pub fn redo_count(&self) -> usize {
198        self.redo_stack.len()
199    }
200
201    /// Mark that we're in the middle of an undo/redo operation.
202    pub fn begin_undo(&mut self) {
203        self.is_undoing = true;
204    }
205
206    /// Mark that the undo/redo operation is complete.
207    pub fn end_undo(&mut self) {
208        self.is_undoing = false;
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::components::editor::cursor::{Cursor, CursorPosition};
216
217    fn test_cursors() -> CursorSet {
218        CursorSet::new(Cursor::new(CursorPosition::zero()))
219    }
220
221    #[test]
222    fn test_undo_redo() {
223        let mut history = History::new();
224
225        history.push("state1".to_string(), test_cursors());
226        std::thread::sleep(std::time::Duration::from_millis(600));
227        history.push("state2".to_string(), test_cursors());
228
229        let entry = history.undo("state3", &test_cursors());
230        assert!(entry.is_some());
231        assert_eq!(entry.unwrap().content, "state2");
232
233        let entry = history.redo("state2", &test_cursors());
234        assert!(entry.is_some());
235        assert_eq!(entry.unwrap().content, "state3");
236    }
237
238    #[test]
239    fn test_redo_cleared_on_new_edit() {
240        let mut history = History::new();
241
242        history.push("state1".to_string(), test_cursors());
243        std::thread::sleep(std::time::Duration::from_millis(600));
244        history.push("state2".to_string(), test_cursors());
245
246        history.undo("state3", &test_cursors());
247        assert!(history.can_redo());
248
249        std::thread::sleep(std::time::Duration::from_millis(600));
250        history.push("state4".to_string(), test_cursors());
251        assert!(!history.can_redo());
252    }
253}