max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 files changed,
+507 insertions,
-0 deletions
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | ||
| 9 | 9 | mod audio; | |
| 10 | 10 | mod cli; | |
| 11 | + | mod install; | |
| 11 | 12 | mod mesh; | |
| 12 | 13 | mod net; | |
| 13 | 14 | mod pkg; | |
| @@ -56,6 +57,8 @@ | |||
| 56 | 57 | // exactly what the system tab shows. | |
| 57 | 58 | /// System image: what is booted, what is staged, and rollback | |
| 58 | 59 | Update, | |
| 60 | + | /// Install Alloy to a disk | |
| 61 | + | Install, | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | /// Which surface of the package view to open on. | |
| @@ -103,5 +106,9 @@ | |||
| 103 | 106 | let mut view = pkg::PkgView::new(pkg::Tab::System, &mut log); | |
| 104 | 107 | shell::run(&theme, &mut view, &mut log) | |
| 105 | 108 | } | |
| 109 | + | Command::Install => { | |
| 110 | + | let mut view = install::InstallView::new(&mut log); | |
| 111 | + | shell::run(&theme, &mut view, &mut log) | |
| 112 | + | } | |
| 106 | 113 | } | |
| 107 | 114 | } |
| @@ -1,0 +1,784 @@ | |||
| 1 | + | //! `alloy install` — the installer, as a wizard over `bootc install to-disk`. | |
| 2 | + | //! | |
| 3 | + | //! bootc already does the real work: partitioning, the ostree deploy, and the | |
| 4 | + | //! bootloader via bootupd. So this is a sequence of questions ending in one | |
| 5 | + | //! command, not a reimplementation of Anaconda. See docs/CONSOLE.md and the | |
| 6 | + | //! wiki note `alloy-console`. | |
| 7 | + | //! | |
| 8 | + | //! The disk step is the first of them and the one with the consequences, so it | |
| 9 | + | //! lands first. Everything a later step needs — text fields, the streaming run | |
| 10 | + | //! screen — is absent here on purpose. | |
| 11 | + | //! | |
| 12 | + | //! <!-- wiki: alloy-console --> | |
| 13 | + | ||
| 14 | + | use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text}; | |
| 15 | + | use anyhow::{Context, Result}; | |
| 16 | + | use ratatui::Frame; | |
| 17 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 18 | + | use ratatui::layout::Rect; | |
| 19 | + | use ratatui::text::{Line, Span}; | |
| 20 | + | use serde::Deserialize; | |
| 21 | + | ||
| 22 | + | use alloy_tui::Cursor; | |
| 23 | + | ||
| 24 | + | use crate::cli::{CommandLog, Invocation}; | |
| 25 | + | use crate::shell::{Flow, View, block_title}; | |
| 26 | + | use crate::wizard::Steps; | |
| 27 | + | ||
| 28 | + | /// How many questions the wizard asks. | |
| 29 | + | /// | |
| 30 | + | /// One while only the disk step exists. Each later step raises this as it | |
| 31 | + | /// lands, which is what keeps [`Steps::is_last`] honest about where Enter runs | |
| 32 | + | /// the install rather than advancing into a screen that is not written yet. | |
| 33 | + | const STEP_COUNT: usize = 1; | |
| 34 | + | ||
| 35 | + | // ---- what the installer has been told ---- | |
| 36 | + | ||
| 37 | + | /// The answers collected so far. | |
| 38 | + | /// | |
| 39 | + | /// Separate from the view's own state (cursor position, error text) because | |
| 40 | + | /// this is what the final `bootc install to-disk` invocation is built from, | |
| 41 | + | /// and what the summary step reads back. Nothing else survives to the end. | |
| 42 | + | #[derive(Debug, Default)] | |
| 43 | + | pub struct Answers { | |
| 44 | + | /// Device path of the install target, e.g. `/dev/nvme0n1`. | |
| 45 | + | pub disk: Option<String>, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ---- disks ---- | |
| 49 | + | ||
| 50 | + | /// A whole disk, as an install target. | |
| 51 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 52 | + | pub struct Disk { | |
| 53 | + | /// Device path, which is what `bootc install to-disk` takes. | |
| 54 | + | pub path: String, | |
| 55 | + | pub name: String, | |
| 56 | + | /// Bytes. Formatted for display by [`format_size`]. | |
| 57 | + | pub size: u64, | |
| 58 | + | pub model: Option<String>, | |
| 59 | + | pub removable: bool, | |
| 60 | + | /// `nvme`, `usb`, `sata`. `None` for disks lsblk cannot attribute. | |
| 61 | + | pub transport: Option<String>, | |
| 62 | + | pub read_only: bool, | |
| 63 | + | /// Every mountpoint anywhere beneath this disk, at any depth. | |
| 64 | + | pub mountpoints: Vec<String>, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Why a disk cannot be installed to. | |
| 68 | + | /// | |
| 69 | + | /// Blocked disks are listed rather than hidden. A user whose disk is simply | |
| 70 | + | /// missing from the list has no way to tell a filter from a hardware fault, | |
| 71 | + | /// and "your install medium is not a target" is a thing worth saying once | |
| 72 | + | /// rather than a row silently absent. | |
| 73 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 74 | + | pub enum Blocked { | |
| 75 | + | /// Something on it is mounted, so it is the running system or the medium | |
| 76 | + | /// booted from. In a live install both of those are exactly the disk a | |
| 77 | + | /// user must not overwrite. | |
| 78 | + | InUse, | |
| 79 | + | ReadOnly, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl Blocked { | |
| 83 | + | pub const fn label(self) -> &'static str { | |
| 84 | + | match self { | |
| 85 | + | Self::InUse => "in use", | |
| 86 | + | Self::ReadOnly => "read-only", | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// The refusal shown when the user tries to pick it anyway. | |
| 91 | + | /// | |
| 92 | + | /// Phrased to follow the device path, so the whole message reads as one | |
| 93 | + | /// sentence: "/dev/nvme0n1 is mounted; it holds the running system". | |
| 94 | + | pub const fn reason(self) -> &'static str { | |
| 95 | + | match self { | |
| 96 | + | Self::InUse => "is mounted; it holds the running system or the install medium", | |
| 97 | + | Self::ReadOnly => "is read-only", | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | impl Disk { | |
| 103 | + | /// Why this disk cannot be a target, or `None` if it can. | |
| 104 | + | /// | |
| 105 | + | /// Read-only is reported ahead of in-use so a write-protected medium says | |
| 106 | + | /// so, rather than blaming the mount it also has. | |
| 107 | + | pub fn blocker(&self) -> Option<Blocked> { | |
| 108 | + | if self.read_only { | |
| 109 | + | Some(Blocked::ReadOnly) | |
| 110 | + | } else if !self.mountpoints.is_empty() { | |
| 111 | + | Some(Blocked::InUse) | |
| 112 | + | } else { | |
| 113 | + | None | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// How the disk is attached, for the row. Removable is worth surfacing on | |
| 118 | + | /// its own: on a live install the USB stick and the target look alike | |
| 119 | + | /// until one of them says "usb, removable". | |
| 120 | + | fn attachment(&self) -> String { | |
| 121 | + | match (self.transport.as_deref(), self.removable) { | |
| 122 | + | (Some(transport), true) => format!("{transport}, removable"), | |
| 123 | + | (Some(transport), false) => transport.to_string(), | |
| 124 | + | (None, true) => "removable".into(), | |
| 125 | + | (None, false) => String::new(), | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Decimal units, deliberately. | |
| 131 | + | /// | |
| 132 | + | /// A 2TB drive is 2_000_398_934_016 bytes, which is "2.0 TB" decimal and | |
| 133 | + | /// "1.8 TiB" binary. The number printed on the drive and repeated in its own | |
| 134 | + | /// model string is the decimal one, and a user matching the list against the | |
| 135 | + | /// label in their hand is the whole point of showing a size. | |
| 136 | + | const UNITS: [(&str, u64); 4] = [ | |
| 137 | + | ("TB", 1_000_000_000_000), | |
| 138 | + | ("GB", 1_000_000_000), | |
| 139 | + | ("MB", 1_000_000), | |
| 140 | + | ("kB", 1_000), | |
| 141 | + | ]; | |
| 142 | + | ||
| 143 | + | pub fn format_size(bytes: u64) -> String { | |
| 144 | + | for (unit, scale) in UNITS { | |
| 145 | + | if bytes >= scale { | |
| 146 | + | return format!("{:.1} {unit}", bytes as f64 / scale as f64); | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | format!("{bytes} B") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // ---- backend ---- | |
| 153 | + | ||
| 154 | + | /// A source of disks. | |
| 155 | + | pub trait Backend { | |
| 156 | + | fn name(&self) -> &'static str; | |
| 157 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Disk>>; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Pick a backend: the real one when `lsblk` answers, the mock otherwise. | |
| 161 | + | /// | |
| 162 | + | /// A `--version` probe rather than a `which` check, matching `net` and `mesh`. | |
| 163 | + | pub fn detect() -> Box<dyn Backend> { | |
| 164 | + | if Invocation::new("lsblk").arg("--version").probe() { | |
| 165 | + | Box::new(LsBlk) | |
| 166 | + | } else { | |
| 167 | + | Box::new(Mock) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | pub struct LsBlk; | |
| 172 | + | ||
| 173 | + | impl LsBlk { | |
| 174 | + | /// `-b` for bytes, so the size arrives as a number to format rather than a | |
| 175 | + | /// string lsblk already rounded. The column list is explicit because | |
| 176 | + | /// lsblk's default set does not include `tran` or `path`, and `path` is | |
| 177 | + | /// what `bootc install to-disk` is handed. | |
| 178 | + | fn invocation() -> Invocation { | |
| 179 | + | Invocation::new("lsblk").args([ | |
| 180 | + | "-J", | |
| 181 | + | "-b", | |
| 182 | + | "-o", | |
| 183 | + | "NAME,PATH,SIZE,MODEL,TYPE,RM,RO,TRAN,MOUNTPOINTS", | |
| 184 | + | ]) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | impl Backend for LsBlk { | |
| 189 | + | fn name(&self) -> &'static str { | |
| 190 | + | "lsblk" | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Disk>> { | |
| 194 | + | parse_disks(&Self::invocation().run(log)?) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Fixed sample disks, for machines without lsblk. | |
| 199 | + | pub struct Mock; | |
| 200 | + | ||
| 201 | + | impl Backend for Mock { | |
| 202 | + | fn name(&self) -> &'static str { | |
| 203 | + | "mock" | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Disk>> { | |
| 207 | + | log.record("# no lsblk; showing mock disks", Severity::Warn); | |
| 208 | + | Ok(vec![ | |
| 209 | + | Disk { | |
| 210 | + | path: "/dev/nvme0n1".into(), | |
| 211 | + | name: "nvme0n1".into(), | |
| 212 | + | size: 512_110_190_592, | |
| 213 | + | model: Some("SAMSUNG MZVLB512HBJQ".into()), | |
| 214 | + | removable: false, | |
| 215 | + | transport: Some("nvme".into()), | |
| 216 | + | read_only: false, | |
| 217 | + | mountpoints: Vec::new(), | |
| 218 | + | }, | |
| 219 | + | Disk { | |
| 220 | + | path: "/dev/sda".into(), | |
| 221 | + | name: "sda".into(), | |
| 222 | + | size: 30_752_440_320, | |
| 223 | + | model: Some("Alloy Install Medium".into()), | |
| 224 | + | removable: true, | |
| 225 | + | transport: Some("usb".into()), | |
| 226 | + | read_only: false, | |
| 227 | + | mountpoints: vec!["/run/initramfs/live".into()], | |
| 228 | + | }, | |
| 229 | + | ]) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // ---- lsblk JSON ---- | |
| 234 | + | ||
| 235 | + | /// Assumes util-linux 2.33 or newer, which emits JSON scalars as numbers and | |
| 236 | + | /// booleans. Older lsblk quoted everything, so a size arrived as `"512110190592"` | |
| 237 | + | /// and `rm` as `"0"`. Fedora 43 ships 2.41; a `--version` probe would not tell | |
| 238 | + | /// the two apart anyway, so this surfaces as a parse error in the view rather | |
| 239 | + | /// than being guarded against. | |
| 240 | + | #[derive(Deserialize)] | |
| 241 | + | struct LsBlkOutput { | |
| 242 | + | blockdevices: Vec<LsBlkDevice>, | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[derive(Deserialize)] | |
| 246 | + | struct LsBlkDevice { | |
| 247 | + | name: String, | |
| 248 | + | path: String, | |
| 249 | + | size: u64, | |
| 250 | + | #[serde(default)] | |
| 251 | + | model: Option<String>, | |
| 252 | + | /// `disk`, `part`, `crypt`, `lvm`, `rom`. Named `kind` because `type` is a | |
| 253 | + | /// keyword. | |
| 254 | + | #[serde(rename = "type")] | |
| 255 | + | kind: String, | |
| 256 | + | #[serde(default)] | |
| 257 | + | rm: bool, | |
| 258 | + | #[serde(default)] | |
| 259 | + | ro: bool, | |
| 260 | + | #[serde(default)] | |
| 261 | + | tran: Option<String>, | |
| 262 | + | /// An unmounted device reports `[null]` rather than `[]`, so the nulls are | |
| 263 | + | /// filtered rather than assumed away. | |
| 264 | + | #[serde(default)] | |
| 265 | + | mountpoints: Vec<Option<String>>, | |
| 266 | + | #[serde(default)] | |
| 267 | + | children: Vec<LsBlkDevice>, | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// Device-name prefixes that are `type: "disk"` without being a disk. | |
| 271 | + | /// | |
| 272 | + | /// `zram0` is the case that forced this: compressed RAM used for swap, typed | |
| 273 | + | /// `disk` by lsblk, and offering it as an install target would be absurd. | |
| 274 | + | /// `loop` covers the squashfs a live ISO mounts itself from, `dm-` the | |
| 275 | + | /// device-mapper nodes behind LUKS and LVM, and `ram` the legacy ramdisks. | |
| 276 | + | const VIRTUAL_PREFIXES: [&str; 4] = ["zram", "loop", "ram", "dm-"]; | |
| 277 | + | ||
| 278 | + | fn is_virtual(name: &str) -> bool { | |
| 279 | + | VIRTUAL_PREFIXES | |
| 280 | + | .iter() | |
| 281 | + | .any(|prefix| name.starts_with(prefix)) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// Collect every mountpoint at or beneath `device`. | |
| 285 | + | /// | |
| 286 | + | /// Recursive rather than a walk of direct children, which is not a | |
| 287 | + | /// generalization for its own sake: a LUKS volume sits at | |
| 288 | + | /// `disk > part > crypt`, two levels down, and on the box this was written | |
| 289 | + | /// against that is where the mounted swap lives. Checking one level reports | |
| 290 | + | /// such a disk as free while a filesystem on it is mounted, which is the exact | |
| 291 | + | /// mistake that ends with a wipe the user did not agree to. | |
| 292 | + | fn mountpoints_of(device: &LsBlkDevice, out: &mut Vec<String>) { | |
| 293 | + | out.extend(device.mountpoints.iter().flatten().cloned()); | |
| 294 | + | for child in &device.children { | |
| 295 | + | mountpoints_of(child, out); | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Whole disks that could plausibly be install targets, in lsblk's order. | |
| 300 | + | /// | |
| 301 | + | /// Two different exclusions, deliberately kept apart. Partitions and virtual | |
| 302 | + | /// devices are dropped outright, because they are not disks and a row saying | |
| 303 | + | /// so would be noise. Real disks that are mounted or read-only are *kept* and | |
| 304 | + | /// marked, because their absence is the confusing case: see [`Blocked`]. | |
| 305 | + | fn parse_disks(raw: &str) -> Result<Vec<Disk>> { | |
| 306 | + | let parsed: LsBlkOutput = serde_json::from_str(raw).context("lsblk emitted invalid JSON")?; | |
| 307 | + | ||
| 308 | + | Ok(parsed | |
| 309 | + | .blockdevices | |
| 310 | + | .into_iter() | |
| 311 | + | .filter(|device| device.kind == "disk" && !is_virtual(&device.name)) | |
| 312 | + | .map(|device| { | |
| 313 | + | let mut mountpoints = Vec::new(); | |
| 314 | + | mountpoints_of(&device, &mut mountpoints); | |
| 315 | + | Disk { | |
| 316 | + | path: device.path, | |
| 317 | + | name: device.name, | |
| 318 | + | size: device.size, | |
| 319 | + | // lsblk emits `null` for a disk with no model string, and an | |
| 320 | + | // empty one for some USB bridges. Both mean "unknown", so | |
| 321 | + | // neither should render as a blank column with a stray gap. | |
| 322 | + | model: device.model.filter(|model| !model.trim().is_empty()), | |
| 323 | + | removable: device.rm, | |
| 324 | + | transport: device.tran.filter(|tran| !tran.trim().is_empty()), | |
| 325 | + | read_only: device.ro, | |
| 326 | + | mountpoints, | |
| 327 | + | } | |
| 328 | + | }) | |
| 329 | + | .collect()) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // ---- the view ---- | |
| 333 | + | ||
| 334 | + | /// The `alloy install` screen. | |
| 335 | + | pub struct InstallView { | |
| 336 | + | steps: Steps, | |
| 337 | + | backend: Box<dyn Backend>, | |
| 338 | + | disks: Vec<Disk>, | |
| 339 | + | cursor: Cursor, | |
| 340 | + | answers: Answers, | |
| 341 | + | error: Option<String>, | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | impl InstallView { | |
| 345 | + | pub fn new(log: &mut CommandLog) -> Self { | |
| 346 | + | let mut view = Self { | |
| 347 | + | steps: Steps::new(STEP_COUNT), | |
| 348 | + | backend: detect(), | |
| 349 | + | disks: Vec::new(), | |
| 350 | + | cursor: Cursor::new(), | |
| 351 | + | answers: Answers::default(), | |
| 352 | + | error: None, | |
| 353 | + | }; | |
| 354 | + | view.refresh(log); | |
| 355 | + | view | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | fn refresh(&mut self, log: &mut CommandLog) { | |
| 359 | + | match self.backend.list(log) { | |
| 360 | + | Ok(disks) => { | |
| 361 | + | self.disks = disks; | |
| 362 | + | // A disk can vanish between refreshes (a stick pulled out); | |
| 363 | + | // the cursor clamps itself back into range. | |
| 364 | + | self.cursor.resize(self.disks.len()); | |
| 365 | + | self.error = None; | |
| 366 | + | } | |
| 367 | + | Err(err) => self.error = Some(err.to_string()), | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | fn selected(&self) -> Option<&Disk> { | |
| 372 | + | self.cursor.selected().and_then(|i| self.disks.get(i)) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /// Take the disk under the cursor as the answer, and move on. | |
| 376 | + | /// | |
| 377 | + | /// A blocked disk refuses with its reason rather than silently doing | |
| 378 | + | /// nothing. Enter that appears to be ignored is how a user concludes the | |
| 379 | + | /// installer is broken and reaches for `dd`. | |
| 380 | + | fn choose(&mut self) -> Flow { | |
| 381 | + | let Some(disk) = self.selected() else { | |
| 382 | + | self.error = Some("no disk to install to".into()); | |
| 383 | + | return Flow::Continue; | |
| 384 | + | }; | |
| 385 | + | ||
| 386 | + | if let Some(blocked) = disk.blocker() { | |
| 387 | + | self.error = Some(format!("{} {}", disk.path, blocked.reason())); | |
| 388 | + | return Flow::Continue; | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | self.answers.disk = Some(disk.path.clone()); | |
| 392 | + | self.error = None; | |
| 393 | + | self.steps.advance(); | |
| 394 | + | Flow::Continue | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | fn row<'a>(&self, theme: &Theme, disk: &'a Disk) -> Line<'a> { | |
| 398 | + | let (status, severity) = match disk.blocker() { | |
| 399 | + | Some(blocked) => (blocked.label(), Severity::Warn), | |
| 400 | + | None => ("", Severity::Info), | |
| 401 | + | }; | |
| 402 | + | ||
| 403 | + | let model = disk.model.as_deref().unwrap_or("-"); | |
| 404 | + | ||
| 405 | + | Line::from(vec![ | |
| 406 | + | text::bold(theme, format!("{:<14}", disk.name)), | |
| 407 | + | text::primary(theme, format!("{:>10} ", format_size(disk.size))), | |
| 408 | + | text::secondary(theme, format!("{model:<24}")), | |
| 409 | + | text::muted(theme, format!("{:<16}", disk.attachment())), | |
| 410 | + | Span::styled(status.to_string(), severity.style(theme)), | |
| 411 | + | ]) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | impl View for InstallView { | |
| 416 | + | fn title(&self) -> String { | |
| 417 | + | format!( | |
| 418 | + | "install ({}) — step {} of {}: select a disk", | |
| 419 | + | self.backend.name(), | |
| 420 | + | self.steps.current() + 1, | |
| 421 | + | self.steps.len() | |
| 422 | + | ) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | fn hints(&self) -> Vec<Hint> { | |
| 426 | + | vec![ | |
| 427 | + | hint("j/k", "select"), | |
| 428 | + | hint("enter", "choose"), | |
| 429 | + | hint("r", "refresh"), | |
| 430 | + | ] | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | fn status(&self) -> Option<(Severity, String)> { | |
| 434 | + | if let Some(message) = &self.error { | |
| 435 | + | return Some((Severity::Error, message.clone())); | |
| 436 | + | } | |
| 437 | + | self.answers | |
| 438 | + | .disk | |
| 439 | + | .as_ref() | |
| 440 | + | .map(|disk| (Severity::Healthy, format!("target: {disk}"))) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 444 | + | let block = AlloyBlock::new(theme) | |
| 445 | + | .focused(true) | |
| 446 | + | .build() | |
| 447 | + | .title(block_title(&self.title())); | |
| 448 | + | let inner = block.inner(area); | |
| 449 | + | frame.render_widget(block, area); | |
| 450 | + | ||
| 451 | + | if self.disks.is_empty() { | |
| 452 | + | frame.render_widget(Line::from(text::muted(theme, "no disks found")), inner); | |
| 453 | + | return; | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | let rows: Vec<Line> = self | |
| 457 | + | .disks | |
| 458 | + | .iter() | |
| 459 | + | .map(|disk| self.row(theme, disk)) | |
| 460 | + | .collect(); | |
| 461 | + | frame.render_widget( | |
| 462 | + | AlloyList::new(theme, rows).selected(self.cursor.selected()), | |
| 463 | + | inner, | |
| 464 | + | ); | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { | |
| 468 | + | match key.code { | |
| 469 | + | KeyCode::Char('j') | KeyCode::Down => self.cursor.next(), | |
| 470 | + | KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(), | |
| 471 | + | KeyCode::Char('r') => self.refresh(log), | |
| 472 | + | KeyCode::Enter => return self.choose(), | |
| 473 | + | _ => {} | |
| 474 | + | } | |
| 475 | + | Flow::Continue | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | /// Esc steps back, and leaves once there is nowhere back to go. | |
| 479 | + | fn cancel(&mut self) -> Flow { | |
| 480 | + | if self.steps.back() { | |
| 481 | + | self.error = None; | |
| 482 | + | Flow::Continue | |
| 483 | + | } else { | |
| 484 | + | Flow::Exit | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | #[cfg(test)] | |
| 490 | + | mod tests { | |
| 491 | + | use super::*; | |
| 492 | + | ||
| 493 | + | /// Captured verbatim from `lsblk -J -b -o | |
| 494 | + | /// NAME,PATH,SIZE,MODEL,TYPE,RM,RO,TRAN,MOUNTPOINTS` on fw13, util-linux | |
| 495 | + | /// 2.39.3. Kept real rather than trimmed, because the three awkward parts | |
| 496 | + | /// are the whole reason this parser has rules: `zram0` is typed `disk`, | |
| 497 | + | /// `nvme0n1` holds the running root, and `cryptswap` is a mounted volume | |
| 498 | + | /// two levels below its disk. A tidied fixture is where a parser passes | |
| 499 | + | /// tests it would fail against a real machine. | |
| 500 | + | const SAMPLE: &str = r#"{ |
Lines truncated