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.get(server.0).is_some()
1661 {
1662 connections.remove(server.0);
1663 }
1664 });
1665 }
1666
1667 fn delete_remote_project(
1668 &mut self,
1669 server: ServerIndex,
1670 project: &RemoteProject,
1671 cx: &mut Context<Self>,
1672 ) {
1673 match server {
1674 ServerIndex::Ssh(server) => {
1675 self.delete_ssh_project(server, project, cx);
1676 }
1677 ServerIndex::Wsl(server) => {
1678 self.delete_wsl_project(server, project, cx);
1679 }
1680 }
1681 }
1682
1683 fn delete_ssh_project(
1684 &mut self,
1685 server: SshServerIndex,
1686 project: &RemoteProject,
1687 cx: &mut Context<Self>,
1688 ) {
1689 let project = project.clone();
1690 self.update_settings_file(cx, move |setting, _| {
1691 if let Some(server) = setting
1692 .ssh_connections
1693 .as_mut()
1694 .and_then(|connections| connections.get_mut(server.0))
1695 {
1696 server.projects.remove(&project);
1697 }
1698 });
1699 }
1700
1701 fn delete_wsl_project(
1702 &mut self,
1703 server: WslServerIndex,
1704 project: &RemoteProject,
1705 cx: &mut Context<Self>,
1706 ) {
1707 let project = project.clone();
1708 self.update_settings_file(cx, move |setting, _| {
1709 if let Some(server) = setting
1710 .wsl_connections
1711 .as_mut()
1712 .and_then(|connections| connections.get_mut(server.0))
1713 {
1714 server.projects.remove(&project);
1715 }
1716 });
1717 }
1718
1719 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1720 self.update_settings_file(cx, move |setting, _| {
1721 if let Some(connections) = setting.wsl_connections.as_mut() {
1722 connections.remove(server.0);
1723 }
1724 });
1725 }
1726
1727 fn add_ssh_server(
1728 &mut self,
1729 connection_options: remote::SshConnectionOptions,
1730 cx: &mut Context<Self>,
1731 ) {
1732 self.update_settings_file(cx, move |setting, _| {
1733 setting
1734 .ssh_connections
1735 .get_or_insert(Default::default())
1736 .push(SshConnection {
1737 host: connection_options.host.to_string(),
1738 username: connection_options.username,
1739 port: connection_options.port,
1740 projects: BTreeSet::new(),
1741 nickname: None,
1742 args: connection_options.args.unwrap_or_default(),
1743 upload_binary_over_ssh: None,
1744 port_forwards: connection_options.port_forwards,
1745 connection_timeout: connection_options.connection_timeout,
1746 })
1747 });
1748 }
1749
1750 fn edit_in_dev_container_json(
1751 &mut self,
1752 config: Option<DevContainerConfig>,
1753 window: &mut Window,
1754 cx: &mut Context<Self>,
1755 ) {
1756 let Some(workspace) = self.workspace.upgrade() else {
1757 cx.emit(DismissEvent);
1758 cx.notify();
1759 return;
1760 };
1761
1762 let config_path = config
1763 .map(|c| c.config_path)
1764 .unwrap_or_else(|| PathBuf::from(".devcontainer/devcontainer.json"));
1765
1766 workspace.update(cx, |workspace, cx| {
1767 let project = workspace.project().clone();
1768
1769 let worktree = project
1770 .read(cx)
1771 .visible_worktrees(cx)
1772 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
1773
1774 if let Some(worktree) = worktree {
1775 let tree_id = worktree.read(cx).id();
1776 let devcontainer_path =
1777 match RelPath::new(&config_path, util::paths::PathStyle::Posix) {
1778 Ok(path) => path.into_owned(),
1779 Err(error) => {
1780 log::error!(
1781 "Invalid devcontainer path: {} - {}",
1782 config_path.display(),
1783 error
1784 );
1785 return;
1786 }
1787 };
1788 cx.spawn_in(window, async move |workspace, cx| {
1789 workspace
1790 .update_in(cx, |workspace, window, cx| {
1791 workspace.open_path(
1792 (tree_id, devcontainer_path),
1793 None,
1794 true,
1795 window,
1796 cx,
1797 )
1798 })?
1799 .await
1800 })
1801 .detach();
1802 } else {
1803 return;
1804 }
1805 });
1806 cx.emit(DismissEvent);
1807 cx.notify();
1808 }
1809
1810 fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1811 let configs = self
1812 .workspace
1813 .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
1814 .unwrap_or_default();
1815
1816 if configs.len() > 1 {
1817 let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
1818 self.dev_container_picker =
1819 Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
1820
1821 let state =
1822 CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx);
1823 self.mode = Mode::CreateRemoteDevContainer(state);
1824 cx.notify();
1825 } else if let Some((app_state, context)) = self
1826 .workspace
1827 .read_with(cx, |workspace, cx| {
1828 let app_state = workspace.app_state().clone();
1829 let context = DevContainerContext::from_workspace(workspace, cx)?;
1830 Some((app_state, context))
1831 })
1832 .ok()
1833 .flatten()
1834 {
1835 let config = configs.into_iter().next();
1836 self.open_dev_container(config, app_state, context, window, cx);
1837 self.view_in_progress_dev_container(window, cx);
1838 } else {
1839 log::error!("No active project directory for Dev Container");
1840 }
1841 }
1842
1843 fn open_dev_container(
1844 &self,
1845 config: Option<DevContainerConfig>,
1846 app_state: Arc<AppState>,
1847 context: DevContainerContext,
1848 window: &mut Window,
1849 cx: &mut Context<Self>,
1850 ) {
1851 let replace_window = window.window_handle().downcast::<MultiWorkspace>();
1852
1853 let app_state = Arc::downgrade(&app_state);
1854 cx.spawn_in(window, async move |entity, cx| {
1855 let (connection, starting_dir) =
1856 match start_dev_container_with_config(context, config).await {
1857 Ok((c, s)) => (Connection::DevContainer(c), s),
1858 Err(e) => {
1859 log::error!("Failed to start dev container: {:?}", e);
1860 cx.prompt(
1861 gpui::PromptLevel::Critical,
1862 "Failed to start Dev Container. See logs for details",
1863 Some(&format!("{e}")),
1864 &["Ok"],
1865 )
1866 .await
1867 .ok();
1868 entity
1869 .update_in(cx, |remote_server_projects, window, cx| {
1870 remote_server_projects.mode =
1871 Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(
1872 DevContainerCreationProgress::Error(format!("{e}")),
1873 cx,
1874 ));
1875 remote_server_projects.focus_handle(cx).focus(window, cx);
1876 })
1877 .ok();
1878 return;
1879 }
1880 };
1881 entity
1882 .update(cx, |_, cx| {
1883 cx.emit(DismissEvent);
1884 })
1885 .log_err();
1886
1887 let Some(app_state) = app_state.upgrade() else {
1888 return;
1889 };
1890 let result = open_remote_project(
1891 connection.into(),
1892 vec![starting_dir].into_iter().map(PathBuf::from).collect(),
1893 app_state,
1894 OpenOptions {
1895 replace_window,
1896 ..OpenOptions::default()
1897 },
1898 cx,
1899 )
1900 .await;
1901 if let Err(e) = result {
1902 log::error!("Failed to connect: {e:#}");
1903 cx.prompt(
1904 gpui::PromptLevel::Critical,
1905 "Failed to connect",
1906 Some(&e.to_string()),
1907 &["Ok"],
1908 )
1909 .await
1910 .ok();
1911 }
1912 })
1913 .detach();
1914 }
1915
1916 fn render_create_dev_container(
1917 &self,
1918 state: &CreateRemoteDevContainer,
1919 window: &mut Window,
1920 cx: &mut Context<Self>,
1921 ) -> impl IntoElement {
1922 match &state.progress {
1923 DevContainerCreationProgress::Error(message) => {
1924 let view = Navigable::new(
1925 div()
1926 .child(
1927 div().track_focus(&self.focus_handle(cx)).size_full().child(
1928 v_flex().py_1().child(
1929 ListItem::new("Error")
1930 .inset(true)
1931 .selectable(false)
1932 .spacing(ui::ListItemSpacing::Sparse)
1933 .start_slot(
1934 Icon::new(IconName::XCircle).color(Color::Error),
1935 )
1936 .child(Label::new("Error Creating Dev Container:"))
1937 .child(Label::new(message).buffer_font(cx)),
1938 ),
1939 ),
1940 )
1941 .child(ListSeparator)
1942 .child(
1943 div()
1944 .id("devcontainer-see-log")
1945 .track_focus(&state.view_logs_entry.focus_handle)
1946 .on_action(cx.listener(|_, _: &menu::Confirm, window, cx| {
1947 window.dispatch_action(Box::new(OpenLog), cx);
1948 cx.emit(DismissEvent);
1949 cx.notify();
1950 }))
1951 .child(
1952 ListItem::new("li-devcontainer-see-log")
1953 .toggle_state(
1954 state
1955 .view_logs_entry
1956 .focus_handle
1957 .contains_focused(window, cx),
1958 )
1959 .inset(true)
1960 .spacing(ui::ListItemSpacing::Sparse)
1961 .start_slot(Icon::new(IconName::File).color(Color::Muted))
1962 .child(Label::new("Open Zed Log"))
1963 .on_click(cx.listener(|_, _, window, cx| {
1964 window.dispatch_action(Box::new(OpenLog), cx);
1965 cx.emit(DismissEvent);
1966 cx.notify();
1967 })),
1968 ),
1969 )
1970 .child(
1971 div()
1972 .id("devcontainer-go-back")
1973 .track_focus(&state.back_entry.focus_handle)
1974 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1975 this.cancel(&menu::Cancel, window, cx);
1976 cx.notify();
1977 }))
1978 .child(
1979 ListItem::new("li-devcontainer-go-back")
1980 .toggle_state(
1981 state
1982 .back_entry
1983 .focus_handle
1984 .contains_focused(window, cx),
1985 )
1986 .inset(true)
1987 .spacing(ui::ListItemSpacing::Sparse)
1988 .start_slot(Icon::new(IconName::Exit).color(Color::Muted))
1989 .child(Label::new("Exit"))
1990 .on_click(cx.listener(|this, _, window, cx| {
1991 this.cancel(&menu::Cancel, window, cx);
1992 cx.notify();
1993 })),
1994 ),
1995 )
1996 .into_any_element(),
1997 )
1998 .entry(state.view_logs_entry.clone())
1999 .entry(state.back_entry.clone());
2000 view.render(window, cx).into_any_element()
2001 }
2002 DevContainerCreationProgress::SelectingConfig => {
2003 self.render_config_selection(window, cx).into_any_element()
2004 }
2005 DevContainerCreationProgress::Creating => {
2006 self.focus_handle(cx).focus(window, cx);
2007 div()
2008 .track_focus(&self.focus_handle(cx))
2009 .size_full()
2010 .child(
2011 v_flex()
2012 .pb_1()
2013 .child(
2014 ModalHeader::new().child(
2015 Headline::new("Dev Containers").size(HeadlineSize::XSmall),
2016 ),
2017 )
2018 .child(ListSeparator)
2019 .child(
2020 ListItem::new("creating")
2021 .inset(true)
2022 .spacing(ui::ListItemSpacing::Sparse)
2023 .disabled(true)
2024 .start_slot(
2025 Icon::new(IconName::ArrowCircle)
2026 .color(Color::Muted)
2027 .with_rotate_animation(2),
2028 )
2029 .child(
2030 h_flex()
2031 .opacity(0.6)
2032 .gap_1()
2033 .child(Label::new("Creating Dev Container"))
2034 .child(LoadingLabel::new("")),
2035 ),
2036 ),
2037 )
2038 .into_any_element()
2039 }
2040 }
2041 }
2042
2043 fn render_config_selection(
2044 &self,
2045 window: &mut Window,
2046 cx: &mut Context<Self>,
2047 ) -> impl IntoElement {
2048 let Some(picker) = &self.dev_container_picker else {
2049 return div().into_any_element();
2050 };
2051
2052 let content = v_flex().pb_1().child(picker.clone().into_any_element());
2053
2054 picker.focus_handle(cx).focus(window, cx);
2055
2056 content.into_any_element()
2057 }
2058
2059 fn render_create_remote_server(
2060 &self,
2061 state: &CreateRemoteServer,
2062 window: &mut Window,
2063 cx: &mut Context<Self>,
2064 ) -> impl IntoElement {
2065 let ssh_prompt = state.ssh_prompt.clone();
2066
2067 state.address_editor.update(cx, |editor, cx| {
2068 if editor.text(cx).is_empty() {
2069 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
2070 }
2071 });
2072
2073 let theme = cx.theme();
2074
2075 v_flex()
2076 .track_focus(&self.focus_handle(cx))
2077 .id("create-remote-server")
2078 .overflow_hidden()
2079 .size_full()
2080 .flex_1()
2081 .child(
2082 div()
2083 .p_2()
2084 .border_b_1()
2085 .border_color(theme.colors().border_variant)
2086 .child(state.address_editor.clone()),
2087 )
2088 .child(
2089 h_flex()
2090 .bg(theme.colors().editor_background)
2091 .rounded_b_sm()
2092 .w_full()
2093 .map(|this| {
2094 if let Some(ssh_prompt) = ssh_prompt {
2095 this.child(h_flex().w_full().child(ssh_prompt))
2096 } else if let Some(address_error) = &state.address_error {
2097 this.child(
2098 h_flex().p_2().w_full().gap_2().child(
2099 Label::new(address_error.clone())
2100 .size(LabelSize::Small)
2101 .color(Color::Error),
2102 ),
2103 )
2104 } else {
2105 this.child(
2106 h_flex()
2107 .p_2()
2108 .w_full()
2109 .gap_1()
2110 .child(
2111 Label::new(
2112 "Enter the command you use to SSH into this server.",
2113 )
2114 .color(Color::Muted)
2115 .size(LabelSize::Small),
2116 )
2117 .child(
2118 Button::new("learn-more", "Learn More")
2119 .label_size(LabelSize::Small)
2120 .icon(IconName::ArrowUpRight)
2121 .icon_size(IconSize::XSmall)
2122 .on_click(|_, _, cx| {
2123 cx.open_url(
2124 "https://zed.dev/docs/remote-development",
2125 );
2126 }),
2127 ),
2128 )
2129 }
2130 }),
2131 )
2132 }
2133
2134 #[cfg(target_os = "windows")]
2135 fn render_add_wsl_distro(
2136 &self,
2137 state: &AddWslDistro,
2138 window: &mut Window,
2139 cx: &mut Context<Self>,
2140 ) -> impl IntoElement {
2141 let connection_prompt = state.connection_prompt.clone();
2142
2143 state.picker.update(cx, |picker, cx| {
2144 picker.focus_handle(cx).focus(window, cx);
2145 });
2146
2147 v_flex()
2148 .id("add-wsl-distro")
2149 .overflow_hidden()
2150 .size_full()
2151 .flex_1()
2152 .map(|this| {
2153 if let Some(connection_prompt) = connection_prompt {
2154 this.child(connection_prompt)
2155 } else {
2156 this.child(state.picker.clone())
2157 }
2158 })
2159 }
2160
2161 fn render_view_options(
2162 &mut self,
2163 options: ViewServerOptionsState,
2164 window: &mut Window,
2165 cx: &mut Context<Self>,
2166 ) -> impl IntoElement {
2167 let last_entry = options.entries().last().unwrap();
2168
2169 let mut view = Navigable::new(
2170 div()
2171 .track_focus(&self.focus_handle(cx))
2172 .size_full()
2173 .child(match &options {
2174 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
2175 connection_string: connection.host.to_string().into(),
2176 paths: Default::default(),
2177 nickname: connection.nickname.clone().map(|s| s.into()),
2178 is_wsl: false,
2179 is_devcontainer: false,
2180 }
2181 .render(window, cx)
2182 .into_any_element(),
2183 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
2184 connection_string: connection.distro_name.clone().into(),
2185 paths: Default::default(),
2186 nickname: None,
2187 is_wsl: true,
2188 is_devcontainer: false,
2189 }
2190 .render(window, cx)
2191 .into_any_element(),
2192 })
2193 .child(
2194 v_flex()
2195 .pb_1()
2196 .child(ListSeparator)
2197 .map(|this| match &options {
2198 ViewServerOptionsState::Ssh {
2199 connection,
2200 entries,
2201 server_index,
2202 } => this.child(self.render_edit_ssh(
2203 connection,
2204 *server_index,
2205 entries,
2206 window,
2207 cx,
2208 )),
2209 ViewServerOptionsState::Wsl {
2210 connection,
2211 entries,
2212 server_index,
2213 } => this.child(self.render_edit_wsl(
2214 connection,
2215 *server_index,
2216 entries,
2217 window,
2218 cx,
2219 )),
2220 })
2221 .child(ListSeparator)
2222 .child({
2223 div()
2224 .id("ssh-options-copy-server-address")
2225 .track_focus(&last_entry.focus_handle)
2226 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2227 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2228 cx.focus_self(window);
2229 cx.notify();
2230 }))
2231 .child(
2232 ListItem::new("go-back")
2233 .toggle_state(
2234 last_entry.focus_handle.contains_focused(window, cx),
2235 )
2236 .inset(true)
2237 .spacing(ui::ListItemSpacing::Sparse)
2238 .start_slot(
2239 Icon::new(IconName::ArrowLeft).color(Color::Muted),
2240 )
2241 .child(Label::new("Go Back"))
2242 .on_click(cx.listener(|this, _, window, cx| {
2243 this.mode =
2244 Mode::default_mode(&this.ssh_config_servers, cx);
2245 cx.focus_self(window);
2246 cx.notify()
2247 })),
2248 )
2249 }),
2250 )
2251 .into_any_element(),
2252 );
2253
2254 for entry in options.entries() {
2255 view = view.entry(entry.clone());
2256 }
2257
2258 view.render(window, cx).into_any_element()
2259 }
2260
2261 fn render_edit_wsl(
2262 &self,
2263 connection: &WslConnectionOptions,
2264 index: WslServerIndex,
2265 entries: &[NavigableEntry],
2266 window: &mut Window,
2267 cx: &mut Context<Self>,
2268 ) -> impl IntoElement {
2269 let distro_name = SharedString::new(connection.distro_name.clone());
2270
2271 v_flex().child({
2272 fn remove_wsl_distro(
2273 remote_servers: Entity<RemoteServerProjects>,
2274 index: WslServerIndex,
2275 distro_name: SharedString,
2276 window: &mut Window,
2277 cx: &mut App,
2278 ) {
2279 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
2280
2281 let confirmation = window.prompt(
2282 PromptLevel::Warning,
2283 &prompt_message,
2284 None,
2285 &["Yes, remove it", "No, keep it"],
2286 cx,
2287 );
2288
2289 cx.spawn(async move |cx| {
2290 if confirmation.await.ok() == Some(0) {
2291 remote_servers.update(cx, |this, cx| {
2292 this.delete_wsl_distro(index, cx);
2293 });
2294 remote_servers.update(cx, |this, cx| {
2295 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2296 cx.notify();
2297 });
2298 }
2299 anyhow::Ok(())
2300 })
2301 .detach_and_log_err(cx);
2302 }
2303 div()
2304 .id("wsl-options-remove-distro")
2305 .track_focus(&entries[0].focus_handle)
2306 .on_action(cx.listener({
2307 let distro_name = distro_name.clone();
2308 move |_, _: &menu::Confirm, window, cx| {
2309 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2310 cx.focus_self(window);
2311 }
2312 }))
2313 .child(
2314 ListItem::new("remove-distro")
2315 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2316 .inset(true)
2317 .spacing(ui::ListItemSpacing::Sparse)
2318 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2319 .child(Label::new("Remove Distro").color(Color::Error))
2320 .on_click(cx.listener(move |_, _, window, cx| {
2321 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2322 cx.focus_self(window);
2323 })),
2324 )
2325 })
2326 }
2327
2328 fn render_edit_ssh(
2329 &self,
2330 connection: &SshConnectionOptions,
2331 index: SshServerIndex,
2332 entries: &[NavigableEntry],
2333 window: &mut Window,
2334 cx: &mut Context<Self>,
2335 ) -> impl IntoElement {
2336 let connection_string = SharedString::new(connection.host.to_string());
2337
2338 v_flex()
2339 .child({
2340 let label = if connection.nickname.is_some() {
2341 "Edit Nickname"
2342 } else {
2343 "Add Nickname to Server"
2344 };
2345 div()
2346 .id("ssh-options-add-nickname")
2347 .track_focus(&entries[0].focus_handle)
2348 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
2349 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
2350 cx.notify();
2351 }))
2352 .child(
2353 ListItem::new("add-nickname")
2354 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2355 .inset(true)
2356 .spacing(ui::ListItemSpacing::Sparse)
2357 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
2358 .child(Label::new(label))
2359 .on_click(cx.listener(move |this, _, window, cx| {
2360 this.mode =
2361 Mode::EditNickname(EditNicknameState::new(index, window, cx));
2362 cx.notify();
2363 })),
2364 )
2365 })
2366 .child({
2367 let workspace = self.workspace.clone();
2368 fn callback(
2369 workspace: WeakEntity<Workspace>,
2370 connection_string: SharedString,
2371 cx: &mut App,
2372 ) {
2373 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
2374 workspace
2375 .update(cx, |this, cx| {
2376 struct SshServerAddressCopiedToClipboard;
2377 let notification = format!(
2378 "Copied server address ({}) to clipboard",
2379 connection_string
2380 );
2381
2382 this.show_toast(
2383 Toast::new(
2384 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
2385 connection_string.clone(),
2386 ),
2387 notification,
2388 )
2389 .autohide(),
2390 cx,
2391 );
2392 })
2393 .ok();
2394 }
2395 div()
2396 .id("ssh-options-copy-server-address")
2397 .track_focus(&entries[1].focus_handle)
2398 .on_action({
2399 let connection_string = connection_string.clone();
2400 let workspace = self.workspace.clone();
2401 move |_: &menu::Confirm, _, cx| {
2402 callback(workspace.clone(), connection_string.clone(), cx);
2403 }
2404 })
2405 .child(
2406 ListItem::new("copy-server-address")
2407 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
2408 .inset(true)
2409 .spacing(ui::ListItemSpacing::Sparse)
2410 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
2411 .child(Label::new("Copy Server Address"))
2412 .end_hover_slot(
2413 Label::new(connection_string.clone()).color(Color::Muted),
2414 )
2415 .on_click({
2416 let connection_string = connection_string.clone();
2417 move |_, _, cx| {
2418 callback(workspace.clone(), connection_string.clone(), cx);
2419 }
2420 }),
2421 )
2422 })
2423 .child({
2424 fn remove_ssh_server(
2425 remote_servers: Entity<RemoteServerProjects>,
2426 index: SshServerIndex,
2427 connection_string: SharedString,
2428 window: &mut Window,
2429 cx: &mut App,
2430 ) {
2431 let prompt_message = format!("Remove server `{}`?", connection_string);
2432
2433 let confirmation = window.prompt(
2434 PromptLevel::Warning,
2435 &prompt_message,
2436 None,
2437 &["Yes, remove it", "No, keep it"],
2438 cx,
2439 );
2440
2441 cx.spawn(async move |cx| {
2442 if confirmation.await.ok() == Some(0) {
2443 remote_servers.update(cx, |this, cx| {
2444 this.delete_ssh_server(index, cx);
2445 });
2446 remote_servers.update(cx, |this, cx| {
2447 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2448 cx.notify();
2449 });
2450 }
2451 anyhow::Ok(())
2452 })
2453 .detach_and_log_err(cx);
2454 }
2455 div()
2456 .id("ssh-options-copy-server-address")
2457 .track_focus(&entries[2].focus_handle)
2458 .on_action(cx.listener({
2459 let connection_string = connection_string.clone();
2460 move |_, _: &menu::Confirm, window, cx| {
2461 remove_ssh_server(
2462 cx.entity(),
2463 index,
2464 connection_string.clone(),
2465 window,
2466 cx,
2467 );
2468 cx.focus_self(window);
2469 }
2470 }))
2471 .child(
2472 ListItem::new("remove-server")
2473 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
2474 .inset(true)
2475 .spacing(ui::ListItemSpacing::Sparse)
2476 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2477 .child(Label::new("Remove Server").color(Color::Error))
2478 .on_click(cx.listener(move |_, _, window, cx| {
2479 remove_ssh_server(
2480 cx.entity(),
2481 index,
2482 connection_string.clone(),
2483 window,
2484 cx,
2485 );
2486 cx.focus_self(window);
2487 })),
2488 )
2489 })
2490 }
2491
2492 fn render_edit_nickname(
2493 &self,
2494 state: &EditNicknameState,
2495 window: &mut Window,
2496 cx: &mut Context<Self>,
2497 ) -> impl IntoElement {
2498 let Some(connection) = RemoteSettings::get_global(cx)
2499 .ssh_connections()
2500 .nth(state.index.0)
2501 else {
2502 return v_flex()
2503 .id("ssh-edit-nickname")
2504 .track_focus(&self.focus_handle(cx));
2505 };
2506
2507 let connection_string = connection.host.clone();
2508 let nickname = connection.nickname.map(|s| s.into());
2509
2510 v_flex()
2511 .id("ssh-edit-nickname")
2512 .track_focus(&self.focus_handle(cx))
2513 .child(
2514 SshConnectionHeader {
2515 connection_string: connection_string.into(),
2516 paths: Default::default(),
2517 nickname,
2518 is_wsl: false,
2519 is_devcontainer: false,
2520 }
2521 .render(window, cx),
2522 )
2523 .child(
2524 h_flex()
2525 .p_2()
2526 .border_t_1()
2527 .border_color(cx.theme().colors().border_variant)
2528 .child(state.editor.clone()),
2529 )
2530 }
2531
2532 fn render_default(
2533 &mut self,
2534 mut state: DefaultState,
2535 window: &mut Window,
2536 cx: &mut Context<Self>,
2537 ) -> impl IntoElement {
2538 let ssh_settings = RemoteSettings::get_global(cx);
2539 let mut should_rebuild = false;
2540
2541 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
2542 .servers
2543 .iter()
2544 .filter_map(|server| match server {
2545 RemoteEntry::Project {
2546 connection: Connection::Ssh(connection),
2547 ..
2548 } => Some(connection),
2549 _ => None,
2550 }));
2551
2552 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
2553 .servers
2554 .iter()
2555 .filter_map(|server| match server {
2556 RemoteEntry::Project {
2557 connection: Connection::Wsl(connection),
2558 ..
2559 } => Some(connection),
2560 _ => None,
2561 }));
2562
2563 if ssh_connections_changed || wsl_connections_changed {
2564 should_rebuild = true;
2565 };
2566
2567 if !should_rebuild && ssh_settings.read_ssh_config {
2568 let current_ssh_hosts: BTreeSet<SharedString> = state
2569 .servers
2570 .iter()
2571 .filter_map(|server| match server {
2572 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
2573 _ => None,
2574 })
2575 .collect();
2576 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
2577 for server in &state.servers {
2578 if let RemoteEntry::Project {
2579 connection: Connection::Ssh(connection),
2580 ..
2581 } = server
2582 {
2583 expected_ssh_hosts.remove(connection.host.as_str());
2584 }
2585 }
2586 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2587 }
2588
2589 if should_rebuild {
2590 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2591 if let Mode::Default(new_state) = &self.mode {
2592 state = new_state.clone();
2593 }
2594 }
2595
2596 let connect_button = div()
2597 .id("ssh-connect-new-server-container")
2598 .track_focus(&state.add_new_server.focus_handle)
2599 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2600 .child(
2601 ListItem::new("register-remote-server-button")
2602 .toggle_state(
2603 state
2604 .add_new_server
2605 .focus_handle
2606 .contains_focused(window, cx),
2607 )
2608 .inset(true)
2609 .spacing(ui::ListItemSpacing::Sparse)
2610 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2611 .child(Label::new("Connect SSH Server"))
2612 .on_click(cx.listener(|this, _, window, cx| {
2613 let state = CreateRemoteServer::new(window, cx);
2614 this.mode = Mode::CreateRemoteServer(state);
2615
2616 cx.notify();
2617 })),
2618 )
2619 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2620 let state = CreateRemoteServer::new(window, cx);
2621 this.mode = Mode::CreateRemoteServer(state);
2622
2623 cx.notify();
2624 }));
2625
2626 let connect_dev_container_button = div()
2627 .id("connect-new-dev-container")
2628 .track_focus(&state.add_new_devcontainer.focus_handle)
2629 .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
2630 .child(
2631 ListItem::new("register-dev-container-button")
2632 .toggle_state(
2633 state
2634 .add_new_devcontainer
2635 .focus_handle
2636 .contains_focused(window, cx),
2637 )
2638 .inset(true)
2639 .spacing(ui::ListItemSpacing::Sparse)
2640 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2641 .child(Label::new("Connect Dev Container"))
2642 .on_click(cx.listener(|this, _, window, cx| {
2643 this.init_dev_container_mode(window, cx);
2644 })),
2645 )
2646 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2647 this.init_dev_container_mode(window, cx);
2648 }));
2649
2650 #[cfg(target_os = "windows")]
2651 let wsl_connect_button = div()
2652 .id("wsl-connect-new-server")
2653 .track_focus(&state.add_new_wsl.focus_handle)
2654 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2655 .child(
2656 ListItem::new("wsl-add-new-server")
2657 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2658 .inset(true)
2659 .spacing(ui::ListItemSpacing::Sparse)
2660 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2661 .child(Label::new("Add WSL Distro"))
2662 .on_click(cx.listener(|this, _, window, cx| {
2663 let state = AddWslDistro::new(window, cx);
2664 this.mode = Mode::AddWslDistro(state);
2665
2666 cx.notify();
2667 })),
2668 )
2669 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2670 let state = AddWslDistro::new(window, cx);
2671 this.mode = Mode::AddWslDistro(state);
2672
2673 cx.notify();
2674 }));
2675
2676 let has_open_project = self
2677 .workspace
2678 .upgrade()
2679 .map(|workspace| {
2680 workspace
2681 .read(cx)
2682 .project()
2683 .read(cx)
2684 .visible_worktrees(cx)
2685 .next()
2686 .is_some()
2687 })
2688 .unwrap_or(false);
2689
2690 // We cannot currently connect a dev container from within a remote server due to the remote_server architecture
2691 let is_local = self
2692 .workspace
2693 .upgrade()
2694 .map(|workspace| workspace.read(cx).project().read(cx).is_local())
2695 .unwrap_or(true);
2696
2697 let modal_section = v_flex()
2698 .track_focus(&self.focus_handle(cx))
2699 .id("ssh-server-list")
2700 .overflow_y_scroll()
2701 .track_scroll(&state.scroll_handle)
2702 .size_full()
2703 .child(connect_button)
2704 .when(has_open_project && is_local, |this| {
2705 this.child(connect_dev_container_button)
2706 });
2707
2708 #[cfg(target_os = "windows")]
2709 let modal_section = modal_section.child(wsl_connect_button);
2710 #[cfg(not(target_os = "windows"))]
2711 let modal_section = modal_section;
2712
2713 let mut modal_section = Navigable::new(
2714 modal_section
2715 .child(
2716 List::new()
2717 .empty_message(
2718 h_flex()
2719 .size_full()
2720 .p_2()
2721 .justify_center()
2722 .border_t_1()
2723 .border_color(cx.theme().colors().border_variant)
2724 .child(
2725 Label::new("No remote servers registered yet.")
2726 .color(Color::Muted),
2727 )
2728 .into_any_element(),
2729 )
2730 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2731 self.render_remote_connection(ix, connection.clone(), window, cx)
2732 .into_any_element()
2733 })),
2734 )
2735 .into_any_element(),
2736 )
2737 .entry(state.add_new_server.clone());
2738
2739 if has_open_project && is_local {
2740 modal_section = modal_section.entry(state.add_new_devcontainer.clone());
2741 }
2742
2743 if cfg!(target_os = "windows") {
2744 modal_section = modal_section.entry(state.add_new_wsl.clone());
2745 }
2746
2747 for server in &state.servers {
2748 match server {
2749 RemoteEntry::Project {
2750 open_folder,
2751 projects,
2752 configure,
2753 ..
2754 } => {
2755 for (navigation_state, _) in projects {
2756 modal_section = modal_section.entry(navigation_state.clone());
2757 }
2758 modal_section = modal_section
2759 .entry(open_folder.clone())
2760 .entry(configure.clone());
2761 }
2762 RemoteEntry::SshConfig { open_folder, .. } => {
2763 modal_section = modal_section.entry(open_folder.clone());
2764 }
2765 }
2766 }
2767 let mut modal_section = modal_section.render(window, cx).into_any_element();
2768
2769 let is_project_selected = state.servers.iter().any(|server| match server {
2770 RemoteEntry::Project { projects, .. } => projects
2771 .iter()
2772 .any(|(entry, _)| entry.focus_handle.contains_focused(window, cx)),
2773 RemoteEntry::SshConfig { .. } => false,
2774 });
2775
2776 Modal::new("remote-projects", None)
2777 .header(ModalHeader::new().headline("Remote Projects"))
2778 .section(
2779 Section::new().padded(false).child(
2780 v_flex()
2781 .min_h(rems(20.))
2782 .size_full()
2783 .relative()
2784 .child(ListSeparator)
2785 .child(
2786 canvas(
2787 |bounds, window, cx| {
2788 modal_section.prepaint_as_root(
2789 bounds.origin,
2790 bounds.size.into(),
2791 window,
2792 cx,
2793 );
2794 modal_section
2795 },
2796 |_, mut modal_section, window, cx| {
2797 modal_section.paint(window, cx);
2798 },
2799 )
2800 .size_full(),
2801 )
2802 .vertical_scrollbar_for(&state.scroll_handle, window, cx),
2803 ),
2804 )
2805 .footer(ModalFooter::new().end_slot({
2806 let confirm_button = |label: SharedString| {
2807 Button::new("select", label)
2808 .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
2809 .on_click(|_, window, cx| {
2810 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
2811 })
2812 };
2813
2814 if is_project_selected {
2815 h_flex()
2816 .gap_1()
2817 .child(
2818 Button::new("open_new_window", "New Window")
2819 .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
2820 .on_click(|_, window, cx| {
2821 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
2822 }),
2823 )
2824 .child(confirm_button("Open".into()))
2825 .into_any_element()
2826 } else {
2827 confirm_button("Select".into()).into_any_element()
2828 }
2829 }))
2830 .into_any_element()
2831 }
2832
2833 fn create_host_from_ssh_config(
2834 &mut self,
2835 ssh_config_host: &SharedString,
2836 cx: &mut Context<'_, Self>,
2837 ) -> SshServerIndex {
2838 let new_ix = Arc::new(AtomicUsize::new(0));
2839
2840 let update_new_ix = new_ix.clone();
2841 self.update_settings_file(cx, move |settings, _| {
2842 update_new_ix.store(
2843 settings
2844 .ssh_connections
2845 .as_ref()
2846 .map_or(0, |connections| connections.len()),
2847 atomic::Ordering::Release,
2848 );
2849 });
2850
2851 self.add_ssh_server(
2852 SshConnectionOptions {
2853 host: ssh_config_host.to_string().into(),
2854 ..SshConnectionOptions::default()
2855 },
2856 cx,
2857 );
2858 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2859 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2860 }
2861}
2862
2863fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2864 enum ConfigSource {
2865 User(String),
2866 Global(String),
2867 }
2868
2869 let mut streams = Vec::new();
2870 let mut tasks = Vec::new();
2871
2872 // Setup User Watcher
2873 let user_path = user_ssh_config_file();
2874 info!("SSH: Watching User Config at: {:?}", user_path);
2875
2876 // We clone 'fs' here because we might need it again for the global watcher.
2877 let (user_s, user_t) = watch_config_file(cx.background_executor(), fs.clone(), user_path);
2878 streams.push(user_s.map(ConfigSource::User).boxed());
2879 tasks.push(user_t);
2880
2881 // Setup Global Watcher
2882 if let Some(gp) = global_ssh_config_file() {
2883 info!("SSH: Watching Global Config at: {:?}", gp);
2884 let (global_s, global_t) =
2885 watch_config_file(cx.background_executor(), fs, gp.to_path_buf());
2886 streams.push(global_s.map(ConfigSource::Global).boxed());
2887 tasks.push(global_t);
2888 } else {
2889 debug!("SSH: No Global Config defined.");
2890 }
2891
2892 // Combine into a single stream so that only one is parsed at once.
2893 let mut merged_stream = futures::stream::select_all(streams);
2894
2895 cx.spawn(async move |remote_server_projects, cx| {
2896 let _tasks = tasks; // Keeps the background watchers alive
2897 let mut global_hosts = BTreeSet::default();
2898 let mut user_hosts = BTreeSet::default();
2899
2900 while let Some(event) = merged_stream.next().await {
2901 match event {
2902 ConfigSource::Global(content) => {
2903 global_hosts = parse_ssh_config_hosts(&content);
2904 }
2905 ConfigSource::User(content) => {
2906 user_hosts = parse_ssh_config_hosts(&content);
2907 }
2908 }
2909
2910 // Sync to Model
2911 if remote_server_projects
2912 .update(cx, |project, cx| {
2913 project.ssh_config_servers = global_hosts
2914 .iter()
2915 .chain(user_hosts.iter())
2916 .map(SharedString::from)
2917 .collect();
2918 cx.notify();
2919 })
2920 .is_err()
2921 {
2922 return;
2923 }
2924 }
2925 })
2926}
2927
2928fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2929 element.read(cx).text(cx).trim().to_string()
2930}
2931
2932impl ModalView for RemoteServerProjects {}
2933
2934impl Focusable for RemoteServerProjects {
2935 fn focus_handle(&self, cx: &App) -> FocusHandle {
2936 match &self.mode {
2937 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2938 _ => self.focus_handle.clone(),
2939 }
2940 }
2941}
2942
2943impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2944
2945impl Render for RemoteServerProjects {
2946 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2947 div()
2948 .elevation_3(cx)
2949 .w(rems(34.))
2950 .key_context("RemoteServerModal")
2951 .on_action(cx.listener(Self::cancel))
2952 .on_action(cx.listener(Self::confirm))
2953 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2954 this.focus_handle(cx).focus(window, cx);
2955 }))
2956 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2957 if matches!(this.mode, Mode::Default(_)) {
2958 cx.emit(DismissEvent)
2959 }
2960 }))
2961 .child(match &self.mode {
2962 Mode::Default(state) => self
2963 .render_default(state.clone(), window, cx)
2964 .into_any_element(),
2965 Mode::ViewServerOptions(state) => self
2966 .render_view_options(state.clone(), window, cx)
2967 .into_any_element(),
2968 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2969 Mode::CreateRemoteServer(state) => self
2970 .render_create_remote_server(state, window, cx)
2971 .into_any_element(),
2972 Mode::CreateRemoteDevContainer(state) => self
2973 .render_create_dev_container(state, window, cx)
2974 .into_any_element(),
2975 Mode::EditNickname(state) => self
2976 .render_edit_nickname(state, window, cx)
2977 .into_any_element(),
2978 #[cfg(target_os = "windows")]
2979 Mode::AddWslDistro(state) => self
2980 .render_add_wsl_distro(state, window, cx)
2981 .into_any_element(),
2982 })
2983 }
2984}