undo.rs

  1#![cfg(test)]
  2
  3use collections::HashSet;
  4use fs::{FakeFs, Fs};
  5use gpui::{Entity, VisualTestContext};
  6use project::Project;
  7use serde_json::{Value, json};
  8use std::path::Path;
  9use std::sync::Arc;
 10use workspace::MultiWorkspace;
 11
 12use crate::project_panel_tests::{self, find_project_entry, select_path};
 13use crate::{NewDirectory, NewFile, ProjectPanel, Redo, Rename, Trash, Undo};
 14
 15struct TestContext {
 16    panel: Entity<ProjectPanel>,
 17    fs: Arc<FakeFs>,
 18    cx: VisualTestContext,
 19}
 20
 21// Using the `util::path` macro requires a string literal, which would mean that
 22// callers of, for example, `rename`, would now need to know about `/` and
 23// use `path!` in tests.
 24//
 25// As such, we define it as a function here to make the helper methods more
 26// ergonomic for our use case.
 27fn path(path: impl AsRef<str>) -> String {
 28    let path = path.as_ref();
 29    #[cfg(target_os = "windows")]
 30    {
 31        let mut path = path.replace("/", "\\");
 32        if path.starts_with("\\") {
 33            path = format!("C:{}", &path);
 34        }
 35        path
 36    }
 37
 38    #[cfg(not(target_os = "windows"))]
 39    {
 40        path.to_string()
 41    }
 42}
 43
 44impl TestContext {
 45    async fn undo(&mut self) {
 46        self.panel.update_in(&mut self.cx, |panel, window, cx| {
 47            panel.undo(&Undo, window, cx);
 48        });
 49        self.cx.run_until_parked();
 50    }
 51    async fn redo(&mut self) {
 52        self.panel.update_in(&mut self.cx, |panel, window, cx| {
 53            panel.redo(&Redo, window, cx);
 54        });
 55        self.cx.run_until_parked();
 56    }
 57
 58    /// Note this only works when every file has an extension
 59    fn assert_fs_state_is(&mut self, state: &[&str]) {
 60        let state: HashSet<_> = state
 61            .into_iter()
 62            .map(|s| path(format!("/workspace/{s}")))
 63            .chain([path("/workspace"), path("/")])
 64            .map(|s| Path::new(&s).to_path_buf())
 65            .collect();
 66
 67        let dirs: HashSet<_> = state
 68            .iter()
 69            .map(|p| match p.extension() {
 70                Some(_) => p.parent().unwrap_or(Path::new(&path("/"))).to_owned(),
 71                None => p.clone(),
 72            })
 73            .collect();
 74
 75        assert_eq!(
 76            self.fs
 77                .directories(true)
 78                .into_iter()
 79                .collect::<HashSet<_>>(),
 80            dirs
 81        );
 82        assert_eq!(
 83            self.fs.paths(true).into_iter().collect::<HashSet<_>>(),
 84            state
 85        );
 86    }
 87
 88    fn assert_exists(&mut self, file: &str) {
 89        assert!(
 90            find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx).is_some(),
 91            "{file} should exist"
 92        );
 93    }
 94
 95    fn assert_not_exists(&mut self, file: &str) {
 96        assert_eq!(
 97            find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx),
 98            None,
 99            "{file} should not exist"
100        );
101    }
102
103    async fn rename(&mut self, from: &str, to: &str) {
104        let from = format!("workspace/{from}");
105        let Self { panel, cx, .. } = self;
106        select_path(&panel, &from, cx);
107        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
108        cx.run_until_parked();
109
110        let confirm = panel.update_in(cx, |panel, window, cx| {
111            panel
112                .filename_editor
113                .update(cx, |editor, cx| editor.set_text(to, window, cx));
114            panel.confirm_edit(true, window, cx).unwrap()
115        });
116        confirm.await.unwrap();
117        cx.run_until_parked();
118    }
119
120    async fn create_file(&mut self, path: &str) {
121        let Self { panel, cx, .. } = self;
122        select_path(&panel, "workspace", cx);
123        panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
124        cx.run_until_parked();
125
126        let confirm = panel.update_in(cx, |panel, window, cx| {
127            panel
128                .filename_editor
129                .update(cx, |editor, cx| editor.set_text(path, window, cx));
130            panel.confirm_edit(true, window, cx).unwrap()
131        });
132        confirm.await.unwrap();
133        cx.run_until_parked();
134    }
135
136    async fn create_directory(&mut self, path: &str) {
137        let Self { panel, cx, .. } = self;
138
139        select_path(&panel, "workspace", cx);
140        panel.update_in(cx, |panel, window, cx| {
141            panel.new_directory(&NewDirectory, window, cx)
142        });
143        cx.run_until_parked();
144
145        let confirm = panel.update_in(cx, |panel, window, cx| {
146            panel
147                .filename_editor
148                .update(cx, |editor, cx| editor.set_text(path, window, cx));
149            panel.confirm_edit(true, window, cx).unwrap()
150        });
151        confirm.await.unwrap();
152        cx.run_until_parked();
153    }
154
155    /// Drags the `files` to the provided `directory`.
156    fn drag(&mut self, files: &[&str], directory: &str) {
157        self.panel
158            .update(&mut self.cx, |panel, _| panel.marked_entries.clear());
159        files.into_iter().for_each(|file| {
160            project_panel_tests::select_path_with_mark(
161                &self.panel,
162                &format!("workspace/{file}"),
163                &mut self.cx,
164            )
165        });
166        project_panel_tests::drag_selection_to(
167            &self.panel,
168            &format!("workspace/{directory}"),
169            false,
170            &mut self.cx,
171        );
172    }
173
174    /// Only supports files in root (otherwise would need toggle_expand_dir).
175    /// For undo redo the paths themselves do not matter so this is fine
176    async fn cut(&mut self, file: &str) {
177        project_panel_tests::select_path_with_mark(
178            &self.panel,
179            &format!("workspace/{file}"),
180            &mut self.cx,
181        );
182        self.panel.update_in(&mut self.cx, |panel, window, cx| {
183            panel.cut(&Default::default(), window, cx);
184        });
185    }
186
187    /// Only supports files in root (otherwise would need toggle_expand_dir).
188    /// For undo redo the paths themselves do not matter so this is fine
189    async fn paste(&mut self, directory: &str) {
190        select_path(&self.panel, &format!("workspace/{directory}"), &mut self.cx);
191        self.panel.update_in(&mut self.cx, |panel, window, cx| {
192            panel.paste(&Default::default(), window, cx);
193        });
194        self.cx.run_until_parked();
195    }
196
197    async fn trash(&mut self, paths: &[&str]) {
198        paths.iter().for_each(|p| {
199            project_panel_tests::select_path_with_mark(
200                &self.panel,
201                &format!("workspace/{p}"),
202                &mut self.cx,
203            )
204        });
205
206        self.panel.update_in(&mut self.cx, |panel, window, cx| {
207            panel.trash(&Trash { skip_prompt: true }, window, cx);
208        });
209
210        self.cx.run_until_parked();
211    }
212
213    /// The test tree is:
214    /// ```txt
215    /// a.txt
216    /// b.txt
217    /// ```
218    /// a and b are empty, x has the text "content" inside
219    async fn new(cx: &mut gpui::TestAppContext) -> TestContext {
220        Self::new_with_tree(
221            cx,
222            json!({
223                    "a.txt": "",
224                    "b.txt": "",
225            }),
226        )
227        .await
228    }
229
230    async fn new_with_tree(cx: &mut gpui::TestAppContext, tree: Value) -> TestContext {
231        project_panel_tests::init_test(cx);
232
233        let fs = FakeFs::new(cx.executor());
234        fs.insert_tree("/workspace", tree).await;
235        let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
236        let window =
237            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
238        let workspace = window
239            .read_with(cx, |mw, _| mw.workspace().clone())
240            .unwrap();
241        let mut cx = VisualTestContext::from_window(window.into(), cx);
242        let panel = workspace.update_in(&mut cx, ProjectPanel::new);
243        cx.run_until_parked();
244
245        TestContext { panel, fs, cx }
246    }
247}
248
249#[gpui::test]
250async fn rename_undo_redo(cx: &mut gpui::TestAppContext) {
251    let mut cx = TestContext::new(cx).await;
252
253    cx.rename("a.txt", "renamed.txt").await;
254    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
255
256    cx.undo().await;
257    cx.assert_fs_state_is(&["a.txt", "b.txt"]);
258
259    cx.redo().await;
260    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
261}
262
263#[gpui::test]
264async fn create_undo_redo(cx: &mut gpui::TestAppContext) {
265    let mut cx = TestContext::new(cx).await;
266    let path = path("/workspace/c.txt");
267
268    cx.create_file("c.txt").await;
269    cx.assert_exists("c.txt");
270
271    // We'll now insert some content into `c.txt` in order to ensure that, after
272    // undoing the trash operation, i.e., when the file is restored, the actual
273    // file's contents are preserved instead of a new one with the same path
274    // being created.
275    cx.fs.write(Path::new(&path), b"Hello!").await.unwrap();
276
277    cx.undo().await;
278    cx.assert_not_exists("c.txt");
279
280    cx.redo().await;
281    cx.assert_exists("c.txt");
282    assert_eq!(cx.fs.load(Path::new(&path)).await.unwrap(), "Hello!");
283}
284
285#[gpui::test]
286async fn create_dir_undo(cx: &mut gpui::TestAppContext) {
287    let mut cx = TestContext::new(cx).await;
288
289    cx.create_directory("new_dir").await;
290    cx.assert_exists("new_dir");
291    cx.undo().await;
292    cx.assert_not_exists("new_dir");
293}
294
295#[gpui::test]
296async fn cut_paste_undo(cx: &mut gpui::TestAppContext) {
297    let mut cx = TestContext::new(cx).await;
298
299    cx.create_directory("files").await;
300    cx.cut("a.txt").await;
301    cx.paste("files").await;
302    cx.assert_fs_state_is(&["b.txt", "files/", "files/a.txt"]);
303
304    cx.undo().await;
305    cx.assert_fs_state_is(&["a.txt", "b.txt", "files/"]);
306
307    cx.redo().await;
308    cx.assert_fs_state_is(&["b.txt", "files/", "files/a.txt"]);
309}
310
311#[gpui::test]
312async fn drag_undo_redo(cx: &mut gpui::TestAppContext) {
313    let mut cx = TestContext::new(cx).await;
314
315    cx.create_directory("src").await;
316    cx.create_file("src/a.rs").await;
317    cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/a.rs"]);
318
319    cx.drag(&["src/a.rs"], "");
320    cx.assert_fs_state_is(&["a.txt", "b.txt", "a.rs", "src/"]);
321
322    cx.undo().await;
323    cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/a.rs"]);
324
325    cx.redo().await;
326    cx.assert_fs_state_is(&["a.txt", "b.txt", "a.rs", "src/"]);
327}
328
329#[gpui::test]
330async fn drag_multiple_undo_redo(cx: &mut gpui::TestAppContext) {
331    let mut cx = TestContext::new(cx).await;
332
333    cx.create_directory("src").await;
334    cx.create_file("src/x.rs").await;
335    cx.create_file("src/y.rs").await;
336
337    cx.drag(&["src/x.rs", "src/y.rs"], "");
338    cx.assert_fs_state_is(&["a.txt", "b.txt", "x.rs", "y.rs", "src/"]);
339
340    cx.undo().await;
341    cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/x.rs", "src/y.rs"]);
342
343    cx.redo().await;
344    cx.assert_fs_state_is(&["a.txt", "b.txt", "x.rs", "y.rs", "src/"]);
345}
346
347#[gpui::test]
348async fn two_sequential_undos(cx: &mut gpui::TestAppContext) {
349    let mut cx = TestContext::new(cx).await;
350
351    cx.rename("a.txt", "x.txt").await;
352    cx.create_file("y.txt").await;
353    cx.assert_fs_state_is(&["b.txt", "x.txt", "y.txt"]);
354
355    cx.undo().await;
356    cx.assert_fs_state_is(&["b.txt", "x.txt"]);
357
358    cx.undo().await;
359    cx.assert_fs_state_is(&["a.txt", "b.txt"]);
360}
361
362#[gpui::test]
363async fn undo_without_history(cx: &mut gpui::TestAppContext) {
364    let mut cx = TestContext::new(cx).await;
365
366    // Undoing without any history should just result in the filesystem state
367    // remaining unchanged.
368    cx.undo().await;
369    cx.assert_fs_state_is(&["a.txt", "b.txt"])
370}
371
372#[gpui::test]
373async fn trash_undo_redo(cx: &mut gpui::TestAppContext) {
374    let mut cx = TestContext::new(cx).await;
375
376    cx.trash(&["a.txt", "b.txt"]).await;
377    cx.assert_fs_state_is(&[]);
378
379    cx.undo().await;
380    cx.assert_fs_state_is(&["a.txt", "b.txt"]);
381
382    cx.redo().await;
383    cx.assert_fs_state_is(&[]);
384}