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