Account · Profile and access: profiles, switchers, a user menu, sessions, keys

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 13:29:35 -04:00
1 parent 0177d9f3c7
commit 31f5f91507
12 files changed
+1654 -8

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use super::login::{Run, field};
use crate::{
buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode, CopyButton},
feedback::Callout,
forms::{Enter, Input},
primitives::Severity,
theme::{ActiveTheme, ControlSize, TextSize},
typography::Ellipsis,
};
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// An API key as a list shows it: its key, its name, its ends only, and when it was made and last used.
#[derive(Clone, Debug, PartialEq)]
pub struct ApiKey {
pub key: SharedString,
pub name: SharedString,
pub hint: SharedString,
pub created: SharedString,
pub used: SharedString,
}
/// API keys by name, each with its ends, when it was made and last used, and a Revoke that asks twice. A name makes a new key; its secret, handed back by the owner, shows once with a way to copy it.
#[derive(IntoElement)]
pub struct ApiKeyManager {
id: ElementId,
keys: Vec<ApiKey>,
secret: Option<SharedString>,
on_create: Option<OnKey>,
on_revoke: Option<OnKey>,
on_dismiss: Option<Run>,
}
impl ApiKeyManager {
pub fn new(id: impl Into<ElementId>, keys: impl IntoIterator<Item = ApiKey>) -> Self {
Self {
id: id.into(),
keys: keys.into_iter().collect(),
secret: None,
on_create: None,
on_revoke: None,
on_dismiss: None,
}
}
/// A new key's secret, shown until the owner clears it.
pub fn secret(mut self, secret: impl Into<SharedString>) -> Self {
self.secret = Some(secret.into());
self
}
/// Runs with the new key's name.
pub fn on_create(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_create = Some(Rc::new(handler));
self
}
pub fn on_revoke(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_revoke = Some(Rc::new(handler));
self
}
/// Runs on Done under a secret; the owner clears it.
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ApiKeyManager {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_create = self
.on_create
.unwrap_or_else(|| panic!("api key manager {id:?} has no on_create"));
let on_revoke = self
.on_revoke
.unwrap_or_else(|| panic!("api key manager {id:?} has no on_revoke"));
let name = field(&id, "name", "A name, such as Deploys", false, window, cx);
let named: SharedString = name.read(cx).text().trim().to_string().into();
let ready = !named.is_empty();
let clear = name.clone();
let create: Run = Rc::new(move |window, cx| {
log::info!("api key manager: create {named}");
on_create(&named, window, cx);
clear.update(cx, |field, cx| field.set_text("", cx));
});
let enter = create.clone();
let theme = cx.theme();
let mono = theme.mono_family.clone();
let secret = self.secret.map(|secret| {
let dismiss = self.on_dismiss.clone().unwrap_or_else(|| {
panic!("api key manager {id:?} shows a secret with no on_dismiss")
});
Callout::new(Severity::Warning)
.title("Copy this key now. It will not show again.")
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
div().flex().child(
div()
.flex_1()
.min_w_0()
.font_family(mono.clone())
.child(secret.clone()),
),
)
.child(
div()
.flex()
.flex_wrap()
.gap_2()
.child(CopyButton::new((id.clone(), "copy"), secret))
.child(
Button::new((id.clone(), "done"), "Done")
.size(ControlSize::Sm)
.on_click(move |_, window, cx| dismiss(window, cx)),
),
),
)
});
let rows = self.keys.iter().enumerate().map(|(ix, each)| {
let (key, revoke) = (each.key.clone(), on_revoke.clone());
div()
.flex()
.flex_wrap()
.items_center()
.gap_3()
.py_2p5()
.when(ix > 0, |row| {
row.border_t_1().border_color(theme.colors.border)
})
.child(
div()
.flex_1()
.min_w(theme.label_width())
.child(
div()
.flex()
.gap_2()
.child(
div()
.flex_none()
.font_weight(FontWeight::MEDIUM)
.child(each.name.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.font_family(mono.clone())
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(each.hint.clone())),
),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(format!("{} · {}", each.created, each.used))),
),
)
.child(
div().flex_none().child(
ConfirmButton::new(
(id.clone(), format!("revoke-{}", each.key)),
"Revoke",
ConfirmMode::Twice,
)
.size(ControlSize::Sm)
.on_confirm(move |window, cx| {
log::info!("api key manager: revoke {key}");
revoke(&key, window, cx);
}),
),
)
});
let empty = self.keys.is_empty().then(|| {
div()
.py_2()
.text_color(theme.colors.fg_muted)
.child("No keys yet. Name one to make it.")
});
div()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Sm))
.children(secret)
.child(
div()
.flex()
.flex_wrap()
.gap_2()
.capture_action(move |_: &Enter, window, cx| {
if ready {
cx.stop_propagation();
enter(window, cx);
}
})
.child(
div()
.flex_1()
.min_w(theme.label_width())
.child(Input::new(&name)),
)
.child(
Button::new((id.clone(), "create"), "Create key")
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, window, cx| create(window, cx)),
),
)
.child(div().flex().flex_col().children(rows).children(empty))
}
}
+8
View File
@@ -1,14 +1,22 @@
mod factor;
mod keys;
mod login;
mod mail;
mod oauth;
mod profile;
mod sessions;
mod signup;
mod switcher;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use factor::TwoFactorInput;
pub use keys::{ApiKey, ApiKeyManager};
pub use login::{Login, LoginForm};
pub use mail::{ForgotPassword, MagicLinkForm};
pub use oauth::OAuthButtons;
pub use profile::{Profile, ProfileCard, ProfileEditor};
pub use sessions::{Session, SessionList};
pub use signup::{Signup, SignupForm, password_rules};
pub use switcher::{Account, AccountSwitcher, UserMenu, Workspace, WorkspaceSwitcher};
+360
View File
@@ -0,0 +1,360 @@
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"
);
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::*,
};
use super::login::Run;
use crate::{
buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode},
data_display::{Badge, Tone},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::Ellipsis,
};
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A place an account is signed in: its key, an icon for the device, the device and the app on it, where it is, when it was last seen, and whether it is this one.
#[derive(Clone, Debug, PartialEq)]
pub struct Session {
pub key: SharedString,
pub icon: IconName,
pub device: SharedString,
pub client: SharedString,
pub place: SharedString,
pub seen: SharedString,
pub current: bool,
}
/// Where an account is signed in, this device first. Each other one signs out on its own, or all of them at once after a second press.
#[derive(IntoElement)]
pub struct SessionList {
id: ElementId,
sessions: Vec<Session>,
on_sign_out: Option<OnKey>,
on_sign_out_others: Option<Run>,
}
impl SessionList {
pub fn new(id: impl Into<ElementId>, sessions: impl IntoIterator<Item = Session>) -> Self {
let mut sessions: Vec<_> = sessions.into_iter().collect();
assert!(
sessions.iter().filter(|session| session.current).count() <= 1,
"session list: two sessions are this device"
);
sessions.sort_by_key(|session| !session.current);
Self {
id: id.into(),
sessions,
on_sign_out: None,
on_sign_out_others: None,
}
}
pub fn on_sign_out(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_sign_out = Some(Rc::new(handler));
self
}
/// Shows "Sign out of all other devices" while there are others.
pub fn on_sign_out_others(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_sign_out_others = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SessionList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_sign_out = self
.on_sign_out
.unwrap_or_else(|| panic!("session list {id:?} has no on_sign_out"));
let others = self.sessions.iter().any(|session| !session.current);
let theme = cx.theme();
let rows = self.sessions.into_iter().enumerate().map(|(ix, session)| {
let end = match session.current {
true => Badge::new("This device")
.tone(Tone::Neutral)
.into_any_element(),
false => {
let (key, out) = (session.key.clone(), on_sign_out.clone());
Button::new((id.clone(), format!("out-{}", session.key)), "Sign out")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("session list: sign out {key}");
out(&key, window, cx);
})
.into_any_element()
}
};
div()
.flex()
.items_center()
.gap_3()
.py_2p5()
.when(ix > 0, |row| {
row.border_t_1().border_color(theme.colors.border)
})
.child(
Icon::new(session.icon)
.size(IconSize::Md)
.color(theme.colors.fg_muted),
)
.child(
div()
.flex_1()
.min_w_0()
.child(Ellipsis::new(format!(
"{} · {}",
session.device, session.client
)))
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(format!(
"{} · {}",
session.place, session.seen
))),
),
)
.child(div().flex_none().child(end))
});
let everyone = self.on_sign_out_others.filter(|_| others).map(|run| {
div().pt_2().flex().child(
ConfirmButton::new(
(id.clone(), "others"),
"Sign out of all other devices",
ConfirmMode::Twice,
)
.on_confirm(move |window, cx| {
log::info!("session list: sign out of the others");
run(window, cx);
}),
)
});
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
.children(everyone)
}
}
+380
View File
@@ -0,0 +1,380 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, FontWeight, ImageSource, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement,
Styled, Window, div, transparent_black,
};
use super::login::Run;
use crate::{
data_display::{Avatar, Presence},
menus::{Menu, MenuItem, menu_under},
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, AvatarSize, IconSize, Radius, TextSize},
typography::Ellipsis,
};
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Someone signed in: a key, a name and an email, and a picture when there is one.
#[derive(Clone)]
pub struct Account {
pub key: SharedString,
pub name: SharedString,
pub email: SharedString,
pub image: Option<ImageSource>,
}
/// A workspace or an organization: a key, a name, and a line such as its plan.
#[derive(Clone, Debug, PartialEq)]
pub struct Workspace {
pub key: SharedString,
pub name: SharedString,
pub detail: SharedString,
}
/// A name over a muted line, each cut to the room there is.
fn named(title: SharedString, detail: SharedString, cx: &App) -> Div {
let theme = cx.theme();
div()
.flex_1()
.min_w_0()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.child(Ellipsis::new(title)),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(detail)),
)
}
/// A row that opens a menu: what it shows, then a chevron. A press leaves focus where it was; Tab reaches it.
fn row(id: impl Into<ElementId>, lead: impl IntoElement, text: Div, cx: &App) -> Stateful<Div> {
let theme = cx.theme();
let hover = theme.colors.hover;
div()
.id(id)
.flex()
.items_center()
.gap_2()
.px_2()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(transparent_black())
.text_size(theme.text_size(TextSize::Sm))
.tab_index(0)
.focus_ring(cx)
.hover(move |style| style.bg(hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.child(div().flex_none().child(lead))
.child(text)
.child(
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Sm)
.color(theme.colors.fg_subtle),
)
}
/// `trigger` opening `menu` under it.
fn opening(
id: ElementId,
menu: &Menu,
trigger: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
menu_under(
id,
menu,
move |click| {
trigger
.on_click(move |event, window, cx| click(event, window, cx))
.into_any_element()
},
window,
cx,
)
.into_any_element()
}
/// The accounts signed in, the current one showing; its menu picks another or adds one.
#[derive(IntoElement)]
pub struct AccountSwitcher {
id: ElementId,
accounts: Vec<Account>,
current: SharedString,
on_select: Option<OnKey>,
on_add: Option<Run>,
}
impl AccountSwitcher {
pub fn new(
id: impl Into<ElementId>,
accounts: impl IntoIterator<Item = Account>,
current: impl Into<SharedString>,
) -> Self {
let (accounts, current): (Vec<_>, SharedString) =
(accounts.into_iter().collect(), current.into());
assert!(
accounts.iter().any(|account| account.key == current),
"account switcher: no account {current}"
);
Self {
id: id.into(),
accounts,
current,
on_select: None,
on_add: None,
}
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// Shows "Add account".
pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_add = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AccountSwitcher {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_select = self
.on_select
.unwrap_or_else(|| panic!("account switcher {id:?} has no on_select"));
let items = self.accounts.iter().map(|account| {
let (key, pick) = (account.key.clone(), on_select.clone());
MenuItem::radio(account.email.clone(), account.key == self.current).on_click(
move |window, cx| {
log::info!("account switcher: {key}");
pick(&key, window, cx);
},
)
});
let mut menu = Menu::new().group("Accounts", items.collect::<Vec<_>>());
if let Some(add) = self.on_add {
menu = menu.separator().item(
MenuItem::new("Add account")
.icon(IconName::UserPlus)
.on_click(move |window, cx| add(window, cx)),
);
}
let now = self
.accounts
.into_iter()
.find(|account| account.key == self.current)
.expect("the current account");
let mut avatar = Avatar::new((id.clone(), "avatar"), now.name.clone()).size(AvatarSize::Sm);
if let Some(image) = now.image {
avatar = avatar.image(image);
}
let trigger = row(
(id.clone(), "trigger"),
avatar,
named(now.name, now.email, cx),
cx,
);
opening(id, &menu, trigger, window, cx)
}
}
/// The workspaces one belongs to, the current one showing; its menu picks another or makes one.
#[derive(IntoElement)]
pub struct WorkspaceSwitcher {
id: ElementId,
workspaces: Vec<Workspace>,
current: SharedString,
on_select: Option<OnKey>,
on_create: Option<Run>,
}
impl WorkspaceSwitcher {
pub fn new(
id: impl Into<ElementId>,
workspaces: impl IntoIterator<Item = Workspace>,
current: impl Into<SharedString>,
) -> Self {
let (workspaces, current): (Vec<_>, SharedString) =
(workspaces.into_iter().collect(), current.into());
assert!(
workspaces.iter().any(|each| each.key == current),
"workspace switcher: no workspace {current}"
);
Self {
id: id.into(),
workspaces,
current,
on_select: None,
on_create: None,
}
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// Shows "Create workspace".
pub fn on_create(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_create = Some(Rc::new(handler));
self
}
}
impl RenderOnce for WorkspaceSwitcher {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_select = self
.on_select
.unwrap_or_else(|| panic!("workspace switcher {id:?} has no on_select"));
let items: Vec<_> = self
.workspaces
.iter()
.map(|each| {
let (key, pick) = (each.key.clone(), on_select.clone());
MenuItem::radio(each.name.clone(), each.key == self.current).on_click(
move |window, cx| {
log::info!("workspace switcher: {key}");
pick(&key, window, cx);
},
)
})
.collect();
let mut menu = Menu::new().group("Workspaces", items);
if let Some(create) = self.on_create {
menu = menu.separator().item(
MenuItem::new("Create workspace")
.icon(IconName::Plus)
.on_click(move |window, cx| create(window, cx)),
);
}
let now = self
.workspaces
.into_iter()
.find(|each| each.key == self.current)
.expect("the current workspace");
let mark = Avatar::new((id.clone(), "mark"), now.name.clone())
.square()
.size(AvatarSize::Sm);
let trigger = row(
(id.clone(), "trigger"),
mark,
named(now.name, now.detail, cx),
cx,
);
opening(id, &menu, trigger, window, cx)
}
}
/// Someone's avatar that opens their menu: who is signed in over the owner's rows, and Sign out last.
#[derive(IntoElement)]
pub struct UserMenu {
id: ElementId,
name: SharedString,
email: SharedString,
image: Option<ImageSource>,
presence: Option<Presence>,
items: Vec<MenuItem>,
on_sign_out: Option<Run>,
}
impl UserMenu {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
email: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
email: email.into(),
image: None,
presence: None,
items: Vec::new(),
on_sign_out: None,
}
}
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
}
/// A row of the owner's, such as Profile or Settings.
pub fn item(mut self, item: MenuItem) -> Self {
self.items.push(item);
self
}
pub fn on_sign_out(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_sign_out = Some(Rc::new(handler));
self
}
}
impl RenderOnce for UserMenu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let sign_out = self
.on_sign_out
.unwrap_or_else(|| panic!("user menu {id:?} has no on_sign_out"));
let menu =
Menu::new()
.group(format!("Signed in as {}", self.email), self.items)
.separator()
.item(MenuItem::new("Sign out").icon(IconName::LogOut).on_click(
move |window, cx| {
log::info!("user menu: sign out");
sign_out(window, cx);
},
));
let mut avatar = Avatar::new((id.clone(), "avatar"), self.name).size(AvatarSize::Sm);
if let Some(image) = self.image {
avatar = avatar.image(image);
}
if let Some(presence) = self.presence {
avatar = avatar.presence(presence);
}
let trigger = div()
.id((id.clone(), "trigger"))
.rounded_full()
.border_1()
.border_color(transparent_black())
.tab_index(0)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.child(avatar);
menu_under(
id,
&menu,
move |click| {
trigger
.on_click(move |event, window, cx| click(event, window, cx))
.into_any_element()
},
window,
cx,
)
}
}
+225
View File
@@ -0,0 +1,225 @@
use gpui::{AnyElement, Entity, IntoElement, SharedString, TestAppContext};
use super::{Bench, bench, said, say, settle, tab, tap, write};
use crate::account::{
Account, AccountSwitcher, ApiKey, ApiKeyManager, Profile, ProfileEditor, Session, SessionList,
UserMenu, Workspace, WorkspaceSwitcher,
};
use crate::{menus::MenuItem, primitives::IconName};
fn accounts(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let account = |key: &str, name: &str, email: &str| Account {
key: key.to_string().into(),
name: name.to_string().into(),
email: email.to_string().into(),
image: None,
};
AccountSwitcher::new(
"accounts",
[
account("ada", "Ada", "ada@work.com"),
account("home", "Ada", "ada@home.com"),
],
"ada",
)
.on_select(move |key, _, cx| say(&owner, key.to_string(), cx))
.into_any_element()
}
/// Stops: the switcher; its menu marks the first row on a keyboard open.
#[gpui::test]
fn the_account_menu_picks_another(cx: &mut TestAppContext) {
let (host, cx) = bench(accounts, cx);
tab(1, cx);
tap("enter", cx);
tap("down", cx);
tap("enter", cx);
assert_eq!(said(&host, cx), ["home"]);
}
fn sessions(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let session = |key: &str, current: bool| Session {
key: key.to_string().into(),
icon: IconName::Monitor,
device: "MacBook".into(),
client: "Safari".into(),
place: "Lisbon".into(),
seen: "Active now".into(),
current,
};
let everyone = owner.clone();
SessionList::new("sessions", [session("phone", false), session("mac", true)])
.on_sign_out(move |key, _, cx| say(&owner, format!("out {key}"), cx))
.on_sign_out_others(move |_, cx| say(&everyone, "others".into(), cx))
.into_any_element()
}
/// Stops: the phone's Sign out, then Sign out of all other devices; this device has none.
#[gpui::test]
fn a_session_signs_out_and_the_rest_ask_twice(cx: &mut TestAppContext) {
let (host, cx) = bench(sessions, cx);
tab(1, cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
["out phone"],
"this device lists first and has no Sign out"
);
tab(2, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["out phone"], "the first press only arms");
tap("space", cx);
assert_eq!(said(&host, cx), ["out phone", "others"]);
}
fn keys(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let key = ApiKey {
key: "ci".into(),
name: "CI".into(),
hint: "sk_…4f2a".into(),
created: "Made 3 Sep".into(),
used: "Never used".into(),
};
let [made, revoked, done] = [(); 3].map(|_| owner.clone());
let manager = ApiKeyManager::new("keys", [key])
.on_create(move |name, _, cx| say(&made, format!("make {name}"), cx))
.on_revoke(move |key, _, cx| say(&revoked, format!("revoke {key}"), cx))
.on_dismiss(move |_, cx| say(&done, "done".into(), cx));
match bench.sent {
true => manager.secret("sk_live_0123456789").into_any_element(),
false => manager.into_any_element(),
}
}
/// Stops: the name, then Revoke while Create rests; with a secret, Copy and Done lead.
#[gpui::test]
fn a_key_is_made_by_name_revoked_on_the_second_press_and_its_secret_dismissed(
cx: &mut TestAppContext,
) {
let (host, cx) = bench(keys, cx);
tab(2, cx);
tap("space", cx);
tap("space", cx);
tab(1, cx);
write("Deploys", cx);
tap("enter", cx);
assert_eq!(said(&host, cx), ["revoke ci", "make Deploys"]);
host.update(cx, |bench, cx| {
bench.sent = true;
cx.notify();
});
settle(cx);
tab(2, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["revoke ci", "make Deploys", "done"]);
}
fn profile() -> Profile {
Profile {
name: "Ada".into(),
username: "ada".into(),
email: "ada@example.com".into(),
..Profile::default()
}
}
fn editor(_: &Bench, owner: Entity<Bench>) -> AnyElement {
ProfileEditor::new("profile", profile())
.on_save(move |profile, _, cx| {
say(&owner, format!("{} {}", profile.name, profile.email), cx)
})
.into_any_element()
}
/// Stops: the picture, name, username, email, about, location; Discard and Save once changed.
#[gpui::test]
fn a_profile_saves_only_what_changed_and_discard_puts_it_back(cx: &mut TestAppContext) {
let (host, cx) = bench(editor, cx);
tab(2, cx);
write(" Lovelace", cx);
tab(8, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["Ada Lovelace ada@example.com"]);
tab(7, cx);
tap("space", cx);
tab(8, cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
["Ada Lovelace ada@example.com"],
"after Discard nothing changed, so Save rests"
);
}
fn menu(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let [profile, out] = [(); 2].map(|_| owner.clone());
UserMenu::new("user", "Ada", "ada@example.com")
.item(MenuItem::new("Profile").on_click(move |_, cx| say(&profile, "profile".into(), cx)))
.on_sign_out(move |_, cx| say(&out, "out".into(), cx))
.into_any_element()
}
/// Stops: the avatar; the menu's rows are Profile, then Sign out.
#[gpui::test]
fn the_user_menu_holds_the_owners_rows_and_sign_out_last(cx: &mut TestAppContext) {
let (host, cx) = bench(menu, cx);
tab(1, cx);
tap("enter", cx);
tap("down", cx);
tap("enter", cx);
assert_eq!(said(&host, cx), ["out"]);
}
fn workspaces(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let space = |key: &str| Workspace {
key: key.to_string().into(),
name: SharedString::from(key.to_string()),
detail: "Pro".into(),
};
let made = owner.clone();
WorkspaceSwitcher::new("spaces", [space("north"), space("south")], "south")
.on_select(move |key, _, cx| say(&owner, key.to_string(), cx))
.on_create(move |_, cx| say(&made, "create".into(), cx))
.into_any_element()
}
/// Stops: the switcher; its rows are north, south, then Create workspace.
#[gpui::test]
fn the_workspace_menu_picks_or_makes_one(cx: &mut TestAppContext) {
let (host, cx) = bench(workspaces, cx);
tab(1, cx);
tap("enter", cx);
tap("enter", cx);
tab(1, cx);
tap("enter", cx);
tap("up", cx);
tap("enter", cx);
assert_eq!(said(&host, cx), ["north", "create"]);
}
fn alone(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let here = Session {
key: "mac".into(),
icon: IconName::Monitor,
device: "MacBook".into(),
client: "Safari".into(),
place: "Lisbon".into(),
seen: "Active now".into(),
current: true,
};
let everyone = owner.clone();
SessionList::new("sessions", [here])
.on_sign_out(move |key, _, cx| say(&owner, format!("out {key}"), cx))
.on_sign_out_others(move |_, cx| say(&everyone, "others".into(), cx))
.into_any_element()
}
/// With this device alone there is nothing to sign out, so the list has no stop.
#[gpui::test]
fn this_device_alone_offers_no_sign_out(cx: &mut TestAppContext) {
let (host, cx) = bench(alone, cx);
tab(1, cx);
tap("space", cx);
tap("space", cx);
assert!(said(&host, cx).is_empty());
}
+1
View File
@@ -82,4 +82,5 @@ pub(super) fn write(text: &str, cx: &mut VisualTestContext) {
settle(cx);
}
mod access;
mod sign_in;