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