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)]
 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    stack: VecDeque<ProjectPanelOperation>,
 28    /// Maximum number of operations to keep on the undo stack.
 29    limit: usize,
 30}
 31
 32impl UndoManager {
 33    pub fn new(workspace: WeakEntity<Workspace>) -> Self {
 34        Self::new_with_limit(workspace, MAX_UNDO_OPERATIONS)
 35    }
 36
 37    pub fn new_with_limit(workspace: WeakEntity<Workspace>, limit: usize) -> Self {
 38        Self {
 39            workspace,
 40            limit,
 41            stack: VecDeque::new(),
 42        }
 43    }
 44
 45    pub fn can_undo(&self) -> bool {
 46        !self.stack.is_empty()
 47    }
 48
 49    pub fn undo(&mut self, cx: &mut App) {
 50        if let Some(operation) = self.stack.pop_back() {
 51            let task = self.revert_operation(operation, cx);
 52            let workspace = self.workspace.clone();
 53
 54            cx.spawn(async move |cx| {
 55                let errors = task.await;
 56                if !errors.is_empty() {
 57                    cx.update(|cx| {
 58                        let messages = errors
 59                            .iter()
 60                            .map(|err| SharedString::from(err.to_string()))
 61                            .collect();
 62
 63                        Self::show_errors(workspace, messages, cx)
 64                    })
 65                }
 66            })
 67            .detach();
 68        }
 69    }
 70
 71    pub fn record(&mut self, operation: ProjectPanelOperation) {
 72        if self.stack.len() >= self.limit {
 73            self.stack.pop_front();
 74        }
 75
 76        self.stack.push_back(operation);
 77    }
 78
 79    pub fn record_batch(&mut self, operations: impl IntoIterator<Item = ProjectPanelOperation>) {
 80        let mut operations = operations.into_iter().collect::<Vec<_>>();
 81        let operation = match operations.len() {
 82            0 => return,
 83            1 => operations.pop().unwrap(),
 84            _ => ProjectPanelOperation::Batch(operations),
 85        };
 86
 87        self.record(operation);
 88    }
 89
 90    /// Attempts to revert the provided `operation`, returning a vector of errors
 91    /// in case there was any failure while reverting the operation.
 92    ///
 93    /// For all operations other than [`crate::undo::ProjectPanelOperation::Batch`], a maximum
 94    /// of one error is returned.
 95    fn revert_operation(
 96        &self,
 97        operation: ProjectPanelOperation,
 98        cx: &mut App,
 99    ) -> Task<Vec<anyhow::Error>> {
100        match operation {
101            ProjectPanelOperation::Create { project_path } => {
102                let Some(workspace) = self.workspace.upgrade() else {
103                    return Task::ready(vec![anyhow!("Failed to obtain workspace.")]);
104                };
105
106                let result = workspace.update(cx, |workspace, cx| {
107                    workspace.project().update(cx, |project, cx| {
108                        let entry_id = project
109                            .entry_for_path(&project_path, cx)
110                            .map(|entry| entry.id)
111                            .ok_or_else(|| anyhow!("No entry for path."))?;
112
113                        project
114                            .delete_entry(entry_id, true, cx)
115                            .ok_or_else(|| anyhow!("Failed to trash entry."))
116                    })
117                });
118
119                let task = match result {
120                    Ok(task) => task,
121                    Err(err) => return Task::ready(vec![err]),
122                };
123
124                cx.spawn(async move |_| match task.await {
125                    Ok(_) => vec![],
126                    Err(err) => vec![err],
127                })
128            }
129            ProjectPanelOperation::Rename { old_path, new_path } => {
130                let Some(workspace) = self.workspace.upgrade() else {
131                    return Task::ready(vec![anyhow!("Failed to obtain workspace.")]);
132                };
133
134                let result = workspace.update(cx, |workspace, cx| {
135                    workspace.project().update(cx, |project, cx| {
136                        let entry_id = project
137                            .entry_for_path(&new_path, cx)
138                            .map(|entry| entry.id)
139                            .ok_or_else(|| anyhow!("No entry for path."))?;
140
141                        Ok(project.rename_entry(entry_id, old_path.clone(), cx))
142                    })
143                });
144
145                let task = match result {
146                    Ok(task) => task,
147                    Err(err) => return Task::ready(vec![err]),
148                };
149
150                cx.spawn(async move |_| match task.await {
151                    Ok(_) => vec![],
152                    Err(err) => vec![err],
153                })
154            }
155            ProjectPanelOperation::Batch(operations) => {
156                // When reverting operations in a batch, we reverse the order of
157                // operations to handle dependencies between them. For example,
158                // if a batch contains the following order of operations:
159                //
160                // 1. Create `src/`
161                // 2. Create `src/main.rs`
162                //
163                // If we first try to revert the directory creation, it would
164                // fail because there's still files inside the directory.
165                // Operations are also reverted sequentially in order to avoid
166                // this same problem.
167                let tasks: Vec<_> = operations
168                    .into_iter()
169                    .rev()
170                    .map(|operation| self.revert_operation(operation, cx))
171                    .collect();
172
173                cx.spawn(async move |_| {
174                    let mut errors = Vec::new();
175                    for task in tasks {
176                        errors.extend(task.await);
177                    }
178                    errors
179                })
180            }
181        }
182    }
183
184    /// Displays a notification with the list of provided errors ensuring that,
185    /// when more than one error is provided, which can be the case when dealing
186    /// with undoing a [`crate::undo::ProjectPanelOperation::Batch`], a list is
187    /// displayed with each of the errors, instead of a single message.
188    fn show_errors(workspace: WeakEntity<Workspace>, messages: Vec<SharedString>, cx: &mut App) {
189        workspace
190            .update(cx, move |workspace, cx| {
191                let notification_id =
192                    NotificationId::Named(SharedString::new_static("project_panel_undo"));
193
194                workspace.show_notification(notification_id, cx, move |cx| {
195                    cx.new(|cx| {
196                        if let [err] = messages.as_slice() {
197                            MessageNotification::new(err.to_string(), cx)
198                                .with_title("Failed to undo Project Panel Operation")
199                        } else {
200                            MessageNotification::new_from_builder(cx, move |_, _| {
201                                v_flex()
202                                    .gap_1()
203                                    .children(
204                                        messages
205                                            .iter()
206                                            .map(|message| Label::new(format!("- {message}"))),
207                                    )
208                                    .into_any_element()
209                            })
210                            .with_title("Failed to undo Project Panel Operations")
211                        }
212                    })
213                })
214            })
215            .ok();
216    }
217}
218
219#[cfg(test)]
220mod test {
221    use crate::{
222        ProjectPanel, project_panel_tests,
223        undo::{ProjectPanelOperation, UndoManager},
224    };
225    use gpui::{Entity, TestAppContext, VisualTestContext};
226    use project::{FakeFs, Project, ProjectPath};
227    use std::sync::Arc;
228    use util::rel_path::rel_path;
229    use workspace::MultiWorkspace;
230
231    struct TestContext {
232        project: Entity<Project>,
233        panel: Entity<ProjectPanel>,
234    }
235
236    async fn init_test(cx: &mut TestAppContext) -> TestContext {
237        project_panel_tests::init_test(cx);
238
239        let fs = FakeFs::new(cx.executor());
240        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
241        let window =
242            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
243        let workspace = window
244            .read_with(cx, |mw, _| mw.workspace().clone())
245            .unwrap();
246        let cx = &mut VisualTestContext::from_window(window.into(), cx);
247        let panel = workspace.update_in(cx, ProjectPanel::new);
248        cx.run_until_parked();
249
250        TestContext { project, panel }
251    }
252
253    #[gpui::test]
254    async fn test_limit(cx: &mut TestAppContext) {
255        let test_context = init_test(cx).await;
256        let worktree_id = test_context.project.update(cx, |project, cx| {
257            project.visible_worktrees(cx).next().unwrap().read(cx).id()
258        });
259
260        let build_create_operation = |file_name: &str| ProjectPanelOperation::Create {
261            project_path: ProjectPath {
262                path: Arc::from(rel_path(file_name)),
263                worktree_id,
264            },
265        };
266
267        // Since we're updating the `ProjectPanel`'s undo manager with one whose
268        // limit is 3 operations, we only need to create 4 operations which
269        // we'll record, in order to confirm that the oldest operation is
270        // evicted.
271        let operation_a = build_create_operation("file_a.txt");
272        let operation_b = build_create_operation("file_b.txt");
273        let operation_c = build_create_operation("file_c.txt");
274        let operation_d = build_create_operation("file_d.txt");
275
276        test_context.panel.update(cx, move |panel, _cx| {
277            panel.undo_manager = UndoManager::new_with_limit(panel.workspace.clone(), 3);
278            panel.undo_manager.record(operation_a);
279            panel.undo_manager.record(operation_b);
280            panel.undo_manager.record(operation_c);
281            panel.undo_manager.record(operation_d);
282
283            assert_eq!(panel.undo_manager.stack.len(), 3);
284        });
285    }
286}