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