ssh: Overhaul remoting UI (#18727)

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
This commit is contained in:
Piotr Osiewicz 2024-10-07 15:01:50 +02:00 committed by GitHub
parent 9c5bec5efb
commit 5aa165c530
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 361 additions and 427 deletions

1
Cargo.lock generated
View file

@ -9002,7 +9002,6 @@ dependencies = [
"gpui", "gpui",
"language", "language",
"log", "log",
"markdown",
"menu", "menu",
"ordered-float 2.10.1", "ordered-float 2.10.1",
"picker", "picker",

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>

After

Width:  |  Height:  |  Size: 330 B

View file

@ -22,7 +22,6 @@ futures.workspace = true
fuzzy.workspace = true fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true
log.workspace = true log.workspace = true
markdown.workspace = true
menu.workspace = true menu.workspace = true
ordered-float.workspace = true ordered-float.workspace = true
picker.workspace = true picker.workspace = true

View file

@ -8,17 +8,18 @@ use anyhow::Result;
use client::Client; use client::Client;
use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId}; use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
use editor::Editor; use editor::Editor;
use gpui::pulsating_between;
use gpui::AsyncWindowContext; use gpui::AsyncWindowContext;
use gpui::ClipboardItem;
use gpui::PathPromptOptions; use gpui::PathPromptOptions;
use gpui::Subscription; use gpui::Subscription;
use gpui::Task; use gpui::Task;
use gpui::WeakView; use gpui::WeakView;
use gpui::{ use gpui::{
percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, percentage, Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent,
FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext, EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View,
ViewContext,
}; };
use markdown::Markdown;
use markdown::MarkdownStyle;
use project::terminals::wrap_for_ssh; use project::terminals::wrap_for_ssh;
use project::terminals::SshCommand; use project::terminals::SshCommand;
use rpc::proto::RegenerateDevServerTokenResponse; use rpc::proto::RegenerateDevServerTokenResponse;
@ -35,8 +36,8 @@ use terminal_view::terminal_panel::TerminalPanel;
use ui::ElevationIndex; use ui::ElevationIndex;
use ui::Section; use ui::Section;
use ui::{ use ui::{
prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader, prelude::*, IconButtonShape, Indicator, List, ListItem, Modal, ModalFooter, ModalHeader,
RadioWithLabel, Tooltip, Tooltip,
}; };
use ui_input::{FieldLabelLayout, TextField}; use ui_input::{FieldLabelLayout, TextField};
use util::ResultExt; use util::ResultExt;
@ -62,7 +63,6 @@ pub struct DevServerProjects {
workspace: WeakView<Workspace>, workspace: WeakView<Workspace>,
project_path_input: View<Editor>, project_path_input: View<Editor>,
dev_server_name_input: View<TextField>, dev_server_name_input: View<TextField>,
markdown: View<Markdown>,
_dev_server_subscription: Subscription, _dev_server_subscription: Subscription,
} }
@ -132,26 +132,6 @@ impl DevServerProjects {
..Default::default() ..Default::default()
}); });
let markdown_style = MarkdownStyle {
base_text_style: base_style,
code_block: gpui::StyleRefinement {
text: Some(gpui::TextStyleRefinement {
font_family: Some("Zed Plex Mono".into()),
..Default::default()
}),
..Default::default()
},
link: gpui::TextStyleRefinement {
color: Some(Color::Accent.color(cx)),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().players().local().selection,
..Default::default()
};
let markdown =
cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
Self { Self {
mode: Mode::Default(None), mode: Mode::Default(None),
focus_handle, focus_handle,
@ -159,7 +139,6 @@ impl DevServerProjects {
dev_server_store, dev_server_store,
project_path_input, project_path_input,
dev_server_name_input, dev_server_name_input,
markdown,
workspace, workspace,
_dev_server_subscription: subscription, _dev_server_subscription: subscription,
} }
@ -845,7 +824,7 @@ impl DevServerProjects {
}) })
.child({ .child({
let dev_server_id = dev_server.id; let dev_server_id = dev_server.id;
IconButton::new("remove-dev-server", IconName::Trash) IconButton::new("remove-dev-server", IconName::TrashAlt)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.delete_dev_server(dev_server_id, cx) this.delete_dev_server(dev_server_id, cx)
})) }))
@ -913,40 +892,73 @@ impl DevServerProjects {
) -> impl IntoElement { ) -> impl IntoElement {
v_flex() v_flex()
.w_full() .w_full()
.px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
.child( .child(
h_flex().group("ssh-server").justify_between().child( h_flex()
h_flex() .w_full()
.gap_2() .group("ssh-server")
.child( .justify_between()
div() .child(
.id(("status", ix)) h_flex()
.relative() .gap_2()
.child(Icon::new(IconName::Server).size(IconSize::Small)), .w_full()
) .child(
.child( div()
div() .id(("status", ix))
.max_w(rems(26.)) .relative()
.overflow_hidden() .child(Icon::new(IconName::Server).size(IconSize::Small)),
.whitespace_nowrap() )
.child(Label::new(ssh_connection.host.clone())), .child(
) h_flex()
.child(h_flex().visible_on_hover("ssh-server").gap_1().child({ .max_w(rems(26.))
IconButton::new("remove-dev-server", IconName::Trash) .overflow_hidden()
.on_click( .whitespace_nowrap()
cx.listener(move |this, _, cx| this.delete_ssh_server(ix, cx)), .child(Label::new(ssh_connection.host.clone())),
) ),
.tooltip(|cx| Tooltip::text("Remove Dev Server", cx)) )
})), .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))
}),
),
) )
.child( .child(
v_flex() v_flex()
.w_full() .w_full()
.bg(cx.theme().colors().background) .border_l_1()
.border_1()
.border_color(cx.theme().colors().border_variant) .border_color(cx.theme().colors().border_variant)
.rounded_md()
.my_1() .my_1()
.mx_1p5()
.py_0p5() .py_0p5()
.px_3() .px_3()
.child( .child(
@ -956,12 +968,17 @@ impl DevServerProjects {
self.render_ssh_project(ix, &ssh_connection, pix, p, cx) self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
})) }))
.child( .child(
ListItem::new("new-remote_project") h_flex().child(
.start_slot(Icon::new(IconName::Plus)) Button::new("new-remote_project", "Open Folder…")
.child(Label::new("Open folder…")) .icon(IconName::Plus)
.on_click(cx.listener(move |this, _, cx| { .size(ButtonSize::Default)
this.create_ssh_project(ix, ssh_connection.clone(), cx); .style(ButtonStyle::Filled)
})), .layer(ElevationIndex::ModalSurface)
.icon_position(IconPosition::Start)
.on_click(cx.listener(move |this, _, cx| {
this.create_ssh_project(ix, ssh_connection.clone(), cx);
})),
),
), ),
), ),
) )
@ -978,7 +995,8 @@ impl DevServerProjects {
let project = project.clone(); let project = project.clone();
let server = server.clone(); let server = server.clone();
ListItem::new(("remote-project", ix)) ListItem::new(("remote-project", ix))
.start_slot(Icon::new(IconName::FileTree)) .spacing(ui::ListItemSpacing::Sparse)
.start_slot(Icon::new(IconName::Folder).color(Color::Muted))
.child(Label::new(project.paths.join(", "))) .child(Label::new(project.paths.join(", ")))
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
let Some(app_state) = this let Some(app_state) = this
@ -1014,7 +1032,7 @@ impl DevServerProjects {
.detach(); .detach();
})) }))
.end_hover_slot::<AnyElement>(Some( .end_hover_slot::<AnyElement>(Some(
IconButton::new("remove-remote-project", IconName::Trash) IconButton::new("remove-remote-project", IconName::TrashAlt)
.on_click( .on_click(
cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)), cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
) )
@ -1026,7 +1044,7 @@ impl DevServerProjects {
fn update_settings_file( fn update_settings_file(
&mut self, &mut self,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static, f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
) { ) {
let Some(fs) = self let Some(fs) = self
.workspace .workspace
@ -1035,11 +1053,11 @@ impl DevServerProjects {
else { else {
return; return;
}; };
update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting)); update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
} }
fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) { fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
self.update_settings_file(cx, move |setting| { self.update_settings_file(cx, move |setting, _| {
if let Some(connections) = setting.ssh_connections.as_mut() { if let Some(connections) = setting.ssh_connections.as_mut() {
connections.remove(server); connections.remove(server);
} }
@ -1047,7 +1065,7 @@ impl DevServerProjects {
} }
fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) { fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
self.update_settings_file(cx, move |setting| { self.update_settings_file(cx, move |setting, _| {
if let Some(server) = setting if let Some(server) = setting
.ssh_connections .ssh_connections
.as_mut() .as_mut()
@ -1063,7 +1081,7 @@ impl DevServerProjects {
connection_options: remote::SshConnectionOptions, connection_options: remote::SshConnectionOptions,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
self.update_settings_file(cx, move |setting| { self.update_settings_file(cx, move |setting, _| {
setting setting
.ssh_connections .ssh_connections
.get_or_insert(Default::default()) .get_or_insert(Default::default())
@ -1124,7 +1142,7 @@ impl DevServerProjects {
}).detach(); }).detach();
} }
})) }))
.end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash) .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::TrashAlt)
.on_click(cx.listener(move |this, _, cx| { .on_click(cx.listener(move |this, _, cx| {
this.delete_dev_server_project(dev_server_project_id, cx) this.delete_dev_server_project(dev_server_project_id, cx)
})) }))
@ -1148,250 +1166,109 @@ impl DevServerProjects {
kind = NewServerKind::DirectSSH; kind = NewServerKind::DirectSSH;
} }
let status = dev_server_id self.dev_server_name_input.update(cx, |input, cx| {
.map(|id| self.dev_server_store.read(cx).dev_server_status(id))
.unwrap_or_default();
let name = self.dev_server_name_input.update(cx, |input, cx| {
input.editor().update(cx, |editor, cx| { input.editor().update(cx, |editor, cx| {
if editor.text(cx).is_empty() { if editor.text(cx).is_empty() {
match kind { editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
}
} }
editor.text(cx)
}) })
}); });
let theme = cx.theme();
const MANUAL_SETUP_MESSAGE: &str =
"Generate a token for this server and follow the steps to set Zed up on that machine.";
const SSH_SETUP_MESSAGE: &str =
"Enter the command you use to SSH into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
.header(
ModalHeader::new()
.headline("Create Dev Server")
.show_back_button(true),
)
.section(
Section::new()
.header(if kind == NewServerKind::Manual {
"Server Name".into()
} else {
"SSH arguments".into()
})
.child(
div()
.max_w(rems(16.))
.child(self.dev_server_name_input.clone()),
),
)
.section(
Section::new_contained()
.header("Connection Method".into())
.child(
v_flex()
.w_full()
.px_2()
.gap_y(Spacing::Large.rems(cx))
.when(ssh_prompt.is_none(), |el| {
el.child(
v_flex()
.when(use_direct_ssh, |el| {
el.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Connect via SSH (default)"),
NewServerKind::DirectSSH == kind,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(
CreateDevServer { kind, .. },
) = &mut this.mode
{
*kind = NewServerKind::DirectSSH;
}
cx.notify()
}
}),
))
})
.when(!use_direct_ssh, |el| {
el.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Configure over SSH (default)"),
kind == NewServerKind::LegacySSH,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(
CreateDevServer { kind, .. },
) = &mut this.mode
{
*kind = NewServerKind::LegacySSH;
}
cx.notify()
}
}),
))
})
.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Configure manually"),
kind == NewServerKind::Manual,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(
CreateDevServer { kind, .. },
) = &mut this.mode
{
*kind = NewServerKind::Manual;
}
cx.notify()
}
}),
)),
)
})
.when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
el.child(
if kind == NewServerKind::Manual {
Label::new(MANUAL_SETUP_MESSAGE)
} else {
Label::new(SSH_SETUP_MESSAGE)
}
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
.when(dev_server_id.is_some() && access_token.is_none(), |el| {
el.child(
if kind == NewServerKind::Manual {
Label::new(
"Note: updating the dev server generate a new token",
)
} else {
Label::new(SSH_SETUP_MESSAGE)
}
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when_some(access_token.clone(), {
|el, access_token| {
el.child(self.render_dev_server_token_creating(
access_token,
name,
kind,
status,
creating,
cx,
))
}
}),
),
)
.footer(
ModalFooter::new().end_slot(if status == DevServerStatus::Online {
Button::new("create-dev-server", "Done")
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ModalSurface)
.on_click(cx.listener(move |this, _, cx| {
cx.focus(&this.focus_handle);
this.mode = Mode::Default(None);
cx.notify();
}))
} else {
Button::new(
"create-dev-server",
if kind == NewServerKind::Manual {
if dev_server_id.is_some() {
"Update"
} else {
"Create"
}
} else if dev_server_id.is_some() {
"Reconnect"
} else {
"Connect"
},
)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ModalSurface)
.disabled(creating && dev_server_id.is_none())
.on_click(cx.listener({
let access_token = access_token.clone();
move |this, _, cx| {
if kind == NewServerKind::DirectSSH {
this.create_ssh_server(cx);
return;
}
this.create_or_update_dev_server(
kind,
dev_server_id,
access_token.clone(),
cx,
);
}
}))
}),
)
}
fn render_dev_server_token_creating(
&self,
access_token: String,
dev_server_name: String,
kind: NewServerKind,
status: DevServerStatus,
creating: bool,
cx: &mut ViewContext<Self>,
) -> Div {
self.markdown.update(cx, |markdown, cx| {
if kind == NewServerKind::Manual {
markdown.reset(format!("Please log into '{}'. If you don't yet have Zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen, to start Zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
} else {
markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using the manual setup.".to_string(), cx);
}
});
v_flex() v_flex()
.pl_2() .id("create-dev-server")
.pt_2() .overflow_hidden()
.gap_2() .size_full()
.child(v_flex().w_full().text_sm().child(self.markdown.clone())) .flex_1()
.map(|el| {
if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
{
el.child(
h_flex()
.gap_2()
.child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
.child(Label::new("Not connected")),
)
} else if status == DevServerStatus::Offline {
el.child(Self::render_loading_spinner("Waiting for connection…"))
} else {
el.child(Label::new("🎊 Connection established!"))
}
})
}
fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
h_flex()
.gap_2()
.child( .child(
Icon::new(IconName::ArrowCircle) h_flex()
.size(IconSize::Medium) .p_2()
.with_animation( .gap_2()
"arrow-circle", .items_center()
Animation::new(Duration::from_secs(2)).repeat(), .border_b_1()
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))), .border_color(theme.colors().border_variant)
.child(
IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
.shape(IconButtonShape::Square)
.on_click(|_, cx| {
cx.dispatch_action(menu::Cancel.boxed_clone());
}),
)
.child(Label::new("Connect New Dev Server")),
)
.child(
v_flex()
.p_3()
.border_b_1()
.border_color(theme.colors().border_variant)
.child(Label::new("SSH Arguments"))
.child(
Label::new("Enter the command you use to SSH into this server.")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
.mt_2()
.w_full()
.gap_2()
.child(self.dev_server_name_input.clone())
.child(
Button::new("create-dev-server", "Connect Server")
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ModalSurface)
.disabled(creating && dev_server_id.is_none())
.on_click(cx.listener({
let access_token = access_token.clone();
move |this, _, cx| {
if kind == NewServerKind::DirectSSH {
this.create_ssh_server(cx);
return;
}
this.create_or_update_dev_server(
kind,
dev_server_id,
access_token.clone(),
cx,
);
}
})),
),
), ),
) )
.child(Label::new(label)) .child(
h_flex()
.bg(theme.colors().editor_background)
.w_full()
.map(|this| {
if let Some(ssh_prompt) = ssh_prompt {
this.child(h_flex().w_full().child(ssh_prompt))
} else {
let color = Color::Muted.color(cx);
this.child(
h_flex()
.p_2()
.w_full()
.content_center()
.gap_2()
.child(h_flex().w_full())
.child(
div().p_1().rounded_lg().bg(color).with_animation(
"pulse-ssh-waiting-for-connection",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.2, 0.5)),
move |this, progress| this.bg(color.opacity(progress)),
),
)
.child(
Label::new("Waiting for connection…")
.size(LabelSize::Small),
)
.child(h_flex().w_full()),
)
}
}),
)
} }
fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@ -1416,64 +1293,73 @@ impl DevServerProjects {
creating_dev_server = Some(*dev_server_id); creating_dev_server = Some(*dev_server_id);
}; };
let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
Modal::new("remote-projects", Some(self.scroll_handle.clone())) Modal::new("remote-projects", Some(self.scroll_handle.clone()))
.header( .header(
ModalHeader::new() ModalHeader::new().child(
.show_dismiss_button(true) h_flex()
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)), .justify_between()
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
.child(
Button::new("register-dev-server-button", "Connect New Server")
.style(ButtonStyle::Filled)
.layer(ElevationIndex::ModalSurface)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_color(Color::Muted)
.on_click(cx.listener(|this, _, cx| {
this.mode = Mode::CreateDevServer(CreateDevServer {
kind: if SshSettings::get_global(cx).use_direct_ssh() {
NewServerKind::DirectSSH
} else {
NewServerKind::LegacySSH
},
..Default::default()
});
this.dev_server_name_input.update(cx, |text_field, cx| {
text_field.editor().update(cx, |editor, cx| {
editor.set_text("", cx);
});
});
cx.notify();
})),
),
),
) )
.section( .section(
Section::new().child( Section::new().padded(false).child(
div().child( div()
List::new() .border_y_1()
.empty_message("No dev servers registered yet.") .border_color(cx.theme().colors().border_variant)
.header(Some( .w_full()
ListHeader::new("Connections").end_slot( .child(
Button::new("register-dev-server-button", "Connect New Server") div().p_2().child(
.icon(IconName::Plus) List::new()
.icon_position(IconPosition::Start) .empty_message("No dev servers registered yet.")
.icon_color(Color::Muted) .children(ssh_connections.iter().cloned().enumerate().map(
.on_click(cx.listener(|this, _, cx| { |(ix, connection)| {
this.mode = Mode::CreateDevServer(CreateDevServer { self.render_ssh_connection(ix, connection, cx)
kind: if SshSettings::get_global(cx) .into_any_element()
.use_direct_ssh() },
{ ))
NewServerKind::DirectSSH .children(dev_servers.iter().map(|dev_server| {
} else { let creating = if creating_dev_server == Some(dev_server.id)
NewServerKind::LegacySSH {
}, is_creating
..Default::default() } else {
}); None
this.dev_server_name_input.update( };
cx, self.render_dev_server(dev_server, creating, cx)
|text_field, cx| { .into_any_element()
text_field.editor().update(cx, |editor, cx| { })),
editor.set_text("", cx); ),
}); ),
},
);
cx.notify();
})),
),
))
.children(ssh_connections.iter().cloned().enumerate().map(
|(ix, connection)| {
self.render_ssh_connection(ix, connection, cx)
.into_any_element()
},
))
.children(dev_servers.iter().map(|dev_server| {
let creating = if creating_dev_server == Some(dev_server.id) {
is_creating
} else {
None
};
self.render_dev_server(dev_server, creating, cx)
.into_any_element()
})),
),
), ),
) )
.footer(
ModalFooter::new()
.start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
)
} }
} }
@ -1501,7 +1387,6 @@ impl Render for DevServerProjects {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div() div()
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.p_2()
.elevation_3(cx) .elevation_3(cx)
.key_context("DevServerModal") .key_context("DevServerModal")
.on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::cancel))

