Detailed changes
@@ -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",
@@ -92,6 +92,7 @@ members = [
"crates/project_symbols",
"crates/quick_action_bar2",
"crates/recent_projects",
+ "crates/recent_projects2",
"crates/rope",
"crates/rpc",
"crates/rpc2",
@@ -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"}
@@ -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"] }
@@ -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,
+ },
+ )
+ }
+}
@@ -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)
+ }),
+ ),
+ )
+ }
+}
@@ -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};
@@ -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" }
@@ -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 }),
@@ -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);