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