Coverage Report

Created: 2026-07-21 00:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/ui/views/models/configure.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
use cosmic::Element;
3
use cosmic::iced::{Alignment, Length};
4
use cosmic::iced_widget::{column, row};
5
use cosmic::widget::{self, settings, text};
6
7
use crate::core::app::AppModel;
8
use crate::daemon::backends::{BackendInfo, BackendOption, BackendSecret};
9
use crate::ui::messages::{BackendMessage, Message};
10
11
use super::surface::muted_text_color;
12
13
/// Body of the per-backend configuration sheet (shown in the right-side
14
/// context drawer): the backend's secrets (system keyring) and options (daemon
15
/// config) as one settings section. The drawer supplies the title and close
16
/// affordance, so there's no in-body header or Back button.
17
0
pub fn configure_sheet<'a>(backend: &'a BackendInfo, app: &'a AppModel) -> Element<'a, Message> {
18
0
    let spacing = cosmic::theme::spacing();
19
20
0
    let mut body: Vec<Element<'a, Message>> = Vec::new();
21
22
    // Surface a failed secret/option save inline in the sheet (Tier 1 #15)
23
    // instead of dropping it to the log.
24
0
    if let Some(message) = app.action_error_for(crate::state::ErrorScope::ConfigureBackend) {
25
0
        body.push(crate::ui::views::common::error_banner(message));
26
0
    }
27
28
0
    if backend.secrets.is_empty() && backend.options.is_empty() {
29
0
        body.push(text::body("This backend has nothing to configure.").into());
30
0
    } else {
31
0
        let mut section = settings::section();
32
0
        for secret in &backend.secrets {
33
0
            let key = (backend.source.clone(), secret.name.clone());
34
0
            let configured = app
35
0
                .backend_secret_configured
36
0
                .get(&key)
37
0
                .copied()
38
0
                .unwrap_or(false);
39
0
            let input = app
40
0
                .backend_secret_inputs
41
0
                .get(&key)
42
0
                .map_or("", String::as_str);
43
0
            section = section.add(secret_row(&backend.source, secret, configured, input));
44
0
        }
45
0
        for option in &backend.options {
46
0
            let key = (backend.source.clone(), option.name.clone());
47
0
            let input = app
48
0
                .backend_option_inputs
49
0
                .get(&key)
50
0
                .map_or("", String::as_str);
51
0
            section = section.add(option_row(&backend.source, option, input));
52
0
        }
53
0
        body.push(section.into());
54
    }
55
56
0
    column(body)
57
0
        .spacing(spacing.space_m)
58
0
        .width(Length::Fill)
59
0
        .into()
60
0
}
61
62
/// A label + optional caption stacked vertically, used as the heading above a
63
/// configuration row's control. The caption is dimmed so it reads as a hint.
64
/// When `required` is true, an accent-colored asterisk follows the title to
65
/// signal that the field must be filled.
66
0
pub(super) fn config_label<'a>(
67
0
    title: String,
68
0
    hint: Option<String>,
69
0
    required: bool,
70
0
) -> Element<'a, Message> {
71
0
    let mut block = widget::column::with_capacity(2).spacing(cosmic::theme::spacing().space_xxxs);
72
0
    let title_row: Element<'a, Message> = if required {
73
0
        row![
74
0
            text::body(title),
75
0
            text::body(" *").class(cosmic::theme::Text::Accent),
76
        ]
77
0
        .into()
78
    } else {
79
0
        text::body(title).into()
80
    };
81
0
    block = block.push(title_row);
82
0
    if let Some(hint) = hint.filter(|h| !h.is_empty()) {
83
0
        block =
84
0
            block.push(text::caption(hint).class(cosmic::theme::Text::Color(muted_text_color())));
85
0
    }
86
0
    block.into()
87
0
}
88
89
/// One secret-entry row for a backend (e.g. an API key): the label/description
90
/// over a full-width password field + Save when unconfigured, or a "Configured"
91
/// badge + Remove when stored. The control gets its own row beneath the label
92
/// so the input spans the (narrow) sheet width instead of being squeezed to the
93
/// right of the label.
94
0
pub(super) fn secret_row<'a>(
95
0
    source: &'a str,
96
0
    secret: &'a BackendSecret,
97
0
    configured: bool,
98
0
    input: &'a str,
99
0
) -> Element<'a, Message> {
100
0
    let spacing = cosmic::theme::spacing();
101
    // Show the backend's human label when set; fall back to the technical name.
102
0
    let display = secret.label.clone().unwrap_or_else(|| secret.name.clone());
103
0
    let description = (!secret.description.is_empty()).then(|| secret.description.clone());
104
0
    let label = config_label(display, description, secret.required);
105
106
0
    let source_owned = source.to_string();
107
0
    let name_owned = secret.name.clone();
