Coverage Report

Created: 2026-10-23 22:23

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/ui/views/models/add_sheet.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
use cosmic::iced::widget::{column, row};
3
use cosmic::iced::{Alignment, Length};
4
use cosmic::widget::{self, button, space::horizontal as horizontal_space, text};
5
use cosmic::{Apply, Element};
6
7
use crate::core::app::AppModel;
8
use crate::state::registry::{AddSource, PreviewState};
9
use crate::ui::icons;
10
use crate::ui::messages::{Message, ModelsPageMessage};
11
12
use super::chips::{CloudEgress, capability_chips, count_chip, model_device_chips, role_label};
13
use super::surface::{card_divider, muted_text_color};
14
15
/// Right-side "Install manually" sheet, rendered as a COSMIC context drawer.
16
///
17
/// One path, not two. The sheet used to offer a repository card and a folder
18
/// card side by side, each with its own install button that fired straight at
19
/// the daemon: paste a URL, press Install, and either a backend appeared or a
20
/// line landed in a log. Here the source is a choice inside a single flow, and
21
/// resolving it is a separate step from committing to it — Check fills the
22
/// preview, Install commits what is in it.
23
///
24
/// The preview is where every outcome lands, successes and failures alike. That
25
/// is the point of it: a rejected resolve is keyed by the pasted URL, which
26
/// matches no Browse card, so before this the only place it appeared was the
27
/// log (issue #423).
28
0
pub fn add_backend_sheet(app: &AppModel) -> Element<'_, Message> {
29
0
    let spacing = cosmic::theme::spacing();
30
0
    let muted = muted_text_color();
31
0
    let source = app.registry.add_source;
32
33
    // Source row: label left, dropdown right, the COSMIC settings idiom.
34
0
    let selected = AddSource::ALL.iter().position(|s| *s == source);
35
0
    let source_row = row![
36
0
        text::body("Source").class(cosmic::theme::Text::Color(muted)),
37
0
        horizontal_space(),
38
0
        widget::dropdown(&SOURCE_LABELS, selected, |i| {
39
0
            Message::ModelsPage(ModelsPageMessage::AddSourceChanged(
40
0
                AddSource::ALL[i.min(AddSource::ALL.len() - 1)],
41
0
            ))
42
0
        }),
43
    ]
44
0
    .spacing(spacing.space_xs)
45
0
    .align_y(Alignment::Center);
46
47
0
    let entry_row = match source {
48
0
        AddSource::Repository => repository_entry(app, spacing.space_xs),
49
0
        AddSource::Folder => folder_entry(app, spacing.space_xs),
50
    };
51
52
0
    let sheet = column![
53
0
        source_row,
54
0
        entry_row,
55
0
        card_divider(),
56
0
        preview_panel(app, muted),
57
    ]
58
0
    .spacing(spacing.space_s)
59
0
    .apply(widget::container)
60
0
    .class(cosmic::theme::Container::List)
61
0
    .padding(spacing.space_m)
62
0
    .width(Length::Fill);
63
64
0
    sheet.into()
65
0
}
66
67
/// The drawer's pinned footer: the Install action.
68
///
69
/// Rendered through `ContextDrawer::footer` rather than as the last row of the
70
/// sheet, because the sheet's body scrolls. A resolved preview is tall enough
71
/// to push a button at the end of that column past the bottom of the drawer,
72
/// which left the one control the whole flow builds towards only reachable by
73
/// scrolling to it.
74
0
pub fn add_backend_footer(app: &AppModel) -> Element<'_, Message> {
75
0
    install_row(app, muted_text_color())
