1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Side {
27 You,
29 Admin,
31}
32
33#[derive(Debug, Clone)]
35pub enum ShellMsg {
36 Asked,
38 Opened(Result<(Arc<str>, TerminalSession), Problem>),
40 Leftover(Arc<str>, Session),
42 Continue,
44 AddLeftover,
46 Event(Side, TerminalEvent),
48 Admin,
50 AdminOpened(Result<TerminalSession, Problem>),
52 Show(Side),
54 Close,
56 Surveyed(Result<Session, Problem>),
58 Add,
60 Discard,
62 Added(Result<Own, Problem>),
64 Discarded,
66 Trim(usize),
68 TrimFailed(Problem),
70 OwnRead(HashMap<String, Own>),
72}
73
74#[derive(Debug, Clone)]
76pub enum Stage {
77 Opening,
79 Leftover(Session),
81 Open,
83 Surveying,
85 Deciding(Session),
87 Adding,
89 Failed(Problem),
91}
92
93#[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 #[must_use]
107 pub fn stage(&self) -> &Stage {
108 &self.stage
109 }
110
111 #[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
124pub(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 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 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
233fn 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
242fn 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 shell::discard(&engine, &profile.name);
272 }
273 Ok(Msg::Shell(ShellMsg::Opened(entered.map(|session| (Arc::from(container.as_str()), session)))))
274 }))
275}
276
277fn 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
290fn 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
302fn 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
308fn 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
334fn 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 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
365fn 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
409fn 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
423fn 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
440pub(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
452pub(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
460fn 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
470pub(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
481pub(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
503pub(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}