Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:16:51.910Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

undo.rs

546 lines · 16.9 KB · rust
1#![cfg(test)]
2
3use client::proto;
4use collections::HashSet;
5use fs::{FakeFs, Fs};
6use gpui::{App, BorrowAppContext, Entity, VisualTestContext};
7use project::Project;
8use serde_json::{Value, json};
9use settings::SettingsStore;
10use std::path::Path;
11use std::sync::Arc;
12use workspace::{MultiWorkspace, register_project_item};
13
14use crate::project_panel_tests::{self, TestProjectItemView, find_project_entry, select_path};
15use crate::{NewDirectory, NewFile, ProjectPanel, Redo, Rename, Trash, Undo};
16
17struct TestContext {
18    panel: Entity<ProjectPanel>,
19    fs: Arc<FakeFs>,
20    cx: VisualTestContext,
21}
22
23// Using the `util::path` macro requires a string literal, which would mean that
24// callers of, for example, `rename`, would now need to know about `/` and
25// use `path!` in tests.
26//
27// As such, we define it as a function here to make the helper methods more
28// ergonomic for our use case.
29fn path(path: impl AsRef<str>) -> String {
30    let path = path.as_ref();
31    #[cfg(target_os = "windows")]
32    {
33        let mut path = path.replace("/", "\\");
34        if path.starts_with("\\") {
35            path = format!("C:{}", &path);
36        }
37        path
38    }
39
40    #[cfg(not(target_os = "windows"))]
41    {
42        path.to_string()
43    }
44}
45
46impl TestContext {
47    async fn undo(&mut self) {
48        self.panel.update_in(&mut self.cx, |panel, window, cx| {
49            panel.undo(&Undo, window, cx);
50        });
51        self.cx.run_until_parked();
52    }
53    async fn redo(&mut self) {
54        self.panel.update_in(&mut self.cx, |panel, window, cx| {
55            panel.redo(&Redo, window, cx);
56        });
57        self.cx.run_until_parked();
58    }
59
60    /// Note this only works when every file has an extension
61    fn assert_fs_state_is(&mut self, state: &[&str]) {
62        let state: HashSet<_> = state
63            .into_iter()
64            .map(|s| path(format!("/workspace/{s}")))
65            .chain([path("/workspace"), path("/")])
66            .map(|s| Path::new(&s).to_path_buf())
67            .collect();
68
69        let dirs: HashSet<_> = state
70            .iter()
71            .map(|p| match p.extension() {
72                Some(_) => p.parent().unwrap_or(Path::new(&path("/"))).to_owned(),
73                None => p.clone(),
74            })
75            .collect();
76
77        assert_eq!(
78            self.fs
79                .directories(true)
80                .into_iter()
81                .collect::<HashSet<_>>(),
82            dirs
83        );
84        assert_eq!(
85            self.fs.paths(true).into_iter().collect::<HashSet<_>>(),
86            state
87        );
88    }
89
90    fn assert_exists(&mut self, file: &str) {
91        assert!(
92            find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx).is_some(),
93            "{file} should exist"
94        );
95    }
96
97    fn assert_not_exists(&mut self, file: &str) {
98        assert_eq!(
99            find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx),
100            None,
101            "{file} should not exist"
102        );
103    }
104
105    async fn rename(&mut self, from: &str, to: &str) {
106        let from = format!("workspace/{from}");
107        let Self { panel, cx, .. } = self;
108        select_path(&panel, &from, cx);
109        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
110        cx.run_until_parked();
111
112        let confirm = panel.update_in(cx, |panel, window, cx| {
113            panel
114                .filename_editor
115                .update(cx, |editor, cx| editor.set_text(to, window, cx));
116            panel.confirm_edit(true, window, cx).unwrap()
117        });
118        confirm.await.unwrap();
119        cx.run_until_parked();
120    }
121
122    async fn create_file(&mut self, path: &str) {
123        let Self { panel, cx, .. } = self;
124        select_path(&panel, "workspace", cx);
125        panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
126        cx.run_until_parked();
127
128        let confirm = panel.update_in(cx, |panel, window, cx| {
129            panel
130                .filename_editor
131                .update(cx, |editor, cx| editor.set_text(path, window, cx));
132            panel.confirm_edit(true, window, cx).unwrap()
133        });
134        confirm.await.unwrap();
135        cx.run_until_parked();
136    }
137
138    async fn create_directory(&mut self, path: &str) {
139        let Self { panel, cx, .. } = self;
140
141        select_path(&panel, "workspace", cx);
142        panel.update_in(cx, |panel, window, cx| {
143            panel.new_directory(&NewDirectory, window, cx)
144        });
145        cx.run_until_parked();
146
147        let confirm = panel.update_in(cx, |panel, window, cx| {
148            panel
149                .filename_editor
150                .update(cx, |editor, cx| editor.set_text(path, window, cx));
151            panel.confirm_edit(true, window, cx).unwrap()
152        });
153        confirm.await.unwrap();
154        cx.run_until_parked();
155    }
156
157    /// Drags the `files` to the provided `directory`.
158    fn drag(&mut self, files: &[&str], directory: &str) {
159        self.panel
160            .update(&mut self.cx, |panel, _| panel.marked_entries.clear());
161        files.into_iter().for_each(|file| {
162            project_panel_tests::select_path_with_mark(
163                &self.panel,
164                &format!("workspace/{file}"),
165                &mut self.cx,
166            )
167        });
168        project_panel_tests::drag_selection_to(
169            &self.panel,
170            &format!("workspace/{directory}"),
171            false,
172            &mut self.cx,
173        );
174    }
175
176    /// Only supports files in root (otherwise would need toggle_expand_dir).
177    /// For undo redo the paths themselves do not matter so this is fine
178    async fn cut(&mut self, file: &str) {
179        project_panel_tests::select_path_with_mark(
180            &self.panel,
181            &format!("workspace/{file}"),
182            &mut self.cx,
183        );
184        self.panel.update_in(&mut self.cx, |panel, window, cx| {
185            panel.cut(&Default::default(), window, cx);
186        });
187    }
188
189    /// Only supports files in root (otherwise would need toggle_expand_dir).
190    /// For undo redo the paths themselves do not matter so this is fine
191    async fn paste(&mut self, directory: &str) {
192        select_path(&self.panel, &format!("workspace/{directory}"), &mut self.cx);
193        self.panel.update_in(&mut self.cx, |panel, window, cx| {
194            panel.paste(&Default::default(), window, cx);
195        });
196        self.cx.run_until_parked();
197    }
198
199    async fn trash(&mut self, paths: &[&str]) {
200        paths.iter().for_each(|p| {
201            project_panel_tests::select_path_with_mark(
202                &self.panel,
203                &format!("workspace/{p}"),
204                &mut self.cx,
205            )
206        });
207
208        self.panel.update_in(&mut self.cx, |panel, window, cx| {
209            panel.trash(&Trash { skip_prompt: true }, window, cx);
210        });
211
212        self.cx.run_until_parked();
213    }
214
215    /// The test tree is:
216    /// ```txt
217    /// a.txt
218    /// b.txt
219    /// ```
220    /// a and b are empty, x has the text "content" inside
221    async fn new(cx: &mut gpui::TestAppContext) -> TestContext {
222        Self::new_with_tree(
223            cx,
224            json!({
225                    "a.txt": "",
226                    "b.txt": "",
227            }),
228        )
229        .await
230    }
231
232    async fn new_with_tree(cx: &mut gpui::TestAppContext, tree: Value) -> TestContext {
233        project_panel_tests::init_test(cx);
234
235        let fs = FakeFs::new(cx.executor());
236        fs.insert_tree("/workspace", tree).await;
237        let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
238        let window =
239            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
240        let workspace = window
241            .read_with(cx, |mw, _| mw.workspace().clone())
242            .unwrap();
243        let mut cx = VisualTestContext::from_window(window.into(), cx);
244        let panel = workspace.update_in(&mut cx, ProjectPanel::new);
245
246        workspace.update_in(&mut cx, |workspace, window, cx| {
247            workspace.add_panel(panel.clone(), window, cx);
248            workspace.focus_panel::<ProjectPanel>(window, cx);
249        });
250
251        cx.run_until_parked();
252
253        TestContext { panel, fs, cx }
254    }
255
256    fn update_app<R>(&mut self, update: impl FnOnce(&mut App) -> R) -> R {
257        self.cx.cx.update(update)
258    }
259}
260
261#[gpui::test]
262async fn rename_undo_redo(cx: &mut gpui::TestAppContext) {
263    let mut cx = TestContext::new(cx).await;
264
265    cx.rename("a.txt", "renamed.txt").await;
266    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
267
268    cx.undo().await;
269    cx.assert_fs_state_is(&["a.txt", "b.txt"]);
270
271    cx.redo().await;
272    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
273}
274
275#[gpui::test]
276async fn create_undo_redo(cx: &mut gpui::TestAppContext) {
277    let mut cx = TestContext::new(cx).await;
278    let path = path("/workspace/c.txt");
279
280    cx.create_file("c.txt").await;
281    cx.assert_exists("c.txt");
282
283    // We'll now insert some content into `c.txt` in order to ensure that, after
284    // undoing the trash operation, i.e., when the file is restored, the actual
285    // file's contents are preserved instead of a new one with the same path
286    // being created.
287    cx.fs.write(Path::new(&path), b"Hello!").await.unwrap();
288
289    cx.undo().await;
290    cx.assert_not_exists("c.txt");
291
292    cx.redo().await;
293    cx.assert_exists("c.txt");
294    assert_eq!(cx.fs.load(Path::new(&path)).await.unwrap(), "Hello!");
295}
296
297#[gpui::test]
298async fn create_dir_undo(cx: &mut gpui::TestAppContext) {
299    let mut cx = TestContext::new(cx).await;
300
301    cx.create_directory("new_dir").await;
302    cx.assert_exists("new_dir");
303    cx.undo().await;
304    cx.assert_not_exists("new_dir");
305}
306
307#[gpui::test]
308async fn cut_paste_undo(cx: &mut gpui::TestAppContext) {
309    let mut cx = TestContext::new(cx).await;
310
311    cx.create_directory("files").await;
312    cx.cut("a.txt").await;
313    cx.paste("files").await;
314    cx.assert_fs_state_is(&["b.txt", "files/", "files/a.txt"]);
315
316    cx.undo().await;
317    cx.assert_fs_state_is(&["a.txt", "b.txt", "files/"]);
318
319    cx.redo().await;
320    cx.assert_fs_state_is(&["b.txt", "files/", "files/a.txt"]);
321}
322
323#[gpui::test]
324async fn drag_undo_redo(cx: &mut gpui::TestAppContext) {
325    let mut cx = TestContext::new(cx).await;
326
327    cx.create_directory("src").await;
328    cx.create_file("src/a.rs").await;
329    cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/a.rs"]);
330
331    cx.drag(&["src/a.rs"], "");
332    cx.assert_fs_state_is(&["a.txt", "b.txt", "a.rs", "src/"]);
333
334    cx.undo().await;
335    cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/a.rs"]);
336
337    cx.redo().await;
338    cx.assert_fs_state_is(&["a.txt", "b.txt", "a.rs", "src/"]);
339}
340
341#[gpui::test]
342async fn drag_multiple_undo_redo(cx: &mut gpui::TestAppContext) {
343    let mut cx = TestContext::new(cx).await;
344
345    cx.create_directory("src").await;
346    cx.create_file("src/x.rs").await;
347    cx.create_file("src/y.rs").await;
348
349    cx.drag(&["src/x.rs", "src/y.rs"], "");
350    cx.assert_fs_state_is(&["a.txt", "b.txt", "x.rs", "y.rs", "src/"]);
351
352    cx.undo().await;
353    cx.assert_fs_state_is(&["a.txt", "b.txt", "src/", "src/x.rs", "src/y.rs"]);
354
355    cx.redo().await;
356    cx.assert_fs_state_is(&["a.txt", "b.txt", "x.rs", "y.rs", "src/"]);
357}
358
359#[gpui::test]
360async fn two_sequential_undos(cx: &mut gpui::TestAppContext) {
361    let mut cx = TestContext::new(cx).await;
362
363    cx.rename("a.txt", "x.txt").await;
364    cx.create_file("y.txt").await;
365    cx.assert_fs_state_is(&["b.txt", "x.txt", "y.txt"]);
366
367    cx.undo().await;
368    cx.assert_fs_state_is(&["b.txt", "x.txt"]);
369
370    cx.undo().await;
371    cx.assert_fs_state_is(&["a.txt", "b.txt"]);
372}
373
374#[gpui::test]
375async fn undo_without_history(cx: &mut gpui::TestAppContext) {
376    let mut cx = TestContext::new(cx).await;
377
378    // Undoing without any history should just result in the filesystem state
379    // remaining unchanged.
380    cx.undo().await;
381    cx.assert_fs_state_is(&["a.txt", "b.txt"])
382}
383
384#[gpui::test]
385async fn trash_undo_redo(cx: &mut gpui::TestAppContext) {
386    let mut cx = TestContext::new(cx).await;
387
388    cx.trash(&["a.txt", "b.txt"]).await;
389    cx.assert_fs_state_is(&[]);
390
391    cx.undo().await;
392    cx.assert_fs_state_is(&["a.txt", "b.txt"]);
393
394    cx.redo().await;
395    cx.assert_fs_state_is(&[]);
396}
397
398#[gpui::test]
399async fn trash_directory_undo_redo(cx: &mut gpui::TestAppContext) {
400    let mut cx = TestContext::new_with_tree(
401        cx,
402        json!({
403            "a.txt": "",
404            "dir": {
405                "b.txt": "File B's Content",
406            },
407        }),
408    )
409    .await;
410    cx.assert_fs_state_is(&["a.txt", "dir/", "dir/b.txt"]);
411
412    cx.trash(&["dir"]).await;
413    cx.assert_fs_state_is(&["a.txt"]);
414
415    cx.undo().await;
416    cx.assert_fs_state_is(&["a.txt", "dir/", "dir/b.txt"]);
417    assert_eq!(
418        cx.fs
419            .load(Path::new(&path("/workspace/dir/b.txt")))
420            .await
421            .unwrap(),
422        "File B's Content",
423    );
424
425    cx.redo().await;
426    cx.assert_fs_state_is(&["a.txt"]);
427}
428
429#[gpui::test]
430async fn trash_continues_when_one_entry_fails(cx: &mut gpui::TestAppContext) {
431    let mut cx = TestContext::new_with_tree(
432        cx,
433        json!({
434            "0_dir": {},
435            "a.txt": "",
436            "b.txt": "",
437        }),
438    )
439    .await;
440
441    cx.fs
442        .set_remove_dir_error(path("/workspace/0_dir"), "simulated failure".into());
443
444    cx.trash(&["0_dir", "a.txt", "b.txt"]).await;
445    cx.assert_fs_state_is(&["0_dir/"]);
446
447    cx.undo().await;
448    cx.assert_fs_state_is(&["0_dir/", "a.txt", "b.txt"]);
449}
450
451#[gpui::test]
452async fn record_via_collab(cx: &mut gpui::TestAppContext) {
453    let mut cx = TestContext::new(cx).await;
454
455    // Manually update the `UndoManager::is_via_collab` field in order to
456    // simulate a Project Panel's Undo Manager in a collab scenario, acting as a
457    // client.
458    cx.panel.update(&mut cx.cx, |panel, _cx| {
459        panel.undo_manager.set_is_via_collab(true);
460    });
461
462    // Even though the rename operation should succeed, no operation should be
463    // recorded so calling undo or redo should not update the filesystem state.
464    cx.rename("a.txt", "renamed.txt").await;
465    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
466
467    cx.undo().await;
468    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
469
470    cx.redo().await;
471    cx.assert_fs_state_is(&["b.txt", "renamed.txt"]);
472}
473
474#[gpui::test]
475async fn undo_redo_unavailable_for_read_only_collab_guest(cx: &mut gpui::TestAppContext) {
476    let mut cx = TestContext::new(cx).await;
477    let focus_handle = cx
478        .panel
479        .read_with(&cx.cx, |panel, _| panel.focus_handle.clone());
480
481    cx.cx.update(|window, _cx| {
482        assert!(window.is_action_available_in(&crate::Undo, &focus_handle));
483        assert!(window.is_action_available_in(&crate::Redo, &focus_handle));
484    });
485
486    // In order to simulate a read-only project, we mark it both as a collab
487    // session as well as being a guest, which only has read access.
488    // This is currently a bit redundant, seeing as these actions are already
489    // disabled in collab either way. However, we'll want to enable undo/redo in
490    // collab in the future and this test will ensure that, at that point, we
491    // continue to not allow undo/redo in read-only projects.
492    cx.panel.update(&mut cx.cx, |panel, cx| {
493        panel.project.update(cx, |project, cx| {
494            project.mark_as_collab_for_testing();
495            project.set_role(proto::ChannelRole::Guest, cx);
496        });
497
498        assert!(panel.project.read(cx).is_read_only(cx));
499        cx.notify();
500    });
501
502    cx.cx.update(|window, _cx| {
503        assert!(!window.is_action_available_in(&crate::Undo, &focus_handle));
504        assert!(!window.is_action_available_in(&crate::Redo, &focus_handle));
505    });
506}
507
508#[gpui::test]
509async fn excluded_create_is_not_recorded(cx: &mut gpui::TestAppContext) {
510    let mut cx = TestContext::new_with_tree(
511        cx,
512        json!({
513            "a.txt": "",
514            "b.txt": ""
515        }),
516    )
517    .await;
518
519    cx.update_app(|cx| {
520        cx.update_global::<SettingsStore, _>(|store, cx| {
521            store.update_user_settings(cx, |settings| {
522                settings.project.worktree.file_scan_exclusions = Some(vec![
523                    "**/token.secret".to_string(),
524                    "**/banana.secret".to_string(),
525                ]);
526            });
527        });
528
529        register_project_item::<TestProjectItemView>(cx);
530    });
531
532    cx.rename("a.txt", "renamed.txt").await;
533    cx.create_file("token.secret").await;
534    cx.assert_fs_state_is(&["b.txt", "renamed.txt", "token.secret"]);
535
536    cx.undo().await;
537    cx.assert_fs_state_is(&["b.txt", "a.txt", "token.secret"]);
538
539    cx.rename("a.txt", "renamed.txt").await;
540    cx.rename("b.txt", "banana.secret").await;
541    cx.assert_fs_state_is(&["renamed.txt", "token.secret", "banana.secret"]);
542
543    cx.undo().await;
544    cx.assert_fs_state_is(&["a.txt", "token.secret", "banana.secret"]);
545}
546
Served at tenant.openagents/omega Member data and write actions are omitted.