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

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>
This commit is contained in:
Daniel Zhu 2024-03-15 07:22:43 -07:00 committed by GitHub
parent 5bf0c8ed2d
commit cb16003133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 121 additions and 23 deletions

2
Cargo.lock generated
View File

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

View File

@ -1132,6 +1132,17 @@ impl AppContext {
self.platform.set_menus(menus, &self.keymap.borrow()); 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 /// Dispatch an action to the currently active window or global action handler
/// See [action::Action] for more information on how actions work /// See [action::Action] for more information on how actions work
pub fn dispatch_action(&mut self, action: &dyn Action) { pub fn dispatch_action(&mut self, action: &dyn Action) {

View File

@ -118,6 +118,8 @@ pub(crate) trait Platform: 'static {
fn on_event(&self, callback: Box<dyn FnMut(PlatformInput) -> bool>); fn on_event(&self, callback: Box<dyn FnMut(PlatformInput) -> bool>);
fn set_menus(&self, menus: Vec<Menu>, keymap: &Keymap); 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_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>);
fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>); 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>); fn on_validate_app_menu_command(&self, callback: Box<dyn FnMut(&dyn Action) -> bool>);

View File

@ -137,6 +137,7 @@ unsafe fn build_classes() {
sel!(application:openURLs:), sel!(application:openURLs:),
open_urls as extern "C" fn(&mut Object, Sel, id, id), open_urls as extern "C" fn(&mut Object, Sel, id, id),
); );
decl.register() 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 { fn local_timezone(&self) -> UtcOffset {
unsafe { unsafe {
let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone]; 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 { unsafe {
let app: id = msg_send![APP_CLASS, sharedApplication]; let app: id = msg_send![APP_CLASS, sharedApplication];
app.setActivationPolicy_(NSApplicationActivationPolicyRegular); app.setActivationPolicy_(NSApplicationActivationPolicyRegular);
let platform = get_mac_platform(this); let platform = get_mac_platform(this);
let callback = platform.0.lock().finish_launching.take(); let callback = platform.0.lock().finish_launching.take();
if let Some(callback) = callback { if let Some(callback) = callback {

View File

@ -241,6 +241,10 @@ impl Platform for TestPlatform {
fn set_menus(&self, _menus: Vec<crate::Menu>, _keymap: &Keymap) {} 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_app_menu_action(&self, _callback: Box<dyn FnMut(&dyn crate::Action)>) {}
fn on_will_open_app_menu(&self, _callback: Box<dyn FnMut()>) {} fn on_will_open_app_menu(&self, _callback: Box<dyn FnMut()>) {}

View File

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

View File

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

View File

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

View File

@ -578,7 +578,16 @@ impl Workspace {
project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => { project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => {
this.update_window_title(cx); 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 => { project::Event::DisconnectedFromHost => {
@ -748,15 +757,15 @@ impl Workspace {
ThemeSettings::reload_current_theme(cx); ThemeSettings::reload_current_theme(cx);
}), }),
cx.observe(&left_dock, |this, _, cx| { cx.observe(&left_dock, |this, _, cx| {
this.serialize_workspace(cx); this.serialize_workspace(cx).detach();
cx.notify(); cx.notify();
}), }),
cx.observe(&bottom_dock, |this, _, cx| { cx.observe(&bottom_dock, |this, _, cx| {
this.serialize_workspace(cx); this.serialize_workspace(cx).detach();
cx.notify(); cx.notify();
}), }),
cx.observe(&right_dock, |this, _, cx| { cx.observe(&right_dock, |this, _, cx| {
this.serialize_workspace(cx); this.serialize_workspace(cx).detach();
cx.notify(); cx.notify();
}), }),
cx.on_release(|this, window, cx| { 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); notify_if_database_failed(window, &mut cx);
let opened_items = window let opened_items = window
.update(&mut cx, |_workspace, cx| { .update(&mut cx, |_workspace, cx| {
@ -925,6 +930,14 @@ impl Workspace {
.await .await
.unwrap_or_default(); .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)) Ok((window, opened_items))
}) })
} }
@ -1764,7 +1777,7 @@ impl Workspace {
} }
cx.notify(); cx.notify();
self.serialize_workspace(cx); self.serialize_workspace(cx).detach();
} }
pub fn close_all_docks(&mut self, cx: &mut ViewContext<Self>) { pub fn close_all_docks(&mut self, cx: &mut ViewContext<Self>) {
@ -1778,7 +1791,7 @@ impl Workspace {
cx.focus_self(); cx.focus_self();
cx.notify(); cx.notify();
self.serialize_workspace(cx); self.serialize_workspace(cx).detach();
} }
/// Transfer focus to the panel of the given type. /// 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.active_pane.update(cx, |pane, cx| pane.focus(cx))
} }
self.serialize_workspace(cx); self.serialize_workspace(cx).detach();
cx.notify(); cx.notify();
return panel; return panel;
} }
@ -2377,7 +2390,7 @@ impl Workspace {
} }
} }
self.serialize_workspace(cx); self.serialize_workspace(cx).detach();
} }
pub fn split_pane( pub fn split_pane(
@ -3333,12 +3346,12 @@ impl Workspace {
cx.background_executor() cx.background_executor()
.timer(Duration::from_millis(100)) .timer(Duration::from_millis(100))
.await; .await;
this.update(&mut cx, |this, cx| this.serialize_workspace(cx)) this.update(&mut cx, |this, cx| this.serialize_workspace(cx).detach())
.log_err(); .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 { fn serialize_pane_handle(pane_handle: &View<Pane>, cx: &WindowContext) -> SerializedPane {
let (items, active) = { let (items, active) = {
let pane = pane_handle.read(cx); let pane = pane_handle.read(cx);
@ -3441,7 +3454,6 @@ impl Workspace {
if !location.paths().is_empty() { if !location.paths().is_empty() {
let center_group = build_serialized_pane_group(&self.center.root, cx); let center_group = build_serialized_pane_group(&self.center.root, cx);
let docks = build_serialized_docks(self, cx); let docks = build_serialized_docks(self, cx);
let serialized_workspace = SerializedWorkspace { let serialized_workspace = SerializedWorkspace {
id: self.database_id, id: self.database_id,
location, location,
@ -3451,11 +3463,37 @@ impl Workspace {
docks, docks,
fullscreen: cx.is_fullscreen(), fullscreen: cx.is_fullscreen(),
}; };
return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace));
}
}
Task::ready(())
}
cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)) fn refresh_recent_documents(&self, cx: &mut AppContext) -> Task<Result<()>> {
.detach(); 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( 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 // 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) Ok(opened_items)
}) })