Port `recent_projects` to Zed2 (#3525)

Marshall Bowers created

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

Change summary

Cargo.lock                                                    |  24 
Cargo.toml                                                    |   1 
crates/collab_ui2/Cargo.toml                                  |   2 
crates/recent_projects2/Cargo.toml                            |  31 
crates/recent_projects2/src/highlighted_workspace_location.rs | 131 ++
crates/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(-)

Detailed changes

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",

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",

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"}

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"] }

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<usize>,
+    char_count: usize,
+}
+
+impl HighlightedText {
+    fn join(components: impl Iterator<Item = Self>, 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<HighlightedText>,
+}
+
+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<usize>,
+        path_start_offset: usize,
+    ) -> (Option<HighlightedText>, 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::<Vec<_>>();
+
+        // 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::<Vec<_>>();
+            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,
+            },
+        )
+    }
+}

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<Workspace>,
+) -> Option<Task<Result<()>>> {
+    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<Picker<RecentProjectsDelegate>>,
+}
+
+impl RecentProjects {
+    fn new(delegate: RecentProjectsDelegate, cx: &mut ViewContext<Self>) -> Self {
+        Self {
+            picker: cx.build_view(|cx| Picker::new(delegate, cx)),
+        }
+    }
+
+    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+        workspace.register_action(|workspace, _: &OpenRecent, cx| {
+            let Some(recent_projects) = workspace.active_modal::<Self>(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<DismissEvent> 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>) -> Self::Element {
+        v_stack().w_96().child(self.picker.clone())
+    }
+}
+
+pub struct RecentProjectsDelegate {
+    workspace: WeakView<Workspace>,
+    workspace_locations: Vec<WorkspaceLocation>,
+    selected_match_index: usize,
+    matches: Vec<StringMatch>,
+    render_paths: bool,
+}
+
+impl RecentProjectsDelegate {
+    fn new(
+        workspace: WeakView<Workspace>,
+        workspace_locations: Vec<WorkspaceLocation>,
+        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<str> {
+        "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<Picker<Self>>) {
+        self.selected_match_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> 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::<Vec<_>>()
+                    .join("");
+                StringMatchCandidate::new(id, combined_string)
+            })
+            .collect::<Vec<_>>();
+        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<Picker<Self>>) {
+        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<Picker<Self>>) {}
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        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)
+                    }),
+            ),
+        )
+    }
+}

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};

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" }

crates/zed2/src/app_menus.rs πŸ”—

@@ -35,7 +35,7 @@ pub fn app_menus() -> Vec<Menu<'static>> {
                 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 }),

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);