max / alloy
2 files changed,
+379 insertions,
-30 deletions
| @@ -156,9 +156,9 @@ | |||
| 156 | 156 | ||
| 157 | 157 | The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy config` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens. | |
| 158 | 158 | ||
| 159 | - | - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, and `alloy pkg box`. 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. | |
| 159 | + | - **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. | |
| 160 | 160 | - **Next.** `alloy config`, 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. | |
| 161 | - | - **Blocked on the target machine.** `alloy display`, `alloy update`, and `alloy pkg install` front `swaymsg`/`wlr-randr` and `rpm-ostree`, none of which exist on a non-Fedora, non-sway development box. The boxes tab was never blocked this way, since podman and flatpak are both on the dev box and their output was captured from it. Writing them now would mean shipping parsers checked against nothing but their own fixtures, which is exactly how the two parser bugs found so far got written. They want the QEMU image or real hardware. | |
| 161 | + | - **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. | |
| 162 | 162 | - **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. | |
| 163 | 163 | - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.) | |
| 164 | 164 | - **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. |
| @@ -10,11 +10,15 @@ | |||
| 10 | 10 | //! | `alloy pkg box` | boxes | `podman ps` and `flatpak list` | | |
| 11 | 11 | //! | `alloy update` | system | `rpm-ostree status --json` | | |
| 12 | 12 | //! | |
| 13 | - | //! Only the boxes tab is live. The other two front `rpm-ostree`, which does not | |
| 14 | - | //! exist on a non-Fedora development box, and docs/CONSOLE.md:144 is explicit | |
| 15 | - | //! that shipping parsers checked against nothing but their own fixtures is how | |
| 16 | - | //! the two parser bugs found so far got written. They render what they are | |
| 17 | - | //! waiting for instead of guessing at it. | |
| 13 | + | //! All three tabs are live. The boxes tab fronts podman and flatpak; the | |
| 14 | + | //! installed and system tabs front `rpm-ostree status --json`, parsed once and | |
| 15 | + | //! read two ways ([`Status`]). rpm-ostree does not exist on a non-Fedora | |
| 16 | + | //! development box, so off an ostree system those two tabs say so rather than | |
| 17 | + | //! showing an empty inventory that reads as a machine with nothing on it. The | |
| 18 | + | //! `RPM_OSTREE` fixture was captured from a booted Alloy install, not invented: | |
| 19 | + | //! docs/CONSOLE.md:144 is explicit that shipping parsers checked against nothing | |
| 20 | + | //! but their own fixtures is how the two box-parser bugs got written, and | |
| 21 | + | //! `parses_this_machines_real_status` is the live check for an ostree box. | |
| 18 | 22 | //! | |
| 19 | 23 | //! # Not a package manager | |
| 20 | 24 | //! | |
| @@ -997,6 +1001,143 @@ | |||
| 997 | 1001 | }); | |
| 998 | 1002 | } | |
| 999 | 1003 | ||
| 1004 | + | // ---- the host image: rpm-ostree deployments ---- | |
| 1005 | + | ||
| 1006 | + | /// The parse of `rpm-ostree status --json`. | |
| 1007 | + | /// | |
| 1008 | + | /// One command feeds two tabs: the system tab lists [`Status::deployments`], the | |
| 1009 | + | /// installed tab lists the booted deployment's layered packages. They are two | |
| 1010 | + | /// readings of one fact, which is why they refresh together and share a status | |
| 1011 | + | /// line. Unknown top-level keys (`transaction`, `cached-update`, `update-driver`) | |
| 1012 | + | /// are ignored; the two tabs read deployments and nothing else. | |
| 1013 | + | #[derive(Debug, Default, Deserialize)] | |
| 1014 | + | struct Status { | |
| 1015 | + | #[serde(default)] | |
| 1016 | + | deployments: Vec<Deployment>, | |
| 1017 | + | } | |
| 1018 | + | ||
| 1019 | + | impl Status { | |
| 1020 | + | /// The deployment currently running, if the status names one. | |
| 1021 | + | /// | |
| 1022 | + | /// The installed tab is about the layering on the machine as it is now, so it | |
| 1023 | + | /// reads the booted deployment rather than a staged or rollback one. A status | |
| 1024 | + | /// with no booted deployment is possible (mid-stage) and yields no layered | |
| 1025 | + | /// list rather than a wrong one. | |
| 1026 | + | fn booted(&self) -> Option<&Deployment> { | |
| 1027 | + | self.deployments.iter().find(|d| d.booted) | |
| 1028 | + | } | |
| 1029 | + | } | |
| 1030 | + | ||
| 1031 | + | /// One ostree deployment. | |
| 1032 | + | /// | |
| 1033 | + | /// Alloy is a bootc container image, so its deployments carry a | |
| 1034 | + | /// `container-image-reference` rather than the classic ostree `origin` | |
| 1035 | + | /// (a `remote:ref` string). Both are modeled and neither is invented when | |
| 1036 | + | /// absent: a machine tracking an ostree remote instead of a container still | |
| 1037 | + | /// reads correctly, and the row shows whichever identity the deployment actually | |
| 1038 | + | /// has. Fields the tabs do not read (the base-commit metadata, the override | |
| 1039 | + | /// tables) are left out of the struct rather than parsed and ignored. | |
| 1040 | + | #[derive(Debug, Deserialize)] | |
| 1041 | + | struct Deployment { | |
| 1042 | + | checksum: String, | |
| 1043 | + | /// The image's own version label, e.g. `43.20260719.0`. Human-meaningful and | |
| 1044 | + | /// date-bearing, which is why the system tab shows it rather than formatting | |
| 1045 | + | /// the raw `timestamp`. | |
| 1046 | + | #[serde(default)] | |
| 1047 | + | version: Option<String>, | |
| 1048 | + | #[serde(default)] | |
| 1049 | + | booted: bool, | |
| 1050 | + | #[serde(default)] | |
| 1051 | + | staged: bool, | |
| 1052 | + | #[serde(default)] | |
| 1053 | + | pinned: bool, | |
| 1054 | + | /// The container image this deployment was pulled from — the bootc case, and | |
| 1055 | + | /// the one every Alloy deployment is in. | |
| 1056 | + | #[serde(rename = "container-image-reference", default)] | |
| 1057 | + | image: Option<String>, | |
| 1058 | + | /// The classic ostree origin (`remote:ref`), present instead of `image` on a | |
| 1059 | + | /// deployment that tracks an ostree remote rather than a container. | |
| 1060 | + | #[serde(default)] | |
| 1061 | + | origin: Option<String>, | |
| 1062 | + | /// Layered packages actually applied over the base image. | |
| 1063 | + | #[serde(default)] | |
| 1064 | + | packages: Vec<String>, | |
| 1065 | + | /// Layered packages the user requested by name. Equal to `packages` except | |
| 1066 | + | /// while a change to the layering is staged but not yet booted, which is the | |
| 1067 | + | /// one moment the two disagree. | |
| 1068 | + | #[serde(rename = "requested-packages", default)] | |
| 1069 | + | requested_packages: Vec<String>, | |
| 1070 | + | } | |
| 1071 | + | ||
| 1072 | + | impl Deployment { | |
| 1073 | + | /// The deployment's identity for the system tab: its container image, or its | |
| 1074 | + | /// ostree origin, or — failing both — the bare commit, so a deployment with | |
| 1075 | + | /// neither still names itself rather than showing an empty cell. | |
| 1076 | + | fn source(&self) -> &str { | |
| 1077 | + | self.image | |
| 1078 | + | .as_deref() | |
| 1079 | + | .or(self.origin.as_deref()) | |
| 1080 | + | .unwrap_or(&self.checksum) | |
| 1081 | + | } | |
| 1082 | + | ||
| 1083 | + | /// The version label, or a dash when the deployment carries none. A commit | |
| 1084 | + | /// without a version is legal ostree and the column shows the gap rather than | |
| 1085 | + | /// collapsing the row. | |
| 1086 | + | fn version(&self) -> &str { | |
| 1087 | + | self.version.as_deref().unwrap_or("-") | |
| 1088 | + | } | |
| 1089 | + | ||
| 1090 | + | /// The layered set for the installed tab: the requested names, falling back | |
| 1091 | + | /// to the applied set. Empty is the ordinary, healthy answer on an image | |
| 1092 | + | /// system and the tab says as much rather than reading empty as broken. | |
| 1093 | + | fn layered(&self) -> &[String] { | |
| 1094 | + | if self.requested_packages.is_empty() { | |
| 1095 | + | &self.packages | |
| 1096 | + | } else { | |
| 1097 | + | &self.requested_packages | |
| 1098 | + | } | |
| 1099 | + | } | |
| 1100 | + | ||
| 1101 | + | /// The word in the state column and its severity: the booted deployment is | |
| 1102 | + | /// the healthy resting state, a staged one is a pending change worth a | |
| 1103 | + | /// glance, and everything else is a rollback target sitting in reserve. | |
| 1104 | + | fn state(&self) -> (&'static str, Severity) { | |
| 1105 | + | if self.booted { | |
| 1106 | + | ("booted", Severity::Healthy) | |
| 1107 | + | } else if self.staged { | |
| 1108 | + | ("staged", Severity::Warn) | |
| 1109 | + | } else { | |
| 1110 | + | ("rollback", Severity::Info) | |
| 1111 | + | } | |
| 1112 | + | } | |
| 1113 | + | } | |
| 1114 | + | ||
| 1115 | + | /// Reads `rpm-ostree status --json`. | |
| 1116 | + | /// | |
| 1117 | + | /// Deliberately not a [`Backend`]: those answer for containers and yield [`Box`] | |
| 1118 | + | /// rows, and rpm-ostree answers for the host image on a different axis entirely. | |
| 1119 | + | /// It keeps the same return-not-execute shape, so [`Rpm::parse`] is a pure | |
| 1120 | + | /// function checkable against a fixture with no rpm-ostree on the machine — | |
| 1121 | + | /// which the dev box this is written on does not have. | |
| 1122 | + | struct Rpm; | |
| 1123 | + | ||
| 1124 | + | impl Rpm { | |
| 1125 | + | /// Whether this is an ostree machine at all. Probed once, like the container | |
| 1126 | + | /// backends in [`detect`], rather than on every refresh: the answer does not | |
| 1127 | + | /// change while the console is running. | |
| 1128 | + | fn present() -> bool { | |
| 1129 | + | Invocation::new("rpm-ostree").arg("--version").probe() | |
| 1130 | + | } | |
| 1131 | + | ||
| 1132 | + | fn status() -> Invocation { | |
| 1133 | + | Invocation::new("rpm-ostree").args(["status", "--json"]) | |
| 1134 | + | } | |
| 1135 | + | ||
| 1136 | + | fn parse(raw: &str) -> Result<Status> { | |
| 1137 | + | serde_json::from_str(raw).context("rpm-ostree emitted invalid JSON") | |
| 1138 | + | } | |
| 1139 | + | } | |
| 1140 | + | ||
| 1000 | 1141 | // ---- the view ---- | |
| 1001 | 1142 | ||
| 1002 | 1143 | /// The three tabs, in bar order. | |
| @@ -1038,13 +1179,12 @@ | |||
| 1038 | 1179 | } | |
| 1039 | 1180 | } | |
| 1040 | 1181 | ||
| 1041 | - | /// What the installed and system tabs are waiting for. | |
| 1182 | + | /// Shown on the installed and system tabs when there is no rpm-ostree to read. | |
| 1042 | 1183 | /// | |
| 1043 | - | /// Both read `rpm-ostree status --json`. Saying so is more useful than an empty | |
| 1044 | - | /// list, and more honest than a mock: this is a screen about what is really on | |
| 1045 | - | /// the machine, and the machine this is running on is not the target. | |
| 1046 | - | const RPM_OSTREE_PENDING: &str = | |
| 1047 | - | "needs rpm-ostree, which is not on this machine (see docs/CONSOLE.md)"; | |
| 1184 | + | /// Both tabs front `rpm-ostree status --json`. Off an ostree system the binary | |
| 1185 | + | /// is absent, and saying so is more honest than an empty list: this is a screen | |
| 1186 | + | /// about what is really on the machine, and a dev box is not an Alloy install. | |
| 1187 | + | const RPM_OSTREE_ABSENT: &str = "not an ostree system: rpm-ostree is not installed"; | |
| 1048 | 1188 | ||
| 1049 | 1189 | /// A remove the user has been asked to confirm. | |
| 1050 | 1190 | /// | |
| @@ -1080,6 +1220,15 @@ | |||
| 1080 | 1220 | error: Option<String>, | |
| 1081 | 1221 | pending: Option<PendingRemove>, | |
| 1082 | 1222 | ticks: u64, | |
| 1223 | + | /// Whether rpm-ostree is on this machine, probed once at construction. Off an | |
| 1224 | + | /// ostree system it is absent, and the installed and system tabs say so | |
| 1225 | + | /// rather than reading empty as a machine with nothing installed. | |
| 1226 | + | has_rpm: bool, | |
| 1227 | + | /// The last `rpm-ostree status --json`, or the error from trying. `None` | |
| 1228 | + | /// until the first read, and left `None` forever when [`has_rpm`] is false. | |
| 1229 | + | /// | |
| 1230 | + | /// [`has_rpm`]: PkgView::has_rpm | |
| 1231 | + | rpm: Option<Result<Status>>, | |
| 1083 | 1232 | } | |
| 1084 | 1233 | ||
| 1085 | 1234 | impl PkgView { | |
| @@ -1096,6 +1245,8 @@ | |||
| 1096 | 1245 | error: None, | |
| 1097 | 1246 | pending: None, | |
| 1098 | 1247 | ticks: 0, | |
| 1248 | + | has_rpm: Rpm::present(), | |
| 1249 | + | rpm: None, | |
| 1099 | 1250 | }; | |
| 1100 | 1251 | view.refresh(log); | |
| 1101 | 1252 | view | |
| @@ -1151,6 +1302,14 @@ | |||
| 1151 | 1302 | if let Some(message) = failure { | |
| 1152 | 1303 | self.error = Some(message); | |
| 1153 | 1304 | } | |
| 1305 | + | ||
| 1306 | + | // The host image, on the same refresh as the containers: the installed | |
| 1307 | + | // and system tabs are as live as the boxes tab, and a rollback the user | |
| 1308 | + | // just pinned shows up on the next tick without a keypress. Skipped | |
| 1309 | + | // entirely off an ostree system, where the probe already answered no. | |
| 1310 | + | if self.has_rpm { | |
| 1311 | + | self.rpm = Some(Rpm::status().run(log).and_then(|raw| Rpm::parse(&raw))); | |
| 1312 | + | } | |
| 1154 | 1313 | } | |
| 1155 | 1314 | ||
| 1156 | 1315 | /// Rows for declared boxes the system does not have. | |
| @@ -1420,6 +1579,120 @@ | |||
| 1420 | 1579 | ||
| 1421 | 1580 | frame.render_widget(Line::from(text::muted(theme, self.summary())), summary_area); | |
| 1422 | 1581 | } | |
| 1582 | + | ||
| 1583 | + | /// The installed tab: the layered packages on top of the base image. | |
| 1584 | + | /// | |
| 1585 | + | /// On an Alloy system this is empty, and that emptiness is the thing the tab | |
| 1586 | + | /// exists to show — an image system carries no per-machine package drift, so | |
| 1587 | + | /// the honest reading is "nothing added", not a blank that looks like a | |
| 1588 | + | /// failure. The rows, when there are any, are the exception worth seeing. | |
| 1589 | + | fn render_installed(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 1590 | + | let status = match self.rpm_or_reason() { | |
| 1591 | + | Ok(status) => status, | |
| 1592 | + | Err(line) => { | |
| 1593 | + | frame.render_widget(Line::from(text::muted(theme, line)), area); | |
| 1594 | + | return; | |
| 1595 | + | } | |
| 1596 | + | }; | |
| 1597 | + | ||
| 1598 | + | let [list_area, summary_area] = | |
| 1599 | + | Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area); | |
| 1600 | + | ||
| 1601 | + | let packages = status.booted().map(Deployment::layered).unwrap_or_default(); | |
| 1602 | + | if packages.is_empty() { | |
| 1603 | + | frame.render_widget( | |
| 1604 | + | Line::from(text::muted( | |
| 1605 | + | theme, | |
| 1606 | + | "nothing layered — this system is the image, with no packages added on top", | |
| 1607 | + | )), | |
| 1608 | + | list_area, | |
| 1609 | + | ); | |
| 1610 | + | frame.render_widget( | |
| 1611 | + | Line::from(text::muted(theme, "0 layered packages")), | |
| 1612 | + | summary_area, | |
| 1613 | + | ); | |
| 1614 | + | return; | |
| 1615 | + | } | |
| 1616 | + | ||
| 1617 | + | let rows: Vec<Line> = packages | |
| 1618 | + | .iter() | |
| 1619 | + | .map(|name| Line::from(text::bold(theme, name.clone()))) | |
| 1620 | + | .collect(); | |
| 1621 | + | frame.render_widget(AlloyList::new(theme, rows), list_area); | |
| 1622 | + | frame.render_widget( | |
| 1623 | + | Line::from(text::muted( | |
| 1624 | + | theme, | |
| 1625 | + | format!("{} layered on top of the base image", packages.len()), | |
| 1626 | + | )), | |
| 1627 | + | summary_area, | |
| 1628 | + | ); | |
| 1629 | + | } | |
| 1630 | + | ||
| 1631 | + | /// The system tab: the ostree deployments, booted first. | |
| 1632 | + | /// | |
| 1633 | + | /// One row per deployment — the booted one, anything staged, and the | |
| 1634 | + | /// rollback targets held in reserve — each naming the image or origin it came | |
| 1635 | + | /// from and its version. This is what `alloy update` acts on, so seeing the | |
| 1636 | + | /// booted image and the one waiting to replace it in the same list is the | |
| 1637 | + | /// point. | |
| 1638 | + | fn render_system(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 1639 | + | let status = match self.rpm_or_reason() { | |
| 1640 | + | Ok(status) => status, | |
| 1641 | + | Err(line) => { | |
| 1642 | + | frame.render_widget(Line::from(text::muted(theme, line)), area); | |
| 1643 | + | return; | |
| 1644 | + | } | |
| 1645 | + | }; | |
| 1646 | + | ||
| 1647 | + | let [list_area, summary_area] = | |
| 1648 | + | Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area); | |
| 1649 | + | ||
| 1650 | + | if status.deployments.is_empty() { | |
| 1651 | + | frame.render_widget(Line::from(text::muted(theme, "no deployments")), list_area); | |
| 1652 | + | return; | |
| 1653 | + | } | |
| 1654 | + | ||
| 1655 | + | let rows: Vec<Line> = status | |
| 1656 | + | .deployments | |
| 1657 | + | .iter() | |
| 1658 | + | .map(|dep| self.deployment_row(theme, dep)) | |
| 1659 | + | .collect(); | |
| 1660 | + | frame.render_widget(AlloyList::new(theme, rows), list_area); | |
| 1661 | + | ||
| 1662 | + | let pinned = status.deployments.iter().filter(|d| d.pinned).count(); | |
| 1663 | + | let mut summary = format!("{} deployments", status.deployments.len()); | |
| 1664 | + | if pinned > 0 { | |
| 1665 | + | summary.push_str(&format!(", {pinned} pinned")); | |
| 1666 | + | } | |
| 1667 | + | frame.render_widget(Line::from(text::muted(theme, summary)), summary_area); | |
| 1668 | + | } | |
| 1669 | + | ||
| 1670 | + | fn deployment_row<'a>(&self, theme: &Theme, dep: &'a Deployment) -> Line<'a> { | |
| 1671 | + | let (state, severity) = dep.state(); | |
| 1672 | + | Line::from(vec![ | |
| 1673 | + | Span::styled(format!("{state:<9}"), severity.style(theme)), | |
| 1674 | + | text::bold(theme, format!("{:<34}", truncate(dep.source(), 33))), | |
| 1675 | + | text::secondary(theme, format!("{:<16}", truncate(dep.version(), 15))), | |
| 1676 | + | text::muted(theme, if dep.pinned { "pinned" } else { "" }), | |
| 1677 | + | ]) | |
| 1678 | + | } | |
| 1679 | + | ||
| 1680 | + | /// The parsed status, or the line to render in its place. | |
| 1681 | + | /// | |
| 1682 | + | /// Three cases the tabs share: no rpm-ostree at all (not an ostree system), | |
| 1683 | + | /// rpm-ostree present but the read failed, and a read that has not happened | |
| 1684 | + | /// yet. Each is a reason the list is empty that is not "the machine has | |
| 1685 | + | /// nothing", which is the one wrong thing an inventory must never imply. | |
| 1686 | + | fn rpm_or_reason(&self) -> std::result::Result<&Status, String> { | |
| 1687 | + | if !self.has_rpm { | |
| 1688 | + | return Err(RPM_OSTREE_ABSENT.to_string()); | |
| 1689 | + | } | |
| 1690 | + | match &self.rpm { | |
| 1691 | + | Some(Ok(status)) => Ok(status), | |
| 1692 | + | Some(Err(err)) => Err(format!("rpm-ostree failed: {err}")), | |
| 1693 | + | None => Err("reading rpm-ostree...".to_string()), | |
| 1694 | + | } | |
| 1695 | + | } | |
| 1423 | 1696 | } | |
| 1424 | 1697 | ||
| 1425 | 1698 | impl View for PkgView { | |
| @@ -1446,9 +1719,11 @@ | |||
| 1446 | 1719 | hint("e", "export"), | |
| 1447 | 1720 | hint("enter", "shell"), | |
| 1448 | 1721 | hint("x", "remove"), | |
| 1449 | - | hint("r", "refresh"), | |
| 1450 | 1722 | ]); | |
| 1451 | 1723 | } | |
| 1724 | + | // Every tab refreshes; the rpm-ostree tabs have no per-row actions yet, | |
| 1725 | + | // so refresh is the whole of their interaction. | |
| 1726 | + | hints.push(hint("r", "refresh")); | |
| 1452 | 1727 | hints | |
| 1453 | 1728 | } | |
| 1454 | 1729 | ||
| @@ -1456,12 +1731,17 @@ | |||
| 1456 | 1731 | if let Some(error) = &self.error { | |
| 1457 | 1732 | return Some((Severity::Error, error.clone())); | |
| 1458 | 1733 | } | |
| 1459 | - | // The blocked tabs say so in the status line as well as the body: the | |
| 1460 | - | // body text explains, the status line is what a user scanning the | |
| 1461 | - | // footer sees without reading. | |
| 1734 | + | // The rpm-ostree tabs echo their blocker in the footer too: the body | |
| 1735 | + | // explains, the status line is what a user scanning the bottom sees | |
| 1736 | + | // without reading. A tab that is reading fine says nothing, same as the | |
| 1737 | + | // boxes tab. | |
| 1462 | 1738 | match self.tab() { | |
| 1463 | - | Tab::Installed | Tab::System => Some((Severity::Warn, RPM_OSTREE_PENDING.to_string())), | |
| 1464 | 1739 | Tab::Boxes => None, | |
| 1740 | + | Tab::Installed | Tab::System => match self.rpm_or_reason() { | |
| 1741 | + | Ok(_) => None, | |
| 1742 | + | Err(_) if !self.has_rpm => Some((Severity::Warn, RPM_OSTREE_ABSENT.to_string())), | |
| 1743 | + | Err(reason) => Some((Severity::Error, reason)), | |
| 1744 | + | }, | |
| 1465 | 1745 | } | |
| 1466 | 1746 | } | |
| 1467 | 1747 | ||
| @@ -1489,16 +1769,8 @@ | |||
| 1489 | 1769 | ||
| 1490 | 1770 | match self.tab() { | |
| 1491 | 1771 | Tab::Boxes => self.render_boxes(frame, body, theme), | |
| 1492 | - | // Both blocked tabs name what they would show, so the wait reads as | |
| 1493 | - | // a missing tool rather than as an empty system. | |
| 1494 | - | Tab::Installed | Tab::System => { | |
| 1495 | - | let subject = match self.tab() { | |
| 1496 | - | Tab::Installed => "layered packages", | |
| 1497 | - | _ => "deployments", | |
| 1498 | - | }; | |
| 1499 | - | let line = format!("{subject}: {RPM_OSTREE_PENDING}"); | |
| 1500 | - | frame.render_widget(Line::from(text::muted(theme, line)), body); | |
| 1501 | - | } | |
| 1772 | + | Tab::Installed => self.render_installed(frame, body, theme), | |
| 1773 | + | Tab::System => self.render_system(frame, body, theme), | |
| 1502 | 1774 | } | |
| 1503 | 1775 | } | |
| 1504 | 1776 | ||
| @@ -1519,6 +1791,13 @@ | |||
| 1519 | 1791 | _ => {} | |
| 1520 | 1792 | } | |
| 1521 | 1793 | ||
| 1794 | + | // Refresh works on every tab: the rpm-ostree tabs are read-only and this | |
| 1795 | + | // is their one control. Handled before the boxes-only gate below. | |
| 1796 | + | if key.code == KeyCode::Char('r') { | |
| 1797 | + | self.refresh(log); | |
| 1798 | + | return Flow::Continue; | |
| 1799 | + | } | |
| 1800 | + | ||
| 1522 | 1801 | if self.tab() != Tab::Boxes { | |
| 1523 | 1802 | return Flow::Continue; | |
| 1524 | 1803 | } | |
| @@ -1530,7 +1809,6 @@ | |||
| 1530 | 1809 | KeyCode::Char('c') => self.create(log), | |
| 1531 | 1810 | KeyCode::Char('e') => self.export(log), | |
| 1532 | 1811 | KeyCode::Char('x') => return self.confirm_remove(), | |
| 1533 | - | KeyCode::Char('r') => self.refresh(log), | |
| 1534 | 1812 | KeyCode::Enter => return self.enter(log), | |
| 1535 | 1813 | _ => {} | |
| 1536 | 1814 | } | |
| @@ -1587,6 +1865,68 @@ | |||
| 1587 | 1865 | const FLATPAK: &str = "org.chromium.Chromium\tChromium Web Browser\tflathub\n\ | |
| 1588 | 1866 | dev.edfloreshz.Tasks\tTasks\tflathub\n"; | |
| 1589 | 1867 | ||
| 1868 | + | // Captured from a real `alloy install` booted in QEMU, 2026-07-22, by running | |
| 1869 | + | // `rpm-ostree status --json` on the installed system. The `base-commit-meta` | |
| 1870 | + | // manifest blob (a 38 KB embedded OCI manifest) and the null `transaction` / | |
| 1871 | + | // `cached-update` / `update-driver` keys are elided; the parser reads none of | |
| 1872 | + | // them. This is the whole of a stock Alloy deployment: one booted | |
| 1873 | + | // container-image deployment with nothing layered, which is the state the | |
| 1874 | + | // installed tab exists to show. | |
| 1875 | + | const RPM_OSTREE: &str = r#"{ | |
| 1876 | + | "deployments": [ | |
| 1877 | + | { | |
| 1878 | + | "booted": true, | |
| 1879 | + | "staged": false, | |
| 1880 | + | "pinned": false, | |
| 1881 | + | "checksum": "8c661a7de56796ed1794c6061d09d5ee75d45abcf48bbf0c85cabbdfb6bd4cc9", | |
| 1882 | + | "container-image-reference": "ostree-unverified-registry:/run/initramfs/live/source/alloy:local", | |
| 1883 | + | "container-image-reference-digest": "sha256:dcc5a684cceefca22366d09aed5aed9698ccbfa576abe543e32ad349ab58af5b", | |
| 1884 | + | "version": "43.20260719.0", | |
| 1885 | + | "timestamp": 1784753154, | |
| 1886 | + | "packages": [], | |
| 1887 | + | "requested-packages": [] | |
| 1888 | + | } | |
| 1889 | + | ] | |
| 1890 | + | }"#; | |
| 1891 | + | ||
| 1892 | + | // Not a real Alloy capture: a stock image layers nothing, so this state does | |
| 1893 | + | // not occur on Alloy. Hand-authored to rpm-ostree's documented schema (the | |
| 1894 | + | // field shapes match the real capture above) to exercise the cases the | |
| 1895 | + | // pristine one cannot — a booted deployment with layered packages, a staged | |
| 1896 | + | // change pending underneath it, and a classic ostree-origin rollback target | |
| 1897 | + | // with no container reference. It is what a machine someone had run | |
| 1898 | + | // `rpm-ostree install` on would report, which is exactly the exception the | |
| 1899 | + | // installed tab is for. | |
| 1900 | + | const RPM_OSTREE_LAYERED: &str = r#"{ | |
| 1901 | + | "deployments": [ | |
| 1902 | + | { | |
| 1903 | + | "staged": true, | |
| 1904 | + | "booted": false, | |
| 1905 | + | "checksum": "1111111111111111111111111111111111111111111111111111111111111111", | |
| 1906 | + | "container-image-reference": "ostree-unverified-registry:localhost/alloy:local", | |
| 1907 | + | "version": "43.20260720.0", | |
| 1908 | + | "requested-packages": ["vim-enhanced", "rsync", "tmux"], | |
| 1909 | + | "packages": ["vim-enhanced", "rsync", "tmux"] | |
| 1910 | + | }, | |
| 1911 | + | { | |
| 1912 | + | "booted": true, | |
| 1913 | + | "pinned": true, | |
| 1914 | + | "checksum": "2222222222222222222222222222222222222222222222222222222222222222", | |
| 1915 | + | "container-image-reference": "ostree-unverified-registry:localhost/alloy:local", | |
| 1916 | + | "version": "43.20260719.0", | |
| 1917 | + | "requested-packages": ["vim-enhanced", "rsync"], | |
| 1918 | + | "packages": ["vim-enhanced", "rsync"] | |
| 1919 | + | }, | |
| 1920 | + | { | |
| 1921 | + | "booted": false, | |
| 1922 | + | "checksum": "3333333333333333333333333333333333333333333333333333333333333333", | |
| 1923 | + | "origin": "fedora:fedora/43/x86_64/silverblue", | |
| 1924 | + | "version": "42.20260601.0", | |
| 1925 | + | "packages": [] | |
| 1926 | + | } | |
| 1927 | + | ] | |
| 1928 | + | }"#; | |
| 1929 | + | ||
| 1590 | 1930 | /// A spec declaring each name at a level, with no source fields. | |
| 1591 | 1931 | /// | |
| 1592 | 1932 | /// Enough for the parsers, which only ask the spec for a level and a | |
| @@ -2324,6 +2664,11 @@ | |||
| 2324 | 2664 | error: None, | |
| 2325 | 2665 | pending: None, | |
| 2326 | 2666 | ticks: 0, | |
| 2667 | + | // The fixture views exercise the boxes tab; rpm-ostree is neither | |
| 2668 | + | // probed nor read here, which keeps these tests off the host and | |
| 2669 | + | // deterministic. The rpm-ostree parser is covered on its own above. | |
| 2670 | + | has_rpm: false, | |
| 2671 | + | rpm: None, | |
| 2327 | 2672 | }; | |
| 2328 | 2673 | view.cursor.resize(view.boxes.len()); | |
| 2329 | 2674 | (view, CommandLog::new()) | |
| @@ -2504,9 +2849,13 @@ | |||
| 2504 | 2849 | let labels: Vec<&str> = boxes_view.hints().iter().map(|h| h.key).collect(); | |
| 2505 | 2850 | assert!(labels.contains(&"x"), "the live tab offers its actions"); | |
| 2506 | 2851 | ||
| 2852 | + | // The rpm-ostree tabs have no per-row actions, so they offer tab movement | |
| 2853 | + | // and refresh and nothing else — not the box verbs, which would advertise | |
| 2854 | + | // actions that do nothing here. | |
| 2507 | 2855 | let (system_view, _log) = fixture_view(Tab::System, Vec::new()); | |
| 2508 | 2856 | let labels: Vec<&str> = system_view.hints().iter().map(|h| h.key).collect(); | |
| 2509 | - | assert_eq!(labels, ["h/l"], "a blocked tab offers only tab movement"); | |
| 2857 | + | assert_eq!(labels, ["h/l", "r"]); | |
| 2858 | + | assert!(!labels.contains(&"x"), "and none of the box actions"); | |
| 2510 | 2859 | } |
Lines truncated