From a4b271e06395b5fe794f88affd0dc08d046628d3 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 6 Dec 2023 18:41:50 -0500 Subject: [PATCH] Port `recent_projects` to Zed2 (#3525) This PR ports the `recent_projects` crate to Zed2 (`recent_projects2`). Absent from this PR is wiring up the "Recent Projects" item in the title bar. We'll come back to that soon. Release Notes: - N/A --- Cargo.lock | 24 ++ Cargo.toml | 1 + crates/collab_ui2/Cargo.toml | 2 +- crates/recent_projects2/Cargo.toml | 31 +++ .../src/highlighted_workspace_location.rs | 131 ++++++++++ .../recent_projects2/src/recent_projects.rs | 239 ++++++++++++++++++ crates/workspace2/src/workspace2.rs | 3 +- crates/zed2/Cargo.toml | 2 +- crates/zed2/src/app_menus.rs | 2 +- crates/zed2/src/main.rs | 2 +- 10 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 crates/recent_projects2/Cargo.toml create mode 100644 crates/recent_projects2/src/highlighted_workspace_location.rs create mode 100644 crates/recent_projects2/src/recent_projects.rs diff --git a/Cargo.lock b/Cargo.lock index 59062e5e14..b48fd4dc3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1959,6 +1959,7 @@ dependencies = [ "postage", "pretty_assertions", "project2", + "recent_projects2", "rich_text2", "rpc2", "schemars", @@ -7307,6 +7308,28 @@ dependencies = [ "workspace", ] +[[package]] +name = "recent_projects2" +version = "0.1.0" +dependencies = [ + "db", + "editor2", + "futures 0.3.28", + "fuzzy2", + "gpui2", + "language2", + "ordered-float 2.10.0", + "picker2", + "postage", + "settings2", + "smol", + "text2", + "theme2", + "ui2", + "util", + "workspace2", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -11948,6 +11971,7 @@ dependencies = [ "project_panel2", "quick_action_bar2", "rand 0.8.5", + "recent_projects2", "regex", "rope2", "rpc2", diff --git a/Cargo.toml b/Cargo.toml index 6b154cc87f..5a3c451fd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ members = [ "crates/project_symbols", "crates/quick_action_bar2", "crates/recent_projects", + "crates/recent_projects2", "crates/rope", "crates/rpc", "crates/rpc2", diff --git a/crates/collab_ui2/Cargo.toml b/crates/collab_ui2/Cargo.toml index c7c00d7696..65aced8e7e 100644 --- a/crates/collab_ui2/Cargo.toml +++ b/crates/collab_ui2/Cargo.toml @@ -41,7 +41,7 @@ notifications = { package = "notifications2", path = "../notifications2" } rich_text = { package = "rich_text2", path = "../rich_text2" } picker = { package = "picker2", path = "../picker2" } project = { package = "project2", path = "../project2" } -# recent_projects = { path = "../recent_projects" } +recent_projects = { package = "recent_projects2", path = "../recent_projects2" } rpc = { package ="rpc2", path = "../rpc2" } settings = { package = "settings2", path = "../settings2" } feature_flags = { package = "feature_flags2", path = "../feature_flags2"} diff --git a/crates/recent_projects2/Cargo.toml b/crates/recent_projects2/Cargo.toml new file mode 100644 index 0000000000..3d10c147e0 --- /dev/null +++ b/crates/recent_projects2/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "recent_projects2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/recent_projects.rs" +doctest = false + +[dependencies] +db = { path = "../db" } +editor = { package = "editor2", path = "../editor2" } +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +picker = { package = "picker2", path = "../picker2" } +settings = { package = "settings2", path = "../settings2" } +text = { package = "text2", path = "../text2" } +util = { path = "../util"} +theme = { package = "theme2", path = "../theme2" } +ui = { package = "ui2", path = "../ui2" } +workspace = { package = "workspace2", path = "../workspace2" } + +futures.workspace = true +ordered-float.workspace = true +postage.workspace = true +smol.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/recent_projects2/src/highlighted_workspace_location.rs b/crates/recent_projects2/src/highlighted_workspace_location.rs new file mode 100644 index 0000000000..a4057d2f4b --- /dev/null +++ b/crates/recent_projects2/src/highlighted_workspace_location.rs @@ -0,0 +1,131 @@ +use std::path::Path; + +use fuzzy::StringMatch; +use ui::{prelude::*, HighlightedLabel}; +use util::paths::PathExt; +use workspace::WorkspaceLocation; + +#[derive(IntoElement)] +pub struct HighlightedText { + pub text: String, + pub highlight_positions: Vec, + char_count: usize, +} + +impl HighlightedText { + fn join(components: impl Iterator, separator: &str) -> Self { + let mut char_count = 0; + let separator_char_count = separator.chars().count(); + let mut text = String::new(); + let mut highlight_positions = Vec::new(); + for component in components { + if char_count != 0 { + text.push_str(separator); + char_count += separator_char_count; + } + + highlight_positions.extend( + component + .highlight_positions + .iter() + .map(|position| position + char_count), + ); + text.push_str(&component.text); + char_count += component.text.chars().count(); + } + + Self { + text, + highlight_positions, + char_count, + } + } +} + +impl RenderOnce for HighlightedText { + type Rendered = HighlightedLabel; + + fn render(self, _cx: &mut WindowContext) -> Self::Rendered { + HighlightedLabel::new(self.text, self.highlight_positions) + } +} + +pub struct HighlightedWorkspaceLocation { + pub names: HighlightedText, + pub paths: Vec, +} + +impl HighlightedWorkspaceLocation { + pub fn new(string_match: &StringMatch, location: &WorkspaceLocation) -> Self { + let mut path_start_offset = 0; + let (names, paths): (Vec<_>, Vec<_>) = location + .paths() + .iter() + .map(|path| { + let path = path.compact(); + let highlighted_text = Self::highlights_for_path( + path.as_ref(), + &string_match.positions, + path_start_offset, + ); + + path_start_offset += highlighted_text.1.char_count; + + highlighted_text + }) + .unzip(); + + Self { + names: HighlightedText::join(names.into_iter().filter_map(|name| name), ", "), + paths, + } + } + + // Compute the highlighted text for the name and path + fn highlights_for_path( + path: &Path, + match_positions: &Vec, + path_start_offset: usize, + ) -> (Option, HighlightedText) { + let path_string = path.to_string_lossy(); + let path_char_count = path_string.chars().count(); + // Get the subset of match highlight positions that line up with the given path. + // Also adjusts them to start at the path start + let path_positions = match_positions + .iter() + .copied() + .skip_while(|position| *position < path_start_offset) + .take_while(|position| *position < path_start_offset + path_char_count) + .map(|position| position - path_start_offset) + .collect::>(); + + // Again subset the highlight positions to just those that line up with the file_name + // again adjusted to the start of the file_name + let file_name_text_and_positions = path.file_name().map(|file_name| { + let text = file_name.to_string_lossy(); + let char_count = text.chars().count(); + let file_name_start = path_char_count - char_count; + let highlight_positions = path_positions + .iter() + .copied() + .skip_while(|position| *position < file_name_start) + .take_while(|position| *position < file_name_start + char_count) + .map(|position| position - file_name_start) + .collect::>(); + HighlightedText { + text: text.to_string(), + highlight_positions, + char_count, + } + }); + + ( + file_name_text_and_positions, + HighlightedText { + text: path_string.to_string(), + highlight_positions: path_positions, + char_count: path_char_count, + }, + ) + } +} diff --git a/crates/recent_projects2/src/recent_projects.rs b/crates/recent_projects2/src/recent_projects.rs new file mode 100644 index 0000000000..03cd042f82 --- /dev/null +++ b/crates/recent_projects2/src/recent_projects.rs @@ -0,0 +1,239 @@ +mod highlighted_workspace_location; + +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + actions, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, Result, Task, + View, ViewContext, WeakView, +}; +use highlighted_workspace_location::HighlightedWorkspaceLocation; +use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; +use std::sync::Arc; +use ui::{prelude::*, ListItem}; +use util::paths::PathExt; +use workspace::{ + notifications::simple_message_notification::MessageNotification, Workspace, WorkspaceLocation, + WORKSPACE_DB, +}; + +actions!(OpenRecent); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(RecentProjects::register).detach(); +} + +fn toggle( + _: &mut Workspace, + _: &OpenRecent, + cx: &mut ViewContext, +) -> Option>> { + Some(cx.spawn(|workspace, mut cx| async move { + let workspace_locations: Vec<_> = cx + .background_executor() + .spawn(async { + WORKSPACE_DB + .recent_workspaces_on_disk() + .await + .unwrap_or_default() + .into_iter() + .map(|(_, location)| location) + .collect() + }) + .await; + + workspace.update(&mut cx, |workspace, cx| { + if !workspace_locations.is_empty() { + let weak_workspace = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| { + let delegate = + RecentProjectsDelegate::new(weak_workspace, workspace_locations, true); + + RecentProjects::new(delegate, cx) + }); + } else { + workspace.show_notification(0, cx, |cx| { + cx.build_view(|_| MessageNotification::new("No recent projects to open.")) + }) + } + })?; + Ok(()) + })) +} + +pub struct RecentProjects { + picker: View>, +} + +impl RecentProjects { + fn new(delegate: RecentProjectsDelegate, cx: &mut ViewContext) -> Self { + Self { + picker: cx.build_view(|cx| Picker::new(delegate, cx)), + } + } + + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(|workspace, _: &OpenRecent, cx| { + let Some(recent_projects) = workspace.active_modal::(cx) else { + // TODO(Marshall): Is this how we should be handling this? + // The previous code was using `cx.add_async_action` to invoke `toggle`. + if let Some(handler) = toggle(workspace, &OpenRecent, cx) { + handler.detach_and_log_err(cx); + } + return; + }; + + recent_projects.update(cx, |recent_projects, cx| { + recent_projects + .picker + .update(cx, |picker, cx| picker.cycle_selection(cx)) + }); + }); + } +} + +impl EventEmitter for RecentProjects {} + +impl FocusableView for RecentProjects { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for RecentProjects { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + v_stack().w_96().child(self.picker.clone()) + } +} + +pub struct RecentProjectsDelegate { + workspace: WeakView, + workspace_locations: Vec, + selected_match_index: usize, + matches: Vec, + render_paths: bool, +} + +impl RecentProjectsDelegate { + fn new( + workspace: WeakView, + workspace_locations: Vec, + render_paths: bool, + ) -> Self { + Self { + workspace, + workspace_locations, + selected_match_index: 0, + matches: Default::default(), + render_paths, + } + } +} + +impl PickerDelegate for RecentProjectsDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self) -> Arc { + "Recent Projects...".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_match_index + } + + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_match_index = ix; + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> gpui::Task<()> { + let query = query.trim_start(); + let smart_case = query.chars().any(|c| c.is_uppercase()); + let candidates = self + .workspace_locations + .iter() + .enumerate() + .map(|(id, location)| { + let combined_string = location + .paths() + .iter() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, combined_string) + }) + .collect::>(); + self.matches = smol::block_on(fuzzy::match_strings( + candidates.as_slice(), + query, + smart_case, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + self.matches.sort_unstable_by_key(|m| m.candidate_id); + + self.selected_match_index = self + .matches + .iter() + .enumerate() + .rev() + .max_by_key(|(_, m)| OrderedFloat(m.score)) + .map(|(ix, _)| ix) + .unwrap_or(0); + Task::ready(()) + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + if let Some((selected_match, workspace)) = self + .matches + .get(self.selected_index()) + .zip(self.workspace.upgrade()) + { + let workspace_location = &self.workspace_locations[selected_match.candidate_id]; + workspace + .update(cx, |workspace, cx| { + workspace + .open_workspace_for_paths(workspace_location.paths().as_ref().clone(), cx) + }) + .detach_and_log_err(cx); + self.dismissed(cx); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + let Some(r#match) = self.matches.get(ix) else { + return None; + }; + + let highlighted_location = HighlightedWorkspaceLocation::new( + &r#match, + &self.workspace_locations[r#match.candidate_id], + ); + + Some( + ListItem::new(ix).inset(true).selected(selected).child( + v_stack() + .child(highlighted_location.names) + .when(self.render_paths, |this| { + this.children(highlighted_location.paths) + }), + ), + ) + } +} diff --git a/crates/workspace2/src/workspace2.rs b/crates/workspace2/src/workspace2.rs index d5583be0bc..abf9089929 100644 --- a/crates/workspace2/src/workspace2.rs +++ b/crates/workspace2/src/workspace2.rs @@ -45,9 +45,10 @@ use node_runtime::NodeRuntime; use notifications::{simple_message_notification::MessageNotification, NotificationHandle}; pub use pane::*; pub use pane_group::*; +use persistence::DB; pub use persistence::{ model::{ItemId, SerializedWorkspace, WorkspaceLocation}, - WorkspaceDb, DB, + WorkspaceDb, DB as WORKSPACE_DB, }; use postage::stream::Stream; use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index 427e72068e..e545fe3c97 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -56,7 +56,7 @@ project = { package = "project2", path = "../project2" } project_panel = { package = "project_panel2", path = "../project_panel2" } # project_symbols = { path = "../project_symbols" } quick_action_bar = { package = "quick_action_bar2", path = "../quick_action_bar2" } -# recent_projects = { path = "../recent_projects" } +recent_projects = { package = "recent_projects2", path = "../recent_projects2" } rope = { package = "rope2", path = "../rope2"} rpc = { package = "rpc2", path = "../rpc2" } settings = { package = "settings2", path = "../settings2" } diff --git a/crates/zed2/src/app_menus.rs b/crates/zed2/src/app_menus.rs index 70b04e8f9b..63db41e7bd 100644 --- a/crates/zed2/src/app_menus.rs +++ b/crates/zed2/src/app_menus.rs @@ -35,7 +35,7 @@ pub fn app_menus() -> Vec> { MenuItem::action("New Window", workspace::NewWindow), MenuItem::separator(), MenuItem::action("Open…", workspace::Open), - // MenuItem::action("Open Recent...", recent_projects::OpenRecent), + MenuItem::action("Open Recent...", recent_projects::OpenRecent), MenuItem::separator(), MenuItem::action("Add Folder to Project…", workspace::AddFolderToProject), MenuItem::action("Save", workspace::Save { save_intent: None }), diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index 78a8bdf292..f11c2eaadd 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -200,7 +200,7 @@ fn main() { auto_update::init(http.clone(), client::ZED_SERVER_URL.clone(), cx); workspace::init(app_state.clone(), cx); - // recent_projects::init(cx); + recent_projects::init(cx); go_to_line::init(cx); file_finder::init(cx);