//! The screen: tabs, rows, the value prompts, the confirmations and the keys. //! //! The top of the stack. Names every sibling and is named by none of them, so a //! change to what is drawn cannot reach the parser or the argv. The two rules //! the module docs promise live here rather than in the model because both need //! the whole list: [`DiskView::drive_is_system`] refuses a partition for what a //! sibling on its disk is doing, and [`describe_loss`] is the one sentence the //! four destructive confirmations are built from. use anyhow::Result; use alloy_tui::keys::Action; use alloy_tui::{ AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding, hint, text, unavailable, }; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Wrap}; use super::backend::{Backend, FILESYSTEMS, detect}; use super::model::{Blocked, Drive, Volume}; use crate::cli::CommandLog; use crate::shell::{Confirm, Flow, View, block_title, truncate}; use crate::size::format_size; mod edits; use edits::Editing; /// Which list is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { /// Volumes on drives a person unplugs. The default, because it is what the /// verb exists for. Removable, /// Every volume on the machine, so the tab above is visibly a filter rather /// than the whole truth. All, } impl Tab { const ALL: [Tab; 2] = [Tab::Removable, Tab::All]; const fn label(self) -> &'static str { match self { Self::Removable => "removable", Self::All => "all", } } const fn slot(self) -> usize { match self { Self::Removable => 0, Self::All => 1, } } const fn from_slot(slot: usize) -> Self { match slot { 0 => Self::Removable, _ => Self::All, } } } /// What the pending confirmation would do once answered. pub(super) enum PendingAction { /// Eject a drive that still has something mounted on it. Eject(Volume), /// Add a partition to a drive. Carries the drive rather than the volume the /// cursor was on, because the operation is the table's and not the row's. Create { drive: Drive, size: u64, }, Delete(Volume), Format { volume: Volume, fstype: String, }, Resize { volume: Volume, size: u64, }, } /// The `alloy disk` screen. pub(crate) struct DiskView { backend: Box, volumes: Vec, tabs: FocusRing, /// One cursor per tab. Sharing one across tabs would move the selection in a /// list the user is not looking at, and the two lists are different lengths. removable_cursor: Cursor, all_cursor: Cursor, error: Option, pending_action: Option, /// The value being collected before a partition action can be confirmed. editing: Option, } impl DiskView { pub(crate) fn new(tab: Tab, log: &mut CommandLog) -> Self { let mut tabs = FocusRing::new(Tab::ALL.len()); tabs.focus(tab.slot()); let mut view = Self { backend: detect(), volumes: Vec::new(), tabs, removable_cursor: Cursor::new(), all_cursor: Cursor::new(), error: None, pending_action: None, editing: None, }; view.refresh(log); view } fn tab(&self) -> Tab { Tab::from_slot(self.tabs.current()) } /// The rows on the current tab. fn rows(&self) -> Vec<&Volume> { match self.tab() { Tab::Removable => self .volumes .iter() .filter(|volume| volume.drive.detachable()) .collect(), Tab::All => self.volumes.iter().collect(), } } fn cursor(&mut self) -> &mut Cursor { match self.tab() { Tab::Removable => &mut self.removable_cursor, Tab::All => &mut self.all_cursor, } } fn selected(&self) -> Option { let cursor = match self.tab() { Tab::Removable => &self.removable_cursor, Tab::All => &self.all_cursor, }; self.rows().get(cursor.selected()?).map(|v| (*v).clone()) } /// Re-read the block devices. /// /// A failed read leaves the previous rows on screen and reports the error. /// Blanking a list because one read failed loses the thing the user was /// looking at, and the stale rows are still the best information available. fn refresh(&mut self, log: &mut CommandLog) { match self.backend.list(log) { Ok(volumes) => { self.volumes = volumes; self.error = None; } Err(err) => self.error = Some(err.to_string()), } // Both cursors are resized whichever tab is showing: switching tabs must // not land on a row index that no longer exists. let removable = self .volumes .iter() .filter(|volume| volume.drive.detachable()) .count(); let all = self.volumes.len(); self.removable_cursor.resize(removable); self.all_cursor.resize(all); } /// The standard post-action shape: clear the error and re-read on success, /// report and keep the rows on failure. fn finish(&mut self, result: Result<()>, log: &mut CommandLog) { match result { Ok(()) => { self.error = None; log.quiet(|log| self.refresh(log)); } Err(err) => self.error = Some(err.to_string()), } } /// Whether udisks is here to act at all. Drives whether the action keys /// render as available. fn can_act(&self) -> bool { self.backend.mount(&mock_probe_volume()).is_some() } /// Whether the running system lives on this drive. /// /// The check the whole partitioning surface rests on, and it is deliberately /// drive-wide rather than volume-wide. `SYSTEM_MOUNTS` already refuses to /// unmount `/`, and that is enough for unmounting, where the worst case is /// an error from udisks. It is not enough here: the ESP on the boot disk is /// usually not mounted, `/boot` may not be either, and a spare partition /// beside them is idle by every test the volume-level check applies. Delete /// it and the machine still boots; delete the one next to it and it does /// not. So the refusal attaches to the disk, and one system mount anywhere /// on it takes the whole disk out of reach. /// /// It is a refusal and not a confirmation, per the ruling on this task: /// there is no legitimate use of the console to repartition the disk it is /// running from, so offering it behind a prompt would only be offering a /// way to get it wrong. fn drive_is_system(&self, drive: &Drive) -> bool { self.volumes .iter() .any(|volume| volume.drive.path == drive.path && volume.is_system()) } /// The blocker for a partition edit, disk check included. fn edit_blocker(&self, volume: &Volume) -> Option { if self.drive_is_system(&volume.drive) { return Some(Blocked::SystemDisk); } volume.edit_blocker() } /// The refusal that applies to every partition key on the selected row, or /// `None` if the row can be worked on. /// /// Only absolute refusals count here. A mounted partition still gets its /// keys rendered as available, because pressing one and being told to /// unmount is how a user learns what to do next; a partition on the boot /// disk gets them dimmed with the reason, because there is no next step. /// That is what [`Blocked::absolute`] is for. fn partition_blocker(&self) -> Option { let volume = self.selected()?; self.edit_blocker(&volume).filter(|b| b.absolute()) } fn refuse(&mut self, volume: &Volume, blocked: Blocked) { self.error = Some(format!("{} {}", volume.path, blocked.reason())); } fn mount_selected(&mut self, log: &mut CommandLog) { let Some(volume) = self.selected() else { self.error = Some("nothing selected".to_string()); return; }; if let Some(blocked) = volume.mount_blocker() { self.error = Some(format!("{} {}", volume.path, blocked.reason())); return; } let Some(invocation) = self.backend.mount(&volume) else { self.error = Some(no_udisks(self.backend.name(), &volume)); return; }; self.finish(invocation.run(log).map(drop), log); } fn unmount_selected(&mut self, log: &mut CommandLog) { let Some(volume) = self.selected() else { self.error = Some("nothing selected".to_string()); return; }; if let Some(blocked) = volume.unmount_blocker() { self.error = Some(format!("{} {}", volume.path, blocked.reason())); return; } let Some(invocation) = self.backend.unmount(&volume) else { self.error = Some(no_udisks(self.backend.name(), &volume)); return; }; self.finish(invocation.run(log).map(drop), log); } /// Eject the drive under the selection. /// /// Confirms only when something on the drive is still mounted, which is the /// case where a user can lose a write that has not reached the medium. An /// unmounted drive is powered off without a prompt: a confirmation over a /// safe action trains people to press Enter, and then the one that mattered /// gets the same reflex. fn eject_selected(&mut self, log: &mut CommandLog) -> Flow { let Some(volume) = self.selected() else { self.error = Some("nothing selected".to_string()); return Flow::Continue; }; if self.backend.eject(&volume).is_none() { self.error = Some(no_udisks(self.backend.name(), &volume)); return Flow::Continue; } let mounted: Vec<&Volume> = self .volumes .iter() .filter(|other| other.drive.path == volume.drive.path && other.mountpoint.is_some()) .collect(); if mounted.is_empty() { let invocation = self.backend.eject(&volume); let result = invocation.expect("checked above").run(log).map(drop); self.finish(result, log); return Flow::Continue; } // The message names the fact that decides the answer: which filesystems // are still mounted, not the command that would run. let at: Vec<&str> = mounted.iter().map(|v| v.where_at()).collect(); let message = format!( "Power off {}? {} still mounted at {}. Unmount first, or a write that has not reached the medium is lost.", volume.drive.path, if mounted.len() == 1 { "1 filesystem is" } else { "filesystems are" }, at.join(", "), ); self.pending_action = Some(PendingAction::Eject(volume)); Flow::Confirm(Confirm::destructive("eject drive", message)) } // ---- rendering ---- /// A row: device, size, filesystem, label or model, and where it is. /// /// Every column is truncated as well as padded. A format width is a minimum, /// and vendors ship model strings long enough to push the mountpoint off the /// pane, which is the column a user checks before pressing `e`. fn row<'a>(theme: &Theme, volume: &'a Volume) -> Line<'a> { let mountpoint = volume.where_at(); let mount_span = if volume.mountpoint.is_some() { Span::styled( format!("{:<20}", truncate(mountpoint, 19)), Severity::Healthy.style(theme), ) } else { text::muted(theme, format!("{:<20}", truncate(mountpoint, 19))) }; Line::from(vec![ text::bold(theme, format!("{:<15}", truncate(&volume.path, 14))), text::secondary(theme, format!("{:<10}", format_size(volume.size))), text::muted( theme, format!("{:<9}", truncate(volume.fstype_or_dash(), 8)), ), text::primary(theme, format!("{:<20}", truncate(&volume.describe(), 19))), mount_span, ]) } /// The value-collection step: a prompt, then either a field or a list. fn render_editing(frame: &mut Frame, area: Rect, theme: &Theme, editing: &Editing) { let [prompt, _gap, entry] = Layout::vertical([ Constraint::Length(2), Constraint::Length(1), Constraint::Min(1), ]) .areas(area); frame.render_widget( Paragraph::new(Line::from(text::primary(theme, editing.prompt()))) .wrap(Wrap { trim: true }), prompt, ); match editing { Editing::FormatType { choice, .. } => { let lines: Vec = FILESYSTEMS .iter() .map(|fstype| Line::from(text::primary(theme, (*fstype).to_string()))) .collect(); frame.render_widget( AlloyList::new(theme, lines).selected(choice.selected()), entry, ); } Editing::CreateSize { field, .. } | Editing::ResizeSize { field, .. } => { // Reversed cell for the caret, and a space standing in past the // end of the value: the same treatment `alloy sync` and the // installer's fields use, so a field looks like a field // wherever it appears. let (before, under, after) = field.split(); let spans = vec![ text::muted(theme, "> ".to_string()), text::primary(theme, before.to_string()), Span::styled( under.unwrap_or(' ').to_string(), Style::default().add_modifier(Modifier::REVERSED), ), text::primary(theme, after.to_string()), ]; frame.render_widget(Line::from(spans), entry); } } } /// What an empty list should say, which is never just "nothing here". fn empty_line(&self) -> String { match self.tab() { // The install-drive pointer is here because this is where someone // looking to make one arrives: `disk` is the verb that sounds like // it writes disks, and the module docs promise the empty state says // otherwise rather than leaving them to guess. Tab::Removable => { "no removable drives attached. Plug one in and press r. To write an Alloy \ install drive, use alloy image." .to_string() } Tab::All => "no block devices; lsblk reported nothing".to_string(), } } } /// A stand-in volume used only to ask a backend whether it can act at all. /// /// The alternative is a `can_act` method on the trait, which every backend would /// have to implement to say the same thing twice: whether `mount` returns /// `Some`. Asking the question the view actually cares about keeps the trait to /// argv-building. pub(super) fn mock_probe_volume() -> Volume { Volume { path: "/dev/null".to_string(), name: "null".to_string(), size: 0, fstype: None, label: None, mountpoint: None, read_only: false, kind: "disk".to_string(), drive: Drive { path: "/dev/null".to_string(), model: None, removable: false, transport: None, }, } } /// What is on a volume, phrased for a confirmation. /// /// The ruling on this task is that a destructive confirm names the device and /// what is on it: the size, the label and the filesystem, rather than "are you /// sure". This is that sentence, and it is one function so the four /// confirmations cannot drift into describing the same volume differently. /// /// An unlabeled volume with no filesystem still gets a sentence. "5.4 GB, no /// filesystem" is the fact that tells a user they are about to lose nothing, /// and leaving it out would make the emptiest case the least informative one. pub(super) fn describe_loss(volume: &Volume) -> String { let filesystem = match volume.fstype.as_deref() { Some(fstype) => format!("{fstype} filesystem"), None => "no filesystem".to_string(), }; match volume.label.as_deref().filter(|l| !l.trim().is_empty()) { Some(label) => format!( "{} holds {}, labelled {label}, and everything on it is lost.", format_size(volume.size), filesystem, ), None => format!( "{} holds {}, unlabelled, and everything on it is lost.", format_size(volume.size), filesystem, ), } } /// The refusal when the volume a confirmation was raised about is no longer /// there. Unplugging a stick with the prompt up is the ordinary way to get /// here, and it is a normal thing to have done rather than an error. pub(super) fn gone(volume: &Volume) -> String { format!("{} is no longer attached; nothing was changed", volume.path) } /// The refusal when udisks is not answering. /// /// Names the daemon rather than the binary: udisksctl being installed and /// udisksd being down are different problems with different fixes, and this is /// the second one. pub(super) fn no_udisks(backend: &str, volume: &Volume) -> String { format!( "{backend} cannot act on {}: the udisks daemon is not answering (systemctl start udisks2)", volume.path ) } impl View for DiskView { fn title(&self) -> String { "disk".to_string() } fn hints(&self) -> Vec { // While a value is being collected every other binding is off, so the // footer says only what works. A footer advertising `d` during a size // prompt is advertising a key that types the letter d. if self.editing.is_some() { return vec![hint("Enter", "confirm"), hint("Esc", "cancel")]; } let mut hints = vec![hint("j/k", "select"), hint("h/l", "tab")]; if self.can_act() { hints.push(hint("m", "mount")); hints.push(hint("u", "unmount")); hints.push(hint("e", "eject")); // The footer has room for the partition keys only when they would // work. `?` still lists them dimmed with the reason, which is where // a user goes to find out why a key they expected is not offered. if self.partition_blocker().is_none() { hints.push(hint("n/d", "partition")); hints.push(hint("f", "format")); } } hints.push(hint("r", "refresh")); hints } fn keys(&self) -> Vec> { let acting = self.can_act(); let gated = |key: &'static str, label: &'static str| { if acting { binding(key, label) } else { unavailable(key, label, "the udisks daemon is not answering") } }; // A partition key is dimmed for two different reasons, and the reason // shown is the one the user can do least about: no daemon first, then // the disk being off limits. let refused = self.partition_blocker(); let partition = |key: &'static str, label: &'static str| match (acting, refused) { (false, _) => unavailable(key, label, "the udisks daemon is not answering"), (true, Some(blocked)) => unavailable(key, label, blocked.reason()), (true, None) => binding(key, label), }; vec![ KeyGroup::new( "this pane", vec![ binding("j/k", "select"), binding("h/l", "tab"), gated("m", "mount"), gated("u", "unmount"), gated("e", "eject"), binding("r", "refresh"), ], ), // Its own group, because it is the destructive half and reads as // one: a user scanning the overlay should see where the disk starts // getting rewritten rather than find `d` between `u` and `e`. KeyGroup::new( "partitions", vec![ partition("n", "new partition"), partition("d", "delete partition"), partition("f", "format"), partition("z", "resize partition"), ], ), ] } fn unanswered(&self) -> &'static [Action] { &[] } fn status(&self) -> Option<(Severity, String)> { if let Some(message) = &self.error { return Some((Severity::Error, message.clone())); } // A screen full of rows whose action keys all refuse deserves one line // saying why, rather than making the user press a key to find out. (!self.can_act()).then(|| { ( Severity::Warn, "udisks is not answering; this pane is read-only".to_string(), ) }) } fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let block = AlloyBlock::new(theme) .focused(true) .build() .title(block_title(&self.title())); let inner = block.inner(area); frame.render_widget(block, area); let [bar, _gap, body] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), ]) .areas(inner); let labels: Vec<&str> = Tab::ALL.iter().map(|tab| tab.label()).collect(); frame.render_widget( AlloyTabs::new(theme, labels).selected(self.tab().slot()), bar, ); // The edit replaces the list rather than sitting under it. The value // being typed is the only thing that matters at that moment, and a // prompt tucked below twenty rows is a prompt people miss. if let Some(editing) = &self.editing { Self::render_editing(frame, body, theme, editing); return; } let rows = self.rows(); if rows.is_empty() { frame.render_widget(Line::from(text::muted(theme, self.empty_line())), body); return; } let cursor = match self.tab() { Tab::Removable => &self.removable_cursor, Tab::All => &self.all_cursor, }; let lines: Vec = rows.iter().map(|volume| Self::row(theme, volume)).collect(); frame.render_widget( AlloyList::new(theme, lines).selected(cursor.selected()), body, ); } fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { self.error = None; // The edit takes the whole keyboard while it is open. `j` has to reach // a text field as the letter j, and every action key has to be // unreachable until the value in hand is either committed or dropped. if let Some(flow) = self.handle_editing(key) { return flow; } match key.code { KeyCode::Char('j') | KeyCode::Down => self.cursor().next(), KeyCode::Char('k') | KeyCode::Up => self.cursor().prev(), KeyCode::Char('h') | KeyCode::Left => { let slot = self.tabs.current().saturating_sub(1); self.tabs.focus(slot); } KeyCode::Char('l') | KeyCode::Right => { let slot = (self.tabs.current() + 1).min(Tab::ALL.len() - 1); self.tabs.focus(slot); } KeyCode::Tab => { let slot = (self.tabs.current() + 1) % Tab::ALL.len(); self.tabs.focus(slot); } KeyCode::Char('m') => self.mount_selected(log), KeyCode::Char('u') => self.unmount_selected(log), KeyCode::Char('e') => return self.eject_selected(log), KeyCode::Char('n') => self.create_partition(), KeyCode::Char('d') => return self.delete_partition(), KeyCode::Char('f') => self.format_selected(), KeyCode::Char('z') => self.resize_selected(), KeyCode::Char('r') => self.refresh(log), _ => {} } Flow::Continue } fn confirmed(&mut self, log: &mut CommandLog) -> Flow { let Some(action) = self.pending_action.take() else { return Flow::Continue; }; // Re-read the disks before acting, then re-check the blockers against // what came back. A confirmation is answered by a person, so seconds or // minutes pass, and in that window the volume can have been mounted // from another terminal or unplugged entirely. Checking the copy armed // when the prompt went up would only re-assert what was already known. // // Quietly, because the log should show the command the user asked for // rather than a bookkeeping lsblk in front of it. log.quiet(|log| self.refresh(log)); let current = |view: &Self, volume: &Volume| -> Option { view.volumes .iter() .find(|other| other.path == volume.path) .cloned() }; let (invocation, subject) = match &action { PendingAction::Eject(volume) => (self.backend.eject(volume), volume.clone()), PendingAction::Create { drive, size } => { if self.drive_is_system(drive) { self.error = Some(format!("{} {}", drive.path, Blocked::SystemDisk.reason())); return Flow::Continue; } let probe = mock_probe_volume(); (self.backend.create_partition(drive, *size), probe) } PendingAction::Delete(volume) => { let Some(fresh) = current(self, volume) else { self.error = Some(gone(volume)); return Flow::Continue; }; if let Some(blocked) = self.edit_blocker(&fresh) { self.refuse(&fresh, blocked); return Flow::Continue; } (self.backend.delete_partition(&fresh), fresh) } PendingAction::Format { volume, fstype } => { let Some(fresh) = current(self, volume) else { self.error = Some(gone(volume)); return Flow::Continue; }; if self.drive_is_system(&fresh.drive) { self.refuse(&fresh, Blocked::SystemDisk); return Flow::Continue; } if let Some(blocked) = fresh.format_blocker() { self.refuse(&fresh, blocked); return Flow::Continue; } (self.backend.format(&fresh, fstype), fresh) } PendingAction::Resize { volume, size } => { let Some(fresh) = current(self, volume) else { self.error = Some(gone(volume)); return Flow::Continue; }; if let Some(blocked) = self.edit_blocker(&fresh) { self.refuse(&fresh, blocked); return Flow::Continue; } (self.backend.resize_partition(&fresh, *size), fresh) } }; let Some(invocation) = invocation else { self.error = Some(no_udisks(self.backend.name(), &subject)); return Flow::Continue; }; self.finish(invocation.run(log).map(drop), log); Flow::Continue } fn cancelled(&mut self) { // Declining must disarm, or the next confirmation acts on a drive the // user already refused. self.pending_action = None; } } #[cfg(test)] mod tests;