use crate::{ remote_connections::{ Connection, RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettings, SshConnection, SshConnectionHeader, connect, determine_paths_with_positions, open_remote_project, }, ssh_config::parse_ssh_config_hosts, }; use dev_container::{ DevContainerConfig, DevContainerContext, find_devcontainer_configs, start_dev_container_with_config, }; use editor::Editor; use futures::{FutureExt, channel::oneshot, future::Shared}; use gpui::{ Action, AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window, canvas, }; use log::{debug, info}; use open_path_prompt::OpenPathDelegate; use paths::{global_ssh_config_file, user_ssh_config_file}; use picker::{Picker, PickerDelegate}; use project::{Fs, Project}; use remote::{ RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, remote_client::ConnectionIdentifier, }; use settings::{ RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file, watch_config_file, }; use smol::stream::StreamExt as _; use std::{ borrow::Cow, collections::BTreeSet, path::PathBuf, rc::Rc, sync::{ Arc, atomic::{self, AtomicUsize}, }, }; use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*, }; use util::{ ResultExt, paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, }; use workspace::{ AppState, ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, notifications::{DetachAndPromptErr, NotificationId}, open_remote_project_with_existing_connection, }; pub struct RemoteServerProjects { mode: Mode, focus_handle: FocusHandle, workspace: WeakEntity, retained_connections: Vec>, ssh_config_updates: Task<()>, ssh_config_servers: BTreeSet, create_new_window: bool, dev_container_picker: Option>>, _subscription: Subscription, } struct CreateRemoteServer { address_editor: Entity, address_error: Option, ssh_prompt: Option>, _creating: Option>>, } impl CreateRemoteServer { fn new(window: &mut Window, cx: &mut App) -> Self { let address_editor = cx.new(|cx| Editor::single_line(window, cx)); address_editor.update(cx, |this, cx| { this.focus_handle(cx).focus(window, cx); }); Self { address_editor, address_error: None, ssh_prompt: None, _creating: None, } } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] enum DevContainerCreationProgress { SelectingConfig, Creating, Error(String), } #[derive(Clone)] struct CreateRemoteDevContainer { view_logs_entry: NavigableEntry, back_entry: NavigableEntry, progress: DevContainerCreationProgress, } impl CreateRemoteDevContainer { fn new(progress: DevContainerCreationProgress, cx: &mut Context) -> Self { let view_logs_entry = NavigableEntry::focusable(cx); let back_entry = NavigableEntry::focusable(cx); Self { view_logs_entry, back_entry, progress, } } } #[cfg(target_os = "windows")] struct AddWslDistro { picker: Entity>, connection_prompt: Option>, _creating: Option>, } #[cfg(target_os = "windows")] impl AddWslDistro { fn new(window: &mut Window, cx: &mut Context) -> Self { use crate::wsl_picker::{WslDistroSelected, WslPickerDelegate, WslPickerDismissed}; let delegate = WslPickerDelegate::new(); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); cx.subscribe_in( &picker, window, |this, _, _: &WslDistroSelected, window, cx| { this.confirm(&menu::Confirm, window, cx); }, ) .detach(); cx.subscribe_in( &picker, window, |this, _, _: &WslPickerDismissed, window, cx| { this.cancel(&menu::Cancel, window, cx); }, ) .detach(); AddWslDistro { picker, connection_prompt: None, _creating: None, } } } enum ProjectPickerData { Ssh { connection_string: SharedString, nickname: Option, }, Wsl { distro_name: SharedString, }, } struct ProjectPicker { data: ProjectPickerData, picker: Entity>, _path_task: Shared>>, } struct EditNicknameState { index: SshServerIndex, editor: Entity, } struct DevContainerPickerDelegate { selected_index: usize, candidates: Vec, matching_candidates: Vec, parent_modal: WeakEntity, } impl DevContainerPickerDelegate { fn new( candidates: Vec, parent_modal: WeakEntity, ) -> Self { Self { selected_index: 0, matching_candidates: candidates.clone(), candidates, parent_modal, } } } impl PickerDelegate for DevContainerPickerDelegate { type ListItem = AnyElement; fn match_count(&self) -> usize { self.matching_candidates.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context>, ) { self.selected_index = ix; } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Dev Container Configuration".into() } fn update_matches( &mut self, query: String, _window: &mut Window, _cx: &mut Context>, ) -> Task<()> { let query_lower = query.to_lowercase(); self.matching_candidates = self .candidates .iter() .filter(|c| { c.name.to_lowercase().contains(&query_lower) || c.config_path .to_string_lossy() .to_lowercase() .contains(&query_lower) }) .cloned() .collect(); self.selected_index = std::cmp::min( self.selected_index, self.matching_candidates.len().saturating_sub(1), ); Task::ready(()) } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { let selected_config = self.matching_candidates.get(self.selected_index).cloned(); self.parent_modal .update(cx, move |modal, cx| { if secondary { modal.edit_in_dev_container_json(selected_config.clone(), window, cx); } else if let Some((app_state, context)) = modal .workspace .read_with(cx, |workspace, cx| { let app_state = workspace.app_state().clone(); let context = DevContainerContext::from_workspace(workspace, cx)?; Some((app_state, context)) }) .ok() .flatten() { modal.open_dev_container(selected_config, app_state, context, window, cx); modal.view_in_progress_dev_container(window, cx); } else { log::error!("No active project directory for Dev Container"); } }) .ok(); } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { self.parent_modal .update(cx, |modal, cx| { modal.cancel(&menu::Cancel, window, cx); }) .ok(); } fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, _cx: &mut Context>, ) -> Option { let candidate = self.matching_candidates.get(ix)?; let config_path = candidate.config_path.display().to_string(); Some( ListItem::new(SharedString::from(format!("li-devcontainer-config-{}", ix))) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .toggle_state(selected) .start_slot(Icon::new(IconName::FileToml).color(Color::Muted)) .child( v_flex().child(Label::new(candidate.name.clone())).child( Label::new(config_path) .size(ui::LabelSize::Small) .color(Color::Muted), ), ) .into_any_element(), ) } fn render_footer( &self, _window: &mut Window, cx: &mut Context>, ) -> Option { Some( h_flex() .w_full() .p_1p5() .gap_1() .justify_start() .border_t_1() .border_color(cx.theme().colors().border_variant) .child( Button::new("run-action", "Start Dev Container") .key_binding( KeyBinding::for_action(&menu::Confirm, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx) }), ) .child( Button::new("run-action-secondary", "Open devcontainer.json") .key_binding( KeyBinding::for_action(&menu::SecondaryConfirm, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) }), ) .into_any_element(), ) } } impl EditNicknameState { fn new(index: SshServerIndex, window: &mut Window, cx: &mut App) -> Self { let this = Self { index, editor: cx.new(|cx| Editor::single_line(window, cx)), }; let starting_text = RemoteSettings::get_global(cx) .ssh_connections() .nth(index.0) .and_then(|state| state.nickname) .filter(|text| !text.is_empty()); this.editor.update(cx, |this, cx| { this.set_placeholder_text("Add a nickname for this server", window, cx); if let Some(starting_text) = starting_text { this.set_text(starting_text, window, cx); } }); this.editor.focus_handle(cx).focus(window, cx); this } } impl Focusable for ProjectPicker { fn focus_handle(&self, cx: &App) -> FocusHandle { self.picker.focus_handle(cx) } } impl ProjectPicker { fn new( create_new_window: bool, index: ServerIndex, connection: RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx).show_hidden(); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) .modal(false); picker.set_query(&home_dir.to_string(), window, cx); picker }); let data = match &connection { RemoteConnectionOptions::Ssh(connection) => ProjectPickerData::Ssh { connection_string: connection.connection_string().into(), nickname: connection.nickname.clone().map(|nick| nick.into()), }, RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl { distro_name: connection.distro_name.clone().into(), }, RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh { // Not implemented as a project picker at this time connection_string: "".into(), nickname: None, }, #[cfg(any(test, feature = "test-support"))] RemoteConnectionOptions::Mock(options) => ProjectPickerData::Ssh { connection_string: format!("mock-{}", options.id).into(), nickname: None, }, }; let _path_task = cx .spawn_in(window, { let workspace = workspace; async move |this, cx| { let Ok(Some(paths)) = rx.await else { workspace .update_in(cx, |workspace, window, cx| { let fs = workspace.project().read(cx).fs().clone(); let weak = cx.entity().downgrade(); workspace.toggle_modal(window, cx, |window, cx| { RemoteServerProjects::new( create_new_window, fs, window, weak, cx, ) }); }) .log_err()?; return None; }; let app_state = workspace .read_with(cx, |workspace, _| workspace.app_state().clone()) .ok()?; let remote_connection = project.read_with(cx, |project, cx| { project.remote_client()?.read(cx).connection() })?; let (paths, paths_with_positions) = determine_paths_with_positions(&remote_connection, paths).await; cx.update(|_, cx| { let fs = app_state.fs.clone(); update_settings_file(fs, cx, { let paths = paths .iter() .map(|path| path.to_string_lossy().into_owned()) .collect(); move |settings, _| match index { ServerIndex::Ssh(index) => { if let Some(server) = settings .remote .ssh_connections .as_mut() .and_then(|connections| connections.get_mut(index.0)) { server.projects.insert(RemoteProject { paths }); }; } ServerIndex::Wsl(index) => { if let Some(server) = settings .remote .wsl_connections .as_mut() .and_then(|connections| connections.get_mut(index.0)) { server.projects.insert(RemoteProject { paths }); }; } } }); }) .log_err(); let options = cx .update(|_, cx| (app_state.build_window_options)(None, cx)) .log_err()?; let window = cx .open_window(options, |window, cx| { let workspace = cx.new(|cx| { telemetry::event!("SSH Project Created"); Workspace::new(None, project.clone(), app_state.clone(), window, cx) }); cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) }) .log_err()?; let items = open_remote_project_with_existing_connection( connection, project, paths, app_state, window, cx, ) .await .log_err(); if let Some(items) = items { for (item, path) in items.into_iter().zip(paths_with_positions) { let Some(item) = item else { continue; }; let Some(row) = path.row else { continue; }; if let Some(active_editor) = item.downcast::() { window .update(cx, |_, window, cx| { active_editor.update(cx, |editor, cx| { let row = row.saturating_sub(1); let col = path.column.unwrap_or(0).saturating_sub(1); let Some(buffer) = editor.buffer().read(cx).as_singleton() else { return; }; let buffer_snapshot = buffer.read(cx).snapshot(); let point = buffer_snapshot.point_from_external_input(row, col); editor.go_to_singleton_buffer_point(point, window, cx); }); }) .ok(); } } } this.update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); Some(()) } }) .shared(); cx.new(|_| Self { _path_task, picker, data, }) } } impl gpui::Render for ProjectPicker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .child(match &self.data { ProjectPickerData::Ssh { connection_string, nickname, } => SshConnectionHeader { connection_string: connection_string.clone(), paths: Default::default(), nickname: nickname.clone(), is_wsl: false, is_devcontainer: false, } .render(window, cx), ProjectPickerData::Wsl { distro_name } => SshConnectionHeader { connection_string: distro_name.clone(), paths: Default::default(), nickname: None, is_wsl: true, is_devcontainer: false, } .render(window, cx), }) .child( div() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(self.picker.clone()), ) } } #[repr(transparent)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] struct SshServerIndex(usize); impl std::fmt::Display for SshServerIndex { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } #[repr(transparent)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] struct WslServerIndex(usize); impl std::fmt::Display for WslServerIndex { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] enum ServerIndex { Ssh(SshServerIndex), Wsl(WslServerIndex), } impl From for ServerIndex { fn from(index: SshServerIndex) -> Self { Self::Ssh(index) } } impl From for ServerIndex { fn from(index: WslServerIndex) -> Self { Self::Wsl(index) } } #[derive(Clone)] enum RemoteEntry { Project { open_folder: NavigableEntry, projects: Vec<(NavigableEntry, RemoteProject)>, configure: NavigableEntry, connection: Connection, index: ServerIndex, }, SshConfig { open_folder: NavigableEntry, host: SharedString, }, } impl RemoteEntry { fn is_from_zed(&self) -> bool { matches!(self, Self::Project { .. }) } fn connection(&self) -> Cow<'_, Connection> { match self { Self::Project { connection, .. } => Cow::Borrowed(connection), Self::SshConfig { host, .. } => Cow::Owned( SshConnection { host: host.to_string(), ..SshConnection::default() } .into(), ), } } } #[derive(Clone)] struct DefaultState { scroll_handle: ScrollHandle, add_new_server: NavigableEntry, add_new_devcontainer: NavigableEntry, add_new_wsl: NavigableEntry, servers: Vec, } impl DefaultState { fn new(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { let handle = ScrollHandle::new(); let add_new_server = NavigableEntry::new(&handle, cx); let add_new_devcontainer = NavigableEntry::new(&handle, cx); let add_new_wsl = NavigableEntry::new(&handle, cx); let ssh_settings = RemoteSettings::get_global(cx); let read_ssh_config = ssh_settings.read_ssh_config; let ssh_servers = ssh_settings .ssh_connections() .enumerate() .map(|(index, connection)| { let open_folder = NavigableEntry::new(&handle, cx); let configure = NavigableEntry::new(&handle, cx); let projects = connection .projects .iter() .map(|project| (NavigableEntry::new(&handle, cx), project.clone())) .collect(); RemoteEntry::Project { open_folder, configure, projects, index: ServerIndex::Ssh(SshServerIndex(index)), connection: connection.into(), } }); let wsl_servers = ssh_settings .wsl_connections() .enumerate() .map(|(index, connection)| { let open_folder = NavigableEntry::new(&handle, cx); let configure = NavigableEntry::new(&handle, cx); let projects = connection .projects .iter() .map(|project| (NavigableEntry::new(&handle, cx), project.clone())) .collect(); RemoteEntry::Project { open_folder, configure, projects, index: ServerIndex::Wsl(WslServerIndex(index)), connection: connection.into(), } }); let mut servers = ssh_servers.chain(wsl_servers).collect::>(); if read_ssh_config { let mut extra_servers_from_config = ssh_config_servers.clone(); for server in &servers { if let RemoteEntry::Project { connection: Connection::Ssh(ssh_options), .. } = server { extra_servers_from_config.remove(&SharedString::new(ssh_options.host.clone())); } } servers.extend(extra_servers_from_config.into_iter().map(|host| { RemoteEntry::SshConfig { open_folder: NavigableEntry::new(&handle, cx), host, } })); } Self { scroll_handle: handle, add_new_server, add_new_devcontainer, add_new_wsl, servers, } } } #[derive(Clone)] enum ViewServerOptionsState { Ssh { connection: SshConnectionOptions, server_index: SshServerIndex, entries: [NavigableEntry; 4], }, Wsl { connection: WslConnectionOptions, server_index: WslServerIndex, entries: [NavigableEntry; 2], }, } impl ViewServerOptionsState { fn entries(&self) -> &[NavigableEntry] { match self { Self::Ssh { entries, .. } => entries, Self::Wsl { entries, .. } => entries, } } } enum Mode { Default(DefaultState), ViewServerOptions(ViewServerOptionsState), EditNickname(EditNicknameState), ProjectPicker(Entity), CreateRemoteServer(CreateRemoteServer), CreateRemoteDevContainer(CreateRemoteDevContainer), #[cfg(target_os = "windows")] AddWslDistro(AddWslDistro), } impl Mode { fn default_mode(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { Self::Default(DefaultState::new(ssh_config_servers, cx)) } } impl RemoteServerProjects { #[cfg(target_os = "windows")] pub fn wsl( create_new_window: bool, fs: Arc, window: &mut Window, workspace: WeakEntity, cx: &mut Context, ) -> Self { Self::new_inner( Mode::AddWslDistro(AddWslDistro::new(window, cx)), create_new_window, fs, window, workspace, cx, ) } pub fn new( create_new_window: bool, fs: Arc, window: &mut Window, workspace: WeakEntity, cx: &mut Context, ) -> Self { Self::new_inner( Mode::default_mode(&BTreeSet::new(), cx), create_new_window, fs, window, workspace, cx, ) } /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode. /// Used when suggesting dev container connection from toast notification. pub fn new_dev_container( fs: Arc, configs: Vec, app_state: Arc, dev_container_context: Option, window: &mut Window, workspace: WeakEntity, cx: &mut Context, ) -> Self { let initial_mode = if configs.len() > 1 { DevContainerCreationProgress::SelectingConfig } else { DevContainerCreationProgress::Creating }; let mut this = Self::new_inner( Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(initial_mode, cx)), false, fs, window, workspace, cx, ); if configs.len() > 1 { let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); this.dev_container_picker = Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); } else if let Some(context) = dev_container_context { let config = configs.into_iter().next(); this.open_dev_container(config, app_state, context, window, cx); this.view_in_progress_dev_container(window, cx); } else { log::error!("No active project directory for Dev Container"); } this } pub fn popover( fs: Arc, workspace: WeakEntity, create_new_window: bool, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let server = Self::new(create_new_window, fs, window, workspace, cx); server.focus_handle(cx).focus(window, cx); server }) } fn new_inner( mode: Mode, create_new_window: bool, fs: Arc, window: &mut Window, workspace: WeakEntity, cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let mut read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; let ssh_config_updates = if read_ssh_config { spawn_ssh_config_watch(fs.clone(), cx) } else { Task::ready(()) }; let mut base_style = window.text_style(); base_style.refine(&gpui::TextStyleRefinement { color: Some(cx.theme().colors().editor_foreground), ..Default::default() }); let _subscription = cx.observe_global_in::(window, move |recent_projects, _, cx| { let new_read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; if read_ssh_config != new_read_ssh_config { read_ssh_config = new_read_ssh_config; if read_ssh_config { recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx); } else { recent_projects.ssh_config_servers.clear(); recent_projects.ssh_config_updates = Task::ready(()); } } }); Self { mode, focus_handle, workspace, retained_connections: Vec::new(), ssh_config_updates, ssh_config_servers: BTreeSet::new(), create_new_window, dev_container_picker: None, _subscription, } } fn project_picker( create_new_window: bool, index: ServerIndex, connection_options: remote::RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, window: &mut Window, cx: &mut Context, workspace: WeakEntity, ) -> Self { let fs = project.read(cx).fs().clone(); let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx); this.mode = Mode::ProjectPicker(ProjectPicker::new( create_new_window, index, connection_options, project, home_dir, workspace, window, cx, )); cx.notify(); this } fn create_ssh_server( &mut self, editor: Entity, window: &mut Window, cx: &mut Context, ) { let input = get_text(&editor, cx); if input.is_empty() { return; } let connection_options = match SshConnectionOptions::parse_command_line(&input) { Ok(c) => c, Err(e) => { self.mode = Mode::CreateRemoteServer(CreateRemoteServer { address_editor: editor, address_error: Some(format!("could not parse: {:?}", e).into()), ssh_prompt: None, _creating: None, }); return; } }; let ssh_prompt = cx.new(|cx| { RemoteConnectionPrompt::new( connection_options.connection_string(), connection_options.nickname.clone(), false, false, window, cx, ) }); let connection = connect( ConnectionIdentifier::setup(), RemoteConnectionOptions::Ssh(connection_options.clone()), ssh_prompt.clone(), window, cx, ) .prompt_err("Failed to connect", window, cx, |_, _, _| None); let address_editor = editor.clone(); let creating = cx.spawn_in(window, async move |this, cx| { match connection.await { Some(Some(client)) => this .update_in(cx, |this, window, cx| { info!("ssh server created"); telemetry::event!("SSH Server Created"); this.retained_connections.push(client); this.add_ssh_server(connection_options, cx); this.mode = Mode::default_mode(&this.ssh_config_servers, cx); this.focus_handle(cx).focus(window, cx); cx.notify() }) .log_err(), _ => this .update(cx, |this, cx| { address_editor.update(cx, |this, _| { this.set_read_only(false); }); this.mode = Mode::CreateRemoteServer(CreateRemoteServer { address_editor, address_error: None, ssh_prompt: None, _creating: None, }); cx.notify() }) .log_err(), }; None }); editor.update(cx, |this, _| { this.set_read_only(true); }); self.mode = Mode::CreateRemoteServer(CreateRemoteServer { address_editor: editor, address_error: None, ssh_prompt: Some(ssh_prompt), _creating: Some(creating), }); } #[cfg(target_os = "windows")] fn connect_wsl_distro( &mut self, picker: Entity>, distro: String, window: &mut Window, cx: &mut Context, ) { let connection_options = WslConnectionOptions { distro_name: distro, user: None, }; let prompt = cx.new(|cx| { RemoteConnectionPrompt::new( connection_options.distro_name.clone(), None, true, false, window, cx, ) }); let connection = connect( ConnectionIdentifier::setup(), connection_options.clone().into(), prompt.clone(), window, cx, ) .prompt_err("Failed to connect", window, cx, |_, _, _| None); let wsl_picker = picker.clone(); let creating = cx.spawn_in(window, async move |this, cx| { match connection.await { Some(Some(client)) => this.update_in(cx, |this, window, cx| { telemetry::event!("WSL Distro Added"); this.retained_connections.push(client); let Some(fs) = this .workspace .read_with(cx, |workspace, cx| { workspace.project().read(cx).fs().clone() }) .log_err() else { return; }; crate::add_wsl_distro(fs, &connection_options, cx); this.mode = Mode::default_mode(&BTreeSet::new(), cx); this.focus_handle(cx).focus(window, cx); cx.notify(); }), _ => this.update(cx, |this, cx| { this.mode = Mode::AddWslDistro(AddWslDistro { picker: wsl_picker, connection_prompt: None, _creating: None, }); cx.notify(); }), } .log_err(); }); self.mode = Mode::AddWslDistro(AddWslDistro { picker, connection_prompt: Some(prompt), _creating: Some(creating), }); } fn view_server_options( &mut self, (server_index, connection): (ServerIndex, RemoteConnectionOptions), window: &mut Window, cx: &mut Context, ) { self.mode = Mode::ViewServerOptions(match (server_index, connection) { (ServerIndex::Ssh(server_index), RemoteConnectionOptions::Ssh(connection)) => { ViewServerOptionsState::Ssh { connection, server_index, entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)), } } (ServerIndex::Wsl(server_index), RemoteConnectionOptions::Wsl(connection)) => { ViewServerOptionsState::Wsl { connection, server_index, entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)), } } _ => { log::error!("server index and connection options mismatch"); self.mode = Mode::default_mode(&BTreeSet::default(), cx); return; } }); self.focus_handle(cx).focus(window, cx); cx.notify(); } fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context) { self.mode = Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new( DevContainerCreationProgress::Creating, cx, )); self.focus_handle(cx).focus(window, cx); cx.notify(); } fn create_remote_project( &mut self, index: ServerIndex, connection_options: RemoteConnectionOptions, window: &mut Window, cx: &mut Context, ) { let Some(workspace) = self.workspace.upgrade() else { return; }; let create_new_window = self.create_new_window; workspace.update(cx, |_, cx| { cx.defer_in(window, move |workspace, window, cx| { let app_state = workspace.app_state().clone(); workspace.toggle_modal(window, cx, |window, cx| { RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) }); // can be None if another copy of this modal opened in the meantime let Some(modal) = workspace.active_modal::(cx) else { return; }; let prompt = modal.read(cx).prompt.clone(); let connect = connect( ConnectionIdentifier::setup(), connection_options.clone(), prompt, window, cx, ) .prompt_err("Failed to connect", window, cx, |_, _, _| None); cx.spawn_in(window, async move |workspace, cx| { let session = connect.await; workspace.update(cx, |workspace, cx| { if let Some(prompt) = workspace.active_modal::(cx) { prompt.update(cx, |prompt, cx| prompt.finished(cx)) } })?; let Some(Some(session)) = session else { return workspace.update_in(cx, |workspace, window, cx| { let weak = cx.entity().downgrade(); let fs = workspace.project().read(cx).fs().clone(); workspace.toggle_modal(window, cx, |window, cx| { RemoteServerProjects::new(create_new_window, fs, window, weak, cx) }); }); }; let (path_style, project) = cx.update(|_, cx| { ( session.read(cx).path_style(), project::Project::remote( session, app_state.client.clone(), app_state.node_runtime.clone(), app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), true, cx, ), ) })?; let home_dir = project .read_with(cx, |project, cx| project.resolve_abs_path("~", cx)) .await .and_then(|path| path.into_abs_path()) .map(|path| RemotePathBuf::new(path, path_style)) .unwrap_or_else(|| match path_style { PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix), PathStyle::Windows => { RemotePathBuf::from_str("C:\\", PathStyle::Windows) } }); workspace .update_in(cx, |workspace, window, cx| { let weak = cx.entity().downgrade(); workspace.toggle_modal(window, cx, |window, cx| { RemoteServerProjects::project_picker( create_new_window, index, connection_options, project, home_dir, window, cx, weak, ) }); }) .ok(); Ok(()) }) .detach(); }) }) } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { match &self.mode { Mode::Default(_) | Mode::ViewServerOptions(_) => {} Mode::ProjectPicker(_) => {} Mode::CreateRemoteServer(state) => { if let Some(prompt) = state.ssh_prompt.as_ref() { prompt.update(cx, |prompt, cx| { prompt.confirm(window, cx); }); return; } self.create_ssh_server(state.address_editor.clone(), window, cx); } Mode::CreateRemoteDevContainer(_) => {} Mode::EditNickname(state) => { let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty()); let index = state.index; self.update_settings_file(cx, move |setting, _| { if let Some(connections) = setting.ssh_connections.as_mut() && let Some(connection) = connections.get_mut(index.0) { connection.nickname = text; } }); self.mode = Mode::default_mode(&self.ssh_config_servers, cx); self.focus_handle.focus(window, cx); } #[cfg(target_os = "windows")] Mode::AddWslDistro(state) => { let delegate = &state.picker.read(cx).delegate; let distro = delegate.selected_distro().unwrap(); self.connect_wsl_distro(state.picker.clone(), distro, window, cx); } } } fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { match &self.mode { Mode::Default(_) => cx.emit(DismissEvent), Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => { let new_state = CreateRemoteServer::new(window, cx); let old_prompt = state.address_editor.read(cx).text(cx); new_state.address_editor.update(cx, |this, cx| { this.set_text(old_prompt, window, cx); }); self.mode = Mode::CreateRemoteServer(new_state); cx.notify(); } Mode::CreateRemoteDevContainer(CreateRemoteDevContainer { progress: DevContainerCreationProgress::Error(_), .. }) => { cx.emit(DismissEvent); } _ => { self.mode = Mode::default_mode(&self.ssh_config_servers, cx); self.focus_handle(cx).focus(window, cx); cx.notify(); } } } fn render_remote_connection( &mut self, ix: usize, remote_server: RemoteEntry, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let connection = remote_server.connection().into_owned(); let (main_label, aux_label, is_wsl) = match &connection { Connection::Ssh(connection) => { if let Some(nickname) = connection.nickname.clone() { let aux_label = SharedString::from(format!("({})", connection.host)); (nickname, Some(aux_label), false) } else { (connection.host.clone(), None, false) } } Connection::Wsl(wsl_connection_options) => { (wsl_connection_options.distro_name.clone(), None, true) } Connection::DevContainer(dev_container_options) => { (dev_container_options.name.clone(), None, false) } }; v_flex() .w_full() .child(ListSeparator) .child( h_flex() .group("ssh-server") .w_full() .pt_0p5() .px_3() .gap_1() .overflow_hidden() .child( h_flex() .gap_1() .max_w_96() .overflow_hidden() .text_ellipsis() .when(is_wsl, |this| { this.child( Label::new("WSL:") .size(LabelSize::Small) .color(Color::Muted), ) }) .child( Label::new(main_label) .size(LabelSize::Small) .color(Color::Muted), ), ) .children( aux_label.map(|label| { Label::new(label).size(LabelSize::Small).color(Color::Muted) }), ), ) .child(match &remote_server { RemoteEntry::Project { open_folder, projects, configure, connection, index, } => { let index = *index; List::new() .empty_message("No projects.") .children(projects.iter().enumerate().map(|(pix, p)| { v_flex().gap_0p5().child(self.render_remote_project( index, remote_server.clone(), pix, p, window, cx, )) })) .child( h_flex() .id(("new-remote-project-container", ix)) .track_focus(&open_folder.focus_handle) .anchor_scroll(open_folder.scroll_anchor.clone()) .on_action(cx.listener({ let connection = connection.clone(); move |this, _: &menu::Confirm, window, cx| { this.create_remote_project( index, connection.clone().into(), window, cx, ); } })) .child( ListItem::new(("new-remote-project", ix)) .toggle_state( open_folder.focus_handle.contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .child(Label::new("Open Folder")) .on_click(cx.listener({ let connection = connection.clone(); move |this, _, window, cx| { this.create_remote_project( index, connection.clone().into(), window, cx, ); } })), ), ) .child( h_flex() .id(("server-options-container", ix)) .track_focus(&configure.focus_handle) .anchor_scroll(configure.scroll_anchor.clone()) .on_action(cx.listener({ let connection = connection.clone(); move |this, _: &menu::Confirm, window, cx| { this.view_server_options( (index, connection.clone().into()), window, cx, ); } })) .child( ListItem::new(("server-options", ix)) .toggle_state( configure.focus_handle.contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::Settings).color(Color::Muted), ) .child(Label::new("View Server Options")) .on_click(cx.listener({ let ssh_connection = connection.clone(); move |this, _, window, cx| { this.view_server_options( (index, ssh_connection.clone().into()), window, cx, ); } })), ), ) } RemoteEntry::SshConfig { open_folder, host } => List::new().child( h_flex() .id(("new-remote-project-container", ix)) .track_focus(&open_folder.focus_handle) .anchor_scroll(open_folder.scroll_anchor.clone()) .on_action(cx.listener({ let connection = connection.clone(); let host = host.clone(); move |this, _: &menu::Confirm, window, cx| { let new_ix = this.create_host_from_ssh_config(&host, cx); this.create_remote_project( new_ix.into(), connection.clone().into(), window, cx, ); } })) .child( ListItem::new(("new-remote-project", ix)) .toggle_state(open_folder.focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .child(Label::new("Open Folder")) .on_click(cx.listener({ let host = host.clone(); move |this, _, window, cx| { let new_ix = this.create_host_from_ssh_config(&host, cx); this.create_remote_project( new_ix.into(), connection.clone().into(), window, cx, ); } })), ), ), }) } fn render_remote_project( &mut self, server_ix: ServerIndex, server: RemoteEntry, ix: usize, (navigation, project): &(NavigableEntry, RemoteProject), window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let create_new_window = self.create_new_window; let is_from_zed = server.is_from_zed(); let element_id_base = SharedString::from(format!( "remote-project-{}", match server_ix { ServerIndex::Ssh(index) => format!("ssh-{index}"), ServerIndex::Wsl(index) => format!("wsl-{index}"), } )); let container_element_id_base = SharedString::from(format!("remote-project-container-{element_id_base}")); let callback = Rc::new({ let project = project.clone(); move |remote_server_projects: &mut Self, secondary_confirm: bool, window: &mut Window, cx: &mut Context| { let Some(app_state) = remote_server_projects .workspace .read_with(cx, |workspace, _| workspace.app_state().clone()) .log_err() else { return; }; let project = project.clone(); let server = server.connection().into_owned(); cx.emit(DismissEvent); let replace_window = match (create_new_window, secondary_confirm) { (true, false) | (false, true) => None, (true, true) | (false, false) => { window.window_handle().downcast::() } }; cx.spawn_in(window, async move |_, cx| { let result = open_remote_project( server.into(), project.paths.into_iter().map(PathBuf::from).collect(), app_state, OpenOptions { replace_window, ..OpenOptions::default() }, cx, ) .await; if let Err(e) = result { log::error!("Failed to connect: {e:#}"); cx.prompt( gpui::PromptLevel::Critical, "Failed to connect", Some(&e.to_string()), &["Ok"], ) .await .ok(); } }) .detach(); } }); div() .id((container_element_id_base, ix)) .track_focus(&navigation.focus_handle) .anchor_scroll(navigation.scroll_anchor.clone()) .on_action(cx.listener({ let callback = callback.clone(); move |this, _: &menu::Confirm, window, cx| { callback(this, false, window, cx); } })) .on_action(cx.listener({ let callback = callback.clone(); move |this, _: &menu::SecondaryConfirm, window, cx| { callback(this, true, window, cx); } })) .child( ListItem::new((element_id_base, ix)) .toggle_state(navigation.focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::Folder) .color(Color::Muted) .size(IconSize::Small), ) .child(Label::new(project.paths.join(", ")).truncate_start()) .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { let secondary_confirm = e.modifiers().platform; callback(this, secondary_confirm, window, cx) })) .tooltip(Tooltip::text(project.paths.join("\n"))) .when(is_from_zed, |server_list_item| { server_list_item.end_hover_slot::(Some( div() .mr_2() .child({ let project = project.clone(); // Right-margin to offset it from the Scrollbar IconButton::new("remove-remote-project", IconName::Trash) .icon_size(IconSize::Small) .shape(IconButtonShape::Square) .size(ButtonSize::Large) .tooltip(Tooltip::text("Delete Remote Project")) .on_click(cx.listener(move |this, _, _, cx| { this.delete_remote_project(server_ix, &project, cx) })) }) .into_any_element(), )) }), ) } fn update_settings_file( &mut self, cx: &mut Context, f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static, ) { let Some(fs) = self .workspace .read_with(cx, |workspace, _| workspace.app_state().fs.clone()) .log_err() else { return; }; update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx)); } fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context) { self.update_settings_file(cx, move |setting, _| { if let Some(connections) = setting.ssh_connections.as_mut() && connections.get(server.0).is_some() { connections.remove(server.0); } }); } fn delete_remote_project( &mut self, server: ServerIndex, project: &RemoteProject, cx: &mut Context, ) { match server { ServerIndex::Ssh(server) => { self.delete_ssh_project(server, project, cx); } ServerIndex::Wsl(server) => { self.delete_wsl_project(server, project, cx); } } } fn delete_ssh_project( &mut self, server: SshServerIndex, project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); self.update_settings_file(cx, move |setting, _| { if let Some(server) = setting .ssh_connections .as_mut() .and_then(|connections| connections.get_mut(server.0)) { server.projects.remove(&project); } }); } fn delete_wsl_project( &mut self, server: WslServerIndex, project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); self.update_settings_file(cx, move |setting, _| { if let Some(server) = setting .wsl_connections .as_mut() .and_then(|connections| connections.get_mut(server.0)) { server.projects.remove(&project); } }); } fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context) { self.update_settings_file(cx, move |setting, _| { if let Some(connections) = setting.wsl_connections.as_mut() { connections.remove(server.0); } }); } fn add_ssh_server( &mut self, connection_options: remote::SshConnectionOptions, cx: &mut Context, ) { self.update_settings_file(cx, move |setting, _| { setting .ssh_connections .get_or_insert(Default::default()) .push(SshConnection { host: connection_options.host.to_string(), username: connection_options.username, port: connection_options.port, projects: BTreeSet::new(), nickname: None, args: connection_options.args.unwrap_or_default(), upload_binary_over_ssh: None, port_forwards: connection_options.port_forwards, connection_timeout: connection_options.connection_timeout, }) }); } fn edit_in_dev_container_json( &mut self, config: Option, window: &mut Window, cx: &mut Context, ) { let Some(workspace) = self.workspace.upgrade() else { cx.emit(DismissEvent); cx.notify(); return; }; let config_path = config .map(|c| c.config_path) .unwrap_or_else(|| PathBuf::from(".devcontainer/devcontainer.json")); workspace.update(cx, |workspace, cx| { let project = workspace.project().clone(); let worktree = project .read(cx) .visible_worktrees(cx) .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); if let Some(worktree) = worktree { let tree_id = worktree.read(cx).id(); let devcontainer_path = match RelPath::new(&config_path, util::paths::PathStyle::Posix) { Ok(path) => path.into_owned(), Err(error) => { log::error!( "Invalid devcontainer path: {} - {}", config_path.display(), error ); return; } }; cx.spawn_in(window, async move |workspace, cx| { workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( (tree_id, devcontainer_path), None, true, window, cx, ) })? .await }) .detach(); } else { return; } }); cx.emit(DismissEvent); cx.notify(); } fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context) { let configs = self .workspace .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx)) .unwrap_or_default(); if configs.len() > 1 { let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); self.dev_container_picker = Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); let state = CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx); self.mode = Mode::CreateRemoteDevContainer(state); cx.notify(); } else if let Some((app_state, context)) = self .workspace .read_with(cx, |workspace, cx| { let app_state = workspace.app_state().clone(); let context = DevContainerContext::from_workspace(workspace, cx)?; Some((app_state, context)) }) .ok() .flatten() { let config = configs.into_iter().next(); self.open_dev_container(config, app_state, context, window, cx); self.view_in_progress_dev_container(window, cx); } else { log::error!("No active project directory for Dev Container"); } } fn open_dev_container( &self, config: Option, app_state: Arc, context: DevContainerContext, window: &mut Window, cx: &mut Context, ) { let replace_window = window.window_handle().downcast::(); let app_state = Arc::downgrade(&app_state); cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = match start_dev_container_with_config(context, config).await { Ok((c, s)) => (Connection::DevContainer(c), s), Err(e) => { log::error!("Failed to start dev container: {:?}", e); cx.prompt( gpui::PromptLevel::Critical, "Failed to start Dev Container. See logs for details", Some(&format!("{e}")), &["Ok"], ) .await .ok(); entity .update_in(cx, |remote_server_projects, window, cx| { remote_server_projects.mode = Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new( DevContainerCreationProgress::Error(format!("{e}")), cx, )); remote_server_projects.focus_handle(cx).focus(window, cx); }) .ok(); return; } }; entity .update(cx, |_, cx| { cx.emit(DismissEvent); }) .log_err(); let Some(app_state) = app_state.upgrade() else { return; }; let result = open_remote_project( connection.into(), vec![starting_dir].into_iter().map(PathBuf::from).collect(), app_state, OpenOptions { replace_window, ..OpenOptions::default() }, cx, ) .await; if let Err(e) = result { log::error!("Failed to connect: {e:#}"); cx.prompt( gpui::PromptLevel::Critical, "Failed to connect", Some(&e.to_string()), &["Ok"], ) .await .ok(); } }) .detach(); } fn render_create_dev_container( &self, state: &CreateRemoteDevContainer, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { match &state.progress { DevContainerCreationProgress::Error(message) => { let view = Navigable::new( div() .child( div().track_focus(&self.focus_handle(cx)).size_full().child( v_flex().py_1().child( ListItem::new("Error") .inset(true) .selectable(false) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::XCircle).color(Color::Error), ) .child(Label::new("Error Creating Dev Container:")) .child(Label::new(message).buffer_font(cx)), ), ), ) .child(ListSeparator) .child( div() .id("devcontainer-see-log") .track_focus(&state.view_logs_entry.focus_handle) .on_action(cx.listener(|_, _: &menu::Confirm, window, cx| { window.dispatch_action(Box::new(OpenLog), cx); cx.emit(DismissEvent); cx.notify(); })) .child( ListItem::new("li-devcontainer-see-log") .toggle_state( state .view_logs_entry .focus_handle .contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::File).color(Color::Muted)) .child(Label::new("Open Zed Log")) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(Box::new(OpenLog), cx); cx.emit(DismissEvent); cx.notify(); })), ), ) .child( div() .id("devcontainer-go-back") .track_focus(&state.back_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.cancel(&menu::Cancel, window, cx); cx.notify(); })) .child( ListItem::new("li-devcontainer-go-back") .toggle_state( state .back_entry .focus_handle .contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Exit).color(Color::Muted)) .child(Label::new("Exit")) .on_click(cx.listener(|this, _, window, cx| { this.cancel(&menu::Cancel, window, cx); cx.notify(); })), ), ) .into_any_element(), ) .entry(state.view_logs_entry.clone()) .entry(state.back_entry.clone()); view.render(window, cx).into_any_element() } DevContainerCreationProgress::SelectingConfig => { self.render_config_selection(window, cx).into_any_element() } DevContainerCreationProgress::Creating => { self.focus_handle(cx).focus(window, cx); div() .track_focus(&self.focus_handle(cx)) .size_full() .child( v_flex() .pb_1() .child( ModalHeader::new().child( Headline::new("Dev Containers").size(HeadlineSize::XSmall), ), ) .child(ListSeparator) .child( ListItem::new("creating") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .disabled(true) .start_slot( Icon::new(IconName::ArrowCircle) .color(Color::Muted) .with_rotate_animation(2), ) .child( h_flex() .opacity(0.6) .gap_1() .child(Label::new("Creating Dev Container")) .child(LoadingLabel::new("")), ), ), ) .into_any_element() } } } fn render_config_selection( &self, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let Some(picker) = &self.dev_container_picker else { return div().into_any_element(); }; let content = v_flex().pb_1().child(picker.clone().into_any_element()); picker.focus_handle(cx).focus(window, cx); content.into_any_element() } fn render_create_remote_server( &self, state: &CreateRemoteServer, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let ssh_prompt = state.ssh_prompt.clone(); state.address_editor.update(cx, |editor, cx| { if editor.text(cx).is_empty() { editor.set_placeholder_text("ssh user@example -p 2222", window, cx); } }); let theme = cx.theme(); v_flex() .track_focus(&self.focus_handle(cx)) .id("create-remote-server") .overflow_hidden() .size_full() .flex_1() .child( div() .p_2() .border_b_1() .border_color(theme.colors().border_variant) .child(state.address_editor.clone()), ) .child( h_flex() .bg(theme.colors().editor_background) .rounded_b_sm() .w_full() .map(|this| { if let Some(ssh_prompt) = ssh_prompt { this.child(h_flex().w_full().child(ssh_prompt)) } else if let Some(address_error) = &state.address_error { this.child( h_flex().p_2().w_full().gap_2().child( Label::new(address_error.clone()) .size(LabelSize::Small) .color(Color::Error), ), ) } else { this.child( h_flex() .p_2() .w_full() .gap_1() .child( Label::new( "Enter the command you use to SSH into this server.", ) .color(Color::Muted) .size(LabelSize::Small), ) .child( Button::new("learn-more", "Learn More") .label_size(LabelSize::Small) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall), ) .on_click(|_, _, cx| { cx.open_url( "https://zed.dev/docs/remote-development", ); }), ), ) } }), ) } #[cfg(target_os = "windows")] fn render_add_wsl_distro( &self, state: &AddWslDistro, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let connection_prompt = state.connection_prompt.clone(); state.picker.update(cx, |picker, cx| { picker.focus_handle(cx).focus(window, cx); }); v_flex() .id("add-wsl-distro") .overflow_hidden() .size_full() .flex_1() .map(|this| { if let Some(connection_prompt) = connection_prompt { this.child(connection_prompt) } else { this.child(state.picker.clone()) } }) } fn render_view_options( &mut self, options: ViewServerOptionsState, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let last_entry = options.entries().last().unwrap(); let mut view = Navigable::new( div() .track_focus(&self.focus_handle(cx)) .size_full() .child(match &options { ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader { connection_string: connection.host.to_string().into(), paths: Default::default(), nickname: connection.nickname.clone().map(|s| s.into()), is_wsl: false, is_devcontainer: false, } .render(window, cx) .into_any_element(), ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader { connection_string: connection.distro_name.clone().into(), paths: Default::default(), nickname: None, is_wsl: true, is_devcontainer: false, } .render(window, cx) .into_any_element(), }) .child( v_flex() .pb_1() .child(ListSeparator) .map(|this| match &options { ViewServerOptionsState::Ssh { connection, entries, server_index, } => this.child(self.render_edit_ssh( connection, *server_index, entries, window, cx, )), ViewServerOptionsState::Wsl { connection, entries, server_index, } => this.child(self.render_edit_wsl( connection, *server_index, entries, window, cx, )), }) .child(ListSeparator) .child({ div() .id("ssh-options-copy-server-address") .track_focus(&last_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.mode = Mode::default_mode(&this.ssh_config_servers, cx); cx.focus_self(window); cx.notify(); })) .child( ListItem::new("go-back") .toggle_state( last_entry.focus_handle.contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::ArrowLeft).color(Color::Muted), ) .child(Label::new("Go Back")) .on_click(cx.listener(|this, _, window, cx| { this.mode = Mode::default_mode(&this.ssh_config_servers, cx); cx.focus_self(window); cx.notify() })), ) }), ) .into_any_element(), ); for entry in options.entries() { view = view.entry(entry.clone()); } view.render(window, cx).into_any_element() } fn render_edit_wsl( &self, connection: &WslConnectionOptions, index: WslServerIndex, entries: &[NavigableEntry], window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let distro_name = SharedString::new(connection.distro_name.clone()); v_flex().child({ fn remove_wsl_distro( remote_servers: Entity, index: WslServerIndex, distro_name: SharedString, window: &mut Window, cx: &mut App, ) { let prompt_message = format!("Remove WSL distro `{}`?", distro_name); let confirmation = window.prompt( PromptLevel::Warning, &prompt_message, None, &["Yes, remove it", "No, keep it"], cx, ); cx.spawn(async move |cx| { if confirmation.await.ok() == Some(0) { remote_servers.update(cx, |this, cx| { this.delete_wsl_distro(index, cx); }); remote_servers.update(cx, |this, cx| { this.mode = Mode::default_mode(&this.ssh_config_servers, cx); cx.notify(); }); } anyhow::Ok(()) }) .detach_and_log_err(cx); } div() .id("wsl-options-remove-distro") .track_focus(&entries[0].focus_handle) .on_action(cx.listener({ let distro_name = distro_name.clone(); move |_, _: &menu::Confirm, window, cx| { remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); cx.focus_self(window); } })) .child( ListItem::new("remove-distro") .toggle_state(entries[0].focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Trash).color(Color::Error)) .child(Label::new("Remove Distro").color(Color::Error)) .on_click(cx.listener(move |_, _, window, cx| { remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); cx.focus_self(window); })), ) }) } fn render_edit_ssh( &self, connection: &SshConnectionOptions, index: SshServerIndex, entries: &[NavigableEntry], window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let connection_string = SharedString::new(connection.host.to_string()); v_flex() .child({ let label = if connection.nickname.is_some() { "Edit Nickname" } else { "Add Nickname to Server" }; div() .id("ssh-options-add-nickname") .track_focus(&entries[0].focus_handle) .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| { this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx)); cx.notify(); })) .child( ListItem::new("add-nickname") .toggle_state(entries[0].focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) .child(Label::new(label)) .on_click(cx.listener(move |this, _, window, cx| { this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx)); cx.notify(); })), ) }) .child({ let workspace = self.workspace.clone(); fn callback( workspace: WeakEntity, connection_string: SharedString, cx: &mut App, ) { cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string())); workspace .update(cx, |this, cx| { struct SshServerAddressCopiedToClipboard; let notification = format!( "Copied server address ({}) to clipboard", connection_string ); this.show_toast( Toast::new( NotificationId::composite::( connection_string.clone(), ), notification, ) .autohide(), cx, ); }) .ok(); } div() .id("ssh-options-copy-server-address") .track_focus(&entries[1].focus_handle) .on_action({ let connection_string = connection_string.clone(); let workspace = self.workspace.clone(); move |_: &menu::Confirm, _, cx| { callback(workspace.clone(), connection_string.clone(), cx); } }) .child( ListItem::new("copy-server-address") .toggle_state(entries[1].focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) .child(Label::new("Copy Server Address")) .end_hover_slot( Label::new(connection_string.clone()).color(Color::Muted), ) .on_click({ let connection_string = connection_string.clone(); move |_, _, cx| { callback(workspace.clone(), connection_string.clone(), cx); } }), ) }) .child({ fn remove_ssh_server( remote_servers: Entity, index: SshServerIndex, connection_string: SharedString, window: &mut Window, cx: &mut App, ) { let prompt_message = format!("Remove server `{}`?", connection_string); let confirmation = window.prompt( PromptLevel::Warning, &prompt_message, None, &["Yes, remove it", "No, keep it"], cx, ); cx.spawn(async move |cx| { if confirmation.await.ok() == Some(0) { remote_servers.update(cx, |this, cx| { this.delete_ssh_server(index, cx); }); remote_servers.update(cx, |this, cx| { this.mode = Mode::default_mode(&this.ssh_config_servers, cx); cx.notify(); }); } anyhow::Ok(()) }) .detach_and_log_err(cx); } div() .id("ssh-options-copy-server-address") .track_focus(&entries[2].focus_handle) .on_action(cx.listener({ let connection_string = connection_string.clone(); move |_, _: &menu::Confirm, window, cx| { remove_ssh_server( cx.entity(), index, connection_string.clone(), window, cx, ); cx.focus_self(window); } })) .child( ListItem::new("remove-server") .toggle_state(entries[2].focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Trash).color(Color::Error)) .child(Label::new("Remove Server").color(Color::Error)) .on_click(cx.listener(move |_, _, window, cx| { remove_ssh_server( cx.entity(), index, connection_string.clone(), window, cx, ); cx.focus_self(window); })), ) }) } fn render_edit_nickname( &self, state: &EditNicknameState, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let Some(connection) = RemoteSettings::get_global(cx) .ssh_connections() .nth(state.index.0) else { return v_flex() .id("ssh-edit-nickname") .track_focus(&self.focus_handle(cx)); }; let connection_string = connection.host.clone(); let nickname = connection.nickname.map(|s| s.into()); v_flex() .id("ssh-edit-nickname") .track_focus(&self.focus_handle(cx)) .child( SshConnectionHeader { connection_string: connection_string.into(), paths: Default::default(), nickname, is_wsl: false, is_devcontainer: false, } .render(window, cx), ) .child( h_flex() .p_2() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(state.editor.clone()), ) } fn render_default( &mut self, mut state: DefaultState, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let ssh_settings = RemoteSettings::get_global(cx); let mut should_rebuild = false; let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state .servers .iter() .filter_map(|server| match server { RemoteEntry::Project { connection: Connection::Ssh(connection), .. } => Some(connection), _ => None, })); let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state .servers .iter() .filter_map(|server| match server { RemoteEntry::Project { connection: Connection::Wsl(connection), .. } => Some(connection), _ => None, })); if ssh_connections_changed || wsl_connections_changed { should_rebuild = true; }; if !should_rebuild && ssh_settings.read_ssh_config { let current_ssh_hosts: BTreeSet = state .servers .iter() .filter_map(|server| match server { RemoteEntry::SshConfig { host, .. } => Some(host.clone()), _ => None, }) .collect(); let mut expected_ssh_hosts = self.ssh_config_servers.clone(); for server in &state.servers { if let RemoteEntry::Project { connection: Connection::Ssh(connection), .. } = server { expected_ssh_hosts.remove(connection.host.as_str()); } } should_rebuild = current_ssh_hosts != expected_ssh_hosts; } if should_rebuild { self.mode = Mode::default_mode(&self.ssh_config_servers, cx); if let Mode::Default(new_state) = &self.mode { state = new_state.clone(); } } let connect_button = div() .id("ssh-connect-new-server-container") .track_focus(&state.add_new_server.focus_handle) .anchor_scroll(state.add_new_server.scroll_anchor.clone()) .child( ListItem::new("register-remote-server-button") .toggle_state( state .add_new_server .focus_handle .contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .child(Label::new("Connect SSH Server")) .on_click(cx.listener(|this, _, window, cx| { let state = CreateRemoteServer::new(window, cx); this.mode = Mode::CreateRemoteServer(state); cx.notify(); })), ) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { let state = CreateRemoteServer::new(window, cx); this.mode = Mode::CreateRemoteServer(state); cx.notify(); })); let connect_dev_container_button = div() .id("connect-new-dev-container") .track_focus(&state.add_new_devcontainer.focus_handle) .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone()) .child( ListItem::new("register-dev-container-button") .toggle_state( state .add_new_devcontainer .focus_handle .contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .child(Label::new("Connect Dev Container")) .on_click(cx.listener(|this, _, window, cx| { this.init_dev_container_mode(window, cx); })), ) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.init_dev_container_mode(window, cx); })); #[cfg(target_os = "windows")] let wsl_connect_button = div() .id("wsl-connect-new-server") .track_focus(&state.add_new_wsl.focus_handle) .anchor_scroll(state.add_new_wsl.scroll_anchor.clone()) .child( ListItem::new("wsl-add-new-server") .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .child(Label::new("Add WSL Distro")) .on_click(cx.listener(|this, _, window, cx| { let state = AddWslDistro::new(window, cx); this.mode = Mode::AddWslDistro(state); cx.notify(); })), ) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { let state = AddWslDistro::new(window, cx); this.mode = Mode::AddWslDistro(state); cx.notify(); })); let has_open_project = self .workspace .upgrade() .map(|workspace| { workspace .read(cx) .project() .read(cx) .visible_worktrees(cx) .next() .is_some() }) .unwrap_or(false); // We cannot currently connect a dev container from within a remote server due to the remote_server architecture let is_local = self .workspace .upgrade() .map(|workspace| workspace.read(cx).project().read(cx).is_local()) .unwrap_or(true); let modal_section = v_flex() .track_focus(&self.focus_handle(cx)) .id("ssh-server-list") .overflow_y_scroll() .track_scroll(&state.scroll_handle) .size_full() .child(connect_button) .when(has_open_project && is_local, |this| { this.child(connect_dev_container_button) }); #[cfg(target_os = "windows")] let modal_section = modal_section.child(wsl_connect_button); #[cfg(not(target_os = "windows"))] let modal_section = modal_section; let mut modal_section = Navigable::new( modal_section .child( List::new() .empty_message( h_flex() .size_full() .p_2() .justify_center() .border_t_1() .border_color(cx.theme().colors().border_variant) .child( Label::new("No remote servers registered yet.") .color(Color::Muted), ) .into_any_element(), ) .children(state.servers.iter().enumerate().map(|(ix, connection)| { self.render_remote_connection(ix, connection.clone(), window, cx) .into_any_element() })), ) .into_any_element(), ) .entry(state.add_new_server.clone()); if has_open_project && is_local { modal_section = modal_section.entry(state.add_new_devcontainer.clone()); } if cfg!(target_os = "windows") { modal_section = modal_section.entry(state.add_new_wsl.clone()); } for server in &state.servers { match server { RemoteEntry::Project { open_folder, projects, configure, .. } => { for (navigation_state, _) in projects { modal_section = modal_section.entry(navigation_state.clone()); } modal_section = modal_section .entry(open_folder.clone()) .entry(configure.clone()); } RemoteEntry::SshConfig { open_folder, .. } => { modal_section = modal_section.entry(open_folder.clone()); } } } let mut modal_section = modal_section.render(window, cx).into_any_element(); let is_project_selected = state.servers.iter().any(|server| match server { RemoteEntry::Project { projects, .. } => projects .iter() .any(|(entry, _)| entry.focus_handle.contains_focused(window, cx)), RemoteEntry::SshConfig { .. } => false, }); Modal::new("remote-projects", None) .header(ModalHeader::new().headline("Remote Projects")) .section( Section::new().padded(false).child( v_flex() .min_h(rems(20.)) .size_full() .relative() .child(ListSeparator) .child( canvas( |bounds, window, cx| { modal_section.prepaint_as_root( bounds.origin, bounds.size.into(), window, cx, ); modal_section }, |_, mut modal_section, window, cx| { modal_section.paint(window, cx); }, ) .size_full(), ) .vertical_scrollbar_for(&state.scroll_handle, window, cx), ), ) .footer(ModalFooter::new().end_slot({ let confirm_button = |label: SharedString| { Button::new("select", label) .key_binding(KeyBinding::for_action(&menu::Confirm, cx)) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx) }) }; if is_project_selected { h_flex() .gap_1() .child( Button::new("open_new_window", "New Window") .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx)) .on_click(|_, window, cx| { window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) }), ) .child(confirm_button("Open".into())) .into_any_element() } else { confirm_button("Select".into()).into_any_element() } })) .into_any_element() } fn create_host_from_ssh_config( &mut self, ssh_config_host: &SharedString, cx: &mut Context<'_, Self>, ) -> SshServerIndex { let new_ix = Arc::new(AtomicUsize::new(0)); let update_new_ix = new_ix.clone(); self.update_settings_file(cx, move |settings, _| { update_new_ix.store( settings .ssh_connections .as_ref() .map_or(0, |connections| connections.len()), atomic::Ordering::Release, ); }); self.add_ssh_server( SshConnectionOptions { host: ssh_config_host.to_string().into(), ..SshConnectionOptions::default() }, cx, ); self.mode = Mode::default_mode(&self.ssh_config_servers, cx); SshServerIndex(new_ix.load(atomic::Ordering::Acquire)) } } fn spawn_ssh_config_watch(fs: Arc, cx: &Context) -> Task<()> { enum ConfigSource { User(String), Global(String), } let mut streams = Vec::new(); let mut tasks = Vec::new(); // Setup User Watcher let user_path = user_ssh_config_file(); info!("SSH: Watching User Config at: {:?}", user_path); // We clone 'fs' here because we might need it again for the global watcher. let (user_s, user_t) = watch_config_file(cx.background_executor(), fs.clone(), user_path); streams.push(user_s.map(ConfigSource::User).boxed()); tasks.push(user_t); // Setup Global Watcher if let Some(gp) = global_ssh_config_file() { info!("SSH: Watching Global Config at: {:?}", gp); let (global_s, global_t) = watch_config_file(cx.background_executor(), fs, gp.to_path_buf()); streams.push(global_s.map(ConfigSource::Global).boxed()); tasks.push(global_t); } else { debug!("SSH: No Global Config defined."); } // Combine into a single stream so that only one is parsed at once. let mut merged_stream = futures::stream::select_all(streams); cx.spawn(async move |remote_server_projects, cx| { let _tasks = tasks; // Keeps the background watchers alive let mut global_hosts = BTreeSet::default(); let mut user_hosts = BTreeSet::default(); while let Some(event) = merged_stream.next().await { match event { ConfigSource::Global(content) => { global_hosts = parse_ssh_config_hosts(&content); } ConfigSource::User(content) => { user_hosts = parse_ssh_config_hosts(&content); } } // Sync to Model if remote_server_projects .update(cx, |project, cx| { project.ssh_config_servers = global_hosts .iter() .chain(user_hosts.iter()) .map(SharedString::from) .collect(); cx.notify(); }) .is_err() { return; } } }) } fn get_text(element: &Entity, cx: &mut App) -> String { element.read(cx).text(cx).trim().to_string() } impl ModalView for RemoteServerProjects {} impl Focusable for RemoteServerProjects { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.mode { Mode::ProjectPicker(picker) => picker.focus_handle(cx), _ => self.focus_handle.clone(), } } } impl EventEmitter for RemoteServerProjects {} impl Render for RemoteServerProjects { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .elevation_3(cx) .w(rems(34.)) .key_context("RemoteServerModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { this.focus_handle(cx).focus(window, cx); })) .on_mouse_down_out(cx.listener(|this, _, _, cx| { if matches!(this.mode, Mode::Default(_)) { cx.emit(DismissEvent) } })) .child(match &self.mode { Mode::Default(state) => self .render_default(state.clone(), window, cx) .into_any_element(), Mode::ViewServerOptions(state) => self .render_view_options(state.clone(), window, cx) .into_any_element(), Mode::ProjectPicker(element) => element.clone().into_any_element(), Mode::CreateRemoteServer(state) => self .render_create_remote_server(state, window, cx) .into_any_element(), Mode::CreateRemoteDevContainer(state) => self .render_create_dev_container(state, window, cx) .into_any_element(), Mode::EditNickname(state) => self .render_edit_nickname(state, window, cx) .into_any_element(), #[cfg(target_os = "windows")] Mode::AddWslDistro(state) => self .render_add_wsl_distro(state, window, cx) .into_any_element(), }) } }