| 21 |
21 |
|
Backend, DeviceDraft, FolderDraft, UNIT, detect, validate_device, validate_folder,
|
| 22 |
22 |
|
};
|
| 23 |
23 |
|
use super::model::{Device, Folder, PendingDevice, Reach, SyncState, day};
|
| 24 |
|
- |
use crate::cli::CommandLog;
|
|
24 |
+ |
use crate::cli::{CommandLog, Invocation};
|
| 25 |
25 |
|
use crate::shell::{Confirm, Flow, View, block_title, truncate};
|
| 26 |
26 |
|
|
| 27 |
27 |
|
/// Ticks between background refreshes.
|
| 311 |
311 |
|
self.finish(result, log);
|
| 312 |
312 |
|
}
|
| 313 |
313 |
|
|
|
314 |
+ |
/// Hand the web UI to a browser, for the edge cases this screen does not do.
|
|
315 |
+ |
///
|
|
316 |
+ |
/// docs/CONTINUITY.md puts the web UI as reachable and not recommended:
|
|
317 |
+ |
/// this screen "does not try to replicate the web UI's full feature
|
|
318 |
+ |
/// surface, only the operations users perform; the web UI remains available
|
|
319 |
+ |
/// for edge cases". Ignore patterns, versioning and per-folder advanced
|
|
320 |
+ |
/// settings are those edge cases, and until today reaching them meant
|
|
321 |
+ |
/// knowing the address by heart.
|
|
322 |
+ |
///
|
|
323 |
+ |
/// Not a suspend. `alloy mesh` tears the console down for `tailscale up`
|
|
324 |
+ |
/// because that command owns the terminal and blocks; a browser owns a
|
|
325 |
+ |
/// window instead, so the console stays up and the list is still there when
|
|
326 |
+ |
/// the user looks back. See [`Invocation::launch`](crate::cli::Invocation::launch).
|
|
327 |
+ |
///
|
|
328 |
+ |
/// Address hardcoded rather than read from the config. It is Syncthing's
|
|
329 |
+ |
/// documented default and this module's own header already names it; a
|
|
330 |
+ |
/// machine whose GUI has been moved is one where this key is the least of
|
|
331 |
+ |
/// what has changed.
|
|
332 |
+ |
fn open_web_ui(&mut self, log: &mut CommandLog) {
|
|
333 |
+ |
if self.reach == Reach::NotRunning {
|
|
334 |
+ |
self.error = Some("syncthing is not running; enrol first with `e`".into());
|
|
335 |
+ |
return;
|
|
336 |
+ |
}
|
|
337 |
+ |
let result = Invocation::new("alloy-open")
|
|
338 |
+ |
.arg("http://127.0.0.1:8384")
|
|
339 |
+ |
.launch(log);
|
|
340 |
+ |
if let Err(err) = result {
|
|
341 |
+ |
self.error = Some(err.to_string());
|
|
342 |
+ |
}
|
|
343 |
+ |
}
|
|
344 |
+ |
|
| 314 |
345 |
|
fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
|
| 315 |
346 |
|
match result {
|
| 316 |
347 |
|
Ok(()) => log.quiet(|log| self.refresh(log)),
|
| 611 |
642 |
|
hint("a", "add"),
|
| 612 |
643 |
|
hint("d", "remove"),
|
| 613 |
644 |
|
hint("p", "pause/resume"),
|
|
645 |
+ |
hint("w", "web ui"),
|
| 614 |
646 |
|
hint("r", "refresh"),
|
| 615 |
647 |
|
]
|
| 616 |
648 |
|
}
|
| 625 |
657 |
|
// Said on every tab, not only the one that lists them: an invitation
|
| 626 |
658 |
|
// nobody notices is the same as one that never arrived.
|
| 627 |
659 |
|
match self.pending_list().len() {
|
| 628 |
|
- |
0 => None,
|
| 629 |
|
- |
1 => Some((Severity::Info, "1 device waiting to connect".into())),
|
| 630 |
|
- |
n => Some((Severity::Info, format!("{n} devices waiting to connect"))),
|
|
660 |
+ |
0 => {}
|
|
661 |
+ |
1 => return Some((Severity::Info, "1 device waiting to connect".into())),
|
|
662 |
+ |
n => return Some((Severity::Info, format!("{n} devices waiting to connect"))),
|
| 631 |
663 |
|
}
|
|
664 |
+ |
// A running daemon with nothing shared is the state this screen used to
|
|
665 |
+ |
// call enrolled and leave at that.
|
|
666 |
+ |
//
|
|
667 |
+ |
// MEASURED on fw12 2026-09-07, after a reinstall: the daemon was up,
|
|
668 |
+ |
// four devices were paired, and not one of fw13's eight folders had
|
|
669 |
+ |
// arrived. Every surface said the right thing separately and none of
|
|
670 |
+ |
// them said the next step, so the machine read as done for hours.
|
|
671 |
+ |
//
|
|
672 |
+ |
// The next step is not on this machine, which is why saying it matters.
|
|
673 |
+ |
// A folder is shared FROM the machine that has it TO this one, and what
|
|
674 |
+ |
// that machine needs is this one's device id. So the id is the message
|
|
675 |
+ |
// rather than a suggestion to go and look for it.
|
|
676 |
+ |
if let Reach::Running(state) = &self.reach
|
|
677 |
+ |
&& state.folders.is_empty()
|
|
678 |
+ |
{
|
|
679 |
+ |
let mine = state.devices.iter().find(|device| device.is_self);
|
|
680 |
+ |
return Some((
|
|
681 |
+ |
Severity::Warn,
|
|
682 |
+ |
match mine {
|
|
683 |
+ |
Some(device) => format!(
|
|
684 |
+ |
"no folders yet: share them to this machine ({}) from the one that has them",
|
|
685 |
+ |
device.short_id()
|
|
686 |
+ |
),
|
|
687 |
+ |
None => "no folders yet: share them from the machine that has them".into(),
|
|
688 |
+ |
},
|
|
689 |
+ |
));
|
|
690 |
+ |
}
|
|
691 |
+ |
None
|
| 632 |
692 |
|
}
|
| 633 |
693 |
|
|
| 634 |
694 |
|
fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
| 763 |
823 |
|
KeyCode::Char('d') => return self.remove_selected(),
|
| 764 |
824 |
|
KeyCode::Char('p') => self.toggle_paused(log),
|
| 765 |
825 |
|
KeyCode::Char('e') => self.enroll(log),
|
|
826 |
+ |
KeyCode::Char('w') => self.open_web_ui(log),
|
| 766 |
827 |
|
KeyCode::Char('r') => self.refresh(log),
|
| 767 |
828 |
|
_ => {}
|
| 768 |
829 |
|
}
|
| 873 |
934 |
|
}
|
| 874 |
935 |
|
}
|
| 875 |
936 |
|
}
|
|
937 |
+ |
|
|
938 |
+ |
// -----------------------------------------------------------------------
|
|
939 |
+ |
// What a running daemon with nothing shared says
|
|
940 |
+ |
// -----------------------------------------------------------------------
|
|
941 |
+ |
|
|
942 |
+ |
fn view_with(reach: Reach) -> SyncView {
|
|
943 |
+ |
SyncView {
|
|
944 |
+ |
backend: Box::new(super::super::backend::Mock),
|
|
945 |
+ |
reach,
|
|
946 |
+ |
tab: Tab::Folders,
|
|
947 |
+ |
folders: Cursor::new(),
|
|
948 |
+ |
devices: Cursor::new(),
|
|
949 |
+ |
pending: Cursor::new(),
|
|
950 |
+ |
draft: None,
|
|
951 |
+ |
draft_focus: FocusRing::new(0),
|
|
952 |
+ |
pending_action: None,
|
|
953 |
+ |
error: None,
|
|
954 |
+ |
ticks: 0,
|
|
955 |
+ |
}
|
|
956 |
+ |
}
|
|
957 |
+ |
|
|
958 |
+ |
fn a_device(id: &str, is_self: bool) -> Device {
|
|
959 |
+ |
Device {
|
|
960 |
+ |
id: id.into(),
|
|
961 |
+ |
name: "somebody".into(),
|
|
962 |
+ |
paused: false,
|
|
963 |
+ |
connected: false,
|
|
964 |
+ |
is_self,
|
|
965 |
+ |
}
|
|
966 |
+ |
}
|
|
967 |
+ |
|
|
968 |
+ |
/// The state fw12 sat in for hours on 2026-09-07: daemon up, devices
|
|
969 |
+ |
/// paired, not one folder shared. It has to name the next step, and the
|
|
970 |
+ |
/// next step needs this machine's id because the sharing happens elsewhere.
|
|
971 |
+ |
#[test]
|
|
972 |
+ |
fn a_running_daemon_with_no_folders_says_so_and_gives_this_machine_s_id() {
|
|
973 |
+ |
let view = view_with(Reach::Running(SyncState {
|
|
974 |
+ |
folders: Vec::new(),
|
|
975 |
+ |
devices: vec![
|
|
976 |
+ |
a_device("AAAAAAA-BBBBBBB", true),
|
|
977 |
+ |
a_device("CCCCCCC-DDDDDDD", false),
|
|
978 |
+ |
],
|
|
979 |
+ |
pending: Vec::new(),
|
|
980 |
+ |
}));
|
|
981 |
+ |
let (severity, message) = view.status().expect("a bare daemon is worth saying");
|
|
982 |
+ |
assert_eq!(severity, Severity::Warn);
|
|
983 |
+ |
assert!(message.contains("no folders"), "{message}");
|
|
984 |
+ |
assert!(
|
|
985 |
+ |
message.contains("AAAAAAA"),
|
|
986 |
+ |
"it carries this machine's id: {message}"
|
|
987 |
+ |
);
|
|
988 |
+ |
assert!(
|
|
989 |
+ |
!message.contains("CCCCCCC"),
|
|
990 |
+ |
"and not the other machine's: {message}"
|
|
991 |
+ |
);
|
|
992 |
+ |
}
|
|
993 |
+ |
|
|
994 |
+ |
/// A device waiting to connect is the more urgent thing and still wins.
|
|
995 |
+ |
#[test]
|
|
996 |
+ |
fn a_pending_device_outranks_the_no_folders_notice() {
|
|
997 |
+ |
let view = view_with(Reach::Running(SyncState {
|
|
998 |
+ |
folders: Vec::new(),
|
|
999 |
+ |
devices: vec![a_device("AAAAAAA-BBBBBBB", true)],
|
|
1000 |
+ |
pending: vec![PendingDevice {
|
|
1001 |
+ |
id: "EEEEEEE-FFFFFFF".into(),
|
|
1002 |
+ |
name: "knocking".into(),
|
|
1003 |
+ |
address: "10.0.0.2:22000".into(),
|
|
1004 |
+ |
time: "2026-09-07T21:00:00Z".into(),
|
|
1005 |
+ |
}],
|
|
1006 |
+ |
}));
|
|
1007 |
+ |
let (_, message) = view.status().expect("a knock is worth saying");
|
|
1008 |
+ |
assert!(message.contains("waiting to connect"), "{message}");
|
|
1009 |
+ |
}
|
|
1010 |
+ |
|
|
1011 |
+ |
/// Once a folder is there the notice goes away rather than nagging.
|
|
1012 |
+ |
#[test]
|
|
1013 |
+ |
fn a_shared_folder_clears_the_notice() {
|
|
1014 |
+ |
let view = view_with(Reach::Running(SyncState {
|
|
1015 |
+ |
folders: vec![Folder {
|
|
1016 |
+ |
id: "mailbox".into(),
|
|
1017 |
+ |
label: "mailbox".into(),
|
|
1018 |
+ |
path: "/var/home/max/mailbox".into(),
|
|
1019 |
+ |
kind: "sendreceive".into(),
|
|
1020 |
+ |
paused: false,
|
|
1021 |
+ |
shared_with: 2,
|
|
1022 |
+ |
}],
|
|
1023 |
+ |
devices: vec![a_device("AAAAAAA-BBBBBBB", true)],
|
|
1024 |
+ |
pending: Vec::new(),
|
|
1025 |
+ |
}));
|
|
1026 |
+ |
assert!(view.status().is_none());
|
|
1027 |
+ |
}
|
|
1028 |
+ |
|
|
1029 |
+ |
/// The web UI key is refused before there is a daemon to show.
|
|
1030 |
+ |
#[test]
|
|
1031 |
+ |
fn the_web_ui_key_is_refused_until_the_daemon_is_up() {
|
|
1032 |
+ |
let mut view = view_with(Reach::NotRunning);
|
|
1033 |
+ |
let mut log = CommandLog::new();
|
|
1034 |
+ |
view.open_web_ui(&mut log);
|
|
1035 |
+ |
let error = view.error.as_deref().expect("it says why");
|
|
1036 |
+ |
assert!(error.contains("not running"), "{error}");
|
|
1037 |
+ |
}
|
| 876 |
1038 |
|
}
|