View file

@ -5,9 +5,9 @@ use auto_update::AutoUpdater;
use editor::Editor; use editor::Editor;
use futures::channel::oneshot; use futures::channel::oneshot;
use gpui::{ use gpui::{
percentage, px, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent, percentage, px, Action, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext,
EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion, SharedString, Task, DismissEvent, EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion,
Transformation, View, SharedString, Task, Transformation, View,
}; };
use gpui::{AppContext, Model}; use gpui::{AppContext, Model};
use release_channel::{AppVersion, ReleaseChannel}; use release_channel::{AppVersion, ReleaseChannel};
@ -16,9 +16,9 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources}; use settings::{Settings, SettingsSources};
use ui::{ use ui::{
h_flex, v_flex, Color, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, div, h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, FluentBuilder as _, Icon,
IntoElement, Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext, IconButton, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled,
WindowContext, StyledExt as _, Tooltip, ViewContext, VisualContext, WindowContext,
}; };
use workspace::{AppState, ModalView, Workspace}; use workspace::{AppState, ModalView, Workspace};
@ -140,47 +140,57 @@ impl SshPrompt {
} }
impl Render for SshPrompt { impl Render for SshPrompt {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
v_flex() v_flex()
.w_full()
.key_context("PasswordPrompt") .key_context("PasswordPrompt")
.p_4() .justify_start()
.size_full()
.child( .child(
h_flex() v_flex()
.gap_2() .p_4()
.child(if self.error_message.is_some() { .size_full()
Icon::new(IconName::XCircle)
.size(IconSize::Medium)
.color(Color::Error)
.into_any_element()
} else {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Medium)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
)
.into_any_element()
})
.child( .child(
Label::new(format!("ssh {}", self.connection_string)) h_flex()
.size(ui::LabelSize::Large), .gap_2()
), .justify_between()
.child(h_flex().w_full())
.child(if self.error_message.is_some() {
Icon::new(IconName::XCircle)
.size(IconSize::Medium)
.color(Color::Error)
.into_any_element()
} else {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Medium)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(
delta,
)))
},
)
.into_any_element()
})
.child(Label::new(format!(
"Connecting to {}…",
self.connection_string
)))
.child(h_flex().w_full()),
)
.when_some(self.error_message.as_ref(), |el, error| {
el.child(Label::new(error.clone()))
})
.when(
self.error_message.is_none() && self.status_message.is_some(),
|el| el.child(Label::new(self.status_message.clone().unwrap())),
)
.when_some(self.prompt.as_ref(), |el, prompt| {
el.child(Label::new(prompt.0.clone()))
.child(self.editor.clone())
}),
) )
.when_some(self.error_message.as_ref(), |el, error| {
el.child(Label::new(error.clone()))
})
.when(
self.error_message.is_none() && self.status_message.is_some(),
|el| el.child(Label::new(self.status_message.clone().unwrap())),
)
.when_some(self.prompt.as_ref(), |el, prompt| {
el.child(Label::new(prompt.0.clone()))
.child(self.editor.clone())
})
} }
} }
@ -202,14 +212,41 @@ impl SshConnectionModal {
impl Render for SshConnectionModal { impl Render for SshConnectionModal {
fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement { 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 header_color = theme.colors().element_background;
let body_color = theme.colors().background;
v_flex() v_flex()
.elevation_3(cx) .elevation_3(cx)
.p_4()
.gap_2()
.on_action(cx.listener(Self::dismiss)) .on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::confirm))
.w(px(400.)) .w(px(400.))
.child(self.prompt.clone()) .child(
h_flex()
.p_1()
.border_b_1()
.border_color(theme.colors().border)
.bg(header_color)
.justify_between()
.child(
IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
.icon_size(IconSize::XSmall)
.on_click(|_, cx| cx.dispatch_action(menu::Cancel.boxed_clone()))
.tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
)
.child(
h_flex()
.gap_2()
.child(Icon::new(IconName::Server).size(IconSize::XSmall))
.child(
Label::new(connection_string)
.size(ui::LabelSize::Small)
.single_line(),
),
)
.child(div()),
)
.child(h_flex().bg(body_color).w_full().child(self.prompt.clone()))
} }
} }

