Skip to main content

max / alloy

sync: a pending tab, so a pairing can be finished here This is how a pairing actually completes: the other machine adds this one, dials it, and waits to be let in. Without the list, the answer to "I added you, now what" was the web UI, which is the interface this view exists to replace. Three tabs now. Pending is last because it is usually empty, and carries its count in its own label plus a line in the status bar on every tab. An invitation nobody notices is the same as one that never arrived, and that is the whole discoverability story for a tab you have no reason to visit. `a` on that tab accepts rather than opening the add overlay: the id and name are already on screen, and retyping a 56-character id would be the opposite of help. Accepting is the same add the overlay runs, and syncthing drops the entry once the device is configured, so nothing has to clear the list. ACCEPTING IS POSSIBLE, DECLINING IS NOT. Syncthing's REST API can drop a pending entry and `syncthing cli` does not expose that, so this can let a device in and cannot turn one away. An unaccepted device stays listed. Shipped as-is, with `d` on that tab saying so out loud, because a dismiss button that silently did nothing would be worse than not having one. h and l now step between neighbours instead of jumping to the ends, or the middle tab could not be reached with them. The shape came from a real pending entry, produced by pointing a second instance at a first with distinct listen ports. Worth the setup: `address` is a single string, where the REST reference describes an `addresses` array elsewhere, so assuming would have been the deviceID bug a second time. Accept is verified the same way, end to end: the entry clears and the device appears configured.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 20:18 UTC
Signed with PGP, not checked
Commit: 19cf84c8536eefeb87cb56823305c88c41f90ae5
Parent: 4688359
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