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