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
This commit is contained in:
Marshall Bowers 2023-12-06 18:41:50 -05:00 committed by GitHub
parent 1a747c8457
commit a4b271e063
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 432 additions and 5 deletions

24
Cargo.lock generated
View File

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

View File

@ -92,6 +92,7 @@ members = [
"crates/project_symbols",
"crates/quick_action_bar2",
"crates/recent_projects",
"crates/recent_projects2",
"crates/rope",
"crates/rpc",
"crates/rpc2",

View File

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

View File

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

View File

@ -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,
},
)
}
}

View File

@ -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)
}),
),
)
}
}

View File

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

View File

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

View File

@ -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 }),

View File

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