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