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