1use crate::ProjectPanel;
2use anyhow::{Result, anyhow};
3use gpui::{AppContext, SharedString, Task, WeakEntity};
4use project::ProjectPath;
5use std::collections::VecDeque;
6use ui::App;
7use workspace::{
8 Workspace,
9 notifications::{NotificationId, simple_message_notification::MessageNotification},
10};
11
12const MAX_UNDO_OPERATIONS: usize = 10_000;
13
14#[derive(Clone, Debug, PartialEq)]
15pub enum ProjectPanelOperation {
16 Batch(Vec<ProjectPanelOperation>),
17 Create { project_path: ProjectPath },
18 Trash { project_path: ProjectPath },
19 Rename { from: ProjectPath, to: ProjectPath },
20}
21
22impl ProjectPanelOperation {
23 fn inverse(&self) -> Self {
24 match self {
25 Self::Create { project_path } => Self::Trash {
26 project_path: project_path.clone(),
27 },
28 Self::Trash { project_path } => Self::Create {
29 project_path: project_path.clone(),
30 },
31 Self::Rename { from, to } => Self::Rename {
32 from: to.clone(),
33 to: from.clone(),
34 },
35 // When inverting a batch of operations, we reverse the order of
36 // operations to handle dependencies between them. For example, if a
37 // batch contains the following order of operations:
38 //
39 // 1. Create `src/`
40 // 2. Create `src/main.rs`
41 //
42 // If we first tried to revert the directory creation, it would fail
43 // because there's still files inside the directory.
44 Self::Batch(operations) => Self::Batch(
45 operations
46 .iter()
47 .rev()
48 .map(|operation| operation.inverse())
49 .collect(),
50 ),
51 }
52 }
53}
54
55pub struct UndoManager {
56 workspace: WeakEntity<Workspace>,
57 panel: WeakEntity<ProjectPanel>,
58 undo_stack: VecDeque<ProjectPanelOperation>,
59 redo_stack: Vec<ProjectPanelOperation>,
60 /// Maximum number of operations to keep on the undo history.
61 limit: usize,
62}
63
64impl UndoManager {
65 pub fn new(workspace: WeakEntity<Workspace>, panel: WeakEntity<ProjectPanel>) -> Self {
66 Self::new_with_limit(workspace, panel, MAX_UNDO_OPERATIONS)
67 }
68
69 pub fn new_with_limit(
70 workspace: WeakEntity<Workspace>,
71 panel: WeakEntity<ProjectPanel>,
72 limit: usize,
73 ) -> Self {
74 Self {
75 workspace,
76 panel,
77 limit,
78 undo_stack: VecDeque::new(),
79 redo_stack: Vec::new(),
80 }
81 }
82
83 pub fn can_undo(&self) -> bool {
84 !self.undo_stack.is_empty()
85 }
86
87 pub fn can_redo(&self) -> bool {
88 !self.redo_stack.is_empty()
89 }
90
91 pub fn undo(&mut self, cx: &mut App) {
92 if let Some(operation) = self.undo_stack.pop_back() {
93 let task = self.execute_operation(&operation, cx);
94 let panel = self.panel.clone();
95 let workspace = self.workspace.clone();
96
97 cx.spawn(async move |cx| match task.await {
98 Ok(operation) => panel.update(cx, |panel, _cx| {
99 panel.undo_manager.redo_stack.push(operation)
100 }),
101 Err(err) => cx.update(|cx| {
102 Self::show_error(
103 "Failed to undo Project Panel Operation(s)",
104 workspace,
105 err.to_string().into(),
106 cx,
107 );
108
109 Ok(())
110 }),
111 })
112 .detach();
113 }
114 }
115
116 pub fn redo(&mut self, cx: &mut App) {
117 if let Some(operation) = self.redo_stack.pop() {
118 let task = self.execute_operation(&operation, cx);
119 let panel = self.panel.clone();
120 let workspace = self.workspace.clone();
121
122 cx.spawn(async move |cx| match task.await {
123 Ok(operation) => panel.update(cx, |panel, _cx| {
124 panel.undo_manager.undo_stack.push_back(operation)
125 }),
126 Err(err) => cx.update(|cx| {
127 Self::show_error(
128 "Failed to redo Project Panel Operation(s)",
129 workspace,
130 err.to_string().into(),
131 cx,
132 );
133
134 Ok(())
135 }),
136 })
137 .detach();
138 }
139 }
140
141 pub fn record(&mut self, operation: ProjectPanelOperation) {
142 // Recording a new operation while there's still operations in the
143 // `redo_stack` should clear all operations from the `redo_stack`, as we
144 // might end up in a situation where the state diverges and the
145 // `redo_stack` operations can no longer be done.
146 if !self.redo_stack.is_empty() {
147 self.redo_stack.clear();
148 }
149
150 if self.undo_stack.len() >= self.limit {
151 self.undo_stack.pop_front();
152 }
153
154 self.undo_stack.push_back(operation.inverse());
155 }
156
157 pub fn record_batch(&mut self, operations: impl IntoIterator<Item = ProjectPanelOperation>) {
158 let mut operations = operations.into_iter().collect::<Vec<_>>();
159 let operation = match operations.len() {
160 0 => return,
161 1 => operations.pop().unwrap(),
162 _ => ProjectPanelOperation::Batch(operations),
163 };
164
165 self.record(operation);
166 }
167
168 /// Attempts to execute the provided operation, returning the inverse of the
169 /// provided `operation` as a result.
170 fn execute_operation(
171 &mut self,
172 operation: &ProjectPanelOperation,
173 cx: &mut App,
174 ) -> Task<Result<ProjectPanelOperation>> {
175 match operation {
176 ProjectPanelOperation::Rename { from, to } => self.rename(from, to, cx),
177 ProjectPanelOperation::Trash { project_path } => self.trash(project_path, cx),
178 ProjectPanelOperation::Create { project_path } => self.create(project_path, cx),
179 ProjectPanelOperation::Batch(operations) => self.batch(operations, cx),
180 }
181 }
182
183 fn rename(
184 &self,
185 from: &ProjectPath,
186 to: &ProjectPath,
187 cx: &mut App,
188 ) -> Task<Result<ProjectPanelOperation>> {
189 let Some(workspace) = self.workspace.upgrade() else {
190 return Task::ready(Err(anyhow!("Failed to obtain workspace.")));
191 };
192
193 let result = workspace.update(cx, |workspace, cx| {
194 workspace.project().update(cx, |project, cx| {
195 let entry_id = project
196 .entry_for_path(from, cx)
197 .map(|entry| entry.id)
198 .ok_or_else(|| anyhow!("No entry for path."))?;
199
200 Ok(project.rename_entry(entry_id, to.clone(), cx))
201 })
202 });
203
204 let task = match result {
205 Ok(task) => task,
206 Err(err) => return Task::ready(Err(err)),
207 };
208
209 let from = from.clone();
210 let to = to.clone();
211 cx.spawn(async move |_| match task.await {
212 Err(err) => Err(err),
213 Ok(_) => Ok(ProjectPanelOperation::Rename {
214 from: to.clone(),
215 to: from.clone(),
216 }),
217 })
218 }
219
220 fn create(
221 &self,
222 project_path: &ProjectPath,
223 cx: &mut App,
224 ) -> Task<Result<ProjectPanelOperation>> {
225 let Some(workspace) = self.workspace.upgrade() else {
226 return Task::ready(Err(anyhow!("Failed to obtain workspace.")));
227 };
228
229 let task = workspace.update(cx, |workspace, cx| {
230 workspace.project().update(cx, |project, cx| {
231 // This should not be hardcoded to `false`, as it can genuinely
232 // be a directory and it misses all the nuances and details from
233 // `ProjectPanel::confirm_edit`. However, we expect this to be a
234 // short-lived solution as we add support for restoring trashed
235 // files, at which point we'll no longer need to `Create` new
236 // files, any redoing of a trash operation should be a restore.
237 let is_directory = false;
238 project.create_entry(project_path.clone(), is_directory, cx)
239 })
240 });
241
242 let project_path = project_path.clone();
243 cx.spawn(async move |_| match task.await {
244 Ok(_) => Ok(ProjectPanelOperation::Trash { project_path }),
245 Err(err) => Err(err),
246 })
247 }
248
249 fn trash(
250 &self,
251 project_path: &ProjectPath,
252 cx: &mut App,
253 ) -> Task<Result<ProjectPanelOperation>> {
254 let Some(workspace) = self.workspace.upgrade() else {
255 return Task::ready(Err(anyhow!("Failed to obtain workspace.")));
256 };
257
258 let result = workspace.update(cx, |workspace, cx| {
259 workspace.project().update(cx, |project, cx| {
260 let entry_id = project
261 .entry_for_path(&project_path, cx)
262 .map(|entry| entry.id)
263 .ok_or_else(|| anyhow!("No entry for path."))?;
264
265 project
266 .delete_entry(entry_id, true, cx)
267 .ok_or_else(|| anyhow!("Failed to trash entry."))
268 })
269 });
270
271 let task = match result {
272 Ok(task) => task,
273 Err(err) => return Task::ready(Err(err)),
274 };
275
276 let project_path = project_path.clone();
277 cx.spawn(async move |_| match task.await {
278 // We'll want this to eventually be a `Restore` operation, once
279 // we've added support, in `fs` to track and restore a trashed file.
280 Ok(_) => Ok(ProjectPanelOperation::Create { project_path }),
281 Err(err) => Err(err),
282 })
283 }
284
285 fn batch(
286 &mut self,
287 operations: &[ProjectPanelOperation],
288 cx: &mut App,
289 ) -> Task<Result<ProjectPanelOperation>> {
290 let tasks: Vec<_> = operations
291 .into_iter()
292 .map(|operation| self.execute_operation(operation, cx))
293 .collect();
294
295 cx.spawn(async move |_| {
296 let mut operations = Vec::new();
297
298 for task in tasks {
299 match task.await {
300 Ok(operation) => operations.push(operation),
301 Err(err) => return Err(err),
302 }
303 }
304
305 // Return the `ProjectPanelOperation::Batch` that reverses all of
306 // the provided operations. The order of operations should be reversed
307 // so that dependencies are handled correctly.
308 operations.reverse();
309 Ok(ProjectPanelOperation::Batch(operations))
310 })
311 }
312
313 /// Displays a notification with the provided `title` and `error`.
314 fn show_error(
315 title: impl Into<SharedString>,
316 workspace: WeakEntity<Workspace>,
317 error: SharedString,
318 cx: &mut App,
319 ) {
320 workspace
321 .update(cx, move |workspace, cx| {
322 let notification_id =
323 NotificationId::Named(SharedString::new_static("project_panel_undo"));
324
325 workspace.show_notification(notification_id, cx, move |cx| {
326 cx.new(|cx| MessageNotification::new(error.to_string(), cx).with_title(title))
327 })
328 })
329 .ok();
330 }
331}
332
333#[cfg(test)]
334pub(crate) mod test {
335 use crate::{
336 ProjectPanel, project_panel_tests,
337 undo::{ProjectPanelOperation, UndoManager},
338 };
339 use gpui::{Entity, TestAppContext, VisualTestContext, WindowHandle};
340 use project::{FakeFs, Project, ProjectPath, WorktreeId};
341 use serde_json::{Value, json};
342 use std::sync::Arc;
343 use util::rel_path::rel_path;
344 use workspace::MultiWorkspace;
345
346 struct TestContext {
347 project: Entity<Project>,
348 panel: Entity<ProjectPanel>,
349 window: WindowHandle<MultiWorkspace>,
350 }
351
352 async fn init_test(cx: &mut TestAppContext, tree: Option<Value>) -> TestContext {
353 project_panel_tests::init_test(cx);
354
355 let fs = FakeFs::new(cx.executor());
356 if let Some(tree) = tree {
357 fs.insert_tree("/root", tree).await;
358 }
359 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
360 let window =
361 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
362 let workspace = window
363 .read_with(cx, |mw, _| mw.workspace().clone())
364 .unwrap();
365 let cx = &mut VisualTestContext::from_window(window.into(), cx);
366 let panel = workspace.update_in(cx, ProjectPanel::new);
367 cx.run_until_parked();
368
369 TestContext {
370 project,
371 panel,
372 window,
373 }
374 }
375
376 pub(crate) fn build_create_operation(
377 worktree_id: WorktreeId,
378 file_name: &str,
379 ) -> ProjectPanelOperation {
380 ProjectPanelOperation::Create {
381 project_path: ProjectPath {
382 path: Arc::from(rel_path(file_name)),
383 worktree_id,
384 },
385 }
386 }
387
388 pub(crate) fn build_trash_operation(
389 worktree_id: WorktreeId,
390 file_name: &str,
391 ) -> ProjectPanelOperation {
392 ProjectPanelOperation::Trash {
393 project_path: ProjectPath {
394 path: Arc::from(rel_path(file_name)),
395 worktree_id,
396 },
397 }
398 }
399
400 pub(crate) fn build_rename_operation(
401 worktree_id: WorktreeId,
402 from: &str,
403 to: &str,
404 ) -> ProjectPanelOperation {
405 let from_path = Arc::from(rel_path(from));
406 let to_path = Arc::from(rel_path(to));
407
408 ProjectPanelOperation::Rename {
409 from: ProjectPath {
410 worktree_id,
411 path: from_path,
412 },
413 to: ProjectPath {
414 worktree_id,
415 path: to_path,
416 },
417 }
418 }
419
420 async fn rename(
421 panel: &Entity<ProjectPanel>,
422 from: &str,
423 to: &str,
424 cx: &mut VisualTestContext,
425 ) {
426 project_panel_tests::select_path(panel, from, cx);
427 panel.update_in(cx, |panel, window, cx| {
428 panel.rename(&Default::default(), window, cx)
429 });
430 cx.run_until_parked();
431
432 panel
433 .update_in(cx, |panel, window, cx| {
434 panel
435 .filename_editor
436 .update(cx, |editor, cx| editor.set_text(to, window, cx));
437 panel.confirm_edit(true, window, cx).unwrap()
438 })
439 .await
440 .unwrap();
441 cx.run_until_parked();
442 }
443
444 #[gpui::test]
445 async fn test_limit(cx: &mut TestAppContext) {
446 let test_context = init_test(cx, None).await;
447 let worktree_id = test_context.project.update(cx, |project, cx| {
448 project.visible_worktrees(cx).next().unwrap().read(cx).id()
449 });
450
451 // Since we're updating the `ProjectPanel`'s undo manager with one whose
452 // limit is 3 operations, we only need to create 4 operations which
453 // we'll record, in order to confirm that the oldest operation is
454 // evicted.
455 let operation_a = build_create_operation(worktree_id, "file_a.txt");
456 let operation_b = build_create_operation(worktree_id, "file_b.txt");
457 let operation_c = build_create_operation(worktree_id, "file_c.txt");
458 let operation_d = build_create_operation(worktree_id, "file_d.txt");
459
460 test_context.panel.update(cx, move |panel, cx| {
461 panel.undo_manager =
462 UndoManager::new_with_limit(panel.workspace.clone(), cx.weak_entity(), 3);
463 panel.undo_manager.record(operation_a);
464 panel.undo_manager.record(operation_b);
465 panel.undo_manager.record(operation_c);
466 panel.undo_manager.record(operation_d);
467
468 assert_eq!(panel.undo_manager.undo_stack.len(), 3);
469 });
470 }
471 #[gpui::test]
472 async fn test_undo_redo_stacks(cx: &mut TestAppContext) {
473 let TestContext {
474 window,
475 panel,
476 project,
477 ..
478 } = init_test(
479 cx,
480 Some(json!({
481 "a.txt": "",
482 "b.txt": ""
483 })),
484 )
485 .await;
486 let worktree_id = project.update(cx, |project, cx| {
487 project.visible_worktrees(cx).next().unwrap().read(cx).id()
488 });
489 let cx = &mut VisualTestContext::from_window(window.into(), cx);
490
491 // Start by renaming `src/file_a.txt` to `src/file_1.txt` and asserting
492 // we get the correct inverse operation in the
493 // `UndoManager::undo_stackand asserting we get the correct inverse
494 // operation in the `UndoManager::undo_stack`.
495 rename(&panel, "root/a.txt", "1.txt", cx).await;
496 panel.update(cx, |panel, _cx| {
497 assert_eq!(
498 panel.undo_manager.undo_stack,
499 vec![build_rename_operation(worktree_id, "1.txt", "a.txt")]
500 );
501 assert!(panel.undo_manager.redo_stack.is_empty());
502 });
503
504 // After undoing, the operation to be executed should be popped from
505 // `UndoManager::undo_stack` and its inverse operation pushed to
506 // `UndoManager::redo_stack`.
507 panel.update_in(cx, |panel, window, cx| {
508 panel.undo(&Default::default(), window, cx);
509 });
510 cx.run_until_parked();
511
512 panel.update(cx, |panel, _cx| {
513 assert!(panel.undo_manager.undo_stack.is_empty());
514 assert_eq!(
515 panel.undo_manager.redo_stack,
516 vec![build_rename_operation(worktree_id, "a.txt", "1.txt")]
517 );
518 });
519
520 // Redoing should have the same effect as undoing, but in reverse.
521 panel.update_in(cx, |panel, window, cx| {
522 panel.redo(&Default::default(), window, cx);
523 });
524 cx.run_until_parked();
525
526 panel.update(cx, |panel, _cx| {
527 assert_eq!(
528 panel.undo_manager.undo_stack,
529 vec![build_rename_operation(worktree_id, "1.txt", "a.txt")]
530 );
531 assert!(panel.undo_manager.redo_stack.is_empty());
532 });
533 }
534
535 #[gpui::test]
536 async fn test_undo_redo_trash(cx: &mut TestAppContext) {
537 let TestContext {
538 window,
539 panel,
540 project,
541 ..
542 } = init_test(
543 cx,
544 Some(json!({
545 "a.txt": "",
546 "b.txt": ""
547 })),
548 )
549 .await;
550 let worktree_id = project.update(cx, |project, cx| {
551 project.visible_worktrees(cx).next().unwrap().read(cx).id()
552 });
553 let cx = &mut VisualTestContext::from_window(window.into(), cx);
554
555 // Start by setting up the `UndoManager::undo_stack` such that, undoing
556 // the last user operation will trash `a.txt`.
557 panel.update(cx, |panel, _cx| {
558 panel
559 .undo_manager
560 .undo_stack
561 .push_back(build_trash_operation(worktree_id, "a.txt"));
562 });
563
564 // Undoing should now delete the file and update the
565 // `UndoManager::redo_stack` state with a new `Create` operation.
566 panel.update_in(cx, |panel, window, cx| {
567 panel.undo(&Default::default(), window, cx);
568 });
569 cx.run_until_parked();
570
571 panel.update(cx, |panel, _cx| {
572 assert!(panel.undo_manager.undo_stack.is_empty());
573 assert_eq!(
574 panel.undo_manager.redo_stack,
575 vec![build_create_operation(worktree_id, "a.txt")]
576 );
577 });
578
579 // Redoing should create the file again and pop the operation from
580 // `UndoManager::redo_stack`.
581 panel.update_in(cx, |panel, window, cx| {
582 panel.redo(&Default::default(), window, cx);
583 });
584 cx.run_until_parked();
585
586 panel.update(cx, |panel, _cx| {
587 assert_eq!(
588 panel.undo_manager.undo_stack,
589 vec![build_trash_operation(worktree_id, "a.txt")]
590 );
591 assert!(panel.undo_manager.redo_stack.is_empty());
592 });
593 }
594
595 #[gpui::test]
596 async fn test_undo_redo_batch(cx: &mut TestAppContext) {
597 let TestContext {
598 window,
599 panel,
600 project,
601 ..
602 } = init_test(
603 cx,
604 Some(json!({
605 "a.txt": "",
606 "b.txt": ""
607 })),
608 )
609 .await;
610 let worktree_id = project.update(cx, |project, cx| {
611 project.visible_worktrees(cx).next().unwrap().read(cx).id()
612 });
613 let cx = &mut VisualTestContext::from_window(window.into(), cx);
614
615 // There's currently no way to trigger two file renames in a single
616 // operation using the `ProjectPanel`. As such, we'll directly record
617 // the batch of operations in `UndoManager`, simulating that `1.txt` and
618 // `2.txt` had been renamed to `a.txt` and `b.txt`, respectively.
619 panel.update(cx, |panel, _cx| {
620 panel.undo_manager.record_batch(vec![
621 build_rename_operation(worktree_id, "1.txt", "a.txt"),
622 build_rename_operation(worktree_id, "2.txt", "b.txt"),
623 ]);
624
625 assert_eq!(
626 panel.undo_manager.undo_stack,
627 vec![ProjectPanelOperation::Batch(vec![
628 build_rename_operation(worktree_id, "b.txt", "2.txt"),
629 build_rename_operation(worktree_id, "a.txt", "1.txt"),
630 ])]
631 );
632 assert!(panel.undo_manager.redo_stack.is_empty());
633 });
634
635 panel.update_in(cx, |panel, window, cx| {
636 panel.undo(&Default::default(), window, cx);
637 });
638 cx.run_until_parked();
639
640 // Since the operations in the `Batch` are meant to be done in order,
641 // the inverse should have the operations in the opposite order to avoid
642 // dependencies. For example, creating a `src/` folder come before
643 // creating the `src/file_a.txt` file, but when undoing, the file should
644 // be trashed first.
645 panel.update(cx, |panel, _cx| {
646 assert!(panel.undo_manager.undo_stack.is_empty());
647 assert_eq!(
648 panel.undo_manager.redo_stack,
649 vec![ProjectPanelOperation::Batch(vec![
650 build_rename_operation(worktree_id, "1.txt", "a.txt"),
651 build_rename_operation(worktree_id, "2.txt", "b.txt"),
652 ])]
653 );
654 });
655
656 panel.update_in(cx, |panel, window, cx| {
657 panel.redo(&Default::default(), window, cx);
658 });
659 cx.run_until_parked();
660
661 panel.update(cx, |panel, _cx| {
662 assert_eq!(
663 panel.undo_manager.undo_stack,
664 vec![ProjectPanelOperation::Batch(vec![
665 build_rename_operation(worktree_id, "b.txt", "2.txt"),
666 build_rename_operation(worktree_id, "a.txt", "1.txt"),
667 ])]
668 );
669 assert!(panel.undo_manager.redo_stack.is_empty());
670 });
671 }
672}