ProfileCard shows someone at a glance; ProfileEditor edits a profile seeded by its owner and saves once it changed, has a name and an email that reads, with a way to put it back. AccountSwitcher and WorkspaceSwitcher show the one in use in a row that opens a menu of the others and a way to add one; UserMenu opens from an avatar with who is signed in, the owner's rows and Sign out, through the new menus::menu_under. SessionList puts this device first, signs others out one by one or all after a second press. ApiKeyManager lists keys by their ends, makes one by name and shows its secret once.
361 lines
12 KiB
Rust
361 lines
12 KiB
Rust
use std::{path::PathBuf, rc::Rc};
|
|
|
|
use gpui::{
|
|
AnyElement, App, AppContext as _, ElementId, Entity, FontWeight, ImageSource, IntoElement,
|
|
ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
|
};
|
|
|
|
use crate::{
|
|
buttons::{Button, ButtonVariant},
|
|
data_display::{Avatar, Presence},
|
|
forms::{AvatarUpload, EmailInput, FormField, Input, TextInput, is_email},
|
|
layout::Card,
|
|
primitives::{Icon, IconName},
|
|
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
|
|
typography::Ellipsis,
|
|
};
|
|
|
|
/// Someone at a glance: their picture or initials, name and title, a line about them, details each with an icon, and the owner's actions.
|
|
#[derive(IntoElement)]
|
|
pub struct ProfileCard {
|
|
id: ElementId,
|
|
name: SharedString,
|
|
title: Option<SharedString>,
|
|
image: Option<ImageSource>,
|
|
presence: Option<Presence>,
|
|
bio: Option<SharedString>,
|
|
details: Vec<(IconName, SharedString)>,
|
|
actions: Vec<AnyElement>,
|
|
}
|
|
|
|
impl ProfileCard {
|
|
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
name: name.into(),
|
|
title: None,
|
|
image: None,
|
|
presence: None,
|
|
bio: None,
|
|
details: Vec::new(),
|
|
actions: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
|
self.title = Some(title.into());
|
|
self
|
|
}
|
|
|
|
pub fn image(mut self, source: impl Into<ImageSource>) -> Self {
|
|
self.image = Some(source.into());
|
|
self
|
|
}
|
|
|
|
pub fn presence(mut self, presence: Presence) -> Self {
|
|
self.presence = Some(presence);
|
|
self
|
|
}
|
|
|
|
pub fn bio(mut self, bio: impl Into<SharedString>) -> Self {
|
|
self.bio = Some(bio.into());
|
|
self
|
|
}
|
|
|
|
/// A line such as an email or a place, after its icon.
|
|
pub fn detail(mut self, icon: IconName, text: impl Into<SharedString>) -> Self {
|
|
self.details.push((icon, text.into()));
|
|
self
|
|
}
|
|
|
|
pub fn action(mut self, action: impl IntoElement) -> Self {
|
|
self.actions.push(action.into_any_element());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for ProfileCard {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let mut avatar =
|
|
Avatar::new((self.id.clone(), "avatar"), self.name.clone()).size(AvatarSize::Lg);
|
|
if let Some(image) = self.image {
|
|
avatar = avatar.image(image);
|
|
}
|
|
if let Some(presence) = self.presence {
|
|
avatar = avatar.presence(presence);
|
|
}
|
|
let muted = theme.colors.fg_muted;
|
|
let details = self.details.into_iter().map(|(icon, text)| {
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(Icon::new(icon).size(IconSize::Sm).color(muted))
|
|
.child(div().flex_1().min_w_0().child(Ellipsis::new(text)))
|
|
});
|
|
Card::new().child(
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_4()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(div().flex_none().child(avatar))
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.child(
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Md))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.child(Ellipsis::new(self.name)),
|
|
)
|
|
.children(self.title.map(|title| {
|
|
div().text_color(muted).child(Ellipsis::new(title))
|
|
})),
|
|
),
|
|
)
|
|
.children(
|
|
self.bio
|
|
.map(|bio| div().flex().child(div().flex_1().min_w_0().child(bio))),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1p5()
|
|
.text_color(muted)
|
|
.children(details),
|
|
)
|
|
.children(
|
|
(!self.actions.is_empty())
|
|
.then(|| div().flex().flex_wrap().gap_2().children(self.actions)),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// What a profile holds.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Profile {
|
|
pub name: SharedString,
|
|
pub username: SharedString,
|
|
pub email: SharedString,
|
|
pub bio: SharedString,
|
|
pub location: SharedString,
|
|
pub picture: Option<PathBuf>,
|
|
}
|
|
|
|
type OnProfile = Rc<dyn Fn(&Profile, &mut Window, &mut App)>;
|
|
|
|
/// The editor's own fields, filled from the profile they started as.
|
|
struct Fields {
|
|
seed: Profile,
|
|
name: Entity<TextInput>,
|
|
username: Entity<TextInput>,
|
|
email: Entity<TextInput>,
|
|
bio: Entity<TextInput>,
|
|
location: Entity<TextInput>,
|
|
picture: Option<PathBuf>,
|
|
}
|
|
|
|
impl Fields {
|
|
fn filled(profile: &Profile, window: &mut Window, cx: &mut App) -> Self {
|
|
let mut field = |text: &SharedString, lines: bool| {
|
|
let text = text.to_string();
|
|
cx.new(|cx| {
|
|
let input = TextInput::new(window, cx);
|
|
let mut input = if lines { input.multi_line(3, 6) } else { input };
|
|
input.set_text(text, cx);
|
|
input
|
|
})
|
|
};
|
|
Self {
|
|
seed: profile.clone(),
|
|
name: field(&profile.name, false),
|
|
username: field(&profile.username, false),
|
|
email: field(&profile.email, false),
|
|
bio: field(&profile.bio, true),
|
|
location: field(&profile.location, false),
|
|
picture: profile.picture.clone(),
|
|
}
|
|
}
|
|
|
|
fn read(&self, cx: &App) -> Profile {
|
|
let text = |input: &Entity<TextInput>| {
|
|
SharedString::from(input.read(cx).text().trim().to_string())
|
|
};
|
|
Profile {
|
|
name: text(&self.name),
|
|
username: text(&self.username),
|
|
email: text(&self.email),
|
|
bio: text(&self.bio),
|
|
location: text(&self.location),
|
|
picture: self.picture.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether an edited profile may be saved: it changed, it has a name, and its email reads.
|
|
fn savable(edited: &Profile, seed: &Profile) -> bool {
|
|
edited != seed && !edited.name.is_empty() && is_email(&edited.email)
|
|
}
|
|
|
|
/// A profile to edit: the picture, name, username, email, a line about oneself and a place. A new profile from the owner refills it. Save hands the owner the changes and rests while nothing changed, a name is missing or the email does not read; Discard puts the owner's profile back.
|
|
#[derive(IntoElement)]
|
|
pub struct ProfileEditor {
|
|
id: ElementId,
|
|
profile: Profile,
|
|
on_save: Option<OnProfile>,
|
|
}
|
|
|
|
impl ProfileEditor {
|
|
pub fn new(id: impl Into<ElementId>, profile: Profile) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
profile,
|
|
on_save: None,
|
|
}
|
|
}
|
|
|
|
pub fn on_save(mut self, handler: impl Fn(&Profile, &mut Window, &mut App) + 'static) -> Self {
|
|
self.on_save = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for ProfileEditor {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (id, profile) = (self.id, self.profile);
|
|
let on_save = self
|
|
.on_save
|
|
.unwrap_or_else(|| panic!("profile editor {id:?} has no on_save"));
|
|
let fields = window.use_keyed_state((id.clone(), "fields"), cx, |window, cx| {
|
|
Fields::filled(&profile, window, cx)
|
|
});
|
|
if fields.read(cx).seed != profile {
|
|
log::info!("profile editor: filled from the owner's profile");
|
|
let fresh = Fields::filled(&profile, window, cx);
|
|
fields.update(cx, |fields, _| *fields = fresh);
|
|
}
|
|
let now = fields.read(cx);
|
|
let edited = now.read(cx);
|
|
let ready = savable(&edited, &profile);
|
|
let changed = edited != profile;
|
|
let (name, username, email, bio, location) = (
|
|
now.name.clone(),
|
|
now.username.clone(),
|
|
now.email.clone(),
|
|
now.bio.clone(),
|
|
now.location.clone(),
|
|
);
|
|
let mut avatar =
|
|
Avatar::new((id.clone(), "avatar"), edited.name.clone()).size(AvatarSize::Xl);
|
|
if let Some(picture) = &edited.picture {
|
|
avatar = avatar.image(picture.as_path());
|
|
}
|
|
let [pictured, discarded] = [(); 2].map(|_| fields.clone());
|
|
let seed = profile.clone();
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_5()
|
|
.child(
|
|
AvatarUpload::new((id.clone(), "picture"), avatar).on_change(move |path, _, cx| {
|
|
log::info!("profile editor: a new picture");
|
|
pictured.update(cx, |fields, cx| {
|
|
fields.picture = Some(path);
|
|
cx.notify();
|
|
})
|
|
}),
|
|
)
|
|
.child(
|
|
FormField::new((id.clone(), "name-field"), "Name")
|
|
.required()
|
|
.child(Input::new(&name)),
|
|
)
|
|
.child(
|
|
FormField::new((id.clone(), "username-field"), "Username")
|
|
.child(Input::new(&username).prefix("@")),
|
|
)
|
|
.child(
|
|
FormField::new((id.clone(), "email-field"), "Email")
|
|
.required()
|
|
.child(EmailInput::new(&email)),
|
|
)
|
|
.child(
|
|
FormField::new((id.clone(), "bio-field"), "About")
|
|
.description("A line or two others see.")
|
|
.child(Input::new(&bio)),
|
|
)
|
|
.child(
|
|
FormField::new((id.clone(), "location-field"), "Location")
|
|
.child(Input::new(&location)),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_wrap()
|
|
.justify_end()
|
|
.gap_2()
|
|
.child(
|
|
Button::new((id.clone(), "discard"), "Discard")
|
|
.variant(ButtonVariant::Ghost)
|
|
.disabled(!changed)
|
|
.on_click(move |_, window, cx| {
|
|
log::info!("profile editor: discard");
|
|
let fresh = Fields::filled(&seed, window, cx);
|
|
discarded.update(cx, |fields, cx| {
|
|
*fields = fresh;
|
|
cx.notify();
|
|
})
|
|
}),
|
|
)
|
|
.child(
|
|
Button::new((id.clone(), "save"), "Save")
|
|
.variant(ButtonVariant::Primary)
|
|
.disabled(!ready)
|
|
.on_click(move |_, window, cx| {
|
|
log::info!("profile editor: save");
|
|
on_save(&edited, window, cx);
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Profile, savable};
|
|
|
|
#[test]
|
|
fn a_profile_saves_once_changed_named_and_reachable() {
|
|
let seed = Profile {
|
|
name: "Ada".into(),
|
|
email: "ada@example.com".into(),
|
|
..Profile::default()
|
|
};
|
|
let with = |edit: fn(&mut Profile)| {
|
|
let mut edited = seed.clone();
|
|
edit(&mut edited);
|
|
savable(&edited, &seed)
|
|
};
|
|
assert!(!with(|_| {}), "nothing changed");
|
|
assert!(with(|profile| profile.location = "London".into()));
|
|
assert!(
|
|
!with(|profile| profile.name = "".into()),
|
|
"a name is needed"
|
|
);
|
|
assert!(
|
|
!with(|profile| profile.email = "ada".into()),
|
|
"the email must read"
|
|
);
|
|
}
|
|
}
|