diff --git a/examples/gallery/pages/account/access.rs b/examples/gallery/pages/account/access.rs new file mode 100644 index 0000000..adaf485 --- /dev/null +++ b/examples/gallery/pages/account/access.rs @@ -0,0 +1,268 @@ +use ely_gpui_component::{ + account::{ + Account, AccountSwitcher, ApiKey, ApiKeyManager, Profile, ProfileCard, ProfileEditor, + Session, SessionList, UserMenu, Workspace, WorkspaceSwitcher, + }, + buttons::Button, + data_display::Presence, + menus::MenuItem, + primitives::IconName, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// The switchers' demo: the account and workspace in use. +struct Places { + account: SharedString, + workspace: SharedString, +} + +pub fn switchers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep( + "account-places", + || Places { + account: "work".into(), + workspace: "atlas".into(), + }, + window, + cx, + ); + let now = state.read(cx); + let (account, workspace) = (now.account.clone(), now.workspace.clone()); + let [to_account, to_workspace] = [(); 2].map(|_| state.clone()); + let accounts = [ + Account { + key: "work".into(), + name: "Ada Lovelace".into(), + email: "ada@atlas.dev".into(), + image: None, + }, + Account { + key: "home".into(), + name: "Ada Lovelace".into(), + email: "ada@hey.com".into(), + image: None, + }, + ]; + let workspaces = [ + Workspace { + key: "atlas".into(), + name: "Atlas".into(), + detail: "Pro · 12 members".into(), + }, + Workspace { + key: "field".into(), + name: "Field Notes".into(), + detail: "Free · 3 members".into(), + }, + ]; + section( + "AccountSwitcher · WorkspaceSwitcher / OrgSwitcher · UserMenu", + "The account and the workspace in use, each a row that opens a menu of the others; the avatar opens who is signed in, the owner's rows, and Sign out.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div().w(px(260.)).flex().flex_col().gap_2().child( + WorkspaceSwitcher::new("account-workspaces", workspaces, workspace) + .on_select(move |key, _, cx| change(&to_workspace, cx, |places| places.workspace = key.clone())) + .on_create(|_, _| log::info!("gallery: create workspace")), + ) + .child( + AccountSwitcher::new("account-accounts", accounts, account) + .on_select(move |key, _, cx| change(&to_account, cx, |places| places.account = key.clone())) + .on_add(|_, _| log::info!("gallery: add account")), + ), + ) + .child(probe( + "account-user", + UserMenu::new("account-user", "Ada Lovelace", "ada@atlas.dev") + .presence(Presence::Online) + .item(MenuItem::new("Profile").icon(IconName::User).on_click(|_, _| log::info!("gallery: profile"))) + .item(MenuItem::new("Settings").icon(IconName::Settings).on_click(|_, _| log::info!("gallery: settings"))) + .on_sign_out(|_, _| log::info!("gallery: sign out")), + )), + ) +} + +pub fn profile(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep( + "account-profile", + || Profile { + name: "Ada Lovelace".into(), + username: "ada".into(), + email: "ada@atlas.dev".into(), + bio: "Writes the notes that make machines do more than count.".into(), + location: "London".into(), + picture: None, + }, + window, + cx, + ); + let now = state.read(cx).clone(); + let saved = state.clone(); + section( + "ProfileCard · ProfileEditor", + "Someone at a glance, and their profile to edit: Save waits for a change, a name and an email that reads; Discard puts the saved profile back.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div().w(px(320.)).child( + ProfileCard::new("account-card", now.name.clone()) + .title("Analyst, Atlas") + .presence(Presence::Online) + .bio(now.bio.clone()) + .detail(IconName::Mail, now.email.clone()) + .detail(IconName::MapPin, now.location.clone()) + .action(Button::new("account-card-message", "Message").primary()) + .action(Button::new("account-card-profile", "View profile")), + ), + ) + .child(div().w(px(400.)).child(ProfileEditor::new("account-editor", now).on_save(move |profile, _, cx| { + let profile = profile.clone(); + change(&saved, cx, move |now| *now = profile) + }))), + ) +} + +/// The security demo: sessions still signed in, keys, and a secret just made. +struct Security { + sessions: Vec, + keys: Vec, + secret: Option, + made: usize, +} + +fn security() -> Security { + let session = + |key: &str, icon, device: &str, client: &str, place: &str, seen: &str, current| Session { + key: key.to_string().into(), + icon, + device: device.to_string().into(), + client: client.to_string().into(), + place: place.to_string().into(), + seen: seen.to_string().into(), + current, + }; + Security { + sessions: vec![ + session( + "phone", + IconName::Smartphone, + "iPhone 17", + "Ely for iOS", + "Lisbon, Portugal", + "2 hours ago", + false, + ), + session( + "mac", + IconName::Monitor, + "MacBook Pro", + "Ely 4.2", + "Lisbon, Portugal", + "Active now", + true, + ), + session( + "office", + IconName::Monitor, + "Office desktop", + "Firefox 150", + "Porto, Portugal", + "3 days ago", + false, + ), + ], + keys: vec![ + ApiKey { + key: "ci".into(), + name: "CI".into(), + hint: "ely_live_…4f2a".into(), + created: "Made 3 Sep 2026".into(), + used: "Last used today".into(), + }, + ApiKey { + key: "backup".into(), + name: "Backups".into(), + hint: "ely_live_…91c0".into(), + created: "Made 12 Aug 2026".into(), + used: "Never used".into(), + }, + ], + secret: None, + made: 0, + } +} + +pub fn security_page(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-security", security, window, cx); + let now = state.read(cx); + let (sessions, keys, secret) = (now.sessions.clone(), now.keys.clone(), now.secret.clone()); + let [out, others, made, revoked, done] = [(); 5].map(|_| state.clone()); + let mut manager = ApiKeyManager::new("account-keys", keys) + .on_create(move |name, _, cx| { + let name = name.clone(); + change(&made, cx, move |security| { + security.made += 1; + let key: SharedString = format!("new-{}", security.made).into(); + security.keys.insert( + 0, + ApiKey { + key, + name, + hint: "ely_live_…7d3e".into(), + created: "Made today".into(), + used: "Never used".into(), + }, + ); + security.secret = Some("ely_live_5f1c0a9e2b7d4c8a3e6f7d3e".into()); + }) + }) + .on_revoke(move |key, _, cx| { + let key = key.clone(); + change(&revoked, cx, move |security| { + security.keys.retain(|each| each.key != key) + }) + }) + .on_dismiss(move |_, cx| change(&done, cx, |security| security.secret = None)); + if let Some(secret) = secret { + manager = manager.secret(secret); + } + section( + "SessionList · ApiKeyManager", + "Where the account is signed in, this device first, each other one signing out on its own or all at once after a second press. API keys show their ends only; a new key's secret shows once.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_8() + .child(div().w(px(380.)).child( + SessionList::new("account-sessions", sessions) + .on_sign_out(move |key, _, cx| { + let key = key.clone(); + change(&out, cx, move |security| security.sessions.retain(|each| each.key != key)) + }) + .on_sign_out_others(move |_, cx| change(&others, cx, |security| security.sessions.retain(|each| each.current))), + )) + .child(div().w(px(420.)).child(manager)), + ) +} diff --git a/examples/gallery/pages/account/mod.rs b/examples/gallery/pages/account/mod.rs index 1f0bff6..b3c237e 100644 --- a/examples/gallery/pages/account/mod.rs +++ b/examples/gallery/pages/account/mod.rs @@ -1,5 +1,6 @@ use gpui::{AnyElement, App, Window, div, prelude::*}; +mod access; mod sign_in; use super::Page; @@ -34,6 +35,11 @@ const SCRIPT: &[Step] = &[ Step::Type("123456"), Step::Wait(200), Step::Shot("code-failed"), + Step::DownAt("account-user", 12.0, 12.0), + Step::UpAt("account-user", 12.0, 12.0), + Step::Wait(200), + Step::Shot("user-menu"), + Step::Key("escape"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -42,5 +48,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(sign_in::sign_up(window, cx)) .child(sign_in::by_email(window, cx)) .child(sign_in::second_factor(window, cx)) + .child(access::switchers(window, cx)) + .child(access::profile(window, cx)) + .child(access::security_page(window, cx)) .into_any_element() } diff --git a/src/account/keys.rs b/src/account/keys.rs new file mode 100644 index 0000000..2ddc807 --- /dev/null +++ b/src/account/keys.rs @@ -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; + +/// 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, + secret: Option, + on_create: Option, + on_revoke: Option, + on_dismiss: Option, +} + +impl ApiKeyManager { + pub fn new(id: impl Into, keys: impl IntoIterator) -> 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) -> 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)) + } +} diff --git a/src/account/mod.rs b/src/account/mod.rs index 80ba2ea..c83117c 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -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}; diff --git a/src/account/profile.rs b/src/account/profile.rs new file mode 100644 index 0000000..4ea6348 --- /dev/null +++ b/src/account/profile.rs @@ -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, + image: Option, + presence: Option, + bio: Option, + details: Vec<(IconName, SharedString)>, + actions: Vec, +} + +impl ProfileCard { + pub fn new(id: impl Into, name: impl Into) -> 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) -> Self { + self.title = Some(title.into()); + self + } + + pub fn image(mut self, source: impl Into) -> 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) -> 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) -> 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, +} + +type OnProfile = Rc; + +/// The editor's own fields, filled from the profile they started as. +struct Fields { + seed: Profile, + name: Entity, + username: Entity, + email: Entity, + bio: Entity, + location: Entity, + picture: Option, +} + +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| { + 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, +} + +impl ProfileEditor { + pub fn new(id: impl Into, 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" + ); + } +} diff --git a/src/account/sessions.rs b/src/account/sessions.rs new file mode 100644 index 0000000..1775c8e --- /dev/null +++ b/src/account/sessions.rs @@ -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; + +/// 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, + on_sign_out: Option, + on_sign_out_others: Option, +} + +impl SessionList { + pub fn new(id: impl Into, sessions: impl IntoIterator) -> 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) + } +} diff --git a/src/account/switcher.rs b/src/account/switcher.rs new file mode 100644 index 0000000..37eafba --- /dev/null +++ b/src/account/switcher.rs @@ -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; + +/// 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, +} + +/// 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, lead: impl IntoElement, text: Div, cx: &App) -> Stateful
{ + 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
, + 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, + current: SharedString, + on_select: Option, + on_add: Option, +} + +impl AccountSwitcher { + pub fn new( + id: impl Into, + accounts: impl IntoIterator, + current: impl Into, + ) -> 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::>()); + 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, + current: SharedString, + on_select: Option, + on_create: Option, +} + +impl WorkspaceSwitcher { + pub fn new( + id: impl Into, + workspaces: impl IntoIterator, + current: impl Into, + ) -> 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, + presence: Option, + items: Vec, + on_sign_out: Option, +} + +impl UserMenu { + pub fn new( + id: impl Into, + name: impl Into, + email: impl Into, + ) -> 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) -> 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, + ) + } +} diff --git a/src/account/tests/access.rs b/src/account/tests/access.rs new file mode 100644 index 0000000..a1fb200 --- /dev/null +++ b/src/account/tests/access.rs @@ -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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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()); +} diff --git a/src/account/tests/mod.rs b/src/account/tests/mod.rs index 1054803..3e865d3 100644 --- a/src/account/tests/mod.rs +++ b/src/account/tests/mod.rs @@ -82,4 +82,5 @@ pub(super) fn write(text: &str, cx: &mut VisualTestContext) { settle(cx); } +mod access; mod sign_in; diff --git a/src/menus/hosts.rs b/src/menus/hosts.rs index ad1f702..ca5940a 100644 --- a/src/menus/hosts.rs +++ b/src/menus/hosts.rs @@ -17,7 +17,7 @@ use crate::{ primitives::IconName, }; -type Click = Rc; +pub(crate) type Click = Rc; fn state(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity { window.use_keyed_state((id.clone(), "menu"), cx, |_, _| Open::default()) @@ -54,6 +54,19 @@ fn host( .children(hang(&id, menu, state, field, window, cx)) } +/// A menu under a trigger of the caller's making, which runs `click` when pressed. +pub(crate) fn menu_under( + id: ElementId, + menu: &Menu, + trigger: impl FnOnce(Click) -> AnyElement, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + let state = state(&id, window, cx); + let click = toggle(&state, menu, cx); + host(id, trigger(click), menu, &state, None, window, cx) +} + /// A button that opens a menu under it. #[derive(IntoElement)] pub struct DropdownMenu { diff --git a/src/menus/mod.rs b/src/menus/mod.rs index fafccc7..d16405e 100644 --- a/src/menus/mod.rs +++ b/src/menus/mod.rs @@ -8,6 +8,7 @@ mod pie; mod tests; pub use bar::MenuBar; +pub(crate) use hosts::menu_under; pub use hosts::{ContextMenu, DropdownMenu, OverflowMenu, SearchableMenu, SplitButton}; pub use model::{Menu, MenuItem}; pub use pie::{PieItem, PieMenu}; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 6bdf1ff..e64b193 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -179,13 +179,13 @@ ## T36b Account · Profile and access -- [ ] ProfileCard -- [ ] ProfileEditor -- [ ] AccountSwitcher -- [ ] WorkspaceSwitcher / OrgSwitcher -- [ ] UserMenu -- [ ] SessionList -- [ ] ApiKeyManager +- [x] ProfileCard +- [x] ProfileEditor +- [x] AccountSwitcher +- [x] WorkspaceSwitcher / OrgSwitcher +- [x] UserMenu +- [x] SessionList +- [x] ApiKeyManager ## T36c Account · Billing and team