Fill context menu of Zed macOS dock icon with recent projects (#8952)

Daniel Zhu and Kirill Bulatov created

Fixes https://github.com/zed-industries/zed/issues/8416

Release Notes:

- Added recent projects into Zed's macOS dock icon context menu ([8416](https://github.com/zed-industries/zed/issues/8416))

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

Cargo.lock                                    |  2 
crates/gpui/src/app.rs                        | 11 ++
crates/gpui/src/platform.rs                   |  2 
crates/gpui/src/platform/mac/platform.rs      | 25 ++++++
crates/gpui/src/platform/test/platform.rs     |  4 +
crates/recent_projects/Cargo.toml             |  2 
crates/recent_projects/src/recent_projects.rs | 18 ++++
crates/workspace/src/item.rs                  |  2 
crates/workspace/src/workspace.rs             | 78 +++++++++++++++-----
9 files changed, 121 insertions(+), 23 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7600,9 +7600,11 @@ dependencies = [
 name = "recent_projects"
 version = "0.1.0"
 dependencies = [
+ "collections",
  "editor",
  "fuzzy",
  "gpui",
+ "itertools 0.11.0",
  "language",
  "menu",
  "ordered-float 2.10.0",

crates/gpui/src/app.rs 🔗

@@ -1132,6 +1132,17 @@ impl AppContext {
         self.platform.set_menus(menus, &self.keymap.borrow());
     }
 
+    /// Adds given path to list of recent paths for the application.
+    /// The list is usually shown on the application icon's context menu in the dock,
+    /// and allows to open the recent files via that context menu.
+    pub fn add_recent_documents(&mut self, paths: &[PathBuf]) {
+        self.platform.add_recent_documents(paths);
+    }
+
+    /// Clears the list of recent paths from the application.
+    pub fn clear_recent_documents(&mut self) {
+        self.platform.clear_recent_documents();
+    }
     /// Dispatch an action to the currently active window or global action handler
     /// See [action::Action] for more information on how actions work
     pub fn dispatch_action(&mut self, action: &dyn Action) {

crates/gpui/src/platform.rs 🔗

@@ -118,6 +118,8 @@ pub(crate) trait Platform: 'static {
     fn on_event(&self, callback: Box<dyn FnMut(PlatformInput) -> bool>);
 
     fn set_menus(&self, menus: Vec<Menu>, keymap: &Keymap);
+    fn add_recent_documents(&self, _paths: &[PathBuf]) {}
+    fn clear_recent_documents(&self) {}
     fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>);
     fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>);
     fn on_validate_app_menu_command(&self, callback: Box<dyn FnMut(&dyn Action) -> bool>);

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -137,6 +137,7 @@ unsafe fn build_classes() {
             sel!(application:openURLs:),
             open_urls as extern "C" fn(&mut Object, Sel, id, id),
         );
+
         decl.register()
     }
 }
@@ -766,6 +767,29 @@ impl Platform for MacPlatform {
         }
     }
 
