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