Rework file picker for SSH modal (#19020)

This PR changes the SSH modal design so its more keyboard
navigation-friendly and adds the server nickname feature.

Release Notes:

- N/A

---------

Co-authored-by: Danilo <danilo@zed.dev>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
This commit is contained in:
Piotr Osiewicz 2024-10-15 12:38:03 +02:00 committed by GitHub
parent be7b24fcf7
commit db7417f3b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 871 additions and 491 deletions

2
Cargo.lock generated
View file

@ -8968,6 +8968,7 @@ dependencies = [
"client", "client",
"dev_server_projects", "dev_server_projects",
"editor", "editor",
"file_finder",
"futures 0.3.30", "futures 0.3.30",
"fuzzy", "fuzzy",
"gpui", "gpui",
@ -8988,7 +8989,6 @@ dependencies = [
"task", "task",
"terminal_view", "terminal_view",
"ui", "ui",
"ui_input",
"util", "util",
"workspace", "workspace",
] ]

View file

@ -395,6 +395,7 @@
// Change the default action on `menu::Confirm` by setting the parameter // Change the default action on `menu::Confirm` by setting the parameter
// "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }],
"alt-cmd-o": "projects::OpenRecent", "alt-cmd-o": "projects::OpenRecent",
"ctrl-cmd-o": "projects::OpenRemote",
"alt-cmd-b": "branches::OpenRecent", "alt-cmd-b": "branches::OpenRecent",
"ctrl-~": "workspace::NewTerminal", "ctrl-~": "workspace::NewTerminal",
"cmd-s": "workspace::Save", "cmd-s": "workspace::Save",

View file

@ -5,6 +5,8 @@ mod file_finder_settings;
mod new_path_prompt; mod new_path_prompt;
mod open_path_prompt; mod open_path_prompt;
pub use open_path_prompt::OpenPathDelegate;
use collections::HashMap; use collections::HashMap;
use editor::{scroll::Autoscroll, Bias, Editor}; use editor::{scroll::Autoscroll, Bias, Editor};
use file_finder_settings::FileFinderSettings; use file_finder_settings::FileFinderSettings;

View file

@ -26,6 +26,20 @@ pub struct OpenPathDelegate {
should_dismiss: bool, should_dismiss: bool,
} }
impl OpenPathDelegate {
pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
Self {
tx: Some(tx),
lister,
selected_index: 0,
directory_state: None,
matches: Vec::new(),
cancel_flag: Arc::new(AtomicBool::new(false)),
should_dismiss: true,
}
}
}
struct DirectoryState { struct DirectoryState {
path: String, path: String,
match_candidates: Vec<StringMatchCandidate>, match_candidates: Vec<StringMatchCandidate>,
@ -48,15 +62,7 @@ impl OpenPathPrompt {
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
) { ) {
workspace.toggle_modal(cx, |cx| { workspace.toggle_modal(cx, |cx| {
let delegate = OpenPathDelegate { let delegate = OpenPathDelegate::new(tx, lister.clone());
tx: Some(tx),
lister: lister.clone(),
selected_index: 0,
directory_state: None,
matches: Vec::new(),
cancel_flag: Arc::new(AtomicBool::new(false)),
should_dismiss: true,
};
let picker = Picker::uniform_list(delegate, cx).width(rems(34.)); let picker = Picker::uniform_list(delegate, cx).width(rems(34.));
let query = lister.default_query(cx); let query = lister.default_query(cx);

View file

@ -1,5 +1,6 @@
use derive_more::{Deref, DerefMut}; use derive_more::{Deref, DerefMut};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{borrow::Borrow, sync::Arc}; use std::{borrow::Borrow, sync::Arc};
use util::arc_cow::ArcCow; use util::arc_cow::ArcCow;
@ -16,6 +17,16 @@ impl SharedString {
} }
} }
impl JsonSchema for SharedString {
fn schema_name() -> String {
String::schema_name()
}
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
String::json_schema(gen)
}
}
impl Default for SharedString { impl Default for SharedString {
fn default() -> Self { fn default() -> Self {
Self(ArcCow::Owned(Arc::default())) Self(ArcCow::Owned(Arc::default()))

View file

@ -4905,6 +4905,12 @@ impl From<(&'static str, usize)> for ElementId {
} }
} }
impl From<(SharedString, usize)> for ElementId {
fn from((name, id): (SharedString, usize)) -> Self {
ElementId::NamedInteger(name, id)
}
}
impl From<(&'static str, u64)> for ElementId { impl From<(&'static str, u64)> for ElementId {
fn from((name, id): (&'static str, u64)) -> Self { fn from((name, id): (&'static str, u64)) -> Self {
ElementId::NamedInteger(name.into(), id as usize) ElementId::NamedInteger(name.into(), id as usize)

View file

@ -476,7 +476,7 @@ impl<D: PickerDelegate> Picker<D> {
} }
} }
pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) { pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut WindowContext<'_>) {
if let Head::Editor(ref editor) = &self.head { if let Head::Editor(ref editor) = &self.head {
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
editor.set_text(query, cx); editor.set_text(query, cx);

View file

@ -18,6 +18,7 @@ auto_update.workspace = true
release_channel.workspace = true release_channel.workspace = true
client.workspace = true client.workspace = true
editor.workspace = true editor.workspace = true
file_finder.workspace = true
futures.workspace = true futures.workspace = true
fuzzy.workspace = true fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true
@ -36,7 +37,6 @@ smol.workspace = true
task.workspace = true task.workspace = true
terminal_view.workspace = true terminal_view.workspace = true
ui.workspace = true ui.workspace = true
ui_input.workspace = true
util.workspace = true util.workspace = true
workspace.workspace = true workspace.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -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::{
div, h_flex, prelude::*, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, Icon, IconButton, div, h_flex, prelude::*, v_flex, ActiveTheme, Color, Icon, IconName, IconSize,
IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, Tooltip, InteractiveElement, IntoElement, Label, LabelCommon, Styled, ViewContext, VisualContext,
ViewContext, VisualContext, WindowContext, WindowContext,
}; };
use workspace::{AppState, ModalView, Workspace}; use workspace::{AppState, ModalView, Workspace};
@ -35,17 +35,20 @@ impl SshSettings {
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct SshConnection { pub struct SshConnection {
pub host: String, pub host: SharedString,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>, pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>, pub port: Option<u16>,
pub projects: Vec<SshProject>, 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 { impl From<SshConnection> for SshConnectionOptions {
fn from(val: SshConnection) -> Self { fn from(val: SshConnection) -> Self {
SshConnectionOptions { SshConnectionOptions {
host: val.host, host: val.host.into(),
username: val.username, username: val.username,
port: val.port, port: val.port,
password: None, password: None,
@ -87,7 +90,10 @@ pub struct SshConnectionModal {
} }
impl SshPrompt { 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(); let connection_string = connection_options.connection_string().into();
Self { Self {
connection_string, 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 { 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 connection_string = self.prompt.read(cx).connection_string.clone();
let theme = cx.theme(); 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; let body_color = theme.colors().editor_background;
v_flex() v_flex()
@ -248,36 +299,11 @@ impl Render for SshConnectionModal {
.border_1() .border_1()
.border_color(theme.colors().border) .border_color(theme.colors().border)
.child( .child(
h_flex() SshConnectionHeader {
.relative() connection_string,
.p_1() nickname: None,
.rounded_t_md() }
.border_b_1() .render(cx),
.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(),
),
),
) )
.child( .child(
h_flex() h_flex()

View file

@ -293,15 +293,20 @@ impl TitleBar {
let meta = SharedString::from(meta); let meta = SharedString::from(meta);
let indicator = div() let indicator = h_flex()
// We're using the circle inside a circle approach because, otherwise, by using borders
// we'd get a very thin, leaking indicator color, which is not what we want.
.absolute() .absolute()
.size_2p5() .size_2p5()
.right_0() .right_0()
.bottom_0() .bottom_0()
.bg(indicator_border_color)
.size_2p5()
.rounded_full() .rounded_full()
.border_2() .items_center()
.border_color(indicator_border_color) .justify_center()
.bg(indicator_color.color(cx)); .overflow_hidden()
.child(Indicator::dot().color(indicator_color));
Some( Some(
div() div()

View file

@ -193,6 +193,7 @@ impl RenderOnce for ListItem {
.id("inner_list_item") .id("inner_list_item")
.w_full() .w_full()
.relative() .relative()
.items_center()
.gap_1() .gap_1()
.px(Spacing::Medium.rems(cx)) .px(Spacing::Medium.rems(cx))
.map(|this| match self.spacing { .map(|this| match self.spacing {
@ -247,7 +248,7 @@ impl RenderOnce for ListItem {
.flex_grow() .flex_grow()
.flex_shrink_0() .flex_shrink_0()
.flex_basis(relative(0.25)) .flex_basis(relative(0.25))
.gap(Spacing::Small.rems(cx)) .gap(Spacing::Medium.rems(cx))
.map(|list_content| { .map(|list_content| {
if self.overflow_x { if self.overflow_x {
list_content list_content