Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:33:20.050Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

remote_servers.rs

3218 lines · 120.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.