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}