1use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UndoEntry {
12 pub id: u64,
14 pub timestamp: SystemTime,
16 pub operation: UndoableOperation,
18 pub description: String,
20}
21
22impl UndoEntry {
23 pub fn new(id: u64, operation: UndoableOperation, description: impl Into<String>) -> Self {
25 Self {
26 id,
27 timestamp: SystemTime::now(),
28 operation,
29 description: description.into(),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub enum UndoableOperation {
37 FilesMoved {
39 moves: Vec<(PathBuf, PathBuf)>,
41 },
42 FilesCopied {
44 created: Vec<PathBuf>,
46 },
47 FilesDeleted {
49 trash_entries: Vec<(PathBuf, PathBuf)>,
52 },
53 FileRenamed {
55 path: PathBuf,
57 old_name: String,
59 new_name: String,
61 },
62 FileCreated {
64 path: PathBuf,
66 },
67 DirectoryCreated {
69 path: PathBuf,
71 },
72}
73
74impl UndoableOperation {
75 pub fn undo_description(&self) -> String {
77 match self {
78 Self::FilesMoved { moves } => {
79 format!("Move {} items back to original location", moves.len())
80 }
81 Self::FilesCopied { created } => {
82 format!("Delete {} copied items", created.len())
83 }
84 Self::FilesDeleted { trash_entries } => {
85 if trash_entries.is_empty() {
86 "Cannot undo permanent deletion".to_string()
87 } else {
88 format!("Restore {} items from trash", trash_entries.len())
89 }
90 }
91 Self::FileRenamed { old_name, .. } => {
92 format!("Rename back to '{}'", old_name)
93 }
94 Self::FileCreated { .. } => "Delete the created file".to_string(),
95 Self::DirectoryCreated { .. } => "Delete the created directory".to_string(),
96 }
97 }
98
99 pub fn can_undo(&self) -> bool {
101 match self {
102 Self::FilesDeleted { trash_entries } => !trash_entries.is_empty(),
103 _ => true,
104 }
105 }
106}
107
108#[derive(Debug)]
110pub struct UndoLog {
111 entries: VecDeque<UndoEntry>,
112 max_entries: usize,
113 next_id: u64,
114}
115
116impl Default for UndoLog {
117 fn default() -> Self {
118 Self::new(100)
119 }
120}
121
122impl UndoLog {
123 pub fn new(max_entries: usize) -> Self {
125 Self {
126 entries: VecDeque::with_capacity(max_entries.min(1000)),
127 max_entries,
128 next_id: 0,
129 }
130 }
131
132 pub fn record(&mut self, operation: UndoableOperation, description: impl Into<String>) -> u64 {
136 let id = self.next_id;
137 self.next_id += 1;
138
139 if self.entries.len() >= self.max_entries {
141 self.entries.pop_front();
142 }
143
144 self.entries
145 .push_back(UndoEntry::new(id, operation, description));
146
147 id
148 }
149
150 pub fn record_move(&mut self, moves: Vec<(PathBuf, PathBuf)>) -> u64 {
152 let count = moves.len();
153 self.record(
154 UndoableOperation::FilesMoved { moves },
155 format!("Moved {} items", count),
156 )
157 }
158
159 pub fn record_copy(&mut self, created: Vec<PathBuf>) -> u64 {
161 let count = created.len();
162 self.record(
163 UndoableOperation::FilesCopied { created },
164 format!("Copied {} items", count),
165 )
166 }
167
168 pub fn record_delete(&mut self, trash_entries: Vec<(PathBuf, PathBuf)>) -> u64 {
170 let count = trash_entries.len();
171 let desc = if trash_entries.is_empty() {
172 format!("Permanently deleted {} items", count)
173 } else {
174 format!("Moved {} items to trash", count)
175 };
176 self.record(UndoableOperation::FilesDeleted { trash_entries }, desc)
177 }
178
179 pub fn record_rename(&mut self, path: PathBuf, old_name: String, new_name: String) -> u64 {
181 self.record(
182 UndoableOperation::FileRenamed {
183 path,
184 old_name: old_name.clone(),
185 new_name: new_name.clone(),
186 },
187 format!("Renamed '{}' to '{}'", old_name, new_name),
188 )
189 }
190
191 pub fn record_create_file(&mut self, path: PathBuf) -> u64 {
193 let name = path
194 .file_name()
195 .map(|n| n.to_string_lossy().to_string())
196 .unwrap_or_default();
197 self.record(
198 UndoableOperation::FileCreated { path },
199 format!("Created file '{}'", name),
200 )
201 }
202
203 pub fn record_create_directory(&mut self, path: PathBuf) -> u64 {
205 let name = path
206 .file_name()
207 .map(|n| n.to_string_lossy().to_string())
208 .unwrap_or_default();
209 self.record(
210 UndoableOperation::DirectoryCreated { path },
211 format!("Created directory '{}'", name),
212 )
213 }
214
215 pub fn pop(&mut self) -> Option<UndoEntry> {
219 while let Some(entry) = self.entries.pop_back() {
221 if entry.operation.can_undo() {
222 return Some(entry);
223 }
224 }
225 None
226 }
227
228 pub fn peek(&self) -> Option<&UndoEntry> {
230 self.entries.back()
231 }
232
233 pub fn len(&self) -> usize {
235 self.entries.len()
236 }
237
238 pub fn is_empty(&self) -> bool {
240 self.entries.is_empty()
241 }
242
243 pub fn clear(&mut self) {
245 self.entries.clear();
246 }
247
248 pub fn iter(&self) -> impl Iterator<Item = &UndoEntry> {
250 self.entries.iter()
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn test_undo_log_record() {
260 let mut log = UndoLog::new(10);
261
262 let id = log.record_create_file(PathBuf::from("/test/file.txt"));
263 assert_eq!(id, 0);
264 assert_eq!(log.len(), 1);
265
266 let id = log.record_create_directory(PathBuf::from("/test/dir"));
267 assert_eq!(id, 1);
268 assert_eq!(log.len(), 2);
269 }
270
271 #[test]
272 fn test_undo_log_max_entries() {
273 let mut log = UndoLog::new(3);
274
275 log.record_create_file(PathBuf::from("/test/1.txt"));
276 log.record_create_file(PathBuf::from("/test/2.txt"));
277 log.record_create_file(PathBuf::from("/test/3.txt"));
278 assert_eq!(log.len(), 3);
279
280 log.record_create_file(PathBuf::from("/test/4.txt"));
281 assert_eq!(log.len(), 3);
282
283 let entry = log.pop().unwrap();
285 assert!(entry.description.contains("4.txt"));
286 }
287
288 #[test]
289 fn test_undo_log_pop() {
290 let mut log = UndoLog::new(10);
291
292 log.record_create_file(PathBuf::from("/test/file.txt"));
293 log.record_rename(
294 PathBuf::from("/test/new.txt"),
295 "old.txt".to_string(),
296 "new.txt".to_string(),
297 );
298
299 let entry = log.pop().unwrap();
300 assert!(matches!(
301 entry.operation,
302 UndoableOperation::FileRenamed { .. }
303 ));
304
305 let entry = log.pop().unwrap();
306 assert!(matches!(
307 entry.operation,
308 UndoableOperation::FileCreated { .. }
309 ));
310
311 assert!(log.pop().is_none());
312 }
313}