1mod highlighted_workspace_location;
2mod projects;
3
4use fuzzy::{StringMatch, StringMatchCandidate};
5use gpui::{
6 AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Result, Subscription, Task,
7 View, ViewContext, WeakView,
8};
9use highlighted_workspace_location::HighlightedWorkspaceLocation;
10use ordered_float::OrderedFloat;
11use picker::{Picker, PickerDelegate};
12use std::sync::Arc;
13use ui::{prelude::*, tooltip_container, HighlightedLabel, ListItem, ListItemSpacing};
14use util::paths::PathExt;
15use workspace::{ModalView, Workspace, WorkspaceLocation, WORKSPACE_DB};
16
17pub use projects::OpenRecent;
18
19pub fn init(cx: &mut AppContext) {
20 cx.observe_new_views(RecentProjects::register).detach();
21}
22
23pub struct RecentProjects {
24 pub picker: View<Picker<RecentProjectsDelegate>>,
25 rem_width: f32,
26 _subscription: Subscription,
27}
28
29impl ModalView for RecentProjects {}
30
31impl RecentProjects {
32 fn new(delegate: RecentProjectsDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
33 let picker = cx.new_view(|cx| {
34 // We want to use a list when we render paths, because the items can have different heights (multiple paths).
35 if delegate.render_paths {
36 Picker::list(delegate, cx)
37 } else {
38 Picker::uniform_list(delegate, cx)
39 }
40 });
41 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
42 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
43 // out workspace locations once the future runs to completion.
44 cx.spawn(|this, mut cx| async move {
45 let workspaces = WORKSPACE_DB
46 .recent_workspaces_on_disk()
47 .await
48 .unwrap_or_default()
49 .into_iter()
50 .map(|(_, location)| location)
51 .collect();
52 this.update(&mut cx, move |this, cx| {
53 this.picker.update(cx, move |picker, cx| {
54 picker.delegate.workspace_locations = workspaces;
55 picker.update_matches(picker.query(cx), cx)
56 })
57 })
58 .ok()
59 })
60 .detach();
61 Self {
62 picker,
63 rem_width,
64 _subscription,
65 }
66 }
67
68 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
69 workspace.register_action(|workspace, _: &OpenRecent, cx| {
70 let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
71 if let Some(handler) = Self::open(workspace, cx) {
72 handler.detach_and_log_err(cx);
73 }
74 return;
75 };
76
77 recent_projects.update(cx, |recent_projects, cx| {
78 recent_projects
79 .picker
80 .update(cx, |picker, cx| picker.cycle_selection(cx))
81 });
82 });
83 }
84
85 fn open(_: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<Task<Result<()>>> {
86 Some(cx.spawn(|workspace, mut cx| async move {
87 workspace.update(&mut cx, |workspace, cx| {
88 let weak_workspace = cx.view().downgrade();
89 workspace.toggle_modal(cx, |cx| {
90 let delegate = RecentProjectsDelegate::new(weak_workspace, true);
91
92 let modal = Self::new(delegate, 34., cx);
93 modal
94 });
95 })?;
96 Ok(())
97 }))
98 }
99 pub fn open_popover(workspace: WeakView<Workspace>, cx: &mut WindowContext<'_>) -> View<Self> {
100 cx.new_view(|cx| Self::new(RecentProjectsDelegate::new(workspace, false), 20., cx))
101 }
102}
103
104impl EventEmitter<DismissEvent> for RecentProjects {}
105
106impl FocusableView for RecentProjects {
107 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
108 self.picker.focus_handle(cx)
109 }
110}
111
112impl Render for RecentProjects {
113 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
114 v_flex()
115 .w(rems(self.rem_width))
116 .child(self.picker.clone())
117 .on_mouse_down_out(cx.listener(|this, _, cx| {
118 this.picker.update(cx, |this, cx| {
119 this.cancel(&Default::default(), cx);
120 })
121 }))
122 }
123}
124
125pub struct RecentProjectsDelegate {
126 workspace: WeakView<Workspace>,
127 workspace_locations: Vec<WorkspaceLocation>,
128 selected_match_index: usize,
129 matches: Vec<StringMatch>,
130 render_paths: bool,
131}
132
133impl RecentProjectsDelegate {
134 fn new(workspace: WeakView<Workspace>, render_paths: bool) -> Self {
135 Self {
136 workspace,
137 workspace_locations: vec![],
138 selected_match_index: 0,
139 matches: Default::default(),
140 render_paths,
141 }
142 }
143}
144impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
145impl PickerDelegate for RecentProjectsDelegate {
146 type ListItem = ListItem;
147
148 fn placeholder_text(&self) -> Arc<str> {
149 "Search recent projects...".into()
150 }
151
152 fn match_count(&self) -> usize {
153 self.matches.len()
154 }
155
156 fn selected_index(&self) -> usize {
157 self.selected_match_index
158 }
159
160 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
161 self.selected_match_index = ix;
162 }
163
164 fn update_matches(
165 &mut self,
166 query: String,
167 cx: &mut ViewContext<Picker<Self>>,
168 ) -> gpui::Task<()> {
169 let query = query.trim_start();
170 let smart_case = query.chars().any(|c| c.is_uppercase());
171 let candidates = self
172 .workspace_locations
173 .iter()
174 .enumerate()
175 .map(|(id, location)| {
176 let combined_string = location
177 .paths()
178 .iter()
179 .map(|path| path.compact().to_string_lossy().into_owned())
180 .collect::<Vec<_>>()
181 .join("");
182 StringMatchCandidate::new(id, combined_string)
183 })
184 .collect::<Vec<_>>();
185 self.matches = smol::block_on(fuzzy::match_strings(
186 candidates.as_slice(),
187 query,
188 smart_case,
189 100,
190 &Default::default(),
191 cx.background_executor().clone(),
192 ));
193 self.matches.sort_unstable_by_key(|m| m.candidate_id);
194
195 self.selected_match_index = self
196 .matches
197 .iter()
198 .enumerate()
199 .rev()
200 .max_by_key(|(_, m)| OrderedFloat(m.score))
201 .map(|(ix, _)| ix)
202 .unwrap_or(0);
203 Task::ready(())
204 }
205
206 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
207 if let Some((selected_match, workspace)) = self
208 .matches
209 .get(self.selected_index())
210 .zip(self.workspace.upgrade())
211 {
212 let workspace_location = &self.workspace_locations[selected_match.candidate_id];
213 workspace
214 .update(cx, |workspace, cx| {
215 workspace
216 .open_workspace_for_paths(workspace_location.paths().as_ref().clone(), cx)
217 })
218 .detach_and_log_err(cx);
219 cx.emit(DismissEvent);
220 }
221 }
222
223 fn dismissed(&mut self, _: &mut ViewContext<Picker<Self>>) {}
224
225 fn render_match(
226 &self,
227 ix: usize,
228 selected: bool,
229 _cx: &mut ViewContext<Picker<Self>>,
230 ) -> Option<Self::ListItem> {
231 let Some(r#match) = self.matches.get(ix) else {
232 return None;
233 };
234
235 let highlighted_location = HighlightedWorkspaceLocation::new(
236 &r#match,
237 &self.workspace_locations[r#match.candidate_id],
238 );
239
240 let tooltip_highlighted_location = highlighted_location.clone();
241
242 Some(
243 ListItem::new(ix)
244 .inset(true)
245 .spacing(ListItemSpacing::Sparse)
246 .selected(selected)
247 .child(
248 v_flex()
249 .child(highlighted_location.names)
250 .when(self.render_paths, |this| {
251 this.children(highlighted_location.paths.into_iter().map(|path| {
252 HighlightedLabel::new(path.text, path.highlight_positions)
253 .size(LabelSize::Small)
254 .color(Color::Muted)
255 }))
256 }),
257 )
258 .tooltip(move |cx| {
259 let tooltip_highlighted_location = tooltip_highlighted_location.clone();
260 cx.new_view(move |_| MatchTooltip {
261 highlighted_location: tooltip_highlighted_location,
262 })
263 .into()
264 }),
265 )
266 }
267}
268
269struct MatchTooltip {
270 highlighted_location: HighlightedWorkspaceLocation,
271}
272
273impl Render for MatchTooltip {
274 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
275 tooltip_container(cx, |div, _| {
276 div.children(
277 self.highlighted_location
278 .paths
279 .clone()
280 .into_iter()
281 .map(|path| {
282 HighlightedLabel::new(path.text, path.highlight_positions)
283 .size(LabelSize::Small)
284 .color(Color::Muted)
285 }),
286 )
287 })
288 }
289}