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