76
0
}
77
78
/// Dropdown labels, in [`AddSource::ALL`] order. `widget::dropdown` borrows
79
/// this for the life of the view, so it is a const rather than a temporary.
80
const SOURCE_LABELS: [&str; 2] = ["Git repository", "Local folder"];
81
82
/// URL field plus Check. Resolving a URL spends one of GitHub's 60
83
/// unauthenticated API calls an hour (`GITHUB_TOKEN` is optional, so most
84
/// installs have that budget), so the trip is worth an explicit press rather
85
/// than a debounce that would spend several per URL typed.
86
0
fn repository_entry(app: &AppModel, gap: u16) -> Element<'_, Message> {
87
0
    let url = app.registry.custom_repo_input.trim();
88
0
    let checking = app.registry.add_preview.is_checking();
89
90
0
    let mut check = button::standard(if checking {
91
0
        "Checking\u{2026}"
92
    } else {
93
0
        "Check"
94
    });
95
0
    if !checking && !url.is_empty() {
96
0
        check = check.on_press(Message::ModelsPage(ModelsPageMessage::CheckAddSource));
97
0
    }
98
99
0
    row![
100
0
        widget::text_input(
101
            "https://github.com/owner/backend",
102
0
            &app.registry.custom_repo_input
103
        )
104
0
        .on_input(|x| Message::ModelsPage(ModelsPageMessage::RegistryCustomRepoInputChanged(x)))
105
0
        .on_submit(|_| Message::ModelsPage(ModelsPageMessage::CheckAddSource))
106
0
        .width(Length::Fill),
107
0
        check,
108
    ]
109
0
    .spacing(gap)
110
0
    .align_y(Alignment::Center)
111
0
    .into()
112
0
}
113
114
/// Path field, the picker, and Check.
115
///
116
/// The path is typed as well as picked: a folder someone is developing a
117
/// backend in is a path they already know, and making them walk a file dialog
118
/// to a directory they could have pasted is the slower half of the flow. The
119
/// picker stays for the times the path is easier found than remembered.
120
///
121
/// Check is here for the same reason it is in the URL row — the field can hold
122
/// a path nothing has read yet — even though a local manifest costs no API
123
/// budget to read.
124
0
fn folder_entry(app: &AppModel, gap: u16) -> Element<'_, Message> {
125
0
    let path = app.registry.add_folder.trim();
126
0
    let checking = app.registry.add_preview.is_checking();
127
128
0
    let mut check = button::standard(if checking {
129
0
        "Checking\u{2026}"
130
    } else {
131
0
        "Check"
132
    });
133
0
    if !checking && !path.is_empty() {
134
0
        check = check.on_press(Message::ModelsPage(ModelsPageMessage::CheckAddSource));
135
0
    }
136
137
0
    row![
138
0
        widget::text_input("/home/you/dev/my-backend", &app.registry.add_folder)
139
0
            .on_input(|x| Message::ModelsPage(ModelsPageMessage::AddFolderInputChanged(x)))
140
0
            .on_submit(|_| Message::ModelsPage(ModelsPageMessage::CheckAddSource))
141
0
            .width(Length::Fill),
142
0
        button::standard("Choose\u{2026}")
143
0
            .on_press(Message::ModelsPage(ModelsPageMessage::ImportBackendFromDir)),
144
0
        check,
145
    ]
146
0
    .spacing(gap)
147
0
    .align_y(Alignment::Center)
148
0
    .into()
149
0
}
150
151
/// The preview: what this source would install, or why it could not be read.
152
0
fn preview_panel(app: &AppModel, muted: cosmic::iced::Color) -> Element<'_, Message> {
153
0
    let spacing = cosmic::theme::spacing();
154
155
0
    let body: Element<'_, Message> = match &app.registry.add_preview {
156
        PreviewState::Empty => {
157
0
            let hint = match app.registry.add_source {
158
                AddSource::Repository => {
159
0
                    "Paste a repository URL and press Check. Super STT reads its latest release \
160
0
                     and shows what it found before anything is installed."
161
                }
162
                AddSource::Folder => {
163
0
                    "Choose a folder that holds a backend.toml manifest. Super STT reads it and \
164
0
                     shows what it found before anything is copied."
165
                }
166
            };
167
0
            column![
168
0
                text::body("Nothing to preview yet"),
169
0
                text::caption(hint).class(cosmic::theme::Text::Color(muted)),
170
            ]
171
0
            .spacing(spacing.space_xxs)
172
0
            .into()
173
        }
