@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
+use std::sync::Arc;
use std::time::Duration;
use anyhow::anyhow;
@@ -7,39 +8,46 @@ use anyhow::Context;
use anyhow::Result;
use dev_server_projects::{DevServer, DevServerId, DevServerProjectId};
use editor::Editor;
+use file_finder::OpenPathDelegate;
+use futures::channel::oneshot;
+use futures::future::Shared;
+use futures::FutureExt;
+use gpui::canvas;
use gpui::pulsating_between;
use gpui::AsyncWindowContext;
use gpui::ClipboardItem;
-use gpui::PathPromptOptions;
use gpui::Subscription;
use gpui::Task;
use gpui::WeakView;
use gpui::{
- Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
- FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext,
+ Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
+ FocusableView, FontWeight, Model, ScrollHandle, View, ViewContext,
};
+use picker::Picker;
use project::terminals::wrap_for_ssh;
use project::terminals::SshCommand;
-use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt};
+use project::Project;
+use rpc::proto::DevServerStatus;
use settings::update_settings_file;
use settings::Settings;
use task::HideStrategy;
use task::RevealStrategy;
use task::SpawnInTerminal;
use terminal_view::terminal_panel::TerminalPanel;
-use ui::ElevationIndex;
use ui::Section;
-use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip};
-use ui_input::{FieldLabelLayout, TextField};
+use ui::{prelude::*, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
use util::ResultExt;
+use workspace::notifications::NotificationId;
use workspace::OpenOptions;
-use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
+use workspace::Toast;
+use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace};
use crate::open_dev_server_project;
use crate::ssh_connections::connect_over_ssh;
use crate::ssh_connections::open_ssh_project;
use crate::ssh_connections::RemoteSettingsContent;
use crate::ssh_connections::SshConnection;
+use crate::ssh_connections::SshConnectionHeader;
use crate::ssh_connections::SshConnectionModal;
use crate::ssh_connections::SshProject;
use crate::ssh_connections::SshPrompt;
@@ -52,24 +60,251 @@ pub struct DevServerProjects {
scroll_handle: ScrollHandle,
dev_server_store: Model<dev_server_projects::Store>,
workspace: WeakView<Workspace>,
- project_path_input: View<Editor>,
- dev_server_name_input: View<TextField>,
_dev_server_subscription: Subscription,
+ selectable_items: SelectableItemList,
}
-#[derive(Default)]
struct CreateDevServer {
+ address_editor: View<Editor>,
creating: Option<Task<Option<()>>>,
ssh_prompt: Option<View<SshPrompt>>,
}
-struct CreateDevServerProject {
- dev_server_id: DevServerId,
- _opening: Option<Subscription>,
+impl CreateDevServer {
+ fn new(cx: &mut WindowContext<'_>) -> Self {
+ let address_editor = cx.new_view(Editor::single_line);
+ address_editor.update(cx, |this, cx| {
+ this.focus_handle(cx).focus(cx);
+ });
+ Self {
+ address_editor,
+ creating: None,
+ ssh_prompt: None,
+ }
+ }
+}
+
+struct ProjectPicker {
+ connection_string: SharedString,
+ picker: View<Picker<OpenPathDelegate>>,
+ _path_task: Shared<Task<Option<()>>>,
+}
+
+type SelectedItemCallback =
+ Box<dyn Fn(&mut DevServerProjects, &mut ViewContext<DevServerProjects>) + 'static>;
+
+/// Used to implement keyboard navigation for SSH modal.
+#[derive(Default)]
+struct SelectableItemList {
+ items: Vec<SelectedItemCallback>,
+ active_item: Option<usize>,
+}
+
+struct EditNicknameState {
+ index: usize,
+ editor: View<Editor>,
+}
+
+impl EditNicknameState {
+ fn new(index: usize, cx: &mut WindowContext<'_>) -> Self {
+ let this = Self {
+ index,
+ editor: cx.new_view(Editor::single_line),
+ };
+ let starting_text = SshSettings::get_global(cx)
+ .ssh_connections()
+ .nth(index)
+ .and_then(|state| state.nickname.clone())
+ .filter(|text| !text.is_empty());
+ this.editor.update(cx, |this, cx| {
+ this.set_placeholder_text("Add a nickname for this server", cx);
+ if let Some(starting_text) = starting_text {
+ this.set_text(starting_text, cx);
+ }
+ });
+ this.editor.focus_handle(cx).focus(cx);
+ this
+ }
}
+impl SelectableItemList {
+ fn reset(&mut self) {
+ self.items.clear();
+ }
+
+ fn reset_selection(&mut self) {
+ self.active_item.take();
+ }
+
+ fn prev(&mut self, _: &mut WindowContext<'_>) {
+ match self.active_item.as_mut() {
+ Some(active_index) => {
+ *active_index = active_index.checked_sub(1).unwrap_or(self.items.len() - 1)
+ }
+ None => {
+ self.active_item = Some(self.items.len() - 1);
+ }
+ }
+ }
+
+ fn next(&mut self, _: &mut WindowContext<'_>) {
+ match self.active_item.as_mut() {
+ Some(active_index) => {
+ if *active_index + 1 < self.items.len() {
+ *active_index += 1;
+ } else {
+ *active_index = 0;
+ }
+ }
+ None => {
+ self.active_item = Some(0);
+ }
+ }
+ }
+
+ fn add_item(&mut self, callback: SelectedItemCallback) {
+ self.items.push(callback)
+ }
+
+ fn is_selected(&self) -> bool {
+ self.active_item == self.items.len().checked_sub(1)
+ }
+
+ fn confirm(&self, dev_modal: &mut DevServerProjects, cx: &mut ViewContext<DevServerProjects>) {
+ if let Some(active_item) = self.active_item.and_then(|ix| self.items.get(ix)) {
+ active_item(dev_modal, cx);
+ }
+ }
+}
+
+impl ProjectPicker {
+ fn new(
+ ix: usize,
+ connection_string: SharedString,
+ project: Model<Project>,
+ workspace: WeakView<Workspace>,
+ cx: &mut ViewContext<DevServerProjects>,
+ ) -> View<Self> {
+ let (tx, rx) = oneshot::channel();
+ let lister = project::DirectoryLister::Project(project.clone());
+ let query = lister.default_query(cx);
+ let delegate = file_finder::OpenPathDelegate::new(tx, lister);
+
+ let picker = cx.new_view(|cx| {
+ let picker = Picker::uniform_list(delegate, cx)
+ .width(rems(34.))
+ .modal(false);
+ picker.set_query(query, cx);
+ picker
+ });
+ cx.new_view(|cx| {
+ let _path_task = cx
+ .spawn({
+ let workspace = workspace.clone();
+ move |_, mut cx| async move {
+ let Ok(Some(paths)) = rx.await else {
+ workspace
+ .update(&mut cx, |workspace, cx| {
+ let weak = cx.view().downgrade();
+ workspace
+ .toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
+ })
+ .log_err()?;
+ return None;
+ };
+
+ let app_state = workspace
+ .update(&mut cx, |workspace, _| workspace.app_state().clone())
+ .ok()?;
+ let options = cx
+ .update(|cx| (app_state.build_window_options)(None, cx))
+ .log_err()?;
+
+ cx.open_window(options, |cx| {
+ cx.activate_window();
+
+ let fs = app_state.fs.clone();
+ update_settings_file::<SshSettings>(fs, cx, {
+ let paths = paths
+ .iter()
+ .map(|path| path.to_string_lossy().to_string())
+ .collect();
+ move |setting, _| {
+ if let Some(server) = setting
+ .ssh_connections
+ .as_mut()
+ .and_then(|connections| connections.get_mut(ix))
+ {
+ server.projects.push(SshProject { paths })
+ }
+ }
+ });
+
+ let tasks = paths
+ .into_iter()
+ .map(|path| {
+ project.update(cx, |project, cx| {
+ project.find_or_create_worktree(&path, true, cx)
+ })
+ })
+ .collect::<Vec<_>>();
+ cx.spawn(|_| async move {
+ for task in tasks {
+ task.await?;
+ }
+ Ok(())
+ })
+ .detach_and_prompt_err(
+ "Failed to open path",
+ cx,
+ |_, _| None,
+ );
+
+ cx.new_view(|cx| {
+ let workspace =
+ Workspace::new(None, project.clone(), app_state.clone(), cx);
+
+ workspace
+ .client()
+ .telemetry()
+ .report_app_event("create ssh project".to_string());
+
+ workspace
+ })
+ })
+ .log_err();
+ Some(())
+ }
+ })
+ .shared();
+
+ Self {
+ _path_task,
+ picker,
+ connection_string,
+ }
+ })
+ }
+}
+
+impl gpui::Render for ProjectPicker {
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+ v_flex()
+ .child(
+ SshConnectionHeader {
+ connection_string: self.connection_string.clone(),
+ nickname: None,
+ }
+ .render(cx),
+ )
+ .child(self.picker.clone())
+ }
+}
enum Mode {
- Default(Option<CreateDevServerProject>),
+ Default,
+ ViewServerOptions(usize, SshConnection),
+ EditNickname(EditNicknameState),
+ ProjectPicker(View<ProjectPicker>),
CreateDevServer(CreateDevServer),
}
@@ -89,15 +324,6 @@ impl DevServerProjects {
}
pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
- let project_path_input = cx.new_view(|cx| {
- let mut editor = Editor::single_line(cx);
- editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, β¦)", cx);
- editor
- });
- let dev_server_name_input = cx.new_view(|cx| {
- TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
- });
-
let focus_handle = cx.focus_handle();
let dev_server_store = dev_server_projects::Store::global(cx);
@@ -112,131 +338,49 @@ impl DevServerProjects {
});
Self {
- mode: Mode::Default(None),
+ mode: Mode::Default,
focus_handle,
scroll_handle: ScrollHandle::new(),
dev_server_store,
- project_path_input,
- dev_server_name_input,
workspace,
_dev_server_subscription: subscription,
+ selectable_items: Default::default(),
}
}
- pub fn create_dev_server_project(
- &mut self,
- dev_server_id: DevServerId,
- cx: &mut ViewContext<Self>,
- ) {
- let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
-
- if path.is_empty() {
+ fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
+ if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
return;
}
-
- if !path.starts_with('/') && !path.starts_with('~') {
- path = format!("~/{}", path);
- }
-
- if self
- .dev_server_store
- .read(cx)
- .projects_for_server(dev_server_id)
- .iter()
- .any(|p| p.paths.iter().any(|p| p == &path))
- {
- cx.spawn(|_, mut cx| async move {
- cx.prompt(
- gpui::PromptLevel::Critical,
- "Failed to create project",
- Some(&format!("{} is already open on this dev server.", path)),
- &["Ok"],
- )
- .await
- })
- .detach_and_log_err(cx);
+ self.selectable_items.next(cx);
+ }
+ fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
+ if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
return;
}
+ self.selectable_items.prev(cx);
+ }
+ pub fn project_picker(
+ ix: usize,
+ connection_options: remote::SshConnectionOptions,
+ project: Model<Project>,
+ cx: &mut ViewContext<Self>,
+ workspace: WeakView<Workspace>,
+ ) -> Self {
+ let mut this = Self::new(cx, workspace.clone());
+ this.mode = Mode::ProjectPicker(ProjectPicker::new(
+ ix,
+ connection_options.connection_string().into(),
+ project,
+ workspace,
+ cx,
+ ));
- let create = {
- let path = path.clone();
- self.dev_server_store.update(cx, |store, cx| {
- store.create_dev_server_project(dev_server_id, path, cx)
- })
- };
-
- cx.spawn(|this, mut cx| async move {
- let result = create.await;
- this.update(&mut cx, |this, cx| {
- if let Ok(result) = &result {
- if let Some(dev_server_project_id) =
- result.dev_server_project.as_ref().map(|p| p.id)
- {
- let subscription =
- cx.observe(&this.dev_server_store, move |this, store, cx| {
- if let Some(project_id) = store
- .read(cx)
- .dev_server_project(DevServerProjectId(dev_server_project_id))
- .and_then(|p| p.project_id)
- {
- this.project_path_input.update(cx, |editor, cx| {
- editor.set_text("", cx);
- });
- this.mode = Mode::Default(None);
- if let Some(app_state) = AppState::global(cx).upgrade() {
- workspace::join_dev_server_project(
- DevServerProjectId(dev_server_project_id),
- project_id,
- app_state,
- None,
- cx,
- )
- .detach_and_prompt_err(
- "Could not join project",
- cx,
- |_, _| None,
- )
- }
- }
- });
-
- this.mode = Mode::Default(Some(CreateDevServerProject {
- dev_server_id,
- _opening: Some(subscription),
- }));
- }
- } else {
- this.mode = Mode::Default(Some(CreateDevServerProject {
- dev_server_id,
- _opening: None,
- }));
- }
- })
- .log_err();
- result
- })
- .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
- match e.error_code() {
- ErrorCode::DevServerOffline => Some(
- "The dev server is offline. Please log in and check it is connected."
- .to_string(),
- ),
- ErrorCode::DevServerProjectPathDoesNotExist => {
- Some(format!("The path `{}` does not exist on the server.", path))
- }
- _ => None,
- }
- });
-
- self.mode = Mode::Default(Some(CreateDevServerProject {
- dev_server_id,
-
- _opening: None,
- }));
+ this
}
- fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
- let host = get_text(&self.dev_server_name_input, cx);
+ fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
+ let host = get_text(&editor, cx);
if host.is_empty() {
return;
}
@@ -287,23 +431,35 @@ impl DevServerProjects {
});
this.add_ssh_server(connection_options, cx);
- this.mode = Mode::Default(None);
+ this.mode = Mode::Default;
+ this.selectable_items.reset_selection();
cx.notify()
})
.log_err(),
None => this
.update(&mut cx, |this, cx| {
- this.mode = Mode::CreateDevServer(CreateDevServer::default());
+ this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
cx.notify()
})
.log_err(),
};
None
});
- self.mode = Mode::CreateDevServer(CreateDevServer {
- ssh_prompt: Some(ssh_prompt.clone()),
- creating: Some(creating),
- });
+ let mut state = CreateDevServer::new(cx);
+ state.address_editor = editor;
+ state.ssh_prompt = Some(ssh_prompt.clone());
+ state.creating = Some(creating);
+ self.mode = Mode::CreateDevServer(state);
+ }
+
+ fn view_server_options(
+ &mut self,
+ (index, connection): (usize, SshConnection),
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.selectable_items.reset_selection();
+ self.mode = Mode::ViewServerOptions(index, connection);
+ cx.notify();
}
fn create_ssh_project(
@@ -331,12 +487,12 @@ impl DevServerProjects {
let connect = connect_over_ssh(
connection_options.dev_server_identifier(),
- connection_options,
+ connection_options.clone(),
prompt,
cx,
)
.prompt_err("Failed to connect", cx, |_, _| None);
- cx.spawn(|workspace, mut cx| async move {
+ cx.spawn(move |workspace, mut cx| async move {
let Some(session) = connect.await else {
workspace
.update(&mut cx, |workspace, cx| {
@@ -346,9 +502,11 @@ impl DevServerProjects {
.log_err();
return;
};
- let Ok((app_state, project, paths)) =
- workspace.update(&mut cx, |workspace, cx| {
+
+ workspace
+ .update(&mut cx, |workspace, cx| {
let app_state = workspace.app_state().clone();
+ let weak = cx.view().downgrade();
let project = project::Project::ssh(
session,
app_state.client.clone(),
@@ -358,91 +516,17 @@ impl DevServerProjects {
app_state.fs.clone(),
cx,
);
- let paths = workspace.prompt_for_open_path(
- PathPromptOptions {
- files: true,
- directories: true,
- multiple: true,
- },
- project::DirectoryLister::Project(project.clone()),
- cx,
- );
- (app_state, project, paths)
- })
- else {
- return;
- };
-
- let Ok(Some(paths)) = paths.await else {
- workspace
- .update(&mut cx, |workspace, cx| {
- let weak = cx.view().downgrade();
- workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
- })
- .log_err();
- return;
- };
-
- let Some(options) = cx
- .update(|cx| (app_state.build_window_options)(None, cx))
- .log_err()
- else {
- return;
- };
-
- cx.open_window(options, |cx| {
- cx.activate_window();
-
- let fs = app_state.fs.clone();
- update_settings_file::<SshSettings>(fs, cx, {
- let paths = paths
- .iter()
- .map(|path| path.to_string_lossy().to_string())
- .collect();
- move |setting, _| {
- if let Some(server) = setting
- .ssh_connections
- .as_mut()
- .and_then(|connections| connections.get_mut(ix))
- {
- server.projects.push(SshProject { paths })
- }
- }
- });
-
- let tasks = paths
- .into_iter()
- .map(|path| {
- project.update(cx, |project, cx| {
- project.find_or_create_worktree(&path, true, cx)
- })
- })
- .collect::<Vec<_>>();
- cx.spawn(|_| async move {
- for task in tasks {
- task.await?;
- }
- Ok(())
- })
- .detach_and_prompt_err(
- "Failed to open path",
- cx,
- |_, _| None,
- );
-
- cx.new_view(|cx| {
- let workspace =
- Workspace::new(None, project.clone(), app_state.clone(), cx);
-
- workspace
- .client()
- .telemetry()
- .report_app_event("create ssh project".to_string());
-
- workspace
+ workspace.toggle_modal(cx, |cx| {
+ DevServerProjects::project_picker(
+ ix,
+ connection_options,
+ project,
+ cx,
+ weak,
+ )
+ });
})
- })
- .log_err();
+ .ok();
})
.detach()
})
@@ -451,10 +535,12 @@ impl DevServerProjects {
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
match &self.mode {
- Mode::Default(None) => {}
- Mode::Default(Some(create_project)) => {
- self.create_dev_server_project(create_project.dev_server_id, cx);
+ Mode::Default | Mode::ViewServerOptions(_, _) => {
+ let items = std::mem::take(&mut self.selectable_items);
+ items.confirm(self, cx);
+ self.selectable_items = items;
}
+ Mode::ProjectPicker(_) => {}
Mode::CreateDevServer(state) => {
if let Some(prompt) = state.ssh_prompt.as_ref() {
prompt.update(cx, |prompt, cx| {
@@ -463,22 +549,41 @@ impl DevServerProjects {
return;
}
- self.create_ssh_server(cx);
+ state.address_editor.update(cx, |this, _| {
+ this.set_read_only(true);
+ });
+ self.create_ssh_server(state.address_editor.clone(), cx);
+ }
+ Mode::EditNickname(state) => {
+ let text = Some(state.editor.read(cx).text(cx))
+ .filter(|text| !text.is_empty())
+ .map(SharedString::from);
+ let index = state.index;
+ self.update_settings_file(cx, move |setting, _| {
+ if let Some(connections) = setting.ssh_connections.as_mut() {
+ if let Some(connection) = connections.get_mut(index) {
+ connection.nickname = text;
+ }
+ }
+ });
+ self.mode = Mode::Default;
+ self.selectable_items.reset_selection();
+ self.focus_handle.focus(cx);
}
}
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
match &self.mode {
- Mode::Default(None) => cx.emit(DismissEvent),
+ Mode::Default => cx.emit(DismissEvent),
Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
- self.mode = Mode::CreateDevServer(CreateDevServer {
- ..Default::default()
- });
+ self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
+ self.selectable_items.reset_selection();
cx.notify();
}
_ => {
- self.mode = Mode::Default(None);
+ self.mode = Mode::Default;
+ self.selectable_items.reset_selection();
self.focus_handle(cx).focus(cx);
cx.notify();
}
@@ -491,126 +596,119 @@ impl DevServerProjects {
ssh_connection: SshConnection,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
+ let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() {
+ let aux_label = SharedString::from(format!("({})", ssh_connection.host));
+ (nickname, Some(aux_label))
+ } else {
+ (ssh_connection.host.clone(), None)
+ };
v_flex()
.w_full()
- .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .mb_1()
.child(
h_flex()
- .w_full()
.group("ssh-server")
- .justify_between()
+ .w_full()
+ .pt_0p5()
+ .px_2p5()
+ .gap_1()
+ .overflow_hidden()
+ .whitespace_nowrap()
+ .w_full()
.child(
- h_flex()
- .gap_2()
- .w_full()
- .child(
- div()
- .id(("status", ix))
- .relative()
- .child(Icon::new(IconName::Server).size(IconSize::Small)),
- )
- .child(
- h_flex()
- .max_w(rems(26.))
- .overflow_hidden()
- .whitespace_nowrap()
- .child(Label::new(ssh_connection.host.clone())),
- ),
+ Label::new(main_label)
+ .size(LabelSize::Small)
+ .weight(FontWeight::SEMIBOLD)
+ .color(Color::Muted),
)
- .child(
- h_flex()
- .visible_on_hover("ssh-server")
- .gap_1()
- .child({
- IconButton::new("copy-dev-server-address", IconName::Copy)
- .icon_size(IconSize::Small)
- .on_click(cx.listener(move |this, _, cx| {
- this.update_settings_file(cx, move |servers, cx| {
- if let Some(content) = servers
- .ssh_connections
- .as_ref()
- .and_then(|connections| {
- connections
- .get(ix)
- .map(|connection| connection.host.clone())
- })
- {
- cx.write_to_clipboard(ClipboardItem::new_string(
- content,
- ));
- }
- });
- }))
- .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
- })
- .child({
- IconButton::new("remove-dev-server", IconName::TrashAlt)
- .icon_size(IconSize::Small)
- .on_click(cx.listener(move |this, _, cx| {
- this.delete_ssh_server(ix, cx)
- }))
- .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
- }),
+ .children(
+ aux_label.map(|label| {
+ Label::new(label).size(LabelSize::Small).color(Color::Muted)
+ }),
),
)
.child(
- v_flex()
- .w_full()
- .border_l_1()
- .border_color(cx.theme().colors().border_variant)
- .mb_1()
- .mx_1p5()
- .pl_2()
- .child(
- List::new()
- .empty_message("No projects.")
- .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
- v_flex().gap_0p5().child(self.render_ssh_project(
- ix,
- &ssh_connection,
- pix,
- p,
- cx,
- ))
- }))
- .child(
- h_flex().mt_1().pl_1().child(
- Button::new(("new-remote_project", ix), "Open Folderβ¦")
- .size(ButtonSize::Default)
- .layer(ElevationIndex::ModalSurface)
- .icon(IconName::Plus)
- .icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
- .on_click(cx.listener(move |this, _, cx| {
+ v_flex().w_full().gap_1().mb_1().child(
+ List::new()
+ .empty_message("No projects.")
+ .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
+ v_flex().gap_0p5().child(self.render_ssh_project(
+ ix,
+ &ssh_connection,
+ pix,
+ p,
+ cx,
+ ))
+ }))
+ .child(h_flex().map(|this| {
+ self.selectable_items.add_item(Box::new({
+ let ssh_connection = ssh_connection.clone();
+ move |this, cx| {
+ this.create_ssh_project(ix, ssh_connection.clone(), cx);
+ }
+ }));
+ let is_selected = self.selectable_items.is_selected();
+ this.child(
+ ListItem::new(("new-remote-project", ix))
+ .selected(is_selected)
+ .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 ssh_connection = ssh_connection.clone();
+ move |this, _, cx| {
this.create_ssh_project(ix, ssh_connection.clone(), cx);
- })),
- ),
- ),
- ),
+ }
+ })),
+ )
+ }))
+ .child(h_flex().map(|this| {
+ self.selectable_items.add_item(Box::new({
+ let ssh_connection = ssh_connection.clone();
+ move |this, cx| {
+ this.view_server_options((ix, ssh_connection.clone()), cx);
+ }
+ }));
+ let is_selected = self.selectable_items.is_selected();
+ this.child(
+ ListItem::new(("server-options", ix))
+ .selected(is_selected)
+ .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 = ssh_connection.clone();
+ move |this, _, cx| {
+ this.view_server_options(
+ (ix, ssh_connection.clone()),
+ cx,
+ );
+ }
+ })),
+ )
+ })),
+ ),
)
}
fn render_ssh_project(
- &self,
+ &mut self,
server_ix: usize,
server: &SshConnection,
ix: usize,
project: &SshProject,
cx: &ViewContext<Self>,
) -> impl IntoElement {
- let project = project.clone();
let server = server.clone();
- ListItem::new(("remote-project", ix))
- .inset(true)
- .spacing(ui::ListItemSpacing::Sparse)
- .start_slot(
- Icon::new(IconName::Folder)
- .color(Color::Muted)
- .size(IconSize::Small),
- )
- .child(Label::new(project.paths.join(", ")))
- .on_click(cx.listener(move |this, _, cx| {
+ let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
+ let callback = Arc::new({
+ let project = project.clone();
+ move |this: &mut Self, cx: &mut ViewContext<Self>| {
let Some(app_state) = this
.workspace
.update(cx, |workspace, _| workspace.app_state().clone())
@@ -642,12 +740,32 @@ impl DevServerProjects {
}
})
.detach();
- }))
+ }
+ });
+ self.selectable_items.add_item(Box::new({
+ let callback = callback.clone();
+ move |this, cx| callback(this, cx)
+ }));
+ let is_selected = self.selectable_items.is_selected();
+
+ ListItem::new((element_id_base, ix))
+ .inset(true)
+ .selected(is_selected)
+ .spacing(ui::ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::Folder)
+ .color(Color::Muted)
+ .size(IconSize::Small),
+ )
+ .child(Label::new(project.paths.join(", ")))
+ .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
.end_hover_slot::<AnyElement>(Some(
IconButton::new("remove-remote-project", IconName::TrashAlt)
+ .icon_size(IconSize::Small)
.on_click(
cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
)
+ .size(ButtonSize::Large)
.tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
.into_any_element(),
))
@@ -698,10 +816,11 @@ impl DevServerProjects {
.ssh_connections
.get_or_insert(Default::default())
.push(SshConnection {
- host: connection_options.host,
+ host: SharedString::from(connection_options.host),
username: connection_options.username,
port: connection_options.port,
projects: vec![],
+ nickname: None,
})
});
}
@@ -711,16 +830,17 @@ impl DevServerProjects {
state: &CreateDevServer,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
- let creating = state.creating.is_some();
let ssh_prompt = state.ssh_prompt.clone();
- self.dev_server_name_input.update(cx, |input, cx| {
- input.editor().update(cx, |editor, cx| {
- if editor.text(cx).is_empty() {
- editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
- }
- })
+ state.address_editor.update(cx, |editor, cx| {
+ if editor.text(cx).is_empty() {
+ editor.set_placeholder_text(
+ "Enter the command you use to SSH into this server: e.g., ssh me@my.server",
+ cx,
+ );
+ }
});
+
let theme = cx.theme();
v_flex()
@@ -16,9 +16,9 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
use ui::{
- div, h_flex, prelude::*, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, Icon, IconButton,
- IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, Tooltip,
- ViewContext, VisualContext, WindowContext,
+ div, h_flex, prelude::*, v_flex, ActiveTheme, Color, Icon, IconName, IconSize,
+ InteractiveElement, IntoElement, Label, LabelCommon, Styled, ViewContext, VisualContext,
+ WindowContext,
};
use workspace::{AppState, ModalView, Workspace};
@@ -35,17 +35,20 @@ impl SshSettings {
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct SshConnection {
- pub host: String,
+ pub host: SharedString,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub projects: Vec<SshProject>,
+ /// Name to use for this server in UI.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub nickname: Option<SharedString>,
}
impl From<SshConnection> for SshConnectionOptions {
fn from(val: SshConnection) -> Self {
SshConnectionOptions {
- host: val.host,
+ host: val.host.into(),
username: val.username,
port: val.port,
password: None,
@@ -87,7 +90,10 @@ pub struct SshConnectionModal {
}
impl SshPrompt {
- pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
+ pub(crate) fn new(
+ connection_options: &SshConnectionOptions,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
let connection_string = connection_options.connection_string().into();
Self {
connection_string,
@@ -231,12 +237,57 @@ impl SshConnectionModal {
}
}
+pub(crate) struct SshConnectionHeader {
+ pub(crate) connection_string: SharedString,
+ pub(crate) nickname: Option<SharedString>,
+}
+
+impl RenderOnce for SshConnectionHeader {
+ fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+ let theme = cx.theme();
+
+ let mut header_color = theme.colors().text;
+ header_color.fade_out(0.96);
+
+ let (main_label, meta_label) = if let Some(nickname) = self.nickname {
+ (nickname, Some(format!("({})", self.connection_string)))
+ } else {
+ (self.connection_string, None)
+ };
+
+ h_flex()
+ .p_1()
+ .rounded_t_md()
+ .w_full()
+ .gap_2()
+ .justify_center()
+ .border_b_1()
+ .border_color(theme.colors().border_variant)
+ .bg(header_color)
+ .child(Icon::new(IconName::Server).size(IconSize::XSmall))
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Label::new(main_label)
+ .size(ui::LabelSize::Small)
+ .single_line(),
+ )
+ .children(meta_label.map(|label| {
+ Label::new(label)
+ .size(ui::LabelSize::Small)
+ .single_line()
+ .color(Color::Muted)
+ })),
+ )
+ }
+}
+
impl Render for SshConnectionModal {
fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
let connection_string = self.prompt.read(cx).connection_string.clone();
let theme = cx.theme();
- let mut header_color = cx.theme().colors().text;
- header_color.fade_out(0.96);
+
let body_color = theme.colors().editor_background;
v_flex()
@@ -248,36 +299,11 @@ impl Render for SshConnectionModal {
.border_1()
.border_color(theme.colors().border)
.child(
- h_flex()
- .relative()
- .p_1()
- .rounded_t_md()
- .border_b_1()
- .border_color(theme.colors().border)
- .bg(header_color)
- .justify_between()
- .child(
- div().absolute().left_0p5().top_0p5().child(
- IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
- .icon_size(IconSize::XSmall)
- .on_click(cx.listener(move |this, _, cx| {
- this.dismiss(&Default::default(), cx);
- }))
- .tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
- ),
- )
- .child(
- h_flex()
- .w_full()
- .gap_2()
- .justify_center()
- .child(Icon::new(IconName::Server).size(IconSize::XSmall))
- .child(
- Label::new(connection_string)
- .size(ui::LabelSize::Small)
- .single_line(),
- ),
- ),
+ SshConnectionHeader {
+ connection_string,
+ nickname: None,
+ }
+ .render(cx),
)
.child(
h_flex()