Skip to repository content546 lines · 16.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:16:51.910Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
undo.rs
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