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 // can be None if another copy of this modal opened in the meantime
1165 let Some(modal) = workspace.active_modal::<RemoteConnectionModal>(cx) else {
1166 return;
1167 };
1168 let prompt = modal.read(cx).prompt.clone();
1169
1170 let connect = connect(
1171 ConnectionIdentifier::setup(),
1172 connection_options.clone(),
1173 prompt,
1174 window,
1175 cx,
1176 )
1177 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
1178
1179 cx.spawn_in(window, async move |workspace, cx| {
1180 let session = connect.await;
1181
1182 workspace.update(cx, |workspace, cx| {
1183 if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
1184 prompt.update(cx, |prompt, cx| prompt.finished(cx))
1185 }
1186 })?;
1187
1188 let Some(Some(session)) = session else {
1189 return workspace.update_in(cx, |workspace, window, cx| {
1190 let weak = cx.entity().downgrade();
1191 let fs = workspace.project().read(cx).fs().clone();
1192 workspace.toggle_modal(window, cx, |window, cx| {
1193 RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
1194 });
1195 });
1196 };
1197
1198 let (path_style, project) = cx.update(|_, cx| {
1199 (
1200 session.read(cx).path_style(),
1201 project::Project::remote(
1202 session,
1203 app_state.client.clone(),
1204 app_state.node_runtime.clone(),
1205 app_state.user_store.clone(),
1206 app_state.languages.clone(),
1207 app_state.fs.clone(),
1208 true,
1209 cx,
1210 ),
1211 )
1212 })?;
1213
1214 let home_dir = project
1215 .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))
1216 .await
1217 .and_then(|path| path.into_abs_path())
1218 .map(|path| RemotePathBuf::new(path, path_style))
1219 .unwrap_or_else(|| match path_style {
1220 PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
1221 PathStyle::Windows => {
1222 RemotePathBuf::from_str("C:\\", PathStyle::Windows)
1223 }
1224 });
1225
1226 workspace
1227 .update_in(cx, |workspace, window, cx| {
1228 let weak = cx.entity().downgrade();
1229 workspace.toggle_modal(window, cx, |window, cx| {
1230 RemoteServerProjects::project_picker(
1231 create_new_window,
1232 index,
1233 connection_options,
1234 project,
1235 home_dir,
1236 window,
1237 cx,
1238 weak,
1239 )
1240 });
1241 })
1242 .ok();
1243 Ok(())
1244 })
1245 .detach();
1246 })
1247 })
1248 }
1249
1250 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1251 match &self.mode {
1252 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
1253 Mode::ProjectPicker(_) => {}
1254 Mode::CreateRemoteServer(state) => {
1255 if let Some(prompt) = state.ssh_prompt.as_ref() {
1256 prompt.update(cx, |prompt, cx| {
1257 prompt.confirm(window, cx);
1258 });
1259 return;
1260 }
1261
1262 self.create_ssh_server(state.address_editor.clone(), window, cx);
1263 }
1264 Mode::CreateRemoteDevContainer(_) => {}
1265 Mode::EditNickname(state) => {
1266 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
1267 let index = state.index;
1268 self.update_settings_file(cx, move |setting, _| {
1269 if let Some(connections) = setting.ssh_connections.as_mut()
1270 && let Some(connection) = connections.get_mut(index.0)
1271 {
1272 connection.nickname = text;
1273 }
1274 });
1275 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1276 self.focus_handle.focus(window, cx);
1277 }
1278 #[cfg(target_os = "windows")]
1279 Mode::AddWslDistro(state) => {
1280 let delegate = &state.picker.read(cx).delegate;
1281 let distro = delegate.selected_distro().unwrap();
1282 self.connect_wsl_distro(state.picker.clone(), distro, window, cx);
1283 }
1284 }
1285 }
1286
1287 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1288 match &self.mode {
1289 Mode::Default(_) => cx.emit(DismissEvent),
1290 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
1291 let new_state = CreateRemoteServer::new(window, cx);
1292 let old_prompt = state.address_editor.read(cx).text(cx);
1293 new_state.address_editor.update(cx, |this, cx| {
1294 this.set_text(old_prompt, window, cx);
1295 });
1296
1297 self.mode = Mode::CreateRemoteServer(new_state);
1298 cx.notify();
1299 }
1300 Mode::CreateRemoteDevContainer(CreateRemoteDevContainer {
1301 progress: DevContainerCreationProgress::Error(_),
1302 ..
1303 }) => {
1304 cx.emit(DismissEvent);
1305 }
1306 _ => {
1307 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1308 self.focus_handle(cx).focus(window, cx);
1309 cx.notify();
1310 }
1311 }
1312 }
1313
1314 fn render_remote_connection(
1315 &mut self,
1316 ix: usize,
1317 remote_server: RemoteEntry,
1318 window: &mut Window,
1319 cx: &mut Context<Self>,
1320 ) -> impl IntoElement {
1321 let connection = remote_server.connection().into_owned();
1322
1323 let (main_label, aux_label, is_wsl) = match &connection {
1324 Connection::Ssh(connection) => {
1325 if let Some(nickname) = connection.nickname.clone() {
1326 let aux_label = SharedString::from(format!("({})", connection.host));
1327 (nickname, Some(aux_label), false)
1328 } else {
1329 (connection.host.clone(), None, false)
1330 }
1331 }
1332 Connection::Wsl(wsl_connection_options) => {
1333 (wsl_connection_options.distro_name.clone(), None, true)
1334 }
1335 Connection::DevContainer(dev_container_options) => {
1336 (dev_container_options.name.clone(), None, false)
1337 }
1338 };
1339 v_flex()
1340 .w_full()
1341 .child(ListSeparator)
1342 .child(
1343 h_flex()
1344 .group("ssh-server")
1345 .w_full()
1346 .pt_0p5()
1347 .px_3()
1348 .gap_1()
1349 .overflow_hidden()
1350 .child(
1351 h_flex()
1352 .gap_1()
1353 .max_w_96()
1354 .overflow_hidden()
1355 .text_ellipsis()
1356 .when(is_wsl, |this| {
1357 this.child(
1358 Label::new("WSL:")
1359 .size(LabelSize::Small)
1360 .color(Color::Muted),
1361 )
1362 })
1363 .child(
1364 Label::new(main_label)
1365 .size(LabelSize::Small)
1366 .color(Color::Muted),
1367 ),
1368 )
1369 .children(
1370 aux_label.map(|label| {
1371 Label::new(label).size(LabelSize::Small).color(Color::Muted)
1372 }),
1373 ),
1374 )
1375 .child(match &remote_server {
1376 RemoteEntry::Project {
1377 open_folder,
1378 projects,
1379 configure,
1380 connection,
1381 index,
1382 } => {
1383 let index = *index;
1384 List::new()
1385 .empty_message("No projects.")
1386 .children(projects.iter().enumerate().map(|(pix, p)| {
1387 v_flex().gap_0p5().child(self.render_remote_project(
1388 index,
1389 remote_server.clone(),
1390 pix,
1391 p,
1392 window,
1393 cx,
1394 ))
1395 }))
1396 .child(
1397 h_flex()
1398 .id(("new-remote-project-container", ix))
1399 .track_focus(&open_folder.focus_handle)
1400 .anchor_scroll(open_folder.scroll_anchor.clone())
1401 .on_action(cx.listener({
1402 let connection = connection.clone();
1403 move |this, _: &menu::Confirm, window, cx| {
1404 this.create_remote_project(
1405 index,
1406 connection.clone().into(),
1407 window,
1408 cx,
1409 );
1410 }
1411 }))
1412 .child(
1413 ListItem::new(("new-remote-project", ix))
1414 .toggle_state(
1415 open_folder.focus_handle.contains_focused(window, cx),
1416 )
1417 .inset(true)
1418 .spacing(ui::ListItemSpacing::Sparse)
1419 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1420 .child(Label::new("Open Folder"))
1421 .on_click(cx.listener({
1422 let connection = connection.clone();
1423 move |this, _, window, cx| {
1424 this.create_remote_project(
1425 index,
1426 connection.clone().into(),
1427 window,
1428 cx,
1429 );
1430 }
1431 })),
1432 ),
1433 )
1434 .child(
1435 h_flex()
1436 .id(("server-options-container", ix))
1437 .track_focus(&configure.focus_handle)
1438 .anchor_scroll(configure.scroll_anchor.clone())
1439 .on_action(cx.listener({
1440 let connection = connection.clone();
1441 move |this, _: &menu::Confirm, window, cx| {
1442 this.view_server_options(
1443 (index, connection.clone().into()),
1444 window,
1445 cx,
1446 );
1447 }
1448 }))
1449 .child(
1450 ListItem::new(("server-options", ix))
1451 .toggle_state(
1452 configure.focus_handle.contains_focused(window, cx),
1453 )
1454 .inset(true)
1455 .spacing(ui::ListItemSpacing::Sparse)
1456 .start_slot(
1457 Icon::new(IconName::Settings).color(Color::Muted),
1458 )
1459 .child(Label::new("View Server Options"))
1460 .on_click(cx.listener({
1461 let ssh_connection = connection.clone();
1462 move |this, _, window, cx| {
1463 this.view_server_options(
1464 (index, ssh_connection.clone().into()),
1465 window,
1466 cx,
1467 );
1468 }
1469 })),
1470 ),
1471 )
1472 }
1473 RemoteEntry::SshConfig { open_folder, host } => List::new().child(
1474 h_flex()
1475 .id(("new-remote-project-container", ix))
1476 .track_focus(&open_folder.focus_handle)
1477 .anchor_scroll(open_folder.scroll_anchor.clone())
1478 .on_action(cx.listener({
1479 let connection = connection.clone();
1480 let host = host.clone();
1481 move |this, _: &menu::Confirm, window, cx| {
1482 let new_ix = this.create_host_from_ssh_config(&host, cx);
1483 this.create_remote_project(
1484 new_ix.into(),
1485 connection.clone().into(),
1486 window,
1487 cx,
1488 );
1489 }
1490 }))
1491 .child(
1492 ListItem::new(("new-remote-project", ix))
1493 .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
1494 .inset(true)
1495 .spacing(ui::ListItemSpacing::Sparse)
1496 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1497 .child(Label::new("Open Folder"))
1498 .on_click(cx.listener({
1499 let host = host.clone();
1500 move |this, _, window, cx| {
1501 let new_ix = this.create_host_from_ssh_config(&host, cx);
1502 this.create_remote_project(
1503 new_ix.into(),
1504 connection.clone().into(),
1505 window,
1506 cx,
1507 );
1508 }
1509 })),
1510 ),
1511 ),
1512 })
1513 }
1514
1515 fn render_remote_project(
1516 &mut self,
1517 server_ix: ServerIndex,
1518 server: RemoteEntry,
1519 ix: usize,
1520 (navigation, project): &(NavigableEntry, RemoteProject),
1521 window: &mut Window,
1522 cx: &mut Context<Self>,
1523 ) -> impl IntoElement {
1524 let create_new_window = self.create_new_window;
1525 let is_from_zed = server.is_from_zed();
1526 let element_id_base = SharedString::from(format!(
1527 "remote-project-{}",
1528 match server_ix {
1529 ServerIndex::Ssh(index) => format!("ssh-{index}"),
1530 ServerIndex::Wsl(index) => format!("wsl-{index}"),
1531 }
1532 ));
1533 let container_element_id_base =
1534 SharedString::from(format!("remote-project-container-{element_id_base}"));
1535
1536 let callback = Rc::new({
1537 let project = project.clone();
1538 move |remote_server_projects: &mut Self,
1539 secondary_confirm: bool,
1540 window: &mut Window,
1541 cx: &mut Context<Self>| {
1542 let Some(app_state) = remote_server_projects
1543 .workspace
1544 .read_with(cx, |workspace, _| workspace.app_state().clone())
1545 .log_err()
1546 else {
1547 return;
1548 };
1549 let project = project.clone();
1550 let server = server.connection().into_owned();
1551 cx.emit(DismissEvent);
1552
1553 let replace_window = match (create_new_window, secondary_confirm) {
1554 (true, false) | (false, true) => None,
1555 (true, true) | (false, false) => {
1556 window.window_handle().downcast::<MultiWorkspace>()
1557 }
1558 };
1559
1560 cx.spawn_in(window, async move |_, cx| {
1561 let result = open_remote_project(
1562 server.into(),
1563 project.paths.into_iter().map(PathBuf::from).collect(),
1564 app_state,
1565 OpenOptions {
1566 replace_window,
1567 ..OpenOptions::default()
1568 },
1569 cx,
1570 )
1571 .await;
1572 if let Err(e) = result {
1573 log::error!("Failed to connect: {e:#}");
1574 cx.prompt(
1575 gpui::PromptLevel::Critical,
1576 "Failed to connect",
1577 Some(&e.to_string()),
1578 &["Ok"],
1579 )
1580 .await
1581 .ok();
1582 }
1583 })
1584 .detach();
1585 }
1586 });
1587
1588 div()
1589 .id((container_element_id_base, ix))
1590 .track_focus(&navigation.focus_handle)
1591 .anchor_scroll(navigation.scroll_anchor.clone())
1592 .on_action(cx.listener({
1593 let callback = callback.clone();
1594 move |this, _: &menu::Confirm, window, cx| {
1595 callback(this, false, window, cx);
1596 }
1597 }))
1598 .on_action(cx.listener({
1599 let callback = callback.clone();
1600 move |this, _: &menu::SecondaryConfirm, window, cx| {
1601 callback(this, true, window, cx);
1602 }
1603 }))
1604 .child(
1605 ListItem::new((element_id_base, ix))
1606 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
1607 .inset(true)
1608 .spacing(ui::ListItemSpacing::Sparse)
1609 .start_slot(
1610 Icon::new(IconName::Folder)
1611 .color(Color::Muted)
1612 .size(IconSize::Small),
1613 )
1614 .child(Label::new(project.paths.join(", ")).truncate_start())
1615 .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
1616 let secondary_confirm = e.modifiers().platform;
1617 callback(this, secondary_confirm, window, cx)
1618 }))
1619 .tooltip(Tooltip::text(project.paths.join("\n")))
1620 .when(is_from_zed, |server_list_item| {
1621 server_list_item.end_hover_slot::<AnyElement>(Some(
1622 div()
1623 .mr_2()
1624 .child({
1625 let project = project.clone();
1626 // Right-margin to offset it from the Scrollbar
1627 IconButton::new("remove-remote-project", IconName::Trash)
1628 .icon_size(IconSize::Small)
1629 .shape(IconButtonShape::Square)
1630 .size(ButtonSize::Large)
1631 .tooltip(Tooltip::text("Delete Remote Project"))
1632 .on_click(cx.listener(move |this, _, _, cx| {
1633 this.delete_remote_project(server_ix, &project, cx)
1634 }))
1635 })
1636 .into_any_element(),
1637 ))
1638 }),
1639 )
1640 }
1641
1642 fn update_settings_file(
1643 &mut self,
1644 cx: &mut Context<Self>,
1645 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
1646 ) {
1647 let Some(fs) = self
1648 .workspace
1649 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
1650 .log_err()
1651 else {
1652 return;
1653 };
1654 update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx));
1655 }
1656
1657 fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
1658 self.update_settings_file(cx, move |setting, _| {
1659 if let Some(connections) = setting.ssh_connections.as_mut() {
1660 connections.remove(server.0);
1661 }
1662 });
1663 }
1664
1665 fn delete_remote_project(
1666 &mut self,
1667 server: ServerIndex,
1668 project: &RemoteProject,
1669 cx: &mut Context<Self>,
1670 ) {
1671 match server {
1672 ServerIndex::Ssh(server) => {
1673 self.delete_ssh_project(server, project, cx);
1674 }
1675 ServerIndex::Wsl(server) => {
1676 self.delete_wsl_project(server, project, cx);
1677 }
1678 }
1679 }
1680
1681 fn delete_ssh_project(
1682 &mut self,
1683 server: SshServerIndex,
1684 project: &RemoteProject,
1685 cx: &mut Context<Self>,
1686 ) {
1687 let project = project.clone();
1688 self.update_settings_file(cx, move |setting, _| {
1689 if let Some(server) = setting
1690 .ssh_connections
1691 .as_mut()
1692 .and_then(|connections| connections.get_mut(server.0))
1693 {
1694 server.projects.remove(&project);
1695 }
1696 });
1697 }
1698
1699 fn delete_wsl_project(
1700 &mut self,
1701 server: WslServerIndex,
1702 project: &RemoteProject,
1703 cx: &mut Context<Self>,
1704 ) {
1705 let project = project.clone();
1706 self.update_settings_file(cx, move |setting, _| {
1707 if let Some(server) = setting
1708 .wsl_connections
1709 .as_mut()
1710 .and_then(|connections| connections.get_mut(server.0))
1711 {
1712 server.projects.remove(&project);
1713 }
1714 });
1715 }
1716
1717 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1718 self.update_settings_file(cx, move |setting, _| {
1719 if let Some(connections) = setting.wsl_connections.as_mut() {
1720 connections.remove(server.0);
1721 }
1722 });
1723 }
1724
1725 fn add_ssh_server(
1726 &mut self,
1727 connection_options: remote::SshConnectionOptions,
1728 cx: &mut Context<Self>,
1729 ) {
1730 self.update_settings_file(cx, move |setting, _| {
1731 setting
1732 .ssh_connections
1733 .get_or_insert(Default::default())
1734 .push(SshConnection {
1735 host: connection_options.host.to_string(),
1736 username: connection_options.username,
1737 port: connection_options.port,
1738 projects: BTreeSet::new(),
1739 nickname: None,
1740 args: connection_options.args.unwrap_or_default(),
1741 upload_binary_over_ssh: None,
1742 port_forwards: connection_options.port_forwards,
1743 connection_timeout: connection_options.connection_timeout,
1744 })
1745 });
1746 }
1747
1748 fn edit_in_dev_container_json(
1749 &mut self,
1750 config: Option<DevContainerConfig>,
1751 window: &mut Window,
1752 cx: &mut Context<Self>,
1753 ) {
1754 let Some(workspace) = self.workspace.upgrade() else {
1755 cx.emit(DismissEvent);
1756 cx.notify();
1757 return;
1758 };
1759
1760 let config_path = config
1761 .map(|c| c.config_path)
1762 .unwrap_or_else(|| PathBuf::from(".devcontainer/devcontainer.json"));
1763
1764 workspace.update(cx, |workspace, cx| {
1765 let project = workspace.project().clone();
1766
1767 let worktree = project
1768 .read(cx)
1769 .visible_worktrees(cx)
1770 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
1771
1772 if let Some(worktree) = worktree {
1773 let tree_id = worktree.read(cx).id();
1774 let devcontainer_path =
1775 match RelPath::new(&config_path, util::paths::PathStyle::Posix) {
1776 Ok(path) => path.into_owned(),
1777 Err(error) => {
1778 log::error!(
1779 "Invalid devcontainer path: {} - {}",
1780 config_path.display(),
1781 error
1782 );
1783 return;
1784 }
1785 };
1786 cx.spawn_in(window, async move |workspace, cx| {
1787 workspace
1788 .update_in(cx, |workspace, window, cx| {
1789 workspace.open_path(
1790 (tree_id, devcontainer_path),
1791 None,
1792 true,
1793 window,
1794 cx,
1795 )
1796 })?
1797 .await
1798 })
1799 .detach();
1800 } else {
1801 return;
1802 }
1803 });
1804 cx.emit(DismissEvent);
1805 cx.notify();
1806 }
1807
1808 fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1809 let configs = self
1810 .workspace
1811 .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
1812 .unwrap_or_default();
1813
1814 if configs.len() > 1 {
1815 let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
1816 self.dev_container_picker =
1817 Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
1818
1819 let state =
1820 CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx);
1821 self.mode = Mode::CreateRemoteDevContainer(state);
1822 cx.notify();
1823 } else if let Some((app_state, context)) = self
1824 .workspace
1825 .read_with(cx, |workspace, cx| {
1826 let app_state = workspace.app_state().clone();
1827 let context = DevContainerContext::from_workspace(workspace, cx)?;
1828 Some((app_state, context))
1829 })
1830 .ok()
1831 .flatten()
1832 {
1833 let config = configs.into_iter().next();
1834 self.open_dev_container(config, app_state, context, window, cx);
1835 self.view_in_progress_dev_container(window, cx);
1836 } else {
1837 log::error!("No active project directory for Dev Container");
1838 }
1839 }
1840
1841 fn open_dev_container(
1842 &self,
1843 config: Option<DevContainerConfig>,
1844 app_state: Arc<AppState>,
1845 context: DevContainerContext,
1846 window: &mut Window,
1847 cx: &mut Context<Self>,
1848 ) {
1849 let replace_window = window.window_handle().downcast::<MultiWorkspace>();
1850
1851 let app_state = Arc::downgrade(&app_state);
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 Some(app_state) = app_state.upgrade() else {
1886 return;
1887 };
1888 let result = open_remote_project(
1889 connection.into(),
1890 vec![starting_dir].into_iter().map(PathBuf::from).collect(),
1891 app_state,
1892 OpenOptions {
1893 replace_window,
1894 ..OpenOptions::default()
1895 },
1896 cx,
1897 )
1898 .await;
1899 if let Err(e) = result {
1900 log::error!("Failed to connect: {e:#}");
1901 cx.prompt(
1902 gpui::PromptLevel::Critical,
1903 "Failed to connect",
1904 Some(&e.to_string()),
1905 &["Ok"],
1906 )
1907 .await
1908 .ok();
1909 }
1910 })
1911 .detach();
1912 }
1913
1914 fn render_create_dev_container(
1915 &self,
1916 state: &CreateRemoteDevContainer,
1917 window: &mut Window,
1918 cx: &mut Context<Self>,
1919 ) -> impl IntoElement {
1920 match &state.progress {
1921 DevContainerCreationProgress::Error(message) => {
1922 let view = Navigable::new(
1923 div()
1924 .child(
1925 div().track_focus(&self.focus_handle(cx)).size_full().child(
1926 v_flex().py_1().child(
1927 ListItem::new("Error")
1928 .inset(true)
1929 .selectable(false)
1930 .spacing(ui::ListItemSpacing::Sparse)
1931 .start_slot(
1932 Icon::new(IconName::XCircle).color(Color::Error),
1933 )
1934 .child(Label::new("Error Creating Dev Container:"))
1935 .child(Label::new(message).buffer_font(cx)),
1936 ),
1937 ),
1938 )
1939 .child(ListSeparator)
1940 .child(
1941 div()
1942 .id("devcontainer-see-log")
1943 .track_focus(&state.view_logs_entry.focus_handle)
1944 .on_action(cx.listener(|_, _: &menu::Confirm, window, cx| {
1945 window.dispatch_action(Box::new(OpenLog), cx);
1946 cx.emit(DismissEvent);
1947 cx.notify();
1948 }))
1949 .child(
1950 ListItem::new("li-devcontainer-see-log")
1951 .toggle_state(
1952 state
1953 .view_logs_entry
1954 .focus_handle
1955 .contains_focused(window, cx),
1956 )
1957 .inset(true)
1958 .spacing(ui::ListItemSpacing::Sparse)
1959 .start_slot(Icon::new(IconName::File).color(Color::Muted))
1960 .child(Label::new("Open Zed Log"))
1961 .on_click(cx.listener(|_, _, window, cx| {
1962 window.dispatch_action(Box::new(OpenLog), cx);
1963 cx.emit(DismissEvent);
1964 cx.notify();
1965 })),
1966 ),
1967 )
1968 .child(
1969 div()
1970 .id("devcontainer-go-back")
1971 .track_focus(&state.back_entry.focus_handle)
1972 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1973 this.cancel(&menu::Cancel, window, cx);
1974 cx.notify();
1975 }))
1976 .child(
1977 ListItem::new("li-devcontainer-go-back")
1978 .toggle_state(
1979 state
1980 .back_entry
1981 .focus_handle
1982 .contains_focused(window, cx),
1983 )
1984 .inset(true)
1985 .spacing(ui::ListItemSpacing::Sparse)
1986 .start_slot(Icon::new(IconName::Exit).color(Color::Muted))
1987 .child(Label::new("Exit"))
1988 .on_click(cx.listener(|this, _, window, cx| {
1989 this.cancel(&menu::Cancel, window, cx);
1990 cx.notify();
1991 })),
1992 ),
1993 )
1994 .into_any_element(),
1995 )
1996 .entry(state.view_logs_entry.clone())
1997 .entry(state.back_entry.clone());
1998 view.render(window, cx).into_any_element()
1999 }
2000 DevContainerCreationProgress::SelectingConfig => {
2001 self.render_config_selection(window, cx).into_any_element()
2002 }
2003 DevContainerCreationProgress::Creating => {
2004 self.focus_handle(cx).focus(window, cx);
2005 div()
2006 .track_focus(&self.focus_handle(cx))
2007 .size_full()
2008 .child(
2009 v_flex()
2010 .pb_1()
2011 .child(
2012 ModalHeader::new().child(
2013 Headline::new("Dev Containers").size(HeadlineSize::XSmall),
2014 ),
2015 )
2016 .child(ListSeparator)
2017 .child(
2018 ListItem::new("creating")
2019 .inset(true)
2020 .spacing(ui::ListItemSpacing::Sparse)
2021 .disabled(true)
2022 .start_slot(
2023 Icon::new(IconName::ArrowCircle)
2024 .color(Color::Muted)
2025 .with_rotate_animation(2),
2026 )
2027 .child(
2028 h_flex()
2029 .opacity(0.6)
2030 .gap_1()
2031 .child(Label::new("Creating Dev Container"))
2032 .child(LoadingLabel::new("")),
2033 ),
2034 ),
2035 )
2036 .into_any_element()
2037 }
2038 }
2039 }
2040
2041 fn render_config_selection(
2042 &self,
2043 window: &mut Window,
2044 cx: &mut Context<Self>,
2045 ) -> impl IntoElement {
2046 let Some(picker) = &self.dev_container_picker else {
2047 return div().into_any_element();
2048 };
2049
2050 let content = v_flex().pb_1().child(picker.clone().into_any_element());
2051
2052 picker.focus_handle(cx).focus(window, cx);
2053
2054 content.into_any_element()
2055 }
2056
2057 fn render_create_remote_server(
2058 &self,
2059 state: &CreateRemoteServer,
2060 window: &mut Window,
2061 cx: &mut Context<Self>,
2062 ) -> impl IntoElement {
2063 let ssh_prompt = state.ssh_prompt.clone();
2064
2065 state.address_editor.update(cx, |editor, cx| {
2066 if editor.text(cx).is_empty() {
2067 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
2068 }
2069 });
2070
2071 let theme = cx.theme();
2072
2073 v_flex()
2074 .track_focus(&self.focus_handle(cx))
2075 .id("create-remote-server")
2076 .overflow_hidden()
2077 .size_full()
2078 .flex_1()
2079 .child(
2080 div()
2081 .p_2()
2082 .border_b_1()
2083 .border_color(theme.colors().border_variant)
2084 .child(state.address_editor.clone()),
2085 )
2086 .child(
2087 h_flex()
2088 .bg(theme.colors().editor_background)
2089 .rounded_b_sm()
2090 .w_full()
2091 .map(|this| {
2092 if let Some(ssh_prompt) = ssh_prompt {
2093 this.child(h_flex().w_full().child(ssh_prompt))
2094 } else if let Some(address_error) = &state.address_error {
2095 this.child(
2096 h_flex().p_2().w_full().gap_2().child(
2097 Label::new(address_error.clone())
2098 .size(LabelSize::Small)
2099 .color(Color::Error),
2100 ),
2101 )
2102 } else {
2103 this.child(
2104 h_flex()
2105 .p_2()
2106 .w_full()
2107 .gap_1()
2108 .child(
2109 Label::new(
2110 "Enter the command you use to SSH into this server.",
2111 )
2112 .color(Color::Muted)
2113 .size(LabelSize::Small),
2114 )
2115 .child(
2116 Button::new("learn-more", "Learn More")
2117 .label_size(LabelSize::Small)
2118 .icon(IconName::ArrowUpRight)
2119 .icon_size(IconSize::XSmall)
2120 .on_click(|_, _, cx| {
2121 cx.open_url(
2122 "https://zed.dev/docs/remote-development",
2123 );
2124 }),
2125 ),
2126 )
2127 }
2128 }),
2129 )
2130 }
2131
2132 #[cfg(target_os = "windows")]
2133 fn render_add_wsl_distro(
2134 &self,
2135 state: &AddWslDistro,
2136 window: &mut Window,
2137 cx: &mut Context<Self>,
2138 ) -> impl IntoElement {
2139 let connection_prompt = state.connection_prompt.clone();
2140
2141 state.picker.update(cx, |picker, cx| {
2142 picker.focus_handle(cx).focus(window, cx);
2143 });
2144
2145 v_flex()
2146 .id("add-wsl-distro")
2147 .overflow_hidden()
2148 .size_full()
2149 .flex_1()
2150 .map(|this| {
2151 if let Some(connection_prompt) = connection_prompt {
2152 this.child(connection_prompt)
2153 } else {
2154 this.child(state.picker.clone())
2155 }
2156 })
2157 }
2158
2159 fn render_view_options(
2160 &mut self,
2161 options: ViewServerOptionsState,
2162 window: &mut Window,
2163 cx: &mut Context<Self>,
2164 ) -> impl IntoElement {
2165 let last_entry = options.entries().last().unwrap();
2166
2167 let mut view = Navigable::new(
2168 div()
2169 .track_focus(&self.focus_handle(cx))
2170 .size_full()
2171 .child(match &options {
2172 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
2173 connection_string: connection.host.to_string().into(),
2174 paths: Default::default(),
2175 nickname: connection.nickname.clone().map(|s| s.into()),
2176 is_wsl: false,
2177 is_devcontainer: false,
2178 }
2179 .render(window, cx)
2180 .into_any_element(),
2181 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
2182 connection_string: connection.distro_name.clone().into(),
2183 paths: Default::default(),
2184 nickname: None,
2185 is_wsl: true,
2186 is_devcontainer: false,
2187 }
2188 .render(window, cx)
2189 .into_any_element(),
2190 })
2191 .child(
2192 v_flex()
2193 .pb_1()
2194 .child(ListSeparator)
2195 .map(|this| match &options {
2196 ViewServerOptionsState::Ssh {
2197 connection,
2198 entries,
2199 server_index,
2200 } => this.child(self.render_edit_ssh(
2201 connection,
2202 *server_index,
2203 entries,
2204 window,
2205 cx,
2206 )),
2207 ViewServerOptionsState::Wsl {
2208 connection,
2209 entries,
2210 server_index,
2211 } => this.child(self.render_edit_wsl(
2212 connection,
2213 *server_index,
2214 entries,
2215 window,
2216 cx,
2217 )),
2218 })
2219 .child(ListSeparator)
2220 .child({
2221 div()
2222 .id("ssh-options-copy-server-address")
2223 .track_focus(&last_entry.focus_handle)
2224 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2225 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2226 cx.focus_self(window);
2227 cx.notify();
2228 }))
2229 .child(
2230 ListItem::new("go-back")
2231 .toggle_state(
2232 last_entry.focus_handle.contains_focused(window, cx),
2233 )
2234 .inset(true)
2235 .spacing(ui::ListItemSpacing::Sparse)
2236 .start_slot(
2237 Icon::new(IconName::ArrowLeft).color(Color::Muted),
2238 )
2239 .child(Label::new("Go Back"))
2240 .on_click(cx.listener(|this, _, window, cx| {
2241 this.mode =
2242 Mode::default_mode(&this.ssh_config_servers, cx);
2243 cx.focus_self(window);
2244 cx.notify()
2245 })),
2246 )
2247 }),
2248 )
2249 .into_any_element(),
2250 );
2251
2252 for entry in options.entries() {
2253 view = view.entry(entry.clone());
2254 }
2255
2256 view.render(window, cx).into_any_element()
2257 }
2258
2259 fn render_edit_wsl(
2260 &self,
2261 connection: &WslConnectionOptions,
2262 index: WslServerIndex,
2263 entries: &[NavigableEntry],
2264 window: &mut Window,
2265 cx: &mut Context<Self>,
2266 ) -> impl IntoElement {
2267 let distro_name = SharedString::new(connection.distro_name.clone());
2268
2269 v_flex().child({
2270 fn remove_wsl_distro(
2271 remote_servers: Entity<RemoteServerProjects>,
2272 index: WslServerIndex,
2273 distro_name: SharedString,
2274 window: &mut Window,
2275 cx: &mut App,
2276 ) {
2277 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
2278
2279 let confirmation = window.prompt(
2280 PromptLevel::Warning,
2281 &prompt_message,
2282 None,
2283 &["Yes, remove it", "No, keep it"],
2284 cx,
2285 );
2286
2287 cx.spawn(async move |cx| {
2288 if confirmation.await.ok() == Some(0) {
2289 remote_servers.update(cx, |this, cx| {
2290 this.delete_wsl_distro(index, cx);
2291 });
2292 remote_servers.update(cx, |this, cx| {
2293 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2294 cx.notify();
2295 });
2296 }
2297 anyhow::Ok(())
2298 })
2299 .detach_and_log_err(cx);
2300 }
2301 div()
2302 .id("wsl-options-remove-distro")
2303 .track_focus(&entries[0].focus_handle)
2304 .on_action(cx.listener({
2305 let distro_name = distro_name.clone();
2306 move |_, _: &menu::Confirm, window, cx| {
2307 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2308 cx.focus_self(window);
2309 }
2310 }))
2311 .child(
2312 ListItem::new("remove-distro")
2313 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2314 .inset(true)
2315 .spacing(ui::ListItemSpacing::Sparse)
2316 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2317 .child(Label::new("Remove Distro").color(Color::Error))
2318 .on_click(cx.listener(move |_, _, window, cx| {
2319 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2320 cx.focus_self(window);
2321 })),
2322 )
2323 })
2324 }
2325
2326 fn render_edit_ssh(
2327 &self,
2328 connection: &SshConnectionOptions,
2329 index: SshServerIndex,
2330 entries: &[NavigableEntry],
2331 window: &mut Window,
2332 cx: &mut Context<Self>,
2333 ) -> impl IntoElement {
2334 let connection_string = SharedString::new(connection.host.to_string());
2335
2336 v_flex()
2337 .child({
2338 let label = if connection.nickname.is_some() {
2339 "Edit Nickname"
2340 } else {
2341 "Add Nickname to Server"
2342 };
2343 div()
2344 .id("ssh-options-add-nickname")
2345 .track_focus(&entries[0].focus_handle)
2346 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
2347 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
2348 cx.notify();
2349 }))
2350 .child(
2351 ListItem::new("add-nickname")
2352 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2353 .inset(true)
2354 .spacing(ui::ListItemSpacing::Sparse)
2355 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
2356 .child(Label::new(label))
2357 .on_click(cx.listener(move |this, _, window, cx| {
2358 this.mode =
2359 Mode::EditNickname(EditNicknameState::new(index, window, cx));
2360 cx.notify();
2361 })),
2362 )
2363 })
2364 .child({
2365 let workspace = self.workspace.clone();
2366 fn callback(
2367 workspace: WeakEntity<Workspace>,
2368 connection_string: SharedString,
2369 cx: &mut App,
2370 ) {
2371 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
2372 workspace
2373 .update(cx, |this, cx| {
2374 struct SshServerAddressCopiedToClipboard;
2375 let notification = format!(
2376 "Copied server address ({}) to clipboard",
2377 connection_string
2378 );
2379
2380 this.show_toast(
2381 Toast::new(
2382 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
2383 connection_string.clone(),
2384 ),
2385 notification,
2386 )
2387 .autohide(),
2388 cx,
2389 );
2390 })
2391 .ok();
2392 }
2393 div()
2394 .id("ssh-options-copy-server-address")
2395 .track_focus(&entries[1].focus_handle)
2396 .on_action({
2397 let connection_string = connection_string.clone();
2398 let workspace = self.workspace.clone();
2399 move |_: &menu::Confirm, _, cx| {
2400 callback(workspace.clone(), connection_string.clone(), cx);
2401 }
2402 })
2403 .child(
2404 ListItem::new("copy-server-address")
2405 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
2406 .inset(true)
2407 .spacing(ui::ListItemSpacing::Sparse)
2408 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
2409 .child(Label::new("Copy Server Address"))
2410 .end_hover_slot(
2411 Label::new(connection_string.clone()).color(Color::Muted),
2412 )
2413 .on_click({
2414 let connection_string = connection_string.clone();
2415 move |_, _, cx| {
2416 callback(workspace.clone(), connection_string.clone(), cx);
2417 }
2418 }),
2419 )
2420 })
2421 .child({
2422 fn remove_ssh_server(
2423 remote_servers: Entity<RemoteServerProjects>,
2424 index: SshServerIndex,
2425 connection_string: SharedString,
2426 window: &mut Window,
2427 cx: &mut App,
2428 ) {
2429 let prompt_message = format!("Remove server `{}`?", connection_string);
2430
2431 let confirmation = window.prompt(
2432 PromptLevel::Warning,
2433 &prompt_message,
2434 None,
2435 &["Yes, remove it", "No, keep it"],
2436 cx,
2437 );
2438
2439 cx.spawn(async move |cx| {
2440 if confirmation.await.ok() == Some(0) {
2441 remote_servers.update(cx, |this, cx| {
2442 this.delete_ssh_server(index, cx);
2443 });
2444 remote_servers.update(cx, |this, cx| {
2445 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2446 cx.notify();
2447 });
2448 }
2449 anyhow::Ok(())
2450 })
2451 .detach_and_log_err(cx);
2452 }
2453 div()
2454 .id("ssh-options-copy-server-address")
2455 .track_focus(&entries[2].focus_handle)
2456 .on_action(cx.listener({
2457 let connection_string = connection_string.clone();
2458 move |_, _: &menu::Confirm, window, cx| {
2459 remove_ssh_server(
2460 cx.entity(),
2461 index,
2462 connection_string.clone(),
2463 window,
2464 cx,
2465 );
2466 cx.focus_self(window);
2467 }
2468 }))
2469 .child(
2470 ListItem::new("remove-server")
2471 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
2472 .inset(true)
2473 .spacing(ui::ListItemSpacing::Sparse)
2474 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2475 .child(Label::new("Remove Server").color(Color::Error))
2476 .on_click(cx.listener(move |_, _, window, cx| {
2477 remove_ssh_server(
2478 cx.entity(),
2479 index,
2480 connection_string.clone(),
2481 window,
2482 cx,
2483 );
2484 cx.focus_self(window);
2485 })),
2486 )
2487 })
2488 }
2489
2490 fn render_edit_nickname(
2491 &self,
2492 state: &EditNicknameState,
2493 window: &mut Window,
2494 cx: &mut Context<Self>,
2495 ) -> impl IntoElement {
2496 let Some(connection) = RemoteSettings::get_global(cx)
2497 .ssh_connections()
2498 .nth(state.index.0)
2499 else {
2500 return v_flex()
2501 .id("ssh-edit-nickname")
2502 .track_focus(&self.focus_handle(cx));
2503 };
2504
2505 let connection_string = connection.host.clone();
2506 let nickname = connection.nickname.map(|s| s.into());
2507
2508 v_flex()
2509 .id("ssh-edit-nickname")
2510 .track_focus(&self.focus_handle(cx))
2511 .child(
2512 SshConnectionHeader {
2513 connection_string: connection_string.into(),
2514 paths: Default::default(),
2515 nickname,
2516 is_wsl: false,
2517 is_devcontainer: false,
2518 }
2519 .render(window, cx),
2520 )
2521 .child(
2522 h_flex()
2523 .p_2()
2524 .border_t_1()
2525 .border_color(cx.theme().colors().border_variant)
2526 .child(state.editor.clone()),
2527 )
2528 }
2529
2530 fn render_default(
2531 &mut self,
2532 mut state: DefaultState,
2533 window: &mut Window,
2534 cx: &mut Context<Self>,
2535 ) -> impl IntoElement {
2536 let ssh_settings = RemoteSettings::get_global(cx);
2537 let mut should_rebuild = false;
2538
2539 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
2540 .servers
2541 .iter()
2542 .filter_map(|server| match server {
2543 RemoteEntry::Project {
2544 connection: Connection::Ssh(connection),
2545 ..
2546 } => Some(connection),
2547 _ => None,
2548 }));
2549
2550 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
2551 .servers
2552 .iter()
2553 .filter_map(|server| match server {
2554 RemoteEntry::Project {
2555 connection: Connection::Wsl(connection),
2556 ..
2557 } => Some(connection),
2558 _ => None,
2559 }));
2560
2561 if ssh_connections_changed || wsl_connections_changed {
2562 should_rebuild = true;
2563 };
2564
2565 if !should_rebuild && ssh_settings.read_ssh_config {
2566 let current_ssh_hosts: BTreeSet<SharedString> = state
2567 .servers
2568 .iter()
2569 .filter_map(|server| match server {
2570 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
2571 _ => None,
2572 })
2573 .collect();
2574 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
2575 for server in &state.servers {
2576 if let RemoteEntry::Project {
2577 connection: Connection::Ssh(connection),
2578 ..
2579 } = server
2580 {
2581 expected_ssh_hosts.remove(connection.host.as_str());
2582 }
2583 }
2584 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2585 }
2586
2587 if should_rebuild {
2588 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2589 if let Mode::Default(new_state) = &self.mode {
2590 state = new_state.clone();
2591 }
2592 }
2593
2594 let connect_button = div()
2595 .id("ssh-connect-new-server-container")
2596 .track_focus(&state.add_new_server.focus_handle)
2597 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2598 .child(
2599 ListItem::new("register-remote-server-button")
2600 .toggle_state(
2601 state
2602 .add_new_server
2603 .focus_handle
2604 .contains_focused(window, cx),
2605 )
2606 .inset(true)
2607 .spacing(ui::ListItemSpacing::Sparse)
2608 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2609 .child(Label::new("Connect SSH Server"))
2610 .on_click(cx.listener(|this, _, window, cx| {
2611 let state = CreateRemoteServer::new(window, cx);
2612 this.mode = Mode::CreateRemoteServer(state);
2613
2614 cx.notify();
2615 })),
2616 )
2617 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2618 let state = CreateRemoteServer::new(window, cx);
2619 this.mode = Mode::CreateRemoteServer(state);
2620
2621 cx.notify();
2622 }));
2623
2624 let connect_dev_container_button = div()
2625 .id("connect-new-dev-container")
2626 .track_focus(&state.add_new_devcontainer.focus_handle)
2627 .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
2628 .child(
2629 ListItem::new("register-dev-container-button")
2630 .toggle_state(
2631 state
2632 .add_new_devcontainer
2633 .focus_handle
2634 .contains_focused(window, cx),
2635 )
2636 .inset(true)
2637 .spacing(ui::ListItemSpacing::Sparse)
2638 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2639 .child(Label::new("Connect Dev Container"))
2640 .on_click(cx.listener(|this, _, window, cx| {
2641 this.init_dev_container_mode(window, cx);
2642 })),
2643 )
2644 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2645 this.init_dev_container_mode(window, cx);
2646 }));
2647
2648 #[cfg(target_os = "windows")]
2649 let wsl_connect_button = div()
2650 .id("wsl-connect-new-server")
2651 .track_focus(&state.add_new_wsl.focus_handle)
2652 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2653 .child(
2654 ListItem::new("wsl-add-new-server")
2655 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2656 .inset(true)
2657 .spacing(ui::ListItemSpacing::Sparse)
2658 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2659 .child(Label::new("Add WSL Distro"))
2660 .on_click(cx.listener(|this, _, window, cx| {
2661 let state = AddWslDistro::new(window, cx);
2662 this.mode = Mode::AddWslDistro(state);
2663
2664 cx.notify();
2665 })),
2666 )
2667 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2668 let state = AddWslDistro::new(window, cx);
2669 this.mode = Mode::AddWslDistro(state);
2670
2671 cx.notify();
2672 }));
2673
2674 let has_open_project = self
2675 .workspace
2676 .upgrade()
2677 .map(|workspace| {
2678 workspace
2679 .read(cx)
2680 .project()
2681 .read(cx)
2682 .visible_worktrees(cx)
2683 .next()
2684 .is_some()
2685 })
2686 .unwrap_or(false);
2687
2688 // We cannot currently connect a dev container from within a remote server due to the remote_server architecture
2689 let is_local = self
2690 .workspace
2691 .upgrade()
2692 .map(|workspace| workspace.read(cx).project().read(cx).is_local())
2693 .unwrap_or(true);
2694
2695 let modal_section = v_flex()
2696 .track_focus(&self.focus_handle(cx))
2697 .id("ssh-server-list")
2698 .overflow_y_scroll()
2699 .track_scroll(&state.scroll_handle)
2700 .size_full()
2701 .child(connect_button)
2702 .when(has_open_project && is_local, |this| {
2703 this.child(connect_dev_container_button)
2704 });
2705
2706 #[cfg(target_os = "windows")]
2707 let modal_section = modal_section.child(wsl_connect_button);
2708 #[cfg(not(target_os = "windows"))]
2709 let modal_section = modal_section;
2710
2711 let mut modal_section = Navigable::new(
2712 modal_section
2713 .child(
2714 List::new()
2715 .empty_message(
2716 h_flex()
2717 .size_full()
2718 .p_2()
2719 .justify_center()
2720 .border_t_1()
2721 .border_color(cx.theme().colors().border_variant)
2722 .child(
2723 Label::new("No remote servers registered yet.")
2724 .color(Color::Muted),
2725 )
2726 .into_any_element(),
2727 )
2728 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2729 self.render_remote_connection(ix, connection.clone(), window, cx)
2730 .into_any_element()
2731 })),
2732 )
2733 .into_any_element(),
2734 )
2735 .entry(state.add_new_server.clone());
2736
2737 if has_open_project && is_local {
2738 modal_section = modal_section.entry(state.add_new_devcontainer.clone());
2739 }
2740
2741 if cfg!(target_os = "windows") {
2742 modal_section = modal_section.entry(state.add_new_wsl.clone());
2743 }
2744
2745 for server in &state.servers {
2746 match server {
2747 RemoteEntry::Project {
2748 open_folder,
2749 projects,
2750 configure,
2751 ..
2752 } => {
2753 for (navigation_state, _) in projects {
2754 modal_section = modal_section.entry(navigation_state.clone());
2755 }
2756 modal_section = modal_section
2757 .entry(open_folder.clone())
2758 .entry(configure.clone());
2759 }
2760 RemoteEntry::SshConfig { open_folder, .. } => {
2761 modal_section = modal_section.entry(open_folder.clone());
2762 }
2763 }
2764 }
2765 let mut modal_section = modal_section.render(window, cx).into_any_element();
2766
2767 let is_project_selected = state.servers.iter().any(|server| match server {
2768 RemoteEntry::Project { projects, .. } => projects
2769 .iter()
2770 .any(|(entry, _)| entry.focus_handle.contains_focused(window, cx)),
2771 RemoteEntry::SshConfig { .. } => false,
2772 });
2773
2774 Modal::new("remote-projects", None)
2775 .header(ModalHeader::new().headline("Remote Projects"))
2776 .section(
2777 Section::new().padded(false).child(
2778 v_flex()
2779 .min_h(rems(20.))
2780 .size_full()
2781 .relative()
2782 .child(ListSeparator)
2783 .child(
2784 canvas(
2785 |bounds, window, cx| {
2786 modal_section.prepaint_as_root(
2787 bounds.origin,
2788 bounds.size.into(),
2789 window,
2790 cx,
2791 );
2792 modal_section
2793 },
2794 |_, mut modal_section, window, cx| {
2795 modal_section.paint(window, cx);
2796 },
2797 )
2798 .size_full(),
2799 )
2800 .vertical_scrollbar_for(&state.scroll_handle, window, cx),
2801 ),
2802 )
2803 .footer(ModalFooter::new().end_slot({
2804 let confirm_button = |label: SharedString| {
2805 Button::new("select", label)
2806 .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
2807 .on_click(|_, window, cx| {
2808 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
2809 })
2810 };
2811
2812 if is_project_selected {
2813 h_flex()
2814 .gap_1()
2815 .child(
2816 Button::new("open_new_window", "New Window")
2817 .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
2818 .on_click(|_, window, cx| {
2819 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
2820 }),
2821 )
2822 .child(confirm_button("Open".into()))
2823 .into_any_element()
2824 } else {
2825 confirm_button("Select".into()).into_any_element()
2826 }
2827 }))
2828 .into_any_element()
2829 }
2830
2831 fn create_host_from_ssh_config(
2832 &mut self,
2833 ssh_config_host: &SharedString,
2834 cx: &mut Context<'_, Self>,
2835 ) -> SshServerIndex {
2836 let new_ix = Arc::new(AtomicUsize::new(0));
2837
2838 let update_new_ix = new_ix.clone();
2839 self.update_settings_file(cx, move |settings, _| {
2840 update_new_ix.store(
2841 settings
2842 .ssh_connections
2843 .as_ref()
2844 .map_or(0, |connections| connections.len()),
2845 atomic::Ordering::Release,
2846 );
2847 });
2848
2849 self.add_ssh_server(
2850 SshConnectionOptions {
2851 host: ssh_config_host.to_string().into(),
2852 ..SshConnectionOptions::default()
2853 },
2854 cx,
2855 );
2856 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2857 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2858 }
2859}
2860
2861fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2862 enum ConfigSource {
2863 User(String),
2864 Global(String),
2865 }
2866
2867 let mut streams = Vec::new();
2868 let mut tasks = Vec::new();
2869
2870 // Setup User Watcher
2871 let user_path = user_ssh_config_file();
2872 info!("SSH: Watching User Config at: {:?}", user_path);
2873
2874 // We clone 'fs' here because we might need it again for the global watcher.
2875 let (user_s, user_t) = watch_config_file(cx.background_executor(), fs.clone(), user_path);
2876 streams.push(user_s.map(ConfigSource::User).boxed());
2877 tasks.push(user_t);
2878
2879 // Setup Global Watcher
2880 if let Some(gp) = global_ssh_config_file() {
2881 info!("SSH: Watching Global Config at: {:?}", gp);
2882 let (global_s, global_t) =
2883 watch_config_file(cx.background_executor(), fs, gp.to_path_buf());
2884 streams.push(global_s.map(ConfigSource::Global).boxed());
2885 tasks.push(global_t);
2886 } else {
2887 debug!("SSH: No Global Config defined.");
2888 }
2889
2890 // Combine into a single stream so that only one is parsed at once.
2891 let mut merged_stream = futures::stream::select_all(streams);
2892
2893 cx.spawn(async move |remote_server_projects, cx| {
2894 let _tasks = tasks; // Keeps the background watchers alive
2895 let mut global_hosts = BTreeSet::default();
2896 let mut user_hosts = BTreeSet::default();
2897
2898 while let Some(event) = merged_stream.next().await {
2899 match event {
2900 ConfigSource::Global(content) => {
2901 global_hosts = parse_ssh_config_hosts(&content);
2902 }
2903 ConfigSource::User(content) => {
2904 user_hosts = parse_ssh_config_hosts(&content);
2905 }
2906 }
2907
2908 // Sync to Model
2909 if remote_server_projects
2910 .update(cx, |project, cx| {
2911 project.ssh_config_servers = global_hosts
2912 .iter()
2913 .chain(user_hosts.iter())
2914 .map(SharedString::from)
2915 .collect();
2916 cx.notify();
2917 })
2918 .is_err()
2919 {
2920 return;
2921 }
2922 }
2923 })
2924}
2925
2926fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2927 element.read(cx).text(cx).trim().to_string()
2928}
2929
2930impl ModalView for RemoteServerProjects {}
2931
2932impl Focusable for RemoteServerProjects {
2933 fn focus_handle(&self, cx: &App) -> FocusHandle {
2934 match &self.mode {
2935 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2936 _ => self.focus_handle.clone(),
2937 }
2938 }
2939}
2940
2941impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2942
2943impl Render for RemoteServerProjects {
2944 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2945 div()
2946 .elevation_3(cx)
2947 .w(rems(34.))
2948 .key_context("RemoteServerModal")
2949 .on_action(cx.listener(Self::cancel))
2950 .on_action(cx.listener(Self::confirm))
2951 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2952 this.focus_handle(cx).focus(window, cx);
2953 }))
2954 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2955 if matches!(this.mode, Mode::Default(_)) {
2956 cx.emit(DismissEvent)
2957 }
2958 }))
2959 .child(match &self.mode {
2960 Mode::Default(state) => self
2961 .render_default(state.clone(), window, cx)
2962 .into_any_element(),
2963 Mode::ViewServerOptions(state) => self
2964 .render_view_options(state.clone(), window, cx)
2965 .into_any_element(),
2966 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2967 Mode::CreateRemoteServer(state) => self
2968 .render_create_remote_server(state, window, cx)
2969 .into_any_element(),
2970 Mode::CreateRemoteDevContainer(state) => self
2971 .render_create_dev_container(state, window, cx)
2972 .into_any_element(),
2973 Mode::EditNickname(state) => self
2974 .render_edit_nickname(state, window, cx)
2975 .into_any_element(),
2976 #[cfg(target_os = "windows")]
2977 Mode::AddWslDistro(state) => self
2978 .render_add_wsl_distro(state, window, cx)
2979 .into_any_element(),
2980 })
2981 }
2982}