1use qframe::diagnostics::Severity;
5use qframe::prelude::*;
6use qframe::widgets::{Column, ColumnWidth, EmptyState, Table, TableCell, TableRow};
7
8use crate::base::Os;
9use crate::profile::{AccountKind, HarnessKind, MountAccess, NetworkMode, Profile, Template};
10use crate::ui::page;
11
12use super::wizard_view::draw_wizard;
13use super::{CHROME_ROWS, LIST_WIDTH, Msg, Profiles, Readiness, Revision, Row, ShellMsg, WIZARD_ROWS, shell_page};
14
15pub fn view(state: &Profiles, ui: &mut View<'_, Msg>) {
17 if let Some(page) = &state.shell {
18 let top = ui.size().height.saturating_sub(WIZARD_ROWS + CHROME_ROWS) / 2;
19 ui.column(|ui| {
20 ui.spacer().height(Length::Cells(top));
21 shell_page::draw(page, ui);
22 })
23 .fill()
24 .align(Align::Center);
25 return;
26 }
27 match state.draft() {
28 Some(draft) => draw_wizard(state, draft, ui),
29 None => draw_list(state, ui),
30 }
31}
32
33#[must_use]
37pub fn entry(state: &Profiles) -> Option<&'static str> {
38 if state.loading {
39 None
40 } else if state.rows().is_empty() {
41 Some("profiles-empty")
42 } else {
43 Some("profiles")
44 }
45}
46
47#[must_use]
49pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
50 let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
51 vec![(move_keys, t!("hints.move")), (icons.glyph("enter").into_owned(), t!("hints.open"))]
52}
53
54fn draw_list(state: &Profiles, ui: &mut View<'_, Msg>) {
56 let engineless = state.engine.is_none();
57 let busy = state.signing_out;
60 let rows = state.rows();
61 let selected = state.selected().cloned();
62 let problems = state.problems().iter().filter(|problem| problem.severity == Severity::Error).count();
65 let loading = state.is_loading();
66 let store = state.root.is_some();
67
68 page::column(ui, LIST_WIDTH, |ui| {
69 ui.column(|ui| {
70 ui.add(Text::new(t!("profiles.title")).bold());
73 if engineless {
74 ui.add(Text::new(t!("profiles.no-engine")).color("warning")).fill_width();
75 }
76 if !store {
77 ui.add(Text::new(t!("profiles.no-folder")).color("warning")).fill_width();
78 return;
79 }
80 if problems > 0 {
81 ui.add(
82 Text::new(t!("profiles.broken", n = i64::try_from(problems).unwrap_or(i64::MAX))).color("warning"),
83 );
84 }
85 if loading {
86 ui.add(Text::new(t!("profiles.reading")).role("secondary"));
87 return;
88 }
89 if rows.is_empty() {
90 ui.add(
91 EmptyState::new(t!("profiles.empty-title"))
92 .icon("inbox")
93 .message(t!("profiles.empty-message"))
94 .action(Button::new(t!("profiles.new")).variant("primary").on_press(Msg::New)),
95 )
96 .id("profiles-empty")
97 .fill();
98 return;
99 }
100
101 let new = TableRow::new([TableCell::new(t!("profiles.new")).icon("add", Some("accent"))]);
106 let table_rows: Vec<TableRow> = std::iter::once(new).chain(rows.iter().map(table_row)).collect();
107 let height = u16::try_from(table_rows.len() + 1).unwrap_or(u16::MAX);
108 let columns = [
109 Column::new(t!("profiles.column.name")).width(ColumnWidth::Fit),
110 Column::new(t!("profiles.column.harness")).width(ColumnWidth::Fit),
111 Column::new(t!("profiles.column.account")).width(ColumnWidth::Fit),
112 Column::new(t!("profiles.column.image")).width(ColumnWidth::Fit),
113 Column::new(t!("profiles.column.sign-in")).width(ColumnWidth::Fill(1)),
114 ];
115 let chosen = if state.on_new { 0 } else { state.selected + 1 };
116 let table = Table::new(columns, table_rows)
117 .selected(Some(chosen))
118 .on_select(|row| row.checked_sub(1).map_or(Msg::SelectNew, Msg::Select))
119 .on_activate(|row| row.checked_sub(1).map_or(Msg::New, Msg::Select));
120 ui.add(table.wrap(true)).id("profiles").height(Length::Cells(height)).fill_width();
121
122 if let Some(row) = selected.filter(|_| !state.on_new) {
125 let needs_login = row.profile.account.needs_login();
126 let signed_in = row.is_signed_in();
127 ui.row(|ui| {
128 if needs_login {
131 let mut again = Button::new(t!("profiles.sign-in"));
132 if !engineless && row.image == Readiness::Present {
133 again = again.on_press(Msg::SignInAsked);
134 }
135 ui.add(again.disabled(engineless || row.image != Readiness::Present)).id("profile-sign-in");
136 let mut out = Button::new(t!("profiles.sign-out")).loading(busy);
137 if !engineless && signed_in && !busy {
138 out = out.on_press(Msg::SignOutAsked);
139 }
140 ui.add(out.variant("danger").disabled(engineless || !signed_in)).id("profile-sign-out");
141 }
142 ui.add(Button::new(t!("profiles.edit.button")).on_press(Msg::EditAsked)).id("profile-edit");
144
145 let rebuildable = !engineless && row.image == Readiness::Present;
148 let mut rebuild = Button::new(t!("profiles.rebuild.button"));
149 if rebuildable {
150 rebuild = rebuild.on_press(Msg::RebuildAsked);
151 }
152 ui.add(rebuild.disabled(!rebuildable)).id("profile-rebuild");
153 let mut delete =
154 Button::new(t!("profiles.removal.button")).variant("danger").loading(state.deleting);
155 if !state.deleting {
156 delete = delete.on_press(Msg::DeleteAsked);
157 }
158 ui.add(delete).id("profile-delete");
159 ui.spacer();
160 })
161 .gap(2)
162 .fill_width();
163 ui.add(Text::new(summary(&row.profile)).role("secondary")).fill_width();
164 ui.add(Text::new(mounts(&row.profile)).role("secondary")).fill_width();
165 if let Some(closed) = closed_sign_in(&row.profile) {
166 ui.add(Text::new(closed).color("warning")).fill_width();
167 }
168 if row.image == Readiness::Present && row.revision == Revision::Earlier {
169 ui.add(Text::new(t!("profiles.rebuild.earlier")).color("warning")).fill_width();
170 }
171 let shell = !engineless && row.image == Readiness::Present;
174 ui.row(|ui| {
175 let mut open = Button::new(t!("profiles.shell.button"));
176 if shell {
177 open = open.on_press(Msg::Shell(ShellMsg::Asked));
178 }
179 ui.add(open.disabled(!shell)).id("profile-shell");
180 ui.spacer();
181 })
182 .fill_width();
183 if let Some(own) = state.own.get(row.profile.name.as_str()) {
184 shell_page::draw_own(own, ui);
185 }
186 }
187 })
188 .fill()
189 .gap(1);
190 });
191}
192
193fn table_row(row: &Row) -> TableRow {
196 let sign_in = if row.profile.account.needs_login() {
197 state_cell(row.identity, "profiles.identity")
198 } else {
199 TableCell::new(t!("profiles.identity-free")).icon("dot-outline", None)
202 };
203 let account = match &row.profile.provider {
204 Some(_) => account_word(AccountKind::Provider),
205 None => account_word(row.profile.account),
206 };
207 TableRow::new([
208 TableCell::new(row.profile.name.to_string()),
209 TableCell::new(row.profile.harness.record().display_name),
210 TableCell::new(account),
211 state_cell(row.image, "profiles.image"),
212 sign_in,
213 ])
214}
215
216fn state_cell(readiness: Readiness, key: &str) -> TableCell {
219 let (word, colour) = match readiness {
220 Readiness::Present => ("ready", Some("success")),
221 Readiness::Missing => ("missing", Some("warning")),
222 Readiness::Unknown => ("unknown", None),
223 };
224 TableCell::new(t!(&format!("{key}-{word}"))).icon("dot", colour)
225}
226
227fn summary(profile: &Profile) -> String {
230 let template = t!(match profile.template {
231 Template::Base => "profiles.template.summary-base",
232 Template::Recommended => "profiles.template.name-recommended",
233 Template::Slim => "profiles.template.name-slim",
234 Template::High => "profiles.template.name-extra",
235 Template::QuvytaDev => "profiles.summary-quvyta-dev",
236 Template::Custom => "profiles.template.name-custom",
237 });
238 let account = match &profile.provider {
239 Some(provider) => {
240 t!("profiles.account-provider-named", tag = provider.tag.as_str(), model = provider.asked())
241 }
242 None => account_word(profile.account),
243 };
244 let summary = t!(
245 "profiles.summary",
246 harness = profile.harness.record().display_name,
247 template = template.as_str(),
248 account = account.as_str()
249 );
250 if profile.os == Os::Debian {
253 summary
254 } else {
255 t!("profiles.summary-system", summary = summary.as_str(), system = profile.os.display_name())
256 }
257}
258
259fn mounts(profile: &Profile) -> String {
261 t!(
262 "profiles.mounts",
263 assets = access_word(profile.assets).as_str(),
264 network = network_word(profile.network).as_str()
265 )
266}
267
268#[must_use]
272pub fn closed_sign_in(profile: &Profile) -> Option<String> {
273 if !profile.harness.withdrawn(profile.account) {
274 return None;
275 }
276 match profile.harness {
277 HarnessKind::GeminiCli => Some(t!("profiles.gemini.closed")),
278 HarnessKind::ClaudeCode
279 | HarnessKind::OpenCode
280 | HarnessKind::Codex
281 | HarnessKind::KimiCode
282 | HarnessKind::QwenCode
283 | HarnessKind::AntigravityIde => None,
284 }
285}
286
287pub(super) fn template_word(template: Template) -> String {
289 t!(match template {
290 Template::Base => "profiles.template.name-base",
291 Template::Recommended => "profiles.template.name-recommended",
292 Template::Slim => "profiles.template.name-slim",
293 Template::High => "profiles.template.name-extra",
294 Template::QuvytaDev => "profiles.template-quvyta-dev",
295 Template::Custom => "profiles.template.name-custom",
296 })
297}
298
299pub(super) fn account_word(account: AccountKind) -> String {
301 t!(match account {
302 AccountKind::Free => "profiles.account-free",
303 AccountKind::Subscription => "profiles.account-subscription",
304 AccountKind::ApiKey => "profiles.account-api-key",
305 AccountKind::InApp => "profiles.account-in-app",
306 AccountKind::Provider => "profiles.account-provider",
307 })
308}
309
310pub(super) fn no_login_detail(account: AccountKind, harness: &str) -> Option<String> {
313 match account {
314 AccountKind::Free => Some(t!("profiles.wizard.account-free-detail", harness = harness)),
315 AccountKind::Subscription | AccountKind::ApiKey | AccountKind::InApp | AccountKind::Provider => None,
316 }
317}
318
319pub(super) fn access_word(access: MountAccess) -> String {
321 t!(match access {
322 MountAccess::ReadWrite => "profiles.access-rw",
323 MountAccess::ReadOnly => "profiles.access-ro",
324 })
325}
326
327pub(super) fn network_word(mode: NetworkMode) -> String {
329 t!(match mode {
330 NetworkMode::Full => "profiles.network-full",
331 NetworkMode::None => "profiles.network-none",
332 })
333}