max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
6 files changed,
+822 insertions,
-755 deletions
| @@ -7,12 +7,12 @@ | |||
| 7 | 7 | //! sibling on its disk is doing, and [`describe_loss`] is the one sentence the | |
| 8 | 8 | //! four destructive confirmations are built from. | |
| 9 | 9 | ||
| 10 | - | use anyhow::{Context, Result}; | |
| 10 | + | use anyhow::Result; | |
| 11 | 11 | ||
| 12 | 12 | use alloy_tui::keys::Action; | |
| 13 | 13 | use alloy_tui::{ | |
| 14 | - | AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, TextField, | |
| 15 | - | Theme, binding, hint, text, unavailable, | |
| 14 | + | AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding, | |
| 15 | + | hint, text, unavailable, | |
| 16 | 16 | }; | |
| 17 | 17 | use ratatui::Frame; | |
| 18 | 18 | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| @@ -27,6 +27,10 @@ | |||
| 27 | 27 | use crate::shell::{Confirm, Flow, View, block_title, truncate}; | |
| 28 | 28 | use crate::size::format_size; | |
| 29 | 29 | ||
| 30 | + | mod edits; | |
| 31 | + | ||
| 32 | + | use edits::Editing; | |
| 33 | + | ||
| 30 | 34 | /// Which list is showing. | |
| 31 | 35 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 32 | 36 | pub(crate) enum Tab { | |
| @@ -64,7 +68,7 @@ | |||
| 64 | 68 | } | |
| 65 | 69 | ||
| 66 | 70 | /// What the pending confirmation would do once answered. | |
| 67 | - | enum PendingAction { | |
| 71 | + | pub(super) enum PendingAction { | |
| 68 | 72 | /// Eject a drive that still has something mounted on it. | |
| 69 | 73 | Eject(Volume), | |
| 70 | 74 | /// Add a partition to a drive. Carries the drive rather than the volume the | |
| @@ -84,97 +88,6 @@ | |||
| 84 | 88 | }, | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 87 | - | /// A value being typed or picked before its action can be confirmed. | |
| 88 | - | /// | |
| 89 | - | /// Two of the four partition operations need a number and one needs a choice | |
| 90 | - | /// from a list, so the surface is a small state machine rather than four keys | |
| 91 | - | /// that act immediately. The machine is deliberately shallow: one step, then | |
| 92 | - | /// the confirm, then the command. Escape leaves at any point and nothing has | |
| 93 | - | /// happened. | |
| 94 | - | enum Editing { | |
| 95 | - | /// Size for a new partition on this drive. Empty means fill the free space. | |
| 96 | - | CreateSize { drive: Drive, field: TextField }, | |
| 97 | - | /// Which filesystem to write onto this volume. | |
| 98 | - | FormatType { volume: Volume, choice: Cursor }, | |
| 99 | - | /// New size for this partition. | |
| 100 | - | ResizeSize { volume: Volume, field: TextField }, | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | impl Editing { | |
| 104 | - | /// The line above the field, naming what is being asked and what it acts | |
| 105 | - | /// on. A prompt that says only "size:" leaves the user to remember which | |
| 106 | - | /// row they pressed a key on. | |
| 107 | - | fn prompt(&self) -> String { | |
| 108 | - | match self { | |
| 109 | - | Self::CreateSize { drive, .. } => format!( | |
| 110 | - | "New partition on {}. Size (blank fills the free space), Enter to confirm, Esc to cancel:", | |
| 111 | - | drive.path | |
| 112 | - | ), | |
| 113 | - | Self::FormatType { volume, .. } => format!( | |
| 114 | - | "Format {}. j/k to choose, Enter to confirm, Esc to cancel:", | |
| 115 | - | volume.path | |
| 116 | - | ), | |
| 117 | - | Self::ResizeSize { volume, .. } => format!( | |
| 118 | - | "Resize {} from {}. New size, Enter to confirm, Esc to cancel:", | |
| 119 | - | volume.path, | |
| 120 | - | format_size(volume.size) | |
| 121 | - | ), | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | /// Parse a size the way a person writes one. | |
| 127 | - | /// | |
| 128 | - | /// Decimal units by default, matching [`format_size`]: this screen prints | |
| 129 | - | /// "16.0 GB" for a stick and a user typing "16GB" back at it must mean the same | |
| 130 | - | /// number. Binary units are accepted where they are spelled out (`GiB`), since | |
| 131 | - | /// anyone who writes the `i` knows which one they want. | |
| 132 | - | /// | |
| 133 | - | /// A bare number is bytes. Not megabytes, which some partitioners assume: a | |
| 134 | - | /// silent factor of a million between what was typed and what is created is | |
| 135 | - | /// exactly the kind of thing this console must not do. | |
| 136 | - | fn parse_size(raw: &str) -> Result<u64> { | |
| 137 | - | const SCALES: [(&str, u64); 9] = [ | |
| 138 | - | ("kb", 1_000), | |
| 139 | - | ("mb", 1_000_000), | |
| 140 | - | ("gb", 1_000_000_000), | |
| 141 | - | ("tb", 1_000_000_000_000), | |
| 142 | - | ("kib", 1 << 10), | |
| 143 | - | ("mib", 1 << 20), | |
| 144 | - | ("gib", 1 << 30), | |
| 145 | - | ("tib", 1 << 40), | |
| 146 | - | ("b", 1), | |
| 147 | - | ]; | |
| 148 | - | ||
| 149 | - | let trimmed = raw.trim().to_ascii_lowercase(); | |
| 150 | - | if trimmed.is_empty() { | |
| 151 | - | anyhow::bail!("no size given"); | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // Longest suffix first, so "gib" is not read as "b" with "gi" left over. | |
| 155 | - | let mut candidates: Vec<(&str, u64)> = SCALES.to_vec(); | |
| 156 | - | candidates.sort_by_key(|(unit, _)| std::cmp::Reverse(unit.len())); | |
| 157 | - | ||
| 158 | - | let (number, scale) = candidates | |
| 159 | - | .iter() | |
| 160 | - | .find_map(|(unit, scale)| trimmed.strip_suffix(unit).map(|rest| (rest, *scale))) | |
| 161 | - | .unwrap_or((trimmed.as_str(), 1)); | |
| 162 | - | ||
| 163 | - | let number: f64 = number | |
| 164 | - | .trim() | |
| 165 | - | .parse() | |
| 166 | - | .with_context(|| format!("not a size: {}", raw.trim()))?; | |
| 167 | - | if !number.is_finite() || number <= 0.0 { | |
| 168 | - | anyhow::bail!("not a size: {}", raw.trim()); | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | let bytes = number * scale as f64; | |
| 172 | - | if bytes >= u64::MAX as f64 { | |
| 173 | - | anyhow::bail!("size is larger than any disk: {}", raw.trim()); | |
| 174 | - | } | |
| 175 | - | Ok(bytes as u64) | |
| 176 | - | } | |
| 177 | - | ||
| 178 | 91 | /// The `alloy disk` screen. | |
| 179 | 92 | pub(crate) struct DiskView { | |
| 180 | 93 | backend: Box<dyn Backend>, | |
| @@ -330,198 +243,6 @@ | |||
| 330 | 243 | self.error = Some(format!("{} {}", volume.path, blocked.reason())); | |
| 331 | 244 | } | |
| 332 | 245 | ||
| 333 | - | /// Start a new partition on the selected row's drive. | |
| 334 | - | fn create_partition(&mut self) { | |
| 335 | - | let Some(volume) = self.selected() else { | |
| 336 | - | self.error = Some("nothing selected".to_string()); | |
| 337 | - | return; | |
| 338 | - | }; | |
| 339 | - | if self.drive_is_system(&volume.drive) { | |
| 340 | - | self.error = Some(format!( | |
| 341 | - | "{} {}", | |
| 342 | - | volume.drive.path, | |
| 343 | - | Blocked::SystemDisk.reason() | |
| 344 | - | )); | |
| 345 | - | return; | |
| 346 | - | } | |
| 347 | - | if self.backend.create_partition(&volume.drive, 0).is_none() { | |
| 348 | - | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 349 | - | return; | |
| 350 | - | } | |
| 351 | - | self.editing = Some(Editing::CreateSize { | |
| 352 | - | drive: volume.drive.clone(), | |
| 353 | - | field: TextField::new(), | |
| 354 | - | }); | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | /// Delete the selected partition. One step: there is nothing to ask. | |
| 358 | - | fn delete_partition(&mut self) -> Flow { | |
| 359 | - | let Some(volume) = self.selected() else { | |
| 360 | - | self.error = Some("nothing selected".to_string()); | |
| 361 | - | return Flow::Continue; | |
| 362 | - | }; | |
| 363 | - | if let Some(blocked) = self.edit_blocker(&volume) { | |
| 364 | - | self.refuse(&volume, blocked); | |
| 365 | - | return Flow::Continue; | |
| 366 | - | } | |
| 367 | - | if self.backend.delete_partition(&volume).is_none() { | |
| 368 | - | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 369 | - | return Flow::Continue; | |
| 370 | - | } | |
| 371 | - | let message = format!("Delete {}? {}", volume.path, describe_loss(&volume)); | |
| 372 | - | self.pending_action = Some(PendingAction::Delete(volume)); | |
| 373 | - | Flow::Confirm(Confirm::destructive("delete partition", message)) | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | fn format_selected(&mut self) { | |
| 377 | - | let Some(volume) = self.selected() else { | |
| 378 | - | self.error = Some("nothing selected".to_string()); | |
| 379 | - | return; | |
| 380 | - | }; | |
| 381 | - | if self.drive_is_system(&volume.drive) { | |
| 382 | - | self.refuse(&volume, Blocked::SystemDisk); | |
| 383 | - | return; | |
| 384 | - | } | |
| 385 | - | if let Some(blocked) = volume.format_blocker() { | |
| 386 | - | self.refuse(&volume, blocked); | |
| 387 | - | return; | |
| 388 | - | } | |
| 389 | - | if self.backend.format(&volume, FILESYSTEMS[0]).is_none() { | |
| 390 | - | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 391 | - | return; | |
| 392 | - | } | |
| 393 | - | let mut choice = Cursor::new(); | |
| 394 | - | choice.resize(FILESYSTEMS.len()); | |
| 395 | - | self.editing = Some(Editing::FormatType { volume, choice }); | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | fn resize_selected(&mut self) { | |
| 399 | - | let Some(volume) = self.selected() else { | |
| 400 | - | self.error = Some("nothing selected".to_string()); | |
| 401 | - | return; | |
| 402 | - | }; | |
| 403 | - | if let Some(blocked) = self.edit_blocker(&volume) { | |
| 404 | - | self.refuse(&volume, blocked); | |
| 405 | - | return; | |
| 406 | - | } | |
| 407 | - | if self.backend.resize_partition(&volume, 0).is_none() { | |
| 408 | - | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 409 | - | return; | |
| 410 | - | } | |
| 411 | - | let mut field = TextField::new(); | |
| 412 | - | field.set(format_size(volume.size).replace(' ', "")); | |
| 413 | - | field.end(); | |
| 414 | - | self.editing = Some(Editing::ResizeSize { volume, field }); | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | /// Turn a finished edit into a pending action and its confirmation. | |
| 418 | - | /// | |
| 419 | - | /// Every branch ends in a confirm. That is the restated rule for this | |
| 420 | - | /// surface: `disk`'s existing one is that a confirm appears only where a | |
| 421 | - | /// mistake costs something, and every partition write costs something, so | |
| 422 | - | /// the exception swallows the rule here rather than being an exception. | |
| 423 | - | /// What keeps the confirms from becoming reflex is that they carry the | |
| 424 | - | /// facts that decide the answer, not the word "sure". | |
| 425 | - | fn commit_edit(&mut self) -> Flow { | |
| 426 | - | let Some(editing) = self.editing.take() else { | |
| 427 | - | return Flow::Continue; | |
| 428 | - | }; | |
| 429 | - | match editing { | |
| 430 | - | Editing::CreateSize { drive, field } => { | |
| 431 | - | let raw = field.value().trim().to_string(); | |
| 432 | - | let size = if raw.is_empty() { | |
| 433 | - | 0 | |
| 434 | - | } else { | |
| 435 | - | match parse_size(&raw) { | |
| 436 | - | Ok(size) => size, | |
| 437 | - | Err(err) => { | |
| 438 | - | self.error = Some(err.to_string()); | |
| 439 | - | return Flow::Continue; | |
| 440 | - | } | |
| 441 | - | } | |
| 442 | - | }; | |
| 443 | - | let message = format!( | |
| 444 | - | "Create a {} partition on {} ({})? The partition table is rewritten.", | |
| 445 | - | if size == 0 { | |
| 446 | - | "free-space-filling".to_string() | |
| 447 | - | } else { | |
| 448 | - | format_size(size) | |
| 449 | - | }, | |
| 450 | - | drive.path, | |
| 451 | - | drive.model_or_dash(), | |
| 452 | - | ); | |
| 453 | - | self.pending_action = Some(PendingAction::Create { drive, size }); | |
| 454 | - | Flow::Confirm(Confirm::destructive("create partition", message)) | |
| 455 | - | } | |
| 456 | - | Editing::FormatType { volume, choice } => { | |
| 457 | - | let fstype = FILESYSTEMS[choice.selected().unwrap_or(0)].to_string(); | |
| 458 | - | let message = format!( | |
| 459 | - | "Format {} as {fstype}? {}", | |
| 460 | - | volume.path, | |
| 461 | - | describe_loss(&volume) | |
| 462 | - | ); | |
| 463 | - | self.pending_action = Some(PendingAction::Format { volume, fstype }); | |
| 464 | - | Flow::Confirm(Confirm::destructive("format", message)) | |
| 465 | - | } | |
| 466 | - | Editing::ResizeSize { volume, field } => { | |
| 467 | - | let size = match parse_size(field.value()) { | |
| 468 | - | Ok(size) => size, | |
| 469 | - | Err(err) => { | |
| 470 | - | self.error = Some(err.to_string()); | |
| 471 | - | return Flow::Continue; | |
| 472 | - | } | |
| 473 | - | }; | |
| 474 | - | let direction = if size < volume.size { | |
| 475 | - | "Shrinking can destroy data past the new end." | |
| 476 | - | } else { | |
| 477 | - | "Growing needs free space after the partition." | |
| 478 | - | }; | |
| 479 | - | let message = format!( | |
| 480 | - | "Resize {} from {} to {}? {} {}", | |
| 481 | - | volume.path, | |
| 482 | - | format_size(volume.size), | |
| 483 | - | format_size(size), | |
| 484 | - | describe_loss(&volume), | |
| 485 | - | direction, | |
| 486 | - | ); | |
| 487 | - | self.pending_action = Some(PendingAction::Resize { volume, size }); | |
| 488 | - | Flow::Confirm(Confirm::destructive("resize partition", message)) | |
| 489 | - | } | |
| 490 | - | } | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | /// Key handling while a value is being collected. `None` means the key was | |
| 494 | - | /// not ours and the normal bindings should see it. | |
| 495 | - | fn handle_editing(&mut self, key: KeyEvent) -> Option<Flow> { | |
| 496 | - | let editing = self.editing.as_mut()?; | |
| 497 | - | match (key.code, editing) { | |
| 498 | - | (KeyCode::Esc, _) => { | |
| 499 | - | self.editing = None; | |
| 500 | - | } | |
| 501 | - | (KeyCode::Enter, _) => return Some(self.commit_edit()), | |
| 502 | - | (KeyCode::Char('j') | KeyCode::Down, Editing::FormatType { choice, .. }) => { | |
| 503 | - | choice.next(); | |
| 504 | - | } | |
| 505 | - | (KeyCode::Char('k') | KeyCode::Up, Editing::FormatType { choice, .. }) => { | |
| 506 | - | choice.prev(); | |
| 507 | - | } | |
| 508 | - | (_, Editing::FormatType { .. }) => {} | |
| 509 | - | (code, Editing::CreateSize { field, .. } | Editing::ResizeSize { field, .. }) => { | |
| 510 | - | match code { | |
| 511 | - | KeyCode::Char(c) => field.insert(c), | |
| 512 | - | KeyCode::Backspace => field.backspace(), | |
| 513 | - | KeyCode::Delete => field.delete(), | |
| 514 | - | KeyCode::Left => field.left(), | |
| 515 | - | KeyCode::Right => field.right(), | |
| 516 | - | KeyCode::Home => field.home(), | |
| 517 | - | KeyCode::End => field.end(), | |
| 518 | - | _ => {} | |
| 519 | - | } | |
| 520 | - | } | |
| 521 | - | } | |
| 522 | - | Some(Flow::Continue) | |
| 523 | - | } | |
| 524 | - | ||
| 525 | 246 | fn mount_selected(&mut self, log: &mut CommandLog) { | |
| 526 | 247 | let Some(volume) = self.selected() else { | |
| 527 | 248 | self.error = Some("nothing selected".to_string()); | |
| @@ -700,7 +421,7 @@ | |||
| 700 | 421 | /// have to implement to say the same thing twice: whether `mount` returns | |
| 701 | 422 | /// `Some`. Asking the question the view actually cares about keeps the trait to | |
| 702 | 423 | /// argv-building. | |
| 703 | - | fn mock_probe_volume() -> Volume { | |
| 424 | + | pub(super) fn mock_probe_volume() -> Volume { | |
| 704 | 425 | Volume { | |
| 705 | 426 | path: "/dev/null".to_string(), | |
| 706 | 427 | name: "null".to_string(), | |
| @@ -729,7 +450,7 @@ | |||
| 729 | 450 | /// An unlabeled volume with no filesystem still gets a sentence. "5.4 GB, no | |
| 730 | 451 | /// filesystem" is the fact that tells a user they are about to lose nothing, | |
| 731 | 452 | /// and leaving it out would make the emptiest case the least informative one. | |
| 732 | - | fn describe_loss(volume: &Volume) -> String { | |
| 453 | + | pub(super) fn describe_loss(volume: &Volume) -> String { | |
| 733 | 454 | let filesystem = match volume.fstype.as_deref() { | |
| 734 | 455 | Some(fstype) => format!("{fstype} filesystem"), | |
| 735 | 456 | None => "no filesystem".to_string(), | |
| @@ -751,7 +472,7 @@ | |||
| 751 | 472 | /// The refusal when the volume a confirmation was raised about is no longer | |
| 752 | 473 | /// there. Unplugging a stick with the prompt up is the ordinary way to get | |
| 753 | 474 | /// here, and it is a normal thing to have done rather than an error. | |
| 754 | - | fn gone(volume: &Volume) -> String { | |
| 475 | + | pub(super) fn gone(volume: &Volume) -> String { | |
| 755 | 476 | format!("{} is no longer attached; nothing was changed", volume.path) | |
| 756 | 477 | } | |
| 757 | 478 | ||
| @@ -760,7 +481,7 @@ | |||
| 760 | 481 | /// Names the daemon rather than the binary: udisksctl being installed and | |
| 761 | 482 | /// udisksd being down are different problems with different fixes, and this is | |
| 762 | 483 | /// the second one. | |
| 763 | - | fn no_udisks(backend: &str, volume: &Volume) -> String { | |
| 484 | + | pub(super) fn no_udisks(backend: &str, volume: &Volume) -> String { | |
| 764 | 485 | format!( | |
| 765 | 486 | "{backend} cannot act on {}: the udisks daemon is not answering (systemctl start udisks2)", | |
| 766 | 487 | volume.path |
| @@ -7,22 +7,23 @@ | |||
| 7 | 7 | //! [`backend`](super::backend) once it is submitted. | |
| 8 | 8 | ||
| 9 | 9 | use alloy_tui::{ | |
| 10 | - | AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, TextField, Theme, hint, | |
| 11 | - | layout, text, | |
| 10 | + | AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, Theme, hint, text, | |
| 12 | 11 | }; | |
| 13 | 12 | use anyhow::Result; | |
| 14 | 13 | use ratatui::Frame; | |
| 15 | 14 | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 16 | 15 | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 17 | - | use ratatui::style::{Modifier, Style}; | |
| 18 | - | use ratatui::text::{Line, Span}; | |
| 16 | + | use ratatui::text::Line; | |
| 19 | 17 | ||
| 20 | - | use super::backend::{ | |
| 21 | - | Backend, DeviceDraft, FolderDraft, UNIT, detect, validate_device, validate_folder, | |
| 22 | - | }; | |
| 23 | - | use super::model::{Device, Folder, PendingDevice, Reach, SyncState, day}; | |
| 18 | + | use super::backend::{Backend, detect}; | |
| 19 | + | use super::model::{Device, Folder, PendingDevice, Reach, SyncState}; | |
| 24 | 20 | use crate::cli::{CommandLog, Invocation}; | |
| 25 | - | use crate::shell::{Confirm, Flow, View, block_title, truncate}; | |
| 21 | + | use crate::shell::{Confirm, Flow, View, block_title}; | |
| 22 | + | ||
| 23 | + | mod overlays; | |
| 24 | + | mod render; | |
| 25 | + | ||
| 26 | + | use overlays::Draft; | |
| 26 | 27 | ||
| 27 | 28 | /// Ticks between background refreshes. | |
| 28 | 29 | /// | |
| @@ -70,76 +71,6 @@ | |||
| 70 | 71 | } | |
| 71 | 72 | } | |
| 72 | 73 | ||
| 73 | - | /// The add overlay, when one is open. | |
| 74 | - | /// | |
| 75 | - | /// Two shapes rather than one form with optional rows: a folder and a device | |
| 76 | - | /// share no fields, and a single struct carrying both sets would spend every | |
| 77 | - | /// read asking which half is live. | |
| 78 | - | enum Draft { | |
| 79 | - | Folder { | |
| 80 | - | id: TextField, | |
| 81 | - | label: TextField, | |
| 82 | - | path: TextField, | |
| 83 | - | }, | |
| 84 | - | Device { | |
| 85 | - | id: TextField, | |
| 86 | - | name: TextField, | |
| 87 | - | }, | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | impl Draft { | |
| 91 | - | /// Field labels, in slot order, so the renderer and the focus ring agree | |
| 92 | - | /// about what slot 1 is. | |
| 93 | - | const FOLDER_LABELS: [&'static str; 3] = ["id", "label", "path"]; | |
| 94 | - | const DEVICE_LABELS: [&'static str; 2] = ["device id", "name"]; | |
| 95 | - | ||
| 96 | - | fn labels(&self) -> &'static [&'static str] { | |
| 97 | - | match self { | |
| 98 | - | Draft::Folder { .. } => &Self::FOLDER_LABELS, | |
| 99 | - | Draft::Device { .. } => &Self::DEVICE_LABELS, | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | fn field_mut(&mut self, slot: usize) -> Option<&mut TextField> { | |
| 104 | - | match self { | |
| 105 | - | Draft::Folder { id, label, path } => match slot { | |
| 106 | - | 0 => Some(id), | |
| 107 | - | 1 => Some(label), | |
| 108 | - | 2 => Some(path), | |
| 109 | - | _ => None, | |
| 110 | - | }, | |
| 111 | - | Draft::Device { id, name } => match slot { | |
| 112 | - | 0 => Some(id), | |
| 113 | - | 1 => Some(name), | |
| 114 | - | _ => None, | |
| 115 | - | }, | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | fn field(&self, slot: usize) -> Option<&TextField> { | |
| 120 | - | match self { | |
| 121 | - | Draft::Folder { id, label, path } => match slot { | |
| 122 | - | 0 => Some(id), | |
| 123 | - | 1 => Some(label), | |
| 124 | - | 2 => Some(path), | |
| 125 | - | _ => None, | |
| 126 | - | }, | |
| 127 | - | Draft::Device { id, name } => match slot { | |
| 128 | - | 0 => Some(id), | |
| 129 | - | 1 => Some(name), | |
| 130 | - | _ => None, | |
| 131 | - | }, | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | fn title(&self) -> &'static str { | |
| 136 | - | match self { | |
| 137 | - | Draft::Folder { .. } => "add folder", | |
| 138 | - | Draft::Device { .. } => "add device", | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | } | |
| 142 | - | ||
| 143 | 74 | /// What a raised confirm is waiting to do. | |
| 144 | 75 | /// | |
| 145 | 76 | /// The shell's [`Confirm`] carries only what to display, so the pending action | |
| @@ -315,100 +246,6 @@ | |||
| 315 | 246 | self.finish(result, log); | |
| 316 | 247 | } | |
| 317 | 248 | ||
| 318 | - | /// Keys while the share editor is open. | |
| 319 | - | /// | |
| 320 | - | /// Returns true when it consumed the key, so nothing underneath sees it. | |
| 321 | - | fn handle_share(&mut self, key: KeyEvent, log: &mut CommandLog) -> bool { | |
| 322 | - | if self.share.is_none() { | |
| 323 | - | return false; | |
| 324 | - | } | |
| 325 | - | match key.code { | |
| 326 | - | KeyCode::Esc => self.share = None, | |
| 327 | - | KeyCode::Char('j') | KeyCode::Down => { | |
| 328 | - | if let Some((_, cursor)) = &mut self.share { | |
| 329 | - | cursor.next(); | |
| 330 | - | } | |
| 331 | - | } | |
| 332 | - | KeyCode::Char('k') | KeyCode::Up => { | |
| 333 | - | if let Some((_, cursor)) = &mut self.share { | |
| 334 | - | cursor.prev(); | |
| 335 | - | } | |
| 336 | - | } | |
| 337 | - | KeyCode::Char(' ') | KeyCode::Enter => self.toggle_share(log), | |
| 338 | - | // Everything else is swallowed rather than passed down. A key with | |
| 339 | - | // no meaning here is not a key that should mean something to the | |
| 340 | - | // list behind the overlay. | |
| 341 | - | _ => {} | |
| 342 | - | } | |
| 343 | - | true | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | /// Open the share editor for the selected folder. | |
| 347 | - | /// | |
| 348 | - | /// A folder is shared with a set of devices, and until now this screen | |
| 349 | - | /// could only report how many. docs/CONTINUITY.md listed editing that list | |
| 350 | - | /// as still to come; `syncthing cli config folders <id> devices` had the | |
| 351 | - | /// whole operation the entire time. | |
| 352 | - | fn open_share(&mut self) { | |
| 353 | - | if self.tab != Tab::Folders { | |
| 354 | - | return; | |
| 355 | - | } | |
| 356 | - | let Some(folder) = self.selected_folder() else { | |
| 357 | - | return; | |
| 358 | - | }; | |
| 359 | - | let id = folder.id.clone(); | |
| 360 | - | let mut cursor = Cursor::new(); | |
| 361 | - | cursor.resize(self.shareable_devices().len()); | |
| 362 | - | self.share = Some((id, cursor)); | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | /// The devices a folder can be shared with: everyone but this machine. | |
| 366 | - | /// | |
| 367 | - | /// This machine is in every folder's device list and removing it there does | |
| 368 | - | /// not mean "unshare", it means the folder stops being here at all. That is | |
| 369 | - | /// what `d` on the folder row is for, and it confirms first. Leaving it out | |
| 370 | - | /// of this list keeps one keystroke from meaning two very different things. | |
| 371 | - | fn shareable_devices(&self) -> Vec<&Device> { | |
| 372 | - | match &self.reach { | |
| 373 | - | Reach::Running(state) => state | |
| 374 | - | .devices | |
| 375 | - | .iter() | |
| 376 | - | .filter(|device| !device.is_self) | |
| 377 | - | .collect(), | |
| 378 | - | Reach::NotRunning => Vec::new(), | |
| 379 | - | } | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | /// Toggle the selected device's share of the folder being edited. | |
| 383 | - | /// | |
| 384 | - | /// Applied at once rather than gathered and saved, which is what `p` does | |
| 385 | - | /// for pausing and what the log pane teaches: one keystroke, one command, | |
| 386 | - | /// visible in the pane as something the user could have typed. | |
| 387 | - | fn toggle_share(&mut self, log: &mut CommandLog) { | |
| 388 | - | let Some((folder_id, cursor)) = &self.share else { | |
| 389 | - | return; | |
| 390 | - | }; | |
| 391 | - | let Some(slot) = cursor.selected() else { | |
| 392 | - | return; | |
| 393 | - | }; | |
| 394 | - | let Some(device) = self.shareable_devices().get(slot).map(|d| (*d).clone()) else { | |
| 395 | - | return; | |
| 396 | - | }; | |
| 397 | - | let Some(folder) = self | |
| 398 | - | .folder_list() | |
| 399 | - | .iter() | |
| 400 | - | .find(|f| &f.id == folder_id) | |
| 401 | - | .cloned() | |
| 402 | - | else { | |
| 403 | - | return; | |
| 404 | - | }; | |
| 405 | - | let shared = folder.is_shared_with(&device.id); | |
| 406 | - | let result = self | |
| 407 | - | .backend | |
| 408 | - | .set_folder_shared(&folder, &device, !shared, log); | |
| 409 | - | self.finish(result, log); | |
| 410 | - | } | |
| 411 | - | ||
| 412 | 249 | /// Hand the web UI to a browser, for the edge cases this screen does not do. | |
| 413 | 250 | /// | |
| 414 | 251 | /// docs/CONTINUITY.md puts the web UI as reachable and not recommended: | |
| @@ -447,108 +284,6 @@ | |||
| 447 | 284 | } | |
| 448 | 285 | } | |
| 449 | 286 | ||
| 450 | - | /// Open the add overlay for whichever tab is showing. | |
| 451 | - | fn open_draft(&mut self) { | |
| 452 | - | let draft = match self.tab { | |
| 453 | - | Tab::Folders => Draft::Folder { | |
| 454 | - | id: TextField::new(), | |
| 455 | - | label: TextField::new(), | |
| 456 | - | path: TextField::new(), | |
| 457 | - | }, | |
| 458 | - | Tab::Devices => Draft::Device { | |
| 459 | - | id: TextField::new(), | |
| 460 | - | name: TextField::new(), | |
| 461 | - | }, | |
| 462 | - | // `a` on the pending tab accepts rather than opens a form: the id | |
| 463 | - | // and name are already known, and retyping a 56-character id that | |
| 464 | - | // is on screen would be the opposite of help. | |
| 465 | - | Tab::Pending => return, | |
| 466 | - | }; | |
| 467 | - | self.draft_focus = FocusRing::new(draft.labels().len()); | |
| 468 | - | self.draft = Some(draft); | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | fn close_draft(&mut self) { | |
| 472 | - | self.draft = None; | |
| 473 | - | self.draft_focus = FocusRing::new(0); | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | /// Validate the open draft and hand it to the backend. | |
| 477 | - | /// | |
| 478 | - | /// A rejected draft stays on screen with the reason in the status line, | |
| 479 | - | /// rather than closing and losing what was typed. Retyping a 56-character | |
| 480 | - | /// device id because one group was wrong would be the worst possible | |
| 481 | - | /// answer to a typo. | |
| 482 | - | fn submit_draft(&mut self, log: &mut CommandLog) { | |
| 483 | - | let Some(draft) = &self.draft else { | |
| 484 | - | return; | |
| 485 | - | }; | |
| 486 | - | let result = match draft { | |
| 487 | - | Draft::Folder { id, label, path } => { | |
| 488 | - | let draft = FolderDraft { | |
| 489 | - | id: id.value().to_string(), | |
| 490 | - | label: label.value().to_string(), | |
| 491 | - | path: path.value().to_string(), | |
| 492 | - | }; | |
| 493 | - | if let Err(reason) = validate_folder(&draft) { | |
| 494 | - | self.error = Some(reason); | |
| 495 | - | return; | |
| 496 | - | } | |
| 497 | - | self.backend.add_folder(&draft, log) | |
| 498 | - | } | |
| 499 | - | Draft::Device { id, name } => { | |
| 500 | - | let draft = DeviceDraft { | |
| 501 | - | id: id.value().to_string(), | |
| 502 | - | name: name.value().to_string(), | |
| 503 | - | }; | |
| 504 | - | if let Err(reason) = validate_device(&draft) { | |
| 505 | - | self.error = Some(reason); | |
| 506 | - | return; | |
| 507 | - | } | |
| 508 | - | self.backend.add_device(&draft, log) | |
| 509 | - | } | |
| 510 | - | }; | |
| 511 | - | // Closed only on success, for the same reason a rejected draft stays | |
| 512 | - | // up: a command that failed has typed input still worth keeping. | |
| 513 | - | if result.is_ok() { | |
| 514 | - | self.close_draft(); | |
| 515 | - | } | |
| 516 | - | self.finish(result, log); | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | /// Keys while the add overlay is open. | |
| 520 | - | /// | |
| 521 | - | /// Returns `true` when the overlay consumed the key, so the list bindings | |
| 522 | - | /// underneath never see a `p` that was meant to be part of a path. | |
| 523 | - | fn handle_draft(&mut self, key: KeyEvent, log: &mut CommandLog) -> bool { | |
| 524 | - | if self.draft.is_none() { | |
| 525 | - | return false; | |
| 526 | - | } | |
| 527 | - | match key.code { | |
| 528 | - | KeyCode::Esc => self.close_draft(), | |
| 529 | - | KeyCode::Enter => self.submit_draft(log), | |
| 530 | - | KeyCode::Tab | KeyCode::Down => self.draft_focus.next(), | |
| 531 | - | KeyCode::BackTab | KeyCode::Up => self.draft_focus.prev(), | |
| 532 | - | _ => { | |
| 533 | - | let slot = self.draft_focus.current(); | |
| 534 | - | let Some(field) = self.draft.as_mut().and_then(|d| d.field_mut(slot)) else { | |
| 535 | - | return true; | |
| 536 | - | }; | |
| 537 | - | match key.code { | |
| 538 | - | KeyCode::Char(c) => field.insert(c), | |
| 539 | - | KeyCode::Backspace => field.backspace(), | |
| 540 | - | KeyCode::Delete => field.delete(), | |
| 541 | - | KeyCode::Left => field.left(), | |
| 542 | - | KeyCode::Right => field.right(), | |
| 543 | - | KeyCode::Home => field.home(), | |
| 544 | - | KeyCode::End => field.end(), | |
| 545 | - | _ => {} | |
| 546 | - | } | |
| 547 | - | } | |
| 548 | - | } | |
| 549 | - | true | |
| 550 | - | } | |
| 551 | - | ||
| 552 | 287 | /// Raise the confirm for removing what is selected. | |
| 553 | 288 | /// | |
| 554 | 289 | /// Both removals are confirmed, and the messages say what is and is not | |
| @@ -594,173 +329,6 @@ | |||
| 594 | 329 | } | |
| 595 | 330 | } | |
| 596 | 331 | } | |
| 597 | - | ||
| 598 | - | fn folder_row<'a>(theme: &Theme, folder: &'a Folder) -> Line<'a> { | |
| 599 | - | Line::from(vec![ | |
| 600 | - | text::bold(theme, format!("{:<20}", truncate(&folder.label, 19))), | |
| 601 | - | text::secondary(theme, format!("{:<28}", truncate(&folder.path, 27))), | |
| 602 | - | text::muted(theme, format!("{:<13}", truncate(&folder.kind, 12))), | |
| 603 | - | Span::styled( | |
| 604 | - | format!("{:<9}", folder.state_label()), | |
| 605 | - | folder.severity().style(theme), | |
| 606 | - | ), | |
| 607 | - | text::muted(theme, format!("{} devices", folder.shared_with())), | |
| 608 | - | ]) | |
| 609 | - | } | |
| 610 | - | ||
| 611 | - | fn device_row<'a>(theme: &Theme, device: &'a Device) -> Line<'a> { | |
| 612 | - | Line::from(vec![ | |
| 613 | - | text::bold(theme, format!("{:<20}", truncate(&device.name, 19))), | |
| 614 | - | text::secondary(theme, format!("{:<10}", device.short_id())), | |
| 615 | - | Span::styled( | |
| 616 | - | format!("{:<15}", device.state_label()), | |
| 617 | - | device.severity().style(theme), | |
| 618 | - | ), | |
| 619 | - | ]) | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | fn pending_row<'a>(theme: &Theme, entry: &'a PendingDevice) -> Line<'a> { | |
| 623 | - | Line::from(vec![ | |
| 624 | - | text::bold(theme, format!("{:<20}", truncate(&entry.name, 19))), | |
| 625 | - | text::secondary(theme, format!("{:<10}", entry.short_id())), | |
| 626 | - | text::muted(theme, format!("{:<22}", truncate(&entry.address, 21))), | |
| 627 | - | Span::styled(format!("{:<10}", "waiting"), Severity::Info.style(theme)), | |
| 628 | - | text::muted(theme, day(&entry.time)), | |
| 629 | - | ]) | |
| 630 | - | } | |
| 631 | - | ||
| 632 | - | /// One labelled field line, with the caret drawn under a character. | |
| 633 | - | /// | |
| 634 | - | /// Same shape as the installer's account fields, minus the masking: none | |
| 635 | - | /// of these is a secret, and a device id in particular is meant to be read | |
| 636 | - | /// back against the one on the other machine's screen. | |
| 637 | - | fn draft_line<'a>(theme: &Theme, label: &'a str, field: &TextField, focused: bool) -> Line<'a> { | |
| 638 | - | let (before, under, after) = field.split(); | |
| 639 | - | let mut spans = vec![ | |
| 640 | - | if focused { | |
| 641 | - | text::bold(theme, format!("{label:>10} ")) | |
| 642 | - | } else { | |
| 643 | - | text::muted(theme, format!("{label:>10} ")) | |
| 644 | - | }, | |
| 645 | - | text::primary(theme, before.to_string()), | |
| 646 | - | ]; | |
| 647 | - | if focused { | |
| 648 | - | // Reversed rather than a block glyph, so the caret sits on the | |
| 649 | - | // character it is about to replace instead of beside it, and a | |
| 650 | - | // space stands in past the end of the value. Same treatment as the | |
| 651 | - | // installer's fields. | |
| 652 | - | spans.push(Span::styled( | |
| 653 | - | under.unwrap_or(' ').to_string(), | |
| 654 | - | Style::default().add_modifier(Modifier::REVERSED), | |
| 655 | - | )); | |
| 656 | - | } else if let Some(under) = under { | |
| 657 | - | spans.push(text::primary(theme, under.to_string())); | |
| 658 | - | } | |
| 659 | - | spans.push(text::primary(theme, after.to_string())); | |
| 660 | - | Line::from(spans) | |
| 661 | - | } | |
| 662 | - | ||
| 663 | - | /// The share editor: every device, with the ones holding this folder marked. | |
| 664 | - | /// | |
| 665 | - | /// A list rather than a form. Sharing is a set, and the question at each row | |
| 666 | - | /// is yes or no, so the overlay shows the answer for every device at once | |
| 667 | - | /// instead of asking the user to remember which are already in. | |
| 668 | - | fn render_share(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 669 | - | let Some((folder_id, cursor)) = &self.share else { | |
| 670 | - | return; | |
| 671 | - | }; | |
| 672 | - | let Some(folder) = self.folder_list().iter().find(|f| &f.id == folder_id) else { | |
| 673 | - | return; | |
| 674 | - | }; | |
| 675 | - | let devices = self.shareable_devices(); | |
| 676 | - | ||
| 677 | - | let height = (devices.len().max(1) as u16) + 4; | |
| 678 | - | let overlay = layout::centered(area, 60, height.min(area.height)); | |
| 679 | - | frame.render_widget(ratatui::widgets::Clear, overlay); | |
| 680 | - | ||
| 681 | - | let block = AlloyBlock::new(theme) | |
| 682 | - | .focused(true) | |
| 683 | - | .build() | |
| 684 | - | .title(block_title(&format!("share {}", folder.label))); | |
| 685 | - | let inner = block.inner(overlay); | |
| 686 | - | frame.render_widget(block, overlay); | |
| 687 | - | ||
| 688 | - | if devices.is_empty() { | |
| 689 | - | frame.render_widget( | |
| 690 | - | Line::from(text::muted(theme, "no other devices to share with")), | |
| 691 | - | inner, | |
| 692 | - | ); | |
| 693 | - | return; | |
| 694 | - | } | |
| 695 | - | ||
| 696 | - | let rows: Vec<Line> = devices | |
| 697 | - | .iter() | |
| 698 | - | .map(|device| { | |
| 699 | - | let shared = folder.is_shared_with(&device.id); | |
| 700 | - | // A mark either way rather than a mark and a blank: an empty | |
| 701 | - | // column reads as "unknown" as easily as it reads as "no". | |
| 702 | - | let mark = if shared { "[x] " } else { "[ ] " }; | |
| 703 | - | Line::from(vec![ | |
| 704 | - | Span::raw(mark), | |
| 705 | - | Span::raw(device.name.clone()), | |
| 706 | - | text::muted(theme, format!(" {}", device.short_id())), | |
| 707 | - | ]) | |
| 708 | - | }) | |
| 709 | - | .collect(); | |
| 710 | - | frame.render_widget( | |
| 711 | - | AlloyList::new(theme, rows).selected(cursor.selected()), | |
| 712 | - | inner, | |
| 713 | - | ); | |
| 714 | - | } | |
| 715 | - | ||
| 716 | - | fn render_draft(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 717 | - | let Some(draft) = &self.draft else { | |
| 718 | - | return; | |
| 719 | - | }; | |
| 720 | - | let labels = draft.labels(); | |
| 721 | - | // Two rows of border, one of padding either side of the fields. | |
| 722 | - | let height = labels.len() as u16 + 4; | |
| 723 | - | let overlay = layout::centered(area, 60, height); | |
| 724 | - | frame.render_widget(ratatui::widgets::Clear, overlay); | |
| 725 | - | ||
| 726 | - | let block = AlloyBlock::new(theme) | |
| 727 | - | .focused(true) | |
| 728 | - | .build() | |
| 729 | - | .title(block_title(draft.title())); | |
| 730 | - | let inner = block.inner(overlay); | |
| 731 | - | frame.render_widget(block, overlay); | |
| 732 | - | ||
| 733 | - | let lines: Vec<Line> = labels | |
| 734 | - | .iter() | |
| 735 | - | .enumerate() | |
| 736 | - | .filter_map(|(slot, label)| { | |
| 737 | - | let field = draft.field(slot)?; | |
| 738 | - | Some(Self::draft_line( | |
| 739 | - | theme, | |
| 740 | - | label, | |
| 741 | - | field, | |
| 742 | - | self.draft_focus.is_focused(slot), | |
| 743 | - | )) | |
| 744 | - | }) | |
| 745 | - | .collect(); | |
| 746 | - | frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner); | |
| 747 | - | } | |
| 748 | - | ||
| 749 | - | /// The offer shown when Syncthing is installed and not running. | |
| 750 | - | fn render_offer(frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 751 | - | let lines = vec![ | |
| 752 | - | Line::from(text::muted( | |
| 753 | - | theme, | |
| 754 | - | "file sync is not enrolled on this machine", | |
| 755 | - | )), | |
| 756 | - | Line::from(""), | |
| 757 | - | Line::from(text::secondary( | |
| 758 | - | theme, | |
| 759 | - | format!("press e to run: systemctl --user enable --now {UNIT}"), | |
| 760 | - | )), | |
| 761 | - | ]; | |
| 762 | - | frame.render_widget(ratatui::widgets::Paragraph::new(lines), area); | |
| 763 | - | } | |
| 764 | 332 | } | |
| 765 | 333 | ||
| 766 | 334 | impl View for SyncView { | |
| @@ -1035,6 +603,8 @@ | |||
| 1035 | 603 | ||
| 1036 | 604 | #[cfg(test)] | |
| 1037 | 605 | mod tests { | |
| 606 | + | use alloy_tui::TextField; | |
| 607 | + | ||
| 1038 | 608 | use super::*; | |
| 1039 | 609 | ||
| 1040 | 610 | #[test] |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | //! [`DiskView`]'s private fields and builds one field by field, which is what | |
| 7 | 7 | //! keeps every one of those fields private. | |
| 8 | 8 | ||
| 9 | + | use super::edits::Editing; | |
| 9 | 10 | use super::*; | |
| 10 | 11 | use crate::cli::Invocation; | |
| 11 | 12 | use crate::disk::backend::{LsBlk, Mock}; | |
| @@ -357,28 +358,6 @@ | |||
| 357 | 358 | assert!(view.error.is_some(), "the parse failure is reported"); | |
| 358 | 359 | } | |
| 359 | 360 | ||
| 360 | - | /// Decimal by default, matching what the rows print. Binary only where the | |
| 361 | - | /// `i` is written, and a bare number is bytes rather than a unit somebody | |
| 362 | - | /// has to guess. | |
| 363 | - | #[test] | |
| 364 | - | fn sizes_parse_the_way_a_person_writes_them() { | |
| 365 | - | assert_eq!(parse_size("16GB").unwrap(), 16_000_000_000); | |
| 366 | - | assert_eq!(parse_size(" 16 gb ").unwrap(), 16_000_000_000); | |
| 367 | - | assert_eq!(parse_size("16GiB").unwrap(), 16 << 30); | |
| 368 | - | assert_eq!(parse_size("1.5TB").unwrap(), 1_500_000_000_000); | |
| 369 | - | assert_eq!(parse_size("4096").unwrap(), 4096); | |
| 370 | - | // The round trip that matters: what a row prints is a size this parses | |
| 371 | - | // back to the same number, modulo the one decimal place it prints. | |
| 372 | - | assert_eq!( | |
| 373 | - | parse_size(&format_size(16_000_000_000)).unwrap(), | |
| 374 | - | 16_000_000_000 | |
| 375 | - | ); | |
| 376 | - | ||
| 377 | - | for bad in ["", " ", "some", "-5GB", "0", "GB"] { | |
| 378 | - | assert!(parse_size(bad).is_err(), "{bad} should not parse"); | |
| 379 | - | } | |
| 380 | - | } | |
| 381 | - | ||
| 382 | 361 | /// The chosen filesystem reaches the command. A chooser that always | |
| 383 | 362 | /// formatted ext4 would be worse than no chooser. | |
| 384 | 363 | #[test] |
| @@ -1,0 +1,329 @@ | |||
| 1 | + | //! The partition edits: collecting a value, then the confirm, then the | |
| 2 | + | //! command. | |
| 3 | + | //! | |
| 4 | + | //! Two of the four operations need a number and one needs a choice from a | |
| 5 | + | //! list, so the surface is a shallow state machine rather than four keys that | |
| 6 | + | //! act immediately. Escape leaves at any point and nothing has happened. | |
| 7 | + | ||
| 8 | + | use anyhow::{Context, Result}; | |
| 9 | + | ||
| 10 | + | use alloy_tui::{Cursor, TextField}; | |
| 11 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 12 | + | ||
| 13 | + | use super::super::backend::FILESYSTEMS; | |
| 14 | + | use super::super::model::{Blocked, Drive, Volume}; | |
| 15 | + | use super::{DiskView, PendingAction, describe_loss, no_udisks}; | |
| 16 | + | use crate::shell::{Confirm, Flow}; | |
| 17 | + | use crate::size::format_size; | |
| 18 | + | ||
| 19 | + | /// A value being typed or picked before its action can be confirmed. | |
| 20 | + | /// | |
| 21 | + | /// Two of the four partition operations need a number and one needs a choice | |
| 22 | + | /// from a list, so the surface is a small state machine rather than four keys | |
| 23 | + | /// that act immediately. The machine is deliberately shallow: one step, then | |
| 24 | + | /// the confirm, then the command. Escape leaves at any point and nothing has | |
| 25 | + | /// happened. | |
| 26 | + | pub(super) enum Editing { | |
| 27 | + | /// Size for a new partition on this drive. Empty means fill the free space. | |
| 28 | + | CreateSize { drive: Drive, field: TextField }, | |
| 29 | + | /// Which filesystem to write onto this volume. | |
| 30 | + | FormatType { volume: Volume, choice: Cursor }, | |
| 31 | + | /// New size for this partition. | |
| 32 | + | ResizeSize { volume: Volume, field: TextField }, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl Editing { | |
| 36 | + | /// The line above the field, naming what is being asked and what it acts | |
| 37 | + | /// on. A prompt that says only "size:" leaves the user to remember which | |
| 38 | + | /// row they pressed a key on. | |
| 39 | + | pub(super) fn prompt(&self) -> String { | |
| 40 | + | match self { | |
| 41 | + | Self::CreateSize { drive, .. } => format!( | |
| 42 | + | "New partition on {}. Size (blank fills the free space), Enter to confirm, Esc to cancel:", | |
| 43 | + | drive.path | |
| 44 | + | ), | |
| 45 | + | Self::FormatType { volume, .. } => format!( | |
| 46 | + | "Format {}. j/k to choose, Enter to confirm, Esc to cancel:", | |
| 47 | + | volume.path | |
| 48 | + | ), | |
| 49 | + | Self::ResizeSize { volume, .. } => format!( | |
| 50 | + | "Resize {} from {}. New size, Enter to confirm, Esc to cancel:", | |
| 51 | + | volume.path, | |
| 52 | + | format_size(volume.size) | |
| 53 | + | ), | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Parse a size the way a person writes one. | |
| 59 | + | /// | |
| 60 | + | /// Decimal units by default, matching [`format_size`]: this screen prints | |
| 61 | + | /// "16.0 GB" for a stick and a user typing "16GB" back at it must mean the same | |
| 62 | + | /// number. Binary units are accepted where they are spelled out (`GiB`), since | |
| 63 | + | /// anyone who writes the `i` knows which one they want. | |
| 64 | + | /// | |
| 65 | + | /// A bare number is bytes. Not megabytes, which some partitioners assume: a | |
| 66 | + | /// silent factor of a million between what was typed and what is created is | |
| 67 | + | /// exactly the kind of thing this console must not do. | |
| 68 | + | fn parse_size(raw: &str) -> Result<u64> { | |
| 69 | + | const SCALES: [(&str, u64); 9] = [ | |
| 70 | + | ("kb", 1_000), | |
| 71 | + | ("mb", 1_000_000), | |
| 72 | + | ("gb", 1_000_000_000), | |
| 73 | + | ("tb", 1_000_000_000_000), | |
| 74 | + | ("kib", 1 << 10), | |
| 75 | + | ("mib", 1 << 20), | |
| 76 | + | ("gib", 1 << 30), | |
| 77 | + | ("tib", 1 << 40), | |
| 78 | + | ("b", 1), | |
| 79 | + | ]; | |
| 80 | + | ||
| 81 | + | let trimmed = raw.trim().to_ascii_lowercase(); | |
| 82 | + | if trimmed.is_empty() { | |
| 83 | + | anyhow::bail!("no size given"); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Longest suffix first, so "gib" is not read as "b" with "gi" left over. | |
| 87 | + | let mut candidates: Vec<(&str, u64)> = SCALES.to_vec(); | |
| 88 | + | candidates.sort_by_key(|(unit, _)| std::cmp::Reverse(unit.len())); | |
| 89 | + | ||
| 90 | + | let (number, scale) = candidates | |
| 91 | + | .iter() | |
| 92 | + | .find_map(|(unit, scale)| trimmed.strip_suffix(unit).map(|rest| (rest, *scale))) | |
| 93 | + | .unwrap_or((trimmed.as_str(), 1)); | |
| 94 | + | ||
| 95 | + | let number: f64 = number | |
| 96 | + | .trim() | |
| 97 | + | .parse() | |
| 98 | + | .with_context(|| format!("not a size: {}", raw.trim()))?; | |
| 99 | + | if !number.is_finite() || number <= 0.0 { | |
| 100 | + | anyhow::bail!("not a size: {}", raw.trim()); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | let bytes = number * scale as f64; | |
| 104 | + | if bytes >= u64::MAX as f64 { | |
| 105 | + | anyhow::bail!("size is larger than any disk: {}", raw.trim()); | |
| 106 | + | } | |
| 107 | + | Ok(bytes as u64) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | impl DiskView { | |
| 111 | + | /// Start a new partition on the selected row's drive. | |
| 112 | + | pub(super) fn create_partition(&mut self) { | |
| 113 | + | let Some(volume) = self.selected() else { | |
| 114 | + | self.error = Some("nothing selected".to_string()); | |
| 115 | + | return; | |
| 116 | + | }; | |
| 117 | + | if self.drive_is_system(&volume.drive) { | |
| 118 | + | self.error = Some(format!( | |
| 119 | + | "{} {}", | |
| 120 | + | volume.drive.path, | |
| 121 | + | Blocked::SystemDisk.reason() | |
| 122 | + | )); | |
| 123 | + | return; | |
| 124 | + | } | |
| 125 | + | if self.backend.create_partition(&volume.drive, 0).is_none() { | |
| 126 | + | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 127 | + | return; | |
| 128 | + | } | |
| 129 | + | self.editing = Some(Editing::CreateSize { | |
| 130 | + | drive: volume.drive.clone(), | |
| 131 | + | field: TextField::new(), | |
| 132 | + | }); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Delete the selected partition. One step: there is nothing to ask. | |
| 136 | + | pub(super) fn delete_partition(&mut self) -> Flow { | |
| 137 | + | let Some(volume) = self.selected() else { | |
| 138 | + | self.error = Some("nothing selected".to_string()); | |
| 139 | + | return Flow::Continue; | |
| 140 | + | }; | |
| 141 | + | if let Some(blocked) = self.edit_blocker(&volume) { | |
| 142 | + | self.refuse(&volume, blocked); | |
| 143 | + | return Flow::Continue; | |
| 144 | + | } | |
| 145 | + | if self.backend.delete_partition(&volume).is_none() { | |
| 146 | + | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 147 | + | return Flow::Continue; | |
| 148 | + | } | |
| 149 | + | let message = format!("Delete {}? {}", volume.path, describe_loss(&volume)); | |
| 150 | + | self.pending_action = Some(PendingAction::Delete(volume)); | |
| 151 | + | Flow::Confirm(Confirm::destructive("delete partition", message)) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | pub(super) fn format_selected(&mut self) { | |
| 155 | + | let Some(volume) = self.selected() else { | |
| 156 | + | self.error = Some("nothing selected".to_string()); | |
| 157 | + | return; | |
| 158 | + | }; | |
| 159 | + | if self.drive_is_system(&volume.drive) { | |
| 160 | + | self.refuse(&volume, Blocked::SystemDisk); | |
| 161 | + | return; | |
| 162 | + | } | |
| 163 | + | if let Some(blocked) = volume.format_blocker() { | |
| 164 | + | self.refuse(&volume, blocked); | |
| 165 | + | return; | |
| 166 | + | } | |
| 167 | + | if self.backend.format(&volume, FILESYSTEMS[0]).is_none() { | |
| 168 | + | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 169 | + | return; | |
| 170 | + | } | |
| 171 | + | let mut choice = Cursor::new(); | |
| 172 | + | choice.resize(FILESYSTEMS.len()); | |
| 173 | + | self.editing = Some(Editing::FormatType { volume, choice }); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | pub(super) fn resize_selected(&mut self) { | |
| 177 | + | let Some(volume) = self.selected() else { | |
| 178 | + | self.error = Some("nothing selected".to_string()); | |
| 179 | + | return; | |
| 180 | + | }; | |
| 181 | + | if let Some(blocked) = self.edit_blocker(&volume) { | |
| 182 | + | self.refuse(&volume, blocked); | |
| 183 | + | return; | |
| 184 | + | } | |
| 185 | + | if self.backend.resize_partition(&volume, 0).is_none() { | |
| 186 | + | self.error = Some(no_udisks(self.backend.name(), &volume)); | |
| 187 | + | return; | |
| 188 | + | } | |
| 189 | + | let mut field = TextField::new(); | |
| 190 | + | field.set(format_size(volume.size).replace(' ', "")); | |
| 191 | + | field.end(); | |
| 192 | + | self.editing = Some(Editing::ResizeSize { volume, field }); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Turn a finished edit into a pending action and its confirmation. | |
| 196 | + | /// | |
| 197 | + | /// Every branch ends in a confirm. That is the restated rule for this | |
| 198 | + | /// surface: `disk`'s existing one is that a confirm appears only where a | |
| 199 | + | /// mistake costs something, and every partition write costs something, so | |
| 200 | + | /// the exception swallows the rule here rather than being an exception. | |
| 201 | + | /// What keeps the confirms from becoming reflex is that they carry the | |
| 202 | + | /// facts that decide the answer, not the word "sure". | |
| 203 | + | pub(super) fn commit_edit(&mut self) -> Flow { | |
| 204 | + | let Some(editing) = self.editing.take() else { | |
| 205 | + | return Flow::Continue; | |
| 206 | + | }; | |
| 207 | + | match editing { | |
| 208 | + | Editing::CreateSize { drive, field } => { | |
| 209 | + | let raw = field.value().trim().to_string(); | |
| 210 | + | let size = if raw.is_empty() { | |
| 211 | + | 0 | |
| 212 | + | } else { | |
| 213 | + | match parse_size(&raw) { | |
| 214 | + | Ok(size) => size, | |
| 215 | + | Err(err) => { | |
| 216 | + | self.error = Some(err.to_string()); | |
| 217 | + | return Flow::Continue; | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | }; | |
| 221 | + | let message = format!( | |
| 222 | + | "Create a {} partition on {} ({})? The partition table is rewritten.", | |
| 223 | + | if size == 0 { | |
| 224 | + | "free-space-filling".to_string() | |
| 225 | + | } else { | |
| 226 | + | format_size(size) | |
| 227 | + | }, | |
| 228 | + | drive.path, | |
| 229 | + | drive.model_or_dash(), | |
| 230 | + | ); | |
| 231 | + | self.pending_action = Some(PendingAction::Create { drive, size }); | |
| 232 | + | Flow::Confirm(Confirm::destructive("create partition", message)) | |
| 233 | + | } | |
| 234 | + | Editing::FormatType { volume, choice } => { | |
| 235 | + | let fstype = FILESYSTEMS[choice.selected().unwrap_or(0)].to_string(); | |
| 236 | + | let message = format!( | |
| 237 | + | "Format {} as {fstype}? {}", | |
| 238 | + | volume.path, | |
| 239 | + | describe_loss(&volume) | |
| 240 | + | ); | |
| 241 | + | self.pending_action = Some(PendingAction::Format { volume, fstype }); | |
| 242 | + | Flow::Confirm(Confirm::destructive("format", message)) | |
| 243 | + | } | |
| 244 | + | Editing::ResizeSize { volume, field } => { | |
| 245 | + | let size = match parse_size(field.value()) { | |
| 246 | + | Ok(size) => size, | |
| 247 | + | Err(err) => { | |
| 248 | + | self.error = Some(err.to_string()); | |
| 249 | + | return Flow::Continue; | |
| 250 | + | } | |
| 251 | + | }; | |
| 252 | + | let direction = if size < volume.size { | |
| 253 | + | "Shrinking can destroy data past the new end." | |
| 254 | + | } else { | |
| 255 | + | "Growing needs free space after the partition." | |
| 256 | + | }; | |
| 257 | + | let message = format!( | |
| 258 | + | "Resize {} from {} to {}? {} {}", | |
| 259 | + | volume.path, | |
| 260 | + | format_size(volume.size), | |
| 261 | + | format_size(size), | |
| 262 | + | describe_loss(&volume), | |
| 263 | + | direction, | |
| 264 | + | ); | |
| 265 | + | self.pending_action = Some(PendingAction::Resize { volume, size }); | |
| 266 | + | Flow::Confirm(Confirm::destructive("resize partition", message)) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// Key handling while a value is being collected. `None` means the key was | |
| 272 | + | /// not ours and the normal bindings should see it. | |
| 273 | + | pub(super) fn handle_editing(&mut self, key: KeyEvent) -> Option<Flow> { | |
| 274 | + | let editing = self.editing.as_mut()?; | |
| 275 | + | match (key.code, editing) { | |
| 276 | + | (KeyCode::Esc, _) => { | |
| 277 | + | self.editing = None; | |
| 278 | + | } | |
| 279 | + | (KeyCode::Enter, _) => return Some(self.commit_edit()), | |
| 280 | + | (KeyCode::Char('j') | KeyCode::Down, Editing::FormatType { choice, .. }) => { | |
| 281 | + | choice.next(); | |
| 282 | + | } | |
| 283 | + | (KeyCode::Char('k') | KeyCode::Up, Editing::FormatType { choice, .. }) => { | |
| 284 | + | choice.prev(); | |
| 285 | + | } | |
| 286 | + | (_, Editing::FormatType { .. }) => {} | |
| 287 | + | (code, Editing::CreateSize { field, .. } | Editing::ResizeSize { field, .. }) => { | |
| 288 | + | match code { | |
| 289 | + | KeyCode::Char(c) => field.insert(c), | |
| 290 | + | KeyCode::Backspace => field.backspace(), | |
| 291 | + | KeyCode::Delete => field.delete(), | |
| 292 | + | KeyCode::Left => field.left(), | |
| 293 | + | KeyCode::Right => field.right(), | |
| 294 | + | KeyCode::Home => field.home(), | |
| 295 | + | KeyCode::End => field.end(), | |
| 296 | + | _ => {} | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | Some(Flow::Continue) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[cfg(test)] | |
| 305 | + | mod tests { | |
| 306 | + | use super::*; | |
| 307 | + | ||
| 308 | + | /// Decimal by default, matching what the rows print. Binary only where the | |
| 309 | + | /// `i` is written, and a bare number is bytes rather than a unit somebody | |
| 310 | + | /// has to guess. | |
| 311 | + | #[test] | |
| 312 | + | fn sizes_parse_the_way_a_person_writes_them() { | |
| 313 | + | assert_eq!(parse_size("16GB").unwrap(), 16_000_000_000); | |
| 314 | + | assert_eq!(parse_size(" 16 gb ").unwrap(), 16_000_000_000); | |
| 315 | + | assert_eq!(parse_size("16GiB").unwrap(), 16 << 30); | |
| 316 | + | assert_eq!(parse_size("1.5TB").unwrap(), 1_500_000_000_000); | |
| 317 | + | assert_eq!(parse_size("4096").unwrap(), 4096); | |
| 318 | + | // The round trip that matters: what a row prints is a size this parses | |
| 319 | + | // back to the same number, modulo the one decimal place it prints. | |
| 320 | + | assert_eq!( | |
| 321 | + | parse_size(&format_size(16_000_000_000)).unwrap(), | |
| 322 | + | 16_000_000_000 | |
| 323 | + | ); | |
| 324 | + | ||
| 325 | + | for bad in ["", " ", "some", "-5GB", "0", "GB"] { | |
| 326 | + | assert!(parse_size(bad).is_err(), "{bad} should not parse"); | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | } |
| @@ -1,0 +1,282 @@ | |||
| 1 | + | //! The two overlays this screen raises: the share editor over a folder, and | |
| 2 | + | //! the add form for a folder or a device. | |
| 3 | + | //! | |
| 4 | + | //! One file because they are one shape — a modal that eats every key while it | |
| 5 | + | //! is up, Enter to commit, Esc to close — and because both are the answer to | |
| 6 | + | //! "the list is not enough" rather than part of the list. | |
| 7 | + | ||
| 8 | + | use alloy_tui::{Cursor, FocusRing, TextField}; | |
| 9 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 10 | + | ||
| 11 | + | use super::super::backend::{DeviceDraft, FolderDraft, validate_device, validate_folder}; | |
| 12 | + | use super::super::model::{Device, Reach}; | |
| 13 | + | use super::{SyncView, Tab}; | |
| 14 | + | use crate::cli::CommandLog; | |
| 15 | + | ||
| 16 | + | /// The add overlay, when one is open. | |
| 17 | + | /// | |
| 18 | + | /// Two shapes rather than one form with optional rows: a folder and a device | |
| 19 | + | /// share no fields, and a single struct carrying both sets would spend every | |
| 20 | + | /// read asking which half is live. | |
| 21 | + | pub(super) enum Draft { | |
| 22 | + | Folder { | |
| 23 | + | id: TextField, | |
| 24 | + | label: TextField, | |
| 25 | + | path: TextField, | |
| 26 | + | }, | |
| 27 | + | Device { | |
| 28 | + | id: TextField, | |
| 29 | + | name: TextField, | |
| 30 | + | }, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | impl Draft { | |
| 34 | + | /// Field labels, in slot order, so the renderer and the focus ring agree | |
| 35 | + | /// about what slot 1 is. | |
| 36 | + | const FOLDER_LABELS: [&'static str; 3] = ["id", "label", "path"]; | |
| 37 | + | const DEVICE_LABELS: [&'static str; 2] = ["device id", "name"]; | |
| 38 | + | ||
| 39 | + | pub(super) fn labels(&self) -> &'static [&'static str] { | |
| 40 | + | match self { | |
| 41 | + | Draft::Folder { .. } => &Self::FOLDER_LABELS, | |
| 42 | + | Draft::Device { .. } => &Self::DEVICE_LABELS, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | pub(super) fn field_mut(&mut self, slot: usize) -> Option<&mut TextField> { | |
| 47 | + | match self { | |
| 48 | + | Draft::Folder { id, label, path } => match slot { | |
| 49 | + | 0 => Some(id), | |
| 50 | + | 1 => Some(label), | |
| 51 | + | 2 => Some(path), | |
| 52 | + | _ => None, | |
| 53 | + | }, | |
| 54 | + | Draft::Device { id, name } => match slot { | |
| 55 | + | 0 => Some(id), | |
| 56 | + | 1 => Some(name), | |
| 57 | + | _ => None, | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pub(super) fn field(&self, slot: usize) -> Option<&TextField> { | |
| 63 | + | match self { | |
| 64 | + | Draft::Folder { id, label, path } => match slot { | |
| 65 | + | 0 => Some(id), | |
| 66 | + | 1 => Some(label), | |
| 67 | + | 2 => Some(path), | |
| 68 | + | _ => None, | |
| 69 | + | }, | |
| 70 | + | Draft::Device { id, name } => match slot { | |
| 71 | + | 0 => Some(id), | |
| 72 | + | 1 => Some(name), | |
| 73 | + | _ => None, | |
| 74 | + | }, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | pub(super) fn title(&self) -> &'static str { | |
| 79 | + | match self { | |
| 80 | + | Draft::Folder { .. } => "add folder", | |
| 81 | + | Draft::Device { .. } => "add device", | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | impl SyncView { | |
| 87 | + | /// Keys while the share editor is open. | |
| 88 | + | /// | |
| 89 | + | /// Returns true when it consumed the key, so nothing underneath sees it. | |
| 90 | + | pub(super) fn handle_share(&mut self, key: KeyEvent, log: &mut CommandLog) -> bool { | |
| 91 | + | if self.share.is_none() { | |
| 92 | + | return false; | |
| 93 | + | } | |
| 94 | + | match key.code { | |
| 95 | + | KeyCode::Esc => self.share = None, | |
| 96 | + | KeyCode::Char('j') | KeyCode::Down => { | |
| 97 | + | if let Some((_, cursor)) = &mut self.share { | |
| 98 | + | cursor.next(); | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | KeyCode::Char('k') | KeyCode::Up => { | |
| 102 | + | if let Some((_, cursor)) = &mut self.share { | |
| 103 | + | cursor.prev(); | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | KeyCode::Char(' ') | KeyCode::Enter => self.toggle_share(log), | |
| 107 | + | // Everything else is swallowed rather than passed down. A key with | |
| 108 | + | // no meaning here is not a key that should mean something to the | |
| 109 | + | // list behind the overlay. | |
| 110 | + | _ => {} | |
| 111 | + | } | |
| 112 | + | true | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Open the share editor for the selected folder. | |
| 116 | + | /// | |
| 117 | + | /// A folder is shared with a set of devices, and until now this screen | |
| 118 | + | /// could only report how many. docs/CONTINUITY.md listed editing that list | |
| 119 | + | /// as still to come; `syncthing cli config folders <id> devices` had the | |
| 120 | + | /// whole operation the entire time. | |
| 121 | + | pub(super) fn open_share(&mut self) { | |
| 122 | + | if self.tab != Tab::Folders { | |
| 123 | + | return; | |
| 124 | + | } | |
| 125 | + | let Some(folder) = self.selected_folder() else { | |
| 126 | + | return; | |
| 127 | + | }; | |
| 128 | + | let id = folder.id.clone(); | |
| 129 | + | let mut cursor = Cursor::new(); | |
| 130 | + | cursor.resize(self.shareable_devices().len()); | |
| 131 | + | self.share = Some((id, cursor)); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// The devices a folder can be shared with: everyone but this machine. | |
| 135 | + | /// | |
| 136 | + | /// This machine is in every folder's device list and removing it there does | |
| 137 | + | /// not mean "unshare", it means the folder stops being here at all. That is | |
| 138 | + | /// what `d` on the folder row is for, and it confirms first. Leaving it out | |
| 139 | + | /// of this list keeps one keystroke from meaning two very different things. | |
| 140 | + | pub(super) fn shareable_devices(&self) -> Vec<&Device> { | |
| 141 | + | match &self.reach { | |
| 142 | + | Reach::Running(state) => state | |
| 143 | + | .devices | |
| 144 | + | .iter() | |
| 145 | + | .filter(|device| !device.is_self) | |
| 146 | + | .collect(), | |
| 147 | + | Reach::NotRunning => Vec::new(), | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Toggle the selected device's share of the folder being edited. | |
| 152 | + | /// | |
| 153 | + | /// Applied at once rather than gathered and saved, which is what `p` does | |
| 154 | + | /// for pausing and what the log pane teaches: one keystroke, one command, | |
| 155 | + | /// visible in the pane as something the user could have typed. | |
| 156 | + | pub(super) fn toggle_share(&mut self, log: &mut CommandLog) { | |
| 157 | + | let Some((folder_id, cursor)) = &self.share else { | |
| 158 | + | return; | |
| 159 | + | }; | |
| 160 | + | let Some(slot) = cursor.selected() else { | |
| 161 | + | return; | |
| 162 | + | }; | |
| 163 | + | let Some(device) = self.shareable_devices().get(slot).map(|d| (*d).clone()) else { | |
| 164 | + | return; | |
| 165 | + | }; | |
| 166 | + | let Some(folder) = self | |
| 167 | + | .folder_list() | |
| 168 | + | .iter() | |
| 169 | + | .find(|f| &f.id == folder_id) | |
| 170 | + | .cloned() | |
| 171 | + | else { | |
| 172 | + | return; | |
| 173 | + | }; | |
| 174 | + | let shared = folder.is_shared_with(&device.id); | |
| 175 | + | let result = self | |
| 176 | + | .backend | |
| 177 | + | .set_folder_shared(&folder, &device, !shared, log); | |
| 178 | + | self.finish(result, log); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Open the add overlay for whichever tab is showing. | |
| 182 | + | pub(super) fn open_draft(&mut self) { | |
| 183 | + | let draft = match self.tab { | |
| 184 | + | Tab::Folders => Draft::Folder { | |
| 185 | + | id: TextField::new(), | |
| 186 | + | label: TextField::new(), | |
| 187 | + | path: TextField::new(), | |
| 188 | + | }, | |
| 189 | + | Tab::Devices => Draft::Device { | |
| 190 | + | id: TextField::new(), | |
| 191 | + | name: TextField::new(), | |
| 192 | + | }, | |
| 193 | + | // `a` on the pending tab accepts rather than opens a form: the id | |
| 194 | + | // and name are already known, and retyping a 56-character id that | |
| 195 | + | // is on screen would be the opposite of help. | |
| 196 | + | Tab::Pending => return, | |
| 197 | + | }; | |
| 198 | + | self.draft_focus = FocusRing::new(draft.labels().len()); | |
| 199 | + | self.draft = Some(draft); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | pub(super) fn close_draft(&mut self) { | |
| 203 | + | self.draft = None; | |
| 204 | + | self.draft_focus = FocusRing::new(0); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Validate the open draft and hand it to the backend. | |
| 208 | + | /// | |
| 209 | + | /// A rejected draft stays on screen with the reason in the status line, | |
| 210 | + | /// rather than closing and losing what was typed. Retyping a 56-character | |
| 211 | + | /// device id because one group was wrong would be the worst possible | |
| 212 | + | /// answer to a typo. | |
| 213 | + | pub(super) fn submit_draft(&mut self, log: &mut CommandLog) { | |
| 214 | + | let Some(draft) = &self.draft else { | |
| 215 | + | return; | |
| 216 | + | }; | |
| 217 | + | let result = match draft { | |
| 218 | + | Draft::Folder { id, label, path } => { | |
| 219 | + | let draft = FolderDraft { | |
| 220 | + | id: id.value().to_string(), | |
| 221 | + | label: label.value().to_string(), | |
| 222 | + | path: path.value().to_string(), | |
| 223 | + | }; | |
| 224 | + | if let Err(reason) = validate_folder(&draft) { | |
| 225 | + | self.error = Some(reason); | |
| 226 | + | return; | |
| 227 | + | } | |
| 228 | + | self.backend.add_folder(&draft, log) | |
| 229 | + | } | |
| 230 | + | Draft::Device { id, name } => { | |
| 231 | + | let draft = DeviceDraft { | |
| 232 | + | id: id.value().to_string(), | |
| 233 | + | name: name.value().to_string(), | |
| 234 | + | }; | |
| 235 | + | if let Err(reason) = validate_device(&draft) { | |
| 236 | + | self.error = Some(reason); | |
| 237 | + | return; | |
| 238 | + | } | |
| 239 | + | self.backend.add_device(&draft, log) | |
| 240 | + | } | |
| 241 | + | }; | |
| 242 | + | // Closed only on success, for the same reason a rejected draft stays | |
| 243 | + | // up: a command that failed has typed input still worth keeping. | |
| 244 | + | if result.is_ok() { | |
| 245 | + | self.close_draft(); | |
| 246 | + | } | |
| 247 | + | self.finish(result, log); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// Keys while the add overlay is open. | |
| 251 | + | /// | |
| 252 | + | /// Returns `true` when the overlay consumed the key, so the list bindings | |
| 253 | + | /// underneath never see a `p` that was meant to be part of a path. | |
| 254 | + | pub(super) fn handle_draft(&mut self, key: KeyEvent, log: &mut CommandLog) -> bool { | |
| 255 | + | if self.draft.is_none() { | |
| 256 | + | return false; | |
| 257 | + | } | |
| 258 | + | match key.code { | |
| 259 | + | KeyCode::Esc => self.close_draft(), | |
| 260 | + | KeyCode::Enter => self.submit_draft(log), | |
| 261 | + | KeyCode::Tab | KeyCode::Down => self.draft_focus.next(), | |
| 262 | + | KeyCode::BackTab | KeyCode::Up => self.draft_focus.prev(), | |
| 263 | + | _ => { | |
| 264 | + | let slot = self.draft_focus.current(); | |
| 265 | + | let Some(field) = self.draft.as_mut().and_then(|d| d.field_mut(slot)) else { | |
| 266 | + | return true; | |
| 267 | + | }; | |
| 268 | + | match key.code { | |
| 269 | + | KeyCode::Char(c) => field.insert(c), | |
| 270 | + | KeyCode::Backspace => field.backspace(), | |
| 271 | + | KeyCode::Delete => field.delete(), | |
| 272 | + | KeyCode::Left => field.left(), | |
| 273 | + | KeyCode::Right => field.right(), | |
| 274 | + | KeyCode::Home => field.home(), | |
| 275 | + | KeyCode::End => field.end(), | |
| 276 | + | _ => {} | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | true | |
| 281 | + | } | |
| 282 | + | } |
| @@ -1,0 +1,186 @@ | |||
| 1 | + | //! Rows and overlay panes, drawn. | |
| 2 | + | ||
| 3 | + | use alloy_tui::{AlloyBlock, AlloyList, Severity, TextField, Theme, layout, text}; | |
| 4 | + | use ratatui::Frame; | |
| 5 | + | use ratatui::layout::Rect; | |
| 6 | + | use ratatui::style::{Modifier, Style}; | |
| 7 | + | use ratatui::text::{Line, Span}; | |
| 8 | + | ||
| 9 | + | use super::super::backend::UNIT; | |
| 10 | + | use super::super::model::{Device, Folder, PendingDevice, day}; | |
| 11 | + | use super::SyncView; | |
| 12 | + | use crate::shell::{block_title, truncate}; | |
| 13 | + | ||
| 14 | + | impl SyncView { | |
| 15 | + | pub(super) fn folder_row<'a>(theme: &Theme, folder: &'a Folder) -> Line<'a> { | |
| 16 | + | Line::from(vec![ | |
| 17 | + | text::bold(theme, format!("{:<20}", truncate(&folder.label, 19))), | |
| 18 | + | text::secondary(theme, format!("{:<28}", truncate(&folder.path, 27))), | |
| 19 | + | text::muted(theme, format!("{:<13}", truncate(&folder.kind, 12))), | |
| 20 | + | Span::styled( | |
| 21 | + | format!("{:<9}", folder.state_label()), | |
| 22 | + | folder.severity().style(theme), | |
| 23 | + | ), | |
| 24 | + | text::muted(theme, format!("{} devices", folder.shared_with())), | |
| 25 | + | ]) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | pub(super) fn device_row<'a>(theme: &Theme, device: &'a Device) -> Line<'a> { | |
| 29 | + | Line::from(vec![ | |
| 30 | + | text::bold(theme, format!("{:<20}", truncate(&device.name, 19))), | |
| 31 | + | text::secondary(theme, format!("{:<10}", device.short_id())), | |
| 32 | + | Span::styled( | |
| 33 | + | format!("{:<15}", device.state_label()), | |
| 34 | + | device.severity().style(theme), | |
| 35 | + | ), | |
| 36 | + | ]) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | pub(super) fn pending_row<'a>(theme: &Theme, entry: &'a PendingDevice) -> Line<'a> { | |
| 40 | + | Line::from(vec![ | |
| 41 | + | text::bold(theme, format!("{:<20}", truncate(&entry.name, 19))), | |
| 42 | + | text::secondary(theme, format!("{:<10}", entry.short_id())), | |
| 43 | + | text::muted(theme, format!("{:<22}", truncate(&entry.address, 21))), | |
| 44 | + | Span::styled(format!("{:<10}", "waiting"), Severity::Info.style(theme)), | |
| 45 | + | text::muted(theme, day(&entry.time)), | |
| 46 | + | ]) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// One labelled field line, with the caret drawn under a character. | |
| 50 | + | /// | |
| 51 | + | /// Same shape as the installer's account fields, minus the masking: none | |
| 52 | + | /// of these is a secret, and a device id in particular is meant to be read | |
| 53 | + | /// back against the one on the other machine's screen. | |
| 54 | + | pub(super) fn draft_line<'a>( | |
| 55 | + | theme: &Theme, | |
| 56 | + | label: &'a str, | |
| 57 | + | field: &TextField, | |
| 58 | + | focused: bool, | |
| 59 | + | ) -> Line<'a> { | |
| 60 | + | let (before, under, after) = field.split(); | |
| 61 | + | let mut spans = vec![ | |
| 62 | + | if focused { | |
| 63 | + | text::bold(theme, format!("{label:>10} ")) | |
| 64 | + | } else { | |
| 65 | + | text::muted(theme, format!("{label:>10} ")) | |
| 66 | + | }, | |
| 67 | + | text::primary(theme, before.to_string()), | |
| 68 | + | ]; | |
| 69 | + | if focused { | |
| 70 | + | // Reversed rather than a block glyph, so the caret sits on the | |
| 71 | + | // character it is about to replace instead of beside it, and a | |
| 72 | + | // space stands in past the end of the value. Same treatment as the | |
| 73 | + | // installer's fields. | |
| 74 | + | spans.push(Span::styled( | |
| 75 | + | under.unwrap_or(' ').to_string(), | |
| 76 | + | Style::default().add_modifier(Modifier::REVERSED), | |
| 77 | + | )); | |
| 78 | + | } else if let Some(under) = under { | |
| 79 | + | spans.push(text::primary(theme, under.to_string())); | |
| 80 | + | } | |
| 81 | + | spans.push(text::primary(theme, after.to_string())); | |
| 82 | + | Line::from(spans) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The share editor: every device, with the ones holding this folder marked. | |
| 86 | + | /// | |
| 87 | + | /// A list rather than a form. Sharing is a set, and the question at each row | |
| 88 | + | /// is yes or no, so the overlay shows the answer for every device at once | |
| 89 | + | /// instead of asking the user to remember which are already in. | |
| 90 | + | pub(super) fn render_share(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 91 | + | let Some((folder_id, cursor)) = &self.share else { | |
| 92 | + | return; | |
| 93 | + | }; | |
| 94 | + | let Some(folder) = self.folder_list().iter().find(|f| &f.id == folder_id) else { | |
| 95 | + | return; | |
| 96 | + | }; | |
| 97 | + | let devices = self.shareable_devices(); | |
| 98 | + | ||
| 99 | + | let height = (devices.len().max(1) as u16) + 4; | |
| 100 | + | let overlay = layout::centered(area, 60, height.min(area.height)); | |
| 101 | + | frame.render_widget(ratatui::widgets::Clear, overlay); | |
| 102 | + | ||
| 103 | + | let block = AlloyBlock::new(theme) | |
| 104 | + | .focused(true) | |
| 105 | + | .build() | |
| 106 | + | .title(block_title(&format!("share {}", folder.label))); | |
| 107 | + | let inner = block.inner(overlay); | |
| 108 | + | frame.render_widget(block, overlay); | |
| 109 | + | ||
| 110 | + | if devices.is_empty() { | |
| 111 | + | frame.render_widget( | |
| 112 | + | Line::from(text::muted(theme, "no other devices to share with")), | |
| 113 | + | inner, | |
| 114 | + | ); | |
| 115 | + | return; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | let rows: Vec<Line> = devices | |
| 119 | + | .iter() | |
| 120 | + | .map(|device| { | |
| 121 | + | let shared = folder.is_shared_with(&device.id); | |
| 122 | + | // A mark either way rather than a mark and a blank: an empty | |
| 123 | + | // column reads as "unknown" as easily as it reads as "no". | |
| 124 | + | let mark = if shared { "[x] " } else { "[ ] " }; | |
| 125 | + | Line::from(vec![ | |
| 126 | + | Span::raw(mark), | |
| 127 | + | Span::raw(device.name.clone()), | |
| 128 | + | text::muted(theme, format!(" {}", device.short_id())), | |
| 129 | + | ]) | |
| 130 | + | }) | |
| 131 | + | .collect(); | |
| 132 | + | frame.render_widget( | |
| 133 | + | AlloyList::new(theme, rows).selected(cursor.selected()), | |
| 134 | + | inner, | |
| 135 | + | ); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | pub(super) fn render_draft(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 139 | + | let Some(draft) = &self.draft else { | |
| 140 | + | return; | |
| 141 | + | }; | |
| 142 | + | let labels = draft.labels(); | |
| 143 | + | // Two rows of border, one of padding either side of the fields. | |
| 144 | + | let height = labels.len() as u16 + 4; | |
| 145 | + | let overlay = layout::centered(area, 60, height); | |
| 146 | + | frame.render_widget(ratatui::widgets::Clear, overlay); | |
| 147 | + | ||
| 148 | + | let block = AlloyBlock::new(theme) | |
| 149 | + | .focused(true) | |
| 150 | + | .build() | |
| 151 | + | .title(block_title(draft.title())); | |
| 152 | + | let inner = block.inner(overlay); | |
| 153 | + | frame.render_widget(block, overlay); | |
| 154 | + | ||
| 155 | + | let lines: Vec<Line> = labels | |
| 156 | + | .iter() | |
| 157 | + | .enumerate() | |
| 158 | + | .filter_map(|(slot, label)| { | |
| 159 | + | let field = draft.field(slot)?; | |
| 160 | + | Some(Self::draft_line( | |
| 161 | + | theme, | |
| 162 | + | label, | |
| 163 | + | field, | |
| 164 | + | self.draft_focus.is_focused(slot), | |
| 165 | + | )) | |
| 166 | + | }) | |
| 167 | + | .collect(); | |
| 168 | + | frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// The offer shown when Syncthing is installed and not running. | |
| 172 | + | pub(super) fn render_offer(frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 173 | + | let lines = vec![ | |
| 174 | + | Line::from(text::muted( | |
| 175 | + | theme, | |
| 176 | + | "file sync is not enrolled on this machine", | |
| 177 | + | )), | |
| 178 | + | Line::from(""), | |
| 179 | + | Line::from(text::secondary( | |
| 180 | + | theme, | |
| 181 | + | format!("press e to run: systemctl --user enable --now {UNIT}"), | |
| 182 | + | )), | |
| 183 | + | ]; | |
| 184 | + | frame.render_widget(ratatui::widgets::Paragraph::new(lines), area); | |
| 185 | + | } | |
| 186 | + | } |