View file

@ -275,6 +275,7 @@ pub enum IconName {
Tab, Tab,
Terminal, Terminal,
Trash, Trash,
TrashAlt,
TriangleRight, TriangleRight,
Undo, Undo,
Unpin, Unpin,

View file

@ -262,6 +262,7 @@ impl RenderOnce for ModalFooter {
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Section { pub struct Section {
contained: bool, contained: bool,
padded: bool,
header: Option<SectionHeader>, header: Option<SectionHeader>,
meta: Option<SharedString>, meta: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
@ -277,6 +278,7 @@ impl Section {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
contained: false, contained: false,
padded: true,
header: None, header: None,
meta: None, meta: None,
children: SmallVec::new(), children: SmallVec::new(),
@ -286,6 +288,7 @@ impl Section {
pub fn new_contained() -> Self { pub fn new_contained() -> Self {
Self { Self {
contained: true, contained: true,
padded: true,
header: None, header: None,
meta: None, meta: None,
children: SmallVec::new(), children: SmallVec::new(),
@ -306,6 +309,10 @@ impl Section {
self.meta = Some(meta.into()); self.meta = Some(meta.into());
self self
} }
pub fn padded(mut self, padded: bool) -> Self {
self.padded = padded;
self
}
} }
impl ParentElement for Section { impl ParentElement for Section {
@ -320,22 +327,27 @@ impl RenderOnce for Section {
section_bg.fade_out(0.96); section_bg.fade_out(0.96);
let children = if self.contained { let children = if self.contained {
v_flex().flex_1().px(Spacing::XLarge.rems(cx)).child( v_flex()
v_flex() .flex_1()
.w_full() .when(self.padded, |this| this.px(Spacing::XLarge.rems(cx)))
.rounded_md() .child(
.border_1() v_flex()
.border_color(cx.theme().colors().border) .w_full()
.bg(section_bg) .rounded_md()
.py(Spacing::Medium.rems(cx)) .border_1()
.gap_y(Spacing::Small.rems(cx)) .border_color(cx.theme().colors().border)
.child(div().flex().flex_1().size_full().children(self.children)), .bg(section_bg)
) .py(Spacing::Medium.rems(cx))
.gap_y(Spacing::Small.rems(cx))
.child(div().flex().flex_1().size_full().children(self.children)),
)
} else { } else {
v_flex() v_flex()
.w_full() .w_full()
.gap_y(Spacing::Small.rems(cx)) .gap_y(Spacing::Small.rems(cx))
.px(Spacing::Medium.rems(cx) + Spacing::Medium.rems(cx)) .when(self.padded, |this| {
this.px(Spacing::Medium.rems(cx) + Spacing::Medium.rems(cx))
})
.children(self.children) .children(self.children)
}; };

View file

@ -3,7 +3,7 @@ use gpui::{hsla, Styled, WindowContext};
use crate::prelude::*; use crate::prelude::*;
use crate::ElevationIndex; use crate::ElevationIndex;
fn elevated<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E { fn elevated<E: Styled>(this: E, cx: &WindowContext, index: ElevationIndex) -> E {
this.bg(cx.theme().colors().elevated_surface_background) this.bg(cx.theme().colors().elevated_surface_background)
.rounded_lg() .rounded_lg()
.border_1() .border_1()
@ -11,7 +11,7 @@ fn elevated<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -
.shadow(index.shadow()) .shadow(index.shadow())
} }
fn elevated_borderless<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E { fn elevated_borderless<E: Styled>(this: E, cx: &WindowContext, index: ElevationIndex) -> E {
this.bg(cx.theme().colors().elevated_surface_background) this.bg(cx.theme().colors().elevated_surface_background)
.rounded_lg() .rounded_lg()
.shadow(index.shadow()) .shadow(index.shadow())
@ -38,14 +38,14 @@ pub trait StyledExt: Styled + Sized {
/// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
/// ///
/// Example Elements: Title Bar, Panel, Tab Bar, Editor /// Example Elements: Title Bar, Panel, Tab Bar, Editor
fn elevation_1(self, cx: &mut WindowContext) -> Self { fn elevation_1(self, cx: &WindowContext) -> Self {
elevated(self, cx, ElevationIndex::Surface) elevated(self, cx, ElevationIndex::Surface)
} }
/// See [`elevation_1`]. /// See [`elevation_1`].
/// ///
/// Renders a borderless version [`elevation_1`]. /// Renders a borderless version [`elevation_1`].
fn elevation_1_borderless(self, cx: &mut WindowContext) -> Self { fn elevation_1_borderless(self, cx: &WindowContext) -> Self {
elevated_borderless(self, cx, ElevationIndex::Surface) elevated_borderless(self, cx, ElevationIndex::Surface)
} }
@ -54,14 +54,14 @@ pub trait StyledExt: Styled + Sized {
/// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
/// ///
/// Examples: Notifications, Palettes, Detached/Floating Windows, Detached/Floating Panels /// Examples: Notifications, Palettes, Detached/Floating Windows, Detached/Floating Panels
fn elevation_2(self, cx: &mut WindowContext) -> Self { fn elevation_2(self, cx: &WindowContext) -> Self {
elevated(self, cx, ElevationIndex::ElevatedSurface) elevated(self, cx, ElevationIndex::ElevatedSurface)
} }
/// See [`elevation_2`]. /// See [`elevation_2`].
/// ///
/// Renders a borderless version [`elevation_2`]. /// Renders a borderless version [`elevation_2`].
fn elevation_2_borderless(self, cx: &mut WindowContext) -> Self { fn elevation_2_borderless(self, cx: &WindowContext) -> Self {
elevated_borderless(self, cx, ElevationIndex::ElevatedSurface) elevated_borderless(self, cx, ElevationIndex::ElevatedSurface)
} }
@ -74,24 +74,24 @@ pub trait StyledExt: Styled + Sized {
/// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
/// ///
/// Examples: Settings Modal, Channel Management, Wizards/Setup UI, Dialogs /// Examples: Settings Modal, Channel Management, Wizards/Setup UI, Dialogs
fn elevation_3(self, cx: &mut WindowContext) -> Self { fn elevation_3(self, cx: &WindowContext) -> Self {
elevated(self, cx, ElevationIndex::ModalSurface) elevated(self, cx, ElevationIndex::ModalSurface)
} }
/// See [`elevation_3`]. /// See [`elevation_3`].
/// ///
/// Renders a borderless version [`elevation_3`]. /// Renders a borderless version [`elevation_3`].
fn elevation_3_borderless(self, cx: &mut WindowContext) -> Self { fn elevation_3_borderless(self, cx: &WindowContext) -> Self {
elevated_borderless(self, cx, ElevationIndex::ModalSurface) elevated_borderless(self, cx, ElevationIndex::ModalSurface)
} }
/// The theme's primary border color. /// The theme's primary border color.
fn border_primary(self, cx: &mut WindowContext) -> Self { fn border_primary(self, cx: &WindowContext) -> Self {
self.border_color(cx.theme().colors().border) self.border_color(cx.theme().colors().border)
} }
/// The theme's secondary or muted border color. /// The theme's secondary or muted border color.
fn border_muted(self, cx: &mut WindowContext) -> Self { fn border_muted(self, cx: &WindowContext) -> Self {
self.border_color(cx.theme().colors().border_variant) self.border_color(cx.theme().colors().border_variant)
} }