max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+275 insertions,
-25 deletions
| @@ -58,7 +58,9 @@ | |||
| 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, 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. | |
| 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:** three tabs. Folders with path, share mode and paused state; devices with connection state and this machine first; and pending, the devices knocking. Pause and resume on either configured list, add and remove for both, accept for a pending device, 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". The pending tab carries its count in its own label and in the status line on every tab, since an invitation nobody notices is the same as one that never arrived. **Still to come:** editing an existing folder's share list. | |
| 62 | + | ||
| 63 | + | **Accepting is possible; declining is not.** The pending tab is how a pairing finishes: the other machine adds this one, dials it, and waits. Accepting is an add with the id and name already known, and Syncthing drops the entry once the device is configured. There is no matching decline, because the REST API can drop a pending entry and `syncthing cli` does not expose that. So an unaccepted device stays listed. That asymmetry ships as-is and the view says so when `d` is pressed on that tab; a dismiss button that silently did nothing would be worse than not having one. Revisit if `syncthing cli` grows the verb. | |
| 62 | 64 | ||
| 63 | 65 | 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 | 66 |
| @@ -16,11 +16,27 @@ | |||
| 16 | 16 | //! key itself, so the console keeps the shape every other view has: a command | |
| 17 | 17 | //! front, logged in the pane, teaching a command the user could have typed. | |
| 18 | 18 | //! | |
| 19 | - | //! Three invocations cover a refresh. `config dump-json` carries every folder | |
| 19 | + | //! Four invocations cover a refresh. `config dump-json` carries every folder | |
| 20 | 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. | |
| 21 | + | //! connected right now, `show system` names which device is this one, and | |
| 22 | + | //! `show pending devices` carries whoever is knocking. Configuration, | |
| 23 | + | //! liveness and invitations are genuinely different endpoints; merging them is | |
| 24 | + | //! this module's job rather than something to wish for from the tool. Only the | |
| 25 | + | //! first is fatal — the other three decorate, and losing one costs a column | |
| 26 | + | //! rather than the screen. | |
| 27 | + | //! | |
| 28 | + | //! # Accepting is possible, declining is not | |
| 29 | + | //! | |
| 30 | + | //! The pending tab is how a pairing finishes: the other machine adds this one, | |
| 31 | + | //! dials it, and waits. Accepting is `config devices add` with the id and name | |
| 32 | + | //! already known, and Syncthing drops the entry once the device is configured. | |
| 33 | + | //! | |
| 34 | + | //! There is no matching decline. Syncthing's REST API can drop a pending entry | |
| 35 | + | //! and `syncthing cli` does not expose that, so the console can let a device in | |
| 36 | + | //! and cannot turn one away; an unaccepted device just stays listed. That | |
| 37 | + | //! asymmetry is shipped as-is and said out loud on screen when `d` is pressed, | |
| 38 | + | //! because a dismiss button that silently did nothing would be worse than not | |
| 39 | + | //! having one. | |
| 24 | 40 | //! | |
| 25 | 41 | //! # The daemon not running is a state, not an error | |
| 26 | 42 | //! | |
| @@ -149,11 +165,46 @@ | |||
| 149 | 165 | } | |
| 150 | 166 | } | |
| 151 | 167 | ||
| 168 | + | /// A device that has tried to connect and is not configured here yet. | |
| 169 | + | /// | |
| 170 | + | /// This is how a pairing actually completes: the other machine adds this one, | |
| 171 | + | /// dials it, and lands here waiting to be let in. Without this list the answer | |
| 172 | + | /// to "I added you, now what" is the web UI, which is the interface | |
| 173 | + | /// docs/CONTINUITY.md says `alloy sync` exists to replace. | |
| 174 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 175 | + | pub(crate) struct PendingDevice { | |
| 176 | + | pub id: String, | |
| 177 | + | /// The name the other machine advertises for itself. Syncthing falls back | |
| 178 | + | /// to a hex string when the device has no name set, and so does this. | |
| 179 | + | pub name: String, | |
| 180 | + | /// Where it dialled from, as `host:port`. | |
| 181 | + | pub address: String, | |
| 182 | + | /// When it last tried, as Syncthing's RFC 3339 timestamp. | |
| 183 | + | pub time: String, | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | impl PendingDevice { | |
| 187 | + | /// Same abbreviation the configured device rows use. | |
| 188 | + | fn short_id(&self) -> &str { | |
| 189 | + | self.id.split('-').next().unwrap_or(&self.id) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// The date out of an RFC 3339 timestamp. | |
| 194 | + | /// | |
| 195 | + | /// Only the day, as `mesh` does with last-seen: the minute a device first | |
| 196 | + | /// knocked is not a thing anyone acts on, and a full timestamp would crowd the | |
| 197 | + | /// row that carries the address. | |
| 198 | + | fn day(time: &str) -> &str { | |
| 199 | + | time.split('T').next().unwrap_or(time) | |
| 200 | + | } | |
| 201 | + | ||
| 152 | 202 | /// Everything one refresh gathers. | |
| 153 | 203 | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 154 | 204 | pub(crate) struct SyncState { | |
| 155 | 205 | pub folders: Vec<Folder>, | |
| 156 | 206 | pub devices: Vec<Device>, | |
| 207 | + | pub pending: Vec<PendingDevice>, | |
| 157 | 208 | } | |
| 158 | 209 | ||
| 159 | 210 | /// Whether the daemon is there to talk to. | |
| @@ -202,6 +253,15 @@ | |||
| 202 | 253 | ||
| 203 | 254 | /// Forget a device. | |
| 204 | 255 | fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()>; | |
| 256 | + | ||
| 257 | + | /// Let a waiting device in. | |
| 258 | + | /// | |
| 259 | + | /// No matching `dismiss`: Syncthing's REST API can drop a pending entry | |
| 260 | + | /// and `syncthing cli` does not expose that, so the console can accept an | |
| 261 | + | /// invitation and cannot decline one. An unaccepted device simply stays in | |
| 262 | + | /// the list, which is the honest behaviour to ship rather than a button | |
| 263 | + | /// that lies. See the module docs. | |
| 264 | + | fn accept_device(&self, pending: &PendingDevice, log: &mut CommandLog) -> Result<()>; | |
| 205 | 265 | } | |
| 206 | 266 | ||
| 207 | 267 | /// A folder the user is describing but has not added yet. | |
| @@ -310,7 +370,15 @@ | |||
| 310 | 370 | let system = Invocation::new("syncthing") | |
| 311 | 371 | .args(["cli", "show", "system"]) | |
| 312 | 372 | .run(log)?; | |
| 313 | - | Ok(Reach::Running(parse(&config, &connections, &system)?)) | |
| 373 | + | let pending = Invocation::new("syncthing") | |
| 374 | + | .args(["cli", "show", "pending", "devices"]) | |
| 375 | + | .run(log)?; | |
| 376 | + | Ok(Reach::Running(parse( | |
| 377 | + | &config, | |
| 378 | + | &connections, | |
| 379 | + | &system, | |
| 380 | + | &pending, | |
| 381 | + | )?)) | |
| 314 | 382 | } | |
| 315 | 383 | ||
| 316 | 384 | /// `--now` so enrolling starts the daemon as well as arranging for it to | |
| @@ -379,6 +447,19 @@ | |||
| 379 | 447 | .run(log) | |
| 380 | 448 | .map(drop) | |
| 381 | 449 | } | |
| 450 | + | ||
| 451 | + | /// Accepting is adding: the same command the add overlay runs, with the | |
| 452 | + | /// id and name already known. Syncthing drops the entry from the pending | |
| 453 | + | /// list once the device is configured, so nothing has to clear it. | |
| 454 | + | fn accept_device(&self, pending: &PendingDevice, log: &mut CommandLog) -> Result<()> { | |
| 455 | + | self.add_device( | |
| 456 | + | &DeviceDraft { | |
| 457 | + | id: pending.id.clone(), | |
| 458 | + | name: pending.name.clone(), | |
| 459 | + | }, | |
| 460 | + | log, | |
| 461 | + | ) | |
| 462 | + | } | |
| 382 | 463 | } | |
| 383 | 464 | ||
| 384 | 465 | /// Fixed sample state, for machines without Syncthing. | |
| @@ -429,6 +510,12 @@ | |||
| 429 | 510 | is_self: false, | |
| 430 | 511 | }, | |
| 431 | 512 | ], | |
| 513 | + | pending: vec![PendingDevice { | |
| 514 | + | id: "EEEEEEE-FFFFFFF".into(), | |
| 515 | + | name: "mbp".into(), | |
| 516 | + | address: "192.168.1.24:22000".into(), | |
| 517 | + | time: "2026-07-25T19:59:54Z".into(), | |
| 518 | + | }], | |
| 432 | 519 | })) | |
| 433 | 520 | } | |
| 434 | 521 | ||
| @@ -459,6 +546,10 @@ | |||
| 459 | 546 | fn remove_device(&self, _device: &Device, _log: &mut CommandLog) -> Result<()> { | |
| 460 | 547 | Ok(()) | |
| 461 | 548 | } | |
| 549 | + | ||
| 550 | + | fn accept_device(&self, _pending: &PendingDevice, _log: &mut CommandLog) -> Result<()> { | |
| 551 | + | Ok(()) | |
| 552 | + | } | |
| 462 | 553 | } | |
| 463 | 554 | ||
| 464 | 555 | // --------------------------------------------------------------------------- | |
| @@ -517,6 +608,22 @@ | |||
| 517 | 608 | connected: bool, | |
| 518 | 609 | } | |
| 519 | 610 | ||
| 611 | + | /// One entry of `show pending devices`, which is a map keyed by device id. | |
| 612 | + | /// | |
| 613 | + | /// `address` is a single string, not the `addresses` array the REST reference | |
| 614 | + | /// might lead you to expect. Taken from a real pending entry produced by | |
| 615 | + | /// pointing a second instance at a first, because guessing this shape is | |
| 616 | + | /// exactly how the `deviceID` bug got written. | |
| 617 | + | #[derive(Deserialize)] | |
| 618 | + | struct StPending { | |
| 619 | + | #[serde(default)] | |
| 620 | + | address: String, | |
| 621 | + | #[serde(default)] | |
| 622 | + | name: String, | |
| 623 | + | #[serde(default)] | |
| 624 | + | time: String, | |
| 625 | + | } | |
| 626 | + | ||
| 520 | 627 | #[derive(Deserialize)] | |
| 521 | 628 | struct StSystem { | |
| 522 | 629 | /// `myID`, with the same capitalisation quirk as `deviceID`. | |
| @@ -524,8 +631,8 @@ | |||
| 524 | 631 | my_id: String, | |
| 525 | 632 | } | |
| 526 | 633 | ||
| 527 | - | /// Merge the three reads into one screen's worth of state. | |
| 528 | - | fn parse(config: &str, connections: &str, system: &str) -> Result<SyncState> { | |
| 634 | + | /// Merge the four reads into one screen's worth of state. | |
| 635 | + | fn parse(config: &str, connections: &str, system: &str, pending: &str) -> Result<SyncState> { | |
| 529 | 636 | let config: StConfig = serde_json::from_str(config)?; | |
| 530 | 637 | // Liveness and identity are best-effort on purpose. A folder list that | |
| 531 | 638 | // renders without connection state is worth more than an error, and the | |
| @@ -590,27 +697,56 @@ | |||
| 590 | 697 | .then(a.name.cmp(&b.name)) | |
| 591 | 698 | }); | |
| 592 | 699 | ||
| 593 | - | Ok(SyncState { folders, devices }) | |
| 700 | + | // Best-effort like the other two decorations: a daemon too old to know the | |
| 701 | + | // endpoint, or a malformed answer, costs the pending tab and nothing else. | |
| 702 | + | let mut pending: Vec<PendingDevice> = | |
| 703 | + | serde_json::from_str::<HashMap<String, StPending>>(pending) | |
| 704 | + | .unwrap_or_default() | |
| 705 | + | .into_iter() | |
| 706 | + | .map(|(id, entry)| PendingDevice { | |
| 707 | + | name: if entry.name.is_empty() { | |
| 708 | + | id.split('-').next().unwrap_or("").to_string() | |
| 709 | + | } else { | |
| 710 | + | entry.name | |
| 711 | + | }, | |
| 712 | + | id, | |
| 713 | + | address: entry.address, | |
| 714 | + | time: entry.time, | |
| 715 | + | }) | |
| 716 | + | .collect(); | |
| 717 | + | // Newest first: the one that just tried to connect is the one being waited | |
| 718 | + | // on. Ties break by name so a map's iteration order never shows through. | |
| 719 | + | pending.sort_by(|a, b| b.time.cmp(&a.time).then(a.name.cmp(&b.name))); | |
| 720 | + | ||
| 721 | + | Ok(SyncState { | |
| 722 | + | folders, | |
| 723 | + | devices, | |
| 724 | + | pending, | |
| 725 | + | }) | |
| 594 | 726 | } | |
| 595 | 727 | ||
| 596 | 728 | // --------------------------------------------------------------------------- | |
| 597 | 729 | // The view | |
| 598 | 730 | // --------------------------------------------------------------------------- | |
| 599 | 731 | ||
| 600 | - | /// The two tabs, in bar order. | |
| 732 | + | /// The three tabs, in bar order. | |
| 601 | 733 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 602 | 734 | pub(crate) enum Tab { | |
| 603 | 735 | Folders, | |
| 604 | 736 | Devices, | |
| 737 | + | /// Devices waiting to be let in. Last because it is usually empty, and | |
| 738 | + | /// carries its count in the label so it is noticed from the other two. | |
| 739 | + | Pending, | |
| 605 | 740 | } | |
| 606 | 741 | ||
| 607 | 742 | impl Tab { | |
| 608 | - | const ALL: [Tab; 2] = [Tab::Folders, Tab::Devices]; | |
| 743 | + | const ALL: [Tab; 3] = [Tab::Folders, Tab::Devices, Tab::Pending]; | |
| 609 | 744 | ||
| 610 | 745 | const fn label(self) -> &'static str { | |
| 611 | 746 | match self { | |
| 612 | 747 | Tab::Folders => "folders", | |
| 613 | 748 | Tab::Devices => "devices", | |
| 749 | + | Tab::Pending => "pending", | |
| 614 | 750 | } | |
| 615 | 751 | } | |
| 616 | 752 | ||
| @@ -618,13 +754,15 @@ | |||
| 618 | 754 | match self { | |
| 619 | 755 | Tab::Folders => 0, | |
| 620 | 756 | Tab::Devices => 1, | |
| 757 | + | Tab::Pending => 2, | |
| 621 | 758 | } | |
| 622 | 759 | } | |
| 623 | 760 | ||
| 624 | 761 | const fn from_slot(slot: usize) -> Self { | |
| 625 | 762 | match slot { | |
| 626 | 763 | 0 => Tab::Folders, | |
| 627 | - | _ => Tab::Devices, | |
| 764 | + | 1 => Tab::Devices, | |
| 765 | + | _ => Tab::Pending, | |
| 628 | 766 | } | |
| 629 | 767 | } | |
| 630 | 768 | } | |
| @@ -703,7 +841,7 @@ | |||
| 703 | 841 | /// | |
| 704 | 842 | /// The shell's [`Confirm`] carries only what to display, so the pending action | |
| 705 | 843 | /// lives here, exactly as [`View::confirmed`]'s contract intends. | |
| 706 | - | enum Pending { | |
| 844 | + | enum PendingAction { | |
| 707 | 845 | RemoveFolder(Folder), | |
| 708 | 846 | RemoveDevice(Device), | |
| 709 | 847 | } | |
| @@ -715,12 +853,13 @@ | |||
| 715 | 853 | /// One cursor per tab, so moving between them does not reset the other. | |
| 716 | 854 | folders: Cursor, | |
| 717 | 855 | devices: Cursor, | |
| 856 | + | pending: Cursor, | |
| 718 | 857 | /// The add overlay, when one is open. | |
| 719 | 858 | draft: Option<Draft>, | |
| 720 | 859 | /// Which field of the overlay has focus. | |
| 721 | 860 | draft_focus: FocusRing, | |
| 722 | 861 | /// What the raised confirm will do if answered yes. | |
| 723 | - | pending: Option<Pending>, | |
| 862 | + | pending_action: Option<PendingAction>, | |
| 724 | 863 | error: Option<String>, | |
| 725 | 864 | ticks: u64, | |
| 726 | 865 | } | |
| @@ -733,9 +872,10 @@ | |||
| 733 | 872 | tab, | |
| 734 | 873 | folders: Cursor::new(), | |
| 735 | 874 | devices: Cursor::new(), | |
| 875 | + | pending: Cursor::new(), | |
| 736 | 876 | draft: None, | |
| 737 | 877 | draft_focus: FocusRing::new(0), | |
| 738 | - | pending: None, | |
| 878 | + | pending_action: None, | |
| 739 | 879 | error: None, | |
| 740 | 880 | ticks: 0, | |
| 741 | 881 | }; | |
| @@ -752,6 +892,7 @@ | |||
| 752 | 892 | if let Reach::Running(state) = &reach { | |
| 753 | 893 | self.folders.resize(state.folders.len()); | |
| 754 | 894 | self.devices.resize(state.devices.len()); | |
| 895 | + | self.pending.resize(state.pending.len()); | |
| 755 | 896 | } | |
| 756 | 897 | self.reach = reach; | |
| 757 | 898 | } | |
| @@ -786,9 +927,46 @@ | |||
| 786 | 927 | match self.tab { | |
| 787 | 928 | Tab::Folders => &mut self.folders, | |
| 788 | 929 | Tab::Devices => &mut self.devices, | |
| 930 | + | Tab::Pending => &mut self.pending, | |
| 789 | 931 | } | |
| 790 | 932 | } | |
| 791 | 933 | ||
| 934 | + | fn pending_list(&self) -> &[PendingDevice] { | |
| 935 | + | self.state().map_or(&[], |state| &state.pending) | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | fn selected_pending(&self) -> Option<&PendingDevice> { | |
| 939 | + | self.pending_list().get(self.pending.selected()?) | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | /// Tab labels, with the pending count when there is one. | |
| 943 | + | /// | |
| 944 | + | /// The count is the whole discoverability story for this tab: an | |
| 945 | + | /// invitation nobody notices is the same as one that never arrived, and | |
| 946 | + | /// the label is visible from the other two tabs without stealing focus. | |
| 947 | + | fn tab_labels(&self) -> Vec<String> { | |
| 948 | + | Tab::ALL | |
| 949 | + | .iter() | |
| 950 | + | .map(|tab| { | |
| 951 | + | let waiting = self.pending_list().len(); | |
| 952 | + | if *tab == Tab::Pending && waiting > 0 { | |
| 953 | + | format!("pending ({waiting})") | |
| 954 | + | } else { | |
| 955 | + | tab.label().to_string() | |
| 956 | + | } | |
| 957 | + | }) | |
| 958 | + | .collect() | |
| 959 | + | } | |
| 960 | + | ||
| 961 | + | /// Let the selected device in. | |
| 962 | + | fn accept_pending(&mut self, log: &mut CommandLog) { | |
| 963 | + | let Some(pending) = self.selected_pending() else { | |
| 964 | + | return; | |
| 965 | + | }; | |
| 966 | + | let result = self.backend.accept_device(pending, log); | |
| 967 | + | self.finish(result, log); | |
| 968 | + | } | |
| 969 | + | ||
| 792 | 970 | /// Pause what is selected if it is running, resume it if it is paused. | |
| 793 | 971 | /// | |
| 794 | 972 | /// One key rather than two, because the row already says which state it is | |
| @@ -815,6 +993,9 @@ | |||
| 815 | 993 | } | |
| 816 | 994 | self.backend.set_device_paused(device, !device.paused, log) | |
| 817 | 995 | } | |
| 996 | + | // A pending device has no configuration to pause. It is not here | |
| 997 | + | // yet; that is what accepting is for. | |
| 998 | + | Tab::Pending => return, | |
| 818 | 999 | }; | |
| 819 | 1000 | self.finish(result, log); | |
| 820 | 1001 | } | |
| @@ -846,6 +1027,10 @@ | |||
| 846 | 1027 | id: TextField::new(), | |
| 847 | 1028 | name: TextField::new(), | |
| 848 | 1029 | }, | |
| 1030 | + | // `a` on the pending tab accepts rather than opens a form: the id | |
| 1031 | + | // and name are already known, and retyping a 56-character id that | |
| 1032 | + | // is on screen would be the opposite of help. | |
| 1033 | + | Tab::Pending => return, | |
| 849 | 1034 | }; | |
| 850 | 1035 | self.draft_focus = FocusRing::new(draft.labels().len()); | |
| 851 | 1036 | self.draft = Some(draft); | |
| @@ -948,7 +1133,7 @@ | |||
| 948 | 1133 | "Stop synchronizing {}? The files in {} stay where they are.", | |
| 949 | 1134 | folder.label, folder.path | |
| 950 | 1135 | ); | |
| 951 | - | self.pending = Some(Pending::RemoveFolder(folder)); | |
| 1136 | + | self.pending_action = Some(PendingAction::RemoveFolder(folder)); | |
| 952 | 1137 | Flow::Confirm(Confirm::destructive("remove folder", message)) | |
| 953 | 1138 | } | |
| 954 | 1139 | Tab::Devices => { | |
| @@ -963,9 +1148,18 @@ | |||
| 963 | 1148 | "Forget {}? It stops sharing folders with this machine, and keeps its own copy.", | |
| 964 | 1149 | device.name | |
| 965 | 1150 | ); | |
| 966 | - | self.pending = Some(Pending::RemoveDevice(device)); | |
| 1151 | + | self.pending_action = Some(PendingAction::RemoveDevice(device)); | |
| 967 | 1152 | Flow::Confirm(Confirm::destructive("remove device", message)) | |
| 968 | 1153 | } | |
| 1154 | + | // Syncthing's API can drop a pending entry; `syncthing cli` does | |
| 1155 | + | // not expose that, so there is nothing honest to bind here. Said | |
| 1156 | + | // out loud rather than left as a key that does nothing. | |
| 1157 | + | Tab::Pending => { | |
| 1158 | + | self.error = Some( | |
| 1159 | + | "syncthing cli cannot dismiss an invitation; accept it or ignore it".into(), | |
| 1160 | + | ); | |
| 1161 | + | Flow::Continue | |
| 1162 | + | } | |
| 969 | 1163 | } | |
| 970 | 1164 | } | |
| 971 | 1165 | ||
| @@ -993,6 +1187,16 @@ | |||
| 993 | 1187 | ]) | |
| 994 | 1188 | } | |
| 995 | 1189 | ||
| 1190 | + | fn pending_row<'a>(theme: &Theme, entry: &'a PendingDevice) -> Line<'a> { | |
| 1191 | + | Line::from(vec![ | |
| 1192 | + | text::bold(theme, format!("{:<20}", truncate(&entry.name, 19))), | |
| 1193 | + | text::secondary(theme, format!("{:<10}", entry.short_id())), | |
| 1194 | + | text::muted(theme, format!("{:<22}", truncate(&entry.address, 21))), | |
| 1195 | + | Span::styled(format!("{:<10}", "waiting"), Severity::Info.style(theme)), | |
| 1196 | + | text::muted(theme, day(&entry.time)), | |
| 1197 | + | ]) | |
| 1198 | + | } | |
| 1199 | + | ||
| 996 | 1200 | /// One labelled field line, with the caret drawn under a character. | |
| 997 | 1201 | /// | |
| 998 | 1202 | /// Same shape as the installer's account fields, minus the masking: none | |
| @@ -1090,6 +1294,14 @@ | |||
| 1090 | 1294 | if self.reach == Reach::NotRunning { | |
| 1091 | 1295 | return vec![hint("e", "enroll"), hint("r", "refresh")]; | |
| 1092 | 1296 | } | |
| 1297 | + | if self.tab == Tab::Pending { | |
| 1298 | + | return vec![ | |
| 1299 | + | hint("j/k", "select"), | |
| 1300 | + | hint("h/l", "tab"), | |
| 1301 | + | hint("a", "accept"), | |
| 1302 | + | hint("r", "refresh"), | |
| 1303 | + | ]; | |
| 1304 | + | } | |
| 1093 | 1305 | vec![ | |
| 1094 | 1306 | hint("j/k", "select"), | |
| 1095 | 1307 | hint("h/l", "tab"), | |
| @@ -1104,9 +1316,15 @@ | |||
| 1104 | 1316 | if let Some(error) = &self.error { | |
| 1105 | 1317 | return Some((Severity::Error, error.clone())); | |
| 1106 | 1318 | } | |
| 1107 | - | match self.reach { | |
| 1108 | - | Reach::NotRunning => Some((Severity::Warn, "not enrolled".into())), | |
| 1109 | - | Reach::Running(_) => None, | |
| 1319 | + | if self.reach == Reach::NotRunning { | |
| 1320 | + | return Some((Severity::Warn, "not enrolled".into())); | |
| 1321 | + | } | |
| 1322 | + | // Said on every tab, not only the one that lists them: an invitation | |
| 1323 | + | // nobody notices is the same as one that never arrived. | |
| 1324 | + | match self.pending_list().len() { | |
| 1325 | + | 0 => None, | |
| 1326 | + | 1 => Some((Severity::Info, "1 device waiting to connect".into())), | |
| 1327 | + | n => Some((Severity::Info, format!("{n} devices waiting to connect"))), | |
| 1110 | 1328 | } | |
| 1111 | 1329 | } | |
| 1112 | 1330 | ||
| @@ -1132,7 +1350,7 @@ | |||
| 1132 | 1350 | ]) | |
| 1133 | 1351 | .areas(inner); | |
| 1134 | 1352 | frame.render_widget( | |
| 1135 | - | AlloyTabs::new(theme, Tab::ALL.map(Tab::label)).selected(self.tab.slot()), | |
| 1353 | + | AlloyTabs::new(theme, self.tab_labels()).selected(self.tab.slot()), | |
| 1136 | 1354 | bar, | |
| 1137 | 1355 | ); | |
| 1138 | 1356 | ||
| @@ -1167,6 +1385,24 @@ | |||
| 1167 | 1385 | body, | |
| 1168 | 1386 | ); | |
| 1169 | 1387 | } | |
| 1388 | + | Tab::Pending => { | |
| 1389 | + | let pending = self.pending_list(); | |
| 1390 | + | if pending.is_empty() { | |
| 1391 | + | frame.render_widget( | |
| 1392 | + | Line::from(text::muted(theme, "nothing waiting to connect")), | |
| 1393 | + | body, | |
| 1394 | + | ); | |
| 1395 | + | return; | |
| 1396 | + | } | |
| 1397 | + | let rows: Vec<Line> = pending | |
| 1398 | + | .iter() | |
| 1399 | + | .map(|entry| Self::pending_row(theme, entry)) | |
| 1400 | + | .collect(); | |
| 1401 | + | frame.render_widget( | |
| 1402 | + | AlloyList::new(theme, rows).selected(self.pending.selected()), | |
| 1403 | + | body, | |
| 1404 | + | ); | |
| 1405 | + | } | |
| 1170 | 1406 | } | |
| 1171 | 1407 | ||
| 1172 | 1408 | // Last, and over the whole area rather than the body, so it floats | |
| @@ -1202,12 +1438,25 @@ | |||
| 1202 | 1438 | match key.code { | |
| 1203 | 1439 | KeyCode::Char('j') | KeyCode::Down => self.cursor().next(), | |
| 1204 | 1440 | KeyCode::Char('k') | KeyCode::Up => self.cursor().prev(), | |
| 1205 | - | KeyCode::Char('h') | KeyCode::Left => self.tab = Tab::Folders, | |
| 1206 | - | KeyCode::Char('l') | KeyCode::Right => self.tab = Tab::Devices, | |
| 1441 | + | // Step, rather than jump to an end: with three tabs "left" and | |
| 1442 | + | // "right" have to mean neighbours or the middle one is unreachable | |
| 1443 | + | // by those keys. | |
| 1444 | + | KeyCode::Char('h') | KeyCode::Left => { | |
| 1445 | + | self.tab = Tab::from_slot(self.tab.slot().saturating_sub(1)); | |
| 1446 | + | } | |
| 1447 | + | KeyCode::Char('l') | KeyCode::Right => { | |
| 1448 | + | self.tab = Tab::from_slot((self.tab.slot() + 1).min(Tab::ALL.len() - 1)); | |
| 1449 | + | } | |
| 1207 | 1450 | KeyCode::Tab => { | |
| 1208 | 1451 | self.tab = Tab::from_slot((self.tab.slot() + 1) % Tab::ALL.len()); | |
| 1209 | 1452 | } | |
| 1210 | - | KeyCode::Char('a') => self.open_draft(), | |
| 1453 | + | KeyCode::Char('a') => { | |
| 1454 | + | if self.tab == Tab::Pending { | |
| 1455 | + | self.accept_pending(log); | |
| 1456 | + | } else { | |
| 1457 | + | self.open_draft(); | |
| 1458 | + | } | |
| 1459 | + | } | |
| 1211 | 1460 | KeyCode::Char('d') => return self.remove_selected(), | |
| 1212 | 1461 | KeyCode::Char('p') => self.toggle_paused(log), | |
| 1213 | 1462 | KeyCode::Char('e') => self.enroll(log), | |
| @@ -1218,12 +1467,12 @@ | |||
| 1218 | 1467 | } | |
| 1219 | 1468 | ||
| 1220 | 1469 | fn confirmed(&mut self, log: &mut CommandLog) -> Flow { | |
| 1221 | - | let Some(pending) = self.pending.take() else { |
Lines truncated