Skip to main content

qcode/ui/settings/
identity.rs

1//! Logins, profile by profile: whether one is stored, refreshing it into the workspaces that use
2//! it, and signing out.
3
4use qframe::prelude::*;
5use qframe::widgets::Skeleton;
6
7use super::Msg;
8use super::engine::Gate;
9use crate::profile::SafeName;
10
11/// How many faint lines stand in for the profiles while the store is being read. Three is
12/// what a first store tends to hold; the list replaces them in one frame.
13const PLACEHOLDER_LINES: u16 = 3;
14
15/// Cells between the two actions.
16const GAP: u16 = 2;
17
18/// A profile and whether QCode holds a login for it.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ProfileIdentity {
21    name: SafeName,
22    stored: bool,
23}
24
25impl ProfileIdentity {
26    /// A profile named `name`, whose credentials volume holds a login when `stored`.
27    #[must_use]
28    pub fn new(name: SafeName, stored: bool) -> Self {
29        Self { name, stored }
30    }
31
32    /// The profile's name.
33    #[must_use]
34    pub fn name(&self) -> &SafeName {
35        &self.name
36    }
37
38    /// Whether a login is stored for it.
39    #[must_use]
40    pub fn stored(&self) -> bool {
41        self.stored
42    }
43}
44
45/// Draws the logins: one row per profile with its state, and the two actions on the chosen one.
46///
47/// `profiles` is `None` while the store has not been read yet, which is drawn as faint lines. A
48/// store without profiles has no logins, and the settings screen does not draw this at all then.
49pub fn view(profiles: Option<&[ProfileIdentity]>, chosen: usize, engine: &Gate, ui: &mut View<'_, Msg>) {
50    let Some(profiles) = profiles else {
51        ui.add(Skeleton::lines(PLACEHOLDER_LINES)).fill_width();
52        return;
53    };
54
55    let items = profiles.iter().map(|profile| {
56        let state = if profile.stored() { t!("settings.logins.kept") } else { t!("settings.logins.none") };
57        ListItem::new(profile.name().as_str().to_owned()).detail(state)
58    });
59    // As tall as its rows: a list left to size itself takes what the screen offers, and the page
60    // is measured before it is placed in the middle, so its height must not depend on the room.
61    let rows = u16::try_from(profiles.len()).unwrap_or(u16::MAX);
62    let list = List::new(items).selected(Some(chosen)).on_select(Msg::Profile);
63    ui.add(list.wrap(true)).id("profiles").height(Length::Cells(rows)).fill_width();
64
65    // The nearer reason wins: without a profile or without a login there is nothing to do even
66    // with an engine running, and saying "needs an engine" there would send the person after the
67    // wrong thing.
68    let gate = match profiles.get(chosen) {
69        None => Gate::shut(t!("settings.no-profile")),
70        Some(profile) if !profile.stored() => Gate::shut(t!("settings.no-identity", profile = profile.name().as_str())),
71        Some(_) => engine.clone(),
72    };
73    gate.show(ui, |ui, enabled| {
74        ui.row(|ui| {
75            ui.add(Button::new(t!("settings.refresh")).on_press(Msg::AskRefresh).disabled(!enabled))
76                .id("refresh-identity");
77            ui.add(Button::new(t!("settings.sign-out")).variant("danger").on_press(Msg::AskSignOut).disabled(!enabled))
78                .id("sign-out");
79        })
80        .gap(GAP);
81    });
82    // The same reason in writing, because a dead button takes no focus and a tooltip alone would
83    // never reach someone working from the keyboard.
84    if let Some(reason) = gate.reason() {
85        ui.add(Text::new(reason.to_owned()).role("secondary")).fill_width();
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use std::time::Duration;
92
93    use qframe::runtime::Harness;
94
95    use crate::engine::EngineKind;
96    use crate::profile::SafeName;
97    use crate::ui::settings::engine::{Health, Trouble};
98    use crate::ui::settings::testing::{self, Host};
99    use crate::ui::settings::{Msg, Request};
100
101    // Tall enough for the settings list and the logins under it: the list grows as the settings
102    // do, and what these tests read is the part below it.
103    const SIZE: (u16, u16) = (100, 50);
104
105    fn named(name: &str) -> SafeName {
106        SafeName::parse(name).expect("the test names are already safe")
107    }
108
109    fn with_profiles(health: Health, profiles: &[(&str, bool)]) -> Harness<Host> {
110        let mut harness = testing::host(testing::screen(EngineKind::Podman, health), SIZE.0, SIZE.1);
111        let read = profiles.iter().map(|(name, signed_in)| testing::profile(name, *signed_in)).collect();
112        harness.send(testing::wrap(Msg::Profiles(read))).render();
113        harness
114    }
115
116    #[test]
117    fn profiles_that_have_not_been_read_yet_do_not_claim_to_be_none() {
118        // The screen opens before the store has been read; an empty state there would be a lie.
119        let harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
120        let screen = harness.screen();
121        assert!(!screen.contains("No profiles yet"), "{screen}");
122    }
123
124    #[test]
125    fn every_profile_says_whether_a_login_is_stored_for_it() {
126        let harness = with_profiles(Health::Working, &[("claude-sub", true), ("codex-key", false)]);
127        let screen = harness.screen();
128        assert!(screen.contains("claude-sub"), "{screen}");
129        assert!(screen.contains("login kept"), "{screen}");
130        assert!(screen.contains("codex-key"), "{screen}");
131        assert!(screen.contains("no login"), "{screen}");
132    }
133
134    #[test]
135    fn signing_out_says_what_it_deletes_and_what_keeps_working() {
136        let mut harness = with_profiles(Health::Working, &[("claude-sub", true)]);
137        harness.click_text("Sign out").advance(Duration::from_millis(400));
138        let screen = harness.screen();
139        assert!(screen.contains("Sign claude-sub out?"), "{screen}");
140        assert!(screen.contains("is deleted"), "{screen}");
141        assert!(screen.contains("with their own copy"), "{screen}");
142        assert!(harness.app().asked.is_empty(), "nothing happens before the answer");
143    }
144
145    #[test]
146    fn the_answer_to_signing_out_is_what_reaches_the_application() {
147        let mut harness = with_profiles(Health::Working, &[("claude-sub", true)]);
148        harness.click_text("Sign out").advance(Duration::from_millis(400));
149        harness.click_text("Delete the login");
150        assert_eq!(harness.app().asked, [Request::SignOut(named("claude-sub"))]);
151    }
152
153    #[test]
154    fn cancelling_the_sign_out_leaves_the_login_alone() {
155        let mut harness = with_profiles(Health::Working, &[("claude-sub", true)]);
156        harness.click_text("Sign out").advance(Duration::from_millis(400));
157        harness.press("esc").advance(Duration::from_millis(400));
158        assert!(harness.app().asked.is_empty(), "{}", harness.screen());
159    }
160
161    #[test]
162    fn refreshing_says_which_copies_are_rewritten_and_what_stays() {
163        let mut harness = with_profiles(Health::Working, &[("claude-sub", true)]);
164        harness.click_text("Refresh identity").advance(Duration::from_millis(400));
165        let screen = harness.screen();
166        assert!(screen.contains("Every workspace that uses claude-sub"), "{screen}");
167        assert!(screen.contains("memory and settings stay."), "{screen}");
168        harness.click_text("Refresh now");
169        assert_eq!(harness.app().asked, [Request::RefreshIdentity(named("claude-sub"))]);
170    }
171
172    #[test]
173    fn a_profile_without_a_login_has_nothing_to_refresh_or_sign_out_of() {
174        let harness = with_profiles(Health::Working, &[("codex-key", false)]);
175        let screen = harness.screen();
176        assert!(screen.contains("codex-key has no stored login."), "{screen}");
177    }
178
179    #[test]
180    fn without_an_engine_the_actions_that_touch_a_volume_are_dead_and_say_why() {
181        let health = Health::Missing(Trouble::NotInstalled);
182        let mut harness = with_profiles(health, &[("claude-sub", true)]);
183        let screen = harness.screen();
184        assert!(screen.contains("Needs a container engine."), "{screen}");
185        harness.click_text("Sign out").advance(Duration::from_millis(400));
186        assert!(!harness.screen().contains("Sign claude-sub out?"), "a dead action does nothing");
187        assert!(harness.app().asked.is_empty());
188    }
189
190    #[test]
191    fn the_pointer_learns_why_an_action_is_dead_by_resting_on_it() {
192        let health = Health::Missing(Trouble::NotInstalled);
193        let mut harness = with_profiles(health, &[("claude-sub", true)]);
194        let reason = "Needs a container engine.";
195        let printed = harness.screen().matches(reason).count();
196        let (x, y) = harness.find("Sign out").expect("the action is on screen");
197        harness.hover(x, y).advance(Duration::from_secs(1));
198        let hovered = harness.screen().matches(reason).count();
199        assert!(hovered > printed, "resting on the action says why as well:\n{}", harness.screen());
200    }
201
202    #[test]
203    fn the_keyboard_moves_the_choice_between_profiles() {
204        let mut harness = with_profiles(Health::Working, &[("claude-sub", true), ("codex-key", false)]);
205        harness.click_text("claude-sub");
206        harness.press("down").render();
207        let screen = harness.screen();
208        assert!(screen.contains("codex-key has no stored login."), "the second profile is the chosen one:\n{screen}");
209    }
210}