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