1use crate::{
2 remote_connections::{
3 Connection, RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettingsContent,
4 SshConnection, SshConnectionHeader, SshProject, SshSettings, connect, connect_over_ssh,
5 open_remote_project,
6 },
7 ssh_config::parse_ssh_config_hosts,
8};
9use editor::Editor;
10use file_finder::OpenPathDelegate;
11use futures::{FutureExt, channel::oneshot, future::Shared, select};
12use gpui::{
13 AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter,
14 FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window,
15 canvas,
16};
17use log::info;
18use paths::{global_ssh_config_file, user_ssh_config_file};
19use picker::Picker;
20use project::{Fs, Project};
21use remote::{
22 RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
23 remote_client::ConnectionIdentifier,
24};
25use settings::{Settings, SettingsStore, update_settings_file, watch_config_file};
26use smol::stream::StreamExt as _;
27use std::{
28 borrow::Cow,
29 collections::BTreeSet,
30 path::PathBuf,
31 rc::Rc,
32 sync::{
33 Arc,
34 atomic::{self, AtomicUsize},
35 },
36};
37use ui::{
38 IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
39 Section, Tooltip, WithScrollbar, prelude::*,
40};
41use util::{
42 ResultExt,
43 paths::{PathStyle, RemotePathBuf},
44};
45use workspace::{
46 ModalView, OpenOptions, Toast, Workspace,
47 notifications::{DetachAndPromptErr, NotificationId},
48 open_remote_project_with_existing_connection,
49};
50
51pub struct RemoteServerProjects {
52 mode: Mode,
53 focus_handle: FocusHandle,
54 workspace: WeakEntity<Workspace>,
55 retained_connections: Vec<Entity<RemoteClient>>,
56 ssh_config_updates: Task<()>,
57 ssh_config_servers: BTreeSet<SharedString>,
58 create_new_window: bool,
59 _subscription: Subscription,
60}
61
62struct CreateRemoteServer {
63 address_editor: Entity<Editor>,
64 address_error: Option<SharedString>,
65 ssh_prompt: Option<Entity<RemoteConnectionPrompt>>,
66 _creating: Option<Task<Option<()>>>,
67}
68
69impl CreateRemoteServer {
70 fn new(window: &mut Window, cx: &mut App) -> Self {
71 let address_editor = cx.new(|cx| Editor::single_line(window, cx));
72 address_editor.update(cx, |this, cx| {
73 this.focus_handle(cx).focus(window);
74 });
75 Self {
76 address_editor,
77 address_error: None,
78 ssh_prompt: None,
79 _creating: None,
80 }
81 }
82}
83
84#[cfg(target_os = "windows")]
85struct AddWslDistro {
86 picker: Entity<Picker<WslPickerDelegate>>,
87 connection_prompt: Option<Entity<RemoteConnectionPrompt>>,
88 _creating: Option<Task<()>>,
89}
90
91#[cfg(target_os = "windows")]
92impl AddWslDistro {
93 fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
94 let delegate = WslPickerDelegate::new();
95 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
96
97 cx.subscribe_in(
98 &picker,
99 window,
100 |this, _, _: &WslDistroSelected, window, cx| {
101 this.confirm(&menu::Confirm, window, cx);
102 },
103 )
104 .detach();
105
106 cx.subscribe_in(
107 &picker,
108 window,
109 |this, _, _: &WslPickerDismissed, window, cx| {
110 this.cancel(&menu::Cancel, window, cx);
111 },
112 )
113 .detach();
114
115 AddWslDistro {
116 picker,
117 connection_prompt: None,
118 _creating: None,
119 }
120 }
121}
122
123#[cfg(target_os = "windows")]
124#[derive(Clone, Debug)]
125pub struct WslDistroSelected(pub String);
126
127#[cfg(target_os = "windows")]
128#[derive(Clone, Debug)]
129pub struct WslPickerDismissed;
130
131#[cfg(target_os = "windows")]
132struct WslPickerDelegate {
133 selected_index: usize,
134 distro_list: Option<Vec<String>>,
135 matches: Vec<fuzzy::StringMatch>,
136}
137
138#[cfg(target_os = "windows")]
139impl WslPickerDelegate {
140 fn new() -> Self {
141 WslPickerDelegate {
142 selected_index: 0,
143 distro_list: None,
144 matches: Vec::new(),
145 }
146 }
147
148 pub fn selected_distro(&self) -> Option<String> {
149 self.matches
150 .get(self.selected_index)
151 .map(|m| m.string.clone())
152 }
153}
154
155#[cfg(target_os = "windows")]
156impl WslPickerDelegate {
157 fn fetch_distros() -> anyhow::Result<Vec<String>> {
158 use anyhow::Context;
159 use windows_registry::CURRENT_USER;
160
161 let lxss_key = CURRENT_USER
162 .open("Software\\Microsoft\\Windows\\CurrentVersion\\Lxss")
163 .context("failed to get lxss wsl key")?;
164
165 let distros = lxss_key
166 .keys()
167 .context("failed to get wsl distros")?
168 .filter_map(|key| {
169 lxss_key
170 .open(&key)
171 .context("failed to open subkey for distro")
172 .log_err()
173 })
174 .filter_map(|distro| distro.get_string("DistributionName").ok())
175 .collect::<Vec<_>>();
176
177 Ok(distros)
178 }
179}
180
181#[cfg(target_os = "windows")]
182impl EventEmitter<WslDistroSelected> for Picker<WslPickerDelegate> {}
183
184#[cfg(target_os = "windows")]
185impl EventEmitter<WslPickerDismissed> for Picker<WslPickerDelegate> {}
186
187#[cfg(target_os = "windows")]
188impl picker::PickerDelegate for WslPickerDelegate {
189 type ListItem = ListItem;
190
191 fn match_count(&self) -> usize {
192 self.matches.len()
193 }
194
195 fn selected_index(&self) -> usize {
196 self.selected_index
197 }
198
199 fn set_selected_index(
200 &mut self,
201 ix: usize,
202 _window: &mut Window,
203 cx: &mut Context<Picker<Self>>,
204 ) {
205 self.selected_index = ix;
206 cx.notify();
207 }
208
209 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
210 Arc::from("Enter WSL distro name")
211 }
212
213 fn update_matches(
214 &mut self,
215 query: String,
216 _window: &mut Window,
217 cx: &mut Context<Picker<Self>>,
218 ) -> Task<()> {
219 use fuzzy::StringMatchCandidate;
220
221 let needs_fetch = self.distro_list.is_none();
222 if needs_fetch {
223 let distros = Self::fetch_distros().log_err();
224 self.distro_list = distros;
225 }
226
227 if let Some(distro_list) = &self.distro_list {
228 use ordered_float::OrderedFloat;
229
230 let candidates = distro_list
231 .iter()
232 .enumerate()
233 .map(|(id, distro)| StringMatchCandidate::new(id, distro))
234 .collect::<Vec<_>>();
235
236 let query = query.trim_start();
237 let smart_case = query.chars().any(|c| c.is_uppercase());
238 self.matches = smol::block_on(fuzzy::match_strings(
239 candidates.as_slice(),
240 query,
241 smart_case,
242 true,
243 100,
244 &Default::default(),
245 cx.background_executor().clone(),
246 ));
247 self.matches.sort_unstable_by_key(|m| m.candidate_id);
248
249 self.selected_index = self
250 .matches
251 .iter()
252 .enumerate()
253 .rev()
254 .max_by_key(|(_, m)| OrderedFloat(m.score))
255 .map(|(index, _)| index)
256 .unwrap_or(0);
257 }
258
259 Task::ready(())
260 }
261
262 fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
263 if let Some(distro) = self.matches.get(self.selected_index) {
264 cx.emit(WslDistroSelected(distro.string.clone()));
265 }
266 }
267
268 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
269 cx.emit(WslPickerDismissed);
270 }
271
272 fn render_match(
273 &self,
274 ix: usize,
275 selected: bool,
276 _: &mut Window,
277 _: &mut Context<Picker<Self>>,
278 ) -> Option<Self::ListItem> {
279 use ui::HighlightedLabel;
280
281 let matched = self.matches.get(ix)?;
282 Some(
283 ListItem::new(ix)
284 .toggle_state(selected)
285 .inset(true)
286 .spacing(ui::ListItemSpacing::Sparse)
287 .child(
288 h_flex()
289 .flex_grow()
290 .gap_3()
291 .child(Icon::new(IconName::Server))
292 .child(v_flex().child(HighlightedLabel::new(
293 matched.string.clone(),
294 matched.positions.clone(),
295 ))),
296 ),
297 )
298 }
299}
300
301enum ProjectPickerData {
302 Ssh {
303 connection_string: SharedString,
304 nickname: Option<SharedString>,
305 },
306 Wsl {
307 distro_name: SharedString,
308 },
309}
310
311struct ProjectPicker {
312 data: ProjectPickerData,
313 picker: Entity<Picker<OpenPathDelegate>>,
314 _path_task: Shared<Task<Option<()>>>,
315}
316
317struct EditNicknameState {
318 index: SshServerIndex,
319 editor: Entity<Editor>,
320}
321
322impl EditNicknameState {
323 fn new(index: SshServerIndex, window: &mut Window, cx: &mut App) -> Self {
324 let this = Self {
325 index,
326 editor: cx.new(|cx| Editor::single_line(window, cx)),
327 };
328 let starting_text = SshSettings::get_global(cx)
329 .ssh_connections()
330 .nth(index.0)
331 .and_then(|state| state.nickname)
332 .filter(|text| !text.is_empty());
333 this.editor.update(cx, |this, cx| {
334 this.set_placeholder_text("Add a nickname for this server", window, cx);
335 if let Some(starting_text) = starting_text {
336 this.set_text(starting_text, window, cx);
337 }
338 });
339 this.editor.focus_handle(cx).focus(window);
340 this
341 }
342}
343
344impl Focusable for ProjectPicker {
345 fn focus_handle(&self, cx: &App) -> FocusHandle {
346 self.picker.focus_handle(cx)
347 }
348}
349
350impl ProjectPicker {
351 fn new(
352 create_new_window: bool,
353 index: ServerIndex,
354 connection: RemoteConnectionOptions,
355 project: Entity<Project>,
356 home_dir: RemotePathBuf,
357 path_style: PathStyle,
358 workspace: WeakEntity<Workspace>,
359 window: &mut Window,
360 cx: &mut Context<RemoteServerProjects>,
361 ) -> Entity<Self> {
362 let (tx, rx) = oneshot::channel();
363 let lister = project::DirectoryLister::Project(project.clone());
364 let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style);
365
366 let picker = cx.new(|cx| {
367 let picker = Picker::uniform_list(delegate, window, cx)
368 .width(rems(34.))
369 .modal(false);
370 picker.set_query(home_dir.to_string(), window, cx);
371 picker
372 });
373
374 let data = match &connection {
375 RemoteConnectionOptions::Ssh(connection) => ProjectPickerData::Ssh {
376 connection_string: connection.connection_string().into(),
377 nickname: connection.nickname.clone().map(|nick| nick.into()),
378 },
379 RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
380 distro_name: connection.distro_name.clone().into(),
381 },
382 };
383 let _path_task = cx
384 .spawn_in(window, {
385 let workspace = workspace;
386 async move |this, cx| {
387 let Ok(Some(paths)) = rx.await else {
388 workspace
389 .update_in(cx, |workspace, window, cx| {
390 let fs = workspace.project().read(cx).fs().clone();
391 let weak = cx.entity().downgrade();
392 workspace.toggle_modal(window, cx, |window, cx| {
393 RemoteServerProjects::new(
394 create_new_window,
395 fs,
396 window,
397 weak,
398 cx,
399 )
400 });
401 })
402 .log_err()?;
403 return None;
404 };
405
406 let app_state = workspace
407 .read_with(cx, |workspace, _| workspace.app_state().clone())
408 .ok()?;
409
410 cx.update(|_, cx| {
411 let fs = app_state.fs.clone();
412 update_settings_file::<SshSettings>(fs, cx, {
413 let paths = paths
414 .iter()
415 .map(|path| path.to_string_lossy().to_string())
416 .collect();
417 move |setting, _| match index {
418 ServerIndex::Ssh(index) => {
419 if let Some(server) = setting
420 .ssh_connections
421 .as_mut()
422 .and_then(|connections| connections.get_mut(index.0))
423 {
424 server.projects.insert(SshProject { paths });
425 };
426 }
427 ServerIndex::Wsl(index) => {
428 if let Some(server) = setting
429 .wsl_connections
430 .as_mut()
431 .and_then(|connections| connections.get_mut(index.0))
432 {
433 server.projects.insert(SshProject { paths });
434 };
435 }
436 }
437 });
438 })
439 .log_err();
440
441 let options = cx
442 .update(|_, cx| (app_state.build_window_options)(None, cx))
443 .log_err()?;
444 let window = cx
445 .open_window(options, |window, cx| {
446 cx.new(|cx| {
447 telemetry::event!("SSH Project Created");
448 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
449 })
450 })
451 .log_err()?;
452
453 open_remote_project_with_existing_connection(
454 connection, project, paths, app_state, window, cx,
455 )
456 .await
457 .log_err();
458
459 this.update(cx, |_, cx| {
460 cx.emit(DismissEvent);
461 })
462 .ok();
463 Some(())
464 }
465 })
466 .shared();
467 cx.new(|_| Self {
468 _path_task,
469 picker,
470 data,
471 })
472 }
473}
474
475impl gpui::Render for ProjectPicker {
476 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
477 v_flex()
478 .child(match &self.data {
479 ProjectPickerData::Ssh {
480 connection_string,
481 nickname,
482 } => SshConnectionHeader {
483 connection_string: connection_string.clone(),
484 paths: Default::default(),
485 nickname: nickname.clone(),
486 }
487 .render(window, cx),
488 ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
489 connection_string: distro_name.clone(),
490 paths: Default::default(),
491 nickname: None,
492 }
493 .render(window, cx),
494 })
495 .child(
496 div()
497 .border_t_1()
498 .border_color(cx.theme().colors().border_variant)
499 .child(self.picker.clone()),
500 )
501 }
502}
503
504#[repr(transparent)]
505#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
506struct SshServerIndex(usize);
507impl std::fmt::Display for SshServerIndex {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 self.0.fmt(f)
510 }
511}
512
513#[repr(transparent)]
514#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
515struct WslServerIndex(usize);
516impl std::fmt::Display for WslServerIndex {
517 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518 self.0.fmt(f)
519 }
520}
521
522#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
523enum ServerIndex {
524 Ssh(SshServerIndex),
525 Wsl(WslServerIndex),
526}
527impl From<SshServerIndex> for ServerIndex {
528 fn from(index: SshServerIndex) -> Self {
529 Self::Ssh(index)
530 }
531}
532impl From<WslServerIndex> for ServerIndex {
533 fn from(index: WslServerIndex) -> Self {
534 Self::Wsl(index)
535 }
536}
537
538#[derive(Clone)]
539enum RemoteEntry {
540 Project {
541 open_folder: NavigableEntry,
542 projects: Vec<(NavigableEntry, SshProject)>,
543 configure: NavigableEntry,
544 connection: Connection,
545 index: ServerIndex,
546 },
547 SshConfig {
548 open_folder: NavigableEntry,
549 host: SharedString,
550 },
551}
552
553impl RemoteEntry {
554 fn is_from_zed(&self) -> bool {
555 matches!(self, Self::Project { .. })
556 }
557
558 fn connection(&self) -> Cow<'_, Connection> {
559 match self {
560 Self::Project { connection, .. } => Cow::Borrowed(connection),
561 Self::SshConfig { host, .. } => Cow::Owned(
562 SshConnection {
563 host: host.clone(),
564 ..SshConnection::default()
565 }
566 .into(),
567 ),
568 }
569 }
570}
571
572#[derive(Clone)]
573struct DefaultState {
574 scroll_handle: ScrollHandle,
575 add_new_server: NavigableEntry,
576 add_new_wsl: NavigableEntry,
577 servers: Vec<RemoteEntry>,
578}
579
580impl DefaultState {
581 fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
582 let handle = ScrollHandle::new();
583 let add_new_server = NavigableEntry::new(&handle, cx);
584 let add_new_wsl = NavigableEntry::new(&handle, cx);
585
586 let ssh_settings = SshSettings::get_global(cx);
587 let read_ssh_config = ssh_settings.read_ssh_config;
588
589 let ssh_servers = ssh_settings
590 .ssh_connections()
591 .enumerate()
592 .map(|(index, connection)| {
593 let open_folder = NavigableEntry::new(&handle, cx);
594 let configure = NavigableEntry::new(&handle, cx);
595 let projects = connection
596 .projects
597 .iter()
598 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
599 .collect();
600 RemoteEntry::Project {
601 open_folder,
602 configure,
603 projects,
604 index: ServerIndex::Ssh(SshServerIndex(index)),
605 connection: connection.into(),
606 }
607 });
608
609 let wsl_servers = ssh_settings
610 .wsl_connections()
611 .enumerate()
612 .map(|(index, connection)| {
613 let open_folder = NavigableEntry::new(&handle, cx);
614 let configure = NavigableEntry::new(&handle, cx);
615 let projects = connection
616 .projects
617 .iter()
618 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
619 .collect();
620 RemoteEntry::Project {
621 open_folder,
622 configure,
623 projects,
624 index: ServerIndex::Wsl(WslServerIndex(index)),
625 connection: connection.into(),
626 }
627 });
628
629 let mut servers = ssh_servers.chain(wsl_servers).collect::<Vec<RemoteEntry>>();
630
631 if read_ssh_config {
632 let mut extra_servers_from_config = ssh_config_servers.clone();
633 for server in &servers {
634 if let RemoteEntry::Project {
635 connection: Connection::Ssh(ssh_options),
636 ..
637 } = server
638 {
639 extra_servers_from_config.remove(&SharedString::new(ssh_options.host.clone()));
640 }
641 }
642 servers.extend(extra_servers_from_config.into_iter().map(|host| {
643 RemoteEntry::SshConfig {
644 open_folder: NavigableEntry::new(&handle, cx),
645 host,
646 }
647 }));
648 }
649
650 Self {
651 scroll_handle: handle,
652 add_new_server,
653 add_new_wsl,
654 servers,
655 }
656 }
657}
658
659#[derive(Clone)]
660enum ViewServerOptionsState {
661 Ssh {
662 connection: SshConnectionOptions,
663 server_index: SshServerIndex,
664 entries: [NavigableEntry; 4],
665 },
666 Wsl {
667 connection: WslConnectionOptions,
668 server_index: WslServerIndex,
669 entries: [NavigableEntry; 2],
670 },
671}
672
673impl ViewServerOptionsState {
674 fn entries(&self) -> &[NavigableEntry] {
675 match self {
676 Self::Ssh { entries, .. } => entries,
677 Self::Wsl { entries, .. } => entries,
678 }
679 }
680}
681
682enum Mode {
683 Default(DefaultState),
684 ViewServerOptions(ViewServerOptionsState),
685 EditNickname(EditNicknameState),
686 ProjectPicker(Entity<ProjectPicker>),
687 CreateRemoteServer(CreateRemoteServer),
688 #[cfg(target_os = "windows")]
689 AddWslDistro(AddWslDistro),
690}
691
692impl Mode {
693 fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
694 Self::Default(DefaultState::new(ssh_config_servers, cx))
695 }
696}
697
698impl RemoteServerProjects {
699 pub fn new(
700 create_new_window: bool,
701 fs: Arc<dyn Fs>,
702 window: &mut Window,
703 workspace: WeakEntity<Workspace>,
704 cx: &mut Context<Self>,
705 ) -> Self {
706 let focus_handle = cx.focus_handle();
707 let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
708 let ssh_config_updates = if read_ssh_config {
709 spawn_ssh_config_watch(fs.clone(), cx)
710 } else {
711 Task::ready(())
712 };
713
714 let mut base_style = window.text_style();
715 base_style.refine(&gpui::TextStyleRefinement {
716 color: Some(cx.theme().colors().editor_foreground),
717 ..Default::default()
718 });
719
720 let _subscription =
721 cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
722 let new_read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
723 if read_ssh_config != new_read_ssh_config {
724 read_ssh_config = new_read_ssh_config;
725 if read_ssh_config {
726 recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx);
727 } else {
728 recent_projects.ssh_config_servers.clear();
729 recent_projects.ssh_config_updates = Task::ready(());
730 }
731 }
732 });
733
734 Self {
735 mode: Mode::default_mode(&BTreeSet::new(), cx),
736 focus_handle,
737 workspace,
738 retained_connections: Vec::new(),
739 ssh_config_updates,
740 ssh_config_servers: BTreeSet::new(),
741 create_new_window,
742 _subscription,
743 }
744 }
745
746 fn project_picker(
747 create_new_window: bool,
748 index: ServerIndex,
749 connection_options: remote::RemoteConnectionOptions,
750 project: Entity<Project>,
751 home_dir: RemotePathBuf,
752 path_style: PathStyle,
753 window: &mut Window,
754 cx: &mut Context<Self>,
755 workspace: WeakEntity<Workspace>,
756 ) -> Self {
757 let fs = project.read(cx).fs().clone();
758 let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
759 this.mode = Mode::ProjectPicker(ProjectPicker::new(
760 create_new_window,
761 index,
762 connection_options,
763 project,
764 home_dir,
765 path_style,
766 workspace,
767 window,
768 cx,
769 ));
770 cx.notify();
771
772 this
773 }
774
775 fn create_ssh_server(
776 &mut self,
777 editor: Entity<Editor>,
778 window: &mut Window,
779 cx: &mut Context<Self>,
780 ) {
781 let input = get_text(&editor, cx);
782 if input.is_empty() {
783 return;
784 }
785
786 let connection_options = match SshConnectionOptions::parse_command_line(&input) {
787 Ok(c) => c,
788 Err(e) => {
789 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
790 address_editor: editor,
791 address_error: Some(format!("could not parse: {:?}", e).into()),
792 ssh_prompt: None,
793 _creating: None,
794 });
795 return;
796 }
797 };
798 let ssh_prompt = cx.new(|cx| {
799 RemoteConnectionPrompt::new(
800 connection_options.connection_string(),
801 connection_options.nickname.clone(),
802 window,
803 cx,
804 )
805 });
806
807 let connection = connect_over_ssh(
808 ConnectionIdentifier::setup(),
809 connection_options.clone(),
810 ssh_prompt.clone(),
811 window,
812 cx,
813 )
814 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
815
816 let address_editor = editor.clone();
817 let creating = cx.spawn_in(window, async move |this, cx| {
818 match connection.await {
819 Some(Some(client)) => this
820 .update_in(cx, |this, window, cx| {
821 info!("ssh server created");
822 telemetry::event!("SSH Server Created");
823 this.retained_connections.push(client);
824 this.add_ssh_server(connection_options, cx);
825 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
826 this.focus_handle(cx).focus(window);
827 cx.notify()
828 })
829 .log_err(),
830 _ => this
831 .update(cx, |this, cx| {
832 address_editor.update(cx, |this, _| {
833 this.set_read_only(false);
834 });
835 this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
836 address_editor,
837 address_error: None,
838 ssh_prompt: None,
839 _creating: None,
840 });
841 cx.notify()
842 })
843 .log_err(),
844 };
845 None
846 });
847
848 editor.update(cx, |this, _| {
849 this.set_read_only(true);
850 });
851 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
852 address_editor: editor,
853 address_error: None,
854 ssh_prompt: Some(ssh_prompt),
855 _creating: Some(creating),
856 });
857 }
858
859 #[cfg(target_os = "windows")]
860 fn connect_wsl_distro(
861 &mut self,
862 picker: Entity<Picker<WslPickerDelegate>>,
863 distro: String,
864 window: &mut Window,
865 cx: &mut Context<Self>,
866 ) {
867 let connection_options = WslConnectionOptions {
868 distro_name: distro,
869 user: None,
870 };
871
872 let prompt = cx.new(|cx| {
873 RemoteConnectionPrompt::new(connection_options.distro_name.clone(), None, window, cx)
874 });
875 let connection = connect(
876 ConnectionIdentifier::setup(),
877 connection_options.clone().into(),
878 prompt.clone(),
879 window,
880 cx,
881 )
882 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
883
884 let wsl_picker = picker.clone();
885 let creating = cx.spawn_in(window, async move |this, cx| {
886 match connection.await {
887 Some(Some(client)) => this
888 .update_in(cx, |this, window, cx| {
889 telemetry::event!("WSL Distro Added");
890 this.retained_connections.push(client);
891 this.add_wsl_distro(connection_options, cx);
892 this.mode = Mode::default_mode(&BTreeSet::new(), cx);
893 this.focus_handle(cx).focus(window);
894 cx.notify()
895 })
896 .log_err(),
897 _ => this
898 .update(cx, |this, cx| {
899 this.mode = Mode::AddWslDistro(AddWslDistro {
900 picker: wsl_picker,
901 connection_prompt: None,
902 _creating: None,
903 });
904 cx.notify()
905 })
906 .log_err(),
907 };
908 ()
909 });
910
911 self.mode = Mode::AddWslDistro(AddWslDistro {
912 picker,
913 connection_prompt: Some(prompt),
914 _creating: Some(creating),
915 });
916 }
917
918 fn view_server_options(
919 &mut self,
920 (server_index, connection): (ServerIndex, RemoteConnectionOptions),
921 window: &mut Window,
922 cx: &mut Context<Self>,
923 ) {
924 self.mode = Mode::ViewServerOptions(match (server_index, connection) {
925 (ServerIndex::Ssh(server_index), RemoteConnectionOptions::Ssh(connection)) => {
926 ViewServerOptionsState::Ssh {
927 connection,
928 server_index,
929 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
930 }
931 }
932 (ServerIndex::Wsl(server_index), RemoteConnectionOptions::Wsl(connection)) => {
933 ViewServerOptionsState::Wsl {
934 connection,
935 server_index,
936 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
937 }
938 }
939 _ => {
940 log::error!("server index and connection options mismatch");
941 self.mode = Mode::default_mode(&BTreeSet::default(), cx);
942 return;
943 }
944 });
945 self.focus_handle(cx).focus(window);
946 cx.notify();
947 }
948
949 fn create_remote_project(
950 &mut self,
951 index: ServerIndex,
952 connection_options: RemoteConnectionOptions,
953 window: &mut Window,
954 cx: &mut Context<Self>,
955 ) {
956 let Some(workspace) = self.workspace.upgrade() else {
957 return;
958 };
959
960 let create_new_window = self.create_new_window;
961 workspace.update(cx, |_, cx| {
962 cx.defer_in(window, move |workspace, window, cx| {
963 let app_state = workspace.app_state().clone();
964 workspace.toggle_modal(window, cx, |window, cx| {
965 RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
966 });
967 let prompt = workspace
968 .active_modal::<RemoteConnectionModal>(cx)
969 .unwrap()
970 .read(cx)
971 .prompt
972 .clone();
973
974 let connect = connect(
975 ConnectionIdentifier::setup(),
976 connection_options.clone(),
977 prompt,
978 window,
979 cx,
980 )
981 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
982
983 cx.spawn_in(window, async move |workspace, cx| {
984 let session = connect.await;
985
986 workspace.update(cx, |workspace, cx| {
987 if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
988 prompt.update(cx, |prompt, cx| prompt.finished(cx))
989 }
990 })?;
991
992 let Some(Some(session)) = session else {
993 return workspace.update_in(cx, |workspace, window, cx| {
994 let weak = cx.entity().downgrade();
995 let fs = workspace.project().read(cx).fs().clone();
996 workspace.toggle_modal(window, cx, |window, cx| {
997 RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
998 });
999 });
1000 };
1001
1002 let (path_style, project) = cx.update(|_, cx| {
1003 (
1004 session.read(cx).path_style(),
1005 project::Project::remote(
1006 session,
1007 app_state.client.clone(),
1008 app_state.node_runtime.clone(),
1009 app_state.user_store.clone(),
1010 app_state.languages.clone(),
1011 app_state.fs.clone(),
1012 cx,
1013 ),
1014 )
1015 })?;
1016
1017 let home_dir = project
1018 .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
1019 .await
1020 .and_then(|path| path.into_abs_path())
1021 .map(|path| RemotePathBuf::new(path, path_style))
1022 .unwrap_or_else(|| match path_style {
1023 PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
1024 PathStyle::Windows => {
1025 RemotePathBuf::from_str("C:\\", PathStyle::Windows)
1026 }
1027 });
1028
1029 workspace
1030 .update_in(cx, |workspace, window, cx| {
1031 let weak = cx.entity().downgrade();
1032 workspace.toggle_modal(window, cx, |window, cx| {
1033 RemoteServerProjects::project_picker(
1034 create_new_window,
1035 index,
1036 connection_options,
1037 project,
1038 home_dir,
1039 path_style,
1040 window,
1041 cx,
1042 weak,
1043 )
1044 });
1045 })
1046 .ok();
1047 Ok(())
1048 })
1049 .detach();
1050 })
1051 })
1052 }
1053
1054 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1055 match &self.mode {
1056 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
1057 Mode::ProjectPicker(_) => {}
1058 Mode::CreateRemoteServer(state) => {
1059 if let Some(prompt) = state.ssh_prompt.as_ref() {
1060 prompt.update(cx, |prompt, cx| {
1061 prompt.confirm(window, cx);
1062 });
1063 return;
1064 }
1065
1066 self.create_ssh_server(state.address_editor.clone(), window, cx);
1067 }
1068 Mode::EditNickname(state) => {
1069 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
1070 let index = state.index;
1071 self.update_settings_file(cx, move |setting, _| {
1072 if let Some(connections) = setting.ssh_connections.as_mut()
1073 && let Some(connection) = connections.get_mut(index.0)
1074 {
1075 connection.nickname = text;
1076 }
1077 });
1078 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1079 self.focus_handle.focus(window);
1080 }
1081 #[cfg(target_os = "windows")]
1082 Mode::AddWslDistro(state) => {
1083 let delegate = &state.picker.read(cx).delegate;
1084 let distro = delegate.selected_distro().unwrap();
1085 self.connect_wsl_distro(state.picker.clone(), distro, window, cx);
1086 }
1087 }
1088 }
1089
1090 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1091 match &self.mode {
1092 Mode::Default(_) => cx.emit(DismissEvent),
1093 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
1094 let new_state = CreateRemoteServer::new(window, cx);
1095 let old_prompt = state.address_editor.read(cx).text(cx);
1096 new_state.address_editor.update(cx, |this, cx| {
1097 this.set_text(old_prompt, window, cx);
1098 });
1099
1100 self.mode = Mode::CreateRemoteServer(new_state);
1101 cx.notify();
1102 }
1103 _ => {
1104 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1105 self.focus_handle(cx).focus(window);
1106 cx.notify();
1107 }
1108 }
1109 }
1110
1111 fn render_ssh_connection(
1112 &mut self,
1113 ix: usize,
1114 ssh_server: RemoteEntry,
1115 window: &mut Window,
1116 cx: &mut Context<Self>,
1117 ) -> impl IntoElement {
1118 let connection = ssh_server.connection().into_owned();
1119
1120 let (main_label, aux_label, is_wsl) = match &connection {
1121 Connection::Ssh(connection) => {
1122 if let Some(nickname) = connection.nickname.clone() {
1123 let aux_label = SharedString::from(format!("({})", connection.host));
1124 (nickname.into(), Some(aux_label), false)
1125 } else {
1126 (connection.host.clone(), None, false)
1127 }
1128 }
1129 Connection::Wsl(wsl_connection_options) => {
1130 (wsl_connection_options.distro_name.clone(), None, true)
1131 }
1132 };
1133 v_flex()
1134 .w_full()
1135 .child(ListSeparator)
1136 .child(
1137 h_flex()
1138 .group("ssh-server")
1139 .w_full()
1140 .pt_0p5()
1141 .px_3()
1142 .gap_1()
1143 .overflow_hidden()
1144 .child(
1145 h_flex()
1146 .gap_1()
1147 .max_w_96()
1148 .overflow_hidden()
1149 .text_ellipsis()
1150 .when(is_wsl, |this| {
1151 this.child(
1152 Label::new("WSL:")
1153 .size(LabelSize::Small)
1154 .color(Color::Muted),
1155 )
1156 })
1157 .child(
1158 Label::new(main_label)
1159 .size(LabelSize::Small)
1160 .color(Color::Muted),
1161 ),
1162 )
1163 .children(
1164 aux_label.map(|label| {
1165 Label::new(label).size(LabelSize::Small).color(Color::Muted)
1166 }),
1167 ),
1168 )
1169 .child(match &ssh_server {
1170 RemoteEntry::Project {
1171 open_folder,
1172 projects,
1173 configure,
1174 connection,
1175 index,
1176 } => {
1177 let index = *index;
1178 List::new()
1179 .empty_message("No projects.")
1180 .children(projects.iter().enumerate().map(|(pix, p)| {
1181 v_flex().gap_0p5().child(self.render_ssh_project(
1182 index,
1183 ssh_server.clone(),
1184 pix,
1185 p,
1186 window,
1187 cx,
1188 ))
1189 }))
1190 .child(
1191 h_flex()
1192 .id(("new-remote-project-container", ix))
1193 .track_focus(&open_folder.focus_handle)
1194 .anchor_scroll(open_folder.scroll_anchor.clone())
1195 .on_action(cx.listener({
1196 let connection = connection.clone();
1197 move |this, _: &menu::Confirm, window, cx| {
1198 this.create_remote_project(
1199 index,
1200 connection.clone().into(),
1201 window,
1202 cx,
1203 );
1204 }
1205 }))
1206 .child(
1207 ListItem::new(("new-remote-project", ix))
1208 .toggle_state(
1209 open_folder.focus_handle.contains_focused(window, cx),
1210 )
1211 .inset(true)
1212 .spacing(ui::ListItemSpacing::Sparse)
1213 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1214 .child(Label::new("Open Folder"))
1215 .on_click(cx.listener({
1216 let connection = connection.clone();
1217 move |this, _, window, cx| {
1218 this.create_remote_project(
1219 index,
1220 connection.clone().into(),
1221 window,
1222 cx,
1223 );
1224 }
1225 })),
1226 ),
1227 )
1228 .child(
1229 h_flex()
1230 .id(("server-options-container", ix))
1231 .track_focus(&configure.focus_handle)
1232 .anchor_scroll(configure.scroll_anchor.clone())
1233 .on_action(cx.listener({
1234 let connection = connection.clone();
1235 move |this, _: &menu::Confirm, window, cx| {
1236 this.view_server_options(
1237 (index, connection.clone().into()),
1238 window,
1239 cx,
1240 );
1241 }
1242 }))
1243 .child(
1244 ListItem::new(("server-options", ix))
1245 .toggle_state(
1246 configure.focus_handle.contains_focused(window, cx),
1247 )
1248 .inset(true)
1249 .spacing(ui::ListItemSpacing::Sparse)
1250 .start_slot(
1251 Icon::new(IconName::Settings).color(Color::Muted),
1252 )
1253 .child(Label::new("View Server Options"))
1254 .on_click(cx.listener({
1255 let ssh_connection = connection.clone();
1256 move |this, _, window, cx| {
1257 this.view_server_options(
1258 (index, ssh_connection.clone().into()),
1259 window,
1260 cx,
1261 );
1262 }
1263 })),
1264 ),
1265 )
1266 }
1267 RemoteEntry::SshConfig { open_folder, host } => List::new().child(
1268 h_flex()
1269 .id(("new-remote-project-container", ix))
1270 .track_focus(&open_folder.focus_handle)
1271 .anchor_scroll(open_folder.scroll_anchor.clone())
1272 .on_action(cx.listener({
1273 let connection = connection.clone();
1274 let host = host.clone();
1275 move |this, _: &menu::Confirm, window, cx| {
1276 let new_ix = this.create_host_from_ssh_config(&host, cx);
1277 this.create_remote_project(
1278 new_ix.into(),
1279 connection.clone().into(),
1280 window,
1281 cx,
1282 );
1283 }
1284 }))
1285 .child(
1286 ListItem::new(("new-remote-project", ix))
1287 .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
1288 .inset(true)
1289 .spacing(ui::ListItemSpacing::Sparse)
1290 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1291 .child(Label::new("Open Folder"))
1292 .on_click(cx.listener({
1293 let host = host.clone();
1294 move |this, _, window, cx| {
1295 let new_ix = this.create_host_from_ssh_config(&host, cx);
1296 this.create_remote_project(
1297 new_ix.into(),
1298 connection.clone().into(),
1299 window,
1300 cx,
1301 );
1302 }
1303 })),
1304 ),
1305 ),
1306 })
1307 }
1308
1309 fn render_ssh_project(
1310 &mut self,
1311 server_ix: ServerIndex,
1312 server: RemoteEntry,
1313 ix: usize,
1314 (navigation, project): &(NavigableEntry, SshProject),
1315 window: &mut Window,
1316 cx: &mut Context<Self>,
1317 ) -> impl IntoElement {
1318 let create_new_window = self.create_new_window;
1319 let is_from_zed = server.is_from_zed();
1320 let element_id_base = SharedString::from(format!(
1321 "remote-project-{}",
1322 match server_ix {
1323 ServerIndex::Ssh(index) => format!("ssh-{index}"),
1324 ServerIndex::Wsl(index) => format!("wsl-{index}"),
1325 }
1326 ));
1327 let container_element_id_base =
1328 SharedString::from(format!("remote-project-container-{element_id_base}"));
1329
1330 let callback = Rc::new({
1331 let project = project.clone();
1332 move |remote_server_projects: &mut Self,
1333 secondary_confirm: bool,
1334 window: &mut Window,
1335 cx: &mut Context<Self>| {
1336 let Some(app_state) = remote_server_projects
1337 .workspace
1338 .read_with(cx, |workspace, _| workspace.app_state().clone())
1339 .log_err()
1340 else {
1341 return;
1342 };
1343 let project = project.clone();
1344 let server = server.connection().into_owned();
1345 cx.emit(DismissEvent);
1346
1347 let replace_window = match (create_new_window, secondary_confirm) {
1348 (true, false) | (false, true) => None,
1349 (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
1350 };
1351
1352 cx.spawn_in(window, async move |_, cx| {
1353 let result = open_remote_project(
1354 server.into(),
1355 project.paths.into_iter().map(PathBuf::from).collect(),
1356 app_state,
1357 OpenOptions {
1358 replace_window,
1359 ..OpenOptions::default()
1360 },
1361 cx,
1362 )
1363 .await;
1364 if let Err(e) = result {
1365 log::error!("Failed to connect: {e:#}");
1366 cx.prompt(
1367 gpui::PromptLevel::Critical,
1368 "Failed to connect",
1369 Some(&e.to_string()),
1370 &["Ok"],
1371 )
1372 .await
1373 .ok();
1374 }
1375 })
1376 .detach();
1377 }
1378 });
1379
1380 div()
1381 .id((container_element_id_base, ix))
1382 .track_focus(&navigation.focus_handle)
1383 .anchor_scroll(navigation.scroll_anchor.clone())
1384 .on_action(cx.listener({
1385 let callback = callback.clone();
1386 move |this, _: &menu::Confirm, window, cx| {
1387 callback(this, false, window, cx);
1388 }
1389 }))
1390 .on_action(cx.listener({
1391 let callback = callback.clone();
1392 move |this, _: &menu::SecondaryConfirm, window, cx| {
1393 callback(this, true, window, cx);
1394 }
1395 }))
1396 .child(
1397 ListItem::new((element_id_base, ix))
1398 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
1399 .inset(true)
1400 .spacing(ui::ListItemSpacing::Sparse)
1401 .start_slot(
1402 Icon::new(IconName::Folder)
1403 .color(Color::Muted)
1404 .size(IconSize::Small),
1405 )
1406 .child(Label::new(project.paths.join(", ")))
1407 .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
1408 let secondary_confirm = e.modifiers().platform;
1409 callback(this, secondary_confirm, window, cx)
1410 }))
1411 .when(
1412 is_from_zed && matches!(server_ix, ServerIndex::Ssh(_)),
1413 |server_list_item| {
1414 let ServerIndex::Ssh(server_ix) = server_ix else {
1415 unreachable!()
1416 };
1417 server_list_item.end_hover_slot::<AnyElement>(Some(
1418 div()
1419 .mr_2()
1420 .child({
1421 let project = project.clone();
1422 // Right-margin to offset it from the Scrollbar
1423 IconButton::new("remove-remote-project", IconName::Trash)
1424 .icon_size(IconSize::Small)
1425 .shape(IconButtonShape::Square)
1426 .size(ButtonSize::Large)
1427 .tooltip(Tooltip::text("Delete Remote Project"))
1428 .on_click(cx.listener(move |this, _, _, cx| {
1429 this.delete_ssh_project(server_ix, &project, cx)
1430 }))
1431 })
1432 .into_any_element(),
1433 ))
1434 },
1435 ),
1436 )
1437 }
1438
1439 fn update_settings_file(
1440 &mut self,
1441 cx: &mut Context<Self>,
1442 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
1443 ) {
1444 let Some(fs) = self
1445 .workspace
1446 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
1447 .log_err()
1448 else {
1449 return;
1450 };
1451 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
1452 }
1453
1454 fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
1455 self.update_settings_file(cx, move |setting, _| {
1456 if let Some(connections) = setting.ssh_connections.as_mut() {
1457 connections.remove(server.0);
1458 }
1459 });
1460 }
1461
1462 fn delete_ssh_project(
1463 &mut self,
1464 server: SshServerIndex,
1465 project: &SshProject,
1466 cx: &mut Context<Self>,
1467 ) {
1468 let project = project.clone();
1469 self.update_settings_file(cx, move |setting, _| {
1470 if let Some(server) = setting
1471 .ssh_connections
1472 .as_mut()
1473 .and_then(|connections| connections.get_mut(server.0))
1474 {
1475 server.projects.remove(&project);
1476 }
1477 });
1478 }
1479
1480 #[cfg(target_os = "windows")]
1481 fn add_wsl_distro(
1482 &mut self,
1483 connection_options: remote::WslConnectionOptions,
1484 cx: &mut Context<Self>,
1485 ) {
1486 self.update_settings_file(cx, move |setting, _| {
1487 setting
1488 .wsl_connections
1489 .get_or_insert(Default::default())
1490 .push(crate::remote_connections::WslConnection {
1491 distro_name: SharedString::from(connection_options.distro_name),
1492 user: connection_options.user,
1493 projects: BTreeSet::new(),
1494 })
1495 });
1496 }
1497
1498 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1499 self.update_settings_file(cx, move |setting, _| {
1500 if let Some(connections) = setting.wsl_connections.as_mut() {
1501 connections.remove(server.0);
1502 }
1503 });
1504 }
1505
1506 fn add_ssh_server(
1507 &mut self,
1508 connection_options: remote::SshConnectionOptions,
1509 cx: &mut Context<Self>,
1510 ) {
1511 self.update_settings_file(cx, move |setting, _| {
1512 setting
1513 .ssh_connections
1514 .get_or_insert(Default::default())
1515 .push(SshConnection {
1516 host: SharedString::from(connection_options.host),
1517 username: connection_options.username,
1518 port: connection_options.port,
1519 projects: BTreeSet::new(),
1520 nickname: None,
1521 args: connection_options.args.unwrap_or_default(),
1522 upload_binary_over_ssh: None,
1523 port_forwards: connection_options.port_forwards,
1524 })
1525 });
1526 }
1527
1528 fn render_create_remote_server(
1529 &self,
1530 state: &CreateRemoteServer,
1531 window: &mut Window,
1532 cx: &mut Context<Self>,
1533 ) -> impl IntoElement {
1534 let ssh_prompt = state.ssh_prompt.clone();
1535
1536 state.address_editor.update(cx, |editor, cx| {
1537 if editor.text(cx).is_empty() {
1538 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1539 }
1540 });
1541
1542 let theme = cx.theme();
1543
1544 v_flex()
1545 .track_focus(&self.focus_handle(cx))
1546 .id("create-remote-server")
1547 .overflow_hidden()
1548 .size_full()
1549 .flex_1()
1550 .child(
1551 div()
1552 .p_2()
1553 .border_b_1()
1554 .border_color(theme.colors().border_variant)
1555 .child(state.address_editor.clone()),
1556 )
1557 .child(
1558 h_flex()
1559 .bg(theme.colors().editor_background)
1560 .rounded_b_sm()
1561 .w_full()
1562 .map(|this| {
1563 if let Some(ssh_prompt) = ssh_prompt {
1564 this.child(h_flex().w_full().child(ssh_prompt))
1565 } else if let Some(address_error) = &state.address_error {
1566 this.child(
1567 h_flex().p_2().w_full().gap_2().child(
1568 Label::new(address_error.clone())
1569 .size(LabelSize::Small)
1570 .color(Color::Error),
1571 ),
1572 )
1573 } else {
1574 this.child(
1575 h_flex()
1576 .p_2()
1577 .w_full()
1578 .gap_1()
1579 .child(
1580 Label::new(
1581 "Enter the command you use to SSH into this server.",
1582 )
1583 .color(Color::Muted)
1584 .size(LabelSize::Small),
1585 )
1586 .child(
1587 Button::new("learn-more", "Learn More")
1588 .label_size(LabelSize::Small)
1589 .icon(IconName::ArrowUpRight)
1590 .icon_size(IconSize::XSmall)
1591 .on_click(|_, _, cx| {
1592 cx.open_url(
1593 "https://zed.dev/docs/remote-development",
1594 );
1595 }),
1596 ),
1597 )
1598 }
1599 }),
1600 )
1601 }
1602
1603 #[cfg(target_os = "windows")]
1604 fn render_add_wsl_distro(
1605 &self,
1606 state: &AddWslDistro,
1607 window: &mut Window,
1608 cx: &mut Context<Self>,
1609 ) -> impl IntoElement {
1610 let connection_prompt = state.connection_prompt.clone();
1611
1612 state.picker.update(cx, |picker, cx| {
1613 picker.focus_handle(cx).focus(window);
1614 });
1615
1616 v_flex()
1617 .id("add-wsl-distro")
1618 .overflow_hidden()
1619 .size_full()
1620 .flex_1()
1621 .map(|this| {
1622 if let Some(connection_prompt) = connection_prompt {
1623 this.child(connection_prompt)
1624 } else {
1625 this.child(state.picker.clone())
1626 }
1627 })
1628 }
1629
1630 fn render_view_options(
1631 &mut self,
1632 options: ViewServerOptionsState,
1633 window: &mut Window,
1634 cx: &mut Context<Self>,
1635 ) -> impl IntoElement {
1636 let last_entry = options.entries().last().unwrap();
1637
1638 let mut view = Navigable::new(
1639 div()
1640 .track_focus(&self.focus_handle(cx))
1641 .size_full()
1642 .child(match &options {
1643 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
1644 connection_string: connection.host.clone().into(),
1645 paths: Default::default(),
1646 nickname: connection.nickname.clone().map(|s| s.into()),
1647 }
1648 .render(window, cx)
1649 .into_any_element(),
1650 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
1651 connection_string: connection.distro_name.clone().into(),
1652 paths: Default::default(),
1653 nickname: None,
1654 }
1655 .render(window, cx)
1656 .into_any_element(),
1657 })
1658 .child(
1659 v_flex()
1660 .pb_1()
1661 .child(ListSeparator)
1662 .map(|this| match &options {
1663 ViewServerOptionsState::Ssh {
1664 connection,
1665 entries,
1666 server_index,
1667 } => this.child(self.render_edit_ssh(
1668 connection,
1669 *server_index,
1670 entries,
1671 window,
1672 cx,
1673 )),
1674 ViewServerOptionsState::Wsl {
1675 connection,
1676 entries,
1677 server_index,
1678 } => this.child(self.render_edit_wsl(
1679 connection,
1680 *server_index,
1681 entries,
1682 window,
1683 cx,
1684 )),
1685 })
1686 .child(ListSeparator)
1687 .child({
1688 div()
1689 .id("ssh-options-copy-server-address")
1690 .track_focus(&last_entry.focus_handle)
1691 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1692 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1693 cx.focus_self(window);
1694 cx.notify();
1695 }))
1696 .child(
1697 ListItem::new("go-back")
1698 .toggle_state(
1699 last_entry.focus_handle.contains_focused(window, cx),
1700 )
1701 .inset(true)
1702 .spacing(ui::ListItemSpacing::Sparse)
1703 .start_slot(
1704 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1705 )
1706 .child(Label::new("Go Back"))
1707 .on_click(cx.listener(|this, _, window, cx| {
1708 this.mode =
1709 Mode::default_mode(&this.ssh_config_servers, cx);
1710 cx.focus_self(window);
1711 cx.notify()
1712 })),
1713 )
1714 }),
1715 )
1716 .into_any_element(),
1717 );
1718
1719 for entry in options.entries() {
1720 view = view.entry(entry.clone());
1721 }
1722
1723 view.render(window, cx).into_any_element()
1724 }
1725
1726 fn render_edit_wsl(
1727 &self,
1728 connection: &WslConnectionOptions,
1729 index: WslServerIndex,
1730 entries: &[NavigableEntry],
1731 window: &mut Window,
1732 cx: &mut Context<Self>,
1733 ) -> impl IntoElement {
1734 let distro_name = SharedString::new(connection.distro_name.clone());
1735
1736 v_flex().child({
1737 fn remove_wsl_distro(
1738 remote_servers: Entity<RemoteServerProjects>,
1739 index: WslServerIndex,
1740 distro_name: SharedString,
1741 window: &mut Window,
1742 cx: &mut App,
1743 ) {
1744 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
1745
1746 let confirmation = window.prompt(
1747 PromptLevel::Warning,
1748 &prompt_message,
1749 None,
1750 &["Yes, remove it", "No, keep it"],
1751 cx,
1752 );
1753
1754 cx.spawn(async move |cx| {
1755 if confirmation.await.ok() == Some(0) {
1756 remote_servers
1757 .update(cx, |this, cx| {
1758 this.delete_wsl_distro(index, cx);
1759 })
1760 .ok();
1761 remote_servers
1762 .update(cx, |this, cx| {
1763 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1764 cx.notify();
1765 })
1766 .ok();
1767 }
1768 anyhow::Ok(())
1769 })
1770 .detach_and_log_err(cx);
1771 }
1772 div()
1773 .id("wsl-options-remove-distro")
1774 .track_focus(&entries[0].focus_handle)
1775 .on_action(cx.listener({
1776 let distro_name = distro_name.clone();
1777 move |_, _: &menu::Confirm, window, cx| {
1778 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1779 cx.focus_self(window);
1780 }
1781 }))
1782 .child(
1783 ListItem::new("remove-distro")
1784 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1785 .inset(true)
1786 .spacing(ui::ListItemSpacing::Sparse)
1787 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1788 .child(Label::new("Remove Distro").color(Color::Error))
1789 .on_click(cx.listener(move |_, _, window, cx| {
1790 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1791 cx.focus_self(window);
1792 })),
1793 )
1794 })
1795 }
1796
1797 fn render_edit_ssh(
1798 &self,
1799 connection: &SshConnectionOptions,
1800 index: SshServerIndex,
1801 entries: &[NavigableEntry],
1802 window: &mut Window,
1803 cx: &mut Context<Self>,
1804 ) -> impl IntoElement {
1805 let connection_string = SharedString::new(connection.host.clone());
1806
1807 v_flex()
1808 .child({
1809 let label = if connection.nickname.is_some() {
1810 "Edit Nickname"
1811 } else {
1812 "Add Nickname to Server"
1813 };
1814 div()
1815 .id("ssh-options-add-nickname")
1816 .track_focus(&entries[0].focus_handle)
1817 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
1818 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
1819 cx.notify();
1820 }))
1821 .child(
1822 ListItem::new("add-nickname")
1823 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1824 .inset(true)
1825 .spacing(ui::ListItemSpacing::Sparse)
1826 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1827 .child(Label::new(label))
1828 .on_click(cx.listener(move |this, _, window, cx| {
1829 this.mode =
1830 Mode::EditNickname(EditNicknameState::new(index, window, cx));
1831 cx.notify();
1832 })),
1833 )
1834 })
1835 .child({
1836 let workspace = self.workspace.clone();
1837 fn callback(
1838 workspace: WeakEntity<Workspace>,
1839 connection_string: SharedString,
1840 cx: &mut App,
1841 ) {
1842 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
1843 workspace
1844 .update(cx, |this, cx| {
1845 struct SshServerAddressCopiedToClipboard;
1846 let notification = format!(
1847 "Copied server address ({}) to clipboard",
1848 connection_string
1849 );
1850
1851 this.show_toast(
1852 Toast::new(
1853 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
1854 connection_string.clone(),
1855 ),
1856 notification,
1857 )
1858 .autohide(),
1859 cx,
1860 );
1861 })
1862 .ok();
1863 }
1864 div()
1865 .id("ssh-options-copy-server-address")
1866 .track_focus(&entries[1].focus_handle)
1867 .on_action({
1868 let connection_string = connection_string.clone();
1869 let workspace = self.workspace.clone();
1870 move |_: &menu::Confirm, _, cx| {
1871 callback(workspace.clone(), connection_string.clone(), cx);
1872 }
1873 })
1874 .child(
1875 ListItem::new("copy-server-address")
1876 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
1877 .inset(true)
1878 .spacing(ui::ListItemSpacing::Sparse)
1879 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1880 .child(Label::new("Copy Server Address"))
1881 .end_hover_slot(
1882 Label::new(connection_string.clone()).color(Color::Muted),
1883 )
1884 .on_click({
1885 let connection_string = connection_string.clone();
1886 move |_, _, cx| {
1887 callback(workspace.clone(), connection_string.clone(), cx);
1888 }
1889 }),
1890 )
1891 })
1892 .child({
1893 fn remove_ssh_server(
1894 remote_servers: Entity<RemoteServerProjects>,
1895 index: SshServerIndex,
1896 connection_string: SharedString,
1897 window: &mut Window,
1898 cx: &mut App,
1899 ) {
1900 let prompt_message = format!("Remove server `{}`?", connection_string);
1901
1902 let confirmation = window.prompt(
1903 PromptLevel::Warning,
1904 &prompt_message,
1905 None,
1906 &["Yes, remove it", "No, keep it"],
1907 cx,
1908 );
1909
1910 cx.spawn(async move |cx| {
1911 if confirmation.await.ok() == Some(0) {
1912 remote_servers
1913 .update(cx, |this, cx| {
1914 this.delete_ssh_server(index, cx);
1915 })
1916 .ok();
1917 remote_servers
1918 .update(cx, |this, cx| {
1919 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1920 cx.notify();
1921 })
1922 .ok();
1923 }
1924 anyhow::Ok(())
1925 })
1926 .detach_and_log_err(cx);
1927 }
1928 div()
1929 .id("ssh-options-copy-server-address")
1930 .track_focus(&entries[2].focus_handle)
1931 .on_action(cx.listener({
1932 let connection_string = connection_string.clone();
1933 move |_, _: &menu::Confirm, window, cx| {
1934 remove_ssh_server(
1935 cx.entity(),
1936 index,
1937 connection_string.clone(),
1938 window,
1939 cx,
1940 );
1941 cx.focus_self(window);
1942 }
1943 }))
1944 .child(
1945 ListItem::new("remove-server")
1946 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
1947 .inset(true)
1948 .spacing(ui::ListItemSpacing::Sparse)
1949 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1950 .child(Label::new("Remove Server").color(Color::Error))
1951 .on_click(cx.listener(move |_, _, window, cx| {
1952 remove_ssh_server(
1953 cx.entity(),
1954 index,
1955 connection_string.clone(),
1956 window,
1957 cx,
1958 );
1959 cx.focus_self(window);
1960 })),
1961 )
1962 })
1963 }
1964
1965 fn render_edit_nickname(
1966 &self,
1967 state: &EditNicknameState,
1968 window: &mut Window,
1969 cx: &mut Context<Self>,
1970 ) -> impl IntoElement {
1971 let Some(connection) = SshSettings::get_global(cx)
1972 .ssh_connections()
1973 .nth(state.index.0)
1974 else {
1975 return v_flex()
1976 .id("ssh-edit-nickname")
1977 .track_focus(&self.focus_handle(cx));
1978 };
1979
1980 let connection_string = connection.host.clone();
1981 let nickname = connection.nickname.map(|s| s.into());
1982
1983 v_flex()
1984 .id("ssh-edit-nickname")
1985 .track_focus(&self.focus_handle(cx))
1986 .child(
1987 SshConnectionHeader {
1988 connection_string,
1989 paths: Default::default(),
1990 nickname,
1991 }
1992 .render(window, cx),
1993 )
1994 .child(
1995 h_flex()
1996 .p_2()
1997 .border_t_1()
1998 .border_color(cx.theme().colors().border_variant)
1999 .child(state.editor.clone()),
2000 )
2001 }
2002
2003 fn render_default(
2004 &mut self,
2005 mut state: DefaultState,
2006 window: &mut Window,
2007 cx: &mut Context<Self>,
2008 ) -> impl IntoElement {
2009 let ssh_settings = SshSettings::get_global(cx);
2010 let mut should_rebuild = false;
2011
2012 let ssh_connections_changed =
2013 ssh_settings
2014 .ssh_connections
2015 .as_ref()
2016 .is_some_and(|connections| {
2017 state
2018 .servers
2019 .iter()
2020 .filter_map(|server| match server {
2021 RemoteEntry::Project {
2022 connection: Connection::Ssh(connection),
2023 ..
2024 } => Some(connection),
2025 _ => None,
2026 })
2027 .ne(connections.iter())
2028 });
2029
2030 let wsl_connections_changed =
2031 ssh_settings
2032 .wsl_connections
2033 .as_ref()
2034 .is_some_and(|connections| {
2035 state
2036 .servers
2037 .iter()
2038 .filter_map(|server| match server {
2039 RemoteEntry::Project {
2040 connection: Connection::Wsl(connection),
2041 ..
2042 } => Some(connection),
2043 _ => None,
2044 })
2045 .ne(connections.iter())
2046 });
2047
2048 if ssh_connections_changed || wsl_connections_changed {
2049 should_rebuild = true;
2050 };
2051
2052 if !should_rebuild && ssh_settings.read_ssh_config {
2053 let current_ssh_hosts: BTreeSet<SharedString> = state
2054 .servers
2055 .iter()
2056 .filter_map(|server| match server {
2057 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
2058 _ => None,
2059 })
2060 .collect();
2061 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
2062 for server in &state.servers {
2063 if let RemoteEntry::Project {
2064 connection: Connection::Ssh(connection),
2065 ..
2066 } = server
2067 {
2068 expected_ssh_hosts.remove(&connection.host);
2069 }
2070 }
2071 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2072 }
2073
2074 if should_rebuild {
2075 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2076 if let Mode::Default(new_state) = &self.mode {
2077 state = new_state.clone();
2078 }
2079 }
2080
2081 let connect_button = div()
2082 .id("ssh-connect-new-server-container")
2083 .track_focus(&state.add_new_server.focus_handle)
2084 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2085 .child(
2086 ListItem::new("register-remove-server-button")
2087 .toggle_state(
2088 state
2089 .add_new_server
2090 .focus_handle
2091 .contains_focused(window, cx),
2092 )
2093 .inset(true)
2094 .spacing(ui::ListItemSpacing::Sparse)
2095 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2096 .child(Label::new("Connect New Server"))
2097 .on_click(cx.listener(|this, _, window, cx| {
2098 let state = CreateRemoteServer::new(window, cx);
2099 this.mode = Mode::CreateRemoteServer(state);
2100
2101 cx.notify();
2102 })),
2103 )
2104 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2105 let state = CreateRemoteServer::new(window, cx);
2106 this.mode = Mode::CreateRemoteServer(state);
2107
2108 cx.notify();
2109 }));
2110
2111 #[cfg(target_os = "windows")]
2112 let wsl_connect_button = div()
2113 .id("wsl-connect-new-server")
2114 .track_focus(&state.add_new_wsl.focus_handle)
2115 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2116 .child(
2117 ListItem::new("wsl-add-new-server")
2118 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2119 .inset(true)
2120 .spacing(ui::ListItemSpacing::Sparse)
2121 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2122 .child(Label::new("Add WSL Distro"))
2123 .on_click(cx.listener(|this, _, window, cx| {
2124 let state = AddWslDistro::new(window, cx);
2125 this.mode = Mode::AddWslDistro(state);
2126
2127 cx.notify();
2128 })),
2129 )
2130 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2131 let state = AddWslDistro::new(window, cx);
2132 this.mode = Mode::AddWslDistro(state);
2133
2134 cx.notify();
2135 }));
2136
2137 let modal_section = v_flex()
2138 .track_focus(&self.focus_handle(cx))
2139 .id("ssh-server-list")
2140 .overflow_y_scroll()
2141 .track_scroll(&state.scroll_handle)
2142 .size_full()
2143 .child(connect_button);
2144
2145 #[cfg(target_os = "windows")]
2146 let modal_section = modal_section.child(wsl_connect_button);
2147 #[cfg(not(target_os = "windows"))]
2148 let modal_section = modal_section;
2149
2150 let mut modal_section = Navigable::new(
2151 modal_section
2152 .child(
2153 List::new()
2154 .empty_message(
2155 v_flex()
2156 .child(
2157 div().px_3().child(
2158 Label::new("No remote servers registered yet.")
2159 .color(Color::Muted),
2160 ),
2161 )
2162 .into_any_element(),
2163 )
2164 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2165 self.render_ssh_connection(ix, connection.clone(), window, cx)
2166 .into_any_element()
2167 })),
2168 )
2169 .into_any_element(),
2170 )
2171 .entry(state.add_new_server.clone())
2172 .entry(state.add_new_wsl.clone());
2173
2174 for server in &state.servers {
2175 match server {
2176 RemoteEntry::Project {
2177 open_folder,
2178 projects,
2179 configure,
2180 ..
2181 } => {
2182 for (navigation_state, _) in projects {
2183 modal_section = modal_section.entry(navigation_state.clone());
2184 }
2185 modal_section = modal_section
2186 .entry(open_folder.clone())
2187 .entry(configure.clone());
2188 }
2189 RemoteEntry::SshConfig { open_folder, .. } => {
2190 modal_section = modal_section.entry(open_folder.clone());
2191 }
2192 }
2193 }
2194 let mut modal_section = modal_section.render(window, cx).into_any_element();
2195
2196 let (create_window, reuse_window) = if self.create_new_window {
2197 (
2198 window.keystroke_text_for(&menu::Confirm),
2199 window.keystroke_text_for(&menu::SecondaryConfirm),
2200 )
2201 } else {
2202 (
2203 window.keystroke_text_for(&menu::SecondaryConfirm),
2204 window.keystroke_text_for(&menu::Confirm),
2205 )
2206 };
2207 let placeholder_text = Arc::from(format!(
2208 "{reuse_window} reuses this window, {create_window} opens a new one",
2209 ));
2210
2211 Modal::new("remote-projects", None)
2212 .header(
2213 ModalHeader::new()
2214 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
2215 .child(
2216 Label::new(placeholder_text)
2217 .color(Color::Muted)
2218 .size(LabelSize::XSmall),
2219 ),
2220 )
2221 .section(
2222 Section::new().padded(false).child(
2223 v_flex()
2224 .min_h(rems(20.))
2225 .size_full()
2226 .relative()
2227 .child(ListSeparator)
2228 .child(
2229 canvas(
2230 |bounds, window, cx| {
2231 modal_section.prepaint_as_root(
2232 bounds.origin,
2233 bounds.size.into(),
2234 window,
2235 cx,
2236 );
2237 modal_section
2238 },
2239 |_, mut modal_section, window, cx| {
2240 modal_section.paint(window, cx);
2241 },
2242 )
2243 .size_full(),
2244 )
2245 .vertical_scrollbar_for(state.scroll_handle, window, cx),
2246 ),
2247 )
2248 .into_any_element()
2249 }
2250
2251 fn create_host_from_ssh_config(
2252 &mut self,
2253 ssh_config_host: &SharedString,
2254 cx: &mut Context<'_, Self>,
2255 ) -> SshServerIndex {
2256 let new_ix = Arc::new(AtomicUsize::new(0));
2257
2258 let update_new_ix = new_ix.clone();
2259 self.update_settings_file(cx, move |settings, _| {
2260 update_new_ix.store(
2261 settings
2262 .ssh_connections
2263 .as_ref()
2264 .map_or(0, |connections| connections.len()),
2265 atomic::Ordering::Release,
2266 );
2267 });
2268
2269 self.add_ssh_server(
2270 SshConnectionOptions {
2271 host: ssh_config_host.to_string(),
2272 ..SshConnectionOptions::default()
2273 },
2274 cx,
2275 );
2276 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2277 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2278 }
2279}
2280
2281fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2282 let mut user_ssh_config_watcher =
2283 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
2284 let mut global_ssh_config_watcher = watch_config_file(
2285 cx.background_executor(),
2286 fs,
2287 global_ssh_config_file().to_owned(),
2288 );
2289
2290 cx.spawn(async move |remote_server_projects, cx| {
2291 let mut global_hosts = BTreeSet::default();
2292 let mut user_hosts = BTreeSet::default();
2293 let mut running_receivers = 2;
2294
2295 loop {
2296 select! {
2297 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
2298 match new_global_file_contents {
2299 Some(new_global_file_contents) => {
2300 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
2301 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2302 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2303 cx.notify();
2304 }).is_err() {
2305 return;
2306 }
2307 },
2308 None => {
2309 running_receivers -= 1;
2310 if running_receivers == 0 {
2311 return;
2312 }
2313 }
2314 }
2315 },
2316 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
2317 match new_user_file_contents {
2318 Some(new_user_file_contents) => {
2319 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
2320 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2321 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2322 cx.notify();
2323 }).is_err() {
2324 return;
2325 }
2326 },
2327 None => {
2328 running_receivers -= 1;
2329 if running_receivers == 0 {
2330 return;
2331 }
2332 }
2333 }
2334 },
2335 }
2336 }
2337 })
2338}
2339
2340fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2341 element.read(cx).text(cx).trim().to_string()
2342}
2343
2344impl ModalView for RemoteServerProjects {}
2345
2346impl Focusable for RemoteServerProjects {
2347 fn focus_handle(&self, cx: &App) -> FocusHandle {
2348 match &self.mode {
2349 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2350 _ => self.focus_handle.clone(),
2351 }
2352 }
2353}
2354
2355impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2356
2357impl Render for RemoteServerProjects {
2358 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2359 div()
2360 .elevation_3(cx)
2361 .w(rems(34.))
2362 .key_context("RemoteServerModal")
2363 .on_action(cx.listener(Self::cancel))
2364 .on_action(cx.listener(Self::confirm))
2365 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2366 this.focus_handle(cx).focus(window);
2367 }))
2368 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2369 if matches!(this.mode, Mode::Default(_)) {
2370 cx.emit(DismissEvent)
2371 }
2372 }))
2373 .child(match &self.mode {
2374 Mode::Default(state) => self
2375 .render_default(state.clone(), window, cx)
2376 .into_any_element(),
2377 Mode::ViewServerOptions(state) => self
2378 .render_view_options(state.clone(), window, cx)
2379 .into_any_element(),
2380 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2381 Mode::CreateRemoteServer(state) => self
2382 .render_create_remote_server(state, window, cx)
2383 .into_any_element(),
2384 Mode::EditNickname(state) => self
2385 .render_edit_nickname(state, window, cx)
2386 .into_any_element(),
2387 #[cfg(target_os = "windows")]
2388 Mode::AddWslDistro(state) => self
2389 .render_add_wsl_distro(state, window, cx)
2390 .into_any_element(),
2391 })
2392 }
2393}