1use std::sync::Arc;
2
3use chrono::{DateTime, Utc};
4use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings};
5use gpui::{
6 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
7 Subscription, Task, WeakEntity, Window,
8};
9use picker::{
10 Picker, PickerDelegate,
11 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
12};
13use remote::RemoteConnectionOptions;
14use settings::Settings;
15use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
16use ui_input::ErasedEditor;
17use util::{ResultExt, paths::PathExt};
18use workspace::{
19 MultiWorkspace, OpenMode, OpenOptions, PathList, ProjectGroupKey, SerializedWorkspaceLocation,
20 Workspace, WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr,
21};
22
23use zed_actions::OpenRemote;
24
25use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
26
27pub struct SidebarRecentProjects {
28 pub picker: Entity<Picker<SidebarRecentProjectsDelegate>>,
29 _subscription: Subscription,
30}
31
32impl SidebarRecentProjects {
33 pub fn popover(
34 workspace: WeakEntity<Workspace>,
35 window_project_groups: Vec<ProjectGroupKey>,
36 _focus_handle: FocusHandle,
37 window: &mut Window,
38 cx: &mut App,
39 ) -> Entity<Self> {
40 let fs = workspace
41 .upgrade()
42 .map(|ws| ws.read(cx).app_state().fs.clone());
43
44 cx.new(|cx| {
45 let delegate = SidebarRecentProjectsDelegate {
46 workspace,
47 window_project_groups,
48 workspaces: Vec::new(),
49 filtered_workspaces: Vec::new(),
50 selected_index: 0,
51 has_any_non_local_projects: false,
52 focus_handle: cx.focus_handle(),
53 };
54
55 let picker: Entity<Picker<SidebarRecentProjectsDelegate>> = cx.new(|cx| {
56 Picker::list(delegate, window, cx)
57 .list_measure_all()
58 .show_scrollbar(true)
59 });
60
61 let picker_focus_handle = picker.focus_handle(cx);
62 picker.update(cx, |picker, _| {
63 picker.delegate.focus_handle = picker_focus_handle;
64 });
65
66 let _subscription =
67 cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent));
68
69 let db = WorkspaceDb::global(cx);
70 cx.spawn_in(window, async move |this, cx| {
71 let Some(fs) = fs else { return };
72 let workspaces = db
73 .recent_project_workspaces(fs.as_ref())
74 .await
75 .log_err()
76 .unwrap_or_default();
77 let workspaces =
78 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
79 this.update_in(cx, move |this, window, cx| {
80 this.picker.update(cx, move |picker, cx| {
81 picker.delegate.set_workspaces(workspaces);
82 picker.update_matches(picker.query(cx), window, cx)
83 })
84 })
85 .ok();
86 })
87 .detach();
88
89 picker.focus_handle(cx).focus(window, cx);
90
91 Self {
92 picker,
93 _subscription,
94 }
95 })
96 }
97}
98
99impl EventEmitter<DismissEvent> for SidebarRecentProjects {}
100
101impl Focusable for SidebarRecentProjects {
102 fn focus_handle(&self, cx: &App) -> FocusHandle {
103 self.picker.focus_handle(cx)
104 }
105}
106
107impl Render for SidebarRecentProjects {
108 fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
109 v_flex()
110 .key_context("SidebarRecentProjects")
111 .w(rems(18.))
112 .child(self.picker.clone())
113 }
114}
115
116pub struct SidebarRecentProjectsDelegate {
117 workspace: WeakEntity<Workspace>,
118 window_project_groups: Vec<ProjectGroupKey>,
119 workspaces: Vec<(
120 WorkspaceId,
121 SerializedWorkspaceLocation,
122 PathList,
123 DateTime<Utc>,
124 )>,
125 filtered_workspaces: Vec<StringMatch>,
126 selected_index: usize,
127 has_any_non_local_projects: bool,
128 focus_handle: FocusHandle,
129}
130
131impl SidebarRecentProjectsDelegate {
132 pub fn set_workspaces(
133 &mut self,
134 workspaces: Vec<(
135 WorkspaceId,
136 SerializedWorkspaceLocation,
137 PathList,
138 DateTime<Utc>,
139 )>,
140 ) {
141 self.has_any_non_local_projects = workspaces
142 .iter()
143 .any(|(_, location, _, _)| !matches!(location, SerializedWorkspaceLocation::Local));
144 self.workspaces = workspaces;
145 }
146}
147
148impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
149
150impl PickerDelegate for SidebarRecentProjectsDelegate {
151 type ListItem = AnyElement;
152
153 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
154 "Search recent projects…".into()
155 }
156
157 fn render_editor(
158 &self,
159 editor: &Arc<dyn ErasedEditor>,
160 window: &mut Window,
161 cx: &mut Context<Picker<Self>>,
162 ) -> Div {
163 h_flex()
164 .flex_none()
165 .h_9()
166 .px_2p5()
167 .justify_between()
168 .border_b_1()
169 .border_color(cx.theme().colors().border_variant)
170 .child(editor.render(window, cx))
171 }
172
173 fn match_count(&self) -> usize {
174 self.filtered_workspaces.len()
175 }
176
177 fn selected_index(&self) -> usize {
178 self.selected_index
179 }
180
181 fn set_selected_index(
182 &mut self,
183 ix: usize,
184 _window: &mut Window,
185 _cx: &mut Context<Picker<Self>>,
186 ) {
187 self.selected_index = ix;
188 }
189
190 fn update_matches(
191 &mut self,
192 query: String,
193 _: &mut Window,
194 cx: &mut Context<Picker<Self>>,
195 ) -> Task<()> {
196 let query = query.trim_start();
197 let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
198 let is_empty_query = query.is_empty();
199
200 let current_workspace_id = self
201 .workspace
202 .upgrade()
203 .and_then(|ws| ws.read(cx).database_id());
204
205 let candidates: Vec<_> = self
206 .workspaces
207 .iter()
208 .enumerate()
209 .filter(|(_, (id, _, paths, _))| {
210 Some(*id) != current_workspace_id
211 && !self
212 .window_project_groups
213 .iter()
214 .any(|key| key.path_list() == paths)
215 })
216 .map(|(id, (_, _, paths, _))| {
217 let combined_string = paths
218 .ordered_paths()
219 .map(|path| path.compact().to_string_lossy().into_owned())
220 .collect::<Vec<_>>()
221 .join("");
222 StringMatchCandidate::new(id, &combined_string)
223 })
224 .collect();
225
226 if is_empty_query {
227 self.filtered_workspaces = candidates
228 .into_iter()
229 .map(|candidate| StringMatch {
230 candidate_id: candidate.id,
231 score: 0.0,
232 positions: Vec::new(),
233 string: candidate.string,
234 })
235 .collect();
236 } else {
237 self.filtered_workspaces = match_strings(
238 &candidates,
239 query,
240 case,
241 fuzzy_nucleo::LengthPenalty::On,
242 100,
243 );
244 }
245
246 self.selected_index = 0;
247 Task::ready(())
248 }
249
250 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
251 let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
252 return;
253 };
254 let Some((_, location, candidate_workspace_paths, _)) =
255 self.workspaces.get(hit.candidate_id)
256 else {
257 return;
258 };
259
260 let Some(workspace) = self.workspace.upgrade() else {
261 return;
262 };
263
264 match location {
265 SerializedWorkspaceLocation::Local => {
266 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
267 let paths = candidate_workspace_paths.paths().to_vec();
268 cx.defer(move |cx| {
269 if let Some(task) = handle
270 .update(cx, |multi_workspace, window, cx| {
271 multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
272 })
273 .log_err()
274 {
275 task.detach_and_log_err(cx);
276 }
277 });
278 }
279 }
280 SerializedWorkspaceLocation::Remote(connection) => {
281 let mut connection = connection.clone();
282 workspace.update(cx, |workspace, cx| {
283 let app_state = workspace.app_state().clone();
284 let replace_window = window.window_handle().downcast::<MultiWorkspace>();
285 let open_options = OpenOptions {
286 requesting_window: replace_window,
287 ..Default::default()
288 };
289 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
290 crate::RemoteSettings::get_global(cx)
291 .fill_connection_options_from_settings(connection);
292 };
293 let paths = candidate_workspace_paths.paths().to_vec();
294 cx.spawn_in(window, async move |_, cx| {
295 open_remote_project(connection.clone(), paths, app_state, open_options, cx)
296 .await
297 })
298 .detach_and_prompt_err(
299 "Failed to open project",
300 window,
301 cx,
302 |_, _, _| None,
303 );
304 });
305 }
306 }
307 cx.emit(DismissEvent);
308 }
309
310 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
311
312 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
313 let text = if self.workspaces.is_empty() {
314 "Recently opened projects will show up here"
315 } else {
316 "No matches"
317 };
318 Some(text.into())
319 }
320
321 fn render_match(
322 &self,
323 ix: usize,
324 selected: bool,
325 window: &mut Window,
326 cx: &mut Context<Picker<Self>>,
327 ) -> Option<Self::ListItem> {
328 let hit = self.filtered_workspaces.get(ix)?;
329 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
330
331 let ordered_paths: Vec<_> = paths
332 .ordered_paths()
333 .map(|p| p.compact().to_string_lossy().to_string())
334 .collect();
335
336 let tooltip_path: SharedString = match &location {
337 SerializedWorkspaceLocation::Remote(options) => {
338 let host = options.display_name();
339 if ordered_paths.len() == 1 {
340 format!("{} ({})", ordered_paths[0], host).into()
341 } else {
342 format!("{}\n({})", ordered_paths.join("\n"), host).into()
343 }
344 }
345 _ => ordered_paths.join("\n").into(),
346 };
347
348 let mut path_start_offset = 0;
349 let match_labels: Vec<_> = paths
350 .ordered_paths()
351 .map(|p| p.compact())
352 .map(|path| {
353 let (label, path_match) =
354 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
355 path_start_offset += path_match.text.len();
356 label
357 })
358 .collect();
359
360 let prefix = match &location {
361 SerializedWorkspaceLocation::Remote(options) => {
362 Some(SharedString::from(options.display_name()))
363 }
364 _ => None,
365 };
366
367 let highlighted_match = HighlightedMatchWithPaths {
368 prefix,
369 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
370 paths: Vec::new(),
371 active: false,
372 };
373
374 let icon = icon_for_remote_connection(match location {
375 SerializedWorkspaceLocation::Local => None,
376 SerializedWorkspaceLocation::Remote(options) => Some(options),
377 });
378
379 Some(
380 ListItem::new(ix)
381 .toggle_state(selected)
382 .inset(true)
383 .spacing(ListItemSpacing::Sparse)
384 .child(
385 h_flex()
386 .gap_3()
387 .flex_grow()
388 .when(self.has_any_non_local_projects, |this| {
389 this.child(Icon::new(icon).color(Color::Muted))
390 })
391 .child(highlighted_match.render(window, cx)),
392 )
393 .tooltip(move |_, cx| {
394 Tooltip::with_meta(
395 "Open Project in This Window",
396 None,
397 tooltip_path.clone(),
398 cx,
399 )
400 })
401 .into_any_element(),
402 )
403 }
404
405 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
406 let focus_handle = self.focus_handle.clone();
407
408 Some(
409 v_flex()
410 .p_1p5()
411 .flex_1()
412 .gap_1()
413 .border_t_1()
414 .border_color(cx.theme().colors().border_variant)
415 .child({
416 let open_action = workspace::Open {
417 create_new_window: false,
418 };
419
420 ButtonLike::new("open_local_folder")
421 .child(
422 h_flex()
423 .w_full()
424 .gap_1()
425 .justify_between()
426 .child(Label::new("Add Local Folders"))
427 .child(KeyBinding::for_action_in(&open_action, &focus_handle, cx)),
428 )
429 .on_click(cx.listener(move |_, _, window, cx| {
430 window.dispatch_action(open_action.boxed_clone(), cx);
431 cx.emit(DismissEvent);
432 }))
433 })
434 .child(
435 ButtonLike::new("open_remote_folder")
436 .child(
437 h_flex()
438 .w_full()
439 .gap_1()
440 .justify_between()
441 .child(Label::new("Add Remote Folder"))
442 .child(KeyBinding::for_action(
443 &OpenRemote {
444 from_existing_connection: false,
445 create_new_window: false,
446 },
447 cx,
448 )),
449 )
450 .on_click(cx.listener(|_, _, window, cx| {
451 window.dispatch_action(
452 OpenRemote {
453 from_existing_connection: false,
454 create_new_window: false,
455 }
456 .boxed_clone(),
457 cx,
458 );
459 cx.emit(DismissEvent);
460 })),
461 )
462 .into_any(),
463 )
464 }
465}