Skip to main content

max / alloy

28.1 KB · 749 lines History Blame Raw
1 //! The screen: tabs, rows, the value prompts, the confirmations and the keys.
2 //!
3 //! The top of the stack. Names every sibling and is named by none of them, so a
4 //! change to what is drawn cannot reach the parser or the argv. The two rules
5 //! the module docs promise live here rather than in the model because both need
6 //! the whole list: [`DiskView::drive_is_system`] refuses a partition for what a
7 //! sibling on its disk is doing, and [`describe_loss`] is the one sentence the
8 //! four destructive confirmations are built from.
9
10 use anyhow::Result;
11
12 use alloy_tui::keys::Action;
13 use alloy_tui::{
14 AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding,
15 hint, text, unavailable,
16 };
17 use ratatui::Frame;
18 use ratatui::crossterm::event::{KeyCode, KeyEvent};
19 use ratatui::layout::{Constraint, Layout, Rect};
20 use ratatui::style::{Modifier, Style};
21 use ratatui::text::{Line, Span};
22 use ratatui::widgets::{Paragraph, Wrap};
23
24 use super::backend::{Backend, FILESYSTEMS, detect};
25 use super::model::{Blocked, Drive, Volume};
26 use crate::cli::CommandLog;
27 use crate::shell::{Confirm, Flow, View, block_title, truncate};
28 use crate::size::format_size;
29
30 mod edits;
31
32 use edits::Editing;
33
34 /// Which list is showing.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub(crate) enum Tab {
37 /// Volumes on drives a person unplugs. The default, because it is what the
38 /// verb exists for.
39 Removable,
40 /// Every volume on the machine, so the tab above is visibly a filter rather
41 /// than the whole truth.
42 All,
43 }
44
45 impl Tab {
46 const ALL: [Tab; 2] = [Tab::Removable, Tab::All];
47
48 const fn label(self) -> &'static str {
49 match self {
50 Self::Removable => "removable",
51 Self::All => "all",
52 }
53 }
54
55 const fn slot(self) -> usize {
56 match self {
57 Self::Removable => 0,
58 Self::All => 1,
59 }
60 }
61
62 const fn from_slot(slot: usize) -> Self {
63 match slot {
64 0 => Self::Removable,
65 _ => Self::All,
66 }
67 }
68 }
69
70 /// What the pending confirmation would do once answered.
71 pub(super) enum PendingAction {
72 /// Eject a drive that still has something mounted on it.
73 Eject(Volume),
74 /// Add a partition to a drive. Carries the drive rather than the volume the
75 /// cursor was on, because the operation is the table's and not the row's.
76 Create {
77 drive: Drive,
78 size: u64,
79 },
80 Delete(Volume),
81 Format {
82 volume: Volume,
83 fstype: String,
84 },
85 Resize {
86 volume: Volume,
87 size: u64,
88 },
89 }
90
91 /// The `alloy disk` screen.
92 pub(crate) struct DiskView {
93 backend: Box<dyn Backend>,
94 volumes: Vec<Volume>,
95 tabs: FocusRing,
96 /// One cursor per tab. Sharing one across tabs would move the selection in a
97 /// list the user is not looking at, and the two lists are different lengths.
98 removable_cursor: Cursor,
99 all_cursor: Cursor,
100 error: Option<String>,
101 pending_action: Option<PendingAction>,
102 /// The value being collected before a partition action can be confirmed.
103 editing: Option<Editing>,
104 }
105
106 impl DiskView {
107 pub(crate) fn new(tab: Tab, log: &mut CommandLog) -> Self {
108 let mut tabs = FocusRing::new(Tab::ALL.len());
109 tabs.focus(tab.slot());
110
111 let mut view = Self {
112 backend: detect(),
113 volumes: Vec::new(),
114 tabs,
115 removable_cursor: Cursor::new(),
116 all_cursor: Cursor::new(),
117 error: None,
118 pending_action: None,
119 editing: None,
120 };
121 view.refresh(log);
122 view
123 }
124
125 fn tab(&self) -> Tab {
126 Tab::from_slot(self.tabs.current())
127 }
128
129 /// The rows on the current tab.
130 fn rows(&self) -> Vec<&Volume> {
131 match self.tab() {
132 Tab::Removable => self
133 .volumes
134 .iter()
135 .filter(|volume| volume.drive.detachable())
136 .collect(),
137 Tab::All => self.volumes.iter().collect(),
138 }
139 }
140
141 fn cursor(&mut self) -> &mut Cursor {
142 match self.tab() {
143 Tab::Removable => &mut self.removable_cursor,
144 Tab::All => &mut self.all_cursor,
145 }
146 }
147
148 fn selected(&self) -> Option<Volume> {
149 let cursor = match self.tab() {
150 Tab::Removable => &self.removable_cursor,
151 Tab::All => &self.all_cursor,
152 };
153 self.rows().get(cursor.selected()?).map(|v| (*v).clone())
154 }
155
156 /// Re-read the block devices.
157 ///
158 /// A failed read leaves the previous rows on screen and reports the error.
159 /// Blanking a list because one read failed loses the thing the user was
160 /// looking at, and the stale rows are still the best information available.
161 fn refresh(&mut self, log: &mut CommandLog) {
162 match self.backend.list(log) {
163 Ok(volumes) => {
164 self.volumes = volumes;
165 self.error = None;
166 }
167 Err(err) => self.error = Some(err.to_string()),
168 }
169 // Both cursors are resized whichever tab is showing: switching tabs must
170 // not land on a row index that no longer exists.
171 let removable = self
172 .volumes
173 .iter()
174 .filter(|volume| volume.drive.detachable())
175 .count();
176 let all = self.volumes.len();
177 self.removable_cursor.resize(removable);
178 self.all_cursor.resize(all);
179 }
180
181 /// The standard post-action shape: clear the error and re-read on success,
182 /// report and keep the rows on failure.
183 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
184 match result {
185 Ok(()) => {
186 self.error = None;
187 log.quiet(|log| self.refresh(log));
188 }
189 Err(err) => self.error = Some(err.to_string()),
190 }
191 }
192
193 /// Whether udisks is here to act at all. Drives whether the action keys
194 /// render as available.
195 fn can_act(&self) -> bool {
196 self.backend.mount(&mock_probe_volume()).is_some()
197 }
198
199 /// Whether the running system lives on this drive.
200 ///
201 /// The check the whole partitioning surface rests on, and it is deliberately
202 /// drive-wide rather than volume-wide. `SYSTEM_MOUNTS` already refuses to
203 /// unmount `/`, and that is enough for unmounting, where the worst case is
204 /// an error from udisks. It is not enough here: the ESP on the boot disk is
205 /// usually not mounted, `/boot` may not be either, and a spare partition
206 /// beside them is idle by every test the volume-level check applies. Delete
207 /// it and the machine still boots; delete the one next to it and it does
208 /// not. So the refusal attaches to the disk, and one system mount anywhere
209 /// on it takes the whole disk out of reach.
210 ///
211 /// It is a refusal and not a confirmation, per the ruling on this task:
212 /// there is no legitimate use of the console to repartition the disk it is
213 /// running from, so offering it behind a prompt would only be offering a
214 /// way to get it wrong.
215 fn drive_is_system(&self, drive: &Drive) -> bool {
216 self.volumes
217 .iter()
218 .any(|volume| volume.drive.path == drive.path && volume.is_system())
219 }
220
221 /// The blocker for a partition edit, disk check included.
222 fn edit_blocker(&self, volume: &Volume) -> Option<Blocked> {
223 if self.drive_is_system(&volume.drive) {
224 return Some(Blocked::SystemDisk);
225 }
226 volume.edit_blocker()
227 }
228
229 /// The refusal that applies to every partition key on the selected row, or
230 /// `None` if the row can be worked on.
231 ///
232 /// Only absolute refusals count here. A mounted partition still gets its
233 /// keys rendered as available, because pressing one and being told to
234 /// unmount is how a user learns what to do next; a partition on the boot
235 /// disk gets them dimmed with the reason, because there is no next step.
236 /// That is what [`Blocked::absolute`] is for.
237 fn partition_blocker(&self) -> Option<Blocked> {
238 let volume = self.selected()?;
239 self.edit_blocker(&volume).filter(|b| b.absolute())
240 }
241
242 fn refuse(&mut self, volume: &Volume, blocked: Blocked) {
243 self.error = Some(format!("{} {}", volume.path, blocked.reason()));
244 }
245
246 fn mount_selected(&mut self, log: &mut CommandLog) {
247 let Some(volume) = self.selected() else {
248 self.error = Some("nothing selected".to_string());
249 return;
250 };
251 if let Some(blocked) = volume.mount_blocker() {
252 self.error = Some(format!("{} {}", volume.path, blocked.reason()));
253 return;
254 }
255 let Some(invocation) = self.backend.mount(&volume) else {
256 self.error = Some(no_udisks(self.backend.name(), &volume));
257 return;
258 };
259 self.finish(invocation.run(log).map(drop), log);
260 }
261
262 fn unmount_selected(&mut self, log: &mut CommandLog) {
263 let Some(volume) = self.selected() else {
264 self.error = Some("nothing selected".to_string());
265 return;
266 };
267 if let Some(blocked) = volume.unmount_blocker() {
268 self.error = Some(format!("{} {}", volume.path, blocked.reason()));
269 return;
270 }
271 let Some(invocation) = self.backend.unmount(&volume) else {
272 self.error = Some(no_udisks(self.backend.name(), &volume));
273 return;
274 };
275 self.finish(invocation.run(log).map(drop), log);
276 }
277
278 /// Eject the drive under the selection.
279 ///
280 /// Confirms only when something on the drive is still mounted, which is the
281 /// case where a user can lose a write that has not reached the medium. An
282 /// unmounted drive is powered off without a prompt: a confirmation over a
283 /// safe action trains people to press Enter, and then the one that mattered
284 /// gets the same reflex.
285 fn eject_selected(&mut self, log: &mut CommandLog) -> Flow {
286 let Some(volume) = self.selected() else {
287 self.error = Some("nothing selected".to_string());
288 return Flow::Continue;
289 };
290 if self.backend.eject(&volume).is_none() {
291 self.error = Some(no_udisks(self.backend.name(), &volume));
292 return Flow::Continue;
293 }
294
295 let mounted: Vec<&Volume> = self
296 .volumes
297 .iter()
298 .filter(|other| other.drive.path == volume.drive.path && other.mountpoint.is_some())
299 .collect();
300
301 if mounted.is_empty() {
302 let invocation = self.backend.eject(&volume);
303 let result = invocation.expect("checked above").run(log).map(drop);
304 self.finish(result, log);
305 return Flow::Continue;
306 }
307
308 // The message names the fact that decides the answer: which filesystems
309 // are still mounted, not the command that would run.
310 let at: Vec<&str> = mounted.iter().map(|v| v.where_at()).collect();
311 let message = format!(
312 "Power off {}? {} still mounted at {}. Unmount first, or a write that has not reached the medium is lost.",
313 volume.drive.path,
314 if mounted.len() == 1 {
315 "1 filesystem is"
316 } else {
317 "filesystems are"
318 },
319 at.join(", "),
320 );
321 self.pending_action = Some(PendingAction::Eject(volume));
322 Flow::Confirm(Confirm::destructive("eject drive", message))
323 }
324
325 // ---- rendering ----
326
327 /// A row: device, size, filesystem, label or model, and where it is.
328 ///
329 /// Every column is truncated as well as padded. A format width is a minimum,
330 /// and vendors ship model strings long enough to push the mountpoint off the
331 /// pane, which is the column a user checks before pressing `e`.
332 fn row<'a>(theme: &Theme, volume: &'a Volume) -> Line<'a> {
333 let mountpoint = volume.where_at();
334 let mount_span = if volume.mountpoint.is_some() {
335 Span::styled(
336 format!("{:<20}", truncate(mountpoint, 19)),
337 Severity::Healthy.style(theme),
338 )
339 } else {
340 text::muted(theme, format!("{:<20}", truncate(mountpoint, 19)))
341 };
342
343 Line::from(vec![
344 text::bold(theme, format!("{:<15}", truncate(&volume.path, 14))),
345 text::secondary(theme, format!("{:<10}", format_size(volume.size))),
346 text::muted(
347 theme,
348 format!("{:<9}", truncate(volume.fstype_or_dash(), 8)),
349 ),
350 text::primary(theme, format!("{:<20}", truncate(&volume.describe(), 19))),
351 mount_span,
352 ])
353 }
354
355 /// The value-collection step: a prompt, then either a field or a list.
356 fn render_editing(frame: &mut Frame, area: Rect, theme: &Theme, editing: &Editing) {
357 let [prompt, _gap, entry] = Layout::vertical([
358 Constraint::Length(2),
359 Constraint::Length(1),
360 Constraint::Min(1),
361 ])
362 .areas(area);
363
364 frame.render_widget(
365 Paragraph::new(Line::from(text::primary(theme, editing.prompt())))
366 .wrap(Wrap { trim: true }),
367 prompt,
368 );
369
370 match editing {
371 Editing::FormatType { choice, .. } => {
372 let lines: Vec<Line> = FILESYSTEMS
373 .iter()
374 .map(|fstype| Line::from(text::primary(theme, (*fstype).to_string())))
375 .collect();
376 frame.render_widget(
377 AlloyList::new(theme, lines).selected(choice.selected()),
378 entry,
379 );
380 }
381 Editing::CreateSize { field, .. } | Editing::ResizeSize { field, .. } => {
382 // Reversed cell for the caret, and a space standing in past the
383 // end of the value: the same treatment `alloy sync` and the
384 // installer's fields use, so a field looks like a field
385 // wherever it appears.
386 let (before, under, after) = field.split();
387 let spans = vec![
388 text::muted(theme, "> ".to_string()),
389 text::primary(theme, before.to_string()),
390 Span::styled(
391 under.unwrap_or(' ').to_string(),
392 Style::default().add_modifier(Modifier::REVERSED),
393 ),
394 text::primary(theme, after.to_string()),
395 ];
396 frame.render_widget(Line::from(spans), entry);
397 }
398 }
399 }
400
401 /// What an empty list should say, which is never just "nothing here".
402 fn empty_line(&self) -> String {
403 match self.tab() {
404 // The install-drive pointer is here because this is where someone
405 // looking to make one arrives: `disk` is the verb that sounds like
406 // it writes disks, and the module docs promise the empty state says
407 // otherwise rather than leaving them to guess.
408 Tab::Removable => {
409 "no removable drives attached. Plug one in and press r. To write an Alloy \
410 install drive, use alloy image."
411 .to_string()
412 }
413 Tab::All => "no block devices; lsblk reported nothing".to_string(),
414 }
415 }
416 }
417
418 /// A stand-in volume used only to ask a backend whether it can act at all.
419 ///
420 /// The alternative is a `can_act` method on the trait, which every backend would
421 /// have to implement to say the same thing twice: whether `mount` returns
422 /// `Some`. Asking the question the view actually cares about keeps the trait to
423 /// argv-building.
424 pub(super) fn mock_probe_volume() -> Volume {
425 Volume {
426 path: "/dev/null".to_string(),
427 name: "null".to_string(),
428 size: 0,
429 fstype: None,
430 label: None,
431 mountpoint: None,
432 read_only: false,
433 kind: "disk".to_string(),
434 drive: Drive {
435 path: "/dev/null".to_string(),
436 model: None,
437 removable: false,
438 transport: None,
439 },
440 }
441 }
442
443 /// What is on a volume, phrased for a confirmation.
444 ///
445 /// The ruling on this task is that a destructive confirm names the device and
446 /// what is on it: the size, the label and the filesystem, rather than "are you
447 /// sure". This is that sentence, and it is one function so the four
448 /// confirmations cannot drift into describing the same volume differently.
449 ///
450 /// An unlabeled volume with no filesystem still gets a sentence. "5.4 GB, no
451 /// filesystem" is the fact that tells a user they are about to lose nothing,
452 /// and leaving it out would make the emptiest case the least informative one.
453 pub(super) fn describe_loss(volume: &Volume) -> String {
454 let filesystem = match volume.fstype.as_deref() {
455 Some(fstype) => format!("{fstype} filesystem"),
456 None => "no filesystem".to_string(),
457 };
458 match volume.label.as_deref().filter(|l| !l.trim().is_empty()) {
459 Some(label) => format!(
460 "{} holds {}, labelled {label}, and everything on it is lost.",
461 format_size(volume.size),
462 filesystem,
463 ),
464 None => format!(
465 "{} holds {}, unlabelled, and everything on it is lost.",
466 format_size(volume.size),
467 filesystem,
468 ),
469 }
470 }
471
472 /// The refusal when the volume a confirmation was raised about is no longer
473 /// there. Unplugging a stick with the prompt up is the ordinary way to get
474 /// here, and it is a normal thing to have done rather than an error.
475 pub(super) fn gone(volume: &Volume) -> String {
476 format!("{} is no longer attached; nothing was changed", volume.path)
477 }
478
479 /// The refusal when udisks is not answering.
480 ///
481 /// Names the daemon rather than the binary: udisksctl being installed and
482 /// udisksd being down are different problems with different fixes, and this is
483 /// the second one.
484 pub(super) fn no_udisks(backend: &str, volume: &Volume) -> String {
485 format!(
486 "{backend} cannot act on {}: the udisks daemon is not answering (systemctl start udisks2)",
487 volume.path
488 )
489 }
490
491 impl View for DiskView {
492 fn title(&self) -> String {
493 "disk".to_string()
494 }
495
496 fn hints(&self) -> Vec<Hint> {
497 // While a value is being collected every other binding is off, so the
498 // footer says only what works. A footer advertising `d` during a size
499 // prompt is advertising a key that types the letter d.
500 if self.editing.is_some() {
501 return vec![hint("Enter", "confirm"), hint("Esc", "cancel")];
502 }
503 let mut hints = vec![hint("j/k", "select"), hint("h/l", "tab")];
504 if self.can_act() {
505 hints.push(hint("m", "mount"));
506 hints.push(hint("u", "unmount"));
507 hints.push(hint("e", "eject"));
508 // The footer has room for the partition keys only when they would
509 // work. `?` still lists them dimmed with the reason, which is where
510 // a user goes to find out why a key they expected is not offered.
511 if self.partition_blocker().is_none() {
512 hints.push(hint("n/d", "partition"));
513 hints.push(hint("f", "format"));
514 }
515 }
516 hints.push(hint("r", "refresh"));
517 hints
518 }
519
520 fn keys(&self) -> Vec<KeyGroup<'static>> {
521 let acting = self.can_act();
522 let gated = |key: &'static str, label: &'static str| {
523 if acting {
524 binding(key, label)
525 } else {
526 unavailable(key, label, "the udisks daemon is not answering")
527 }
528 };
529 // A partition key is dimmed for two different reasons, and the reason
530 // shown is the one the user can do least about: no daemon first, then
531 // the disk being off limits.
532 let refused = self.partition_blocker();
533 let partition = |key: &'static str, label: &'static str| match (acting, refused) {
534 (false, _) => unavailable(key, label, "the udisks daemon is not answering"),
535 (true, Some(blocked)) => unavailable(key, label, blocked.reason()),
536 (true, None) => binding(key, label),
537 };
538 vec![
539 KeyGroup::new(
540 "this pane",
541 vec![
542 binding("j/k", "select"),
543 binding("h/l", "tab"),
544 gated("m", "mount"),
545 gated("u", "unmount"),
546 gated("e", "eject"),
547 binding("r", "refresh"),
548 ],
549 ),
550 // Its own group, because it is the destructive half and reads as
551 // one: a user scanning the overlay should see where the disk starts
552 // getting rewritten rather than find `d` between `u` and `e`.
553 KeyGroup::new(
554 "partitions",
555 vec![
556 partition("n", "new partition"),
557 partition("d", "delete partition"),
558 partition("f", "format"),
559 partition("z", "resize partition"),
560 ],
561 ),
562 ]
563 }
564
565 fn unanswered(&self) -> &'static [Action] {
566 &[]
567 }
568
569 fn status(&self) -> Option<(Severity, String)> {
570 if let Some(message) = &self.error {
571 return Some((Severity::Error, message.clone()));
572 }
573 // A screen full of rows whose action keys all refuse deserves one line
574 // saying why, rather than making the user press a key to find out.
575 (!self.can_act()).then(|| {
576 (
577 Severity::Warn,
578 "udisks is not answering; this pane is read-only".to_string(),
579 )
580 })
581 }
582
583 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
584 let block = AlloyBlock::new(theme)
585 .focused(true)
586 .build()
587 .title(block_title(&self.title()));
588 let inner = block.inner(area);
589 frame.render_widget(block, area);
590
591 let [bar, _gap, body] = Layout::vertical([
592 Constraint::Length(1),
593 Constraint::Length(1),
594 Constraint::Min(1),
595 ])
596 .areas(inner);
597
598 let labels: Vec<&str> = Tab::ALL.iter().map(|tab| tab.label()).collect();
599 frame.render_widget(
600 AlloyTabs::new(theme, labels).selected(self.tab().slot()),
601 bar,
602 );
603
604 // The edit replaces the list rather than sitting under it. The value
605 // being typed is the only thing that matters at that moment, and a
606 // prompt tucked below twenty rows is a prompt people miss.
607 if let Some(editing) = &self.editing {
608 Self::render_editing(frame, body, theme, editing);
609 return;
610 }
611
612 let rows = self.rows();
613 if rows.is_empty() {
614 frame.render_widget(Line::from(text::muted(theme, self.empty_line())), body);
615 return;
616 }
617
618 let cursor = match self.tab() {
619 Tab::Removable => &self.removable_cursor,
620 Tab::All => &self.all_cursor,
621 };
622 let lines: Vec<Line> = rows.iter().map(|volume| Self::row(theme, volume)).collect();
623 frame.render_widget(
624 AlloyList::new(theme, lines).selected(cursor.selected()),
625 body,
626 );
627 }
628
629 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
630 self.error = None;
631 // The edit takes the whole keyboard while it is open. `j` has to reach
632 // a text field as the letter j, and every action key has to be
633 // unreachable until the value in hand is either committed or dropped.
634 if let Some(flow) = self.handle_editing(key) {
635 return flow;
636 }
637 match key.code {
638 KeyCode::Char('j') | KeyCode::Down => self.cursor().next(),
639 KeyCode::Char('k') | KeyCode::Up => self.cursor().prev(),
640 KeyCode::Char('h') | KeyCode::Left => {
641 let slot = self.tabs.current().saturating_sub(1);
642 self.tabs.focus(slot);
643 }
644 KeyCode::Char('l') | KeyCode::Right => {
645 let slot = (self.tabs.current() + 1).min(Tab::ALL.len() - 1);
646 self.tabs.focus(slot);
647 }
648 KeyCode::Tab => {
649 let slot = (self.tabs.current() + 1) % Tab::ALL.len();
650 self.tabs.focus(slot);
651 }
652 KeyCode::Char('m') => self.mount_selected(log),
653 KeyCode::Char('u') => self.unmount_selected(log),
654 KeyCode::Char('e') => return self.eject_selected(log),
655 KeyCode::Char('n') => self.create_partition(),
656 KeyCode::Char('d') => return self.delete_partition(),
657 KeyCode::Char('f') => self.format_selected(),
658 KeyCode::Char('z') => self.resize_selected(),
659 KeyCode::Char('r') => self.refresh(log),
660 _ => {}
661 }
662 Flow::Continue
663 }
664
665 fn confirmed(&mut self, log: &mut CommandLog) -> Flow {
666 let Some(action) = self.pending_action.take() else {
667 return Flow::Continue;
668 };
669 // Re-read the disks before acting, then re-check the blockers against
670 // what came back. A confirmation is answered by a person, so seconds or
671 // minutes pass, and in that window the volume can have been mounted
672 // from another terminal or unplugged entirely. Checking the copy armed
673 // when the prompt went up would only re-assert what was already known.
674 //
675 // Quietly, because the log should show the command the user asked for
676 // rather than a bookkeeping lsblk in front of it.
677 log.quiet(|log| self.refresh(log));
678 let current = |view: &Self, volume: &Volume| -> Option<Volume> {
679 view.volumes
680 .iter()
681 .find(|other| other.path == volume.path)
682 .cloned()
683 };
684 let (invocation, subject) = match &action {
685 PendingAction::Eject(volume) => (self.backend.eject(volume), volume.clone()),
686 PendingAction::Create { drive, size } => {
687 if self.drive_is_system(drive) {
688 self.error = Some(format!("{} {}", drive.path, Blocked::SystemDisk.reason()));
689 return Flow::Continue;
690 }
691 let probe = mock_probe_volume();
692 (self.backend.create_partition(drive, *size), probe)
693 }
694 PendingAction::Delete(volume) => {
695 let Some(fresh) = current(self, volume) else {
696 self.error = Some(gone(volume));
697 return Flow::Continue;
698 };
699 if let Some(blocked) = self.edit_blocker(&fresh) {
700 self.refuse(&fresh, blocked);
701 return Flow::Continue;
702 }
703 (self.backend.delete_partition(&fresh), fresh)
704 }
705 PendingAction::Format { volume, fstype } => {
706 let Some(fresh) = current(self, volume) else {
707 self.error = Some(gone(volume));
708 return Flow::Continue;
709 };
710 if self.drive_is_system(&fresh.drive) {
711 self.refuse(&fresh, Blocked::SystemDisk);
712 return Flow::Continue;
713 }
714 if let Some(blocked) = fresh.format_blocker() {
715 self.refuse(&fresh, blocked);
716 return Flow::Continue;
717 }
718 (self.backend.format(&fresh, fstype), fresh)
719 }
720 PendingAction::Resize { volume, size } => {
721 let Some(fresh) = current(self, volume) else {
722 self.error = Some(gone(volume));
723 return Flow::Continue;
724 };
725 if let Some(blocked) = self.edit_blocker(&fresh) {
726 self.refuse(&fresh, blocked);
727 return Flow::Continue;
728 }
729 (self.backend.resize_partition(&fresh, *size), fresh)
730 }
731 };
732 let Some(invocation) = invocation else {
733 self.error = Some(no_udisks(self.backend.name(), &subject));
734 return Flow::Continue;
735 };
736 self.finish(invocation.run(log).map(drop), log);
737 Flow::Continue
738 }
739
740 fn cancelled(&mut self) {
741 // Declining must disarm, or the next confirmation acts on a drive the
742 // user already refused.
743 self.pending_action = None;
744 }
745 }
746
747 #[cfg(test)]
748 mod tests;
749