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