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:
1 parent
0177d9f3c7
commit
31f5f91507
12 files changed
+1654
-8
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -82,4 +82,5 @@ pub(super) fn write(text: &str, cx: &mut VisualTestContext) {
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
mod access;
|
||||
mod sign_in;
|
||||
Reference in new issue
Block a user