174
175
        // A spinner beside the line, not just the line: reading a release takes
176
        // a round-trip to the forge, and a static sentence gives no sign that
177
        // anything is still happening. The glyph is a still SVG, so the angle
178
        // comes from state and the app ticks it while the check is in flight.
179
0
        PreviewState::Checking(what) => row![
180
0
            icons::spinner(18.0, muted, app.registry.add_spin),
181
0
            text::body(format!("Reading {what}\u{2026}")).class(cosmic::theme::Text::Color(muted)),
182
        ]
183
0
        .spacing(spacing.space_xs)
184
0
        .align_y(Alignment::Center)
185
0
        .into(),
186
187
0
        PreviewState::Failed(err) => row![
188
0
            icons::phosphor_destructive(icons::WARNING, 15.0),
189
0
            text::body(err.clone()).width(Length::Fill),
190
        ]
191
0
        .spacing(spacing.space_xs)
192
0
        .into(),
193
194
0
        PreviewState::Ready(backend) => resolved_preview(backend, muted),
195
    };
196
197
0
    widget::container(body)
198
0
        .padding(spacing.space_s)
199
0
        .width(Length::Fill)
200
0
        .class(cosmic::theme::Container::Primary)
201
0
        .into()
202
0
}
203
204
/// The resolved backend: identity, the facts a person is agreeing to, and the
205
/// models it serves.
206
0
fn resolved_preview(
207
0
    b: &super_stt_shared::registry::RegistryBackend,
208
0
    muted: cosmic::iced::Color,
209
0
) -> Element<'_, Message> {
210
0
    let spacing = cosmic::theme::spacing();
211
0
    let mut col = widget::column::with_capacity(9).spacing(spacing.space_xs);
212
213
    // An incompatible host is the most useful thing the panel can say, so it
214
    // says it first, above the name.
215
0
    if !b.compatibility.compatible {
216
0
        let reason = b
217
0
            .compatibility
218
0
            .reason
219
0
            .as_deref()
220
0
            .unwrap_or("This backend cannot run on this computer.");
221
0
        col = col.push(
222
0
            row![
223
0
                icons::phosphor_destructive(icons::WARNING, 15.0),
224
0
                text::body(reason.to_string()).width(Length::Fill),
225
0
            ]
226
0
            .spacing(spacing.space_xs),
227
0
        );
228
0
    }
229
230
0
    col = col.push(
231
0
        row![
232
0
            text::title4(b.name.clone()),
233
0
            text::body(b.version.clone()).class(cosmic::theme::Text::Color(muted)),
234
        ]
235
0
        .spacing(spacing.space_xs)
236
0
        .align_y(Alignment::End),
237
    );
238
0
    col = col.push(text::caption(b.source.clone()).class(cosmic::theme::Text::Color(muted)));
239
240
0
    if let Some(desc) = b.description.as_deref().filter(|d| !d.is_empty()) {
241
0
        col = col.push(text::body(desc.to_string()).class(cosmic::theme::Text::Color(muted)));
242
0
    }
243
244
0
    let egress = b.online.then_some(CloudEgress {
245
0
        hosts: b.allowed_hosts.as_slice(),
246
0
        user_url: false,
247
0
    });
248
0
    if let Some(chips) = capability_chips(b.supports_gpu, b.supports_cpu, egress, false) {
249
0
        col = col.push(chips);
250
0
    }
251
252
0
    col = col.push(card_divider());
