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