+    fn add_recent_documents(&self, paths: &[PathBuf]) {
+        for path in paths {
+            let Some(path_str) = path.to_str() else {
+                log::error!("Not adding to recent documents a non-unicode path: {path:?}");
+                continue;
+            };
+            unsafe {
+                let document_controller: id =
+                    msg_send![class!(NSDocumentController), sharedDocumentController];
+                let url: id = NSURL::fileURLWithPath_(nil, ns_string(path_str));
+                let _: () = msg_send![document_controller, noteNewRecentDocumentURL:url];
+            }
+        }
+    }
+
+    fn clear_recent_documents(&self) {
+        unsafe {
+            let document_controller: id =
+                msg_send![class!(NSDocumentController), sharedDocumentController];
+            let _: () = msg_send![document_controller, clearRecentDocuments:nil];
+        }
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         unsafe {
             let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone];
@@ -1062,7 +1086,6 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) {
     unsafe {
         let app: id = msg_send![APP_CLASS, sharedApplication];
         app.setActivationPolicy_(NSApplicationActivationPolicyRegular);
-
         let platform = get_mac_platform(this);
         let callback = platform.0.lock().finish_launching.take();
         if let Some(callback) = callback {

crates/gpui/src/platform/test/platform.rs 🔗

@@ -241,6 +241,10 @@ impl Platform for TestPlatform {
 
     fn set_menus(&self, _menus: Vec<crate::Menu>, _keymap: &Keymap) {}
 
+    fn add_recent_documents(&self, _paths: &[PathBuf]) {}
+
+    fn clear_recent_documents(&self) {}
+
     fn on_app_menu_action(&self, _callback: Box<dyn FnMut(&dyn crate::Action)>) {}
 
     fn on_will_open_app_menu(&self, _callback: Box<dyn FnMut()>) {}

crates/recent_projects/Cargo.toml 🔗

@@ -13,8 +13,10 @@ path = "src/recent_projects.rs"
 doctest = false
 
 [dependencies]
+collections.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+itertools.workspace = true
 menu.workspace = true
 ordered-float.workspace = true
 picker.workspace = true

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,8 +1,10 @@
+use collections::HashMap;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Result,
     Subscription, Task, View, ViewContext, WeakView,
 };
+use itertools::Itertools;
 use ordered_float::OrderedFloat;
 use picker::{
     highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
@@ -56,7 +58,6 @@ impl RecentProjects {
                 .recent_workspaces_on_disk()
                 .await
                 .unwrap_or_default();
-
             this.update(&mut cx, move |this, cx| {
                 this.picker.update(cx, move |picker, cx| {
                     picker.delegate.workspaces = workspaces;
@@ -157,7 +158,7 @@ impl RecentProjectsDelegate {
     fn new(workspace: WeakView<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
         Self {
             workspace,
-            workspaces: vec![],
+            workspaces: Vec::new(),
             selected_match_index: 0,
             matches: Default::default(),
             create_new_window,
@@ -430,7 +431,20 @@ impl RecentProjectsDelegate {
                     .recent_workspaces_on_disk()
                     .await
                     .unwrap_or_default();
+                let mut unique_added_paths = HashMap::default();
+                for (id, workspace) in &workspaces {
+                    for path in workspace.paths().iter() {
+                        unique_added_paths.insert(path.clone(), id);
+                    }
+                }
+                let updated_paths = unique_added_paths
+                    .into_iter()
+                    .sorted_by_key(|(_, id)| *id)
+                    .map(|(path, _)| path)
+                    .collect::<Vec<_>>();
                 this.update(&mut cx, move |picker, cx| {
+                    cx.clear_recent_documents();
+                    cx.add_recent_documents(&updated_paths);
                     picker.delegate.workspaces = workspaces;
                     picker.delegate.set_selected_index(ix - 1, cx);
                     picker.delegate.reset_selected_match_index = false;

crates/workspace/src/item.rs 🔗

@@ -564,7 +564,7 @@ impl<T: Item> ItemHandle for View<T> {
         }
 
         cx.defer(|workspace, cx| {
-            workspace.serialize_workspace(cx);
+            workspace.serialize_workspace(cx).detach();
         });
     }
 

crates/workspace/src/workspace.rs 🔗

@@ -578,7 +578,16 @@ impl Workspace {
 
                 project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => {
                     this.update_window_title(cx);
-                    this.serialize_workspace(cx);
+                    let workspace_serialization = this.serialize_workspace(cx);
+                    cx.spawn(|workspace, mut cx| async move {
+                        workspace_serialization.await;
+                        workspace
+                            .update(&mut cx, |workspace, cx| {
+                                workspace.refresh_recent_documents(cx)
+                            })?
+                            .await
+                    })
+                    .detach_and_log_err(cx)
                 }
 
                 project::Event::DisconnectedFromHost => {
@@ -748,15 +757,15 @@ impl Workspace {
                 ThemeSettings::reload_current_theme(cx);
             }),
             cx.observe(&left_dock, |this, _, cx| {
-                this.serialize_workspace(cx);
+                this.serialize_workspace(cx).detach();
                 cx.notify();
             }),
             cx.observe(&bottom_dock, |this, _, cx| {
-                this.serialize_workspace(cx);
+                this.serialize_workspace(cx).detach();
                 cx.notify();
             }),
             cx.observe(&right_dock, |this, _, cx| {
-                this.serialize_workspace(cx);
+                this.serialize_workspace(cx).detach();
                 cx.notify();
             }),
             cx.on_release(|this, window, cx| {
@@ -913,10 +922,6 @@ impl Workspace {
                 })?
             };
 
-            window
-                .update(&mut cx, |_, cx| cx.activate_window())
-                .log_err();
-
             notify_if_database_failed(window, &mut cx);
             let opened_items = window
                 .update(&mut cx, |_workspace, cx| {
@@ -925,6 +930,14 @@ impl Workspace {
                 .await
                 .unwrap_or_default();
 
+            window
+                .update(&mut cx, |workspace, cx| {
+                    workspace
+                        .refresh_recent_documents(cx)
+                        .detach_and_log_err(cx);
+                    cx.activate_window()
+                })
+                .log_err();
             Ok((window, opened_items))
         })
     }
@@ -1764,7 +1777,7 @@ impl Workspace {
         }
 
         cx.notify();
-        self.serialize_workspace(cx);
+        self.serialize_workspace(cx).detach();
     }
 
     pub fn close_all_docks(&mut self, cx: &mut ViewContext<Self>) {
@@ -1778,7 +1791,7 @@ impl Workspace {
 
         cx.focus_self();
         cx.notify();
-        self.serialize_workspace(cx);
+        self.serialize_workspace(cx).detach();
     }
 
     /// Transfer focus to the panel of the given type.
@@ -1823,7 +1836,7 @@ impl Workspace {
                     self.active_pane.update(cx, |pane, cx| pane.focus(cx))
                 }
 
-                self.serialize_workspace(cx);
+                self.serialize_workspace(cx).detach();
                 cx.notify();
                 return panel;
             }
@@ -2377,7 +2390,7 @@ impl Workspace {
             }
         }
 
-        self.serialize_workspace(cx);
+        self.serialize_workspace(cx).detach();
     }
 
     pub fn split_pane(
@@ -3333,12 +3346,12 @@ impl Workspace {
             cx.background_executor()
                 .timer(Duration::from_millis(100))
                 .await;
-            this.update(&mut cx, |this, cx| this.serialize_workspace(cx))
+            this.update(&mut cx, |this, cx| this.serialize_workspace(cx).detach())
                 .log_err();
         }));
     }
 
-    fn serialize_workspace(&self, cx: &mut WindowContext) {
+    fn serialize_workspace(&self, cx: &mut WindowContext) -> Task<()> {
         fn serialize_pane_handle(pane_handle: &View<Pane>, cx: &WindowContext) -> SerializedPane {
             let (items, active) = {
                 let pane = pane_handle.read(cx);
@@ -3441,7 +3454,6 @@ impl Workspace {
             if !location.paths().is_empty() {
                 let center_group = build_serialized_pane_group(&self.center.root, cx);
                 let docks = build_serialized_docks(self, cx);
-
                 let serialized_workspace = SerializedWorkspace {
                     id: self.database_id,
                     location,
@@ -3451,11 +3463,37 @@ impl Workspace {
                     docks,
                     fullscreen: cx.is_fullscreen(),
                 };
-
-                cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace))
-                    .detach();
+                return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace));
             }
         }
+        Task::ready(())
+    }
+
+    fn refresh_recent_documents(&self, cx: &mut AppContext) -> Task<Result<()>> {
+        if !self.project.read(cx).is_local() {
+            return Task::ready(Ok(()));
+        }
+        cx.spawn(|cx| async move {
+            let recents = WORKSPACE_DB
+                .recent_workspaces_on_disk()
+                .await
+                .unwrap_or_default();
+            let mut unique_paths = HashMap::default();
+            for (id, workspace) in &recents {
+                for path in workspace.paths().iter() {
+                    unique_paths.insert(path.clone(), id);
+                }
+            }
+            let current_paths = unique_paths
+                .into_iter()
+                .sorted_by_key(|(_, id)| *id)
+                .map(|(path, _)| path)
+                .collect::<Vec<_>>();
+            cx.update(|cx| {
+                cx.clear_recent_documents();
+                cx.add_recent_documents(&current_paths);
+            })
+        })
     }
 
     pub(crate) fn load_workspace(
@@ -3539,7 +3577,9 @@ impl Workspace {
             })?;
 
             // Serialize ourself to make sure our timestamps and any pane / item changes are replicated
-            workspace.update(&mut cx, |workspace, cx| workspace.serialize_workspace(cx))?;
+            workspace.update(&mut cx, |workspace, cx| {
+                workspace.serialize_workspace(cx).detach()
+            })?;
 
             Ok(opened_items)
         })