253
0
    for (label, value, style) in facts(b) {
254
0
        let value = match style {
255
0
            FactStyle::Plain => text::caption(value),
256
            // A hostname is machine text, and setting it as such is what tells
257
            // the reader it is a literal address rather than prose.
258
0
            FactStyle::Mono => text::monotext(value),
259
            // "Nothing. Runs offline." is the one fact here that is good news,
260
            // and the person installing a microphone reader is looking for it.
261
0
            FactStyle::Good => text::caption(value).class(cosmic::theme::Text::Color(
262
0
                cosmic::theme::active().cosmic().success.base.into(),
263
0
            )),
264
        };
265
0
        col = col.push(
266
0
            row![
267
0
                text::caption(label)
268
0
                    .class(cosmic::theme::Text::Color(muted))
269
0
                    .width(Length::Fixed(86.0)),
270
0
                value.width(Length::Fill),
271
            ]
272
0
            .spacing(spacing.space_xs),
273
        );
274
    }
275
276
    // One row per model rather than one joined caption line: a preview is read
277
    // to find out what a backend actually serves, and names run together with
278
    // separators are exactly the shape that stops being read.
279
0
    if !b.models.is_empty() {
280
0
        col = col.push(card_divider());
281
0
        col = col.push(
282
0
            text::caption(if b.models.len() == 1 {
283
0
                "Model"
284
            } else {
285
0
                "Models"
286
            })
287
0
            .class(cosmic::theme::Text::Color(muted)),
288
        );
289
0
        for m in &b.models {
290
0
            let mut r = row![
291
0
                text::caption(m.name.clone()).width(Length::Fill),
292
0
                count_chip(role_label(&m.product.role).to_string()),
293
            ]
294
0
            .spacing(spacing.space_xxs)
295
0
            .align_y(Alignment::Center);
296
0
            for chip in model_device_chips(&m.supported_devices) {
297
0
                r = r.push(chip);
298
0
            }
299
0
            col = col.push(r);
300
        }
301
0
    }
302
303
0
    col.into()
304
0
}
305
306
/// How a fact's value should read.
307
#[derive(Clone, Copy)]
308
enum FactStyle {
309
    Plain,
310
    /// A literal machine value: a hostname, a target triple.
311
    Mono,
312
    /// Good news worth colouring.
313
    Good,
314
}
315
316
/// The label/value rows: what a person is actually agreeing to run.
317
0
fn facts(b: &super_stt_shared::registry::RegistryBackend) -> Vec<(String, String, FactStyle)> {
318
0
    let mut out = Vec::with_capacity(5);
319
320
0
    out.push((
321
0
        "Runs as".to_string(),
322
0
        match b.kind.as_str() {
323
0
            "wasm" => "WASM component, sandboxed".to_string(),
324
0
            "subprocess" => "Native subprocess".to_string(),
325
0
            other => other.to_string(),
326
        },
327
0
        FactStyle::Plain,
328
    ));
329
330
    // Only when there is something to get. A required secret is a real cost
331
    // the preview can warn about — installing is not enough, you have to go
332
    // fetch a credential before it will run — but every local backend needs
333
    // none, so a row that mostly reads "Nothing" is padding.
334
0
    let needed: Vec<&str> = b
335
0
        .secrets
336
0
        .iter()
337
0
        .filter(|s| s.required)
338
0
        .map(|s| s.label.as_str())
339
0
        .collect();
340
0
    if !needed.is_empty() {
341
0
        out.push(("Needs".to_string(), needed.join(", "), FactStyle::Plain));
342
0
    }
343
344
    // Always, including when it is empty: "nothing, runs offline" is the
345
    // reassurance someone installing a microphone reader is looking for, so
346
    // the absence is worth as much as the list.
347
0
    if b.allowed_hosts.is_empty() {
348
0
        out.push((
349
0
            "Reaches".to_string(),
350
0
            "Nothing. Runs offline.".to_string(),
351
0
            FactStyle::Good,
352
0
        ));
353
0
    } else {
354
0
        out.push((
355
0
            "Reaches".to_string(),
356
0
            b.allowed_hosts.join(", "),
357
0
            FactStyle::Mono,
358
0
        ));
359
0
    }
360
361
0
    if let Some(asset) = &b.compatibility.selected_asset
362
0
        && !asset.target.is_empty()
363
    {
364
0
        let accel = asset.accel.join(", ");
365
0
        out.push((
366
0
            "Built for".to_string(),
367
0
            if accel.is_empty() {
368
0
                asset.target.clone()
369
            } else {
370
0
                format!("{}, {accel}", asset.target)
371
            },
372
0
            FactStyle::Mono,
373
        ));
374
0
    }
375
376
0
    out.push(("License".to_string(), b.license.clone(), FactStyle::Plain));
377
0
    out
378
0
}
379
380
/// Footer: what the button will do on the left, the button on the right.
381
///
382
/// Install commits the previewed backend, so it is live only while there is a
383
/// preview to commit and this machine can run it. Its label names what it
384
/// installs, so the press and its result read as the same act.
385
0
fn install_row(app: &AppModel, muted: cosmic::iced::Color) -> Element<'_, Message> {
386
0
    let spacing = cosmic::theme::spacing();
