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}