Skip to main content

max / alloy

Split sync's and disk's views along the same seam sync/view.rs held the share editor, the add form and every row and overlay pane in one impl. overlays.rs takes the two modals, which are one shape -- eat every key while up, Enter to commit, Esc to close -- and render.rs takes the drawing. disk/view.rs held the partition edits, which are a shallow state machine around a value being typed or picked. edits.rs takes that machine with the size parser it depends on. Every file under crates/alloy/src is now below 1000 production lines.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 17:26 UTC
Signed with PGP, not checked
Commit: 88fb1935472b0bf56bc5708269da782c443d06eb
Parent: be17498
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 + }