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