undo.rs

  1use anyhow::anyhow;
  2use gpui::{AppContext, SharedString, Task, WeakEntity};
  3use project::ProjectPath;
  4use std::collections::VecDeque;
  5use ui::{App, IntoElement, Label, ParentElement, Styled, v_flex};
  6use workspace::{
  7    Workspace,
  8    notifications::{NotificationId, simple_message_notification::MessageNotification},
  9};
 10
 11const MAX_UNDO_OPERATIONS: usize = 10_000;
 12
 13#[derive(Clone, Debug, PartialEq)]
 14pub enum ProjectPanelOperation {
 15    Batch(Vec<ProjectPanelOperation>),
 16    Create {
 17        project_path: ProjectPath,
 18    },
 19    Rename {
 20        old_path: ProjectPath,
 21        new_path: ProjectPath,
 22    },
 23}
 24
 25pub struct UndoManager {
 26    workspace: WeakEntity<Workspace>,
 27    history: VecDeque<ProjectPanelOperation>,
 28    /// Keeps track of the cursor position in the undo stack so we can easily
 29    /// undo by picking the current operation in the stack and decreasing the
 30    /// cursor, as well as redoing, by picking the next operation in the stack
 31    /// and increasing the cursor.
 32    cursor: usize,
 33    /// Maximum number of operations to keep on the undo history.
 34    limit: usize,
 35}
 36
 37impl UndoManager {
 38    pub fn new(workspace: WeakEntity<Workspace>) -> Self {
 39        Self::new_with_limit(workspace, MAX_UNDO_OPERATIONS)
 40    }
 41
 42    pub fn new_with_limit(workspace: WeakEntity<Workspace>, limit: usize) -> Self {
 43        Self {
 44            workspace,
 45            limit,
 46            cursor: 0,
 47            history: VecDeque::new(),
 48        }
 49    }
 50
 51    pub fn can_undo(&self) -> bool {
 52        self.cursor > 0
 53    }
 54
 55    pub fn can_redo(&self) -> bool {
 56        self.cursor < self.history.len()
 57    }
 58
 59    pub fn undo(&mut self, cx: &mut App) {
 60        if self.cursor == 0 {
 61            return;
 62        }
 63
 64        // We don't currently care whether the undo operation failed or
 65        // succeeded, so the cursor can always be updated, as we just assume
 66        // we'll be attempting to undo the next operation, even if undoing
 67        // the previous one failed.
 68        self.cursor -= 1;
 69
 70        if let Some(operation) = self.history.get(self.cursor) {
 71            let task = self.undo_operation(operation, cx);
 72            let workspace = self.workspace.clone();
 73
 74            cx.spawn(async move |cx| {
 75                let errors = task.await;
 76                if !errors.is_empty() {
 77                    cx.update(|cx| {
 78                        let messages = errors
 79                            .iter()
 80                            .map(|err| SharedString::from(err.to_string()))
 81                            .collect();
 82
 83                        Self::show_errors(workspace, messages, cx)
 84                    })
 85                }
 86            })
 87            .detach();
 88        }
 89    }
 90
 91    pub fn redo(&mut self, _cx: &mut App) {
 92        if self.cursor >= self.history.len() {
 93            return;
 94        }
 95
 96        if let Some(_operation) = self.history.get(self.cursor) {
 97            // TODO!: Implement actual operation redo.
 98        }
 99
100        self.cursor += 1;
101    }
102
103    pub fn record(&mut self, operation: ProjectPanelOperation) {
104        // Recording a new operation while the cursor is not at the end of the
105        // undo history should remove all operations from the cursor position to
106        // the end instead of inserting an operation in the middle of the undo
107        // history.
108        if self.cursor < self.history.len() {
109            self.history.drain(self.cursor..);
110        }
111
112        // The `cursor` is only increased in the case where the history's length
113        // is not yet at the limit, because when it is, the `cursor` value
114        // should already match `limit`.
115        if self.history.len() >= self.limit {
116            self.history.pop_front();
117            self.history.push_back(operation);
118        } else {
119            self.history.push_back(operation);
120            self.cursor += 1;
121        }
122    }
123
124    pub fn record_batch(&mut self, operations: impl IntoIterator<Item = ProjectPanelOperation>) {
125        let mut operations = operations.into_iter().collect::<Vec<_>>();
126        let operation = match operations.len() {
127            0 => return,
128            1 => operations.pop().unwrap(),
129            _ => ProjectPanelOperation::Batch(operations),
130        };
131
132        self.record(operation);
133    }
134
135    /// Attempts to revert the provided `operation`, returning a vector of errors
136    /// in case there was any failure while reverting the operation.
137    ///
138    /// For all operations other than [`crate::undo::ProjectPanelOperation::Batch`], a maximum
139    /// of one error is returned.
140    fn undo_operation(
141        &self,
142        operation: &ProjectPanelOperation,
143        cx: &mut App,
144    ) -> Task<Vec<anyhow::Error>> {
145        match operation {
146            ProjectPanelOperation::Create { project_path } => {
147                let Some(workspace) = self.workspace.upgrade() else {
148                    return Task::ready(vec![anyhow!("Failed to obtain workspace.")]);
149                };
150
151                let result = workspace.update(cx, |workspace, cx| {
152                    workspace.project().update(cx, |project, cx| {
153                        let entry_id = project
154                            .entry_for_path(&project_path, cx)
155                            .map(|entry| entry.id)
156                            .ok_or_else(|| anyhow!("No entry for path."))?;
157
158                        project
159                            .delete_entry(entry_id, true, cx)
160                            .ok_or_else(|| anyhow!("Failed to trash entry."))
161                    })
162                });
163
164                let task = match result {
165                    Ok(task) => task,
166                    Err(err) => return Task::ready(vec![err]),
167                };
168
169                cx.spawn(async move |_| match task.await {
170                    Ok(_) => vec![],
171                    Err(err) => vec![err],
172                })
173            }
174            ProjectPanelOperation::Rename { old_path, new_path } => {
175                let Some(workspace) = self.workspace.upgrade() else {
176                    return Task::ready(vec![anyhow!("Failed to obtain workspace.")]);
177                };
178
179                let result = workspace.update(cx, |workspace, cx| {
180                    workspace.project().update(cx, |project, cx| {
181                        let entry_id = project
182                            .entry_for_path(&new_path, cx)
183                            .map(|entry| entry.id)
184                            .ok_or_else(|| anyhow!("No entry for path."))?;
185
186                        Ok(project.rename_entry(entry_id, old_path.clone(), cx))
187                    })
188                });
189
190                let task = match result {
191                    Ok(task) => task,
192                    Err(err) => return Task::ready(vec![err]),
193                };
194
195                cx.spawn(async move |_| match task.await {
196                    Ok(_) => vec![],
197                    Err(err) => vec![err],
198                })
199            }
200            ProjectPanelOperation::Batch(operations) => {
201                // When reverting operations in a batch, we reverse the order of
202                // operations to handle dependencies between them. For example,
203                // if a batch contains the following order of operations:
204                //
205                // 1. Create `src/`
206                // 2. Create `src/main.rs`
207                //
208                // If we first try to revert the directory creation, it would
209                // fail because there's still files inside the directory.
210                // Operations are also reverted sequentially in order to avoid
211                // this same problem.
212                let tasks: Vec<_> = operations
213                    .into_iter()
214                    .rev()
215                    .map(|operation| self.undo_operation(operation, cx))
216                    .collect();
217
218                cx.spawn(async move |_| {
219                    let mut errors = Vec::new();
220                    for task in tasks {
221                        errors.extend(task.await);
222                    }
223                    errors
224                })
225            }
226        }
227    }
228
229    /// Displays a notification with the list of provided errors ensuring that,
230    /// when more than one error is provided, which can be the case when dealing
231    /// with undoing a [`crate::undo::ProjectPanelOperation::Batch`], a list is
232    /// displayed with each of the errors, instead of a single message.
233    fn show_errors(workspace: WeakEntity<Workspace>, messages: Vec<SharedString>, cx: &mut App) {
234        workspace
235            .update(cx, move |workspace, cx| {
236                let notification_id =
237                    NotificationId::Named(SharedString::new_static("project_panel_undo"));
238
239                workspace.show_notification(notification_id, cx, move |cx| {
240                    cx.new(|cx| {
241                        if let [err] = messages.as_slice() {
242                            MessageNotification::new(err.to_string(), cx)
243                                .with_title("Failed to undo Project Panel Operation")
244                        } else {
245                            MessageNotification::new_from_builder(cx, move |_, _| {
246                                v_flex()
247                                    .gap_1()
248                                    .children(
249                                        messages
250                                            .iter()
251                                            .map(|message| Label::new(format!("- {message}"))),
252                                    )
253                                    .into_any_element()
254                            })
255                            .with_title("Failed to undo Project Panel Operations")
256                        }
257                    })
258                })
259            })
260            .ok();
261    }
262}
263
264#[cfg(test)]
265mod test {
266    use crate::{
267        ProjectPanel, project_panel_tests,
268        undo::{ProjectPanelOperation, UndoManager},
269    };
270    use gpui::{Entity, TestAppContext, VisualTestContext};
271    use project::{FakeFs, Project, ProjectPath, WorktreeId};
272    use std::sync::Arc;
273    use util::rel_path::rel_path;
274    use workspace::MultiWorkspace;
275
276    struct TestContext {
277        project: Entity<Project>,
278        panel: Entity<ProjectPanel>,
279    }
280
281    async fn init_test(cx: &mut TestAppContext) -> TestContext {
282        project_panel_tests::init_test(cx);
283
284        let fs = FakeFs::new(cx.executor());
285        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
286        let window =
287            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
288        let workspace = window
289            .read_with(cx, |mw, _| mw.workspace().clone())
290            .unwrap();
291        let cx = &mut VisualTestContext::from_window(window.into(), cx);
292        let panel = workspace.update_in(cx, ProjectPanel::new);
293        cx.run_until_parked();
294
295        TestContext { project, panel }
296    }
297
298    fn build_create_operation(worktree_id: WorktreeId, file_name: &str) -> ProjectPanelOperation {
299        ProjectPanelOperation::Create {
300            project_path: ProjectPath {
301                path: Arc::from(rel_path(file_name)),
302                worktree_id,
303            },
304        }
305    }
306
307    #[gpui::test]
308    async fn test_limit(cx: &mut TestAppContext) {
309        let test_context = init_test(cx).await;
310        let worktree_id = test_context.project.update(cx, |project, cx| {
311            project.visible_worktrees(cx).next().unwrap().read(cx).id()
312        });
313
314        // Since we're updating the `ProjectPanel`'s undo manager with one whose
315        // limit is 3 operations, we only need to create 4 operations which
316        // we'll record, in order to confirm that the oldest operation is
317        // evicted.
318        let operation_a = build_create_operation(worktree_id, "file_a.txt");
319        let operation_b = build_create_operation(worktree_id, "file_b.txt");
320        let operation_c = build_create_operation(worktree_id, "file_c.txt");
321        let operation_d = build_create_operation(worktree_id, "file_d.txt");
322
323        test_context.panel.update(cx, move |panel, _cx| {
324            panel.undo_manager = UndoManager::new_with_limit(panel.workspace.clone(), 3);
325            panel.undo_manager.record(operation_a);
326            panel.undo_manager.record(operation_b);
327            panel.undo_manager.record(operation_c);
328            panel.undo_manager.record(operation_d);
329
330            assert_eq!(panel.undo_manager.history.len(), 3);
331        });
332    }
333
334    #[gpui::test]
335    async fn test_cursor(cx: &mut TestAppContext) {
336        let test_context = init_test(cx).await;
337        let worktree_id = test_context.project.update(cx, |project, cx| {
338            project.visible_worktrees(cx).next().unwrap().read(cx).id()
339        });
340
341        test_context.panel.update(cx, |panel, _cx| {
342            panel.undo_manager = UndoManager::new_with_limit(panel.workspace.clone(), 3);
343            panel
344                .undo_manager
345                .record(build_create_operation(worktree_id, "file_a.txt"));
346
347            assert_eq!(panel.undo_manager.cursor, 1);
348        });
349
350        test_context.panel.update(cx, |panel, cx| {
351            panel.undo_manager.undo(cx);
352
353            // Ensure that only the `UndoManager::cursor` is updated, as the
354            // history should remain unchanged, so we can later redo the
355            // operation.
356            assert_eq!(panel.undo_manager.cursor, 0);
357            assert_eq!(
358                panel.undo_manager.history,
359                vec![build_create_operation(worktree_id, "file_a.txt")]
360            );
361
362            panel.undo_manager.undo(cx);
363
364            // Undoing when cursor is already at `0` should have no effect on
365            // both the `cursor` and `history`.
366            assert_eq!(panel.undo_manager.cursor, 0);
367            assert_eq!(
368                panel.undo_manager.history,
369                vec![build_create_operation(worktree_id, "file_a.txt")]
370            );
371        });
372
373        test_context.panel.update(cx, |panel, cx| {
374            panel.undo_manager.redo(cx);
375
376            // Ensure that only the `UndoManager::cursor` is updated, since
377            // we're only re-doing an operation that was already part of the
378            // undo history.
379            assert_eq!(panel.undo_manager.cursor, 1);
380            assert_eq!(
381                panel.undo_manager.history,
382                vec![build_create_operation(worktree_id, "file_a.txt")]
383            );
384        });
385
386        test_context.panel.update(cx, |panel, _cx| {
387            panel
388                .undo_manager
389                .record(build_create_operation(worktree_id, "file_b.txt"));
390            panel
391                .undo_manager
392                .record(build_create_operation(worktree_id, "file_c.txt"));
393
394            assert_eq!(panel.undo_manager.cursor, panel.undo_manager.limit);
395
396            panel
397                .undo_manager
398                .record(build_create_operation(worktree_id, "file_d.txt"));
399
400            // Ensure that the operation to create `file_a.txt` has been evicted
401            // but the cursor has not grown when that new operation was
402            // recorded, as the history was already at its limit.
403            assert_eq!(panel.undo_manager.cursor, panel.undo_manager.limit);
404            assert_eq!(
405                panel.undo_manager.history,
406                vec![
407                    build_create_operation(worktree_id, "file_b.txt"),
408                    build_create_operation(worktree_id, "file_c.txt"),
409                    build_create_operation(worktree_id, "file_d.txt")
410                ]
411            );
412        });
413
414        // We'll now undo 2 operations, ensuring that the `cursor` is updated
415        // accordingly. Afterwards, we'll record a new operation and verify that
416        // the `cursor` is incremented but that all operations from the previous
417        // cursor position onwards are discarded.
418        test_context.panel.update(cx, |panel, cx| {
419            panel.undo_manager.undo(cx);
420            panel.undo_manager.undo(cx);
421
422            assert_eq!(panel.undo_manager.cursor, 1);
423            assert_eq!(
424                panel.undo_manager.history,
425                vec![
426                    build_create_operation(worktree_id, "file_b.txt"),
427                    build_create_operation(worktree_id, "file_c.txt"),
428                    build_create_operation(worktree_id, "file_d.txt")
429                ]
430            );
431
432            panel
433                .undo_manager
434                .record(build_create_operation(worktree_id, "file_e.txt"));
435
436            assert_eq!(panel.undo_manager.cursor, 2);
437            assert_eq!(
438                panel.undo_manager.history,
439                vec![
440                    build_create_operation(worktree_id, "file_b.txt"),
441                    build_create_operation(worktree_id, "file_e.txt"),
442                ]
443            );
444        });
445    }
446}