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