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