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 remote::RemoteConnectionOptions;
14use settings::Settings;
15use ui::{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_workspaces_on_disk(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 smart_case = query.chars().any(|c| c.is_uppercase());
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 let mut matches = smol::block_on(fuzzy::match_strings(
238 &candidates,
239 query,
240 smart_case,
241 true,
242 100,
243 &Default::default(),
244 cx.background_executor().clone(),
245 ));
246 matches.sort_unstable_by(|a, b| {
247 b.score
248 .partial_cmp(&a.score)
249 .unwrap_or(std::cmp::Ordering::Equal)
250 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
251 });
252 self.filtered_workspaces = matches;
253 }
254
255 self.selected_index = 0;
256 Task::ready(())
257 }
258
259 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
260 let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
261 return;
262 };
263 let Some((_, location, candidate_workspace_paths, _)) =
264 self.workspaces.get(hit.candidate_id)
265 else {
266 return;
267 };
268
269 let Some(workspace) = self.workspace.upgrade() else {
270 return;
271 };
272
273 match location {
274 SerializedWorkspaceLocation::Local => {
275 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
276 let paths = candidate_workspace_paths.paths().to_vec();
277 cx.defer(move |cx| {
278 if let Some(task) = handle
279 .update(cx, |multi_workspace, window, cx| {
280 multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
281 })
282 .log_err()
283 {
284 task.detach_and_log_err(cx);
285 }
286 });
287 }
288 }
289 SerializedWorkspaceLocation::Remote(connection) => {
290 let mut connection = connection.clone();
291 workspace.update(cx, |workspace, cx| {
292 let app_state = workspace.app_state().clone();
293 let replace_window = window.window_handle().downcast::<MultiWorkspace>();
294 let open_options = OpenOptions {
295 requesting_window: replace_window,
296 ..Default::default()
297 };
298 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
299 crate::RemoteSettings::get_global(cx)
300 .fill_connection_options_from_settings(connection);
301 };
302 let paths = candidate_workspace_paths.paths().to_vec();
303 cx.spawn_in(window, async move |_, cx| {
304 open_remote_project(connection.clone(), paths, app_state, open_options, cx)
305 .await
306 })
307 .detach_and_prompt_err(
308 "Failed to open project",
309 window,
310 cx,
311 |_, _, _| None,
312 );
313 });
314 }
315 }
316 cx.emit(DismissEvent);
317 }
318
319 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
320
321 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
322 let text = if self.workspaces.is_empty() {
323 "Recently opened projects will show up here"
324 } else {
325 "No matches"
326 };
327 Some(text.into())
328 }
329
330 fn render_match(
331 &self,
332 ix: usize,
333 selected: bool,
334 window: &mut Window,
335 cx: &mut Context<Picker<Self>>,
336 ) -> Option<Self::ListItem> {
337 let hit = self.filtered_workspaces.get(ix)?;
338 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
339
340 let ordered_paths: Vec<_> = paths
341 .ordered_paths()
342 .map(|p| p.compact().to_string_lossy().to_string())
343 .collect();
344
345 let tooltip_path: SharedString = match &location {
346 SerializedWorkspaceLocation::Remote(options) => {
347 let host = options.display_name();
348 if ordered_paths.len() == 1 {
349 format!("{} ({})", ordered_paths[0], host).into()
350 } else {
351 format!("{}\n({})", ordered_paths.join("\n"), host).into()
352 }
353 }
354 _ => ordered_paths.join("\n").into(),
355 };
356
357 let mut path_start_offset = 0;
358 let match_labels: Vec<_> = paths
359 .ordered_paths()
360 .map(|p| p.compact())
361 .map(|path| {
362 let (label, path_match) =
363 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
364 path_start_offset += path_match.text.len();
365 label
366 })
367 .collect();
368
369 let prefix = match &location {
370 SerializedWorkspaceLocation::Remote(options) => {
371 Some(SharedString::from(options.display_name()))
372 }
373 _ => None,
374 };
375
376 let highlighted_match = HighlightedMatchWithPaths {
377 prefix,
378 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
379 paths: Vec::new(),
380 active: false,
381 };
382
383 let icon = icon_for_remote_connection(match location {
384 SerializedWorkspaceLocation::Local => None,
385 SerializedWorkspaceLocation::Remote(options) => Some(options),
386 });
387
388 Some(
389 ListItem::new(ix)
390 .toggle_state(selected)
391 .inset(true)
392 .spacing(ListItemSpacing::Sparse)
393 .child(
394 h_flex()
395 .gap_3()
396 .flex_grow()
397 .when(self.has_any_non_local_projects, |this| {
398 this.child(Icon::new(icon).color(Color::Muted))
399 })
400 .child(highlighted_match.render(window, cx)),
401 )
402 .tooltip(move |_, cx| {
403 Tooltip::with_meta(
404 "Open Project in This Window",
405 None,
406 tooltip_path.clone(),
407 cx,
408 )
409 })
410 .into_any_element(),
411 )
412 }
413
414 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
415 let focus_handle = self.focus_handle.clone();
416
417 Some(
418 v_flex()
419 .p_1p5()
420 .flex_1()
421 .gap_1()
422 .border_t_1()
423 .border_color(cx.theme().colors().border_variant)
424 .child({
425 let open_action = workspace::Open {
426 create_new_window: false,
427 };
428
429 Button::new("open_local_folder", "Add Local Project")
430 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
431 .on_click(cx.listener(move |_, _, window, cx| {
432 window.dispatch_action(open_action.boxed_clone(), cx);
433 cx.emit(DismissEvent);
434 }))
435 })
436 .child(
437 Button::new("open_remote_folder", "Add Remote Project")
438 .key_binding(KeyBinding::for_action(
439 &OpenRemote {
440 from_existing_connection: false,
441 create_new_window: false,
442 },
443 cx,
444 ))
445 .on_click(cx.listener(|_, _, window, cx| {
446 window.dispatch_action(
447 OpenRemote {
448 from_existing_connection: false,
449 create_new_window: false,
450 }
451 .boxed_clone(),
452 cx,
453 );
454 cx.emit(DismissEvent);
455 })),
456 )
457 .into_any(),
458 )
459 }
460}