Skip to main content

qcode/ui/profiles/
list.rs

1//! The list of profiles: the screen's view, the table of profiles with what the engine says of
2//! each, and the words the list and the wizard both use for a profile's choices.
3
4use 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
15/// Draws the screen: the list of profiles, or the wizard while one is being made.
16pub 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/// The control that takes the keyboard when the screen opens, once there is one: the list of
34/// profiles, or on an empty screen its one button, so Enter makes the first profile without a
35/// Tab the screen never mentions.
36#[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/// The keys of the profiles screen that are not in the keymap, for the key list.
48#[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
54/// The list of profiles, what the engine says about the chosen one, and the way to a new one.
55fn draw_list(state: &Profiles, ui: &mut View<'_, Msg>) {
56    let engineless = state.engine.is_none();
57    // While a login is being removed the button that asked for it shows the work and takes no
58    // second press.
59    let busy = state.signing_out;
60    let rows = state.rows();
61    let selected = state.selected().cloned();
62    // Only a file that could not be read is counted as one: a warning is about a profile that
63    // loaded, and what it says is spelled out beside that profile instead.
64    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            // Only the title stands above the list: what a profile is for belongs on the empty
71            // screen, where it is the answer to a question, and a full list needs its rows instead.
72            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            // Every profile on a row of its own, what the engine said about it in columns that line
102            // up, so the state of all of them is read at a glance. The way to a new profile is the
103            // first row, the way it is on the workspaces screen, so the keyboard reaches it like any
104            // profile; moving onto it only chooses it, and Enter or a click opens the wizard.
105            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            // What can be done to the chosen profile stands once, right under the rows, and nothing
123            // stands there while the new-profile row is chosen: the rows themselves stay plain.
124            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                    // Signing in and out mean nothing to a profile without an account, so it is not
129                    // offered two buttons that could never do anything.
130                    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                    // Everything but the name can be changed, whatever the engine says.
143                    ui.add(Button::new(t!("profiles.edit.button")).on_press(Msg::EditAsked)).id("profile-edit");
144
145                    // Only an image that is there can be built again; one that is not is built from
146                    // the tab that needs it, with the words for that.
147                    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                // On a row of its own beside what was added in it, so the actions above keep their
172                // room on a narrow screen. Only an image that is there has a container to open.
173                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
193/// One profile's row: its name, its harness, what it signs in with, and what the engine said
194/// about its image and its login.
195fn 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        // Nothing to ask the engine: a profile that signs in to nothing is as ready as its image,
200        // and its cell says so, quietly, rather than "not signed in".
201        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
216/// The cell of one answer of the engine, which says plainly when there is no answer yet: a dot in
217/// the answer's colour, and its words.
218fn 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
227/// What a profile runs, in one line. The template is named without the recommendation the
228/// wizard gives it: that is advice for choosing, and this profile has chosen already.
229fn 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    // Debian goes unsaid, as it did before there was a choice; any other system is named, since
251    // it is the one thing about the profile the rest of the line does not show.
252    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
259/// What a profile's containers may see and reach, in one line.
260fn 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/// What the person is told about a profile whose account its harness's makers closed, in plain
269/// words: who closed it, what the harness now answers, for whom it still works, and the ways on.
270/// `None` for every profile whose sign-in still stands.
271#[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
287/// The word for a template.
288pub(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
299/// The word for an account type.
300pub(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
310/// Why a profile has no sign-in step, for the one account that has none: the harness needs no
311/// account at all.
312pub(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
319/// The word for a mount access.
320pub(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
327/// The word for a network mode.
328pub(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}