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