Skip to main content

max / alloy

sync: front syncthing, and enroll it alloy sync, the other half of the continuity pair docs/CONTINUITY.md describes. Two tabs over the shell every other view uses: folders with path, share mode and paused state; devices with connection state and this machine first. p pauses or resumes either. Fronts `syncthing cli` rather than the REST API. The API would mean an HTTP client in a binary that has none plus reading an API key out of a file the daemon owns, while syncthing's own CLI is a first-party client for that same API which finds the key itself. So the view keeps the command-front shape the console has everywhere else, and the log pane teaches a command the user could have typed. Enrollment is systemctl --user enable --now syncthing.service. User scope because the unit is a user service and the files are the user's; nothing here asks for root, which is the whole difference from mesh's privileged tailscale up. The daemon ships preset-disabled, so on a fresh install every read returns connection refused. That is the state this screen exists to change rather than an error, so Reach has a NotRunning arm and the view renders an offer instead of a red line. The first read of a refresh is unlogged for the same reason: on an un-enrolled machine it fails every five seconds and would bury the log pane. Three reads merge into one screen. config dump-json carries folders and devices with their paused flags, show connections carries who is actually connected, show system names which device is this one. Only the first is fatal: losing liveness costs a row reading disconnected, losing identity costs the self marker, and neither is worth withholding the folder list over. A fixture taken from real syncthing v1.30.0 output caught a bug reading the code would not have: the fields are deviceID and myID, so rename_all = camelCase produces deviceId and myId and matches neither. Verified against a live daemon afterwards.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 19:28 UTC
Signed with PGP, not checked
Commit: 53ace8414d829a90ad430a2fe70238c74b5d5a24
Parent: 015e488
4 files changed, +535 insertions, -3 deletions
M docs/CONSOLE.md +2 -2
@@ -22,7 +22,7 @@
22 22 alloy pkg install # what is layered, and where a package belongs
23 23 alloy pkg box # containers: the isolation dial [shipped]
24 24 alloy update # system updates: rpm-ostree front
25 - alloy sync # syncthing front (see CONTINUITY.md)
25 + alloy sync # syncthing front (see CONTINUITY.md) [shipped]
26 26 alloy settings # system settings and app configs: two tabs over one form
27 27 alloy config <path> # one config file, opened directly, without the tab chrome
28 28 alloy theme <name> # swap the runtime theme; reads makeover's themes/*.toml
@@ -197,7 +197,7 @@
197 197 - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
198 198 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, save and dirty tracking having landed with the edit; (10) `AlloyTable` read-only.
199 199 - **Blocked on the target machine.** `alloy display` fronts `swaymsg`/`wlr-randr`, neither of which exists on a non-sway development box, so its parser cannot be checked against real output here — the way the two box-parser bugs got written. The rpm-ostree tabs were in this category until their output was captured from a booted Alloy install in QEMU (2026-07-22); the `install` and `system` parsers are written against that real capture and re-checkable with `parses_this_machines_real_status` on any ostree box. `alloy display` still wants the image or real hardware.
200 - - **Then.** `alloy sync`, `alloy theme`. `alloy theme` swaps the runtime theme in place (makeover consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments.
200 + - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments; `alloy sync` now carries its half of that, so the shim has one real enrollment to call and one still to write.
201 201 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
202 202 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
203 203
@@ -58,7 +58,11 @@
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`**: ratatui front over Syncthing's REST API. Folder list with per-folder sync state, device list with connection state, add/remove folder, add/remove device, pause/resume. Does not try to replicate the Syncthing web UI's full feature surface, only the operations users perform. The web UI remains available for edge cases.
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.
62 +
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 +
65 + Enrollment is `systemctl --user enable --now syncthing.service`, in user scope, because the unit is a user service and the files are the user's. Nothing here asks for root, which is the whole difference from `alloy mesh`, whose `tailscale up` drives a system daemon. On an un-enrolled machine every read returns connection refused, and the view treats that as the state it exists to change rather than as an error: it renders the offer instead of a red line.
62 66
63 67 ## Non-goals
64 68
@@ -17,6 +17,7 @@
17 17 mod schema;
18 18 mod settings;
19 19 mod shell;
20 + mod sync;
20 21 mod system;
21 22 mod theme;
22 23 mod wizard;
@@ -66,6 +67,23 @@
66 67 Install,
67 68 /// System settings and application configs
68 69 Settings,
70 + /// Synchronized folders and the devices they reach
71 + Sync {
72 + #[command(subcommand)]
73 + surface: Option<SyncSurface>,
74 + },
75 + }
76 +
77 + /// Which tab of the sync view to open on.
78 + ///
79 + /// Nouns, like [`PkgSurface`]'s: each heads a list of what is already there
80 + /// rather than naming an action.
81 + #[derive(Subcommand)]
82 + enum SyncSurface {
83 + /// Folders this machine synchronizes
84 + Folders,
85 + /// Devices those folders are shared with
86 + Devices,
69 87 }
70 88
71 89 /// Which surface of the package view to open on.
@@ -119,6 +137,16 @@
119 137 let mut view = settings::SettingsView::new(settings::Tab::System, &mut log);
120 138 shell::run(&theme, &mut view, &mut log)
121 139 }
140 + Command::Sync { surface } => {
141 + let tab = match surface {
142 + Some(SyncSurface::Devices) => sync::Tab::Devices,
143 + // Bare `alloy sync` lands on folders: they are what the verb
144 + // is about, and the devices exist to serve them.
145 + Some(SyncSurface::Folders) | None => sync::Tab::Folders,
146 + };
147 + let mut view = sync::SyncView::new(tab, &mut log);
148 + shell::run(&theme, &mut view, &mut log)
149 + }
122 150 Command::Install => {
123 151 let mut view = install::InstallView::new(&mut log);
124 152 shell::run(&theme, &mut view, &mut log)
@@ -1,0 +1,927 @@
1 + //! `alloy sync` — the file sync view.
2 + //!
3 + //! See docs/CONTINUITY.md: a mesh VPN and a file sync are what make an Alloy
4 + //! machine feel like the same machine as the last one, so the console fronts
5 + //! both. This is the sync half, and the sibling of [`mesh`](crate::mesh) in
6 + //! shape as well as in purpose: a [`Backend`] trait, one real implementation
7 + //! and one mock, a tabbed list, and every string on screen generic except the
8 + //! backend name in the title.
9 + //!
10 + //! # Fronting the CLI, not the REST API
11 + //!
12 + //! Syncthing's documented interface is an HTTP API on `127.0.0.1:8384` behind
13 + //! an API key in `config.xml`. Fronting it directly would mean an HTTP client
14 + //! in a binary that has none, plus reading a key out of a file the daemon owns.
15 + //! `syncthing cli` is a first-party client for that same API which finds the
16 + //! key itself, so the console keeps the shape every other view has: a command
17 + //! front, logged in the pane, teaching a command the user could have typed.
18 + //!
19 + //! Three invocations cover a refresh. `config dump-json` carries every folder
20 + //! and device with its paused flag, `show connections` carries who is actually
21 + //! connected right now, and `show system` names which device is this one.
22 + //! Configuration and liveness are genuinely different endpoints; merging them
23 + //! is this module's job rather than something to wish for from the tool.
24 + //!
25 + //! # The daemon not running is a state, not an error
26 + //!
27 + //! Syncthing ships preset-disabled (docs/CONTINUITY.md: "a running daemon with
28 + //! nothing to do is unjustified surface"), so on a fresh install the expected
29 + //! answer from every command here is `connection refused`. That is not a
30 + //! failure to report, it is the un-enrolled machine this screen exists to
31 + //! enroll, and it renders as an offer rather than as a red line.
32 + //!
33 + //! Enrollment is `systemctl --user enable --now syncthing.service`. User scope
34 + //! on purpose: the unit is a user service, the files it syncs are the user's,
35 + //! and nothing here needs or asks for root. That is the whole of the
36 + //! difference from [`mesh`](crate::mesh), whose `tailscale up` is a system
37 + //! daemon and a privileged action.
38 + //!
39 + //! <!-- wiki: alloy-console -->
40 +
41 + use std::collections::HashMap;
42 +
43 + use alloy_tui::{AlloyBlock, AlloyList, AlloyTabs, Cursor, Hint, Severity, Theme, hint, text};
44 + use anyhow::Result;
45 + use ratatui::Frame;
46 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
47 + use ratatui::layout::{Constraint, Layout, Rect};
48 + use ratatui::text::{Line, Span};
49 + use serde::Deserialize;
50 +
51 + use crate::cli::{CommandLog, Invocation};
52 + use crate::shell::{Flow, View, block_title, truncate};
53 +
54 + /// Ticks between background refreshes.
55 + ///
56 + /// Matching [`mesh`](crate::mesh): a folder finishing a scan or a peer
57 + /// connecting happens on the scale of seconds-to-minutes, and three processes
58 + /// per second to learn nothing would be worse than a clock that is five
59 + /// seconds stale.
60 + const POLL_TICKS: u64 = 5;
61 +
62 + /// The user unit enrollment enables.
63 + const UNIT: &str = "syncthing.service";
64 +
65 + // ---------------------------------------------------------------------------
66 + // What the screen shows
67 + // ---------------------------------------------------------------------------
68 +
69 + /// A synchronized directory.
70 + #[derive(Debug, Clone, PartialEq, Eq)]
71 + pub(crate) struct Folder {
72 + pub id: String,
73 + /// Human label, falling back to the id when unset. Syncthing allows an
74 + /// empty label and shows the id in that case; so does this.
75 + pub label: String,
76 + pub path: String,
77 + /// `sendreceive`, `sendonly`, `receiveonly`, as Syncthing spells them.
78 + pub kind: String,
79 + pub paused: bool,
80 + /// How many devices this folder is shared with, this machine included.
81 + pub shared_with: usize,
82 + }
83 +
84 + impl Folder {
85 + fn severity(&self) -> Severity {
86 + if self.paused {
87 + Severity::Warn
88 + } else {
89 + Severity::Healthy
90 + }
91 + }
92 +
93 + fn state_label(&self) -> &'static str {
94 + if self.paused { "paused" } else { "syncing" }
95 + }
96 + }
97 +
98 + /// A peer this machine syncs with, or this machine itself.
99 + #[derive(Debug, Clone, PartialEq, Eq)]
100 + pub(crate) struct Device {
101 + pub id: String,
102 + pub name: String,
103 + pub paused: bool,
104 + /// Connected right now. Always false for this machine, which does not
105 + /// connect to itself, so [`Device::state_label`] special-cases it.
106 + pub connected: bool,
107 + pub is_self: bool,
108 + }
109 +
110 + impl Device {
111 + fn severity(&self) -> Severity {
112 + // Info rather than a health colour for this machine: it is a
113 + // notable row, not a good or bad one. Matches how `mesh` tints the
114 + // exit node.
115 + if self.is_self {
116 + return Severity::Info;
117 + }
118 + // Paused and disconnected share a tint and differ in label, as
119 + // online/offline do in `mesh`. The distinction worth colouring is
120 + // "reaching this device" against "not", and neither is reaching it.
121 + if self.paused || !self.connected {
122 + return Severity::Warn;
123 + }
124 + Severity::Healthy
125 + }
126 +
127 + fn state_label(&self) -> &'static str {
128 + if self.is_self {
129 + return "this machine";
130 + }
131 + if self.paused {
132 + return "paused";
133 + }
134 + if self.connected {
135 + "connected"
136 + } else {
137 + "disconnected"
138 + }
139 + }
140 +
141 + /// The first block of the device id, which is how Syncthing's own UI
142 + /// abbreviates it and enough to tell two devices apart by eye.
143 + fn short_id(&self) -> &str {
144 + self.id.split('-').next().unwrap_or(&self.id)
145 + }
146 + }
147 +
148 + /// Everything one refresh gathers.
149 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
150 + pub(crate) struct SyncState {
151 + pub folders: Vec<Folder>,
152 + pub devices: Vec<Device>,
153 + }
154 +
155 + /// Whether the daemon is there to talk to.
156 + ///
157 + /// Two states rather than a `Result`, because "not running" is the ordinary
158 + /// condition of a machine that has not enrolled yet: it wants an offer, not an
159 + /// error. There is deliberately no "syncthing is absent" arm — [`detect`]
160 + /// answers that by handing back [`Mock`], the same way [`mesh`](crate::mesh)
161 + /// does, so a machine without the tool gets a demonstrable screen labelled
162 + /// `mock` rather than a dead end.
163 + #[derive(Debug, Clone, PartialEq, Eq)]
164 + pub(crate) enum Reach {
165 + /// The daemon answered.
166 + Running(SyncState),
167 + /// Syncthing is installed and the daemon is not answering.
168 + NotRunning,
169 + }
170 +
171 + // ---------------------------------------------------------------------------
172 + // The backend seam
173 + // ---------------------------------------------------------------------------
174 +
175 + pub(crate) trait Backend {
176 + fn name(&self) -> &'static str;
177 +
178 + /// Read the whole screen, or say why there is nothing to read.
179 + fn reach(&self, log: &mut CommandLog) -> Result<Reach>;
180 +
181 + /// Start the daemon and have it start at every login.
182 + fn enroll(&self, log: &mut CommandLog) -> Result<()>;
183 +
184 + /// Pause or resume a folder.
185 + fn set_folder_paused(&self, folder: &Folder, paused: bool, log: &mut CommandLog) -> Result<()>;
186 +
187 + /// Pause or resume a device.
188 + fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()>;
189 + }
190 +
191 + /// Pick a backend: `syncthing` when it answers, the mock otherwise.
192 + pub(crate) fn detect() -> Box<dyn Backend> {
193 + if Invocation::new("syncthing").arg("--version").probe() {
194 + Box::new(Syncthing)
195 + } else {
196 + Box::new(Mock)
197 + }
198 + }
199 +
200 + pub(crate) struct Syncthing;
201 +
202 + impl Backend for Syncthing {
203 + fn name(&self) -> &'static str {
204 + "syncthing"
205 + }
206 +
207 + fn reach(&self, log: &mut CommandLog) -> Result<Reach> {
208 + // Unlogged and first, because on an un-enrolled machine this fails
209 + // every five seconds and a log pane filling with the same connection
210 + // refused would bury the commands worth reading.
211 + let Ok(config) = Invocation::new("syncthing")
212 + .args(["cli", "config", "dump-json"])
213 + .capture_quiet()
214 + else {
215 + return Ok(Reach::NotRunning);
216 + };
217 + // Now that the daemon is known to answer, the same read runs logged,
218 + // so the pane teaches the command rather than hiding it.
219 + let config = Invocation::new("syncthing")
220 + .args(["cli", "config", "dump-json"])
221 + .run(log)
222 + .unwrap_or(config);
223 + let connections = Invocation::new("syncthing")
224 + .args(["cli", "show", "connections"])
225 + .run(log)?;
226 + let system = Invocation::new("syncthing")
227 + .args(["cli", "show", "system"])
228 + .run(log)?;
229 + Ok(Reach::Running(parse(&config, &connections, &system)?))
230 + }
231 +
232 + /// `--now` so enrolling starts the daemon as well as arranging for it to
233 + /// start next time. A user who pressed enroll and then had to log out
234 + /// before anything happened would reasonably read that as broken.
235 + fn enroll(&self, log: &mut CommandLog) -> Result<()> {
236 + Invocation::new("systemctl")
237 + .args(["--user", "enable", "--now", UNIT])
238 + .run(log)
239 + .map(drop)
240 + }
241 +
242 + fn set_folder_paused(&self, folder: &Folder, paused: bool, log: &mut CommandLog) -> Result<()> {
243 + Invocation::new("syncthing")
244 + .args(["cli", "config", "folders", &folder.id, "paused", "set"])
245 + .arg(paused.to_string())
246 + .run(log)
247 + .map(drop)
248 + }
249 +
250 + fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()> {
251 + Invocation::new("syncthing")
252 + .args(["cli", "config", "devices", &device.id, "paused", "set"])
253 + .arg(paused.to_string())
254 + .run(log)
255 + .map(drop)
256 + }
257 + }
258 +
259 + /// Fixed sample state, for machines without Syncthing.
260 + ///
261 + /// Same purpose as [`mesh::Mock`](crate::mesh::Mock): the screen is
262 + /// developable and demonstrable on a box that will never run the daemon, and
263 + /// the title says which backend is answering so nobody mistakes it for real.
264 + pub(crate) struct Mock;
265 +
266 + impl Backend for Mock {
267 + fn name(&self) -> &'static str {
268 + "mock"
269 + }
270 +
271 + fn reach(&self, _log: &mut CommandLog) -> Result<Reach> {
272 + Ok(Reach::Running(SyncState {
273 + folders: vec![
274 + Folder {
275 + id: "documents".into(),
276 + label: "Documents".into(),
277 + path: "~/Documents".into(),
278 + kind: "sendreceive".into(),
279 + paused: false,
280 + shared_with: 2,
281 + },
282 + Folder {
283 + id: "photos".into(),
284 + label: "Pictures".into(),
285 + path: "~/Pictures".into(),
286 + kind: "sendonly".into(),
287 + paused: true,
288 + shared_with: 1,
289 + },
290 + ],
291 + devices: vec![
292 + Device {
293 + id: "AAAAAAA-BBBBBBB".into(),
294 + name: "fw13".into(),
295 + paused: false,
296 + connected: false,
297 + is_self: true,
298 + },
299 + Device {
300 + id: "CCCCCCC-DDDDDDD".into(),
301 + name: "astra".into(),
302 + paused: false,
303 + connected: true,
304 + is_self: false,
305 + },
306 + ],
307 + }))
308 + }
309 +
310 + fn enroll(&self, _log: &mut CommandLog) -> Result<()> {
311 + Ok(())
312 + }
313 +
314 + fn set_folder_paused(&self, _f: &Folder, _p: bool, _log: &mut CommandLog) -> Result<()> {
315 + Ok(())
316 + }
317 +
318 + fn set_device_paused(&self, _d: &Device, _p: bool, _log: &mut CommandLog) -> Result<()> {
319 + Ok(())
320 + }
321 + }
322 +
323 + // ---------------------------------------------------------------------------
324 + // Parsing
325 + // ---------------------------------------------------------------------------
326 +
327 + #[derive(Deserialize)]
328 + struct StConfig {
329 + #[serde(default)]
330 + folders: Vec<StFolder>,
331 + #[serde(default)]
332 + devices: Vec<StDevice>,
333 + }
334 +
335 + #[derive(Deserialize)]
336 + #[serde(rename_all = "camelCase")]
337 + struct StFolder {
338 + id: String,
339 + #[serde(default)]
340 + label: String,
341 + #[serde(default)]
342 + path: String,
343 + #[serde(rename = "type", default)]
344 + kind: String,
345 + #[serde(default)]
346 + paused: bool,
347 + #[serde(default)]
348 + devices: Vec<StFolderDevice>,
349 + }
350 +
351 + #[derive(Deserialize)]
352 + struct StFolderDevice {}
353 +
354 + #[derive(Deserialize)]
355 + struct StDevice {
356 + // Syncthing spells it `deviceID`, not `deviceId`, so camelCase renaming
357 + // does not reach it and the field has to be named outright. Caught by a
358 + // fixture taken from real output rather than by reading the code.
359 + #[serde(rename = "deviceID")]
360 + device_id: String,
361 + #[serde(default)]
362 + name: String,
363 + #[serde(default)]
364 + paused: bool,
365 + }
366 +
367 + #[derive(Deserialize)]
368 + struct StConnections {
369 + #[serde(default)]
370 + connections: HashMap<String, StConnection>,
371 + }
372 +
373 + #[derive(Deserialize)]
374 + struct StConnection {
375 + #[serde(default)]
376 + connected: bool,
377 + }
378 +
379 + #[derive(Deserialize)]
380 + struct StSystem {
381 + /// `myID`, with the same capitalisation quirk as `deviceID`.
382 + #[serde(rename = "myID", default)]
383 + my_id: String,
384 + }
385 +
386 + /// Merge the three reads into one screen's worth of state.
387 + fn parse(config: &str, connections: &str, system: &str) -> Result<SyncState> {
388 + let config: StConfig = serde_json::from_str(config)?;
389 + // Liveness and identity are best-effort on purpose. A folder list that
390 + // renders without connection state is worth more than an error, and the
391 + // only cost of losing either is a row reading "disconnected" or no row
392 + // being marked as this machine.
393 + let connections: StConnections = serde_json::from_str(connections).unwrap_or(StConnections {
394 + connections: HashMap::new(),
395 + });
396 + let my_id = serde_json::from_str::<StSystem>(system)
397 + .map(|system| system.my_id)
398 + .unwrap_or_default();
399 +
400 + let mut folders: Vec<Folder> = config
401 + .folders
402 + .into_iter()
403 + .map(|folder| Folder {
404 + label: if folder.label.is_empty() {
405 + folder.id.clone()
406 + } else {
407 + folder.label
408 + },
409 + id: folder.id,
410 + path: folder.path,
411 + kind: folder.kind,
412 + paused: folder.paused,
413 + shared_with: folder.devices.len(),
414 + })
415 + .collect();
416 + // Syncthing returns folders in config order, which is insertion order.
417 + // Sorting by label keeps a list stable across a refresh that reordered
418 + // nothing the user can see, and keeps the cursor over the same row.
419 + folders.sort_by(|a, b| a.label.cmp(&b.label));
420 +
421 + let mut devices: Vec<Device> = config
422 + .devices
423 + .into_iter()
424 + .map(|device| {
425 + let is_self = !my_id.is_empty() && device.device_id == my_id;
426 + Device {
427 + connected: connections
428 + .connections
429 + .get(&device.device_id)
430 + .is_some_and(|connection| connection.connected),
431 + name: if device.name.is_empty() {
432 + device.device_id.split('-').next().unwrap_or("").to_string()
433 + } else {
434 + device.name
435 + },
436 + id: device.device_id,
437 + paused: device.paused,
438 + is_self,
439 + }
440 + })
441 + .collect();
442 + // This machine first, then connected peers, then the rest by name. Same
443 + // rule as the peer list in `mesh`, and for the same reason: a map-backed
444 + // source reshuffling under the cursor is the bug it prevents.
445 + devices.sort_by(|a, b| {
446 + b.is_self
447 + .cmp(&a.is_self)
448 + .then(b.connected.cmp(&a.connected))
449 + .then(a.name.cmp(&b.name))
450 + });
451 +
452 + Ok(SyncState { folders, devices })
453 + }
454 +
455 + // ---------------------------------------------------------------------------
456 + // The view
457 + // ---------------------------------------------------------------------------
458 +
459 + /// The two tabs, in bar order.
460 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
461 + pub(crate) enum Tab {
462 + Folders,
463 + Devices,
464 + }
465 +
466 + impl Tab {
467 + const ALL: [Tab; 2] = [Tab::Folders, Tab::Devices];
468 +
469 + const fn label(self) -> &'static str {
470 + match self {
471 + Tab::Folders => "folders",
472 + Tab::Devices => "devices",
473 + }
474 + }
475 +
476 + const fn slot(self) -> usize {
477 + match self {
478 + Tab::Folders => 0,
479 + Tab::Devices => 1,
480 + }
481 + }
482 +
483 + const fn from_slot(slot: usize) -> Self {
484 + match slot {
485 + 0 => Tab::Folders,
486 + _ => Tab::Devices,
487 + }
488 + }
489 + }
490 +
491 + pub(crate) struct SyncView {
492 + backend: Box<dyn Backend>,
493 + reach: Reach,
494 + tab: Tab,
495 + /// One cursor per tab, so moving between them does not reset the other.
496 + folders: Cursor,
497 + devices: Cursor,
498 + error: Option<String>,
499 + ticks: u64,
500 + }
Lines truncated