Skip to main content

qcode/ui/profiles/
shell_page.rs

1//! The page of a profile's shell, and the profile's own steps in the list.
2//!
3//! The shell is opened from the wizard's last page and from the list, and covers whichever it was
4//! opened from until it is closed; the wizard is still there under it. Everything that touches the
5//! engine runs in a task and comes back as a [`ShellMsg`].
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use qframe::prelude::*;
12use qframe::runtime::Task;
13use qframe::widgets::{ShimmerText, Terminal, TerminalEvent, TerminalSession, Toast};
14
15use crate::engine::HostUser;
16use crate::profile::Profile;
17use crate::profile::own::Own;
18use crate::store::Store;
19
20use super::shell::{self, Session};
21use super::work::Problem;
22use super::{Msg, Profiles, VIEWPORT_ROWS, failure_toast, reload};
23
24/// Which of the shell's two terminals.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Side {
27    /// `bash` as the person.
28    You,
29    /// `bash` as root.
30    Admin,
31}
32
33/// Everything that happens to a profile's shell and to the profile's own steps.
34#[derive(Debug, Clone)]
35pub enum ShellMsg {
36    /// The button that opens the shell was pressed, on the wizard's last page or in the list.
37    Asked,
38    /// The container is up and the person's terminal is running in it, or it could not be.
39    Opened(Result<(Arc<str>, TerminalSession), Problem>),
40    /// A shell that was never closed left its container with work in it; the person is asked.
41    Leftover(Arc<str>, Session),
42    /// Carry on in the shell that was left open.
43    Continue,
44    /// Add what the shell that was left open holds to the profile.
45    AddLeftover,
46    /// A terminal drew or ended.
47    Event(Side, TerminalEvent),
48    /// "As administrator" was pressed.
49    Admin,
50    /// The administrator's terminal is running, or could not be started.
51    AdminOpened(Result<TerminalSession, Problem>),
52    /// The person chose which terminal to look at.
53    Show(Side),
54    /// The shell is closed: what changed is read.
55    Close,
56    /// What the shell changed and what its administrator ran.
57    Surveyed(Result<Session, Problem>),
58    /// Add what the shell holds to the profile.
59    Add,
60    /// Throw away what the shell holds.
61    Discard,
62    /// Adding is over.
63    Added(Result<Own, Problem>),
64    /// The shell's container is gone.
65    Discarded,
66    /// The own step at this place of the chosen profile is taken off its list.
67    Trim(usize),
68    /// A step could not be taken off, because the store could not be written.
69    TrimFailed(Problem),
70    /// Every profile's own steps and home files, as the store holds them.
71    OwnRead(HashMap<String, Own>),
72}
73
74/// Where the shell is.
75#[derive(Debug, Clone)]
76pub enum Stage {
77    /// The container is being made.
78    Opening,
79    /// A shell that was never closed left this behind, and the person says what becomes of it.
80    Leftover(Session),
81    /// The person is working in it.
82    Open,
83    /// It was closed and what changed is being read.
84    Surveying,
85    /// What changed is known and the question is asked.
86    Deciding(Session),
87    /// What it holds is being made the profile's.
88    Adding,
89    /// It could not be opened or its work could not be added.
90    Failed(Problem),
91}
92
93/// A profile's shell, while it is open.
94#[derive(Debug)]
95pub struct ShellPage {
96    profile: Profile,
97    container: Option<Arc<str>>,
98    you: Option<TerminalSession>,
99    admin: Option<TerminalSession>,
100    showing: Side,
101    stage: Stage,
102}
103
104impl ShellPage {
105    /// Where the shell is.
106    #[must_use]
107    pub fn stage(&self) -> &Stage {
108        &self.stage
109    }
110
111    /// The name of the container the shell runs in, once it is up.
112    #[must_use]
113    pub fn container(&self) -> Option<&str> {
114        self.container.as_deref()
115    }
116
117    fn kill(&self) {
118        for session in [&self.you, &self.admin].into_iter().flatten() {
119            session.kill();
120        }
121    }
122}
123
124/// Applies a message about the shell.
125pub(super) fn update(state: &mut Profiles, message: ShellMsg) -> Command<Msg> {
126    match message {
127        ShellMsg::Asked => open(state),
128        ShellMsg::Opened(Ok((container, session))) => {
129            let Some(page) = &mut state.shell else {
130                // The screen was left while the container came up; nothing keeps it.
131                session.kill();
132                return discard_container(state.engine.clone(), &container);
133            };
134            let watch = session.watch();
135            page.container = Some(container);
136            page.you = Some(session);
137            page.stage = Stage::Open;
138            Command::batch([
139                Command::perform(move || Msg::Shell(ShellMsg::Event(Side::You, watch.next()))),
140                Command::focus("shell-terminal"),
141            ])
142        }
143        ShellMsg::Opened(Err(problem)) => {
144            if let Some(page) = &mut state.shell {
145                // Nothing of this opening stands, and a container left open before it is not
146                // this page's to throw away when it is closed.
147                page.container = None;
148                page.stage = Stage::Failed(problem);
149            }
150            Command::none()
151        }
152        ShellMsg::Leftover(container, session) => {
153            let Some(page) = &mut state.shell else { return Command::none() };
154            page.container = Some(container);
155            page.stage = Stage::Leftover(session);
156            Command::none()
157        }
158        ShellMsg::Continue => carry_on(state),
159        ShellMsg::AddLeftover => {
160            let Some(page) = &mut state.shell else { return Command::none() };
161            let Stage::Leftover(session) = &page.stage else { return Command::none() };
162            page.stage = Stage::Deciding(session.clone());
163            add(state)
164        }
165        ShellMsg::AdminOpened(Err(problem)) => {
166            if let Some(page) = &mut state.shell {
167                page.kill();
168                page.stage = Stage::Failed(problem);
169            }
170            Command::none()
171        }
172        ShellMsg::Event(side, event) => watch(state, side, event),
173        ShellMsg::Admin => open_admin(state),
174        ShellMsg::AdminOpened(Ok(session)) => {
175            let Some(page) = &mut state.shell else {
176                session.kill();
177                return Command::none();
178            };
179            let watch = session.watch();
180            page.admin = Some(session);
181            page.showing = Side::Admin;
182            Command::batch([
183                Command::perform(move || Msg::Shell(ShellMsg::Event(Side::Admin, watch.next()))),
184                Command::focus("shell-terminal"),
185            ])
186        }
187        ShellMsg::Show(side) => {
188            if let Some(page) = &mut state.shell {
189                page.showing = side;
190            }
191            Command::focus("shell-terminal")
192        }
193        ShellMsg::Close => close(state),
194        ShellMsg::Surveyed(result) => surveyed(state, result),
195        ShellMsg::Add => add(state),
196        ShellMsg::Discard => {
197            let Some(page) = &state.shell else { return Command::none() };
198            let (Some(engine), name) = (state.engine.clone(), page.profile.name.clone()) else {
199                state.shell = None;
200                return Command::none();
201            };
202            Command::perform(move || {
203                shell::discard(&engine, &name);
204                Msg::Shell(ShellMsg::Discarded)
205            })
206        }
207        ShellMsg::Discarded => {
208            state.shell = None;
209            Command::toast(Toast::info(t!("profiles.shell.discarded")))
210        }
211        ShellMsg::Added(Ok(own)) => {
212            let name = state.shell.take().map(|page| page.profile.name.to_string()).unwrap_or_default();
213            let steps = i64::try_from(own.steps.len()).unwrap_or(i64::MAX);
214            let told = Toast::success(t!("profiles.shell.added", name = name.as_str()))
215                .body(t!("profiles.shell.added-body", n = steps));
216            Command::batch([Command::toast(told), reload(state)])
217        }
218        ShellMsg::Added(Err(problem)) => {
219            if let Some(page) = &mut state.shell {
220                page.stage = Stage::Failed(problem);
221            }
222            Command::none()
223        }
224        ShellMsg::Trim(index) => trim(state, index),
225        ShellMsg::TrimFailed(problem) => Command::toast(failure_toast("profiles.own.trim-failed", &problem)),
226        ShellMsg::OwnRead(own) => {
227            state.own.extend(own);
228            Command::none()
229        }
230    }
231}
232
233/// The profile the shell is asked for: the wizard's, when the wizard is open, or the list's
234/// chosen one.
235fn asked_for(state: &Profiles) -> Option<Profile> {
236    match &state.draft {
237        Some(draft) => draft.profile(),
238        None => state.selected().map(|row| row.profile.clone()),
239    }
240}
241
242/// Opens the shell's container and the person's terminal in it.
243fn open(state: &mut Profiles) -> Command<Msg> {
244    let Some(engine) = state.engine.clone() else { return Command::none() };
245    if state.shell.is_some() {
246        return Command::none();
247    }
248    let Some(profile) = asked_for(state) else { return Command::none() };
249    state.shell = Some(ShellPage {
250        profile: profile.clone(),
251        container: None,
252        you: None,
253        admin: None,
254        showing: Side::You,
255        stage: Stage::Opening,
256    });
257    Command::task(Task::new(t!("profiles.shell.opening"), move |_| {
258        let opened = HostUser::current()
259            .map_err(|error| Problem::Machine(error.to_string()))
260            .and_then(|user| shell::open(&engine, &profile, user));
261        let container = match opened {
262            Ok(shell::Opened::Fresh(container)) => container,
263            Ok(shell::Opened::Leftover(container, session)) => {
264                return Ok(Msg::Shell(ShellMsg::Leftover(Arc::from(container.as_str()), session)));
265            }
266            Err(problem) => return Ok(Msg::Shell(ShellMsg::Opened(Err(problem)))),
267        };
268        let entered = spawn(&shell::enter(&engine, &container, false));
269        if entered.is_err() {
270            // Made just now and nothing done in it yet.
271            shell::discard(&engine, &profile.name);
272        }
273        Ok(Msg::Shell(ShellMsg::Opened(entered.map(|session| (Arc::from(container.as_str()), session)))))
274    }))
275}
276
277/// Opens the person's terminal in the shell that was left open, which goes on as if it had never
278/// been left: closing it asks about everything done in it, before and now.
279fn carry_on(state: &mut Profiles) -> Command<Msg> {
280    let Some(engine) = state.engine.clone() else { return Command::none() };
281    let Some(page) = &mut state.shell else { return Command::none() };
282    let (Some(container), Stage::Leftover(_)) = (page.container.clone(), &page.stage) else { return Command::none() };
283    page.stage = Stage::Opening;
284    Command::perform(move || {
285        let entered = spawn(&shell::enter(&engine, &container, false));
286        Msg::Shell(ShellMsg::Opened(entered.map(|session| (container, session))))
287    })
288}
289
290/// Starts the administrator's terminal in the shell's container.
291fn open_admin(state: &mut Profiles) -> Command<Msg> {
292    let Some(engine) = state.engine.clone() else { return Command::none() };
293    let Some(page) = &mut state.shell else { return Command::none() };
294    if page.admin.is_some() {
295        page.showing = Side::Admin;
296        return Command::focus("shell-terminal");
297    }
298    let (Some(container), Stage::Open) = (page.container.clone(), &page.stage) else { return Command::none() };
299    Command::perform(move || Msg::Shell(ShellMsg::AdminOpened(spawn(&shell::enter(&engine, &container, true)))))
300}
301
302/// A terminal session running `command`.
303fn spawn(command: &crate::engine::EngineCommand) -> Result<TerminalSession, Problem> {
304    TerminalSession::spawn(command.program.as_os_str(), &command.args, &std::env::temp_dir())
305        .map_err(|error| Problem::Machine(error.to_string()))
306}
307
308/// Keeps watching a terminal. The person's shell ending is the shell being closed; the
309/// administrator's ending only puts the person's back in front.
310fn watch(state: &mut Profiles, side: Side, event: TerminalEvent) -> Command<Msg> {
311    let Some(page) = &mut state.shell else { return Command::none() };
312    if !matches!(page.stage, Stage::Open) {
313        return Command::none();
314    }
315    let session = match side {
316        Side::You => page.you.as_ref(),
317        Side::Admin => page.admin.as_ref(),
318    };
319    let Some(session) = session else { return Command::none() };
320    match event {
321        TerminalEvent::Output => {
322            let watch = session.watch();
323            Command::perform(move || Msg::Shell(ShellMsg::Event(side, watch.next())))
324        }
325        TerminalEvent::Exited(_) if side == Side::Admin => {
326            page.admin = None;
327            page.showing = Side::You;
328            Command::focus("shell-terminal")
329        }
330        TerminalEvent::Exited(_) => close(state),
331    }
332}
333
334/// Ends both terminals and reads what the shell changed.
335fn close(state: &mut Profiles) -> Command<Msg> {
336    let Some(engine) = state.engine.clone() else { return Command::none() };
337    let Some(page) = &mut state.shell else { return Command::none() };
338    if !matches!(page.stage, Stage::Open) {
339        // Nothing was made to look at, so there is nothing to ask: the page goes. A container
340        // this page never had — one a shell left open, which it failed to open again — stays.
341        if matches!(page.stage, Stage::Failed(_)) {
342            let name = page.profile.name.clone();
343            let ours = page.container.is_some();
344            state.shell = None;
345            if !ours {
346                return Command::none();
347            }
348            return Command::perform(move || {
349                shell::discard(&engine, &name);
350                Msg::Shell(ShellMsg::Discarded)
351            });
352        }
353        return Command::none();
354    }
355    page.kill();
356    page.you = None;
357    page.admin = None;
358    page.stage = Stage::Surveying;
359    let name = page.profile.name.clone();
360    Command::task(Task::new(t!("profiles.shell.surveying"), move |_| {
361        Ok(Msg::Shell(ShellMsg::Surveyed(shell::survey(&engine, &name))))
362    }))
363}
364
365/// Says what the shell changed and asks whether it becomes the profile's; a shell that changed
366/// nothing is simply put away.
367fn surveyed(state: &mut Profiles, result: Result<Session, Problem>) -> Command<Msg> {
368    let Some(page) = &mut state.shell else { return Command::none() };
369    let session = match result {
370        Ok(session) => session,
371        Err(problem) => {
372            page.stage = Stage::Failed(problem);
373            return Command::none();
374        }
375    };
376    if session.changes.is_empty() && session.steps.is_empty() {
377        page.stage = Stage::Adding;
378        let engine = state.engine.clone();
379        let name = page.profile.name.clone();
380        return Command::perform(move || {
381            if let Some(engine) = engine {
382                shell::discard(&engine, &name);
383            }
384            Msg::Shell(ShellMsg::Discarded)
385        });
386    }
387    let count = |n: usize| i64::try_from(n).unwrap_or(i64::MAX);
388    let mut message = vec![t!(
389        "profiles.shell.changed",
390        home = count(session.changes.home.len()).to_string(),
391        system = count(session.changes.system.len()).to_string()
392    )];
393    if !session.steps.is_empty() {
394        message.push(t!("profiles.shell.steps", n = count(session.steps.len())));
395        message.push(session.steps.join("\n"));
396    }
397    message.push(t!("profiles.shell.reach"));
398    let name = page.profile.name.to_string();
399    page.stage = Stage::Deciding(session);
400    Command::confirm(
401        Confirm::new(t!("profiles.shell.changed-title", name = name.as_str()), Msg::Shell(ShellMsg::Add))
402            .message(message.join("\n\n"))
403            .confirm_label(t!("profiles.shell.add"))
404            .cancel_label(t!("profiles.shell.discard"))
405            .on_cancel(Msg::Shell(ShellMsg::Discard)),
406    )
407}
408
409/// Makes what the shell holds the profile's.
410fn add(state: &mut Profiles) -> Command<Msg> {
411    let (Some(engine), Some(store)) = (state.engine.clone(), state.store()) else { return Command::none() };
412    let Some(page) = &mut state.shell else { return Command::none() };
413    let Stage::Deciding(session) = &page.stage else { return Command::none() };
414    let steps = session.steps.clone();
415    let profile = page.profile.clone();
416    page.stage = Stage::Adding;
417    let profiles = store.profiles_dir();
418    Command::task(Task::new(t!("profiles.shell.adding"), move |_| {
419        Ok(Msg::Shell(ShellMsg::Added(shell::add(&engine, &profile, &profiles, steps))))
420    }))
421}
422
423/// Takes one own step off the chosen profile's list.
424fn trim(state: &Profiles, index: usize) -> Command<Msg> {
425    let (Some(store), Some(row)) = (state.store(), state.selected()) else { return Command::none() };
426    let name = row.profile.name.clone();
427    let profiles = store.profiles_dir();
428    Command::perform(move || {
429        let (mut own, _) = Own::load(&profiles, &name);
430        if index < own.steps.len() {
431            own.steps.remove(index);
432            if let Err(problem) = own.save(&profiles, &name) {
433                return Msg::Shell(ShellMsg::TrimFailed(Problem::Machine(problem.message)));
434            }
435        }
436        Msg::Shell(ShellMsg::OwnRead(read_all(&profiles, [name.to_string()])))
437    })
438}
439
440/// Reads the own steps of `names` from the store.
441pub(super) fn read_all(profiles: &std::path::Path, names: impl IntoIterator<Item = String>) -> HashMap<String, Own> {
442    names
443        .into_iter()
444        .filter_map(|name| {
445            let safe = crate::profile::SafeName::parse(&name)?;
446            let (own, _) = Own::load(profiles, &safe);
447            Some((name, own))
448        })
449        .collect()
450}
451
452/// The own steps of every profile of the store, read in the background.
453pub(super) fn load(store: Option<Store>, profiles: &[Profile]) -> Command<Msg> {
454    let Some(store) = store else { return Command::none() };
455    let names: Vec<String> = profiles.iter().map(|profile| profile.name.to_string()).collect();
456    let folder: PathBuf = store.profiles_dir();
457    Command::perform(move || Msg::Shell(ShellMsg::OwnRead(read_all(&folder, names))))
458}
459
460/// Removes a shell's container nobody is looking at any more.
461fn discard_container(engine: Option<crate::engine::Engine>, container: &str) -> Command<Msg> {
462    let Some(engine) = engine else { return Command::none() };
463    let container = container.to_owned();
464    Command::perform(move || {
465        let _ = crate::engine::run::capture(&engine.remove_container(&container));
466        Msg::Shell(ShellMsg::Discarded)
467    })
468}
469
470/// The button that opens a profile's shell.
471pub(super) fn offer(state: &Profiles, ui: &mut View<'_, Msg>) {
472    let engineless = state.engine.is_none();
473    ui.add(Text::new(t!("profiles.shell.offer")).role("secondary")).fill_width();
474    let mut button = Button::new(t!("profiles.shell.open")).disabled(engineless);
475    if !engineless {
476        button = button.on_press(Msg::Shell(ShellMsg::Asked));
477    }
478    ui.add(button).id("profile-shell");
479}
480
481/// The profile's own steps, under its row in the list, each with the way to take it off.
482pub(super) fn draw_own(own: &Own, ui: &mut View<'_, Msg>) {
483    if own.is_empty() {
484        return;
485    }
486    let count = |n: usize| i64::try_from(n).unwrap_or(i64::MAX);
487    ui.add(Text::new(t!("profiles.own.title", n = count(own.steps.len()))).bold()).fill_width();
488    for (index, step) in own.steps.iter().enumerate() {
489        ui.row(|ui| {
490            ui.add(Text::new(step.clone())).fill_width();
491            ui.add(Button::new(t!("profiles.own.remove")).on_press(Msg::Shell(ShellMsg::Trim(index))))
492                .id(format!("own-step-{index}"));
493        })
494        .gap(2)
495        .fill_width();
496    }
497    if !own.home.is_empty() {
498        ui.add(Text::new(t!("profiles.own.home", n = count(own.home.len()))).role("secondary")).fill_width();
499    }
500    ui.add(Text::new(t!("profiles.own.note")).role("secondary")).fill_width();
501}
502
503/// The shell's page.
504pub(super) fn draw(page: &ShellPage, ui: &mut View<'_, Msg>) {
505    let name = page.profile.name.to_string();
506    ui.column(|ui| {
507        ui.add(Text::new(t!("profiles.shell.title", name = name.as_str())).bold());
508        ui.add(Text::new(t!("profiles.shell.lead")).role("secondary")).fill_width();
509        match &page.stage {
510            Stage::Opening => {
511                ui.add(ShimmerText::new(t!("profiles.shell.opening"))).id("shell-working");
512            }
513            Stage::Open => {
514                ui.row(|ui| {
515                    let you = Button::new(t!("profiles.shell.you")).on_press(Msg::Shell(ShellMsg::Show(Side::You)));
516                    let you = if page.showing == Side::You { you.variant("primary") } else { you };
517                    ui.add(you).id("shell-you");
518                    let (label, message) = if page.admin.is_some() {
519                        (t!("profiles.shell.admin"), ShellMsg::Show(Side::Admin))
520                    } else {
521                        (t!("profiles.shell.admin"), ShellMsg::Admin)
522                    };
523                    let admin = Button::new(label).on_press(Msg::Shell(message));
524                    let admin = if page.showing == Side::Admin { admin.variant("primary") } else { admin };
525                    ui.add(admin).id("shell-admin");
526                    ui.spacer();
527                })
528                .gap(2)
529                .fill_width();
530                if page.showing == Side::Admin {
531                    ui.add(Text::new(t!("profiles.shell.admin-note")).color("warning")).fill_width();
532                }
533                let session = match page.showing {
534                    Side::Admin => page.admin.as_ref().or(page.you.as_ref()),
535                    Side::You => page.you.as_ref(),
536                };
537                if let Some(session) = session {
538                    ui.add(Terminal::new(session))
539                        .id("shell-terminal")
540                        .fill_width()
541                        .height(Length::Cells(VIEWPORT_ROWS));
542                }
543            }
544            Stage::Leftover(session) => {
545                ui.add(Text::new(t!("profiles.shell.leftover.lead")).color("warning")).fill_width();
546                let count = |n: usize| i64::try_from(n).unwrap_or(i64::MAX).to_string();
547                let changed = t!(
548                    "profiles.shell.changed",
549                    home = count(session.changes.home.len()),
550                    system = count(session.changes.system.len())
551                );
552                ui.add(Text::new(changed).role("secondary")).fill_width();
553                if !session.steps.is_empty() {
554                    let n = i64::try_from(session.steps.len()).unwrap_or(i64::MAX);
555                    ui.add(Text::new(t!("profiles.shell.steps", n = n)).role("secondary")).fill_width();
556                    ui.add(Text::new(session.steps.join("\n"))).fill_width();
557                }
558                ui.row(|ui| {
559                    let carry = Button::new(t!("profiles.shell.leftover.continue"))
560                        .variant("primary")
561                        .on_press(Msg::Shell(ShellMsg::Continue));
562                    ui.add(carry).id("shell-continue");
563                    let add =
564                        Button::new(t!("profiles.shell.leftover.add")).on_press(Msg::Shell(ShellMsg::AddLeftover));
565                    ui.add(add).id("shell-add-leftover");
566                    let discard = Button::new(t!("profiles.shell.leftover.discard"))
567                        .variant("danger")
568                        .on_press(Msg::Shell(ShellMsg::Discard));
569                    ui.add(discard).id("shell-discard-leftover");
570                    ui.spacer();
571                })
572                .gap(2)
573                .fill_width();
574            }
575            Stage::Surveying => {
576                ui.add(ShimmerText::new(t!("profiles.shell.surveying"))).id("shell-working");
577            }
578            Stage::Deciding(_) => {
579                ui.add(Text::new(t!("profiles.shell.deciding")).role("secondary")).fill_width();
580            }
581            Stage::Adding => {
582                ui.add(ShimmerText::new(t!("profiles.shell.adding"))).id("shell-working");
583            }
584            Stage::Failed(problem) => {
585                ui.add(Text::new(t!("profiles.shell.failed")).color("danger")).fill_width();
586                if let Some(output) = problem.output() {
587                    ui.add(Text::new(output.to_owned()).role("secondary")).fill_width();
588                }
589            }
590        }
591        ui.row(|ui| {
592            ui.spacer();
593            let closable = matches!(page.stage, Stage::Open | Stage::Failed(_));
594            let mut close = Button::new(t!("profiles.shell.close")).variant("primary").disabled(!closable);
595            if closable {
596                close = close.on_press(Msg::Shell(ShellMsg::Close));
597            }
598            ui.add(close).id("shell-close");
599        })
600        .fill_width();
601    })
602    .gap(1)
603    .width(Length::Cells(super::PAGE_WIDTH));
604}