1pub mod disconnected_overlay;
2mod remote_servers;
3mod ssh_connections;
4pub use ssh_connections::{is_connecting_over_ssh, open_ssh_project};
5
6use disconnected_overlay::DisconnectedOverlay;
7use fuzzy::{StringMatch, StringMatchCandidate};
8use gpui::{
9 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
10 Subscription, Task, WeakEntity, Window,
11};
12use ordered_float::OrderedFloat;
13use picker::{
14 Picker, PickerDelegate,
15 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
16};
17pub use remote_servers::RemoteServerProjects;
18use settings::Settings;
19pub use ssh_connections::SshSettings;
20use std::{
21 path::{Path, PathBuf},
22 sync::Arc,
23};
24use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
25use util::{ResultExt, paths::PathExt};
26use workspace::{
27 CloseIntent, HistoryManager, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB,
28 Workspace, WorkspaceId,
29};
30use zed_actions::{OpenRecent, OpenRemote};
31
32pub fn init(cx: &mut App) {
33 SshSettings::register(cx);
34 cx.observe_new(RecentProjects::register).detach();
35 cx.observe_new(RemoteServerProjects::register).detach();
36 cx.observe_new(DisconnectedOverlay::register).detach();
37}
38
39pub struct RecentProjects {
40 pub picker: Entity<Picker<RecentProjectsDelegate>>,
41 rem_width: f32,
42 _subscription: Subscription,
43}
44
45impl ModalView for RecentProjects {}
46
47impl RecentProjects {
48 fn new(
49 delegate: RecentProjectsDelegate,
50 rem_width: f32,
51 window: &mut Window,
52 cx: &mut Context<Self>,
53 ) -> Self {
54 let picker = cx.new(|cx| {
55 // We want to use a list when we render paths, because the items can have different heights (multiple paths).
56 if delegate.render_paths {
57 Picker::list(delegate, window, cx)
58 } else {
59 Picker::uniform_list(delegate, window, cx)
60 }
61 });
62 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
63 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
64 // out workspace locations once the future runs to completion.
65 cx.spawn_in(window, async move |this, cx| {
66 let workspaces = WORKSPACE_DB
67 .recent_workspaces_on_disk()
68 .await
69 .log_err()
70 .unwrap_or_default();
71 this.update_in(cx, move |this, window, cx| {
72 this.picker.update(cx, move |picker, cx| {
73 picker.delegate.set_workspaces(workspaces);
74 picker.update_matches(picker.query(cx), window, cx)
75 })
76 })
77 .ok()
78 })
79 .detach();
80 Self {
81 picker,
82 rem_width,
83 _subscription,
84 }
85 }
86
87 fn register(
88 workspace: &mut Workspace,
89 _window: Option<&mut Window>,
90 _cx: &mut Context<Workspace>,
91 ) {
92 workspace.register_action(|workspace, open_recent: &OpenRecent, window, cx| {
93 let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
94 Self::open(workspace, open_recent.create_new_window, window, cx);
95 return;
96 };
97
98 recent_projects.update(cx, |recent_projects, cx| {
99 recent_projects
100 .picker
101 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
102 });
103 });
104 }
105
106 pub fn open(
107 workspace: &mut Workspace,
108 create_new_window: bool,
109 window: &mut Window,
110 cx: &mut Context<Workspace>,
111 ) {
112 let weak = cx.entity().downgrade();
113 workspace.toggle_modal(window, cx, |window, cx| {
114 let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
115
116 Self::new(delegate, 34., window, cx)
117 })
118 }
119}
120
121impl EventEmitter<DismissEvent> for RecentProjects {}
122
123impl Focusable for RecentProjects {
124 fn focus_handle(&self, cx: &App) -> FocusHandle {
125 self.picker.focus_handle(cx)
126 }
127}
128
129impl Render for RecentProjects {
130 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
131 v_flex()
132 .w(rems(self.rem_width))
133 .child(self.picker.clone())
134 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
135 this.picker.update(cx, |this, cx| {
136 this.cancel(&Default::default(), window, cx);
137 })
138 }))
139 }
140}
141
142pub struct RecentProjectsDelegate {
143 workspace: WeakEntity<Workspace>,
144 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
145 selected_match_index: usize,
146 matches: Vec<StringMatch>,
147 render_paths: bool,
148 create_new_window: bool,
149 // Flag to reset index when there is a new query vs not reset index when user delete an item
150 reset_selected_match_index: bool,
151 has_any_non_local_projects: bool,
152}
153
154impl RecentProjectsDelegate {
155 fn new(workspace: WeakEntity<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
156 Self {
157 workspace,
158 workspaces: Vec::new(),
159 selected_match_index: 0,
160 matches: Default::default(),
161 create_new_window,
162 render_paths,
163 reset_selected_match_index: true,
164 has_any_non_local_projects: false,
165 }
166 }
167
168 pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
169 self.workspaces = workspaces;
170 self.has_any_non_local_projects = !self
171 .workspaces
172 .iter()
173 .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _)));
174 }
175}
176impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
177impl PickerDelegate for RecentProjectsDelegate {
178 type ListItem = ListItem;
179
180 fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
181 let (create_window, reuse_window) = if self.create_new_window {
182 (
183 window.keystroke_text_for(&menu::Confirm),
184 window.keystroke_text_for(&menu::SecondaryConfirm),
185 )
186 } else {
187 (
188 window.keystroke_text_for(&menu::SecondaryConfirm),
189 window.keystroke_text_for(&menu::Confirm),
190 )
191 };
192 Arc::from(format!(
193 "{reuse_window} reuses this window, {create_window} opens a new one",
194 ))
195 }
196
197 fn match_count(&self) -> usize {
198 self.matches.len()
199 }
200
201 fn selected_index(&self) -> usize {
202 self.selected_match_index
203 }
204
205 fn set_selected_index(
206 &mut self,
207 ix: usize,
208 _window: &mut Window,
209 _cx: &mut Context<Picker<Self>>,
210 ) {
211 self.selected_match_index = ix;
212 }
213
214 fn update_matches(
215 &mut self,
216 query: String,
217 _: &mut Window,
218 cx: &mut Context<Picker<Self>>,
219 ) -> gpui::Task<()> {
220 let query = query.trim_start();
221 let smart_case = query.chars().any(|c| c.is_uppercase());
222 let candidates = self
223 .workspaces
224 .iter()
225 .enumerate()
226 .filter(|(_, (id, _))| !self.is_current_workspace(*id, cx))
227 .map(|(id, (_, location))| {
228 let combined_string = location
229 .sorted_paths()
230 .iter()
231 .map(|path| path.compact().to_string_lossy().into_owned())
232 .collect::<Vec<_>>()
233 .join("");
234
235 StringMatchCandidate::new(id, &combined_string)
236 })
237 .collect::<Vec<_>>();
238 self.matches = smol::block_on(fuzzy::match_strings(
239 candidates.as_slice(),
240 query,
241 smart_case,
242 100,
243 &Default::default(),
244 cx.background_executor().clone(),
245 ));
246 self.matches.sort_unstable_by_key(|m| m.candidate_id);
247
248 if self.reset_selected_match_index {
249 self.selected_match_index = self
250 .matches
251 .iter()
252 .enumerate()
253 .rev()
254 .max_by_key(|(_, m)| OrderedFloat(m.score))
255 .map(|(ix, _)| ix)
256 .unwrap_or(0);
257 }
258 self.reset_selected_match_index = true;
259 Task::ready(())
260 }
261
262 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
263 if let Some((selected_match, workspace)) = self
264 .matches
265 .get(self.selected_index())
266 .zip(self.workspace.upgrade())
267 {
268 let (candidate_workspace_id, candidate_workspace_location) =
269 &self.workspaces[selected_match.candidate_id];
270 let replace_current_window = if self.create_new_window {
271 secondary
272 } else {
273 !secondary
274 };
275 workspace
276 .update(cx, |workspace, cx| {
277 if workspace.database_id() == Some(*candidate_workspace_id) {
278 Task::ready(Ok(()))
279 } else {
280 match candidate_workspace_location {
281 SerializedWorkspaceLocation::Local(paths, _) => {
282 let paths = paths.paths().to_vec();
283 if replace_current_window {
284 cx.spawn_in(window, async move |workspace, cx| {
285 let continue_replacing = workspace
286 .update_in(cx, |workspace, window, cx| {
287 workspace.prepare_to_close(
288 CloseIntent::ReplaceWindow,
289 window,
290 cx,
291 )
292 })?
293 .await?;
294 if continue_replacing {
295 workspace
296 .update_in(cx, |workspace, window, cx| {
297 workspace.open_workspace_for_paths(
298 true, paths, window, cx,
299 )
300 })?
301 .await
302 } else {
303 Ok(())
304 }
305 })
306 } else {
307 workspace.open_workspace_for_paths(false, paths, window, cx)
308 }
309 }
310 SerializedWorkspaceLocation::Ssh(ssh_project) => {
311 let app_state = workspace.app_state().clone();
312
313 let replace_window = if replace_current_window {
314 window.window_handle().downcast::<Workspace>()
315 } else {
316 None
317 };
318
319 let open_options = OpenOptions {
320 replace_window,
321 ..Default::default()
322 };
323
324 let connection_options = SshSettings::get_global(cx)
325 .connection_options_for(
326 ssh_project.host.clone(),
327 ssh_project.port,
328 ssh_project.user.clone(),
329 );
330
331 let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
332
333 cx.spawn_in(window, async move |_, cx| {
334 open_ssh_project(
335 connection_options,
336 paths,
337 app_state,
338 open_options,
339 cx,
340 )
341 .await
342 })
343 }
344 }
345 }
346 })
347 .detach_and_log_err(cx);
348 cx.emit(DismissEvent);
349 }
350 }
351
352 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
353
354 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
355 let text = if self.workspaces.is_empty() {
356 "Recently opened projects will show up here".into()
357 } else {
358 "No matches".into()
359 };
360 Some(text)
361 }
362
363 fn render_match(
364 &self,
365 ix: usize,
366 selected: bool,
367 window: &mut Window,
368 cx: &mut Context<Picker<Self>>,
369 ) -> Option<Self::ListItem> {
370 let hit = self.matches.get(ix)?;
371
372 let (_, location) = self.workspaces.get(hit.candidate_id)?;
373
374 let mut path_start_offset = 0;
375
376 let (match_labels, paths): (Vec<_>, Vec<_>) = location
377 .sorted_paths()
378 .iter()
379 .map(|p| p.compact())
380 .map(|path| {
381 let highlighted_text =
382 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
383
384 path_start_offset += highlighted_text.1.char_count;
385 highlighted_text
386 })
387 .unzip();
388
389 let highlighted_match = HighlightedMatchWithPaths {
390 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
391 paths,
392 };
393
394 Some(
395 ListItem::new(ix)
396 .toggle_state(selected)
397 .inset(true)
398 .spacing(ListItemSpacing::Sparse)
399 .child(
400 h_flex()
401 .flex_grow()
402 .gap_3()
403 .when(self.has_any_non_local_projects, |this| {
404 this.child(match location {
405 SerializedWorkspaceLocation::Local(_, _) => {
406 Icon::new(IconName::Screen)
407 .color(Color::Muted)
408 .into_any_element()
409 }
410 SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
411 .color(Color::Muted)
412 .into_any_element(),
413 })
414 })
415 .child({
416 let mut highlighted = highlighted_match.clone();
417 if !self.render_paths {
418 highlighted.paths.clear();
419 }
420 highlighted.render(window, cx)
421 }),
422 )
423 .map(|el| {
424 let delete_button = div()
425 .child(
426 IconButton::new("delete", IconName::Close)
427 .icon_size(IconSize::Small)
428 .on_click(cx.listener(move |this, _event, window, cx| {
429 cx.stop_propagation();
430 window.prevent_default();
431
432 this.delegate.delete_recent_project(ix, window, cx)
433 }))
434 .tooltip(Tooltip::text("Delete from Recent Projects...")),
435 )
436 .into_any_element();
437
438 if self.selected_index() == ix {
439 el.end_slot::<AnyElement>(delete_button)
440 } else {
441 el.end_hover_slot::<AnyElement>(delete_button)
442 }
443 })
444 .tooltip(move |_, cx| {
445 let tooltip_highlighted_location = highlighted_match.clone();
446 cx.new(|_| MatchTooltip {
447 highlighted_location: tooltip_highlighted_location,
448 })
449 .into()
450 }),
451 )
452 }
453
454 fn render_footer(
455 &self,
456 window: &mut Window,
457 cx: &mut Context<Picker<Self>>,
458 ) -> Option<AnyElement> {
459 Some(
460 h_flex()
461 .w_full()
462 .p_2()
463 .gap_2()
464 .justify_end()
465 .border_t_1()
466 .border_color(cx.theme().colors().border_variant)
467 .child(
468 Button::new("remote", "Open Remote Folder")
469 .key_binding(KeyBinding::for_action(&OpenRemote, window, cx))
470 .on_click(|_, window, cx| {
471 window.dispatch_action(OpenRemote.boxed_clone(), cx)
472 }),
473 )
474 .child(
475 Button::new("local", "Open Local Folder")
476 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
477 .on_click(|_, window, cx| {
478 window.dispatch_action(workspace::Open.boxed_clone(), cx)
479 }),
480 )
481 .into_any(),
482 )
483 }
484}
485
486// Compute the highlighted text for the name and path
487fn highlights_for_path(
488 path: &Path,
489 match_positions: &Vec<usize>,
490 path_start_offset: usize,
491) -> (Option<HighlightedMatch>, HighlightedMatch) {
492 let path_string = path.to_string_lossy();
493 let path_char_count = path_string.chars().count();
494 // Get the subset of match highlight positions that line up with the given path.
495 // Also adjusts them to start at the path start
496 let path_positions = match_positions
497 .iter()
498 .copied()
499 .skip_while(|position| *position < path_start_offset)
500 .take_while(|position| *position < path_start_offset + path_char_count)
501 .map(|position| position - path_start_offset)
502 .collect::<Vec<_>>();
503
504 // Again subset the highlight positions to just those that line up with the file_name
505 // again adjusted to the start of the file_name
506 let file_name_text_and_positions = path.file_name().map(|file_name| {
507 let text = file_name.to_string_lossy();
508 let char_count = text.chars().count();
509 let file_name_start = path_char_count - char_count;
510 let highlight_positions = path_positions
511 .iter()
512 .copied()
513 .skip_while(|position| *position < file_name_start)
514 .take_while(|position| *position < file_name_start + char_count)
515 .map(|position| position - file_name_start)
516 .collect::<Vec<_>>();
517 HighlightedMatch {
518 text: text.to_string(),
519 highlight_positions,
520 char_count,
521 color: Color::Default,
522 }
523 });
524
525 (
526 file_name_text_and_positions,
527 HighlightedMatch {
528 text: path_string.to_string(),
529 highlight_positions: path_positions,
530 char_count: path_char_count,
531 color: Color::Default,
532 },
533 )
534}
535impl RecentProjectsDelegate {
536 fn delete_recent_project(
537 &self,
538 ix: usize,
539 window: &mut Window,
540 cx: &mut Context<Picker<Self>>,
541 ) {
542 if let Some(selected_match) = self.matches.get(ix) {
543 let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
544 cx.spawn_in(window, async move |this, cx| {
545 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
546 let workspaces = WORKSPACE_DB
547 .recent_workspaces_on_disk()
548 .await
549 .unwrap_or_default();
550 this.update_in(cx, move |picker, window, cx| {
551 picker.delegate.set_workspaces(workspaces);
552 picker
553 .delegate
554 .set_selected_index(ix.saturating_sub(1), window, cx);
555 picker.delegate.reset_selected_match_index = false;
556 picker.update_matches(picker.query(cx), window, cx);
557 // After deleting a project, we want to update the history manager to reflect the change.
558 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
559 if let Some(history_manager) = HistoryManager::global(cx) {
560 history_manager
561 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
562 }
563 })
564 })
565 .detach();
566 }
567 }
568
569 fn is_current_workspace(
570 &self,
571 workspace_id: WorkspaceId,
572 cx: &mut Context<Picker<Self>>,
573 ) -> bool {
574 if let Some(workspace) = self.workspace.upgrade() {
575 let workspace = workspace.read(cx);
576 if Some(workspace_id) == workspace.database_id() {
577 return true;
578 }
579 }
580
581 false
582 }
583}
584struct MatchTooltip {
585 highlighted_location: HighlightedMatchWithPaths,
586}
587
588impl Render for MatchTooltip {
589 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
590 tooltip_container(window, cx, |div, _, _| {
591 self.highlighted_location.render_paths_children(div)
592 })
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use std::path::PathBuf;
599
600 use dap::debugger_settings::DebuggerSettings;
601 use editor::Editor;
602 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
603 use project::{Project, project_settings::ProjectSettings};
604 use serde_json::json;
605 use settings::SettingsStore;
606 use util::path;
607 use workspace::{AppState, open_paths};
608
609 use super::*;
610
611 #[gpui::test]
612 async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
613 let app_state = init_test(cx);
614
615 cx.update(|cx| {
616 SettingsStore::update_global(cx, |store, cx| {
617 store.update_user_settings::<ProjectSettings>(cx, |settings| {
618 settings.session.restore_unsaved_buffers = false
619 });
620 });
621 });
622
623 app_state
624 .fs
625 .as_fake()
626 .insert_tree(
627 path!("/dir"),
628 json!({
629 "main.ts": "a"
630 }),
631 )
632 .await;
633 cx.update(|cx| {
634 open_paths(
635 &[PathBuf::from(path!("/dir/main.ts"))],
636 app_state,
637 workspace::OpenOptions::default(),
638 cx,
639 )
640 })
641 .await
642 .unwrap();
643 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
644
645 let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
646 workspace
647 .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
648 .unwrap();
649
650 let editor = workspace
651 .read_with(cx, |workspace, cx| {
652 workspace
653 .active_item(cx)
654 .unwrap()
655 .downcast::<Editor>()
656 .unwrap()
657 })
658 .unwrap();
659 workspace
660 .update(cx, |_, window, cx| {
661 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
662 })
663 .unwrap();
664 workspace
665 .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
666 .unwrap();
667
668 let recent_projects_picker = open_recent_projects(&workspace, cx);
669 workspace
670 .update(cx, |_, _, cx| {
671 recent_projects_picker.update(cx, |picker, cx| {
672 assert_eq!(picker.query(cx), "");
673 let delegate = &mut picker.delegate;
674 delegate.matches = vec![StringMatch {
675 candidate_id: 0,
676 score: 1.0,
677 positions: Vec::new(),
678 string: "fake candidate".to_string(),
679 }];
680 delegate.set_workspaces(vec![(
681 WorkspaceId::default(),
682 SerializedWorkspaceLocation::from_local_paths(vec![path!("/test/path/")]),
683 )]);
684 });
685 })
686 .unwrap();
687
688 assert!(
689 !cx.has_pending_prompt(),
690 "Should have no pending prompt on dirty project before opening the new recent project"
691 );
692 cx.dispatch_action(*workspace, menu::Confirm);
693 workspace
694 .update(cx, |workspace, _, cx| {
695 assert!(
696 workspace.active_modal::<RecentProjects>(cx).is_none(),
697 "Should remove the modal after selecting new recent project"
698 )
699 })
700 .unwrap();
701 assert!(
702 cx.has_pending_prompt(),
703 "Dirty workspace should prompt before opening the new recent project"
704 );
705 cx.simulate_prompt_answer("Cancel");
706 assert!(
707 !cx.has_pending_prompt(),
708 "Should have no pending prompt after cancelling"
709 );
710 workspace
711 .update(cx, |workspace, _, _| {
712 assert!(
713 workspace.is_edited(),
714 "Should be in the same dirty project after cancelling"
715 )
716 })
717 .unwrap();
718 }
719
720 fn open_recent_projects(
721 workspace: &WindowHandle<Workspace>,
722 cx: &mut TestAppContext,
723 ) -> Entity<Picker<RecentProjectsDelegate>> {
724 cx.dispatch_action(
725 (*workspace).into(),
726 OpenRecent {
727 create_new_window: false,
728 },
729 );
730 workspace
731 .update(cx, |workspace, _, cx| {
732 workspace
733 .active_modal::<RecentProjects>(cx)
734 .unwrap()
735 .read(cx)
736 .picker
737 .clone()
738 })
739 .unwrap()
740 }
741
742 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
743 cx.update(|cx| {
744 let state = AppState::test(cx);
745 language::init(cx);
746 crate::init(cx);
747 editor::init(cx);
748 workspace::init_settings(cx);
749 DebuggerSettings::register(cx);
750 Project::init_settings(cx);
751 state
752 })
753 }
754}