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