Skip to main content

qcode/ui/profiles/
mod.rs

1//! The profiles screen: the profiles a store holds, and the wizard that makes one.
2//!
3//! A profile is what a workspace opens a harness with, so this screen answers two questions about
4//! every profile — is its image built, and is it signed in — and never answers either of them
5//! from memory. Both come from the engine, and until the engine has answered the screen says
6//! that it does not know yet.
7//!
8//! The screen speaks its own [`Msg`], which the application maps to its own message on the way
9//! out of [`update`] and [`view`]. Everything that touches the disk or
10//! the engine happens in a [`Command`], so the state changes by message alone and every state
11//! the screen has — empty, loading, broken, without an engine, mid-build, mid-login — is reached
12//! in a test with no container runtime.
13
14mod actions;
15mod builds;
16#[cfg(test)]
17mod cleanup_live;
18#[cfg(test)]
19mod inbox_tests;
20mod list;
21#[cfg(test)]
22mod live;
23// Public for the README's recording (tests/readme_gif.rs), which builds a profile's image with
24// QCode's own recipe; it is not part of the library's interface, so it is left out of its documentation.
25#[doc(hidden)]
26pub mod recipe;
27mod remove;
28pub(crate) mod shell;
29mod shell_page;
30#[cfg(test)]
31mod shell_tests;
32mod status;
33#[cfg(test)]
34mod tests;
35#[cfg(test)]
36mod window_tests;
37mod wizard;
38mod wizard_view;
39pub(crate) mod work;
40
41use std::collections::HashMap;
42use std::path::PathBuf;
43use std::sync::Arc;
44
45use qframe::diagnostics::Diagnostic;
46use qframe::prelude::*;
47use qframe::widgets::{LogLevel, LogLine, TerminalEvent, TerminalSession, Toast};
48
49use crate::base::Os;
50use crate::desktop::login::{Seen, SignIn};
51use crate::desktop::{Display, NoDisplay, callback};
52use crate::engine::Engine;
53use crate::profile::own::Own;
54use crate::profile::{AccountKind, Extra, MountAccess, Profile, SafeName, Template};
55use crate::provider::{ProviderEntry, Providers};
56use crate::store::Store;
57use crate::ui::stalling;
58
59pub use list::{closed_sign_in, entry, hints, view};
60pub use shell_page::{ShellMsg, ShellPage, Side as ShellSide, Stage as ShellStage};
61pub use status::{Readiness, Revision, Row, Status};
62pub use wizard::{Blocked, Build, Draft, Login, Page, PickRow, Stage, Unfinished, WindowBack, WindowLogin};
63pub use work::Problem;
64
65use actions::{
66    arrive, ask_delete, ask_rebuild, ask_sign_out, cancel_login, current_window, delete, delete_question, deleted,
67    discard, failure_toast, leave, load_providers, login_event, next, probe, reload, report, sign_out, start_build,
68    start_login, store_login, window_asked, window_opened,
69};
70
71/// Width of the wizard's pages: wide enough for every step to keep its name in the row of
72/// steps, and still one column of text. A narrower terminal shrinks it, and the steps fall back
73/// to their markers.
74///
75/// Measured against the seven English steps, whose row is 104 cells, with the frame around it;
76/// German, French, Turkish, Russian, Portuguese and Chinese fit too. Spanish and Japanese name
77/// their steps longer and show the markers with the current step's name, as a narrow terminal
78/// does.
79const PAGE_WIDTH: u16 = 110;
80
81/// Width of the list of profiles: a page of its own, since it is a table of five columns of words
82/// and the words do not fit the page the settings screen keeps. The longest row the list can make
83/// — a profile with a long name, the longest harness name, "signed in inside its window" for its
84/// account and both the longest answers of the engine — is 98 cells, so nothing in the last column
85/// is cut.
86const LIST_WIDTH: u16 = 100;
87
88/// Rows the build log and the login terminal are given, so the buttons under them stay put.
89const VIEWPORT_ROWS: u16 = 16;
90
91/// Rows the tallest page of the wizard takes at [`PAGE_WIDTH`], with the steps above it and the
92/// buttons under it: the image page of a machine without an engine. The wizard is placed as if
93/// every page were this tall, so the steps stay on the same row from page to page.
94const WIZARD_ROWS: u16 = 28;
95
96/// Rows the application's own foot takes under this screen — the way back and the list of keys —
97/// which the screen's view cannot see but which shares the terminal with it.
98const CHROME_ROWS: u16 = 1;
99
100/// What was read out of a store's `Profiles/` folder.
101#[derive(Debug, Clone, PartialEq)]
102pub struct Listing {
103    /// The profiles whose files could be read.
104    pub profiles: Vec<Profile>,
105    /// Everything that was wrong with the files, each with its place in one.
106    pub diagnostics: Vec<Diagnostic>,
107}
108
109/// Everything that can happen on the profiles screen.
110#[derive(Debug, Clone)]
111pub enum Msg {
112    /// Read the store again. This is also how the screen is started.
113    Reload,
114    /// The store was read.
115    Loaded(Listing),
116    /// The providers file was read, on the same reload.
117    ProvidersLoaded(Vec<ProviderEntry>),
118    /// Read the providers file again, and only that: the Providers page was open over this
119    /// screen, and what was added there is what the wizard's account page now offers.
120    ReloadProviders,
121    /// The engine answered about every profile.
122    Probed(Vec<Status>),
123    /// The selection moved to the profile at this place in the list.
124    Select(usize),
125    /// The selection moved to the row that makes a new profile.
126    SelectNew,
127    /// Signing the chosen profile in was asked for, which is how a login that was interrupted is
128    /// picked up again.
129    SignInAsked,
130    /// Signing a profile out was asked for.
131    SignOutAsked,
132    /// The question was answered with yes.
133    SignOutConfirmed,
134    /// The login was removed, or could not be.
135    SignedOut(Result<(), Problem>),
136    /// Building the chosen profile's image again was asked for.
137    RebuildAsked,
138    /// The question was answered with yes: the image page opens on its own and the build starts.
139    RebuildConfirmed,
140    /// Changing the chosen profile was asked for: the wizard opens on it.
141    EditAsked,
142    /// Open the wizard.
143    New,
144    /// Open the wizard as soon as the store has been read, for a person who asked for a new
145    /// profile from somewhere else and should not have to ask a second time here. It waits for
146    /// the listing because the wizard's suggested name steps around the names already taken.
147    NewWhenRead,
148    /// Leave the wizard without keeping anything that has not been made yet.
149    Cancel,
150    /// Go back a page.
151    Back,
152    /// Go on a page.
153    Next,
154    /// Leave the wizard, keeping what was made.
155    Finish,
156    /// Go back to a finished page.
157    Step(usize),
158    /// The name was typed.
159    Name(String),
160    /// A harness was chosen.
161    PickHarness(usize),
162    /// An operating system was chosen, by its place in [`Os::ALL`].
163    PickOs(usize),
164    /// A template was chosen.
165    PickTemplate(usize),
166    /// A part of a QCode template's additions was switched on (`true`) or off.
167    SwitchExtra(Extra, bool),
168    /// An account type was chosen.
169    PickAccount(usize),
170    /// A provider was chosen, on the account page's own list.
171    PickProvider(usize),
172    /// A model of the chosen provider, or one of its lineups, was chosen by its place in the list
173    /// the filter leaves.
174    PickProviderModel(usize),
175    /// The filter of the model list was typed.
176    ModelFilter(String),
177    /// The person asked to go to the Providers page, because the harness they chose can be
178    /// pointed at one but they have not added any yet.
179    ManageProviders,
180    /// An access for `Assets/` was chosen.
181    PickAssets(usize),
182    /// A network mode was chosen.
183    PickNetwork(usize),
184    /// Build the image.
185    BuildStart,
186    /// The base image was not on the machine, so it is being built before the profile's image.
187    BaseImageStarted,
188    /// The build printed a line.
189    BuildLine(String),
190    /// The build was looked at, which is what says whether its log has gone quiet for long enough
191    /// to be stuck.
192    BuildLooked(std::time::Instant),
193    /// The build ended.
194    BuildEnded(Result<(), Problem>),
195    /// Stop the build.
196    BuildCancel,
197    /// Open the container the login happens in.
198    LoginStart,
199    /// The container is up and the harness is running on a terminal.
200    LoginOpened {
201        /// The terminal the harness draws on.
202        session: TerminalSession,
203        /// The container it runs in.
204        container: Arc<work::LoginContainer>,
205    },
206    /// Something happened on the login terminal.
207    LoginEvent(TerminalEvent),
208    /// The person says they have signed in; look for the login and store it.
209    LoginDone,
210    /// Stop the login and leave nothing behind.
211    LoginCancel,
212    /// The login was stored, or it was not.
213    LoginStored(Result<usize, Problem>),
214    /// The container could not be opened.
215    LoginFailed(Problem),
216    /// A container a login used has been cleared away.
217    LoginDiscarded,
218    /// The window a login is made in is open on the person's screen; the number tells this opening
219    /// apart from an earlier one.
220    WindowOpened(u64, Arc<SignIn>),
221    /// The look for the window's login found it, or found the window gone.
222    WindowSeen(u64, Seen),
223    /// The window asked for these web addresses to be opened, the newest last.
224    WindowAsked(u64, Vec<String>),
225    /// The page the window asked for was shown, or could not be.
226    WindowPage(u64, String, Page),
227    /// The listening for a window sign-in's way back ended; the second number is which listening.
228    WindowBack(u64, u64, callback::Ending),
229    /// Deleting the chosen profile was asked for: the engine is asked what it holds of it first.
230    DeleteAsked,
231    /// The engine answered what it holds of the profile to be deleted.
232    DeleteSurveyed(Box<remove::Survey>),
233    /// The question was answered with yes; `true` when the workspaces' homes of it go too.
234    DeleteConfirmed(bool),
235    /// The question was answered with no.
236    DeleteKept,
237    /// The deletion is over.
238    Deleted(Box<remove::Survey>, remove::Outcome),
239    /// Something about a profile's shell, or its own steps.
240    Shell(ShellMsg),
241}
242
243/// The profiles screen.
244#[derive(Debug)]
245pub struct Profiles {
246    root: Option<PathBuf>,
247    engine: Option<Engine>,
248    loading: bool,
249    rows: Vec<Row>,
250    problems: Vec<Diagnostic>,
251    selected: usize,
252    draft: Option<Draft>,
253    signing_out: bool,
254    made: Option<SafeName>,
255    /// The profile the wizard just finished, to be chosen in the list once the list has been read
256    /// again with it in. Kept apart from `made`, which the application takes at once.
257    choose_when_read: Option<SafeName>,
258    /// The providers the person has added, as of the last reload, offered on the wizard's
259    /// account page to a harness that can be pointed at one.
260    providers: Vec<ProviderEntry>,
261    /// Where they are read from: the file the Providers page writes, or nowhere on a machine
262    /// without a data folder.
263    providers_file: Option<PathBuf>,
264    /// The wizard was asked for while the store was still being read; it opens with the listing.
265    new_wanted: bool,
266    /// A profile is being looked at or deleted, so the button takes no second press.
267    deleting: bool,
268    /// What the question being asked is about.
269    doomed: Option<Box<remove::Survey>>,
270    /// Whether the selection is on the row that makes a new profile rather than on a profile.
271    on_new: bool,
272    /// What this machine offers a window, for the sign-in of a harness that opens one.
273    display: Result<Display, NoDisplay>,
274    /// A profile's shell, while it is open; it covers the list or the wizard it was opened from.
275    shell: Option<ShellPage>,
276    /// What each profile had added in its shell, by the profile's name, as last read.
277    own: HashMap<String, Own>,
278    /// The lengths a build's silence is judged by. The product's are the measured ones; a test
279    /// shortens them, since a test cannot wait five minutes for a warning.
280    stall: stalling::Lengths,
281    /// Whether the next look at a build is timed, so that one is timed at a time.
282    watching: bool,
283}
284
285impl Profiles {
286    /// A profiles screen for the store at `root`, using `engine`.
287    ///
288    /// Both are optional because both can be missing on a working machine: a store that has
289    /// not been chosen yet, and an engine that was uninstalled after the setup. The screen opens
290    /// either way and says what it cannot do.
291    #[must_use]
292    pub fn new(root: Option<PathBuf>, engine: Option<Engine>) -> Self {
293        Self {
294            loading: root.is_some(),
295            root,
296            engine,
297            rows: Vec::new(),
298            problems: Vec::new(),
299            selected: 0,
300            draft: None,
301            signing_out: false,
302            made: None,
303            choose_when_read: None,
304            providers: Vec::new(),
305            providers_file: Providers::file(),
306            new_wanted: false,
307            deleting: false,
308            doomed: None,
309            on_new: false,
310            display: crate::desktop::current(),
311            shell: None,
312            own: HashMap::new(),
313            stall: stalling::Lengths::default(),
314            watching: false,
315        }
316    }
317
318    /// The same screen, opening a sign-in window on `display` rather than on the session this
319    /// process was started in: a test's own stand-in, never the person's screen.
320    #[must_use]
321    pub fn showing_on(mut self, display: Result<Display, NoDisplay>) -> Self {
322        self.display = display;
323        self
324    }
325
326    /// The same screen, saying a build is stuck after `lengths.quiet` of silence and looking every
327    /// `lengths.look`, which the product uses as [`stalling::QUIET`] and [`stalling::LOOK`].
328    ///
329    /// Only for a test: the product uses those two measured lengths, since a test cannot wait five
330    /// minutes to hear a warning and cannot ask the engine for a build that really takes that long.
331    #[cfg(test)]
332    #[must_use]
333    pub fn stalling(mut self, lengths: stalling::Lengths) -> Self {
334        self.stall = lengths;
335        self
336    }
337
338    /// The same screen reading the providers from `path` rather than from this machine's data
339    /// folder, so that it offers exactly what the Providers page it sends the person to wrote.
340    #[must_use]
341    pub fn with_providers_file(mut self, path: Option<PathBuf>) -> Self {
342        self.providers_file = path;
343        self
344    }
345
346    /// The profile the wizard has just finished making, taken once.
347    ///
348    /// It is how this screen tells the application that the work it was opened for is done. Who
349    /// opened it decides what that means: a workspace waiting behind this screen for a profile it
350    /// can open a tab with gets it back, and nobody else is moved.
351    pub fn just_made(&mut self) -> Option<SafeName> {
352        self.made.take()
353    }
354
355    /// The profiles and what the engine said about them.
356    #[must_use]
357    pub fn rows(&self) -> &[Row] {
358        &self.rows
359    }
360
361    /// The profile the selection is on.
362    #[must_use]
363    pub fn selected(&self) -> Option<&Row> {
364        self.rows.get(self.selected)
365    }
366
367    /// The wizard, while it is open.
368    #[must_use]
369    pub fn draft(&self) -> Option<&Draft> {
370        self.draft.as_ref()
371    }
372
373    /// Whether the store is still being read.
374    #[must_use]
375    pub fn is_loading(&self) -> bool {
376        self.loading
377    }
378
379    /// Everything that was wrong with the definition files.
380    #[must_use]
381    pub fn problems(&self) -> &[Diagnostic] {
382        &self.problems
383    }
384
385    /// A profile's shell, while it is open.
386    #[must_use]
387    pub fn shell(&self) -> Option<&ShellPage> {
388        self.shell.as_ref()
389    }
390
391    /// The store, when there is one.
392    fn store(&self) -> Option<Store> {
393        self.root.as_ref().map(Store::new)
394    }
395}
396
397/// Applies a message to the screen. Everything the returned [`Command`] later delivers comes
398/// back through the same `update`.
399pub fn update(state: &mut Profiles, message: Msg) -> Command<Msg> {
400    match message {
401        Msg::Reload => reload(state),
402        Msg::Loaded(listing) => {
403            state.loading = false;
404            state.problems = listing.diagnostics;
405            state.rows = listing.profiles.iter().cloned().map(Row::new).collect();
406            state.selected = state.selected.min(state.rows.len().saturating_sub(1));
407            // A person who just made a profile is looking for it, not for whatever stood first.
408            // A reading that was already under way when the wizard closed does not have it yet, so
409            // the wish waits for the reading that does.
410            let made = state.choose_when_read.as_ref();
411            if let Some(index) = made.and_then(|made| state.rows.iter().position(|row| row.profile.name == *made)) {
412                state.selected = index;
413                state.choose_when_read = None;
414            }
415            state.own.clear();
416            let own = shell_page::load(state.store(), &listing.profiles);
417            let probing = Command::batch([probe(state, listing.profiles), own]);
418            if std::mem::take(&mut state.new_wanted) {
419                return Command::batch([probing, update(state, Msg::New)]);
420            }
421            probing
422        }
423        Msg::ProvidersLoaded(providers) => {
424            // A wizard that is open offers what the file has now, not what it had when it opened.
425            if let Some(draft) = &mut state.draft {
426                draft.offer_providers(providers.clone());
427            }
428            state.providers = providers;
429            Command::none()
430        }
431        Msg::ReloadProviders => load_providers(state),
432        Msg::Probed(answers) => {
433            for row in &mut state.rows {
434                for answer in &answers {
435                    row.apply(answer);
436                }
437            }
438            Command::none()
439        }
440        Msg::Select(index) => {
441            if index < state.rows.len() {
442                state.selected = index;
443                state.on_new = false;
444            }
445            Command::none()
446        }
447        Msg::SelectNew => {
448            state.on_new = true;
449            Command::none()
450        }
451        Msg::SignInAsked => {
452            // A profile without an image has nothing to sign in from; the list says so already.
453            let Some(row) = state.selected() else { return Command::none() };
454            if state.engine.is_none() || row.image != Readiness::Present || !row.profile.account.needs_login() {
455                return Command::none();
456            }
457            state.draft = Some(Draft::for_login(&row.profile));
458            Command::focus("login-open")
459        }
460        Msg::SignOutAsked => ask_sign_out(state),
461        Msg::RebuildAsked => ask_rebuild(state),
462        Msg::RebuildConfirmed => {
463            let Some(row) = state.selected() else { return Command::none() };
464            if state.engine.is_none() || row.image != Readiness::Present {
465                return Command::none();
466            }
467            state.draft = Some(Draft::for_rebuild(&row.profile));
468            start_build(state)
469        }
470        Msg::EditAsked => {
471            let Some(row) = state.selected() else { return Command::none() };
472            state.draft = Some(Draft::for_edit(&row.profile, state.providers.clone()));
473            Command::focus("profile-harness")
474        }
475        Msg::SignOutConfirmed => sign_out(state),
476        Msg::SignedOut(result) => {
477            state.signing_out = false;
478            match result {
479                Ok(()) => Command::batch([Command::toast(Toast::success(t!("profiles.signed-out"))), reload(state)]),
480                Err(problem) => Command::toast(failure_toast("profiles.sign-out-failed", &problem)),
481            }
482        }
483        Msg::NewWhenRead if state.loading => {
484            state.new_wanted = true;
485            Command::none()
486        }
487        Msg::NewWhenRead | Msg::New => {
488            state.choose_when_read = None;
489            let taken = state.rows.iter().map(|row| row.profile.name.as_str().to_owned());
490            state.draft = Some(Draft::new(taken, state.providers.clone()));
491            // The page's first question, the harness, above the name that follows from it.
492            Command::focus("profile-harness")
493        }
494        Msg::Cancel => leave(state),
495        // Finish stands where Next would on the last page. A profile with no sign-in ends on its
496        // image page, where finishing before the image is built would leave with nothing made,
497        // so it is answered the way Next is: with the way to the image.
498        Msg::Finish => {
499            if state.draft.as_ref().is_some_and(|draft| draft.blocked().is_some()) {
500                return next(state);
501            }
502            // Finish is only offered once there is a profile; Cancel is the other way out and
503            // leaves nothing behind, so only this one is a profile having been made. A profile
504            // that was only changed was not made: nobody waiting for a new one is handed it.
505            let finished = state.draft.as_ref().and_then(Draft::profile).map(|profile| profile.name);
506            if state.draft.as_ref().is_some_and(Draft::is_editing) {
507                state.choose_when_read = finished;
508            } else {
509                state.made = finished;
510                state.choose_when_read.clone_from(&state.made);
511            }
512            leave(state)
513        }
514        Msg::Back => {
515            let Some(draft) = &mut state.draft else { return Command::none() };
516            draft.back();
517            arrive(draft)
518        }
519        Msg::Step(index) => {
520            let Some(draft) = &mut state.draft else { return Command::none() };
521            draft.go_to(index);
522            arrive(draft)
523        }
524        Msg::Next => next(state),
525        Msg::Name(name) => {
526            if let Some(draft) = &mut state.draft {
527                draft.renamed = true;
528                draft.name = name;
529            }
530            Command::none()
531        }
532        Msg::PickHarness(index) => {
533            if let Some(draft) = &mut state.draft
534                && let Some(harness) = draft.harnesses().get(index).copied()
535            {
536                draft.choose_harness(harness);
537            }
538            Command::none()
539        }
540        Msg::PickOs(index) => {
541            if let (Some(draft), Some(os)) = (&mut state.draft, Os::ALL.get(index)) {
542                draft.choose_os(*os);
543            }
544            Command::none()
545        }
546        Msg::PickTemplate(index) => {
547            if let Some(draft) = &mut state.draft
548                && let Some(template) = Template::offered(draft.harness).get(index).copied()
549            {
550                draft.choose(template);
551            }
552            Command::none()
553        }
554        Msg::SwitchExtra(extra, on) => {
555            if let Some(draft) = &mut state.draft {
556                draft.switch(extra, on);
557            }
558            Command::none()
559        }
560        Msg::PickAccount(index) => {
561            if let Some(draft) = &mut state.draft
562                && let Some(account) = draft.harness.record().accounts.get(index)
563            {
564                draft.account = *account;
565                // Picking a provider is worth nothing on its own; the person still has to name a
566                // model, so the first of whichever provider stands first is chosen along with it,
567                // the same courtesy `choose_harness` pays the account page itself.
568                let first_tag = draft.providers.first().map(|entry| entry.tag.to_string());
569                if *account == AccountKind::Provider
570                    && let Some(tag) = first_tag
571                {
572                    draft.choose_provider(&tag);
573                }
574            }
575            Command::none()
576        }
577        Msg::PickProvider(index) => {
578            if let Some(draft) = &mut state.draft {
579                let tag = draft.providers.get(index).map(|entry| entry.tag.to_string());
580                if let Some(tag) = tag {
581                    draft.choose_provider(&tag);
582                }
583            }
584            Command::none()
585        }
586        Msg::PickProviderModel(index) => {
587            if let Some(draft) = &mut state.draft {
588                draft.choose_pick_row(index);
589            }
590            Command::none()
591        }
592        Msg::ModelFilter(query) => {
593            if let Some(draft) = &mut state.draft {
594                draft.model_filter = query;
595            }
596            Command::none()
597        }
598        Msg::ManageProviders => Command::none(),
599        Msg::PickAssets(index) => {
600            if let (Some(draft), Some(access)) = (&mut state.draft, MountAccess::ALL.get(index)) {
601                draft.assets = *access;
602            }
603            Command::none()
604        }
605        Msg::PickNetwork(index) => {
606            if let Some(draft) = &mut state.draft
607                && let Some(mode) = draft.networks().get(index).copied()
608            {
609                draft.network = mode;
610            }
611            Command::none()
612        }
613        Msg::BuildStart => start_build(state),
614        Msg::BaseImageStarted => {
615            if let Some(draft) = &mut state.draft {
616                draft.log.push(LogLine::new(LogLevel::Info, t!("profiles.wizard.build-base")));
617                // A line of the build's own is what ends the silence the page warns about.
618                draft.build_said();
619            }
620            Command::none()
621        }
622        Msg::BuildLine(text) => {
623            if let Some(draft) = &mut state.draft {
624                draft.log.push(LogLine::new(LogLevel::Info, text));
625                draft.build_said();
626            }
627            Command::none()
628        }
629        // The look came round: the draft is marked by how long its log has been quiet, and the
630        // next look is timed while a build is still running.
631        Msg::BuildLooked(now) => builds::looked(state, now),
632        Msg::BuildEnded(result) => {
633            let Some(draft) = &mut state.draft else { return Command::none() };
634            match result {
635                Ok(()) => draft.build = Build::Done,
636                Err(problem) => {
637                    report(&mut draft.log, &problem);
638                    draft.build = if problem == Problem::Cancelled { Build::Stopped } else { Build::Failed(problem) };
639                }
640            }
641            draft.build_ended();
642            Command::none()
643        }
644        Msg::BuildCancel => {
645            let Some(draft) = &mut state.draft else { return Command::none() };
646            let Build::Running(id) = draft.build else { return Command::none() };
647            // The build removes the half-made image itself as soon as it notices the stop.
648            draft.build = Build::Stopped;
649            draft.build_ended();
650            let stopping =
651                if draft.is_only_rebuild() { "profiles.rebuild.stopping" } else { "profiles.wizard.build-stopping" };
652            draft.log.push(LogLine::new(LogLevel::Warn, t!(stopping)));
653            Command::cancel_task(id)
654        }
655        Msg::LoginStart => start_login(state),
656        Msg::LoginOpened { session, container } => {
657            let engine = state.engine.clone();
658            let Some(draft) = &mut state.draft else {
659                // The wizard was closed while the container was coming up; nothing keeps it.
660                session.kill();
661                return discard(engine, container);
662            };
663            let watch = session.watch();
664            draft.login = Login::Running { session, container };
665            Command::batch([Command::perform(move || Msg::LoginEvent(watch.next())), Command::focus("login-terminal")])
666        }
667        Msg::LoginEvent(event) => login_event(state, event),
668        Msg::LoginDone => store_login(state),
669        Msg::LoginCancel => cancel_login(state),
670        Msg::LoginStored(result) => {
671            let Some(draft) = &mut state.draft else { return Command::none() };
672            draft.login = match result {
673                Ok(stored) => Login::Stored(stored),
674                Err(Problem::NoLogin) => Login::Unfinished(Unfinished::Missing),
675                Err(problem) => Login::Failed(problem),
676            };
677            Command::none()
678        }
679        Msg::LoginFailed(problem) => {
680            if let Some(draft) = &mut state.draft {
681                draft.login = Login::Failed(problem);
682            }
683            Command::none()
684        }
685        Msg::LoginDiscarded => Command::none(),
686        Msg::WindowOpened(run, sign_in) => window_opened(state, run, sign_in),
687        Msg::WindowSeen(run, seen) => {
688            let current = state
689                .draft
690                .as_ref()
691                .is_some_and(|draft| matches!(&draft.login, Login::Window(window) if window.run == run));
692            // A window that is signed in, or gone, is done with: what its home holds is taken now.
693            if current && seen != Seen::NotYet { store_login(state) } else { Command::none() }
694        }
695        Msg::WindowAsked(run, addresses) => window_asked(state, run, &addresses),
696        Msg::WindowPage(run, address, page) => {
697            if let Some(window) = current_window(state, run) {
698                window.page = Some((address, page));
699            }
700            Command::none()
701        }
702        Msg::WindowBack(run, id, ending) => {
703            if let Some(window) = current_window(state, run) {
704                window.came_back(id, ending);
705            }
706            Command::none()
707        }
708        Msg::DeleteAsked => ask_delete(state),
709        Msg::DeleteSurveyed(survey) => delete_question(state, survey),
710        Msg::DeleteConfirmed(homes) => delete(state, homes),
711        Msg::DeleteKept => {
712            state.deleting = false;
713            state.doomed = None;
714            Command::none()
715        }
716        Msg::Deleted(survey, outcome) => deleted(state, &survey, outcome),
717        Msg::Shell(message) => shell_page::update(state, message),
718    }
719}