Skip to main content

max / alloy

sync: add and remove, for folders and devices Completes the CONTINUITY.md scope for the view. `a` opens a small overlay of text fields, `d` confirms and removes. Adding needs text entry, so the overlay is three fields for a folder and two for a device over a FocusRing, drawn centred above the tab bar like the pick overlay in settings. It eats every key it is given, so a `d` typed into a path never reaches the remove binding underneath, and text_entry() tells the shell to stop claiming the reserved keys while it is open. A rejected draft stays on screen with the reason: retyping a 56-character device id because one group was wrong would be the worst possible answer to a typo. Validation is deliberately shallow. Whether a path exists or an id collides is Syncthing's question and it answers it correctly, so duplicating those rules would mean two validators to keep in agreement. Two checks earn their place. The empty one, because testing against a live daemon showed `config devices add --device-id ""` exits 0 and silently does nothing, so without it a blank field would look like it worked. And the length one, against the truncated paste, which is the error a 56-character id invites; a wrong-but-full-length id is left to Syncthing, whose "check digit incorrect" is already actionable. Both removals confirm, and both messages say what is not lost: neither command touches a file. A user who reads "remove folder" as "delete my documents" will not press it, and saying so costs less than the question does. The background tick pauses while the overlay is open. Re-reading three times a refresh under a form nobody can see is noise for nothing. Verified against a live daemon: add, pause, and delete all take, for folders and for devices.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 19:51 UTC
Signed with PGP, not checked
Commit: 4688359f9298e027fe25aff20aa6a8fc774336af
Parent: 53ace84
2 files changed, +434 insertions, -3 deletions
@@ -58,7 +58,7 @@
58 58 `alloy mesh` and `alloy sync` live under Alloy Console. Full spec in [CONSOLE.md](CONSOLE.md); scope summary here so this document stands alone:
59 59
60 60 - **`alloy mesh`** (was `alloy tail`; the old verb remains an alias). A ratatui front over Tailscale, named for what it is rather than who makes it, since Headscale users drive the same client. Replaces `tailscale status` as the daily-use surface. **Shipped:** peer list with online status and last-seen, this machine first, exit-node selection and clearing, and the control plane named in the title when it is self-hosted. **Still to come:** MagicDNS lookup, share/unshare, and the enrollment flow.
61 - - **`alloy sync`**: a ratatui front over Syncthing, two tabs over one shell. Does not try to replicate the web UI's full feature surface, only the operations users perform; the web UI remains available for edge cases. **Shipped:** the folder list with path, share mode and paused state, the device list with connection state and this machine first, pause and resume on either, and enrollment. **Still to come:** add and remove, for both folders and devices, which need text entry rather than a keypress.
61 + - **`alloy sync`**: a ratatui front over Syncthing, two tabs over one shell. Does not try to replicate the web UI's full feature surface, only the operations users perform; the web UI remains available for edge cases. **Shipped:** the folder list with path, share mode and paused state, the device list with connection state and this machine first, pause and resume on either, add and remove for both, and enrollment. Adding opens a small overlay of text fields (`a`); removing confirms first (`d`), and both confirms say what is *not* lost, because "remove folder" must not read as "delete my documents". **Still to come:** editing an existing folder's share list, and accepting the pending-device invitations Syncthing raises when an unknown device asks to connect.
62 62
63 63 It fronts `syncthing cli` rather than the REST API directly. The API needs an HTTP client and an API key read out of a file the daemon owns; `syncthing cli` is a first-party client for that same API which finds the key itself, so the view keeps the command-front shape every other console screen has, and the log pane teaches a command the user could have typed.
64 64
@@ -40,16 +40,20 @@
40 40
41 41 use std::collections::HashMap;
42 42
43 - use alloy_tui::{AlloyBlock, AlloyList, AlloyTabs, Cursor, Hint, Severity, Theme, hint, text};
43 + use alloy_tui::{
44 + AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, TextField, Theme, hint,
45 + layout, text,
46 + };
44 47 use anyhow::Result;
45 48 use ratatui::Frame;
46 49 use ratatui::crossterm::event::{KeyCode, KeyEvent};
47 50 use ratatui::layout::{Constraint, Layout, Rect};
51 + use ratatui::style::{Modifier, Style};
48 52 use ratatui::text::{Line, Span};
49 53 use serde::Deserialize;
50 54
51 55 use crate::cli::{CommandLog, Invocation};
52 - use crate::shell::{Flow, View, block_title, truncate};
56 + use crate::shell::{Confirm, Flow, View, block_title, truncate};
53 57
54 58 /// Ticks between background refreshes.
55 59 ///
@@ -186,6 +190,86 @@
186 190
187 191 /// Pause or resume a device.
188 192 fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()>;
193 +
194 + /// Start synchronizing a directory.
195 + fn add_folder(&self, draft: &FolderDraft, log: &mut CommandLog) -> Result<()>;
196 +
197 + /// Share this machine's folders with another device.
198 + fn add_device(&self, draft: &DeviceDraft, log: &mut CommandLog) -> Result<()>;
199 +
200 + /// Stop synchronizing a directory. Leaves the files where they are.
201 + fn remove_folder(&self, folder: &Folder, log: &mut CommandLog) -> Result<()>;
202 +
203 + /// Forget a device.
204 + fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
205 + }
206 +
207 + /// A folder the user is describing but has not added yet.
208 + ///
209 + /// Plain strings rather than the [`TextField`]s they come from, so the
210 + /// backends take data instead of UI state and the validation below is
211 + /// testable without a keyboard.
212 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
213 + pub(crate) struct FolderDraft {
214 + pub id: String,
215 + pub label: String,
216 + pub path: String,
217 + }
218 +
219 + /// A device the user is describing but has not added yet.
220 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
221 + pub(crate) struct DeviceDraft {
222 + pub id: String,
223 + pub name: String,
224 + }
225 +
226 + /// Check a folder draft before spending a command on it.
227 + ///
228 + /// Deliberately shallow. Whether the path exists, whether the id collides with
229 + /// an existing folder, whether the filesystem is writable: Syncthing answers
230 + /// all of those and answers them correctly, and duplicating its rules here
231 + /// would mean two validators to keep in agreement. What this catches is the
232 + /// empty submit, which is the one case where the error would otherwise come
233 + /// back as an opaque usage message about a flag the user never saw.
234 + fn validate_folder(draft: &FolderDraft) -> Result<(), String> {
235 + if draft.id.trim().is_empty() {
236 + return Err("a folder needs an id".into());
237 + }
238 + if draft.path.trim().is_empty() {
239 + return Err("a folder needs a path".into());
240 + }
241 + Ok(())
242 + }
243 +
244 + /// Check a device draft. Same shallowness, one extra rule.
245 + ///
246 + /// The length check earns its place against the *truncated* paste, which is
247 + /// the error a 56-character id invites: half an id produces no useful
248 + /// complaint from anything downstream, and this says how many characters
249 + /// arrived. It is not here to second-guess the id's validity — Syncthing
250 + /// verifies the check digits itself and says `check digit incorrect`, which is
251 + /// a perfectly actionable message, so a wrong-but-full-length id is left to it
252 + /// rather than duplicated here.
253 + ///
254 + /// The empty case matters for a different reason, found while testing against
255 + /// a live daemon: `config devices add --device-id ""` **exits 0 and silently
256 + /// does nothing**. Without this check a user could press enter on a blank
257 + /// field, see no error, and find no device.
258 + fn validate_device(draft: &DeviceDraft) -> Result<(), String> {
259 + let id = draft.id.trim();
260 + if id.is_empty() {
261 + return Err("a device needs an id".into());
262 + }
263 + // 56 characters in eight dash-separated groups of seven, which is how
264 + // Syncthing prints one and how a user will paste it.
265 + let bare: String = id.chars().filter(|c| *c != '-').collect();
266 + if bare.len() != 56 {
267 + return Err(format!(
268 + "a device id is 56 characters in eight groups; this one has {}",
269 + bare.len()
270 + ));
271 + }
272 + Ok(())
189 273 }
190 274
191 275 /// Pick a backend: `syncthing` when it answers, the mock otherwise.
@@ -254,6 +338,47 @@
254 338 .run(log)
255 339 .map(drop)
256 340 }
341 +
342 + /// The label is passed even when empty, because Syncthing treats an absent
343 + /// `--label` and an empty one the same way and the parser above already
344 + /// falls back to the id for display.
345 + fn add_folder(&self, draft: &FolderDraft, log: &mut CommandLog) -> Result<()> {
346 + Invocation::new("syncthing")
347 + .args(["cli", "config", "folders", "add"])
348 + .arg("--id")
349 + .arg(draft.id.trim())
350 + .arg("--label")
351 + .arg(draft.label.trim())
352 + .arg("--path")
353 + .arg(draft.path.trim())
354 + .run(log)
355 + .map(drop)
356 + }
357 +
358 + fn add_device(&self, draft: &DeviceDraft, log: &mut CommandLog) -> Result<()> {
359 + Invocation::new("syncthing")
360 + .args(["cli", "config", "devices", "add"])
361 + .arg("--device-id")
362 + .arg(draft.id.trim())
363 + .arg("--name")
364 + .arg(draft.name.trim())
365 + .run(log)
366 + .map(drop)
367 + }
368 +
369 + fn remove_folder(&self, folder: &Folder, log: &mut CommandLog) -> Result<()> {
370 + Invocation::new("syncthing")
371 + .args(["cli", "config", "folders", &folder.id, "delete"])
372 + .run(log)
373 + .map(drop)
374 + }
375 +
376 + fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
377 + Invocation::new("syncthing")
378 + .args(["cli", "config", "devices", &device.id, "delete"])
379 + .run(log)
380 + .map(drop)
381 + }
257 382 }
258 383
259 384 /// Fixed sample state, for machines without Syncthing.
@@ -318,6 +443,22 @@
318 443 fn set_device_paused(&self, _d: &Device, _p: bool, _log: &mut CommandLog) -> Result<()> {
319 444 Ok(())
320 445 }
446 +
447 + fn add_folder(&self, _draft: &FolderDraft, _log: &mut CommandLog) -> Result<()> {
448 + Ok(())
449 + }
450 +
451 + fn add_device(&self, _draft: &DeviceDraft, _log: &mut CommandLog) -> Result<()> {
452 + Ok(())
453 + }
454 +
455 + fn remove_folder(&self, _folder: &Folder, _log: &mut CommandLog) -> Result<()> {
456 + Ok(())
457 + }
458 +
459 + fn remove_device(&self, _device: &Device, _log: &mut CommandLog) -> Result<()> {
460 + Ok(())
461 + }
321 462 }
322 463
323 464 // ---------------------------------------------------------------------------
@@ -488,6 +629,85 @@
488 629 }
489 630 }
490 631
632 + /// The add overlay, when one is open.
633 + ///
634 + /// Two shapes rather than one form with optional rows: a folder and a device
635 + /// share no fields, and a single struct carrying both sets would spend every
636 + /// read asking which half is live.
637 + enum Draft {
638 + Folder {
639 + id: TextField,
640 + label: TextField,
641 + path: TextField,
642 + },
643 + Device {
644 + id: TextField,
645 + name: TextField,
646 + },
647 + }
648 +
649 + impl Draft {
650 + /// Field labels, in slot order, so the renderer and the focus ring agree
651 + /// about what slot 1 is.
652 + const FOLDER_LABELS: [&'static str; 3] = ["id", "label", "path"];
653 + const DEVICE_LABELS: [&'static str; 2] = ["device id", "name"];
654 +
655 + fn labels(&self) -> &'static [&'static str] {
656 + match self {
657 + Draft::Folder { .. } => &Self::FOLDER_LABELS,
658 + Draft::Device { .. } => &Self::DEVICE_LABELS,
659 + }
660 + }
661 +
662 + fn field_mut(&mut self, slot: usize) -> Option<&mut TextField> {
663 + match self {
664 + Draft::Folder { id, label, path } => match slot {
665 + 0 => Some(id),
666 + 1 => Some(label),
667 + 2 => Some(path),
668 + _ => None,
669 + },
670 + Draft::Device { id, name } => match slot {
671 + 0 => Some(id),
672 + 1 => Some(name),
673 + _ => None,
674 + },
675 + }
676 + }
677 +
678 + fn field(&self, slot: usize) -> Option<&TextField> {
679 + match self {
680 + Draft::Folder { id, label, path } => match slot {
681 + 0 => Some(id),
682 + 1 => Some(label),
683 + 2 => Some(path),
684 + _ => None,
685 + },
686 + Draft::Device { id, name } => match slot {
687 + 0 => Some(id),
688 + 1 => Some(name),
689 + _ => None,
690 + },
691 + }
692 + }
693 +
694 + fn title(&self) -> &'static str {
695 + match self {
696 + Draft::Folder { .. } => "add folder",
697 + Draft::Device { .. } => "add device",
698 + }
699 + }
700 + }
701 +
702 + /// What a raised confirm is waiting to do.
703 + ///
704 + /// The shell's [`Confirm`] carries only what to display, so the pending action
705 + /// lives here, exactly as [`View::confirmed`]'s contract intends.
706 + enum Pending {
707 + RemoveFolder(Folder),
708 + RemoveDevice(Device),
709 + }
710 +
491 711 pub(crate) struct SyncView {
492 712 backend: Box<dyn Backend>,
493 713 reach: Reach,
@@ -495,6 +715,12 @@
495 715 /// One cursor per tab, so moving between them does not reset the other.
496 716 folders: Cursor,
497 717 devices: Cursor,
718 + /// The add overlay, when one is open.
719 + draft: Option<Draft>,
720 + /// Which field of the overlay has focus.
721 + draft_focus: FocusRing,
722 + /// What the raised confirm will do if answered yes.
723 + pending: Option<Pending>,
498 724 error: Option<String>,
499 725 ticks: u64,
500 726 }
@@ -507,6 +733,9 @@
507 733 tab,
508 734 folders: Cursor::new(),
509 735 devices: Cursor::new(),
736 + draft: None,
737 + draft_focus: FocusRing::new(0),
738 + pending: None,
510 739 error: None,
511 740 ticks: 0,
512 741 };
@@ -605,6 +834,141 @@
605 834 }
606 835 }
607 836
837 + /// Open the add overlay for whichever tab is showing.
838 + fn open_draft(&mut self) {
839 + let draft = match self.tab {
840 + Tab::Folders => Draft::Folder {
841 + id: TextField::new(),
842 + label: TextField::new(),
843 + path: TextField::new(),
844 + },
845 + Tab::Devices => Draft::Device {
846 + id: TextField::new(),
847 + name: TextField::new(),
848 + },
849 + };
850 + self.draft_focus = FocusRing::new(draft.labels().len());
851 + self.draft = Some(draft);
852 + }
853 +
854 + fn close_draft(&mut self) {
855 + self.draft = None;
856 + self.draft_focus = FocusRing::new(0);
857 + }
858 +
859 + /// Validate the open draft and hand it to the backend.
860 + ///
861 + /// A rejected draft stays on screen with the reason in the status line,
862 + /// rather than closing and losing what was typed. Retyping a 56-character
863 + /// device id because one group was wrong would be the worst possible
864 + /// answer to a typo.
865 + fn submit_draft(&mut self, log: &mut CommandLog) {
866 + let Some(draft) = &self.draft else {
867 + return;
868 + };
869 + let result = match draft {
870 + Draft::Folder { id, label, path } => {
871 + let draft = FolderDraft {
872 + id: id.value().to_string(),
873 + label: label.value().to_string(),
874 + path: path.value().to_string(),
875 + };
876 + if let Err(reason) = validate_folder(&draft) {
877 + self.error = Some(reason);
878 + return;
879 + }
880 + self.backend.add_folder(&draft, log)
881 + }
882 + Draft::Device { id, name } => {
883 + let draft = DeviceDraft {
884 + id: id.value().to_string(),
885 + name: name.value().to_string(),
886 + };
887 + if let Err(reason) = validate_device(&draft) {
888 + self.error = Some(reason);
889 + return;
890 + }
891 + self.backend.add_device(&draft, log)
892 + }
893 + };
894 + // Closed only on success, for the same reason a rejected draft stays
895 + // up: a command that failed has typed input still worth keeping.
896 + if result.is_ok() {
897 + self.close_draft();
898 + }
899 + self.finish(result, log);
900 + }
901 +
902 + /// Keys while the add overlay is open.
903 + ///
904 + /// Returns `true` when the overlay consumed the key, so the list bindings
905 + /// underneath never see a `p` that was meant to be part of a path.
906 + fn handle_draft(&mut self, key: KeyEvent, log: &mut CommandLog) -> bool {
907 + if self.draft.is_none() {
908 + return false;
909 + }
910 + match key.code {
911 + KeyCode::Esc => self.close_draft(),
912 + KeyCode::Enter => self.submit_draft(log),
913 + KeyCode::Tab | KeyCode::Down => self.draft_focus.next(),
914 + KeyCode::BackTab | KeyCode::Up => self.draft_focus.prev(),
915 + _ => {
916 + let slot = self.draft_focus.current();
917 + let Some(field) = self.draft.as_mut().and_then(|d| d.field_mut(slot)) else {
918 + return true;
919 + };
920 + match key.code {
921 + KeyCode::Char(c) => field.insert(c),
922 + KeyCode::Backspace => field.backspace(),
923 + KeyCode::Delete => field.delete(),
924 + KeyCode::Left => field.left(),
925 + KeyCode::Right => field.right(),
926 + KeyCode::Home => field.home(),
927 + KeyCode::End => field.end(),
928 + _ => {}
929 + }
930 + }
931 + }
932 + true
933 + }
934 +
935 + /// Raise the confirm for removing what is selected.
936 + ///
937 + /// Both removals are confirmed, and the messages say what is and is not
938 + /// lost: neither command touches a file, and a user who thinks "remove
939 + /// folder" means "delete my documents" will not press it. Saying so is
940 + /// cheaper than the support question.
941 + fn remove_selected(&mut self) -> Flow {
942 + match self.tab {
943 + Tab::Folders => {
944 + let Some(folder) = self.selected_folder().cloned() else {
945 + return Flow::Continue;
946 + };
947 + let message = format!(
948 + "Stop synchronizing {}? The files in {} stay where they are.",
949 + folder.label, folder.path
950 + );
951 + self.pending = Some(Pending::RemoveFolder(folder));
952 + Flow::Confirm(Confirm::destructive("remove folder", message))
953 + }
954 + Tab::Devices => {
955 + let Some(device) = self.selected_device().cloned() else {
956 + return Flow::Continue;
957 + };
958 + if device.is_self {
959 + self.error = Some("this machine cannot be removed".into());
960 + return Flow::Continue;
961 + }
962 + let message = format!(
963 + "Forget {}? It stops sharing folders with this machine, and keeps its own copy.",
964 + device.name
965 + );
966 + self.pending = Some(Pending::RemoveDevice(device));
967 + Flow::Confirm(Confirm::destructive("remove device", message))
968 + }
969 + }
970 + }
971 +
608 972 fn folder_row<'a>(theme: &Theme, folder: &'a Folder) -> Line<'a> {
609 973 Line::from(vec![
610 974 text::bold(theme, format!("{:<20}", truncate(&folder.label, 19))),
@@ -629,6 +993,70 @@
629 993 ])
630 994 }
631 995
996 + /// One labelled field line, with the caret drawn under a character.
997 + ///
998 + /// Same shape as the installer's account fields, minus the masking: none
999 + /// of these is a secret, and a device id in particular is meant to be read
1000 + /// back against the one on the other machine's screen.
1001 + fn draft_line<'a>(theme: &Theme, label: &'a str, field: &TextField, focused: bool) -> Line<'a> {
1002 + let (before, under, after) = field.split();
1003 + let mut spans = vec![
1004 + if focused {
1005 + text::bold(theme, format!("{label:>10} "))
1006 + } else {
1007 + text::muted(theme, format!("{label:>10} "))
1008 + },
1009 + text::primary(theme, before.to_string()),
1010 + ];
1011 + if focused {
1012 + // Reversed rather than a block glyph, so the caret sits on the
1013 + // character it is about to replace instead of beside it, and a
1014 + // space stands in past the end of the value. Same treatment as the
1015 + // installer's fields.
1016 + spans.push(Span::styled(
1017 + under.unwrap_or(' ').to_string(),
1018 + Style::default().add_modifier(Modifier::REVERSED),
1019 + ));
1020 + } else if let Some(under) = under {
1021 + spans.push(text::primary(theme, under.to_string()));
1022 + }
1023 + spans.push(text::primary(theme, after.to_string()));
1024 + Line::from(spans)
1025 + }
1026 +
1027 + fn render_draft(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
1028 + let Some(draft) = &self.draft else {
1029 + return;
1030 + };
1031 + let labels = draft.labels();
1032 + // Two rows of border, one of padding either side of the fields.
1033 + let height = labels.len() as u16 + 4;
1034 + let overlay = layout::centered(area, 60, height);
1035 + frame.render_widget(ratatui::widgets::Clear, overlay);
1036 +
1037 + let block = AlloyBlock::new(theme)
1038 + .focused(true)
1039 + .build()
1040 + .title(block_title(draft.title()));
1041 + let inner = block.inner(overlay);
1042 + frame.render_widget(block, overlay);
1043 +
1044 + let lines: Vec<Line> = labels
1045 + .iter()
1046 + .enumerate()
1047 + .filter_map(|(slot, label)| {
1048 + let field = draft.field(slot)?;
1049 + Some(Self::draft_line(
1050 + theme,
1051 + label,
1052 + field,
1053 + self.draft_focus.is_focused(slot),
1054 + ))
1055 + })
1056 + .collect();
1057 + frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
1058 + }
1059 +
632 1060 /// The offer shown when Syncthing is installed and not running.
633 1061 fn render_offer(frame: &mut Frame, area: Rect, theme: &Theme) {
634 1062 let lines = vec![
@@ -652,12 +1080,21 @@
652 1080 }
653 1081
654 1082 fn hints(&self) -> Vec<Hint> {
1083 + if self.draft.is_some() {
1084 + return vec![
1085 + hint("tab", "field"),
Lines truncated