108
109
0
    let control: Element<'a, Message> = if configured {
110
0
        let remove_source = source_owned.clone();
111
0
        let remove_name = name_owned.clone();
112
0
        row![
113
0
            text::body("Configured").width(Length::Fill),
114
0
            widget::button::destructive("Remove").on_press(Message::Backend(
115
0
                BackendMessage::BackendSecretRemoved {
116
0
                    source: remove_source,
117
0
                    name: remove_name,
118
0
                },
119
0
            )),
120
        ]
121
0
        .spacing(spacing.space_s)
122
0
        .align_y(Alignment::Center)
123
0
        .into()
124
    } else {
125
0
        let input_source = source_owned.clone();
126
0
        let input_name = name_owned.clone();
127
0
        let field = widget::text_input("Enter API key...", input)
128
0
            .on_input(move |value| {
129
0
                Message::Backend(BackendMessage::BackendSecretInputChanged {
130
0
                    source: input_source.clone(),
131
0
                    name: input_name.clone(),
132
0
                    value,
133
0
                })
134
0
            })
135
0
            .password()
136
0
            .width(Length::Fill);
137
0
        let save = widget::button::standard("Save").on_press(Message::Backend(
138
0
            BackendMessage::BackendSecretSaved {
139
0
                source: source_owned,
140
0
                name: name_owned,
141
0
            },
142
0
        ));
143
0
        row![field, save]
144
0
            .spacing(spacing.space_xs)
145
0
            .align_y(Alignment::Center)
146
0
            .into()
147
    };
148
149
0
    settings::item_row(vec![
150
0
        column![label, control]
151
0
            .spacing(spacing.space_xs)
152
0
            .width(Length::Fill)
153
0
            .into(),
154
    ])
155
0
    .into()
156
0
}
157
158
/// One option-entry row for a backend (e.g. `base_url`): the label/description
159
/// over a full-width text field + Save on its own row beneath, so the input
160
/// isn't squeezed to the right of the label in the narrow sheet.
161
/// When an override is active (stored value differs from the default), a Reset
162
/// button is shown alongside Save to clear the override and revert to the
163
/// daemon default.
164
0
pub(super) fn option_row<'a>(
165
0
    source: &'a str,
166
0
    option: &'a BackendOption,
167
0
    input: &'a str,
168
0
) -> Element<'a, Message> {
169
0
    let spacing = cosmic::theme::spacing();
170
0
    let display = option.label.clone().unwrap_or_else(|| option.name.clone());
171
0
    let mut hint = option.description.clone();
172
0
    if let Some(default) = &option.default
173
0
        && !default.is_empty()
174
    {
175
0
        if hint.is_empty() {
176
0
            hint = format!("Default: {default}");
177
0
        } else {
178
0
            hint = format!("{hint} (default: {default})");
179
0
        }
180
0
    }
181
182
0
    let label = config_label(display, (!hint.is_empty()).then_some(hint), option.required);
183
184
0
    let input_source = source.to_string();
185
0
    let input_name = option.name.clone();
186
0
    let save_source = input_source.clone();
187
0
    let save_name = input_name.clone();
188
189
0
    let field = widget::text_input("", input)
190
0
        .on_input(move |value| {
191
0
            Message::Backend(BackendMessage::BackendOptionInputChanged {
192
0
                source: input_source.clone(),
193
0
                name: input_name.clone(),
194
0
                value,
195
0
            })
196
0
        })
197
0
        .width(Length::Fill);
198
0
    let save = widget::button::standard("Save").on_press(Message::Backend(
199
0
        BackendMessage::BackendOptionSaved {
200
0
            source: save_source,
201
0
            name: save_name,
202
0
        },
203
0
    ));
204
205
    // Show Reset only when an override is stored (value differs from default).
206
0
    let has_override = option.value.as_deref() != option.default.as_deref();
207
0
    let control: Element<'a, Message> = if has_override {
208
0
        let reset_source = source.to_string();
209
0
        let reset_name = option.name.clone();
210
0
        let reset = widget::button::standard("Reset").on_press(Message::Backend(
211
0
            BackendMessage::BackendOptionReset {
212
0
                source: reset_source,
213
0
                name: reset_name,
214
0
            },
215
0
        ));
216
0
        row![field, save, reset]
217
0
            .spacing(spacing.space_xs)
218
0
            .align_y(Alignment::Center)
219
0
            .into()
220
    } else {
221
0
        row![field, save]
222
0
            .spacing(spacing.space_xs)
223
0
            .align_y(Alignment::Center)
224
0
            .into()
225
    };
226
227
0
    settings::item_row(vec![
228
0
        column![label, control]
229
0
            .spacing(spacing.space_xs)
230
0
            .width(Length::Fill)
231
0
            .into(),
232
    ])
233
0
    .into()
234
0
}