Skip to main content

max / alloy

Edit a folder's share list from alloy sync docs/CONTINUITY.md listed this as the one thing still to come, on the reading that `syncthing cli` could not do it. It could all along: `config folders <id> devices` is a collection with `add` and a `delete` on each item, which is the whole operation. `s` over a folder opens a list of every other device with the ones already holding it marked, and space toggles. Applied per keystroke rather than gathered and saved, which is what `p` does for pausing and what keeps the log pane teaching a command the user could have typed. The model now carries which devices hold a folder rather than how many. A count cannot answer "is it on that machine", and deriving the count from the list stops the two disagreeing. The parser was already reading the array and throwing the ids away. THIS MACHINE IS NOT IN THE LIST. It is in every folder's device set, and removing it there does not mean unshare, it means the folder stops being here at all. That is `d`, which confirms first. One keystroke must not mean both. The overlay claims its keys for the same reason the add overlay does: `d` typed over a device list would otherwise reach the remove-folder binding underneath, and those two are not close in consequence. Sharing stays bilateral. Adding a device here offers the folder; that machine still has to accept. Nothing on this screen can do the other machine's half, which is the asymmetry the pending tab exists for.
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-07 22:44 UTC
Signed with PGP, not checked
Commit: 54d3461a645ea2da5f432f84f8b2b1725328f69c
Parent: fdddfbb
5 files changed, +349 insertions, -9 deletions
@@ -617,6 +617,16 @@
617 617 self.running.set(true);
618 618 Ok(())
619 619 }
620 + fn set_folder_shared(
621 + &self,
622 + _f: &sync::Folder,
623 + _d: &sync::Device,
624 + _s: bool,
625 + _log: &mut CommandLog,
626 + ) -> Result<()> {
627 + Ok(())
628 + }
629 +
620 630 fn set_folder_paused(
621 631 &self,
622 632 _f: &sync::Folder,
@@ -45,6 +45,24 @@
45 45 /// Forget a device.
46 46 fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
47 47
48 + /// Share a folder with a device, or stop sharing it.
49 + ///
50 + /// The half CONTINUITY.md listed as "still to come". `syncthing cli` had it
51 + /// all along: `config folders <id> devices` is a collection with `add` and
52 + /// a `delete` on each item, which is the whole operation.
53 + ///
54 + /// Sharing is still bilateral. Adding a device here offers the folder; that
55 + /// machine has to accept it, and until it does the folder is shared from
56 + /// this side only. Nothing on this screen can do the other machine's half,
57 + /// which is the same asymmetry the pending tab exists for.
58 + fn set_folder_shared(
59 + &self,
60 + folder: &Folder,
61 + device: &Device,
62 + shared: bool,
63 + log: &mut CommandLog,
64 + ) -> Result<()>;
65 +
48 66 /// Let a waiting device in.
49 67 ///
50 68 /// No matching `dismiss`: Syncthing's REST API can drop a pending entry
@@ -153,7 +171,7 @@
153 171 };
154 172 let placeholder = state.folders.iter().find(|folder| {
155 173 folder.id == "default"
156 - && folder.shared_with <= 1
174 + && folder.shared_with() <= 1
157 175 && folder.path.trim_end_matches('/').ends_with("/Sync")
158 176 });
159 177 if placeholder.is_some() {
@@ -230,6 +248,29 @@
230 248 .map(drop)
231 249 }
232 250
251 + fn set_folder_shared(
252 + &self,
253 + folder: &Folder,
254 + device: &Device,
255 + shared: bool,
256 + log: &mut CommandLog,
257 + ) -> Result<()> {
258 + let mut args = vec!["cli", "config", "folders", &folder.id, "devices"];
259 + if shared {
260 + args.push("add");
261 + } else {
262 + args.push(&device.id);
263 + args.push("delete");
264 + }
265 + let invocation = Invocation::new("syncthing").args(args);
266 + let invocation = if shared {
267 + invocation.arg(format!("--device-id={}", device.id))
268 + } else {
269 + invocation
270 + };
271 + invocation.run(log).map(drop)
272 + }
273 +
233 274 fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()> {
234 275 Invocation::new("syncthing")
235 276 .args(["cli", "config", "devices", &device.id, "paused", "set"])
@@ -314,7 +355,7 @@
314 355 path: "~/Documents".into(),
315 356 kind: "sendreceive".into(),
316 357 paused: false,
317 - shared_with: 2,
358 + devices: vec!["SELF".into(), "PEER".into()],
318 359 },
319 360 Folder {
320 361 id: "photos".into(),
@@ -322,7 +363,7 @@
322 363 path: "~/Pictures".into(),
323 364 kind: "sendonly".into(),
324 365 paused: true,
325 - shared_with: 1,
366 + devices: vec!["SELF".into()],
326 367 },
327 368 ],
328 369 devices: vec![
@@ -362,6 +403,16 @@
362 403 Ok(())
363 404 }
364 405
406 + fn set_folder_shared(
407 + &self,
408 + _f: &Folder,
409 + _d: &Device,
410 + _s: bool,
411 + _log: &mut CommandLog,
412 + ) -> Result<()> {
413 + Ok(())
414 + }
415 +
365 416 fn add_folder(&self, _draft: &FolderDraft, _log: &mut CommandLog) -> Result<()> {
366 417 Ok(())
367 418 }
@@ -18,8 +18,25 @@
18 18 /// `sendreceive`, `sendonly`, `receiveonly`, as Syncthing spells them.
19 19 pub kind: String,
20 20 pub paused: bool,
21 + /// The devices this folder is shared with, this machine included.
22 + ///
23 + /// The ids rather than a count, which is what this was until the share
24 + /// editor needed to know *which*. A count cannot answer "is this folder on
25 + /// that machine", and deriving the count from the list keeps the two from
26 + /// ever disagreeing.
27 + pub devices: Vec<String>,
28 + }
29 +
30 + impl Folder {
21 31 /// How many devices this folder is shared with, this machine included.
22 - pub shared_with: usize,
32 + pub(super) fn shared_with(&self) -> usize {
33 + self.devices.len()
34 + }
35 +
36 + /// Is it on that device already?
37 + pub(super) fn is_shared_with(&self, device: &str) -> bool {
38 + self.devices.iter().any(|id| id == device)
39 + }
23 40 }
24 41
25 42 impl Folder {
@@ -40,7 +40,12 @@
40 40 }
41 41
42 42 #[derive(Deserialize)]
43 - struct StFolderDevice {}
43 + struct StFolderDevice {
44 + /// Spelled `deviceID` like [`StDevice`]'s, and named outright for the same
45 + /// reason: camelCase renaming does not reach it.
46 + #[serde(rename = "deviceID", default)]
47 + device_id: String,
48 + }
44 49
45 50 #[derive(Deserialize)]
46 51 struct StDevice {
@@ -122,7 +127,11 @@
122 127 path: folder.path,
123 128 kind: folder.kind,
124 129 paused: folder.paused,
125 - shared_with: folder.devices.len(),
130 + devices: folder
131 + .devices
132 + .into_iter()
133 + .map(|device| device.device_id)
134 + .collect(),
126 135 })
127 136 .collect();
128 137 // Syncthing returns folders in config order, which is insertion order.
@@ -340,7 +349,7 @@
340 349 .unwrap();
341 350 assert!(photos.paused);
342 351 assert_eq!(photos.state_label(), "paused");
343 - assert_eq!(photos.shared_with, 2);
352 + assert_eq!(photos.shared_with(), 2);
344 353 }
345 354
346 355 // Losing liveness or identity must not lose the folder list: the config
@@ -159,6 +159,9 @@
159 159 pending: Cursor,
160 160 /// The add overlay, when one is open.
161 161 draft: Option<Draft>,
162 + /// The share editor, when one is open: which folder, and where the cursor
163 + /// is in the device list.
164 + share: Option<(String, Cursor)>,
162 165 /// Which field of the overlay has focus.
163 166 draft_focus: FocusRing,
164 167 /// What the raised confirm will do if answered yes.
@@ -177,6 +180,7 @@
177 180 devices: Cursor::new(),
178 181 pending: Cursor::new(),
179 182 draft: None,
183 + share: None,
180 184 draft_focus: FocusRing::new(0),
181 185 pending_action: None,
182 186 error: None,
@@ -311,6 +315,100 @@
311 315 self.finish(result, log);
312 316 }
313 317
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 + .into_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 +
314 412 /// Hand the web UI to a browser, for the edge cases this screen does not do.
315 413 ///
316 414 /// docs/CONTINUITY.md puts the web UI as reachable and not recommended:
@@ -506,7 +604,7 @@
506 604 format!("{:<9}", folder.state_label()),
507 605 folder.severity().style(theme),
508 606 ),
509 - text::muted(theme, format!("{} devices", folder.shared_with)),
607 + text::muted(theme, format!("{} devices", folder.shared_with())),
510 608 ])
511 609 }
512 610
@@ -562,6 +660,59 @@
562 660 Line::from(spans)
563 661 }
564 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 +
565 716 fn render_draft(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
566 717 let Some(draft) = &self.draft else {
567 718 return;
@@ -618,6 +769,13 @@
618 769 }
619 770
620 771 fn hints(&self) -> Vec<Hint> {
772 + if self.share.is_some() {
773 + return vec![
774 + hint("j/k", "select"),
775 + hint("space", "share/unshare"),
776 + hint("esc", "done"),
777 + ];
778 + }
621 779 if self.draft.is_some() {
622 780 return vec![
623 781 hint("tab", "field"),
@@ -642,6 +800,7 @@
642 800 hint("a", "add"),
643 801 hint("d", "remove"),
644 802 hint("p", "pause/resume"),
803 + hint("s", "share"),
645 804 hint("w", "web ui"),
646 805 hint("r", "refresh"),
647 806 ]
@@ -773,6 +932,7 @@
773 932 // `alloy settings`, and for the same reason: it is a question about
774 933 // one thing, not a pane of the screen behind it.
775 934 self.render_draft(frame, area, theme);
935 + self.render_share(frame, area, theme);
776 936 }
777 937
778 938 /// True while the add overlay is open, so the shell stops claiming the
@@ -788,6 +948,10 @@
788 948 self.close_draft();
789 949 return Flow::Continue;
790 950 }
951 + if self.share.is_some() {
952 + self.share = None;
953 + return Flow::Continue;
954 + }
791 955 Flow::Exit
792 956 }
793 957
@@ -798,6 +962,12 @@
798 962 if self.handle_draft(key, log) {
799 963 return Flow::Continue;
800 964 }
965 + // The share editor claims keys for the same reason the draft does: `d`
966 + // over a device list means "remove this folder" underneath, and the two
967 + // are not close in consequence.
968 + if self.handle_share(key, log) {
969 + return Flow::Continue;
970 + }
801 971 match key.code {
802 972 KeyCode::Char('j') | KeyCode::Down => self.cursor().next(),
803 973 KeyCode::Char('k') | KeyCode::Up => self.cursor().prev(),
@@ -822,6 +992,7 @@
822 992 }
823 993 KeyCode::Char('d') => return self.remove_selected(),
824 994 KeyCode::Char('p') => self.toggle_paused(log),
995 + KeyCode::Char('s') => self.open_share(),
825 996 KeyCode::Char('e') => self.enroll(log),
826 997 KeyCode::Char('w') => self.open_web_ui(log),
827 998 KeyCode::Char('r') => self.refresh(log),
@@ -948,6 +1119,7 @@
948 1119 devices: Cursor::new(),
949 1120 pending: Cursor::new(),
950 1121 draft: None,
1122 + share: None,
951 1123 draft_focus: FocusRing::new(0),
952 1124 pending_action: None,
953 1125 error: None,
@@ -1018,7 +1190,7 @@
1018 1190 path: "/var/home/max/mailbox".into(),
1019 1191 kind: "sendreceive".into(),
1020 1192 paused: false,
1021 - shared_with: 2,
1193 + devices: vec!["SELF".into(), "PEER".into()],
1022 1194 }],
1023 1195 devices: vec![a_device("AAAAAAA-BBBBBBB", true)],
1024 1196 pending: Vec::new(),
@@ -1035,4 +1207,85 @@
1035 1207 let error = view.error.as_deref().expect("it says why");
1036 1208 assert!(error.contains("not running"), "{error}");
1037 1209 }
1210 +
1211 + // -----------------------------------------------------------------------
1212 + // The share editor
1213 + // -----------------------------------------------------------------------
1214 +
1215 + fn press(code: KeyCode) -> KeyEvent {
1216 + KeyEvent::from(code)
1217 + }
1218 +
1219 + fn a_folder(id: &str, devices: &[&str]) -> Folder {
1220 + Folder {
1221 + id: id.into(),
1222 + label: id.into(),
1223 + path: format!("/var/home/max/{id}"),
1224 + kind: "sendreceive".into(),
1225 + paused: false,
1226 + devices: devices.iter().map(|d| (*d).to_string()).collect(),
1227 + }
1228 + }
1229 +
1230 + fn shareable_view() -> SyncView {
1231 + let mut view = view_with(Reach::Running(SyncState {
1232 + folders: vec![a_folder("mailbox", &["SELF", "PEER"])],
1233 + devices: vec![
1234 + a_device("SELF", true),
1235 + a_device("PEER", false),
1236 + a_device("OTHER", false),
1237 + ],
1238 + pending: Vec::new(),
1239 + }));
1240 + view.folders.resize(1);
1241 + view
1242 + }
1243 +
1244 + /// This machine is in every folder's device list and is not offered here.
1245 + ///
1246 + /// Removing it would not mean "unshare", it would mean the folder stops
1247 + /// being on this machine at all -- which is what `d` does, with a confirm.
1248 + /// One keystroke must not mean both.
1249 + #[test]
1250 + fn the_share_editor_does_not_offer_this_machine() {
1251 + let view = shareable_view();
1252 + let offered: Vec<&str> = view
1253 + .shareable_devices()
1254 + .iter()
1255 + .map(|device| device.id.as_str())
1256 + .collect();
1257 + assert_eq!(offered, vec!["PEER", "OTHER"]);
1258 + }
1259 +
1260 + /// The marks come from the folder's own device list, not from a guess.
1261 + #[test]
1262 + fn a_folder_knows_which_devices_hold_it() {
1263 + let folder = a_folder("mailbox", &["SELF", "PEER"]);
1264 + assert!(folder.is_shared_with("PEER"));
1265 + assert!(!folder.is_shared_with("OTHER"));
1266 + assert_eq!(folder.shared_with(), 2);
1267 + }
1268 +
1269 + /// The overlay claims its keys, so `d` over a device list cannot reach the
1270 + /// remove-folder binding underneath.
1271 + #[test]
1272 + fn the_share_overlay_eats_the_keys_the_list_would_claim() {
1273 + let mut view = shareable_view();
1274 + let mut log = CommandLog::new();
1275 + view.open_share();
1276 + assert!(view.share.is_some(), "s opens it on the folders tab");
1277 + assert!(view.handle_share(press(KeyCode::Char('d')), &mut log));
1278 + assert!(view.share.is_some(), "d did not fall through to remove");
1279 + assert!(view.handle_share(press(KeyCode::Esc), &mut log));
1280 + assert!(view.share.is_none(), "esc closes it");
1281 + }
1282 +
1283 + /// It only opens over folders. There is nothing to share on the other tabs.
1284 + #[test]
1285 + fn the_share_editor_opens_only_on_the_folders_tab() {
1286 + let mut view = shareable_view();
1287 + view.tab = Tab::Devices;
1288 + view.open_share();
1289 + assert!(view.share.is_none());
1290 + }
1038 1291 }