qcode/ui/settings/
identity.rs1use qframe::prelude::*;
5use qframe::widgets::Skeleton;
6
7use super::Msg;
8use super::engine::Gate;
9use crate::profile::SafeName;
10
11const PLACEHOLDER_LINES: u16 = 3;
14
15const GAP: u16 = 2;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ProfileIdentity {
21 name: SafeName,
22 stored: bool,
23}
24
25impl ProfileIdentity {
26 #[must_use]
28 pub fn new(name: SafeName, stored: bool) -> Self {
29 Self { name, stored }
30 }
31
32 #[must_use]
34 pub fn name(&self) -> &SafeName {
35 &self.name
36 }
37
38 #[must_use]
40 pub fn stored(&self) -> bool {
41 self.stored
42 }
43}
44
45pub 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 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 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 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 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 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}