Prompt to save files on recent project selection (#8673)

Kirill Bulatov created

Change summary

Cargo.lock                                    |   4 
crates/recent_projects/Cargo.toml             |   7 
crates/recent_projects/src/recent_projects.rs | 167 ++++++++++++++++++++
crates/tasks_ui/src/modal.rs                  |   2 
crates/workspace/src/persistence/model.rs     |  10 +
5 files changed, 184 insertions(+), 6 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7171,11 +7171,15 @@ dependencies = [
 name = "recent_projects"
 version = "0.1.0"
 dependencies = [
+ "editor",
  "fuzzy",
  "gpui",
+ "language",
  "menu",
  "ordered-float 2.10.0",
  "picker",
+ "project",
+ "serde_json",
  "smol",
  "ui",
  "util",

crates/recent_projects/Cargo.toml 🔗

@@ -19,3 +19,10 @@ smol.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
+
+[dev-dependencies]
+editor = { workspace = true, features = ["test-support"] }
+language = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
+workspace = { workspace = true, features = ["test-support"] }

crates/recent_projects/src/recent_projects.rs 🔗

@@ -224,11 +224,35 @@ impl PickerDelegate for RecentProjectsDelegate {
             workspace
                 .update(cx, |workspace, cx| {
                     if workspace.database_id() != *candidate_workspace_id {
-                        workspace.open_workspace_for_paths(
-                            replace_current_window,
-                            candidate_workspace_location.paths().as_ref().clone(),
-                            cx,
-                        )
+                        let candidate_paths = candidate_workspace_location.paths().as_ref().clone();
+                        if replace_current_window {
+                            cx.spawn(move |workspace, mut cx| async move {
+                                let continue_replacing = workspace
+                                    .update(&mut cx, |workspace, cx| {
+                                        workspace.prepare_to_close(true, cx)
+                                    })?
+                                    .await?;
+                                if continue_replacing {
+                                    workspace
+                                        .update(&mut cx, |workspace, cx| {
+                                            workspace.open_workspace_for_paths(
+                                                replace_current_window,
+                                                candidate_paths,
+                                                cx,
+                                            )
+                                        })?
+                                        .await
+                                } else {
+                                    Ok(())
+                                }
+                            })
+                        } else {
+                            workspace.open_workspace_for_paths(
+                                replace_current_window,
+                                candidate_paths,
+                                cx,
+                            )
+                        }
                     } else {
                         Task::ready(Ok(()))
                     }
@@ -407,3 +431,136 @@ impl Render for MatchTooltip {
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use std::path::PathBuf;
+
+    use editor::Editor;
+    use gpui::{TestAppContext, WindowHandle};
+    use project::Project;
+    use serde_json::json;
+    use workspace::{open_paths, AppState};
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/dir",
+                json!({
+                    "main.ts": "a"
+                }),
+            )
+            .await;
+        cx.update(|cx| open_paths(&[PathBuf::from("/dir/main.ts")], &app_state, None, cx))
+            .await
+            .unwrap();
+        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
+
+        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+        workspace
+            .update(cx, |workspace, _| assert!(!workspace.is_edited()))
+            .unwrap();
+
+        let editor = workspace
+            .read_with(cx, |workspace, cx| {
+                workspace
+                    .active_item(cx)
+                    .unwrap()
+                    .downcast::<Editor>()
+                    .unwrap()
+            })
+            .unwrap();
+        workspace
+            .update(cx, |_, cx| {
+                editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
+            })
+            .unwrap();
+        workspace
+            .update(cx, |workspace, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
+            .unwrap();
+
+        let recent_projects_picker = open_recent_projects(&workspace, cx);
+        workspace
+            .update(cx, |_, cx| {
+                recent_projects_picker.update(cx, |picker, cx| {
+                    assert_eq!(picker.query(cx), "");
+                    let delegate = &mut picker.delegate;
+                    delegate.matches = vec![StringMatch {
+                        candidate_id: 0,
+                        score: 1.0,
+                        positions: Vec::new(),
+                        string: "fake candidate".to_string(),
+                    }];
+                    delegate.workspaces = vec![(0, WorkspaceLocation::new(vec!["/test/path/"]))];
+                });
+            })
+            .unwrap();
+
+        assert!(
+            !cx.has_pending_prompt(),
+            "Should have no pending prompt on dirty project before opening the new recent project"
+        );
+        cx.dispatch_action((*workspace).into(), menu::Confirm);
+        workspace
+            .update(cx, |workspace, cx| {
+                assert!(
+                    workspace.active_modal::<RecentProjects>(cx).is_none(),
+                    "Should remove the modal after selecting new recent project"
+                )
+            })
+            .unwrap();
+        assert!(
+            cx.has_pending_prompt(),
+            "Dirty workspace should prompt before opening the new recent project"
+        );
+        // Cancel
+        cx.simulate_prompt_answer(0);
+        assert!(
+            !cx.has_pending_prompt(),
+            "Should have no pending prompt after cancelling"
+        );
+        workspace
+            .update(cx, |workspace, _| {
+                assert!(
+                    workspace.is_edited(),
+                    "Should be in the same dirty project after cancelling"
+                )
+            })
+            .unwrap();
+    }
+
+    fn open_recent_projects(
+        workspace: &WindowHandle<Workspace>,
+        cx: &mut TestAppContext,
+    ) -> View<Picker<RecentProjectsDelegate>> {
+        cx.dispatch_action((*workspace).into(), OpenRecent);
+        workspace
+            .update(cx, |workspace, cx| {
+                workspace
+                    .active_modal::<RecentProjects>(cx)
+                    .unwrap()
+                    .read(cx)
+                    .picker
+                    .clone()
+            })
+            .unwrap()
+    }
+
+    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
+        cx.update(|cx| {
+            let state = AppState::test(cx);
+            language::init(cx);
+            crate::init(cx);
+            editor::init(cx);
+            workspace::init_settings(cx);
+            Project::init_settings(cx);
+            state
+        })
+    }
+}

crates/tasks_ui/src/modal.rs 🔗

@@ -284,7 +284,7 @@ mod tests {
     use super::*;
 
     #[gpui::test]
-    async fn test_name(cx: &mut TestAppContext) {
+    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
         init_test(cx);
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(

crates/workspace/src/persistence/model.rs 🔗

@@ -22,6 +22,16 @@ impl WorkspaceLocation {
     pub fn paths(&self) -> Arc<Vec<PathBuf>> {
         self.0.clone()
     }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn new<P: AsRef<Path>>(paths: Vec<P>) -> Self {
+        Self(Arc::new(
+            paths
+                .into_iter()
+                .map(|p| p.as_ref().to_path_buf())
+                .collect(),
+        ))
+    }
 }
 
 impl<P: AsRef<Path>, T: IntoIterator<Item = P>> From<T> for WorkspaceLocation {