From 7e502d98827b811bb3ba137655cd55a64ca845c1 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:39:05 +0200 Subject: [PATCH] feat(core-status): multi-select cores, bulk update to a named build The Core Status panel could send a named test build to exactly one core: the only entry point was the row context menu, and every bulk path (the server-row arrow, the footer buttons) was hard-wired to the release build. Updating a fleet to a tester build meant one right-click per core. Rows now carry a CoreId-keyed selection in both presentations (plain click, Ctrl toggle, Shift range, Ctrl+A over the visible rows, Escape), built on the controlled-selection algorithm the Report panel already used, lifted into controls/row_selection.rs with Report repointed at it. The row menu acts on the selection when the clicked row belongs to it and names the count it will enqueue; the same menu opens on a server row and on a Flat exchange heading. The footer gains a "to a build..." button whose build-name field lives inside the existing single confirm. Behaviour change: the footer buttons act on the cores the panel shows (or the selection when one exists), no longer on the whole fleet. A panel scoped to one group used to offer a button reaching every core. Scope resolution is a pure function (controls/core_update/scope.rs) with mutation-proven tests; a static contract pins every Core Status enqueue to the shared controls::core_update route. Claude-Session: https://claude.ai/code/session_018e8iwJ8ejRvnev4XkscuHs --- .../src/controls/core_update/actions.rs | 30 -- .../src/controls/core_update/mod.rs | 8 +- .../src/controls/core_update/scope.rs | 58 +++ .../src/controls/core_update/scope/tests.rs | 66 +++ crates/moon-ui-gpui/src/controls/mod.rs | 3 + .../src/controls/row_selection.rs | 215 +++++++++ .../src/controls/row_selection/tests.rs | 130 +++++ .../src/panels/core_status/cache.rs | 9 + .../src/panels/core_status/interactions.rs | 452 +++++++++++++++--- .../src/panels/core_status/mod.rs | 78 ++- .../src/panels/core_status/ordering.rs | 58 +++ .../src/panels/core_status/server_view.rs | 168 +++++-- .../src/panels/core_status/table.rs | 75 ++- .../src/panels/core_status/update_menu.rs | 405 +++++++++++----- .../src/panels/report/selection.rs | 175 +------ .../tests/theme_contract/core_status.rs | 28 +- locales/core_update.yml | 24 + 17 files changed, 1558 insertions(+), 424 deletions(-) create mode 100644 crates/moon-ui-gpui/src/controls/core_update/scope.rs create mode 100644 crates/moon-ui-gpui/src/controls/core_update/scope/tests.rs create mode 100644 crates/moon-ui-gpui/src/controls/row_selection.rs create mode 100644 crates/moon-ui-gpui/src/controls/row_selection/tests.rs diff --git a/crates/moon-ui-gpui/src/controls/core_update/actions.rs b/crates/moon-ui-gpui/src/controls/core_update/actions.rs index ce1e127b..3b9c98d3 100644 --- a/crates/moon-ui-gpui/src/controls/core_update/actions.rs +++ b/crates/moon-ui-gpui/src/controls/core_update/actions.rs @@ -71,36 +71,6 @@ pub(crate) fn update_scope( }); } -/// Enqueue the fleet for a plain release update -- every core, or only the ones behind. -/// -/// Args: -/// backend: Shared terminal state. -/// only_behind: `true` selects through `session.cores_behind()`; `false` takes every core the -/// enqueue gate accepts. -/// app: Application context used to reach the session. -pub(crate) fn update_fleet(backend: &Entity, only_behind: bool, app: &mut App) { - backend.update(app, |backend, cx| { - let now_ms = moon_core::util::now_unix_ms_i64(); - let cores: Vec = if only_behind { - backend.session.cores_behind() - } else { - backend.session.sessions().iter().map(|s| s.id).collect() - }; - let report = - backend - .session - .enqueue_core_updates(&cores, UpdateTarget::Release, now_ms); - log::info!( - "fleet update enqueue ({}): {} accepted, {} skipped offline/unreachable, {} skipped already tracked", - if only_behind { "behind only" } else { "every core" }, - report.accepted, - report.skipped_offline, - report.skipped_already, - ); - cx.notify(); - }); -} - /// Retry one core whose last update attempt ended `Done`, using the target its last attempt /// used. /// diff --git a/crates/moon-ui-gpui/src/controls/core_update/mod.rs b/crates/moon-ui-gpui/src/controls/core_update/mod.rs index 64f6e1bb..0fa782e7 100644 --- a/crates/moon-ui-gpui/src/controls/core_update/mod.rs +++ b/crates/moon-ui-gpui/src/controls/core_update/mod.rs @@ -1,5 +1,5 @@ -//! The shared core UPDATE control: enqueue a plain release update for one core or a whole -//! server, retry a core whose last attempt failed, and update the fleet. +//! The shared core UPDATE control: enqueue a release or a named build for one core, a whole +//! server, an exchange section or the whole panel scope, and retry a core whose last attempt failed. //! //! A SIBLING of [`crate::controls::core_run`], not a fork of it -- read that module before this //! one. What is REUSED is its vocabulary and its rules, never its code: @@ -20,9 +20,11 @@ //! directly: bulk fills the queue, it never bursts commands. mod actions; +mod scope; mod view; -pub(crate) use actions::{retry_core, update_core, update_fleet, update_scope}; +pub(crate) use actions::{retry_core, update_core, update_scope}; +pub(crate) use scope::resolve_menu_scope; pub(crate) use view::update_button; use moon_core::feed::ConnStatus; diff --git a/crates/moon-ui-gpui/src/controls/core_update/scope.rs b/crates/moon-ui-gpui/src/controls/core_update/scope.rs new file mode 100644 index 00000000..3036d09d --- /dev/null +++ b/crates/moon-ui-gpui/src/controls/core_update/scope.rs @@ -0,0 +1,58 @@ +//! Which cores one click stands for -- the pure half of every bulk update this control performs. +//! +//! GPUI-free on purpose. This is the single most dangerous decision in the update path: the queue +//! it feeds reaches LIVE cores that trade real money, so "which cores did the user actually point +//! at" has to be a function that can be read, tested and mutated in isolation rather than a rule +//! spread across three click handlers. + +use std::rc::Rc; + +use moon_core::session::CoreId; + +use crate::controls::row_selection::RowSelection; + +/// Resolve the cores a context-menu click commands. +/// +/// Windows Explorer semantics, and they are the whole point: a right-click on a row INSIDE the +/// current selection acts on the whole selection, and a right-click anywhere else acts on that row +/// alone. The click never MOVES the selection -- opening a menu must not change what it is about to +/// act on -- so this is the only place the two readings are reconciled. +/// +/// The result is filtered through `order` rather than taken from the selection set directly, for +/// two reasons that both matter here: the set has no order of its own (it is a hash set), and it +/// can outlive a row leaving the view for one frame. Anything the user cannot currently see is not +/// something a menu may enqueue. +/// +/// Args: +/// clicked: The core whose row was right-clicked. +/// order: Every selectable core currently rendered, in visual order. +/// selection: The panel's controlled row selection. +/// +/// Returns: +/// The cores to command, in visual order. Never empty: a click outside the selection -- and a +/// click made with nothing selected -- yields the clicked core alone. +pub(crate) fn resolve_menu_scope( + clicked: CoreId, + order: &[CoreId], + selection: &RowSelection, +) -> Rc<[CoreId]> { + if !selection.contains(Some(clicked)) { + return Rc::from(vec![clicked]); + } + let scope: Vec = order + .iter() + .copied() + .filter(|core| selection.contains(Some(*core))) + .collect(); + // The membership test above already proved `clicked` is selected, but it proves nothing about + // `order`: a caller can hand a stale order that no longer draws it. Falling back to the clicked + // core keeps the invariant this function is trusted for -- the result is never empty, so a menu + // entry can never enqueue "everything" by resolving to nothing. + if scope.is_empty() { + return Rc::from(vec![clicked]); + } + Rc::from(scope) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/controls/core_update/scope/tests.rs b/crates/moon-ui-gpui/src/controls/core_update/scope/tests.rs new file mode 100644 index 00000000..a2d3f33d --- /dev/null +++ b/crates/moon-ui-gpui/src/controls/core_update/scope/tests.rs @@ -0,0 +1,66 @@ +//! Contract tests for Core Status context-menu update scope resolution. + +use crate::controls::row_selection::RowSelection; +use moon_core::session::CoreId; + +use super::resolve_menu_scope; + +/// Build a selection through the public click rules rather than by reaching into its private set. +fn selected(ids: &[CoreId], selected_ids: &[CoreId]) -> RowSelection { + let mut selection = RowSelection::default(); + for id in selected_ids { + selection.click( + Some(*id), + &ids.iter().copied().map(Some).collect::>(), + false, + true, + ); + } + selection +} + +/// `controls/core_update/scope.rs:resolve_menu_scope` must use an in-selection clicked core's +/// whole selection in rendered order and filter stale keys. Iterating the set directly could send +/// updates in a nondeterministic order or target a core that is no longer shown. +#[test] +fn selected_click_uses_the_visible_selection_in_panel_order() { + let order = [40, 10, 30, 20]; + let mut selection = selected(&order, &[10, 20]); + let visible = order.map(Some); + selection.click(Some(99), &visible, false, true); + + let scope = resolve_menu_scope(20, &order, &selection); + + assert_eq!(scope.as_ref(), &[10, 20]); +} + +/// `controls/core_update/scope.rs:resolve_menu_scope` must treat an unselected right-click as +/// one core, even when another selection exists. Returning a non-empty selection unconditionally +/// would enqueue real updates on every previously highlighted trading core. +#[test] +fn unselected_click_never_expands_to_an_existing_selection() { + let order = [40, 10, 30, 20]; + let selection = selected(&order, &[10, 20]); + + let scope = resolve_menu_scope(30, &order, &selection); + + assert_eq!(scope.as_ref(), &[30]); +} + +/// `controls/core_update/scope.rs:resolve_menu_scope` must never include a selected id absent +/// from `order` or return an empty scope. Keeping a vanished selection would tell the menu one +/// thing while enqueueing an invisible core that the panel no longer owns. +#[test] +fn stale_selected_identity_is_filtered_from_a_nonempty_visible_scope() { + let order = [40, 10, 30, 20]; + let selection = selected(&order, &[10, 99]); + + let scope = resolve_menu_scope(99, &order, &selection); + + assert_eq!(scope.as_ref(), &[10]); + assert!( + !scope.is_empty(), + "a menu scope must always name at least one core" + ); + assert!(scope.iter().all(|id| order.contains(id))); +} diff --git a/crates/moon-ui-gpui/src/controls/mod.rs b/crates/moon-ui-gpui/src/controls/mod.rs index 66eb599d..f41d35ec 100644 --- a/crates/moon-ui-gpui/src/controls/mod.rs +++ b/crates/moon-ui-gpui/src/controls/mod.rs @@ -26,6 +26,8 @@ //! - [`fmt`] formats size, sell, and field values and computes mouse-wheel steps; //! - [`manual_strat`] provides the header's manual-strategy toggle and picker; //! - [`metric`] provides TP/SL/leverage trigger buttons and popup content; +//! - [`row_selection`] holds the controlled multi-row click algorithm — plain, Ctrl, Shift and +//! select-all over a keyed row order — shared by Report and Core Status; //! - [`strips`] provides size and sell preset strips with native MoonUI interaction; //! - [`scale`] provides price-scale dropdowns for tabs, AddToChart stacks, and trade windows; //! - [`wrap_fit`] lets a wrapping panel row shrink its selectors before it takes a second line; @@ -47,6 +49,7 @@ mod fmt; mod label_fields; mod manual_strat; mod metric; +pub(crate) mod row_selection; mod scale; mod strips; pub(crate) mod toolbar; diff --git a/crates/moon-ui-gpui/src/controls/row_selection.rs b/crates/moon-ui-gpui/src/controls/row_selection.rs new file mode 100644 index 00000000..5b2bdc4e --- /dev/null +++ b/crates/moon-ui-gpui/src/controls/row_selection.rs @@ -0,0 +1,215 @@ +//! Controlled multi-row selection: the click algorithm every table-like panel shares. +//! +//! LIFTED from `panels/report/selection.rs`, which ran it first over `MoonDataTable`'s +//! `controlled_row_selection` mode; Core Status now runs the same gestures over two presentations +//! at once. The algorithm is deliberately unchanged by the lift — the Report panel's own tests are +//! the oracle it kept passing. +//! +//! Keyed by a STABLE ROW IDENTITY, never by a line index. Both callers need that for the same +//! reason: the list is virtual, it re-sorts, and it draws synthetic rows a selection must never +//! address (Report's malformed legacy rows, Core Status' exchange headings). That is why every +//! entry point speaks `Option` — `None` is "this line is not a selectable row", and it is a +//! no-op rather than an error. + +use std::collections::HashSet; +use std::hash::Hash; + +/// Controlled multi-selection with one stable Shift-range anchor. +pub(crate) struct RowSelection { + selected: HashSet, + anchor: Option, + /// Row of the LAST click in any mode, which a detail pane describes. + /// + /// Distinct from `anchor`: a Shift range deliberately keeps its anchor at the range base so the + /// next Shift click re-measures from there, but the row the user just pointed at is the far end. + last_clicked: Option, +} + +// Written out rather than derived: `#[derive(Default)]` would demand `K: Default`, and a row +// identity has no meaningful default. An empty selection is well defined for every key type. +impl Default for RowSelection { + fn default() -> Self { + Self { + selected: HashSet::new(), + anchor: None, + last_clicked: None, + } + } +} + +impl Clone for RowSelection { + fn clone(&self) -> Self { + Self { + selected: self.selected.clone(), + anchor: self.anchor.clone(), + last_clicked: self.last_clicked.clone(), + } + } +} + +impl RowSelection { + /// Select every valid row identity in the current list without changing the Shift anchor. + /// + /// Args: + /// order: Current rendered row identities in visual order, `None` for a non-row line. + /// + /// Returns: + /// Nothing. Lines without a stable identity are excluded. + pub(crate) fn select_all(&mut self, order: &[Option]) { + self.selected = order.iter().filter_map(|key| *key).collect(); + } + + /// Apply one row click using platform-independent modifier meaning. + /// + /// Args: + /// clicked: Stable identity of the clicked row, or `None` for a line that is not a row. + /// order: Current rendered row identities in visual order. + /// shift: Whether Shift was held. + /// secondary: Whether Ctrl on Windows/Linux or Command on macOS was held. + /// + /// Returns: + /// Nothing. Shift takes precedence over the secondary modifier, and a plain click that + /// lands on the sole selected row clears the selection instead of re-selecting it. + pub(crate) fn click( + &mut self, + clicked: Option, + order: &[Option], + shift: bool, + secondary: bool, + ) { + let Some(clicked) = clicked else { + return; + }; + self.last_clicked = Some(clicked); + if shift { + let span = self.anchor.and_then(|anchor| { + let from = order.iter().position(|key| *key == Some(anchor))?; + let to = order.iter().position(|key| *key == Some(clicked))?; + Some(if from <= to { from..=to } else { to..=from }) + }); + self.selected.clear(); + if let Some(span) = span { + self.selected + .extend(order[span].iter().filter_map(|key| *key)); + } else { + self.selected.insert(clicked); + self.anchor = Some(clicked); + } + return; + } + self.anchor = Some(clicked); + if secondary { + if !self.selected.insert(clicked) { + self.selected.remove(&clicked); + } + return; + } + // A plain click on the row that IS the entire selection clears it: clicking the same row + // twice reads as undoing that selection. With anything else selected the click still + // collapses the set to the clicked row — that is the standard table behaviour and the only + // way back from a Shift range to a single row. The anchor is NOT cleared with the set — it + // was just moved to this row above — so a following Shift click still measures from here. + let only_this = self.selected.len() == 1 && self.selected.contains(&clicked); + self.selected.clear(); + if !only_this { + self.selected.insert(clicked); + } + } + + /// Select exactly one row, whatever was selected before. + /// + /// Unlike a plain [`Self::click`], this never clears: it exists for the second half of a + /// physical double-click. MoonDataTable invokes the row-select callback on BOTH clicks and + /// gives it no click count, so the deselecting second click has to be undone from the table's + /// own authoritative double-click callback rather than guessed at from timing. + /// + /// Args: + /// clicked: Stable identity of the double-clicked row, or `None` for a non-row line. + /// + /// Returns: + /// Nothing. The anchor follows the row, as it does for a plain click. + pub(crate) fn select_only(&mut self, clicked: Option) { + let Some(clicked) = clicked else { + return; + }; + self.anchor = Some(clicked); + self.last_clicked = Some(clicked); + self.selected.clear(); + self.selected.insert(clicked); + } + + /// Remove selections no longer present in a newly published list. + /// + /// The one call that keeps a selection HONEST: a row the user can no longer see must not stay + /// in a set that later acts on it. Every owner calls this the moment its rows are rebuilt. + /// + /// Args: + /// visible: Stable row identities in the new list. + /// + /// Returns: + /// Nothing. A missing anchor is cleared with its vanished row. + pub(crate) fn retain_visible(&mut self, visible: &[Option]) { + let visible: HashSet = visible.iter().filter_map(|key| *key).collect(); + self.selected.retain(|key| visible.contains(key)); + if self.anchor.is_some_and(|key| !visible.contains(&key)) { + self.anchor = None; + } + if self.last_clicked.is_some_and(|key| !visible.contains(&key)) { + self.last_clicked = None; + } + } + + /// Clear every selected row and the Shift anchor. + /// + /// Returns: + /// Nothing after selection state becomes empty. + pub(crate) fn clear(&mut self) { + self.selected.clear(); + self.anchor = None; + self.last_clicked = None; + } + + /// Return whether one stable row is selected. + /// + /// Args: + /// key: Stable row identity, or `None` for an unselectable line. + /// + /// Returns: + /// `true` only when a concrete identity belongs to the controlled set. + pub(crate) fn contains(&self, key: Option) -> bool { + key.is_some_and(|key| self.selected.contains(&key)) + } + + /// Return the row the user last clicked, while it is still selected. + /// + /// Returns: + /// The last-clicked identity, or `None` once it has been deselected or has left the list. + /// The membership check matters for Ctrl-click: it clears the row but keeps it as the + /// anchor for a following Shift range. + pub(crate) fn current(&self) -> Option { + self.last_clicked.filter(|key| self.selected.contains(key)) + } + + /// Return the number of selected rows. + /// + /// Returns: + /// Current controlled selection size. + pub(crate) fn len(&self) -> usize { + self.selected.len() + } + + /// Walk the selected identities in the set's own arbitrary order. + /// + /// Deliberately NOT the order to act in: a caller that commands the selection resolves it + /// against the rendered row order instead, so what it does matches what the user sees. This + /// exists for membership folds and per-key lookups that do not care about order. + /// + /// Returns: + /// An iterator over the selected identities. + pub(crate) fn iter(&self) -> impl Iterator { + self.selected.iter() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/controls/row_selection/tests.rs b/crates/moon-ui-gpui/src/controls/row_selection/tests.rs new file mode 100644 index 00000000..97e6c817 --- /dev/null +++ b/crates/moon-ui-gpui/src/controls/row_selection/tests.rs @@ -0,0 +1,130 @@ +//! Contract tests for the shared Core Status row-selection state. + +use super::RowSelection; + +type Key = u64; + +/// Return selected keys in a deterministic order so membership, rather than `HashSet` iteration, +/// is the test oracle. +fn selected_keys(selection: &RowSelection) -> Vec { + let mut keys = selection.iter().copied().collect::>(); + keys.sort_unstable(); + keys +} + +/// `controls/row_selection.rs:RowSelection::click` must leave state unchanged for `None`. +/// Removing that guard would let an exchange-heading click retarget a later Shift selection. +#[test] +fn none_click_is_a_full_noop() { + let order = [Some(1), None, Some(2), Some(3), Some(4)]; + let mut selection = RowSelection::default(); + selection.click(Some(2), &order, false, false); + + selection.click(None, &order, true, true); + + assert_eq!(selected_keys(&selection), vec![2]); + assert_eq!(selection.current(), Some(2)); + selection.click(Some(4), &order, true, false); + assert_eq!( + selected_keys(&selection), + vec![2, 3, 4], + "the ignored heading must not replace the Shift anchor" + ); +} + +/// `controls/row_selection.rs:RowSelection::click` must make Shift win over Ctrl and retain its +/// original anchor. Letting Shift fall through to Ctrl would make the visibly highlighted update +/// set disagree with the menu count and the cores that receive a build. +#[test] +fn shift_selects_the_exact_visible_span_and_keeps_its_anchor() { + let order = [Some(1), None, Some(2), Some(3), Some(4)]; + let mut selection = RowSelection::default(); + selection.click(Some(1), &order, false, false); + selection.click(Some(4), &order, false, true); + + selection.click(Some(2), &order, true, true); + + assert_eq!(selected_keys(&selection), vec![2, 3, 4]); + assert_eq!(selection.current(), Some(2)); + selection.click(Some(1), &order, true, false); + assert_eq!( + selected_keys(&selection), + vec![1, 2, 3, 4], + "the second range must still start at the original anchor" + ); + + let mut without_anchor = RowSelection::default(); + without_anchor.click(Some(3), &order, true, false); + assert_eq!(selected_keys(&without_anchor), vec![3]); + without_anchor.click(Some(1), &order, true, false); + assert_eq!(selected_keys(&without_anchor), vec![1, 2, 3]); +} + +/// `controls/row_selection.rs:RowSelection::click` must clear a sole plain-click selection but +/// preserve its anchor, while Ctrl deselection makes `current()` absent. Replacing either branch +/// with an unconditional insert leaves invisible or stale cores in the next bulk update scope. +#[test] +fn plain_and_secondary_clicks_preserve_the_selection_contract() { + let order = [Some(1), None, Some(2), Some(3), Some(4)]; + let mut selection = RowSelection::default(); + selection.click(Some(2), &order, false, false); + selection.click(Some(2), &order, false, false); + + assert_eq!(selection.len(), 0); + assert_eq!(selection.current(), None); + selection.click(Some(4), &order, true, false); + assert_eq!( + selected_keys(&selection), + vec![2, 3, 4], + "the clearing plain click deliberately leaves a Shift anchor" + ); + + selection.click(Some(3), &order, false, false); + selection.click(Some(3), &order, false, true); + assert_eq!(selection.len(), 0); + assert_eq!( + selection.current(), + None, + "a Ctrl-deselected last click cannot remain the current core" + ); + selection.click(Some(4), &order, true, false); + assert_eq!(selected_keys(&selection), vec![3, 4]); + + selection.select_only(Some(1)); + selection.select_only(None); + assert_eq!(selected_keys(&selection), vec![1]); + assert_eq!(selection.current(), Some(1)); +} + +/// `controls/row_selection.rs:RowSelection::{select_all,retain_visible,clear}` must skip heading +/// rows, retain the pre-existing anchor, and remove vanished identities. Keeping a hidden core +/// selected would silently send its next update even though the operator cannot see it. +#[test] +fn bulk_and_visibility_operations_keep_only_visible_core_identities() { + let order = [Some(1), None, Some(2), Some(3), Some(4)]; + let mut selection = RowSelection::default(); + selection.click(Some(2), &order, false, false); + selection.select_all(&order); + + assert_eq!(selected_keys(&selection), vec![1, 2, 3, 4]); + selection.click(Some(3), &order, true, false); + assert_eq!( + selected_keys(&selection), + vec![2, 3], + "select_all must not synthesize a new Shift anchor" + ); + + selection.click(Some(1), &order, false, true); + selection.click(Some(4), &order, false, true); + selection.retain_visible(&[Some(1), None]); + assert_eq!(selected_keys(&selection), vec![1]); + assert_eq!(selection.current(), None); + selection.click(Some(3), &order, true, false); + assert_eq!(selected_keys(&selection), vec![3]); + + selection.clear(); + assert_eq!(selection.len(), 0); + assert_eq!(selection.current(), None); + selection.click(Some(4), &order, true, false); + assert_eq!(selected_keys(&selection), vec![4]); +} diff --git a/crates/moon-ui-gpui/src/panels/core_status/cache.rs b/crates/moon-ui-gpui/src/panels/core_status/cache.rs index 05fe7d12..8da9aefa 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/cache.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/cache.rs @@ -175,6 +175,15 @@ impl CoreStatusView { self.has_warn = groups.iter().any(|group| group.has_warn()); self.cached_groups = Rc::new(groups); self.cached_rows = Rc::new(rows); + // Prune the row selection against the rows that now EXIST. This is the one call that + // keeps a bulk update honest: a preset change, a group switch or a core simply leaving + // the scope removes a row from the screen, and a core the user can no longer see must + // never stay in a set the update menu and the footer are about to enqueue. Placed here, + // beside the rows themselves, because every path that changes what is displayed ends up + // in this function. + let visible: Vec> = + self.cached_rows.iter().map(|row| Some(row.id)).collect(); + self.core_selection.retain_visible(&visible); // `workspace_revision`'s observer (`mod.rs::new`) calls this unconditionally on every // change, with no signature gate ahead of it -- unlike the backend observer's 1 s/rev // gate above -- so recomputing the marker here keeps it exactly as fresh as the rows and diff --git a/crates/moon-ui-gpui/src/panels/core_status/interactions.rs b/crates/moon-ui-gpui/src/panels/core_status/interactions.rs index e9730287..56effde2 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/interactions.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/interactions.rs @@ -4,23 +4,43 @@ use std::collections::HashSet; use std::net::IpAddr; +use std::rc::Rc; use gpui::*; use moon_ui::{ - MoonButton, MoonButtonSize, MoonButtonVariant, MoonInputEvent, MoonInputState, - MoonNotification, MoonPalette, MoonWindowExt as _, h_flex, + MoonButton, MoonButtonSize, MoonButtonVariant, MoonInput, MoonInputEvent, MoonInputState, + MoonNotification, MoonPalette, MoonWindowExt as _, h_flex, v_flex, }; use super::by_ip_header::ByIpDragAnchor; use super::by_ip_widths::{ByIpCol, MAX_COL_W, MIN_COL_W}; use super::model::ServerKey; -use super::{ChartWindow, CoreStatusMode, CoreStatusView}; +use super::update_menu; +use super::{ChartWindow, CoreStatusMode, CoreStatusView, ordering, server_view}; use crate::design; -use moon_core::feed::ConnStatus; +use moon_core::feed::{ConnStatus, UpdateTarget}; use moon_core::session::CoreId; -use moon_core::session::core_update::CoreUpdatePhase; use rust_i18n::t; +/// Which of the footer's three bulk buttons opened the confirm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FleetUpdateKind { + /// Every offerable core in the panel's scope, or in its selection. + All, + /// Only those behind the fleet's newest build. + Behind, + /// Every offerable core, to a build name the operator types inside the confirm. + Named, +} + +/// Exactly what a footer bulk update would enqueue, resolved before the confirm opens. +pub(super) struct FleetUpdatePlan { + /// Cores to enqueue, in on-screen order. + pub(super) cores: Rc<[CoreId]>, + /// Distinct server lanes they span -- the queue runs one core at a time per lane, and the + /// confirm says so. + pub(super) lanes: usize, +} impl CoreStatusView { /// Record where a By IP header divider drag began. /// @@ -447,6 +467,11 @@ impl CoreStatusView { pub(super) fn set_mode(&mut self, mode: CoreStatusMode, cx: &mut Context) { if self.mode != mode { self.mode = mode; + // The selection belongs to the rows that were on screen when it was made. Problems, + // Warnings and Updates draw no core rows at all, and By-IP and Flat draw different + // ones, so carrying it across a mode change would leave a set nothing highlights -- + // in a panel where the next gesture enqueues a build onto live cores. + self.core_selection.clear(); // Inside the change gate on purpose: re-selecting the current mode writes nothing and // cannot arm a layout flush, matching the width and sort maps beside it. crate::persistence::table_persist::set_core_status_mode( @@ -459,63 +484,241 @@ impl CoreStatusView { } } - /// Best-effort preview of what a fleet-wide bulk update is about to enqueue, read from public - /// session data rather than the private `SessionManager::eligible` gate it mirrors. + /// Every selectable core this panel is drawing RIGHT NOW, in on-screen order. + /// + /// The one place a scope comes from. Both presentations map their rows onto cores here, and + /// both report the same shape -- `None` for a line that is not a selectable core (a Flat + /// exchange heading, a By-IP server row), which the click algorithm ignores. A mode that draws + /// no core rows at all reports NOTHING, which is what stops a selection made in By-IP from + /// being acted on while the Problems list is on screen. + /// + /// A COLLAPSED server group contributes nothing either. That is deliberate and it is the + /// safety property: a bulk update must never reach a core the user cannot see, so a selection + /// resolved through this list shrinks visibly (the footer's own count says so) rather than + /// silently keeping targets off screen. + /// + /// Args: + /// cx: Application context used to read the sort, the venues and the tree expansion. + /// + /// Returns: + /// One entry per rendered row in the current presentation. + pub(super) fn visible_order(&self, cx: &App) -> Vec> { + match self.mode { + CoreStatusMode::Flat => { + let (rows, lines) = self.flat_view(cx); + ordering::flat_order(&lines, &rows) + } + CoreStatusMode::ByIp => { + let expanded = self.tree_state.read(cx).expanded_ids(); + server_view::visible_tree_order(&self.cached_groups, &expanded) + } + CoreStatusMode::Problems | CoreStatusMode::Warnings | CoreStatusMode::Updates => { + Vec::new() + } + } + } + + /// Apply one core-row click to the panel's controlled selection. + /// + /// A PLAIN click routes through `select_only`, not through `click`: in this panel a plain + /// click means "this core", and it has to keep meaning that when the same row is clicked + /// twice. Report's shared algorithm deliberately clears a sole selection on the second plain + /// click, which is right for a report row and wrong here -- it would leave an ordinary + /// double-click with nothing selected, in a panel whose next gesture enqueues a build. Ctrl + /// and Shift go to `click` unchanged, so toggling and ranges are the one shared algorithm. + /// + /// Args: + /// clicked: The clicked core, or `None` for a line that is not a core row. + /// order: Rendered row order from [`Self::visible_order`]. + /// modifiers: Native modifier snapshot from the owning window. + /// cx: View context used to repaint. + pub(super) fn select_core_row( + &mut self, + clicked: Option, + order: &[Option], + modifiers: Modifiers, + cx: &mut Context, + ) { + if modifiers.shift || modifiers.secondary() { + self.core_selection + .click(clicked, order, modifiers.shift, modifiers.secondary()); + } else { + self.core_selection.select_only(clicked); + } + cx.notify(); + } + + /// Select every core the current presentation is drawing. + /// + /// Args: + /// order: Rendered row order from [`Self::visible_order`]. + /// cx: View context used to repaint. + pub(super) fn select_all_visible_cores( + &mut self, + order: &[Option], + cx: &mut Context, + ) { + self.core_selection.select_all(order); + cx.notify(); + } + + /// Drop the whole core-row selection. + /// + /// Args: + /// cx: View context used to repaint. + pub(super) fn clear_core_selection(&mut self, cx: &mut Context) { + self.core_selection.clear(); + cx.notify(); + } + + /// Panel-level keyboard route for the row selection. + /// + /// Ctrl/Cmd+A selects every core the current presentation draws, Escape drops the selection. + /// The Flat table intercepts the same select-all chord itself when the GRID holds focus and + /// calls the same handler, so the two routes converge; this one is what gives the By-IP tree, + /// which is not a `MoonDataTable`, the identical gesture. + /// + /// Args: + /// event: The key press. + /// window: Host window, used to confirm this panel actually holds focus. + /// cx: View context used to repaint. + pub(super) fn on_selection_key( + &mut self, + event: &KeyDownEvent, + window: &Window, + cx: &mut Context, + ) { + if !self.focus.contains_focused(window, cx) { + return; + } + let key = event.keystroke.key.as_str(); + let modifiers = event.keystroke.modifiers; + if key == "a" && modifiers.secondary() && !modifiers.shift && !modifiers.alt { + let order = self.visible_order(cx); + self.select_all_visible_cores(&order, cx); + } else if key == "escape" && self.core_selection.len() > 0 { + self.clear_core_selection(cx); + } + } + /// Resolve the cores a bulk action should command, intersected with what is on screen. + /// + /// The SELECTION when the user has made one, the whole displayed scope otherwise -- and either + /// way filtered through [`Self::visible_order`], so no caller can enqueue a core the panel is + /// not currently drawing. + /// + /// Args: + /// visible: Cores the current presentation draws, in on-screen order. + /// + /// Returns: + /// Cores in on-screen order. + pub(super) fn selected_or_visible(&self, visible: &[CoreId]) -> Vec { + let selecting = self.core_selection.len() > 0; + visible + .iter() + .copied() + .filter(|core| !selecting || self.core_selection.contains(Some(*core))) + .collect() + } + + /// The selectable cores the current presentation is drawing, without the heading rows. + /// + /// Resolved ONCE per frame by the caller and threaded on, because `visible_order` re-sorts + /// the flat rows and re-groups them into exchange sections on every call. The footer alone + /// used to ask for it twice, on top of the one `render` already makes for the table, so a + /// panel repainting at telemetry rate paid three full sorts of the whole fleet per frame. + /// + /// Args: + /// cx: Application context used to read the sort, the venues and the tree expansion. + /// + /// Returns: + /// Cores in on-screen order. + pub(super) fn visible_cores(&self, cx: &App) -> Vec { + self.visible_order(cx).into_iter().flatten().collect() + } + + /// How many SELECTED cores the current presentation is actually drawing. /// - /// Deliberately fleet-wide, over the whole store rather than this panel's own scope: the - /// engine's `cores_behind` and `fleet_newest_version` are already store-wide so that two - /// differently scoped panels never disagree, and a bulk action started from either one must - /// preview the same fleet it is about to enqueue against. + /// Not `core_selection.len()`: collapsing a By-IP group hides its cores from the rendered + /// order without rebuilding the cache, so the raw set can outnumber what is on screen. The + /// footer labels its button with this, so the count it shows is the count its own confirm + /// will name and its own press will enqueue. /// /// Args: - /// only_behind: Whether to preview `cores_behind()` or every core the enqueue gate would - /// currently accept. - /// cx: View context used to read the backend snapshot. + /// visible: Cores the presentation is drawing, from [`Self::visible_cores`]. /// /// Returns: - /// `(core count, distinct lane/server count)` for the confirm question. An exact match to - /// the engine's own gate is not the point -- wording the question honestly before the - /// press is. - fn fleet_update_preview(&self, only_behind: bool, cx: &Context) -> (usize, usize) { + /// Size of the visible part of the selection. + pub(super) fn visible_selected(&self, visible: &[CoreId]) -> usize { + if self.core_selection.len() == 0 { + return 0; + } + visible + .iter() + .filter(|core| self.core_selection.contains(Some(**core))) + .count() + } + /// What a footer bulk update would actually enqueue, and across how many server lanes. + /// + /// Built from the cores this panel SHOWS -- its selection when the operator has made one, its + /// whole displayed scope otherwise ([`Self::selected_or_visible`]) -- and NOT from the store. + /// It used to be fleet-wide: `update_fleet` walked `session.sessions()` and this preview walked + /// the whole store, so a panel scoped to one group offered a button that quietly reached all 56 + /// cores. A control has to act on what it is drawn beside. + /// + /// Eligibility is read from public session data rather than the private + /// `SessionManager::eligible` gate it mirrors; an exact match to that gate is not the point, + /// wording the question honestly before the press is. `cores_behind` stays STORE-WIDE and is + /// intersected here, so two differently scoped panels still agree about which cores are stale. + /// + /// Args: + /// only_behind: Whether to keep only the cores behind the fleet's newest build. + /// visible: Cores the presentation is drawing, resolved once per frame by the caller. + /// cx: Application context used to read the backend snapshot. + /// + /// Returns: + /// The cores to enqueue, in on-screen order, and how many distinct server lanes they span. + pub(super) fn fleet_update_plan( + &self, + only_behind: bool, + visible: &[CoreId], + cx: &App, + ) -> FleetUpdatePlan { + let scope = self.selected_or_visible(visible); let b = self.backend.read(cx); let store = b.session.store(); + let behind: Option> = + only_behind.then(|| b.session.cores_behind().into_iter().collect()); let mut lanes: HashSet = HashSet::new(); - let count = if only_behind { - let ids = b.session.cores_behind(); - for id in &ids { - if let Some(address) = store - .core(*id) - .and_then(|data| data.endpoint) - .map(|endpoint| endpoint.address) - { - lanes.insert(address); - } + let mut cores: Vec = Vec::new(); + for id in scope { + if behind.as_ref().is_some_and(|behind| !behind.contains(&id)) { + continue; } - ids.len() - } else { - let mut n = 0usize; - for (id, data) in store.cores() { - if data.status != ConnStatus::Ready || data.server_version.is_none() { - continue; - } - let Some(endpoint) = data.endpoint else { - continue; - }; - let in_flight = matches!( + let Some(data) = store.core(id) else { + continue; + }; + let Some(endpoint) = data.endpoint else { + continue; + }; + if !matches!( + crate::controls::core_update::offer_state( + &data.status, + data.server_version, + true, b.session.core_update_phase(id), - Some(phase) if !matches!(phase, CoreUpdatePhase::Done(_)) - ); - if in_flight { - continue; - } - n += 1; - lanes.insert(endpoint.address); + ), + crate::controls::core_update::OfferState::Offerable + ) { + continue; } - n - }; - (count, lanes.len()) + cores.push(id); + lanes.insert(endpoint.address); + } + FleetUpdatePlan { + cores: Rc::from(cores), + lanes: lanes.len(), + } } - /// Whether a core is connected right now. /// /// Asked again at the moment of sending, not only when the button was drawn: the command @@ -715,31 +918,68 @@ impl CoreStatusView { } } - /// Open the ONE confirm a fleet-wide bulk update gets, naming the core and lane counts before - /// the press that fills the whole per-IP queue. + /// Open the ONE confirm every footer bulk update gets, naming the core and lane counts + /// before the press that fills the per-IP queue. + /// + /// ONE dialog for all three buttons, and for the named build the PROMPT LIVES INSIDE IT -- a + /// prompt followed by a confirm would be two gates on one action, and the damage here is real + /// exactly once. The row and server menus keep no confirm at all, as before: those are aimed + /// at something the operator pointed at, while this reaches everything the panel shows. /// /// Args: - /// only_behind: Forwarded to `update_fleet` on confirmation: only cores behind the fleet's - /// newest build, or every core the enqueue gate accepts. + /// kind: Which footer button opened this. /// window: Window that owns the unique dialog. - /// cx: View context used to read the preview counts and build the dialog. + /// cx: View context used to resolve the plan and build the dialog. /// /// Returns: - /// Nothing; only the Yes button reaches `update_fleet`, and it closes the dialog either way. + /// Nothing; only Yes enqueues, and it closes the dialog either way. pub(super) fn confirm_fleet_update( &mut self, - only_behind: bool, + kind: FleetUpdateKind, window: &mut Window, cx: &mut Context, ) { - let (core_count, lane_count) = self.fleet_update_preview(only_behind, cx); + let behind = kind == FleetUpdateKind::Behind; + let plan = self.fleet_update_plan(behind, &self.visible_cores(cx), cx); + if plan.cores.is_empty() { + return; + } + let names: Rc<[String]> = plan + .cores + .iter() + .map(|core| self.core_display_name(*core, cx)) + .collect(); + let core_count = plan.cores.len(); + let lane_count = plan.lanes; + let confirmed: Rc<[CoreId]> = plan.cores.clone(); let backend = self.backend.clone(); + let view = cx.entity(); + let input = (kind == FleetUpdateKind::Named).then(|| { + let input = cx.new(|cx| { + MoonInputState::new(window, cx).placeholder( + t!( + "core_update.menu.named_ph", + cmd = moon_core::feed::CORE_UPDATE_COMMAND_WORD + ) + .to_string(), + ) + }); + input + .clone() + .update(cx, |input, cx| input.focus(window, cx)); + input + }); window.open_unique_moon_dialog( "core-status-fleet-update-confirm", cx, move |dialog, _window, cx| { let p = MoonPalette::active(cx); let confirm_backend = backend.clone(); + let confirm_view = view.clone(); + let confirm_cores = confirmed.clone(); + let confirm_input = input.clone(); + let field = input.clone(); + let names = names.clone(); let question = t!( "core_update.confirm.q", cores = core_count, @@ -767,13 +1007,44 @@ impl CoreStatusView { .content(move |content, _window, cx| { let p = MoonPalette::active(cx); content.child( - div() - // MIXED NODE: `core_update.confirm.q` welds the core and server - // COUNTS into the question. Half a node cannot be styled. - .font_family(design::mono()) - .text_size(design::t_body(cx)) - .text_color(rgb(p.text)) - .child(question.clone()), + v_flex() + .w_full() + .gap_2() + .child( + div() + // MIXED NODE: `core_update.confirm.q` welds the core and + // server COUNTS into the question. Half a node cannot be + // styled. + .font_family(design::mono()) + .text_size(design::t_body(cx)) + .text_color(rgb(p.text)) + .child(question.clone()), + ) + // The same non-truncating list the row menu draws, from the same + // helper: two dialogs describing one scope must not be able to + // word it differently, and a core name is never shortened. + .children(update_menu::scope_name_list(&names, p, cx)) + .children(field.clone().map(|field| { + v_flex() + .w_full() + .gap_1() + .child(div().text_color(rgb(p.text_muted)).child( + t!("core_update.confirm.named_prompt").to_string(), + )) + .child( + MoonInput::new("core-status-fleet-named-input") + .state(&field) + .small(), + ) + .child( + div() + .text_color(rgb(p.text_muted)) + .text_size(design::t_caption(cx)) + .child( + t!("core_update.menu.named_hint").to_string(), + ), + ) + })), ) }) .footer( @@ -797,12 +1068,57 @@ impl CoreStatusView { .variant(MoonButtonVariant::Danger) .label(format!(" {} ", t!("dialogs.yes"))) .on_click(move |_, window, cx| { - crate::controls::core_update::update_fleet( + let target = match &confirm_input { + Some(input) => { + let typed = update_menu::typed_build_name( + &input.read(cx).value(), + ); + let Some(typed) = typed else { + // An empty field means do nothing, exactly as + // it does in the row menu prompt. + window.close_dialog(cx); + return; + }; + UpdateTarget::Named(typed) + } + None => UpdateTarget::Release, + }; + // RE-RESOLVED at the press, then intersected with what + // the operator confirmed. A dialog can sit open while a + // preset change or a group switch rebuilds the panel, and + // `update_scope` re-checks only enqueue ELIGIBILITY, never + // scope -- so without this a still-ready core that left + // the shown scope would take the build anyway. The + // intersection can only ever SHRINK what was confirmed. + let still: Vec = confirm_view + .read(cx) + .fleet_update_plan( + behind, + &confirm_view.read(cx).visible_cores(cx), + cx, + ) + .cores + .iter() + .copied() + .filter(|core| confirm_cores.contains(core)) + .collect(); + window.close_dialog(cx); + if still.len() < confirm_cores.len() { + log::info!( + "core status: footer update scope shrank {} -> {} while the confirm was open", + confirm_cores.len(), + still.len(), + ); + } + if still.is_empty() { + return; + } + crate::controls::core_update::update_scope( &confirm_backend, - only_behind, + &Rc::from(still), + target, cx, ); - window.close_dialog(cx); }) .render(), ), diff --git a/crates/moon-ui-gpui/src/panels/core_status/mod.rs b/crates/moon-ui-gpui/src/panels/core_status/mod.rs index 5e214a50..3ff353aa 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/mod.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/mod.rs @@ -48,6 +48,7 @@ use moon_ui::{ }; use crate::Backend; +use crate::controls::row_selection::RowSelection; use crate::core_order::{CoreOrder, OrderedCores}; use crate::design; use crate::workspace::scope_marker::{self, ScopeMarker}; @@ -301,6 +302,14 @@ pub struct CoreStatusView { /// Unlike Assets, this panel has no group-less variant to fall back on — every instance is /// scoped to a window group, so this is never `Option`. cached_scope_marker: ScopeMarker, + /// Which CORE ROWS the user has selected, in either presentation. + /// + /// NOT [`Self::sel_cores`], which is the retained Classic core FILTER deciding what this + /// panel SHOWS. This is the transient on-screen selection a bulk action addresses, and it is + /// keyed by [`CoreId`] rather than by a line index because the flat table draws exchange + /// headings as lines of their own and both presentations re-sort under the user. + /// Pruned against the visible rows on every cache rebuild (`cache::rebuild_cache`). + core_selection: RowSelection, dock: Option>, focus: FocusHandle, } @@ -465,6 +474,7 @@ impl CoreStatusView { backend, group, sel_cores: HashSet::new(), + core_selection: RowSelection::default(), last_repaint_ms: 0, last_update_rev: 0, last_history_rev: 0, @@ -854,7 +864,7 @@ impl Render for CoreStatusView { self.editing, self.edit_input.clone(), self.chart_server, - self.chart_core, + self.core_selection.clone(), self.group_sort, self.by_ip_width, // Row insets are `rems`, so the By-IP width budget needs the window's rem size — @@ -907,6 +917,7 @@ impl Render for CoreStatusView { // Same reason as the By-IP arm above: the callee must not read this view. &self.backend, &marker, + self.core_selection.clone(), cx, ) .into_any_element() @@ -1236,6 +1247,12 @@ impl Render for CoreStatusView { .min_h(px(0.0)) .overflow_hidden() .track_focus(&self.focus) + // Ctrl+A / Escape for the core-row selection. The Flat grid intercepts the same + // select-all chord itself when IT holds focus; this route is what gives the By-IP + // tree, which is not a `MoonDataTable`, the identical gesture. + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + this.on_selection_key(event, window, cx); + })) .font_family(design::mono()) .text_size(design::t_body(cx)) .bg(rgb(p.table_body)) @@ -1481,7 +1498,18 @@ impl CoreStatusView { // Fleet-relative, not release-relative -- see `session::core_update`'s own doc comment. // Empty here means every core already agrees with the fleet's newest build, never that no // release exists, so the button explains that in its tooltip instead of just going gray. - let behind_empty = self.backend.read(cx).session.cores_behind().is_empty(); + // Read off the PANEL's own plan, not off the fleet. Both of these used to ask the store + // directly, so a panel scoped to one group lit a button that reached every core in the + // fleet -- the state a control shows and the set it acts on have to be the same set. + // Resolved ONCE: `visible_order` re-sorts the flat rows and re-groups the exchange + // sections on every call, and this footer used to ask for it twice per repaint. + let visible = self.visible_cores(cx); + // The VISIBLE part of the selection, not the raw set: collapsing a By-IP group hides + // its cores without rebuilding the cache, and a button that says 'update selected (5)' + // while its own confirm would name none is worse than one that says nothing. + let selected = self.visible_selected(&visible); + let all_empty = self.fleet_update_plan(false, &visible, cx).cores.is_empty(); + let behind_empty = self.fleet_update_plan(true, &visible, cx).cores.is_empty(); // TINT, not a second affordance: both buttons keep their handlers, their size and their // place. Amber is the tone this row ALREADY uses for "an update campaign wants attention" // (the counter above), so a fleet with something to update lights the control that acts on @@ -1503,6 +1531,7 @@ impl CoreStatusView { }; let update_all_view = cx.entity(); let update_behind_view = update_all_view.clone(); + let update_named_view = update_all_view.clone(); // Frozen render idiom (`workspace/scope_marker.rs`): head and tail are DIRECT children of // the row, never nested in a shared box, and the tail is the ONE part of this row allowed // to clip. @@ -1571,12 +1600,25 @@ impl CoreStatusView { }) .child( MoonButton::new("core-status-update-all") - .label(t!("core_update.fleet.all").to_string()) + // ONE button that RENAMES, never a fourth beside the other three: + // with a selection on screen the wide action IS the selection, and + // saying so on the control the operator is about to press beats + // adding a second control that means almost the same thing. + .label(if selected > 0 { + t!("core_update.fleet.selected", n = selected).to_string() + } else { + t!("core_update.fleet.all").to_string() + }) .size(MoonButtonSize::Micro) .variant(MoonButtonVariant::Panel) + .disabled(all_empty) .on_click(move |_, window, cx| { update_all_view.update(cx, |this, cx| { - this.confirm_fleet_update(false, window, cx); + this.confirm_fleet_update( + interactions::FleetUpdateKind::All, + window, + cx, + ); }); }) .render(), @@ -1589,7 +1631,11 @@ impl CoreStatusView { .disabled(behind_empty) .on_click(move |_, window, cx| { update_behind_view.update(cx, |this, cx| { - this.confirm_fleet_update(true, window, cx); + this.confirm_fleet_update( + interactions::FleetUpdateKind::Behind, + window, + cx, + ); }); }); if behind_empty { @@ -1598,7 +1644,27 @@ impl CoreStatusView { behind_button } .render() - }), + }) + .child( + // The named build at panel scope. It reuses the SAME confirm the two + // buttons beside it open, with the build-name field inside it -- a + // prompt and then a confirm would be two gates on one action. + MoonButton::new("core-status-update-named") + .label(t!("core_update.fleet.named").to_string()) + .size(MoonButtonSize::Micro) + .variant(MoonButtonVariant::Panel) + .disabled(all_empty) + .on_click(move |_, window, cx| { + update_named_view.update(cx, |this, cx| { + this.confirm_fleet_update( + interactions::FleetUpdateKind::Named, + window, + cx, + ); + }); + }) + .render(), + ), ) } } diff --git a/crates/moon-ui-gpui/src/panels/core_status/ordering.rs b/crates/moon-ui-gpui/src/panels/core_status/ordering.rs index aa9279d9..a3553906 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/ordering.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/ordering.rs @@ -5,6 +5,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use std::net::IpAddr; +use std::rc::Rc; use moon_core::feed::ConnStatus; use moon_core::session::{CoreId, CoreSysStatus}; @@ -564,5 +565,62 @@ fn stable_section_label( .unwrap_or(label) } +/// Project the flat table's LINES onto the cores they draw. +/// +/// `MoonDataTable` addresses every line it draws by index, exchange headings included, so a line +/// index is NOT a core index. This is the translation the selection needs: a heading becomes +/// `None`, which the click algorithm treats as "not a selectable row" and ignores, and a member +/// becomes its core's stable id. Selection identity is the CORE, never the line -- a sort or a +/// re-group moves every line index and would silently retarget a selection built from them. +/// +/// Args: +/// lines: Headings and member rows in render order, as [`flat_lines`] laid them out. +/// rows: The already-sorted rows those lines address. +/// +/// Returns: +/// One entry per line, parallel to `lines`. +pub(super) fn flat_order(lines: &[FlatLine], rows: &[CoreStatusRow]) -> Vec> { + lines + .iter() + .map(|line| match line { + FlatLine::Section(_) => None, + FlatLine::Core(row) => rows.get(*row).map(|row| row.id), + }) + .collect() +} + +/// Collect the cores one exchange heading introduces. +/// +/// Reads the layout invariant [`flat_lines`] builds: a heading is followed by exactly its own +/// members, and the next heading ends the run. Walking the lines is what keeps this honest -- the +/// heading's own `members` count says how many there are but not WHICH, and the two must never be +/// able to disagree about a scope that reaches live cores. +/// +/// Args: +/// lines: Headings and member rows in render order. +/// heading: Index of the heading line the user acted on. +/// rows: The rows those lines address. +/// +/// Returns: +/// The section's cores in render order. Empty when `heading` does not address a heading. +pub(super) fn section_cores( + lines: &[FlatLine], + heading: usize, + rows: &[CoreStatusRow], +) -> Rc<[CoreId]> { + if !matches!(lines.get(heading), Some(FlatLine::Section(_))) { + return Rc::from(Vec::new()); + } + let cores: Vec = lines[heading + 1..] + .iter() + .map_while(|line| match line { + FlatLine::Core(row) => Some(rows.get(*row).map(|row| row.id)), + FlatLine::Section(_) => None, + }) + .flatten() + .collect(); + Rc::from(cores) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-ui-gpui/src/panels/core_status/server_view.rs b/crates/moon-ui-gpui/src/panels/core_status/server_view.rs index 73cb5db8..93d5852a 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/server_view.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/server_view.rs @@ -18,12 +18,60 @@ use rust_i18n::t; use crate::Backend; use crate::conn_diag::fault_short; use crate::controls::core_update::{self, OfferCounts}; +use crate::controls::row_selection::RowSelection; use crate::design; use moon_core::feed::ConnStatus; use moon_core::session::CoreId; use moon_core::session::core_update::{CoreUpdateOutcome, CoreUpdatePhase}; +/// What a core row needs to answer a click: where it sits, and what is already selected. +/// +/// Bundled rather than passed as three parallel parameters, because they are only ever correct +/// TOGETHER -- all three are derived from one frame's expansion, and a row handed a mismatched +/// pair would resolve a scope the tree is not drawing. +pub(super) struct TreeSelection<'a> { + /// Visible line-to-core projection, for a Shift range. + pub(super) order: &'a Rc<[Option]>, + /// The same order without the server rows, for scope resolution. + pub(super) cores: &'a Rc<[CoreId]>, + /// The panel's controlled selection, shared by REFCOUNT: a row only needs to clone a + /// handle into its click closures, and cloning the set itself once per drawn row cost + /// O(rows x selection) hash-set copies on every repaint of an expanded fleet. + pub(super) selection: &'a Rc>, +} +/// Project the By-IP tree's VISIBLE rows onto the cores they draw. +/// +/// The tree's counterpart of `ordering::flat_order`, and it exists for the same reason: a +/// selection is keyed by core, while the thing the user clicks is a row whose position moves. A +/// server row is `None` (it is not a selectable core), and a COLLAPSED group contributes nothing +/// at all -- its cores are not on screen, so a Shift range must not span them and a bulk action +/// must not reach them. +/// +/// Args: +/// groups: The panel's cached servers, already in display order. +/// expanded: `MoonTreeState::expanded_ids`, keyed by [`ServerKey::tree_id`]. Taken as a +/// SLICE because that is what the state hands back and a fleet is ten servers, not ten +/// thousand -- building a set to look each one up once would cost more than the scan. +/// +/// Returns: +/// One entry per rendered row, in on-screen order. +pub(super) fn visible_tree_order( + groups: &[ServerStatusGroup], + expanded: &[SharedString], +) -> Vec> { + let mut order = Vec::new(); + for group in groups { + order.push(None); + let id = SharedString::from(group.key.tree_id()); + if !expanded.contains(&id) { + continue; + } + order.extend(group.cores.iter().map(|core| Some(core.id))); + } + order +} + use super::CoreStatusView; use super::by_ip_widths::{ByIpWidths, CELL_GAP_W, CHEVRON_W, ROW_GAP_W, TREE_SCROLLBAR_W}; use super::ip_cell::{IpCell, ip_cell}; @@ -92,7 +140,7 @@ pub(super) fn tree_items(groups: &[ServerStatusGroup]) -> Vec { /// editing: The server whose name is being renamed inline, if any. /// edit_input: Shared input state backing the inline rename field. /// chart_selected: The server highlighted by a body click (the chart target), if any. -/// chart_core: The core highlighted by a core-row click (charts that core), if any. +/// selection: Snapshot of the panel's controlled core-row selection. /// sort: The active By-IP column sort, for the header arrows. /// measured_width: Width the width probe recorded on the previous frame; `0` before the first. /// rem_size: The window's rem size, which sets the row insets. @@ -115,7 +163,7 @@ pub(super) fn grouped_server_view( editing: Option, edit_input: Option>, chart_selected: Option, - chart_core: Option, + selection: RowSelection, sort: (GroupSortField, bool), measured_width: f32, rem_size: f32, @@ -139,6 +187,13 @@ pub(super) fn grouped_server_view( // the tree's render closure below: the update buttons it hosts command the backend directly, // never through the view. let backend = backend.clone(); + // The tree's line-to-core projection, from the SAME expansion the tree is about to draw. + // Every click handler below resolves against this one list, so a Shift range, a Ctrl toggle + // and a right-click menu can never disagree about what is on screen. + let order: Rc<[Option]> = + Rc::from(visible_tree_order(&groups, &state.read(cx).expanded_ids())); + let order_cores: Rc<[CoreId]> = order.iter().flatten().copied().collect(); + let selection = Rc::new(selection); let server_positions = Rc::new( groups .iter() @@ -214,10 +269,24 @@ pub(super) fn grouped_server_view( .get(server_index) .and_then(|group| group.cores.get(core_index)) { - // A core row highlights when it is the charted core, and clicking it charts that core. + // The highlight follows the SELECTION now, not the chart: a plain click still + // charts the core and selects it alone, so the single-click case looks exactly as + // it did, while a Ctrl or Shift click builds a set the chart has no opinion about. return MoonListItem::new(meta.index) - .selected(chart_core == Some(core.id)) - .child(core_row(core, widths, &weak_view, &backend, p, app)); + .selected(selection.contains(Some(core.id))) + .child(core_row( + core, + widths, + TreeSelection { + order: &order, + cores: &order_cores, + selection: &selection, + }, + &weak_view, + &backend, + p, + app, + )); } } MoonListItem::new(meta.index) @@ -338,6 +407,10 @@ fn server_row( // single enqueue would, never a burst of commands. No retry affordance at this scope: retry // is a one-core action, offered on the core row it belongs to. let group_update_cores: Rc<[CoreId]> = group.cores.iter().map(|core| core.id).collect(); + // One handle, two consumers: the hover button below takes it by value and the right-click + // menu further down needs the same ids. A second collect over `group.cores` would be + // identical content re-allocated on every repaint of every server row. + let menu_cores = group_update_cores.clone(); let mut group_update_counts = OfferCounts::default(); for core in &group.cores { group_update_counts.add(core_update::offer_state( @@ -364,6 +437,32 @@ fn server_row( .items_center() .gap(px(ROW_GAP_W)) .overflow_hidden() + // Right-click updates every core on this server. The hover arrow beside it stays a plain + // RELEASE update; the menu is where a named build becomes reachable at server scope. Like + // every other right-click here it leaves the selection alone. + .on_mouse_down(MouseButton::Right, { + let weak_view = weak_view.clone(); + // The SAME handle the hover button was built from, not a second collect over the + // group: identical content, and a server row repaints on every hover. + let cores = menu_cores.clone(); + move |e: &MouseDownEvent, window, app| { + app.stop_propagation(); + let Some(view) = weak_view.upgrade() else { + return; + }; + let (backend, scope) = { + let this = view.read(app); + ( + this.backend.clone(), + update_menu::UpdateScope::from_cores(&cores, &this.cached_rows), + ) + }; + if scope.is_empty() { + return; + } + update_menu::open_update_row_menu(&backend, scope, e.position, window, app); + } + }) // The chevron is the ONLY expand trigger and toggles expansion directly (headless tree). .child({ let weak_view = weak_view.clone(); @@ -547,7 +646,9 @@ fn server_row( /// Args: /// core: Per-process snapshot. /// w: Shared column widths for this frame, matching the server row above it. -/// weak_view: Non-owning panel handle for the chart-this-core click. +/// sel: Where this row sits in the frame, and what is already selected. +/// weak_view: Non-owning panel handle for the click handlers. +/// backend: Shared terminal state used by the hover-revealed update control. /// p: Active Moon palette. /// app: Application context, for the font-scaled dot-column width. /// @@ -556,6 +657,7 @@ fn server_row( fn core_row( core: &CoreStatusRow, w: ByIpWidths, + sel: TreeSelection<'_>, weak_view: &WeakEntity, backend: &Entity, p: MoonPalette, @@ -606,44 +708,52 @@ fn core_row( .gap(px(ROW_GAP_W)) .overflow_hidden() .cursor_pointer() - // Clicking a core row charts that core; the detached window reads `chart_core`. + // A PLAIN click selects this core alone AND charts it -- the gesture this row always had, + // now backed by the real selection. Ctrl and Shift build a set instead and deliberately + // leave the chart alone: a chart shows ONE subject, and a multi-selection has none. .on_mouse_down(MouseButton::Left, { let weak_view = weak_view.clone(); + let order = sel.order.clone(); let id = core.id; - move |_, _, app| { + move |_, window, app| { + let modifiers = window.modifiers(); + let order = order.clone(); if let Some(view) = weak_view.upgrade() { - view.update(app, |this, cx| this.select_chart_core(id, cx)); + view.update(app, |this, cx| { + this.select_core_row(Some(id), &order, modifiers, cx); + if !modifiers.shift && !modifiers.secondary() { + this.select_chart_core(id, cx); + } + }); } } }) - // Right-click opens the row's update menu. Deliberately does NOT touch selection -- - // opening a menu must not change which core is charted, so the core and its name are - // named explicitly here rather than read back from panel state at click time. + // Right-click opens the update menu for the SCOPE this row stands for, and deliberately + // does NOT touch the selection -- opening a menu must not change what it is about to act + // on. A row inside the selection stands for the whole selection; a row outside it stands + // for itself. `resolve_menu_scope` owns that rule for both presentations. .on_mouse_down(MouseButton::Right, { let weak_view = weak_view.clone(); + let order_cores = sel.cores.clone(); + let selection = sel.selection.clone(); let id = core.id; - let name = core.name.clone(); - let updatable = update_menu::core_updatable( - &core.status, - core.server_version, - core.endpoint.is_some(), - core.update.as_ref(), - ); move |e: &MouseDownEvent, window, app| { app.stop_propagation(); let Some(view) = weak_view.upgrade() else { return; }; - let backend = view.read(app).backend.clone(); - update_menu::open_update_row_menu( - &backend, - id, - name.clone(), - updatable, - e.position, - window, - app, - ); + let cores = core_update::resolve_menu_scope(id, &order_cores, &selection); + let (backend, scope) = { + let this = view.read(app); + ( + this.backend.clone(), + update_menu::UpdateScope::from_cores(&cores, &this.cached_rows), + ) + }; + if scope.is_empty() { + return; + } + update_menu::open_update_row_menu(&backend, scope, e.position, window, app); } }) // Empty chevron gutter, matching the server row's 12 px expand column so the body aligns. diff --git a/crates/moon-ui-gpui/src/panels/core_status/table.rs b/crates/moon-ui-gpui/src/panels/core_status/table.rs index 641b9bb7..8a4c295f 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/table.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/table.rs @@ -20,6 +20,7 @@ use super::update_menu; use super::*; use crate::conn_diag::{fault_facts, fault_tooltip}; use crate::controls::core_update::{self, OfferCounts}; +use crate::controls::row_selection::RowSelection; use gpui::prelude::FluentBuilder; use moon_core::feed::{Diagnosis, diagnose}; use moon_core::session::core_update::{CoreUpdateOutcome, CoreUpdatePhase}; @@ -181,6 +182,8 @@ fn columns(keys: &[&str]) -> Vec { /// sorted: Whether a column sort is active, which the headings explain. /// state: Persisted table interaction state. /// backend: Shared terminal backend, handed to row builders that must not read this view. +/// selection: Snapshot of the panel's controlled row selection, drawn per row and resolved +/// into a scope by the right-click handler. /// marker: This panel's scope marker, which swaps the empty-state sentence when the active /// preset hid every configured core. /// cx: Panel context used for palette, empty-state localization, and the sort callback. @@ -199,6 +202,7 @@ pub(super) fn core_status_table( state: &Entity, backend: &Entity, marker: &ScopeMarker, + selection: RowSelection, cx: &Context, ) -> impl IntoElement { // Keyed on the CORES, not the lines: a table holding nothing but headings is not representable @@ -227,6 +231,18 @@ pub(super) fn core_status_table( let menu_lines = lines.clone(); let menu_rows = rows.clone(); let menu_view = view.clone(); + // Computed ONCE and shared by every closure below: the line-to-core projection is what + // makes a `MoonDataTable` row index addressable as a selection, and all four handlers + // must agree about it or a click, a range and a menu would each resolve a different set. + let order: Rc<[Option]> = Rc::from(ordering::flat_order(&lines, &rows)); + // The same order with the headings dropped -- what `resolve_menu_scope` filters against. + let order_cores: Rc<[CoreId]> = order.iter().flatten().copied().collect(); + let row_order = order.clone(); + let click_order = order.clone(); + let all_order = order.clone(); + let click_view = view.clone(); + let all_view = view.clone(); + let row_selection = selection.clone(); crate::panels::common::data_table_host( SharedString::from(format!("{id}-host")), @@ -246,6 +262,7 @@ pub(super) fn core_status_table( &row_column_keys, &server_names, &backend, + row_selection.contains(row_order.get(ix).copied().flatten()), p, app, ), @@ -255,26 +272,47 @@ pub(super) fn core_status_table( .header_height(design::TABLE_HEAD_H) .row_height(design::TABLE_ROW_H) .style(design::table_style(p)) + // Controlled: the highlight is whatever the rendered rows say it is, so the panel's + // own `CoreId`-keyed selection is the single source of truth and the widget's internal + // `selected_row` (a LINE index, which a re-sort invalidates) never competes with it. + .controlled_row_selection(true) + .on_select_row(move |ix, window, app| { + let clicked = click_order.get(ix).copied().flatten(); + let order = click_order.clone(); + let modifiers = window.modifiers(); + click_view.update(app, |this, cx| { + this.select_core_row(clicked, &order, modifiers, cx); + }); + }) + .on_select_all_rows(move |_window, app| { + let order = all_order.clone(); + all_view.update(app, |this, cx| this.select_all_visible_cores(&order, cx)); + }) + // A right-click resolves a SCOPE and never moves the selection: a core row inside the + // selection stands for the whole selection, a core row outside it stands for itself, and + // an exchange heading stands for its own section. `resolve_menu_scope` owns that rule. .on_right_click_row(move |ix, window, app| { - let core = match menu_lines.get(ix) { - Some(FlatLine::Core(row)) => menu_rows.get(*row), - _ => None, + let scope = match menu_lines.get(ix) { + Some(FlatLine::Core(row)) => { + let Some(core) = menu_rows.get(*row) else { + return; + }; + let cores = core_update::resolve_menu_scope(core.id, &order_cores, &selection); + update_menu::UpdateScope::from_cores(&cores, &menu_rows) + } + Some(FlatLine::Section(_)) => { + let cores = ordering::section_cores(&menu_lines, ix, &menu_rows); + update_menu::UpdateScope::from_cores(&cores, &menu_rows) + } + None => return, }; - let Some(core) = core else { + if scope.is_empty() { return; - }; + } let backend = menu_view.read(app).backend.clone(); - let updatable = update_menu::core_updatable( - &core.status, - core.server_version, - core.endpoint.is_some(), - core.update.as_ref(), - ); update_menu::open_update_row_menu( &backend, - core.id, - core.name.clone(), - updatable, + scope, window.mouse_position(), window, app, @@ -327,6 +365,8 @@ pub(super) fn core_status_table( /// column_keys: Exact ordered keys used to build the table descriptors. /// server_names: Server display name per server key. /// backend: Shared terminal state the hover-revealed update button commands. +/// selected: Whether this core belongs to the panel's controlled selection. Applied to the +/// built row rather than inside the pinned constructor below. /// p: Active Moon palette, for the API and MoonBot cells' colour. /// app: Application context used to scale the update button's geometry. /// @@ -337,6 +377,7 @@ fn core_status_row( column_keys: &[&str], server_names: &HashMap, backend: &Entity, + selected: bool, p: MoonPalette, app: &App, ) -> MoonDataRow { @@ -380,7 +421,11 @@ fn core_status_row( .text_color(level_color(LoadLevel::Normal, p)), _ => unreachable!("canonical Flat column key"), })); - row + // Applied HERE, not inside the match above: `tests/theme_contract/core_status.rs` pins that + // constructor's opening line as source TEXT, and rustfmt rewrites the closure into block form + // the moment its shape changes -- which breaks the contract with no compile error and no fmt + // complaint. The `#[rustfmt::skip]` binding stays exactly as it was. + row.selected(selected) } /// The API-key cell: a bare day count, or the infinity glyph with the phrase behind it. diff --git a/crates/moon-ui-gpui/src/panels/core_status/update_menu.rs b/crates/moon-ui-gpui/src/panels/core_status/update_menu.rs index 168729e2..cd5a66f0 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/update_menu.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/update_menu.rs @@ -1,15 +1,22 @@ -//! Right-click context menu for a Core Status core row: "update to release" -- the same action +//! Right-click context menu for a Core Status update scope: "update to release" -- the same action //! the row button fires -- and, one level deeper, a free-text prompt for a named beta/test build. -//! Both dispatch through the shared `controls::core_update::update_core`, so the menu can never do -//! anything the row button could not already do. +//! Both dispatch through the shared `controls::core_update::{update_core, update_scope}`, so the +//! menu can never do anything the row button could not already do. +//! +//! A SCOPE, not a row: the same menu serves one core, a multi-row selection, every core of a +//! server, and every core under a Flat exchange heading. Which cores a click stands for is decided +//! before the menu opens -- `controls::core_update::resolve_menu_scope` for a row, the group or +//! section membership for a heading -- so this module only ever draws what it was handed. //! //! The fitted menu keeps the named-build input in a self-contained `MoonDialog`, so its transient -//! state does not leak into `CoreStatusView`. Right-click wiring leaves the current table selection +//! state does not leak into `CoreStatusView`. Right-click wiring leaves the current selection //! unchanged: opening a menu must not move the selection the menu is about to act on. Resolving a //! `MoonDataTable` line index back to a row, and the row-level `on_right_click_row` hook, follow //! `crates/moon-ui-gpui/src/panels/report/render.rs:89-111` -- a line index addresses a LINE, not a //! core, because the grouped view draws each heading as its own synthetic row. +use std::rc::Rc; + use gpui::*; use moon_ui::{ MoonButton, MoonButtonSize, MoonButtonVariant, MoonContextMenuWindowExt as _, MoonInput, @@ -17,12 +24,14 @@ use moon_ui::{ }; use rust_i18n::t; -use moon_core::feed::{ConnStatus, UpdateTarget}; +use moon_core::feed::UpdateTarget; use moon_core::session::CoreId; -use moon_core::session::core_update::CoreUpdatePhase; +use super::model::CoreStatusRow; use crate::Backend; -use crate::controls::core_update::{OfferState, offer_state, update_core}; +use crate::controls::core_update::{ + OfferCounts, OfferState, offer_state, update_core, update_scope, +}; use crate::design::{self, moon}; /// Fitted-menu bounds for this two-item (plus separator) menu -- narrower than the shared coin @@ -38,48 +47,121 @@ const NAMED_DIALOG_ID: &str = "core-update-named-dialog"; /// unbounded over the wire and in the persisted history. const NAMED_BUILD_NAME_MAX: usize = 64; -/// Whether `core` may be enqueued for an update right now: connected and settled (`Ready`), with -/// a known build and a known address, and no live attempt already tracked for it. -/// -/// Delegates to [`offer_state`] -- the same rule the row's own update button already draws -/// from -- rather than keeping a second, weaker copy that checked only `status` and `update`. -/// MoonProto's lifecycle events do not arrive in a fixed order, so a core can be `Ready` before -/// it has reported a `server_version` or an endpoint; the weaker copy enabled the menu in exactly -/// that window, `enqueue_core_update` silently rejected the click, and the user got a -/// `log::warn!` they never saw. Found by three independent review passes. +/// How many core names the named-build dialog shows before the LIST starts scrolling. /// -/// Args: -/// status: The row's current connection status. -/// server_version: The row's last reported build, when it reported one. -/// endpoint_known: Whether the row's address has reached the store. -/// update: The row's currently tracked update phase, if any. -/// -/// Returns: -/// Whether the row meets the standard offer-state conditions that enable the menu entries. -pub(super) fn core_updatable( - status: &ConnStatus, - server_version: Option, - endpoint_known: bool, - update: Option<&CoreUpdatePhase>, -) -> bool { - matches!( - offer_state(status, server_version, endpoint_known, update), - OfferState::Offerable - ) +/// The list scrolls; a NAME never shortens. A core name is what the operator typed into Moonbot and +/// is the only thing identifying which machine is about to be updated, so clipping one to fit is +/// the one economy this dialog may not make. +const NAMED_LIST_ROWS: f32 = 7.0; + +/// Height of one name row in the dialog's scope list. +const NAMED_LIST_ROW_H: f32 = 18.0; + +/// What one right-click, server row or heading stands for: the cores to command, their verbatim +/// names, and how the update queue currently classifies them. +pub(super) struct UpdateScope { + /// Cores this menu will actually enqueue: the OFFERABLE subset, in the order drawn. + /// + /// The subset and not the whole click, because the menu NAMES this count and lists these + /// names -- what it says and what it sends have to be one set. Holding every clicked core + /// here instead let a core counted as skipped reconnect between the menu opening and the + /// entry being pressed, and `enqueue_core_updates` would then accept it: more cores + /// updated than the label promised. + pub(super) targets: Rc<[CoreId]>, + /// Display names parallel to [`Self::targets`], exactly as the operator named them. + pub(super) names: Rc<[String]>, + /// Everything the click stood for, classified -- including what is NOT a target, which is + /// what the skip note explains. + pub(super) counts: OfferCounts, + /// How many rows the click stood for at all, offerable or not. + total: usize, } -/// Open the core row's context menu at `pos`. +impl UpdateScope { + /// Build a scope from the rows it stands for. + /// + /// Classifies with [`offer_state`] -- the same rule the row's own update button draws from -- + /// rather than a second, weaker copy that checked only `status` and `update`. MoonProto's + /// lifecycle events do not arrive in a fixed order, so a core can be `Ready` before it has + /// reported a `server_version` or an endpoint; the weaker copy enabled the menu in exactly that + /// window, `enqueue_core_update` silently rejected the click, and the operator got a + /// `log::warn!` they never saw. + /// + /// Args: + /// rows: The rows this click stands for, in render order. + /// + /// Returns: + /// The scope, with its offer tally already folded. + pub(super) fn from_rows<'a>(rows: impl Iterator) -> Self { + let mut targets = Vec::new(); + let mut names = Vec::new(); + let mut counts = OfferCounts::default(); + let mut total = 0usize; + for row in rows { + total += 1; + let state = offer_state( + &row.status, + row.server_version, + row.endpoint.is_some(), + row.update.as_ref(), + ); + counts.add(state); + if state == OfferState::Offerable { + targets.push(row.id); + names.push(row.name.clone()); + } + } + Self { + targets: Rc::from(targets), + names: Rc::from(names), + counts, + total, + } + } + + /// Build a scope from already-resolved core ids, looked up in the panel's rows. + /// + /// The shape every call site actually reaches for: scope resolution hands back IDS (the + /// selection, a server's group, a section's members), and the rows are what carry the name and + /// the state a menu needs. A core with no row is dropped rather than guessed at -- an id the + /// panel is not drawing has no business in a scope that enqueues. + /// + /// Args: + /// cores: Resolved core ids, in the order they are drawn. + /// rows: The panel's current rows. + /// + /// Returns: + /// The scope, in `cores` order. + pub(super) fn from_cores(cores: &[CoreId], rows: &[CoreStatusRow]) -> Self { + Self::from_rows( + cores + .iter() + .filter_map(|core| rows.iter().find(|row| row.id == *core)), + ) + } + + /// Whether the queue would accept anything in this scope right now. + pub(super) fn offerable(&self) -> bool { + !self.targets.is_empty() + } + + /// Whether this scope stands for no core at all -- nothing for a menu to open about. + pub(super) fn is_empty(&self) -> bool { + self.total == 0 + } +} + +/// Open the update menu for `scope` at `pos`. /// -/// Both entries are always present and DISABLED (never hidden) when `updatable` is false, plus a -/// leading disabled row naming the reason -- the row must not silently offer nothing, and the menu -/// must not change shape between the enqueueable and non-enqueueable case. +/// Both entries are always present and DISABLED (never hidden) when nothing in the scope is +/// offerable, plus a leading disabled row naming the reason -- the row must not silently offer +/// nothing, and the menu must not change shape between the enqueueable and non-enqueueable case. +/// A scope that is only PARTLY offerable says so on the same leading row, reusing the wording the +/// server button's tooltip already uses for the same two skips. /// /// Args: -/// backend: Shared terminal state, forwarded to `update_core` unchanged. -/// core: Row's core identity. -/// core_name: Row's display name, shown in the named-build prompt. -/// updatable: [`core_updatable`] for this row, computed by the caller from data it already -/// has so this module needs no extra read of the row. +/// backend: Shared terminal state, forwarded to the shared enqueue entry points unchanged. +/// scope: The cores this click stands for. /// pos: Window-coordinate open point -- the click position, or `window.mouse_position()` from /// a `MoonDataTable` row handler that receives only a line index. /// window: Host window used to open the fitted menu. @@ -89,51 +171,53 @@ pub(super) fn core_updatable( /// Nothing; opens the menu as a side effect. pub(super) fn open_update_row_menu( backend: &Entity, - core: CoreId, - core_name: String, - updatable: bool, + scope: UpdateScope, pos: Point, window: &mut Window, cx: &mut App, ) { + let offerable = scope.offerable(); + let many = scope.targets.len() > 1; + let n = scope.targets.len(); + let scope = Rc::new(scope); + let mut items: Vec = Vec::new(); - if !updatable { - items.push( - MoonMenuItem::with_key( - "core-update-unavailable", - t!("core_update.menu.unavailable").to_string(), - ) - .disabled(true), - ); + if let Some(note) = scope_note(&scope) { + items.push(MoonMenuItem::with_key("core-update-scope-note", note).disabled(true)); } - let mut release_item = MoonMenuItem::with_key( - "core-update-release", - t!("core_update.menu.release").to_string(), - ) - .disabled(!updatable); - if updatable { + let release_label = if many { + t!("core_update.menu.release_n", n = n).to_string() + } else { + t!("core_update.menu.release").to_string() + }; + let mut release_item = + MoonMenuItem::with_key("core-update-release", release_label).disabled(!offerable); + if offerable { let backend_r = backend.clone(); + let cores = scope.targets.clone(); release_item = release_item.on_click(move |_, window, app| { window.close_context_menu(app); - update_core(&backend_r, core, UpdateTarget::Release, app); + enqueue(&backend_r, &cores, UpdateTarget::Release, app); }); } items.push(release_item); items.push(MoonMenuItem::separator()); - let mut named_item = MoonMenuItem::with_key( - "core-update-named", - t!("core_update.menu.named").to_string(), - ) - .disabled(!updatable); - if updatable { + let named_label = if many { + t!("core_update.menu.named_n", n = n).to_string() + } else { + t!("core_update.menu.named").to_string() + }; + let mut named_item = + MoonMenuItem::with_key("core-update-named", named_label).disabled(!offerable); + if offerable { let backend_n = backend.clone(); - let core_name = core_name.clone(); + let scope_n = scope.clone(); named_item = named_item.on_click(move |_, window, app| { window.close_context_menu(app); - open_named_dialog(backend_n.clone(), core, core_name.clone(), window, app); + open_named_dialog(backend_n.clone(), scope_n.clone(), window, app); }); } items.push(named_item); @@ -148,20 +232,130 @@ pub(super) fn open_update_row_menu( ); } -/// Open the free-text "update to a named version" prompt. +/// The leading disabled row, when this scope has something to say before it is acted on. +/// +/// Returns: +/// Why nothing is offerable, or what a press would SKIP when only part of the scope is -- +/// joined the way `controls::core_update::view`'s server tooltip joins the same two counts, so +/// the menu and that tooltip can never word one situation two ways. `None` when every core in +/// the scope would be accepted and there is nothing to warn about. +fn scope_note(scope: &UpdateScope) -> Option { + if !scope.offerable() { + return Some(t!("core_update.menu.unavailable").to_string()); + } + let mut parts = Vec::new(); + if scope.counts.offline > 0 { + parts.push(t!("core_update.skipped_offline", n = scope.counts.offline).to_string()); + } + if scope.counts.tracked > 0 { + parts.push(t!("core_update.skipped_already", n = scope.counts.tracked).to_string()); + } + (!parts.is_empty()).then(|| parts.join(" \u{2014} ")) +} + +/// Send one scope to the update queue. +/// +/// Dispatched by scope SIZE, exactly as `controls::core_update::view`'s own button does: one core +/// goes through `update_core`, several fill the per-IP lane queue through `update_scope`. Both are +/// the shared entry points -- nothing here ever reaches `enqueue_core_update` directly, because the +/// queue is what serializes updates one-per-server. +/// +/// Args: +/// backend: Shared terminal state. +/// cores: The scope to enqueue. +/// target: Release, or the build name the operator typed. +/// app: Application context used to reach the session. +fn enqueue(backend: &Entity, cores: &Rc<[CoreId]>, target: UpdateTarget, app: &mut App) { + if cores.len() == 1 { + update_core(backend, cores[0], target, app); + } else { + update_scope(backend, cores, target, app); + } +} + +/// Normalize what the operator typed into the build name that goes over the wire. +/// +/// A tester may type either the bare build name or paste the whole install command they have in +/// front of them (`InstallTestVersion MoonBot-F8`). `normalize_named_build` strips a leading +/// command-word TOKEN case-insensitively; `None` covers both an empty field and a value that is +/// ONLY the command word, and both mean "do nothing" -- there is no list to validate against, so +/// this is the only rejection the prompt can make. +/// +/// Capping happens AFTER normalizing, never before: capping first could slice `InstallTestVersion` +/// mid-word and defeat the strip. Capped like `core_groups`' own sanitize shape, because this +/// travels unbounded over the MoonProto wire and is written verbatim into the durable +/// `cfg/core_updates.json` history otherwise. +/// +/// Args: +/// raw: The field's current text. +/// +/// Returns: +/// The build name to send, or `None` when there is nothing to send. +pub(super) fn typed_build_name(raw: &str) -> Option { + let normalized = moon_core::feed::normalize_named_build(raw)?; + // Re-trim: truncation can leave a trailing space the normalized name had inside it. + let typed: String = normalized + .chars() + .take(NAMED_BUILD_NAME_MAX) + .collect::() + .trim() + .to_string(); + (!typed.is_empty()).then_some(typed) +} + +/// The scope's core names, in full, as a scrolling list. +/// +/// Shared by this module's prompt and the footer's confirm so the two can never describe one scope +/// differently. Every name is drawn `whitespace_nowrap` and is NEVER truncated: the LIST scrolls +/// past its height cap instead. A core name is the operator's own text and the only thing that says +/// which machine is about to take a new build. +/// +/// Args: +/// names: Verbatim core names, in the order they are drawn. +/// p: Active Moon palette. +/// cx: Application context used to scale the cap. +/// +/// Returns: +/// The list element, or `None` for a single-core scope, which names its core in the prompt. +pub(super) fn scope_name_list(names: &[String], p: MoonPalette, cx: &App) -> Option { + if names.len() < 2 { + return None; + } + let rows = names.len().min(NAMED_LIST_ROWS as usize) as f32; + Some( + v_flex() + .id("core-update-scope-names") + .w_full() + .max_h(design::ui_px(cx, rows * NAMED_LIST_ROW_H)) + .overflow_y_scroll() + // BOTH axes: a core name is arbitrary operator text and is never + // shortened, so a name wider than the 360 px dialog has to be reachable + // by scrolling rather than painted outside the list. + .overflow_x_scroll() + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_soft)) + .children(names.iter().map(|name| { + div() + .flex_none() + .whitespace_nowrap() + .child(name.clone()) + .into_any_element() + })) + .into_any_element(), + ) +} + +/// Open the free-text "update to a named version" prompt for a scope. /// /// Self-contained: the input's `Entity` is held only by this dialog's own builder /// closure and needs no field anywhere else, following `core_group_dialogs::open_save_dialog`'s -/// shape (`crates/moon-ui-gpui/src/controls/core_group_dialogs.rs:140`). No list is offered -- -/// MoonProto's `request_version_update` takes an arbitrary build name and the terminal never -/// learns what builds exist, so the field is deliberately free text. Submission normalizes a -/// pasted complete install command to its bare name; an empty result still means "do nothing". +/// shape (`crates/moon-ui-gpui/src/controls/core_group_dialogs.rs:140`). No list of builds is +/// offered -- MoonProto's `request_version_update` takes an arbitrary build name and the terminal +/// never learns what builds exist, so the field is deliberately free text. /// /// Args: -/// backend: Shared terminal state, forwarded to `update_core` on submit. -/// core: Target core identity, captured at menu-click time. -/// core_name: Target core's display name, shown so a fleet-wide user can confirm the target -/// before typing a build name. +/// backend: Shared terminal state, forwarded to the shared enqueue entry points on submit. +/// scope: Target cores and their verbatim names, captured at menu-click time. /// window: Host window used to create the input and open the dialog. /// app: Application context used to create the input and open the dialog. /// @@ -169,8 +363,7 @@ pub(super) fn open_update_row_menu( /// Nothing; opens the dialog as a side effect. fn open_named_dialog( backend: Entity, - core: CoreId, - core_name: String, + scope: Rc, window: &mut Window, app: &mut App, ) { @@ -192,7 +385,17 @@ fn open_named_dialog( let field = input.clone(); let confirm_input = input.clone(); let confirm_backend = backend.clone(); - let prompt = t!("core_update.menu.named_prompt", core = core_name.clone()).to_string(); + let names = scope.names.clone(); + let cores = scope.targets.clone(); + let prompt = if names.len() > 1 { + t!("core_update.menu.named_prompt_n", n = names.len()).to_string() + } else { + t!( + "core_update.menu.named_prompt", + core = names.first().cloned().unwrap_or_default() + ) + .to_string() + }; let hint = t!("core_update.menu.named_hint").to_string(); dialog .w(px(360.0)) @@ -212,12 +415,13 @@ fn open_named_dialog( .font_weight(FontWeight::SEMIBOLD) .child(t!("core_update.menu.named").to_string()), ) - .content(move |content, _window, _cx| { + .content(move |content, _window, cx| { content.child( v_flex() .w_full() .gap_2() .child(div().text_color(moon(p.text_muted)).child(prompt.clone())) + .children(scope_name_list(&names, p, cx)) .child( MoonInput::new("core-update-named-input") .state(&field) @@ -226,7 +430,7 @@ fn open_named_dialog( .child(div().text_color(moon(p.text_muted)).child(hint.clone())), ) }) - .footer(named_footer(confirm_input, confirm_backend, core, p)) + .footer(named_footer(confirm_input, confirm_backend, cores, p)) }); } @@ -234,8 +438,8 @@ fn open_named_dialog( /// /// Args: /// input: The dialog's own input state, read once on confirm. -/// backend: Shared terminal state, forwarded to `update_core` on a non-empty confirm. -/// core: Target core identity captured at menu-click time. +/// backend: Shared terminal state, forwarded on a non-empty confirm. +/// cores: Target cores captured at menu-click time. /// p: Active Moon palette. /// /// Returns: @@ -243,7 +447,7 @@ fn open_named_dialog( fn named_footer( input: Entity, backend: Entity, - core: CoreId, + cores: Rc<[CoreId]>, p: MoonPalette, ) -> gpui::AnyElement { h_flex() @@ -265,33 +469,12 @@ fn named_footer( .variant(MoonButtonVariant::Blue) .label(t!("dialogs.done").to_string()) .on_click(move |_, window, cx| { - // A tester may type either the bare build name or paste the whole install - // command they have in front of them (`InstallTestVersion MoonBot-F8`). - // `normalize_named_build` strips a - // leading command-word TOKEN case-insensitively; `None` covers both an empty - // field and a value that is ONLY the command word, and both close the dialog - // without sending anything -- there is no list to validate against, so this is - // still the only rejection this dialog can make. - let Some(normalized) = - moon_core::feed::normalize_named_build(&input.read(cx).value()) - else { - window.close_dialog(cx); - return; - }; - // Cap AFTER normalizing, never before: capping first could slice - // `InstallTestVersion` mid-word and defeat the strip above. Capped like - // `core_groups`' own sanitize shape: this travels unbounded over the MoonProto - // wire and is written verbatim into the durable `cfg/core_updates.json` history - // otherwise. - let typed: String = normalized.chars().take(NAMED_BUILD_NAME_MAX).collect(); - // Re-trim: truncation can leave a trailing space the normalized name had - // inside it. - let typed = typed.trim().to_string(); + let typed = typed_build_name(&input.read(cx).value()); window.close_dialog(cx); - if typed.is_empty() { + let Some(typed) = typed else { return; - } - update_core(&backend, core, UpdateTarget::Named(typed), cx); + }; + enqueue(&backend, &cores, UpdateTarget::Named(typed), cx); }) .render(), ) diff --git a/crates/moon-ui-gpui/src/panels/report/selection.rs b/crates/moon-ui-gpui/src/panels/report/selection.rs index 5927b446..2f936ef5 100644 --- a/crates/moon-ui-gpui/src/panels/report/selection.rs +++ b/crates/moon-ui-gpui/src/panels/report/selection.rs @@ -1,6 +1,11 @@ -//! Stable Report row selection, range arithmetic, mutation targets, and clipboard projection. +//! Stable Report row identity, mutation targets, and clipboard projection. +//! +//! The click and range arithmetic used to live here; it is `controls::row_selection` now, shared +//! with Core Status. `ReportSelection` is that helper keyed by [`ReportRowKey`]. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +#[cfg(test)] +use std::collections::HashSet; use chrono_tz::Tz; use moon_core::db::ReportAxis; @@ -9,6 +14,7 @@ use rusqlite::types::Value; use super::query::ReportData; use super::{columns, export}; +use crate::controls::row_selection::RowSelection; /// Stable identity of one displayed report row. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -20,172 +26,19 @@ pub(super) enum ReportRowKey { } /// Controlled Report multi-selection with one stable Shift-range anchor. -#[derive(Clone, Default)] -pub(super) struct ReportSelection { - selected: HashSet, - anchor: Option, - /// Row of the LAST click in any mode, which the comment pane describes. - /// - /// Distinct from `anchor`: a Shift range deliberately keeps its anchor at the range base so the - /// next Shift click re-measures from there, but the row the user just pointed at is the far end. - last_clicked: Option, -} +/// +/// The click algorithm itself is [`RowSelection`], lifted into `controls::row_selection` and +/// shared with Core Status. What stays here is what only a REPORT row means: which selections +/// MoonProto command 48 can actually address. +pub(super) type ReportSelection = RowSelection; impl ReportSelection { - /// Select every valid row identity in the current table without changing the Shift anchor. - /// - /// Args: - /// order: Current filtered and sorted row identities, including read-only legacy rows. - /// - /// Returns: - /// Nothing. Malformed rows without a stable identity are excluded. - pub(super) fn select_all(&mut self, order: &[Option]) { - self.selected = order.iter().filter_map(|key| *key).collect(); - } - - /// Apply one row click using platform-independent modifier meaning. - /// - /// Args: - /// clicked: Stable identity of the clicked row, or `None` for an invalid legacy row. - /// order: Current rendered row identities in visual order. - /// shift: Whether Shift was held. - /// secondary: Whether Ctrl on Windows/Linux or Command on macOS was held. - /// - /// Returns: - /// Nothing. Shift takes precedence over the secondary modifier, and a plain click that - /// lands on the sole selected row clears the selection instead of re-selecting it. - pub(super) fn click( - &mut self, - clicked: Option, - order: &[Option], - shift: bool, - secondary: bool, - ) { - let Some(clicked) = clicked else { - return; - }; - self.last_clicked = Some(clicked); - if shift { - let span = self.anchor.and_then(|anchor| { - let from = order.iter().position(|key| *key == Some(anchor))?; - let to = order.iter().position(|key| *key == Some(clicked))?; - Some(if from <= to { from..=to } else { to..=from }) - }); - self.selected.clear(); - if let Some(span) = span { - self.selected - .extend(order[span].iter().filter_map(|key| *key)); - } else { - self.selected.insert(clicked); - self.anchor = Some(clicked); - } - return; - } - self.anchor = Some(clicked); - if secondary { - if !self.selected.insert(clicked) { - self.selected.remove(&clicked); - } - return; - } - // A plain click on the row that IS the entire selection clears it: clicking the same row - // twice reads as undoing that selection. With anything else selected the click still - // collapses the set to the clicked row — that is the standard table behaviour and the only - // way back from a Shift range to a single row. The anchor is NOT cleared with the set — it - // was just moved to this row above — so a following Shift click still measures from here. - let only_this = self.selected.len() == 1 && self.selected.contains(&clicked); - self.selected.clear(); - if !only_this { - self.selected.insert(clicked); - } - } - - /// Select exactly one row, whatever was selected before. - /// - /// Unlike a plain [`Self::click`], this never clears: it exists for the second half of a - /// physical double-click. MoonDataTable invokes the row-select callback on BOTH clicks and - /// gives it no click count, so the deselecting second click has to be undone from the table's - /// own authoritative double-click callback rather than guessed at from timing. - /// - /// Args: - /// clicked: Stable identity of the double-clicked row, or `None` for an invalid row. - /// - /// Returns: - /// Nothing. The anchor follows the row, as it does for a plain click. - pub(super) fn select_only(&mut self, clicked: Option) { - let Some(clicked) = clicked else { - return; - }; - self.anchor = Some(clicked); - self.last_clicked = Some(clicked); - self.selected.clear(); - self.selected.insert(clicked); - } - - /// Remove selections no longer present in a newly published query result. - /// - /// Args: - /// visible: Stable row identities in the new result. - /// - /// Returns: - /// Nothing. A missing anchor is cleared with its vanished row. - pub(super) fn retain_visible(&mut self, visible: &[Option]) { - let visible: HashSet = visible.iter().filter_map(|key| *key).collect(); - self.selected.retain(|key| visible.contains(key)); - if self.anchor.is_some_and(|key| !visible.contains(&key)) { - self.anchor = None; - } - if self.last_clicked.is_some_and(|key| !visible.contains(&key)) { - self.last_clicked = None; - } - } - - /// Clear every selected row and the Shift anchor. - /// - /// Returns: - /// Nothing after selection state becomes empty. - pub(super) fn clear(&mut self) { - self.selected.clear(); - self.anchor = None; - self.last_clicked = None; - } - - /// Return whether one stable row is selected. - /// - /// Args: - /// key: Stable row identity, or `None` for an unselectable malformed row. - /// - /// Returns: - /// `true` only when a concrete identity belongs to the controlled set. - pub(super) fn contains(&self, key: Option) -> bool { - key.is_some_and(|key| self.selected.contains(&key)) - } - - /// Return the row the user last clicked, while it is still selected. - /// - /// Returns: - /// The last-clicked identity, or `None` once it has been deselected or has left the result. - /// The membership check matters for Ctrl-click: it clears the row but keeps it as the - /// anchor for a following Shift range. - pub(super) fn current(&self) -> Option { - self.last_clicked.filter(|key| self.selected.contains(key)) - } - - /// Return the number of selected rows. - /// - /// Returns: - /// Current controlled selection size. - pub(super) fn len(&self) -> usize { - self.selected.len() - } - /// Count selected rows addressable by MoonProto command 48. /// /// Returns: /// Replicated selections with a protocol `newRecID`; legacy identities are excluded. pub(super) fn mutable_count(&self) -> usize { - self.selected - .iter() + self.iter() .filter(|key| matches!(key, ReportRowKey::Replicated { .. })) .count() } diff --git a/crates/moon-ui-gpui/tests/theme_contract/core_status.rs b/crates/moon-ui-gpui/tests/theme_contract/core_status.rs index d8813f05..6f96abfb 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/core_status.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/core_status.rs @@ -1,6 +1,32 @@ //! Source-level Core Status table contracts for the binary-only GPUI crate. -use super::support::{braced_body, code_only, read_src}; +use super::support::{braced_body, code_only, read_module, read_src}; + +/// `panels/core_status/**` must route update requests through `controls::core_update` and +/// `update_menu.rs` must not call `select_core_row`. Mutation: call an enqueue method directly +/// or select the right-clicked row; either bypasses the per-IP queue or changes the update scope +/// the menu is meant to preserve. `code_only` strips every line and doc comment before the bans, +/// so the module documentation may explain these names without satisfying or tripping this test. +#[test] +fn core_status_bulk_updates_stay_queued_and_right_click_preserves_selection() { + let panel = code_only(&read_module("panels/core_status")); + for forbidden in [ + "enqueue_core_update", + "enqueue_core_updates", + "update_core_version", + ] { + assert!( + !panel.contains(forbidden), + "Core Status must route updates through controls::core_update, not `{forbidden}`" + ); + } + + let menu = code_only(&read_src("panels/core_status/update_menu.rs")); + assert!( + !menu.contains("select_core_row("), + "opening a Core Status update menu must not move the existing selection" + ); +} /// `table.rs:core_status_row` must route every explicitly classified Flat cell through /// `level_color`. Mutation: delete one arm's `.text_color(level_color(...))`; that column would diff --git a/locales/core_update.yml b/locales/core_update.yml index db6839de..24ea1ee6 100644 --- a/locales/core_update.yml +++ b/locales/core_update.yml @@ -159,6 +159,14 @@ core_update.fleet.behind_none: ru: "Ни одно ядро не отстаёт от самой новой версии, видимой здесь, а релизы Moonbot терминалу не видны." en: "Nothing here reports an older build than the newest one visible, and the terminal can't see Moonbot releases." es: "Nada aquí reporta una versión más antigua que la más nueva visible, y la terminal no puede ver los lanzamientos de Moonbot." +core_update.fleet.selected: + ru: "Обновить выбранные (%{n})" + en: "Update selected (%{n})" + es: "Actualizar seleccionados (%{n})" +core_update.fleet.named: + ru: "До версии…" + en: "To a build…" + es: "A una build…" core_update.confirm.title: ru: "Обновление ядер" en: "Update cores" @@ -181,6 +189,22 @@ core_update.menu.named_prompt: ru: "Имя сборки для %{core}:" en: "Build name for %{core}:" es: "Nombre de la build para %{core}:" +core_update.menu.release_n: + ru: "Обновить %{n} ядер до релиза" + en: "Update %{n} core(s) to release" + es: "Actualizar %{n} núcleo(s) a la versión de lanzamiento" +core_update.menu.named_n: + ru: "Обновить %{n} ядер до версии…" + en: "Update %{n} core(s) to a named version…" + es: "Actualizar %{n} núcleo(s) a una versión con nombre…" +core_update.menu.named_prompt_n: + ru: "Имя сборки для %{n} ядер:" + en: "Build name for %{n} core(s):" + es: "Nombre de la build para %{n} núcleo(s):" +core_update.confirm.named_prompt: + ru: "Имя сборки:" + en: "Build name:" + es: "Nombre de la build:" core_update.menu.named_ph: ru: "MoonBot-F8 или %{cmd} MoonBot-F8" en: "MoonBot-F8 or %{cmd} MoonBot-F8"