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