387
0
    let ready = app.registry.add_preview.ready();
388
389
0
    let key = app.registry.add_key();
390
0
    let in_flight = app.registry.installs.get(&key);
391
0
    let start_error = app.registry.install_errors.get(&key);
392
393
0
    let (label, live) = match (in_flight, ready) {
394
0
        (Some(s), _) => (
395
0
            match &s.error {
396
0
                Some(_) => "Failed".to_string(),
397
0
                None => format!(
398
                    "Installing\u{2026} ({})",
399
0
                    super::download::phase_label(s.phase)
400
                ),
401
            },
402
            false,
403
        ),
404
0
        (None, Some(b)) if b.compatibility.compatible => (format!("Install {}", b.name), true),
405
0
        _ => ("Install".to_string(), false),
406
    };
407
408
0
    let mut btn = button::suggested(label);
409
0
    if live {
410
0
        btn = btn.on_press(Message::ModelsPage(ModelsPageMessage::InstallPreviewed));
411
0
    }
412
413
0
    let note: Element<'_, Message> = if let Some(err) = start_error {
414
0
        row![
415
0
            icons::phosphor_destructive(icons::WARNING, 14.0),
416
0
            text::caption(format!("Failed to start: {err}")),
417
        ]
418
0
        .spacing(spacing.space_xxs)
419
0
        .align_y(Alignment::Center)
420
0
        .into()
421
0
    } else if let Some(s) = in_flight {
422
0
        match (&s.error, s.bytes_total) {
423
0
            (Some(err), _) => text::caption(format!("Failed: {err}"))
424
0
                .class(cosmic::theme::Text::Color(muted))
425
0
                .into(),
426
0
            (None, Some(total)) if total > 0 => {
427
0
                text::caption(format!("{}%", (s.bytes_done * 100) / total))
428
0
                    .class(cosmic::theme::Text::Color(muted))
429
0
                    .into()
430
            }
431
0
            _ => horizontal_space().into(),
432
        }
433
0
    } else if let Some(b) = ready {
434
        // What the press will install, beside the press. Without it the footer
435
        // is a bare button and the version it commits to is only readable by
436
        // scrolling the preview back up.
437
0
        let where_from = match app.registry.add_source {
438
0
            AddSource::Repository => "from its latest release",
439
0
            AddSource::Folder => "copied from disk",
440
        };
441
0
        text::caption(format!("Version {}, {where_from}", b.version))
442
0
            .class(cosmic::theme::Text::Color(muted))
443
0
            .into()
444
    } else {
445
0
        horizontal_space().into()
446
    };
447
448
0
    row![widget::container(note).width(Length::Fill), btn]
449
0
        .spacing(spacing.space_xs)
450
0
        .align_y(Alignment::Center)
451
0
        .width(Length::Fill)
452
0
        .into()
453
0
}