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