From 462de994136ece1765a065434700e6e7ddad2b4c Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:18:56 +0200 Subject: [PATCH 1/2] feat(core-status): resizable columns, a remembered mode, and the core's build Five things the panel could not do, plus the reason one of them only looked broken. Columns in the By-IP view are draggable, clamped, and shrink together by one shared factor once the row stops fitting - a tree has no horizontal scroll to fall back on. A drag persists per context; double-click frees a column, Shift+double-click frees them all. The mode tab (By IP / Flat / Alerts) is remembered, docked panel and detached window keeping their own answer. The alerts gear opens its popup under itself, right-aligned, on one geometry scale instead of two. The core's reported MoonBot build gets a column in both modes, with an agreement rollup on a collapsed server row. It only ever reached the UI inside a ConnFault before, so a healthy core showed none at all - the build now travels as its own message, retained while Ready and dropped on any other status. It renders dotted, 769 as 7.69, because that is what the product calls its builds; a bare 769 makes the reader do the conversion. The flat view groups by exchange with the same logos the left rail uses. `MoonDataTable` has no section-row concept, so each heading is a synthetic row whose caption rides MoonUI's new row banner; sorting reorders inside a section and never moves one. The IP column was never empty: the mask was on by default and printed twelve asterisks. It now shows the address, the header carries the one control that hides it, and losing panel focus no longer re-masks. --- Cargo.lock | 74 ++-- crates/moon-core/src/config/layout.rs | 10 + crates/moon-core/src/feed/live/mod.rs | 98 +++-- crates/moon-core/src/feed/types.rs | 20 + crates/moon-core/src/session/store.rs | 35 +- crates/moon-core/src/session/store/tests.rs | 31 ++ crates/moon-core/src/util/fmt.rs | 22 + crates/moon-core/src/util/fmt/tests.rs | 11 + crates/moon-ui-gpui/src/conn_diag.rs | 8 +- .../src/panels/core_status/by_ip_header.rs | 408 ++++++++++++++++-- .../src/panels/core_status/by_ip_widths.rs | 282 +++++++++++- .../panels/core_status/by_ip_widths/tests.rs | 111 ++++- .../src/panels/core_status/cache.rs | 28 +- .../src/panels/core_status/config_popup.rs | 173 +++++++- .../panels/core_status/config_popup/tests.rs | 75 ++++ .../src/panels/core_status/interactions.rs | 129 +++++- .../src/panels/core_status/ip_cell.rs | 77 ++++ .../src/panels/core_status/ip_cell/tests.rs | 50 +++ .../src/panels/core_status/mod.rs | 205 +++++++-- .../src/panels/core_status/model.rs | 61 +++ .../src/panels/core_status/model/tests.rs | 28 +- .../src/panels/core_status/ordering.rs | 183 +++++++- .../src/panels/core_status/ordering/tests.rs | 72 +++- .../src/panels/core_status/presentation.rs | 45 +- .../src/panels/core_status/server_view.rs | 268 +++++++----- .../panels/core_status/server_view/tests.rs | 3 +- .../src/panels/core_status/table.rs | 185 +++++++- .../src/panels/core_status/tests.rs | 65 ++- .../src/persistence/table_persist.rs | 66 ++- .../src/persistence/table_persist/tests.rs | 39 +- .../tests/theme_contract/core_status.rs | 24 ++ .../moon-ui-gpui/tests/theme_contract/main.rs | 1 + locales/core_status.yml | 24 ++ 33 files changed, 2560 insertions(+), 351 deletions(-) create mode 100644 crates/moon-ui-gpui/src/panels/core_status/config_popup/tests.rs create mode 100644 crates/moon-ui-gpui/src/panels/core_status/ip_cell.rs create mode 100644 crates/moon-ui-gpui/src/panels/core_status/ip_cell/tests.rs create mode 100644 crates/moon-ui-gpui/tests/theme_contract/core_status.rs diff --git a/Cargo.lock b/Cargo.lock index b4e89a38..3a2f5d1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,7 +255,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -266,7 +266,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1649,7 +1649,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1881,7 +1881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2781,7 +2781,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3747,7 +3747,7 @@ dependencies = [ [[package]] name = "moon-collections" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "indexmap", "moon-gpui-util", @@ -3793,7 +3793,7 @@ dependencies = [ [[package]] name = "moon-derive-refineable" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "proc-macro2", "quote", @@ -3803,7 +3803,7 @@ dependencies = [ [[package]] name = "moon-gpui" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "accesskit", "anyhow", @@ -3885,7 +3885,7 @@ dependencies = [ [[package]] name = "moon-gpui-linux" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "accesskit", "accesskit_unix", @@ -3936,7 +3936,7 @@ dependencies = [ [[package]] name = "moon-gpui-macos" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "accesskit", "accesskit_macos", @@ -3983,7 +3983,7 @@ dependencies = [ [[package]] name = "moon-gpui-macros" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3994,7 +3994,7 @@ dependencies = [ [[package]] name = "moon-gpui-platform" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "console_error_panic_hook", "moon-gpui", @@ -4007,7 +4007,7 @@ dependencies = [ [[package]] name = "moon-gpui-shared-string" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "schemars", "serde", @@ -4017,7 +4017,7 @@ dependencies = [ [[package]] name = "moon-gpui-util" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "anyhow", "log", @@ -4027,7 +4027,7 @@ dependencies = [ [[package]] name = "moon-gpui-web" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4051,7 +4051,7 @@ dependencies = [ [[package]] name = "moon-gpui-wgpu" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "anyhow", "bytemuck", @@ -4080,7 +4080,7 @@ dependencies = [ [[package]] name = "moon-gpui-windows" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "accesskit", "accesskit_windows", @@ -4108,7 +4108,7 @@ dependencies = [ [[package]] name = "moon-http-client" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "anyhow", "async-compression", @@ -4128,7 +4128,7 @@ dependencies = [ [[package]] name = "moon-media" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "anyhow", "bindgen", @@ -4143,7 +4143,7 @@ dependencies = [ [[package]] name = "moon-perf" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "moon-collections", "serde", @@ -4153,7 +4153,7 @@ dependencies = [ [[package]] name = "moon-refineable" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "moon-derive-refineable", ] @@ -4161,7 +4161,7 @@ dependencies = [ [[package]] name = "moon-scheduler" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "async-task", "backtrace", @@ -4176,7 +4176,7 @@ dependencies = [ [[package]] name = "moon-sum-tree" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "heapless", "log", @@ -4187,7 +4187,7 @@ dependencies = [ [[package]] name = "moon-ui" version = "0.1.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "moon-ui-components", "moon-ui-components-assets", @@ -4196,7 +4196,7 @@ dependencies = [ [[package]] name = "moon-ui-components" version = "0.1.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "aho-corasick", "anyhow", @@ -4246,7 +4246,7 @@ dependencies = [ [[package]] name = "moon-ui-components-assets" version = "0.1.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "anyhow", "log", @@ -4260,7 +4260,7 @@ dependencies = [ [[package]] name = "moon-ui-components-macros" version = "0.1.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "proc-macro2", "quote", @@ -4311,7 +4311,7 @@ dependencies = [ [[package]] name = "moon-util-macros" version = "0.0.0" -source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#d0f3247f67cd2ae6d52da0f96dc75a2fe6d58977" +source = "git+https://github.com/Moonbot-Tech/MoonUI?branch=master#46f74e2e079d0c703cae5d42d4c73555f520a030" dependencies = [ "moon-perf", "quote", @@ -5566,7 +5566,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -5604,9 +5604,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6159,7 +6159,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6744,7 +6744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6802,7 +6802,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7145,7 +7145,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7595,7 +7595,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8500,7 +8500,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 2ede7a0a..16971c89 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -826,6 +826,16 @@ pub struct WindowLayout { /// filter preference must never discard the complete window layout. #[serde(default, deserialize_with = "de_lenient_map")] pub report_filters: HashMap, + /// Core Status presentation choice per host context: `core-status-mode:dock` / + /// `core-status-mode:win`. + /// + /// Keyed like its neighbours above, through `table_persist::ctx_id`, so a docked tab and a + /// detached window remember their own mode independently. The value is an OPAQUE stable code + /// owned by the panel in `moon-ui-gpui`; this crate deliberately does not hold the vocabulary, + /// exactly as it does not hold [`ReportFilterPrefs`]'s. No entry, or a code this build does not + /// know, leaves the panel's own first-run default standing rather than failing the load. + #[serde(default, deserialize_with = "de_lenient_map")] + pub core_status_mode: HashMap, /// One-shot Report column migrations already applied to [`Self::table_visible_columns`]. /// /// A saved visible-column set is an EXPLICIT list, so a column added later is simply absent diff --git a/crates/moon-core/src/feed/live/mod.rs b/crates/moon-core/src/feed/live/mod.rs index f0ac96c6..3b0f46a4 100644 --- a/crates/moon-core/src/feed/live/mod.rs +++ b/crates/moon-core/src/feed/live/mod.rs @@ -335,6 +335,9 @@ pub(super) fn run( } let mut identity_sent = false; + // Tracked separately from `identity_sent`: the two facts come from one payload but are gated + // on different fields, so one can be publishable while the other is not. + let mut version_sent = false; let mut last_orders = Instant::now(); let mut orders_table_pending = false; let mut last_strats = Instant::now(); @@ -427,36 +430,65 @@ pub(super) fn run( } } - // Send the core's exchange from server_info after BaseCheck to the coordinator for grouping - // and provider election. Publish it once, as soon as the identity is known. - if !identity_sent { + // Publish what `server_info` reported after BaseCheck: the core's exchange (for grouping + // and provider election) and its MoonBot build. One snapshot read, TWO independent + // publications, each once. + // + // The build number deliberately does NOT ride the identity gate below. A venue is what + // `exchange_code` establishes; a build number is a fact about the core process itself, and + // a core that reports no exchange code — which lands it in the unidentified group, the + // rows an operator inspects most — would otherwise never report its build either. + if !identity_sent || (is_ready && !version_sent) { if let Some(info) = client.server_info() { - if let Some(code) = info.exchange_code { - // dex_name is nonempty only for Hyperliquid HIP-3 futures. Include it in the - // identity so cores from different DEXes are NOT deduplicated onto one provider - // with an incomplete market list; see ExchangeId. - let dex = info.dex_name.as_deref().unwrap_or(""); - let id = ExchangeId::with_dex(code.stable_id(), dex); - log::info!( - "core {} identity: exchange_code={} dex_name={:?} -> {:?}", - crate::feed::core_label(server.id), - code.stable_id(), - dex, - id - ); - let _ = tx.send(FeedMsg::Identity { - id, - dex: dex.to_string(), - // The core's own caption travels with the identity so no consumer has to - // reach back into a client snapshot for it while rendering. - reported: info.exchange_name.clone().unwrap_or_default(), - }); - // The account base currency selects the USD conversion used for manual orders. - let base = info.base_currency_name.unwrap_or_default(); - if !base.is_empty() { - let _ = tx.send(FeedMsg::CoreBase { base }); + // Read before the identity block below, which moves `base_currency_name` out. + // + // Gated on `is_ready`, which carries the last drained lifecycle batch's verdict. + // MoonProto sets the snapshot behind `server_info` ONCE, at the first Ready, and + // never clears it for an internal reconnect — so it keeps answering `Some` all + // through a `Reconnecting` episode. Without this gate the latch release below would + // republish the build on the very next pass and repopulate a store that had just + // cleared it, leaving a core displaying a build while it is visibly not connected. + // + // The flag itself latches on having EXAMINED the snapshot, not on having sent + // something: a core that honestly reports no build will not start reporting one + // later, and latching only inside the `Some` arm would re-clone `server_info` every + // iteration for the life of the connection — for exactly the absent case this + // column has to render. + if is_ready && !version_sent { + if let Some(version) = info.server_version { + let _ = tx.send(FeedMsg::CoreVersion { version }); + } + version_sent = true; + } + if !identity_sent { + if let Some(code) = info.exchange_code { + // dex_name is nonempty only for Hyperliquid HIP-3 futures. Include it in + // the identity so cores from different DEXes are NOT deduplicated onto one + // provider with an incomplete market list; see ExchangeId. + let dex = info.dex_name.as_deref().unwrap_or(""); + let id = ExchangeId::with_dex(code.stable_id(), dex); + log::info!( + "core {} identity: exchange_code={} dex_name={:?} -> {:?}", + crate::feed::core_label(server.id), + code.stable_id(), + dex, + id + ); + let _ = tx.send(FeedMsg::Identity { + id, + dex: dex.to_string(), + // The core's own caption travels with the identity so no consumer has + // to reach back into a client snapshot for it while rendering. + reported: info.exchange_name.clone().unwrap_or_default(), + }); + // The account base currency selects the USD conversion used for manual + // orders. + let base = info.base_currency_name.unwrap_or_default(); + if !base.is_empty() { + let _ = tx.send(FeedMsg::CoreBase { base }); + } + identity_sent = true; } - identity_sent = true; } } } @@ -556,6 +588,16 @@ pub(super) fn run( if is_ready { account_reconciliation.poll_api_expiry_on_ready(Instant::now()); } + // The store drops the reported BUILD on exactly this status, so the latch that stops us + // republishing it has to be released by exactly this status too. `Reconnecting` and + // `ServerRestart` are handled INSIDE this loop and never return from `run`, so a local + // flag that only resets on a fresh `run` would leave the column permanently blank after + // the first link blip of a session: the store would clear the build, `Connected { + // fresh: false }` would put the core back to Ready, and nothing would ever resend it. + // The two halves must move on the same event or they disagree. + if !is_ready { + version_sent = false; + } let _ = tx.send(FeedMsg::Status(st)); if request_license_state { if let Err(error) = client.settings().request_kernel_license_state() { diff --git a/crates/moon-core/src/feed/types.rs b/crates/moon-core/src/feed/types.rs index 57cd55b7..41bf06f8 100644 --- a/crates/moon-core/src/feed/types.rs +++ b/crates/moon-core/src/feed/types.rs @@ -969,6 +969,26 @@ pub enum FeedMsg { CoreBase { base: String, }, + /// MoonBot build number the core reported in its `BaseCheck` payload, sent at most once per + /// connection independently of `Identity` and `CoreBase`. + /// + /// Unlike those venue-scoped messages, this is published even when the core reported no + /// exchange code: a build identifies the MoonBot process, not its venue. + /// + /// Sent ONLY when the core actually reported one, exactly like `CoreBase`: absence travels as + /// SILENCE — no message, so no entry — rather than as a `None` inside an otherwise-populated + /// payload. Nothing downstream may read that silence as a fault. MoonProto publishes the + /// snapshot behind it only once init reaches Ready, and an unpublished snapshot is + /// byte-identical to the empty payload a genuinely ancient core answers with, so the two are + /// indistinguishable here and neither may be claimed — see [`CoreIdentityFacts`]. + /// + /// Deliberately its own message rather than a field on [`FeedMsg::Identity`]: that variant is + /// scoped to a core's VENUE and is published only when the core also reported an exchange + /// code, so riding it would silently withhold the build number from every core that reports no + /// venue. + CoreVersion { + version: u32, + }, /// Notify that the market read model changed. This lightweight wake-up makes /// `SessionManager` mark particular markets dirty while visible charts pull the snapshots they /// need. The ticks and order book themselves do not travel through the UI channel. diff --git a/crates/moon-core/src/session/store.rs b/crates/moon-core/src/session/store.rs index b96ee6f7..b14cd966 100644 --- a/crates/moon-core/src/session/store.rs +++ b/crates/moon-core/src/session/store.rs @@ -138,6 +138,24 @@ pub struct CoreData { /// does not clear it: the last successful answer is retained until the connection is replaced, /// so a core whose checks start failing keeps showing what it last reported. pub api_expiry: Option, + /// MoonBot build this core most recently reported, or `None`. + /// + /// The `FeedMsg::Status` arm drops it on any non-Ready status, so a replacement feed pointing + /// at a different MoonBot cannot retain the previous host's build. That hook rather than + /// `begin_connection_attempt`, which only the explicit respawn path calls: `live::run` is + /// retried IN PLACE after a failure, and each attempt announces itself with + /// `Status(Connecting)`, so the status arm is the one thing every attempt passes through. + /// `begin_connection_attempt` sends that same status itself, so the respawn path is covered by + /// the same line. + /// + /// `None` covers BOTH "not up, so nothing has been published" and "too old to report a build". + /// Those are indistinguishable at the wire — see [`crate::feed::CoreIdentityFacts`] — so a + /// consumer may render the absence but may never attribute a cause to it. + /// + /// It has NO revision counter, for the reason [`Self::fault`] states: the Core Status panel + /// rebuilds on the backend observer rather than polling one, and a counter nothing reads is + /// dead weight. + pub server_version: Option, /// Unshown Engine action results for toasts. The active window's shell drains them through /// [`CoreData::take_engine_actions`]. engine_actions: VecDeque, @@ -253,6 +271,7 @@ impl CoreData { runtime_state: None, hedge_mode: None, api_expiry: None, + server_version: None, engine_actions: VecDeque::new(), chart_alerts: HashMap::new(), log: VecDeque::new(), @@ -431,6 +450,14 @@ impl CoreData { if matches!(s, ConnStatus::Ready) { self.fault = None; } + // The reported BUILD describes the connection that reported it. MoonProto publishes + // the snapshot behind it only at Ready, so a core that has left Ready has no live + // claim to a build, and the replacement feed may reach a different MoonBot + // entirely. Dropping it here is the inverse of the fault rule above: the fault + // survives everything until Ready, the build survives nothing but Ready. + if !matches!(s, ConnStatus::Ready) { + self.server_version = None; + } self.status = s; } FeedMsg::Orders(mut orders) => { @@ -630,7 +657,13 @@ impl CoreData { self.news_rev = self.news_rev.wrapping_add(1); } } - // Identity and market wake-up messages are not routed into this store. + FeedMsg::CoreVersion { version } => { + self.server_version = Some(version); + } + // Identity, base-currency, and market wake-up messages are not routed into this store. + // The build number above IS, which is why it sits in an arm of its own: it belongs to + // one core's retained state, while a venue and a base currency belong to the session + // manager's cross-core coordination. FeedMsg::Identity { .. } | FeedMsg::CoreBase { .. } | FeedMsg::MarketDataChanged(_) => { } } diff --git a/crates/moon-core/src/session/store/tests.rs b/crates/moon-core/src/session/store/tests.rs index f320bd7e..d088fbbf 100644 --- a/crates/moon-core/src/session/store/tests.rs +++ b/crates/moon-core/src/session/store/tests.rs @@ -51,6 +51,37 @@ fn a_ready_status_erases_the_failure_that_preceded_it() { assert_eq!(core.fault, None); } +/// `store.rs:CoreData::apply` must clear a reported build on every non-Ready status; removing +/// that clear makes a reconnect to another MoonBot retain the previous host's build in Core Status. +#[test] +fn core_status_version_belongs_only_to_the_ready_connection_that_reported_it() { + let mut core = CoreData::new(); + + core.apply(FeedMsg::CoreVersion { version: 734 }); + assert_eq!(core.server_version, Some(734)); + + core.apply(FeedMsg::Status(ConnStatus::Ready)); + assert_eq!( + core.server_version, + Some(734), + "Ready keeps the reported build" + ); + + for status in [ + ConnStatus::Stage("reconnecting".to_string()), + ConnStatus::Connecting, + ConnStatus::Disconnected, + ConnStatus::Failed("replacement failed".to_string()), + ] { + core.apply(FeedMsg::CoreVersion { version: 735 }); + core.apply(FeedMsg::Status(status)); + assert_eq!( + core.server_version, None, + "a non-Ready state must not speak for the previous connection" + ); + } +} + /// No snapshot yet is UNKNOWN, never zero — the distinction the Assets panel exists to make. #[test] fn without_a_snapshot_the_balance_is_awaiting() { diff --git a/crates/moon-core/src/util/fmt.rs b/crates/moon-core/src/util/fmt.rs index 42c96cfd..deeb9a68 100644 --- a/crates/moon-core/src/util/fmt.rs +++ b/crates/moon-core/src/util/fmt.rs @@ -293,5 +293,27 @@ pub fn signed_pct(v: f64, decimals: usize) -> Option<(String, DeltaSign)> { } } +/// Render a Moonbot core's reported build the way the product names it: `769` -> `7.69`. +/// +/// The wire payload is a flat `u32` with no separator in it, so the split has to come from +/// somewhere. It comes from the PRODUCT: Moonbot builds are spoken and written as `7.69`, `7.70`, +/// and the terminal printing a bare `769` beside them makes the reader do the conversion. The last +/// two digits are the minor and everything above them is the major — which is why the minor is +/// zero-padded: `770` is `7.70`, never `7.7`, and dropping that zero would sort and read as an +/// EARLIER build than `7.69`. +/// +/// This lives here rather than in the panel that shows the column because two surfaces print the +/// same number — the Core-Status build column and the connection-fault hover — and one of them is +/// in a different module. A second copy is how the two drift. +/// +/// Args: +/// build: The build number exactly as the core reported it. +/// +/// Returns: +/// The dotted build text. +pub fn core_build(build: u32) -> String { + format!("{}.{:02}", build / 100, build % 100) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/util/fmt/tests.rs b/crates/moon-core/src/util/fmt/tests.rs index 40302f35..6edf1f8c 100644 --- a/crates/moon-core/src/util/fmt/tests.rs +++ b/crates/moon-core/src/util/fmt/tests.rs @@ -255,3 +255,14 @@ fn signed_fixed_rounds_before_choosing_the_sign() { "midpoints round away from zero, not to even" ); } + +/// Regression guard: in `fmt.rs:core_build`, changing the minor formatter from `{:02}` to `{}` +/// turns build `707` into `7.7`, so a reader can mistake it for older than a neighbouring `7.09`. +/// +/// A one-digit minor is the boundary that needs the pad; `710` confirms that the product keeps +/// the same two-digit wire convention immediately beyond it. +#[test] +fn core_build_zero_pads_single_digit_minor_versions() { + assert_eq!(core_build(707), "7.07"); + assert_eq!(core_build(710), "7.10"); +} diff --git a/crates/moon-ui-gpui/src/conn_diag.rs b/crates/moon-ui-gpui/src/conn_diag.rs index dbfec072..fbfd9a2c 100644 --- a/crates/moon-ui-gpui/src/conn_diag.rs +++ b/crates/moon-ui-gpui/src/conn_diag.rs @@ -238,7 +238,13 @@ pub(crate) fn fault_facts(d: &Diagnosis) -> Vec { push( t!("core_status.fault.f.core").to_string(), match d.server_version { - Some(v) => t!("core_status.fault.core.legacy_named", server = v).to_string(), + // Same dotted build the Core-Status column prints. A fault hover that said "769" + // beside a column saying "7.69" would read as two different facts about one core. + Some(v) => t!( + "core_status.fault.core.legacy_named", + server = moon_core::util::fmt::core_build(v) + ) + .to_string(), None => t!("core_status.fault.core.legacy_silent").to_string(), }, ); diff --git a/crates/moon-ui-gpui/src/panels/core_status/by_ip_header.rs b/crates/moon-ui-gpui/src/panels/core_status/by_ip_header.rs index 7676b008..7589fa7a 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/by_ip_header.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/by_ip_header.rs @@ -1,29 +1,197 @@ -//! The fixed caption row above the By-IP tree: one sortable heading per column. +//! The fixed caption row above the By-IP tree: one sortable heading per column, each with a +//! draggable divider at its right edge. //! //! It is a separate element from the rows it labels, so both sides read the same //! [`ByIpWidths`] and the same gap/inset constants — that shared geometry is the only thing //! keeping a caption over its values once the columns shrink. +//! +//! # Why the resize handle is hand-rolled +//! +//! MoonUI ships no reusable column resizer. Its only one lives inside `MoonDataTable`'s private +//! header (`moon/data_table/header.rs`), driven by a private drag payload and by +//! `MoonDataTableState`'s private header-bounds tracking; `resizable::resize_handle` is +//! `pub(crate)` and is a PANEL splitter, and the exported `moon_h_resizable` family builds resizable +//! panel GROUPS, which would mean restructuring this tree into panels. So the strip below mirrors +//! MoonUI's shape deliberately — same 6 px right-edge overlay, same `ResizeColumn` cursor, same +//! double-click-to-reset gesture — while the ARITHMETIC follows this repo's own splitter idiom in +//! `strategies/split.rs` instead. +//! +//! Two differences from MoonUI are load-bearing, not stylistic: +//! +//! - **`.occlude()` is mandatory here.** MoonUI's caption sorts on `on_click`; these captions sort +//! on `on_mouse_down`, and a normal child hitbox does not shield its parent, so without it +//! grabbing a divider would re-sort the column on the way down. +//! - **The drag anchors at the grab, not at the cell origin.** A `flex_1` spacer sits between IP and +//! CPU, so every column right of it is right-anchored and widening one moves its own left edge — +//! `pointer_x - origin_x` would compound every frame. [`CoreStatusView::drag_by_ip_col`] carries +//! the full argument. use gpui::prelude::FluentBuilder; use gpui::*; -use moon_ui::{MoonPalette, h_flex}; +use moon_ui::{MoonButton, MoonPalette, h_flex}; use rust_i18n::t; use super::CoreStatusView; use super::by_ip_widths::{ - ByIpWidths, CELL_GAP_W, CHEVRON_W, ROW_GAP_W, ROW_INSET_REMS, TREE_SCROLLBAR_W, + ByIpCol, ByIpWidths, CELL_GAP_W, CHEVRON_W, ROW_GAP_W, ROW_INSET_REMS, TREE_SCROLLBAR_W, }; +use super::ip_cell::mask_affordance; use super::ordering::GroupSortField; +use crate::design::moon_alpha; + +/// Width of the divider grab strip, in pixels. +/// +/// MIRRORS MoonUI's data-table divider (`moon/data_table/header.rs`, `tokens.ui(6.0)`). Six pixels +/// is already a small target, which is why the strip must span the header's full height — see +/// [`server_header`]'s `h_full` notes. +const HANDLE_W: f32 = 6.0; + +/// Pointer x and LOGICAL column width captured at the instant a divider drag began. +/// +/// Held on the panel for the life of one drag. "Logical" means the pre-shrink width from +/// [`ByIpWidths::resolved`], never the painted width — [`CoreStatusView::drag_by_ip_col`] explains +/// why anchoring on the painted width would double-apply the shrink factor. +#[derive(Clone, Copy, Debug)] +pub(super) struct ByIpDragAnchor { + /// Column being resized. + pub(super) col: ByIpCol, + /// Pointer x at the grab, in window pixels. + pub(super) mouse_x: f32, + /// The column's logical width at the grab. + pub(super) width: f32, +} + +/// GPUI drag payload identifying the panel and the column being resized. Carries no visual. +/// +/// `view` is the guard MoonUI uses for the same reason: a docked tab and a detached window each +/// register a listener for this payload type, and without the check a drag in one would resize the +/// columns of the other. +#[derive(Clone, Debug)] +struct ByIpResizeDrag { + /// Entity id of the panel that owns the handle being dragged. + view: EntityId, + /// Column being resized. + col: ByIpCol, +} + +impl Render for ByIpResizeDrag { + /// Render nothing: the payload exists only for GPUI's drag routing, and the column itself is + /// the drag feedback. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +/// Build the divider strip pinned to a caption cell's right edge. +/// +/// Args: +/// col: Column this divider resizes. +/// anchor_w: That column's LOGICAL (pre-shrink) width, baked in as the drag's starting point. +/// view: The owning panel, when it is still alive; `None` renders an inert spacer. +/// p: Active Moon palette. +/// window: Host window, for `listener_for`. Only `&Window` is needed — it takes `&self`. +/// +/// Returns: +/// An absolutely-positioned grab strip, or an empty element when the panel has gone away. +fn resize_handle( + col: ByIpCol, + anchor_w: f32, + view: Option<&Entity>, + p: MoonPalette, + window: &Window, +) -> AnyElement { + let Some(view) = view else { + return div().into_any_element(); + }; + let view_id = view.entity_id(); + let reset_view = view.downgrade(); + let begin_view = view.downgrade(); + + div() + // A `&'static str` per column rather than a `format!`: this runs once per caption on every + // header repaint, and the nine ids are a fixed set known at compile time. + .id(col.handle_id()) + .absolute() + .right(px(0.0)) + .top(px(0.0)) + .bottom(px(0.0)) + .w(px(HANDLE_W)) + // MANDATORY, not decoration: the caption behind this strip sorts on `on_mouse_down`, and a + // normal child hitbox does not shield its parent, so grabbing the divider would re-sort the + // column before the drag ever started. `occlude` makes the hitboxes behind report un-hovered. + .occlude() + .cursor(CursorStyle::ResizeColumn) + .hover(move |style| style.bg(moon_alpha(p.accent, 0.14))) + .tooltip(crate::panels::common::text_tooltip( + t!("core_status.col_resize").to_string(), + )) + // Same gesture as a MoonDataTable divider, so the two views answer the same input: a plain + // double-click frees this column, Shift+double-click frees all of them. + .on_click(move |event, _, app| { + if event.click_count() < 2 { + return; + } + let all = event.modifiers().shift; + if let Some(view) = reset_view.upgrade() { + view.update(app, |this, cx| this.reset_by_ip_col(col, all, cx)); + } + }) + .on_drag( + ByIpResizeDrag { view: view_id, col }, + move |drag, _, window, app| { + // Runs exactly ONCE, at drag start, past GPUI's drag threshold. Capturing the anchor + // here is what keeps the arithmetic independent of a cell origin that relayout moves. + let mouse_x = f32::from(window.mouse_position().x); + if let Some(view) = begin_view.upgrade() { + view.update(app, |this, _| { + this.begin_by_ip_resize(ByIpDragAnchor { + col, + mouse_x, + width: anchor_w, + }); + }); + } + app.new(|_| drag.clone()) + }, + ) + .on_drag_move(window.listener_for( + view, + move |this: &mut CoreStatusView, + event: &DragMoveEvent, + _window, + cx: &mut Context| { + // Copy out of the payload before touching `cx` again: `drag` borrows from it. + let (owner, dragged) = { + let drag = event.drag(cx); + (drag.view, drag.col) + }; + if owner != cx.entity_id() { + return; + } + this.drag_by_ip_col(dragged, f32::from(event.event.position.x), cx); + }, + )) + .into_any_element() +} /// Render the fixed By IP caption row: one heading per column, aligned to the tree rows. Every /// heading except IP is a sort control — clicking it sorts the server list by that column (an arrow -/// marks the active one). Warnings still pin to the top regardless of the sort. +/// marks the active one). Warnings still pin to the top regardless of the sort. Each heading also +/// carries a divider at its right edge that resizes the column. +/// +/// The IP heading is the exception twice over: it does not sort, and it carries the ONE control that +/// hides or shows every address in the column. That control used to sit on each row, which cost a +/// click per server and, being cleared whenever the panel lost focus, could not hold an address on +/// screen at all. /// /// Args: /// p: Active Moon palette. /// sort: The active `(field, ascending)` sort, to mark the column and drive the toggle. -/// w: Shared column widths for this frame — the same values the rows below use. -/// weak_view: Non-owning panel handle for the sort click. +/// masked: Whether the address column is currently hidden, which picks the control's affordance. +/// w: Shared column widths for this frame — the same values the rows below use, already shrunk. +/// logical: The same widths BEFORE the shrink factor; the divider drags anchor in these. +/// weak_view: Non-owning panel handle for the sort click, the resize handles and the mask toggle. +/// window: Host window, for the drag-move listener. /// cx: Application context, for the font-scaled caption size and dot-column width. /// /// Returns: @@ -31,10 +199,18 @@ use super::ordering::GroupSortField; pub(super) fn server_header( p: MoonPalette, sort: (GroupSortField, bool), + masked: bool, w: ByIpWidths, + logical: ByIpWidths, weak_view: &WeakEntity, + window: &Window, cx: &App, ) -> impl IntoElement { + // Upgraded once for the whole header: `Window::listener_for` needs a strong handle, and doing it + // per cell would be nine upgrades a frame. + let view = weak_view.upgrade(); + let view = view.as_ref(); + h_flex() .w_full() .items_center() @@ -59,89 +235,134 @@ pub(super) fn server_header( h_flex() .flex_1() .min_w_0() + // EXPLICIT, and the divider strips depend on it: this row is `items_center`, which + // CENTRES its children rather than stretching them, so without `h_full` every + // caption cell would size to its ~11 px of text and each `top:0/bottom:0` grab strip + // would be 6x11 px — technically present, practically unusable. + .h_full() .items_center() .gap(px(ROW_GAP_W)) .overflow_hidden() .child(col_sort_header( t!("core_status.col.server").to_string(), GroupSortField::Name, + ByIpCol::Name, w.name, + ByIpCol::Name.width_of(logical), sort, p, weak_view, + view, + window, )) - // IP is masked, so it is not a sort key — a plain caption. - .child( - div() - .w(px(w.ip)) - .flex_none() - .overflow_hidden() - .whitespace_nowrap() - .child(t!("core_status.hdr.ip").to_string()), - ) + // Not a sort key — a caption plus the column's single mask control, and it + // still carries its own resize divider like every other caption. + .child(ip_header(masked, w, logical, p, view, weak_view, window)) .child(div().flex_1()) .child(metric_sort_header( t!("core_status.hdr.cpu").to_string(), GroupSortField::Cpu, + ByIpCol::Cpu, w.cpu, w.icon, + ByIpCol::Cpu.width_of(logical), sort, p, weak_view, + view, + window, )) .child(metric_sort_header( t!("core_status.hdr.mem").to_string(), GroupSortField::Mem, + ByIpCol::Mem, w.mem, w.icon, + ByIpCol::Mem.width_of(logical), sort, p, weak_view, + view, + window, )) .child(metric_sort_header( t!("core_status.chart_ping").to_string(), GroupSortField::Ping, + ByIpCol::Ping, w.ping, w.icon, + ByIpCol::Ping.width_of(logical), sort, p, weak_view, + view, + window, )) .child(metric_sort_header( t!("core_status.chart_exch").to_string(), GroupSortField::Exch, - w.ping, + ByIpCol::Exch, + w.exch, w.icon, + ByIpCol::Exch.width_of(logical), sort, p, weak_view, + view, + window, )) .child(metric_sort_header( t!("core_status.hdr.api_key").to_string(), GroupSortField::ApiKey, + ByIpCol::Api, w.api, w.icon, + ByIpCol::Api.width_of(logical), + sort, + p, + weak_view, + view, + window, + )) + // No icon lead, for the same reason startup has none: nothing warns on a build + // number, so a metric heading's lead would never light here. + .child(col_sort_header( + t!("core_status.col.version").to_string(), + GroupSortField::Version, + ByIpCol::Version, + w.version, + ByIpCol::Version.width_of(logical), sort, p, weak_view, + view, + window, )) // No icon lead: startup has no `WarnAxis` behind it, so it uses the plain // heading like the core ratio rather than a metric heading whose lead never lights. .child(col_sort_header( t!("core_status.col.startup").to_string(), GroupSortField::Startup, + ByIpCol::Startup, w.startup, + ByIpCol::Startup.width_of(logical), sort, p, weak_view, + view, + window, )) .child(col_sort_header( t!("core_status.cores").to_string(), GroupSortField::Cores, + ByIpCol::Cores, w.cores, + ByIpCol::Cores.width_of(logical), sort, p, weak_view, + view, + window, )) // Trailing slots, mirroring the rows so the "Ядра" caption lands over the ratio: // the connectivity-warning slot, the status dot, and the tree's overlay scrollbar. @@ -151,6 +372,73 @@ pub(super) fn server_header( ) } +/// The IP heading: its caption plus the one control that hides or shows the whole address column. +/// +/// The icon/tooltip pairing lives in [`mask_affordance`] rather than inline here, because getting it +/// backwards compiles and renders cleanly while telling the user the exact opposite of the truth — +/// see that function for why the old per-row default makes the inverse so easy to write. +/// +/// No `stop_propagation` here, unlike the row buttons: the header has no ancestor mouse-down handler +/// to steal the click from. +/// +/// Args: +/// masked: Whether the column is currently hidden. +/// w: Shared column widths, supplying the IP column. +/// p: Active Moon palette. +/// weak_view: Non-owning panel handle for the toggle callback. +/// +/// Returns: +/// A fixed-width heading cell matching the IP column below it. +fn ip_header( + masked: bool, + w: ByIpWidths, + logical: ByIpWidths, + p: MoonPalette, + view: Option<&Entity>, + weak_view: &WeakEntity, + window: &Window, +) -> impl IntoElement { + let weak_view = weak_view.clone(); + let (icon, tooltip) = mask_affordance(masked); + h_flex() + .w(px(w.ip)) + .flex_none() + .relative() + .items_center() + .gap_1() + .overflow_hidden() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(t!("core_status.hdr.ip").to_string()), + ) + .child( + div().flex_none().child( + MoonButton::new("core-status-ip-mask") + .xsmall() + .ghost() + .icon(icon) + .tooltip(t!(tooltip).to_string()) + .on_click(move |_, _window, app| { + let Some(view) = weak_view.upgrade() else { + return; + }; + view.update(app, |this, cx| this.toggle_ip_mask(cx)); + }), + ), + ) + .child(resize_handle( + ByIpCol::Ip, + ByIpCol::Ip.width_of(logical), + view, + p, + window, + )) + .text_color(rgb(p.text_muted)) +} + /// The sort arrow suffix for a heading, matching the MoonDataTable header (`↑` ascending, `↓` /// descending), or empty when another field is active. fn sort_arrow(field: GroupSortField, sort: (GroupSortField, bool)) -> &'static str { @@ -162,33 +450,49 @@ fn sort_arrow(field: GroupSortField, sort: (GroupSortField, bool)) -> &'static s } /// A clickable metric heading offset by the metric icon's lead so it sits over the value box, with -/// the sort arrow and an active-column highlight. +/// the sort arrow, an active-column highlight, and a divider on the value box's right edge. +/// +/// The divider hangs off the VALUE BOX rather than the whole cell on purpose: the width it resizes +/// is the value width, and the icon lead is chrome that no drag may touch. /// /// Args: /// label: Localized column heading. /// field: The sort field this heading selects. +/// col: The resizable column this heading labels. /// value_w: The matching value-box width from the row's `metric_cell`. /// icon_w: The matching warning-icon lead, so the caption sits over the value. +/// anchor_w: `col`'s logical width, which the divider drag anchors in. /// sort: The active sort, for the arrow and highlight. /// p: Active Moon palette. /// weak_view: Non-owning panel handle for the sort click. +/// view: Strong panel handle for the divider, when alive. +/// window: Host window, for the drag-move listener. /// /// Returns: /// A fixed-width clickable caption cell aligned to its column. +// One more argument than clippy's default: the resize handle needs both the column identity and its +// pre-shrink width, and every caption in this file already carries the palette and both handles. +#[allow(clippy::too_many_arguments)] fn metric_sort_header( label: String, field: GroupSortField, + col: ByIpCol, value_w: f32, icon_w: f32, + anchor_w: f32, sort: (GroupSortField, bool), p: MoonPalette, weak_view: &WeakEntity, + view: Option<&Entity>, + window: &Window, ) -> impl IntoElement { let text = format!("{label}{}", sort_arrow(field, sort)); let active = sort.0 == field; let weak_view = weak_view.clone(); h_flex() .flex_none() + .h_full() + .items_center() .gap(px(CELL_GAP_W)) .cursor_pointer() .on_mouse_down(MouseButton::Left, move |_, _, app| { @@ -200,50 +504,82 @@ fn metric_sort_header( .child( div() .w(px(value_w)) - // Clip like the value box below it: at the shrink floor a caption would otherwise - // wrap to a second line inside the fixed-height header. - .overflow_hidden() - .whitespace_nowrap() - .when(active, |el| el.text_color(rgb(p.text_soft))) - .child(text), + .h_full() + .flex_none() + .relative() + .flex() + .items_center() + .child( + div() + .flex_1() + .min_w_0() + // Clip like the value box below it: at the shrink floor a caption would + // otherwise wrap to a second line inside the fixed-height header. + .overflow_hidden() + .whitespace_nowrap() + .when(active, |el| el.text_color(rgb(p.text_soft))) + .child(text), + ) + .child(resize_handle(col, anchor_w, view, p, window)), ) } -/// A clickable fixed-width heading for a non-metric column (name, cores), with the sort arrow and an -/// active-column highlight. +/// A clickable fixed-width heading for a non-metric column (name, startup, cores), with the sort +/// arrow, an active-column highlight, and a divider on its right edge. /// /// Args: /// label: Localized column heading. /// field: The sort field this heading selects. -/// width: The matching column width. +/// col: The resizable column this heading labels. +/// width: The matching column width for this frame. +/// anchor_w: The same column's logical width, which the divider drag anchors in. /// sort: The active sort, for the arrow and highlight. /// p: Active Moon palette. /// weak_view: Non-owning panel handle for the sort click. +/// view: Strong panel handle for the divider, when alive. +/// window: Host window, for the drag-move listener. /// /// Returns: /// A fixed-width clickable caption. +// See `metric_sort_header`: the divider needs the column identity and its pre-shrink width on top of +// what a caption already carries. +#[allow(clippy::too_many_arguments)] fn col_sort_header( label: String, field: GroupSortField, + col: ByIpCol, width: f32, + anchor_w: f32, sort: (GroupSortField, bool), p: MoonPalette, weak_view: &WeakEntity, + view: Option<&Entity>, + window: &Window, ) -> impl IntoElement { let text = format!("{label}{}", sort_arrow(field, sort)); let active = sort.0 == field; let weak_view = weak_view.clone(); div() .w(px(width)) + .h_full() .flex_none() - .overflow_hidden() - .whitespace_nowrap() - .cursor_pointer() - .when(active, |el| el.text_color(rgb(p.text_soft))) - .on_mouse_down(MouseButton::Left, move |_, _, app| { - if let Some(view) = weak_view.upgrade() { - view.update(app, |this, cx| this.set_group_sort(field, cx)); - } - }) - .child(text) + .relative() + .flex() + .items_center() + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .cursor_pointer() + .when(active, |el| el.text_color(rgb(p.text_soft))) + .on_mouse_down(MouseButton::Left, move |_, _, app| { + if let Some(view) = weak_view.upgrade() { + view.update(app, |this, cx| this.set_group_sort(field, cx)); + } + }) + .child(text), + ) + .child(resize_handle(col, anchor_w, view, p, window)) } diff --git a/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths.rs b/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths.rs index 507ff31e..a1fd6af8 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths.rs @@ -1,10 +1,24 @@ -//! By-IP column geometry: the base column widths and the ONE factor that shrinks them together. +//! By-IP column geometry: the design widths, the user's own widths, and the ONE factor that +//! shrinks them together. //! //! The By-IP view is a tree, not a `MoonDataTable`, so it draws its own columns at fixed widths — //! a changing number must never reflow the row, and every caption has to land over its values. That //! fixed layout is also why a narrow dock used to clip the right-hand columns instead of shrinking //! them: nothing recomputed the widths. //! +//! # How a user width and the shrink factor coexist +//! +//! A dragged column width is authoritative WHILE THE ROW FITS, and below that it shrinks with +//! everything else by the same shared factor. The two obvious alternatives are both worse: honouring +//! a user width unconditionally lets the columns overflow the panel again, which is the exact bug +//! the shrink was added to fix and which a tree cannot absorb (CONTRIBUTING forbids a panel +//! horizontal scrollbar); rescaling only the user's columns would break the ratio the header and the +//! rows depend on to stay aligned. Shrinking everything by one factor preserves the RELATIVE sizing +//! the user chose, which is what they were actually expressing — the absolute pixels only ever held +//! while there was room for them. [`MAX_COL_W`] is what keeps that promise honest: without a +//! ceiling, one wide drag would drive the factor to [`MIN_SCALE`] and silently halve the other eight +//! columns. +//! //! MoonUI's data table solves the same problem differently (`downscale_columns_to_available` in //! `moon/data_table.rs`): it water-fills columns down to an absolute 40 px floor and sends the //! remainder to a horizontal scrollbar. Neither half fits here — the tree has no horizontal scroll @@ -16,7 +30,9 @@ //! that budget wrong is not cosmetic: too small and the shrink engages late and still overflows, //! too large and it shrinks a row that would have fitted. -/// Widths of the By-IP columns, in raw pixels, plus the two insets that scale with them. +use std::collections::HashMap; + +/// Widths of the By-IP columns and their shrink-coupled row chrome, in raw pixels. #[derive(Clone, Copy, PartialEq, Debug)] pub(super) struct ByIpWidths { /// Server/core name column; the name truncates and the pencil pins to its right edge. @@ -25,14 +41,24 @@ pub(super) struct ByIpWidths { pub(super) ip: f32, pub(super) cpu: f32, pub(super) mem: f32, - /// Both latency columns (client↔core and core→exchange) use this width. + /// Client↔core latency column. + /// + /// Was shared with [`Self::exch`] until the columns became resizable: one width meant the + /// exchange caption had no edge of its own to grab, and dragging either moved both. pub(super) ping: f32, + /// Core→exchange latency column. Same design width as [`Self::ping`], so the default layout is + /// unchanged; they only diverge once a user drags one. + pub(super) exch: f32, /// API-key lifetime cell. Wider than a latency because it holds a word ("истёк") as often as a /// number, and its heading is longer than the others ("АПИ (дн)"). Like every column here it /// still shrinks by the shared factor, so on a very narrow dock the caption clips like the rest. pub(super) api: f32, /// Ready/total core-count cell. pub(super) cores: f32, + /// Reported MoonBot build. Like `cores` and `startup` it carries NO warning-icon lead: a build + /// number has no `WarnAxis` behind it, and this workspace defines no minimum version to warn + /// against, so reserving a lead here would be space that can never light. + pub(super) version: f32, /// Startup-progress cell. Like `cores` it carries NO warning-icon lead: startup has no /// `WarnAxis` behind it, so reserving a lead here would be space that can never light. pub(super) startup: f32, @@ -69,10 +95,19 @@ pub(super) const TREE_SCROLLBAR_W: f32 = 16.0; /// same unit — so the two stay aligned at every font setting instead of at exactly one of them. pub(super) const ROW_INSET_REMS: f32 = 0.75; -/// Number of `ROW_GAP_W` gaps in one row: the outer chevron↔body gap plus the ten between the -/// body's eleven children -/// (name · ip · spacer · cpu · mem · ping · exch · key · startup · cores · dot). -const ROW_GAPS: f32 = 11.0; +/// Number of `ROW_GAP_W` gaps the row's BODY spends: the thirteen between its fourteen children +/// (name · ip · spacer · cpu · mem · ping · exch · key · version · startup · cores · warning slot · +/// dot · scrollbar slot). The outer chevron↔body gap is NOT in here — [`Self::row_chrome`] adds it +/// as its own trailing `ROW_GAP_W` term, so the budget covers fourteen gaps in total. +/// +/// The trailing SCROLLBAR SLOT is a real flex child of the row (`server_view`'s +/// `div().w(px(TREE_SCROLLBAR_W))`), so it brings a gap of its own. `reserved` covers that slot's +/// WIDTH but no gap, and this constant read `11.0` while the row rendered more gaps than that once +/// the warning slot `row_chrome` adds separately is counted — an eight-pixel undercount that made +/// the shrink engage one gap too late and let the row overflow inside that band. The core-version +/// column then added one more child, and therefore one more gap. Count the children in +/// `server_row` before touching this. +const ROW_GAPS: f32 = 13.0; /// Number of `CELL_GAP_W` gaps: one inside each of the five metric cells. const CELL_GAPS: f32 = 5.0; @@ -82,6 +117,150 @@ const CELL_GAPS: f32 = 5.0; /// unreadable slivers. const MIN_SCALE: f32 = 0.5; +/// The columns a user can drag, in visual left-to-right order. +/// +/// `icon` and `indent` are absent on purpose: they are row chrome, not columns, and neither has a +/// header cell to hang a divider off. Every column that a caption labels IS here, including both +/// latency columns separately — they carry the same design width but are not the same column, and +/// a caption a user can see but cannot grab reads as a bug. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum ByIpCol { + Name, + Ip, + Cpu, + Mem, + Ping, + Exch, + Api, + Version, + Startup, + Cores, +} + +impl ByIpCol { + /// Every resizable column, in the order the header lays them out. + pub(super) const ALL: [Self; 10] = [ + Self::Name, + Self::Ip, + Self::Cpu, + Self::Mem, + Self::Ping, + Self::Exch, + Self::Api, + Self::Version, + Self::Startup, + Self::Cores, + ]; + + /// The key this column's width persists under. + /// + /// NEVER rename one of these: they are the map keys inside `layout.table_column_widths`, so a + /// rename silently orphans the width every existing user has already dragged. + pub(super) fn key(self) -> &'static str { + match self { + Self::Name => "name", + Self::Ip => "ip", + Self::Cpu => "cpu", + Self::Mem => "mem", + Self::Ping => "ping", + Self::Exch => "exch", + Self::Api => "api", + Self::Version => "version", + Self::Startup => "startup", + Self::Cores => "cores", + } + } + + /// The element id of this column's divider handle. + /// + /// A `&'static str` rather than a `format!` on [`Self::key`]: the header builds one handle per + /// caption on every repaint, and these ten ids are a fixed set known at compile time. Distinct + /// from `key` on purpose — that one is persisted and must never move, this one is throwaway + /// element identity. + pub(super) fn handle_id(self) -> &'static str { + match self { + Self::Name => "by-ip-resize-name", + Self::Ip => "by-ip-resize-ip", + Self::Cpu => "by-ip-resize-cpu", + Self::Mem => "by-ip-resize-mem", + Self::Ping => "by-ip-resize-ping", + Self::Exch => "by-ip-resize-exch", + Self::Api => "by-ip-resize-api", + Self::Version => "by-ip-resize-version", + Self::Startup => "by-ip-resize-startup", + Self::Cores => "by-ip-resize-cores", + } + } + + /// Read this column's width out of a resolved geometry. + /// + /// Args: + /// w: Resolved or painted By-IP geometry containing this column's width. + /// + /// Returns: + /// The width assigned to this column in `w`. + pub(super) fn width_of(self, w: ByIpWidths) -> f32 { + match self { + Self::Name => w.name, + Self::Ip => w.ip, + Self::Cpu => w.cpu, + Self::Mem => w.mem, + Self::Ping => w.ping, + Self::Exch => w.exch, + Self::Api => w.api, + Self::Version => w.version, + Self::Startup => w.startup, + Self::Cores => w.cores, + } + } + + /// Write this column's width into a geometry being resolved. + /// + /// Args: + /// w: Mutable geometry receiving the width. + /// value: Width to assign to this column. + /// + /// Returns: + /// Nothing. + fn set(self, w: &mut ByIpWidths, value: f32) { + match self { + Self::Name => w.name = value, + Self::Ip => w.ip = value, + Self::Cpu => w.cpu = value, + Self::Mem => w.mem = value, + Self::Ping => w.ping = value, + Self::Exch => w.exch = value, + Self::Api => w.api = value, + Self::Version => w.version = value, + Self::Startup => w.startup = value, + Self::Cores => w.cores = value, + } + } +} + +/// Narrowest a user may drag a column. +/// +/// MIRRORS MoonUI: `MIN_COLUMN_WIDTH` in `moon/data_table.rs`, which is private there, so nothing +/// checks this — if it moves, this must follow by hand. Matching it keeps a By-IP column from +/// reaching a width the flat table would have refused for the same content. +/// +/// It bounds the DRAG, not the painted width. A column dragged to 40 still shrinks with everything +/// else on a narrow panel and can paint at [`MIN_SCALE`] × 40 = 20 px — which is not a new +/// weakness: `cores` is 40 at BASE and already painted at 20 there before any of this was +/// draggable. Giving each column a per-column PAINTED floor is MoonUI's water-fill answer +/// (`downscale_columns_to_available`), and it is deliberately not copied here: it would break the +/// single-shared-factor property that keeps the separate header element aligned over the rows. +pub(super) const MIN_COL_W: f32 = 40.0; + +/// Widest a user may drag a column. +/// +/// Deliberately NOT a MoonUI value: the data table has a horizontal scroller to absorb an over-wide +/// column and this tree has none. The cap is what makes the shrink rule in the module doc a fair +/// deal rather than a trap — see there for why one uncapped drag would resize all eight other +/// columns. 400 is far past the widest design width (150) and still leaves every other column its +/// base width on a panel around 1100 px. +pub(super) const MAX_COL_W: f32 = 400.0; + impl ByIpWidths { /// Design widths, used whenever the row has room for them. pub(super) const BASE: Self = Self { @@ -90,8 +269,10 @@ impl ByIpWidths { cpu: 100.0, mem: 116.0, ping: 64.0, + exch: 64.0, api: 84.0, cores: 40.0, + version: 72.0, startup: 84.0, icon: 12.0, indent: 16.0, @@ -107,9 +288,11 @@ impl ByIpWidths { + self.ip + self.cpu + self.mem - + self.ping * 2.0 + + self.ping + + self.exch + self.api + self.cores + + self.version + self.startup + self.icon * 5.0 } @@ -120,6 +303,10 @@ impl ByIpWidths { /// server row inserts before its status dot. That triangle is reserved unconditionally: it /// appears exactly on the rows whose numbers matter most, and a budget that ignores it would let /// those rows — and only those — overflow. + /// + /// It reads `BASE.icon` rather than a resolved width, and that is correct rather than an + /// oversight: `icon` is row chrome and is NOT in [`ByIpCol`], so no drag can move it and the + /// budget stays constant across every user override. fn row_chrome(rem: f32) -> f32 { let icon = Self::BASE.icon; ROW_INSET_REMS * rem * 2.0 @@ -130,19 +317,60 @@ impl ByIpWidths { + ROW_GAP_W } - /// Widths for a row of `available` pixels. + /// The design widths with the user's dragged widths applied, clamped, BEFORE any shrink. + /// + /// This is the LOGICAL geometry: what the columns would be on a panel wide enough for them. The + /// header's drag handles anchor in it rather than in the painted widths, because anchoring in + /// painted widths would let the shrink factor apply twice and snap the column narrower on the + /// first pixel of a drag. + /// + /// A non-finite stored width is skipped rather than clamped — `f32::clamp` PANICS on NaN, and + /// this map is deserialized from `layout.toml`, a plain file a user can hand-edit, so it is + /// untrusted input rather than something only the drag handler ever writes. Unknown keys are + /// ignored for the same reason. + /// + /// Args: + /// user: Stored per-column widths, keyed by [`ByIpCol::key`]. + /// + /// Returns: + /// The design widths with every recognized override substituted. + pub(super) fn resolved(user: &HashMap) -> Self { + let mut w = Self::BASE; + for col in ByIpCol::ALL { + if let Some(&value) = user.get(col.key()) + && value.is_finite() + { + col.set(&mut w, value.clamp(MIN_COL_W, MAX_COL_W)); + } + } + w + } + + /// Widths for a row of `available` pixels, starting from whatever the user dragged. /// - /// `reserved` is what the row must leave free at its right edge — the status dot and the tree's - /// overlay scrollbar — and `rem` is the window's rem size, which sets the insets. + /// Args: + /// available: Measured row width in pixels; a non-finite or non-positive value means the + /// first frame has not measured the view yet. + /// reserved: Right-edge width reserved for the status dot and overlay scrollbar. + /// rem: Window rem size, which sets the row insets. + /// user: Persisted width bag, keyed by [`ByIpCol::key`]. /// - /// Returns [`Self::BASE`] while the row still fits: the layout keeps a `flex_1` spacer that - /// absorbs any surplus, so growing the columns is not this function's job. A row narrower than - /// its columns gets one shared factor, floored at [`MIN_SCALE`]. + /// Returns: + /// Resolved widths unscaled while the row still fits: the layout keeps a `flex_1` spacer + /// that absorbs any surplus, so growing the columns is not this function's job. A row + /// narrower than its columns gets one shared factor, floored at [`MIN_SCALE`], applied to + /// the RESOLVED widths — the module doc argues why the user's columns shrink with the rest + /// instead of being exempted. /// /// `available <= 0.0` means the view has not been measured yet (the first frame): return the - /// base widths rather than collapsing everything to the floor for one frame. - pub(super) fn for_width(available: f32, reserved: f32, rem: f32) -> Self { - let base = Self::BASE; + /// resolved widths rather than collapsing everything to the floor for one frame. + pub(super) fn for_width( + available: f32, + reserved: f32, + rem: f32, + user: &HashMap, + ) -> Self { + let base = Self::resolved(user); if !available.is_finite() || available <= 0.0 { return base; } @@ -162,21 +390,33 @@ impl ByIpWidths { cpu: self.cpu * k, mem: self.mem * k, ping: self.ping * k, + exch: self.exch * k, api: self.api * k, cores: self.cores * k, + version: self.version * k, startup: self.startup * k, icon: self.icon * k, indent: self.indent * k, } } - /// The narrowest row that still fits the base widths — the width at which shrinking begins. + /// The narrowest row that still fits the resolved widths — the width at which shrinking begins. /// /// Exposed for the tests, which pin the crossover: a wrong chrome budget moves it, and every - /// "does it shrink at all" assertion would still pass. + /// "does it shrink at all" assertion would still pass. It takes the same `user` bag as + /// [`for_width`](Self::for_width) because a dragged column MOVES the crossover, and a crossover + /// test that could not see the overrides would silently stop testing them. + /// + /// Args: + /// reserved: Right-edge width reserved for the status dot and overlay scrollbar. + /// rem: Window rem size, which sets the row insets. + /// user: Persisted width bag, keyed by [`ByIpCol::key`]. + /// + /// Returns: + /// The available row width at which the resolved columns begin to shrink. #[cfg(test)] - pub(super) fn full_width(reserved: f32, rem: f32) -> f32 { - Self::BASE.columns_w() + Self::row_chrome(rem) + reserved + pub(super) fn full_width(reserved: f32, rem: f32, user: &HashMap) -> f32 { + Self::resolved(user).columns_w() + Self::row_chrome(rem) + reserved } } diff --git a/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths/tests.rs index b3511fa5..0038ae3c 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths/tests.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/by_ip_widths/tests.rs @@ -1,22 +1,37 @@ // Explicit imports, NOT `use super::*`: the parent re-exports `gpui::*`, whose own `test` shadows // the built-in attribute and makes `#[test]` expand recursively. -use crate::panels::core_status::by_ip_widths::ByIpWidths; +use std::collections::HashMap; + +use crate::panels::core_status::by_ip_widths::{ByIpWidths, MAX_COL_W, MIN_COL_W}; /// Right-edge reserve of a real row: the status dot plus the tree's overlay scrollbar. const RESERVED: f32 = 5.0 + 16.0; /// Rem size at the shipped defaults (`12 * scale + ui_font_delta`). const REM: f32 = 14.0; +/// An empty user-width bag: the panel as shipped, before anyone has dragged a column. +/// +/// A function rather than a `const` because `HashMap::new` is not usable in a const item here. +fn none() -> HashMap { + HashMap::new() +} + /// An unmeasured view keeps its design widths. /// /// The plausible edit: dropping the `available <= 0.0` guard. The first frame reports no width, so /// every column would collapse to the floor and visibly snap back one frame later. #[test] fn an_unmeasured_row_keeps_the_base_widths() { - assert_eq!(ByIpWidths::for_width(0.0, RESERVED, REM), ByIpWidths::BASE); - assert_eq!(ByIpWidths::for_width(-1.0, RESERVED, REM), ByIpWidths::BASE); assert_eq!( - ByIpWidths::for_width(f32::NAN, RESERVED, REM), + ByIpWidths::for_width(0.0, RESERVED, REM, &none()), + ByIpWidths::BASE + ); + assert_eq!( + ByIpWidths::for_width(-1.0, RESERVED, REM, &none()), + ByIpWidths::BASE + ); + assert_eq!( + ByIpWidths::for_width(f32::NAN, RESERVED, REM, &none()), ByIpWidths::BASE ); } @@ -29,13 +44,13 @@ fn an_unmeasured_row_keeps_the_base_widths() { /// pixel narrower already shrinks. #[test] fn shrinking_starts_exactly_where_the_row_stops_fitting() { - let full = ByIpWidths::full_width(RESERVED, REM); + let full = ByIpWidths::full_width(RESERVED, REM, &none()); assert_eq!( - ByIpWidths::for_width(full + 1.0, RESERVED, REM), + ByIpWidths::for_width(full + 1.0, RESERVED, REM, &none()), ByIpWidths::BASE ); assert!( - ByIpWidths::for_width(full - 1.0, RESERVED, REM).name < ByIpWidths::BASE.name, + ByIpWidths::for_width(full - 1.0, RESERVED, REM, &none()).name < ByIpWidths::BASE.name, "one pixel below the requirement must already shrink" ); } @@ -46,14 +61,14 @@ fn shrinking_starts_exactly_where_the_row_stops_fitting() { /// tests would notice, and the status dot would end up under the tree's scrollbar again. #[test] fn a_larger_right_edge_reserve_shrinks_the_row_sooner() { - let full = ByIpWidths::full_width(RESERVED, REM); + let full = ByIpWidths::full_width(RESERVED, REM, &none()); assert_eq!( - ByIpWidths::for_width(full, RESERVED, REM), + ByIpWidths::for_width(full, RESERVED, REM, &none()), ByIpWidths::BASE, "this width fits with the small reserve" ); assert!( - ByIpWidths::for_width(full, RESERVED + 40.0, REM).name < ByIpWidths::BASE.name, + ByIpWidths::for_width(full, RESERVED + 40.0, REM, &none()).name < ByIpWidths::BASE.name, "the same width must not fit once more is reserved" ); } @@ -64,9 +79,9 @@ fn a_larger_right_edge_reserve_shrinks_the_row_sooner() { /// default font and silently overflows once the "Шрифт" slider moves. #[test] fn a_larger_rem_shrinks_the_row_sooner() { - let full = ByIpWidths::full_width(RESERVED, REM); + let full = ByIpWidths::full_width(RESERVED, REM, &none()); assert!( - ByIpWidths::for_width(full, RESERVED, REM + 4.0).name < ByIpWidths::BASE.name, + ByIpWidths::for_width(full, RESERVED, REM + 4.0, &none()).name < ByIpWidths::BASE.name, "a wider inset must eat into the same available width" ); } @@ -75,7 +90,7 @@ fn a_larger_rem_shrinks_the_row_sooner() { #[test] fn a_wide_row_keeps_the_base_widths() { assert_eq!( - ByIpWidths::for_width(4000.0, RESERVED, REM), + ByIpWidths::for_width(4000.0, RESERVED, REM, &none()), ByIpWidths::BASE ); } @@ -87,7 +102,7 @@ fn a_wide_row_keeps_the_base_widths() { #[test] fn a_narrow_row_shrinks_every_column_by_the_same_factor() { let base = ByIpWidths::BASE; - let narrow = ByIpWidths::for_width(500.0, RESERVED, REM); + let narrow = ByIpWidths::for_width(500.0, RESERVED, REM, &none()); assert!(narrow.name < base.name, "the name column must shrink"); let k = narrow.name / base.name; @@ -112,9 +127,75 @@ fn a_narrow_row_shrinks_every_column_by_the_same_factor() { /// The shrink stops at the floor instead of collapsing the columns to nothing. #[test] fn an_extremely_narrow_row_stops_at_the_floor() { - let floored = ByIpWidths::for_width(60.0, RESERVED, REM); + let floored = ByIpWidths::for_width(60.0, RESERVED, REM, &none()); assert!( (floored.name - ByIpWidths::BASE.name * 0.5).abs() < 0.01, "below the floor the row clips instead of shrinking further" ); } + +/// `by_ip_widths.rs:ByIpWidths::resolved` and `for_width` must preserve valid dragged widths. +/// +/// The plausible edit replaces `Self::resolved(user)` with `Self::BASE` in `for_width`; a user's +/// width would then disappear as soon as the row paints, so the divider snaps back and the header +/// loses its intended proportional layout on a narrow dock. +#[test] +fn persisted_widths_are_sanitized_and_drive_the_shared_shrink_factor() { + let mut stored = HashMap::from([ + ("name".to_owned(), ByIpWidths::BASE.name + 50.0), + ("ip".to_owned(), MIN_COL_W - 1.0), + ("cpu".to_owned(), MAX_COL_W + 1.0), + ("mem".to_owned(), f32::NAN), + ("ping".to_owned(), f32::INFINITY), + ("obsolete-column".to_owned(), 123.0), + ]); + + let resolved = ByIpWidths::resolved(&stored); + assert_eq!(resolved.name, ByIpWidths::BASE.name + 50.0); + assert_eq!(resolved.ip, MIN_COL_W); + assert_eq!(resolved.cpu, MAX_COL_W); + assert_eq!(resolved.mem, ByIpWidths::BASE.mem); + assert_eq!(resolved.ping, ByIpWidths::BASE.ping); + assert_eq!(resolved.exch, ByIpWidths::BASE.exch); + + let dragged = HashMap::from([("name".to_owned(), ByIpWidths::BASE.name + 50.0)]); + let base_full = ByIpWidths::full_width(RESERVED, REM, &none()); + let overridden_full = ByIpWidths::full_width(RESERVED, REM, &dragged); + assert_eq!(overridden_full - base_full, 50.0); + assert_eq!( + ByIpWidths::for_width(overridden_full, RESERVED, REM, &dragged), + ByIpWidths::resolved(&dragged), + "a row that fits must leave the resolved widths unscaled" + ); + + let narrow = ByIpWidths::for_width(overridden_full - 100.0, RESERVED, REM, &dragged); + let dragged_resolved = ByIpWidths::resolved(&dragged); + assert!( + narrow.name < dragged_resolved.name, + "the chosen row must require shrinking" + ); + assert!( + ((narrow.name / narrow.ip) - (dragged_resolved.name / dragged_resolved.ip)).abs() < 0.01, + "the user-selected ratio must survive the shared shrink factor" + ); + + stored.insert("name".to_owned(), 9999.0); + assert_eq!(ByIpWidths::resolved(&stored).name, MAX_COL_W); +} + +/// `by_ip_widths.rs:ByIpWidths::columns_w` must count `ping` and `exch` independently. +/// +/// The plausible edit reuses `ping` for the exchange term; dragging one latency header would then +/// silently change both widths and the row would reserve the wrong amount of horizontal space. +#[test] +fn ping_and_exchange_widths_remain_independent_in_the_row_total() { + let user = HashMap::from([("ping".to_owned(), 73.0), ("exch".to_owned(), 149.0)]); + let resolved = ByIpWidths::resolved(&user); + assert_eq!(resolved.ping, 73.0); + assert_eq!(resolved.exch, 149.0); + + let base_full = ByIpWidths::full_width(RESERVED, REM, &none()); + let overridden_full = ByIpWidths::full_width(RESERVED, REM, &user); + let expected_delta = (73.0 - ByIpWidths::BASE.ping) + (149.0 - ByIpWidths::BASE.exch); + assert_eq!(overridden_full - base_full, expected_delta); +} 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 96137a6b..9d217fdd 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/cache.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/cache.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use gpui::*; use super::model::{self, CoreStatusRow, ServerKey, aggregate_servers}; -use super::ordering::{assign_server_names, compare_flat_rows, compare_groups, natural_cmp}; +use super::ordering::{self, assign_server_names, compare_flat_rows, compare_groups, natural_cmp}; use super::{CoreStatusView, server_view}; use crate::Backend; use moon_core::feed::ConnStatus; @@ -65,6 +65,10 @@ impl CoreStatusView { // in this frame agrees about the same key. api_key: model::ApiKeyState::of(api_expiry, now_ms), api_warn: b.warn.core_api_warn(id), + // Straight off the same store record every other field above reads: the store + // drops it on any non-Ready status, so a row can never show a build the current + // connection did not report. + server_version: core.and_then(|core| core.server_version), }); } out @@ -190,4 +194,26 @@ impl CoreStatusView { } out } + + /// Build the Flat presentation: rows in their final order, plus the exchange lines drawn over + /// them. + /// + /// Everything returned is OWNED. [`moon_ui::MoonDataTable`]'s row closure is `'static`, so it + /// cannot hold a `&CoreVenue` borrowed out of the session's venue map; resolving the sections + /// here rather than at the call site is what keeps that borrow from ever reaching the closure. + /// + /// Args: + /// cx: Application context used to read the session's venue map. + /// + /// Returns: + /// The sorted rows, and the heading/member lines addressing them by index. + pub(super) fn flat_view( + &self, + cx: &App, + ) -> (Rc>, Rc>) { + let rows = self.sorted_flat_rows(&self.cached_rows); + let venues = self.backend.read(cx).session.core_venues(); + let lines = ordering::flat_lines(&rows, venues); + (Rc::new(rows), Rc::new(lines)) + } } diff --git a/crates/moon-ui-gpui/src/panels/core_status/config_popup.rs b/crates/moon-ui-gpui/src/panels/core_status/config_popup.rs index 03b5ccaf..04c8ef78 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/config_popup.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/config_popup.rs @@ -20,29 +20,144 @@ use crate::design; use crate::panels::common::{RadioMark, popup_close_button, popup_title, radio_items}; use moon_core::config::layout::{WarnAxesCfg, WarnParams}; -// Fixed column widths (UI px) so every control lines up in a table down the rows. The widest row -// (a latency axis: 4 params) sets the total; shorter rows pad empty param slots so their sound column -// still lines up. Gap between columns is `COL_GAP`. +// Design-reference column widths, at UI scale 1.0 and Font-slider delta 0, so every control lines +// up in a table down the rows. The widest row (a latency axis: 4 params) sets the total; shorter +// rows pad empty param slots so their sound column still lines up. Gap between columns is +// `COL_GAP`. Nothing here is a rendered pixel — every one of them goes through +// [`WarnCfgMetrics`], which is the only place a scale is applied. const NAME_W: f32 = 138.0; const ON_W: f32 = 30.0; const CHART_W: f32 = 34.0; const PARAM_W: f32 = 66.0; const SOUND_W: f32 = 128.0; const COL_GAP: f32 = 6.0; -/// Fixed caption-row and control-row heights, so every column's caption and control line up on the -/// same two baselines down the whole table (the checkboxes, steppers and the sound row all centre in -/// the control band). +/// Design-reference caption-row and control-row heights, so every column's caption and control line +/// up on the same two baselines down the whole table (the checkboxes, steppers and the sound row all +/// centre in the control band). const CAP_H: f32 = 12.0; const CTRL_H: f32 = 28.0; /// The most threshold columns any row has (the latency axes: yellow, red, window, hold). const MAX_PARAMS: usize = 4; -/// Popover CONTENT width: the eight columns (name, on, chart, four params, sound) plus the seven -/// gaps between them plus a small margin, so nothing wraps to a second line. +/// One ROW: the eight columns (name, on, chart, four params, sound) plus the seven gaps between +/// them. Derived from the column constants, never restated as a literal — the trailing controls +/// ended up outside the box the last time two numbers had to agree and only one of them changed. +const WARN_CFG_ROW_W: f32 = + NAME_W + ON_W + CHART_W + MAX_PARAMS as f32 * PARAM_W + SOUND_W + 7.0 * COL_GAP; +/// Slack over the row width. /// -/// `MoonPopover::content_width_ui` adds its own padding and border around this, and the content no -/// longer pads itself, so no term here accounts for popup chrome. -const WARN_CFG_W: f32 = - NAME_W + ON_W + CHART_W + MAX_PARAMS as f32 * PARAM_W + SOUND_W + 7.0 * COL_GAP + 16.0; +/// The sound column's `MoonDropdown` resolves its trigger and menu as `(width * text_scale).max( +/// minimum readable)`, so its real width can exceed what was asked for on a long localized label. +/// This is the margin that keeps the ▶ button inside the box when it does. +const ROW_SLACK: f32 = 16.0; +/// Ratio between a rendered font size and the line box GPUI lays it out in. +/// +/// Raw GPUI text with no explicit line height gets its font's own, a little over 1.25x the size. +/// A fixed band shorter than that clips the glyphs rather than overflowing visibly, which is why +/// the two band heights below take it as a floor. +const LINE_BOX: f32 = 1.3; +/// MoonUI's own HEIGHT metrics for a `MoonButtonSize::Action` control — the size the sound column's +/// `MoonDropdown` trigger runs at, and the TALLEST thing standing in the control band. +/// +/// Copied from `MoonButtonMetrics::base_for_size(Size::Small)`, which is what a `MoonButton` of that +/// size actually resolves through: `height: 26`, `line_height: 14`, and a vertical pad its `scaled()` +/// derives as `(height - line_height) * 0.5`. Do NOT take these from `button_text_metrics(Action)` +/// instead — that returns `(font_size, line_height, GAP)` for the label, whose third field is a +/// HORIZONTAL icon gap, and its `(26, 16, 5)` only happens to give the same answer because +/// `16 + 2*5 == 14 + 2*6`; it would stop agreeing the moment MoonUI retunes either function. +/// +/// The height resolves as `fit_height(base, line, pad) = max(ui(base), line_height(line) + +/// 2 * ui(pad))`, and `line_height` is ADDITIVE in the Font-slider delta — so the trigger is 26px at +/// delta 0, 28px at the shipped +2 and 32px at +6, past `CTRL_H` exactly where the goal says nothing +/// may clip. Deriving the floor from MoonUI's own contract rather than from a generic text ratio is +/// what makes the band track the CONTROL instead of merely the text beside it. +const ACTION_H: f32 = 26.0; +const ACTION_LINE_H: f32 = 14.0; +const ACTION_PAD_Y: f32 = (ACTION_H - ACTION_LINE_H) * 0.5; + +/// Every rendered dimension of the alert table, resolved once from the active scales. +/// +/// The popup mixes two scales that MoonUI moves independently: its captions and values are raw +/// GPUI text on the FONT scale (`design::t_caption` / `design::t_body`, which grow with the Font +/// slider AND its `ui_font_delta`, +2 at the shipped default), while its `MoonCheckbox`, +/// `MoonButton` steppers and the ▶ box are MoonUI widgets on the UI scale. A column sized on one +/// of the two is wrong whenever the other is larger: on the UI scale the text outgrew its column +/// at the stock config, and on the font scale a widened UI would push the widgets out. +/// +/// So every HORIZONTAL length takes `max(ui, font_width)` — wide enough for whichever occupant is +/// bigger — and the popover is told the result in ALREADY-RENDERED pixels +/// (`MoonPopover::content_width`) rather than through `content_width_ui` or `content_width_font`, +/// because neither single-scale policy can bound a row that holds both. +/// +/// [`Self::resolve`] is pure and takes no context, so the geometry is testable without a window. +#[derive(Clone, Copy, Debug, PartialEq)] +struct WarnCfgMetrics { + /// The one scale every horizontal design-reference length is multiplied by. + w_scale: f32, + /// Rendered height of the caption band. + cap_h: f32, + /// Rendered height of the control band. + ctrl_h: f32, +} + +impl WarnCfgMetrics { + /// Resolve the table's geometry for one pair of active scales. + /// + /// Args: + /// ui: The UI geometry scale (`MoonThemeTokens::ui(1.0)`). + /// font_w: The font WIDTH scale (`MoonThemeTokens::font_width_scale`), which is the pure + /// multiply `MoonPopover` itself uses — not the ADDITIVE `font()` used for text sizes. + /// cap_px: The rendered caption font size. + /// body_px: The rendered body font size. + /// action_h: The rendered height of a `MoonButtonSize::Action` control, from MoonUI's own + /// fit-height rule — see [`ACTION_H`]. The control band can never be shorter than the + /// tallest control standing in it. + /// + /// Returns: + /// The resolved metrics. + fn resolve(ui: f32, font_w: f32, cap_px: f32, body_px: f32, action_h: f32) -> Self { + Self { + w_scale: ui.max(font_w).max(0.25), + cap_h: (CAP_H * ui).max(cap_px * LINE_BOX), + ctrl_h: (CTRL_H * ui).max(body_px * LINE_BOX).max(action_h), + } + } + + /// Scale one design-reference horizontal length into rendered pixels. + fn w(self, base: f32) -> Pixels { + px(base * self.w_scale) + } + + /// The rendered width one row occupies: its eight columns and the seven gaps between them. + fn row_w(self) -> f32 { + WARN_CFG_ROW_W * self.w_scale + } + + /// The rendered CONTENT width the popover is told to paint: the row plus [`ROW_SLACK`]. + /// + /// Built ON TOP of [`Self::row_w`] rather than beside it, so the box and the row it has to + /// hold cannot drift apart — `MoonPopover` adds its own padding and border around this, and the + /// content root pads itself with nothing. + fn content_w(self) -> f32 { + self.row_w() + ROW_SLACK * self.w_scale + } +} + +/// Read the active scales and resolve [`WarnCfgMetrics`]. +/// +/// Args: +/// cx: Application context used to read active theme tokens. +/// +/// Returns: +/// The table geometry for the current theme, font slider and UI scale. +fn warn_cfg_metrics(cx: &App) -> WarnCfgMetrics { + WarnCfgMetrics::resolve( + design::ui_value(cx, 1.0), + design::font_scale(cx), + f32::from(design::t_caption(cx)), + f32::from(design::t_body(cx)), + design::fit_h_value(cx, ACTION_H, ACTION_LINE_H, ACTION_PAD_Y), + ) +} /// One row's "show on chart" state and its writer, or `None` for an axis that cannot be drawn on a /// chart at all — which leaves that column EMPTY rather than showing a checkbox whose state would @@ -95,10 +210,15 @@ impl CoreStatusView { .render(); let mut popover = MoonPopover::new("core-status-warn-popover") - // Open ABOVE the gear (right edge aligned to it, growing left), so the wide panel sits - // over the content area and the gear stays visible below it. - .placement(MoonPopoverPlacement::TopEnd) - .content_width_ui(WARN_CFG_W) + // Open BELOW the gear, right edge aligned to it so the wide panel grows left into the + // strip rather than off the right edge of the window. Downward is what the gear's own + // position asks for: it sits on the tab strip at the TOP of the panel, so a popup above + // it covered the chrome and read as detached from the control that opened it. + .placement(MoonPopoverPlacement::BottomEnd) + // Already-rendered pixels, resolved from the same metrics the rows lay themselves out + // with — see `WarnCfgMetrics` for why neither `content_width_ui` nor + // `content_width_font` can bound this row on its own. + .content_width(warn_cfg_metrics(cx).content_w()) .close_on_content_click(false) // A `MoonDropdown` menu inside this popover paints in its OWN deferred layer, outside // this popover's box, and `on_mouse_down_out` is bounds-based and runs in the CAPTURE @@ -339,7 +459,7 @@ impl CoreStatusView { h_flex() .w_full() .items_start() - .gap(design::ui_px(cx, COL_GAP)) + .gap(warn_cfg_metrics(cx).w(COL_GAP)) .py(design::ui_px(cx, 5.0)) // Only a separator BETWEEN rows — the first row has no line above it. .when(!first, |el| el.border_t_1().border_color(rgb(p.border))) @@ -387,7 +507,7 @@ impl CoreStatusView { // Pad the missing threshold columns so the sound column lines up across every row. .children((n_params..MAX_PARAMS).map(|_| { div() - .w(design::ui_px(cx, PARAM_W)) + .w(warn_cfg_metrics(cx).w(PARAM_W)) .flex_none() .into_any_element() })) @@ -410,13 +530,14 @@ impl CoreStatusView { cx: &Context, ) -> impl IntoElement { let p = MoonPalette::active(cx); + let m = warn_cfg_metrics(cx); v_flex() - .w(design::ui_px(cx, width)) + .w(m.w(width)) .flex_none() .gap(design::ui_px(cx, 3.0)) .child( div() - .h(design::ui_px(cx, CAP_H)) + .h(px(m.cap_h)) .w_full() .flex() .items_center() @@ -429,7 +550,7 @@ impl CoreStatusView { ) .child( div() - .h(design::ui_px(cx, CTRL_H)) + .h(px(m.ctrl_h)) .w_full() .flex() .items_center() @@ -539,10 +660,11 @@ impl CoreStatusView { let play_name = cur; let p = MoonPalette::active(cx); let enabled = play_name.is_some(); + let m = warn_cfg_metrics(cx); h_flex() .w_full() .items_center() - .gap(design::ui_px(cx, 4.0)) + .gap(m.w(4.0)) .child( MoonDropdown::new(SharedString::from(format!("cs-warn-{id}-snd"))) .label(label) @@ -558,7 +680,9 @@ impl CoreStatusView { .child( div() .id(SharedString::from(format!("cs-warn-{id}-play"))) - .size(design::ui_px(cx, CTRL_H)) + // Square, and its side IS the control band, so it can never outgrow the row it + // sits in nor leave a gap under it. + .size(px(m.ctrl_h)) .flex_none() .flex() .items_center() @@ -712,3 +836,6 @@ fn chart_checkbox( }); }) } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/panels/core_status/config_popup/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/config_popup/tests.rs new file mode 100644 index 00000000..feb5379f --- /dev/null +++ b/crates/moon-ui-gpui/src/panels/core_status/config_popup/tests.rs @@ -0,0 +1,75 @@ +//! Regression tests for the Core Status alert-configuration popover metrics. + +use crate::panels::core_status::config_popup::WarnCfgMetrics; + +/// `config_popup.rs:WarnCfgMetrics::resolve` must retain `ui.max(font_w)` for horizontal sizing; +/// simplifying it to `font_w` lets UI-scaled checkboxes, steppers, and the play button overflow +/// their columns when the UI scale is wider than the font scale. +#[test] +fn ui_scale_controls_set_the_horizontal_extent() { + let metrics = WarnCfgMetrics::resolve(1.5, 1.0, 9.0, 11.0, 39.0); + + // 636 is the independently specified base row width (eight columns plus seven gaps), and the + // popover adds 16px of slack before the same UI-or-font scale is applied. + assert!( + (metrics.row_w() - 954.0).abs() < 0.01, + "the UI-dominant row must be 636 * 1.5 px, not font-width-sized" + ); + assert!( + (metrics.content_w() - 978.0).abs() < 0.01, + "the popover content must retain its independently specified 16px scaled slack" + ); +} + +/// `config_popup.rs:WarnCfgMetrics::resolve` must retain `.max(action_h)` in `ctrl_h`; dropping it +/// clips the 32px Action-sized sound dropdown at Font-slider +6 inside a 28px control band. +#[test] +fn font_deltas_keep_the_control_band_as_tall_as_the_action_dropdown() { + let cases = [ + // (font-width scale, caption px, body px, Action height, row px, content px, caption band, + // control band). These are derived from the published font-slider contract, not from the + // metrics implementation: 636px row plus 16px slack at delta 0, then font-width scaling. + (1.0, 9.0, 11.0, 26.0, 636.0, 652.0, 12.0, 28.0), + ( + 13.0 / 11.0, + 11.0, + 13.0, + 28.0, + 751.63635, + 770.5455, + 14.3, + 28.0, + ), + ( + 17.0 / 11.0, + 15.0, + 17.0, + 32.0, + 982.9091, + 1007.63635, + 19.5, + 32.0, + ), + ]; + + for (font_w, cap_px, body_px, action_h, row_w, content_w, cap_h, ctrl_h) in cases { + let metrics = WarnCfgMetrics::resolve(1.0, font_w, cap_px, body_px, action_h); + + assert!( + (metrics.row_w() - row_w).abs() < 0.01, + "row width must fit its columns" + ); + assert!( + (metrics.content_w() - content_w).abs() < 0.01, + "content width must fit the row and its slack" + ); + assert!( + (metrics.cap_h - cap_h).abs() < 0.01, + "caption band must fit its font line box" + ); + assert!( + (metrics.ctrl_h - ctrl_h).abs() < 0.01, + "control band must fit the Action dropdown at every required font delta" + ); + } +} 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 c3877086..6976230c 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/interactions.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/interactions.rs @@ -7,11 +7,98 @@ use std::collections::HashSet; use gpui::*; use moon_ui::{MoonInputEvent, MoonInputState}; +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 moon_core::session::CoreId; impl CoreStatusView { + /// Record where a By IP header divider drag began. + /// + /// Called once per drag, from the handle's `on_drag` constructor, which GPUI runs a single time + /// after the drag threshold is crossed. The anchor is in LOGICAL (pre-shrink) width space — see + /// [`Self::drag_by_ip_col`] for why that matters. + /// + /// Args: + /// anchor: The column, the pointer x, and the column's logical width at the grab. + /// + /// Returns: + /// Nothing; no repaint, because nothing has moved yet. + pub(super) fn begin_by_ip_resize(&mut self, anchor: ByIpDragAnchor) { + self.by_ip_drag = Some(anchor); + } + + /// Apply a live By IP divider drag: the anchored width plus the pointer's travel since the grab. + /// + /// Deliberately NOT `pointer_x - cell_origin_x`, which is how MoonUI's data table does it. The + /// By IP header puts a `flex_1` spacer between IP and CPU, so every column right of it is + /// right-anchored: widening one moves its OWN left edge left by the same amount, and the next + /// event measures against the moved origin. That loop triples the sensitivity per frame. An + /// anchor captured once at the grab is immune to relayout. + /// + /// The anchor is the LOGICAL width, so on a shrunk panel the column tracks the pointer at the + /// shrink factor's speed rather than 1:1. That is the correct trade: anchoring on the PAINTED + /// width would write back an already-scaled value, the resolver would scale it a second time, + /// and the column would jump narrower on the first pixel of the drag. + /// + /// Args: + /// col: Column whose divider is being dragged. + /// pointer_x: Current pointer x, in window pixels. + /// cx: View context; the width bag's observer persists and repaints. + /// + /// Returns: + /// Nothing. A drag for a different column than the live anchor, or with no anchor at all, is + /// ignored rather than guessed at. + pub(super) fn drag_by_ip_col(&mut self, col: ByIpCol, pointer_x: f32, cx: &mut Context) { + let Some(anchor) = self.by_ip_drag else { + return; + }; + if anchor.col != col { + return; + } + let width = (anchor.width + (pointer_x - anchor.mouse_x)).clamp(MIN_COL_W, MAX_COL_W); + self.by_ip_col_widths.update(cx, |state, cx| { + if state.column_widths.get(col.key()).copied() == Some(width) { + return; + } + state.set_column_width(col.key(), width); + cx.notify(); + }); + } + + /// Restore automatic width for one By IP column, or for every one of them. + /// + /// Mirrors the `MoonDataTable` divider gesture so the two views answer the same input: a plain + /// double-click drops this column back to its design width, Shift+double-click drops all of them + /// (the toolbar button's equivalent, and the only route to it in a detached window — see + /// `toolbar_buttons`). + /// + /// An already-clear bag changes nothing and must NOT notify: the observer would otherwise arm + /// `layout_dirty` and schedule a layout write for a double-click that did nothing. + /// + /// Args: + /// col: Column to reset when `all` is false. + /// all: Reset every column instead of just `col`. + /// cx: View context; the width bag's observer persists and repaints. + /// + /// Returns: + /// Nothing. + pub(super) fn reset_by_ip_col(&mut self, col: ByIpCol, all: bool, cx: &mut Context) { + self.by_ip_col_widths.update(cx, |state, cx| { + let changed = if all { + let had_any = !state.column_widths.is_empty(); + state.column_widths.clear(); + had_any + } else { + state.column_widths.remove(col.key()).is_some() + }; + if changed { + cx.notify(); + } + }); + } + /// Switch the detached-window chart span and repaint. /// /// Args: @@ -246,28 +333,23 @@ impl CoreStatusView { } } - /// Toggle the momentary IP reveal for one server. + /// Hide or show the whole By-IP address column. + /// + /// One state for the whole column, not one per server: a fleet of servers is masked or shown in + /// a single click, and the panel does not have to hold — or leak — which rows a user happened to + /// open. The state is transient and never persisted, so it lasts only as long as this panel. /// - /// Revealing focuses the panel so the [`Context::on_blur`] handler re-masks the IP when focus - /// later leaves the panel. Hiding is immediate. + /// Deliberately NOT tied to focus. An earlier per-row reveal was cleared by the panel's blur, + /// and a docked panel loses focus on nearly any click, so an address vanished as fast as it + /// appeared. /// /// Args: - /// key: Server identity whose IP should be shown or hidden. - /// window: Host window used to move focus to the panel on reveal. /// cx: View context used to repaint. /// /// Returns: - /// Nothing; the reveal set is transient and never persisted. - pub(super) fn toggle_reveal( - &mut self, - key: ServerKey, - window: &mut Window, - cx: &mut Context, - ) { - if !self.revealed_ips.remove(&key) { - self.revealed_ips.insert(key); - window.focus(&self.focus, cx); - } + /// Nothing; the mask flag is transient and never persisted. + pub(super) fn toggle_ip_mask(&mut self, cx: &mut Context) { + self.ip_masked = !self.ip_masked; cx.notify(); } @@ -343,17 +425,28 @@ impl CoreStatusView { cx.notify(); } - /// Switch between grouped and flat presentations. + /// Switch the Core Status presentation. + /// + /// The choice is remembered per host context, so this panel reopens in the same mode after a + /// dock rebuild or a restart; a docked tab and a detached window keep their own selections. /// /// Args: /// mode: Requested presentation. /// cx: View context used to repaint. /// /// Returns: - /// Nothing; data caches and visibility state are retained. + /// Nothing; the changed mode is persisted while data caches and visibility state are retained. pub(super) fn set_mode(&mut self, mode: CoreStatusMode, cx: &mut Context) { if self.mode != mode { self.mode = mode; + // 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( + &self.backend, + &super::mode_ctx_id(self.detached), + mode.code(), + cx, + ); cx.notify(); } } diff --git a/crates/moon-ui-gpui/src/panels/core_status/ip_cell.rs b/crates/moon-ui-gpui/src/panels/core_status/ip_cell.rs new file mode 100644 index 00000000..ca42edd0 --- /dev/null +++ b/crates/moon-ui-gpui/src/panels/core_status/ip_cell.rs @@ -0,0 +1,77 @@ +//! What one By-IP address cell shows, resolved away from the renderer. +//! +//! The cell has THREE states and they mean different things, which is the whole reason this is a +//! module and not an `if` inside the row: the terminal knows the address and shows it, it knows +//! the address and the user has hidden the column, or it has no address at all. The renderer used +//! to collapse the last two into "draw nothing" — an unknown endpoint got an EMPTY fixed-width +//! slot with no glyph and no control — so a server the terminal could not name was indistinguishable +//! from a panel that had failed to draw. That is the question this module exists to answer. +//! +//! Masking is a PANEL-WIDE, transient user act rather than a per-server one, and it is not the +//! default. A view whose entire purpose is "По IP" shows addresses; hiding them is something the +//! user does deliberately before sharing a screen, from the one control in the column header. +//! Nothing here is persisted, so a fresh panel always comes up showing addresses. +//! +//! The length-hiding property of the mask lives with the mask CONSTANT in the renderer, not here: +//! this module decides only WHICH state a cell is in. + +use std::net::IpAddr; + +/// What one server's IP cell shows this frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum IpCell { + /// The terminal knows the address and the column is unmasked. + Shown(IpAddr), + /// The terminal knows the address and the user has masked the column. + Masked, + /// No endpoint has reached the store for this server, so there is no address to show or hide. + Unknown, +} + +/// Resolve what one server's IP cell shows. +/// +/// `masked` is panel-wide, so it can only ever hide an address that EXISTS. A server with no +/// address stays [`IpCell::Unknown`] whether or not the column is masked, and that precedence is +/// load-bearing: masking an unknown address into a run of asterisks would have the panel claim an +/// address it does not have, which is a worse version of the bug this replaces. +/// +/// Args: +/// address: The server's shared endpoint address, when one has reached the store. +/// masked: Whether the user has hidden the whole IP column. +/// +/// Returns: +/// The state this frame's cell renders. +pub(super) fn ip_cell(address: Option, masked: bool) -> IpCell { + match address { + None => IpCell::Unknown, + Some(_) if masked => IpCell::Masked, + Some(address) => IpCell::Shown(address), + } +} + +/// What the column's ONE mask control offers, given the current state. +/// +/// Pure, and deliberately kept out of the button that renders it, because this pairing is the one +/// place in the panel where a wrong answer is invisible. The per-row control this replaced had the +/// OPPOSITE default — it showed a struck-through eye while the address was hidden — so anyone +/// porting that code forward, or reading it in history, writes the inverse pairing. The result +/// compiles, renders and screenshots cleanly while telling the user the exact opposite of the +/// truth, and nothing downstream can catch it. Here, a test can. +/// +/// Args: +/// masked: Whether the address column is currently hidden. +/// +/// Returns: +/// The icon asset path and the locale key of its tooltip, in that order. The control names +/// what a click DOES, not what the column currently is: while nothing is hidden it offers to +/// hide, and only once the column is masked does it offer to show. +pub(super) fn mask_affordance(masked: bool) -> (&'static str, &'static str) { + if masked { + ("icons/eye.svg", "core_status.show_ip") + } else { + ("icons/eye-off.svg", "core_status.hide_ip") + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/panels/core_status/ip_cell/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/ip_cell/tests.rs new file mode 100644 index 00000000..4a0d79f9 --- /dev/null +++ b/crates/moon-ui-gpui/src/panels/core_status/ip_cell/tests.rs @@ -0,0 +1,50 @@ +//! Regression proofs for the By-IP cell resolver and its column-control affordance. + +use std::net::{IpAddr, Ipv4Addr}; + +use super::{IpCell, ip_cell, mask_affordance}; + +/// Documentation-range endpoint used to cover address-present resolver states safely. +const ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 17)); + +/// `ip_cell.rs:ip_cell` must keep `None` ahead of a panel-wide mask; testing `masked` first would +/// render an unknown endpoint as `************`, falsely claiming the panel has an address to hide. +#[test] +fn an_unknown_endpoint_stays_unknown_while_the_column_is_masked() { + assert_eq!(ip_cell(None, true), IpCell::Unknown); +} + +/// `ip_cell.rs:ip_cell` must retain its unmasked present-address arm; returning `Unknown` here +/// would make a known server address disappear from the By-IP column. +#[test] +fn a_present_endpoint_is_shown_while_the_column_is_unmasked() { + assert_eq!(ip_cell(Some(ADDRESS), false), IpCell::Shown(ADDRESS)); +} + +/// `ip_cell.rs:ip_cell` must retain its masked present-address arm; showing the address here would +/// reveal every known endpoint when the user has hidden the column for screen sharing. +#[test] +fn a_present_endpoint_is_masked_while_the_column_is_masked() { + assert_eq!(ip_cell(Some(ADDRESS), true), IpCell::Masked); +} + +/// `ip_cell.rs:ip_cell` must retain its unmasked absent-address arm; masking the missing value +/// here would falsely imply that an unknown endpoint has an address to reveal. +#[test] +fn an_unknown_endpoint_stays_unknown_while_the_column_is_unmasked() { + assert_eq!(ip_cell(None, false), IpCell::Unknown); +} + +/// `ip_cell.rs:mask_affordance` must not swap the two branch tuples; a swapped pair makes the only +/// column control offer the opposite action to every server in the panel. +#[test] +fn the_mask_control_names_the_action_that_its_click_will_take() { + assert_eq!( + mask_affordance(false), + ("icons/eye-off.svg", "core_status.hide_ip") + ); + assert_eq!( + mask_affordance(true), + ("icons/eye.svg", "core_status.show_ip") + ); +} 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 40dcb9a2..f25fe9aa 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/mod.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/mod.rs @@ -6,8 +6,8 @@ //! //! Like the Assets panel, it is scoped to a window group and can live in a dock //! tab or a detached window. [`crate::persistence::table_persist`] stores separate column -//! widths for `:dock` and `:win`. This module owns data and lifecycle; [`server_view`] -//! and [`table`] own the two presentations. +//! widths and a separate remembered mode choice for `:dock` and `:win`. This module owns data and +//! lifecycle; [`server_view`] and [`table`] own the two presentations. mod by_ip_header; mod by_ip_widths; @@ -15,6 +15,7 @@ mod cache; mod chart; mod config_popup; mod interactions; +mod ip_cell; mod model; mod ordering; mod presentation; @@ -34,14 +35,14 @@ use std::rc::Rc; use gpui::*; use moon_ui::{ - DockArea, MoonDataTableState, MoonInputState, MoonPalette, MoonSegmentItem, - MoonSegmentedControl, MoonTreeState, Panel, PanelEvent, PanelState, h_flex, v_flex, + h_flex, v_flex, DockArea, MoonDataTableState, MoonInputState, MoonPalette, MoonSegmentItem, + MoonSegmentedControl, MoonTreeState, Panel, PanelEvent, PanelState, }; -use crate::Backend; use crate::core_order::{CoreOrder, OrderedCores}; use crate::design; use crate::workspace::{EffectiveCoreScope, RetainedCoreScope}; +use crate::Backend; use model::{CoreStatusRow, ServerKey, ServerStatusGroup}; use moon_core::session::CoreId; use rust_i18n::t; @@ -94,12 +95,65 @@ enum CoreStatusMode { const WARN_LIST_LIMIT: usize = 500; impl Default for CoreStatusMode { - /// Return the server-by-IP presentation used on every new panel instance. + /// Return the server-by-IP presentation a panel opens on when nothing was ever remembered. fn default() -> Self { Self::ByIp } } +impl CoreStatusMode { + /// Stable machine code written to `layout.toml`. + /// + /// Never localized and never derived from the tab caption: the captions come from + /// `core_status.mode.*` and change with the locale, while this is the persistence contract and + /// must not. Kebab-case matches `WorkspaceMode::code` in `moon-core`. + /// + /// Returns: + /// The stable, non-localized persistence code for this presentation. + const fn code(self) -> &'static str { + match self { + Self::ByIp => "by-ip", + Self::Flat => "flat", + Self::Warnings => "warnings", + } + } + + /// Resolve a persisted code without letting a hand edit change what the panel does. + /// + /// Leading and trailing whitespace is ignored. Anything unknown — an empty value, a typo, or a + /// code a newer build wrote — yields the first-run default rather than an error, so a single bad + /// entry costs one remembered mode and never the window layout around it. + /// + /// Args: + /// code: Persisted machine code, potentially hand-edited. + /// + /// Returns: + /// The matching presentation, or By IP for an empty or unrecognized code. + fn from_code(code: &str) -> Self { + match code.trim() { + "flat" => Self::Flat, + "warnings" => Self::Warnings, + _ => Self::default(), + } + } +} + +/// Context-qualified storage id for the Core Status presentation choice. +/// +/// A panel-level choice rather than a property of a table, so it takes its own base and never +/// shares `core-status-table`'s. The `:dock`/`:win` split is what lets a docked tab and a detached +/// window remember different modes; a detached window therefore opens on whatever `:win` last held, +/// and is deliberately NOT seeded from the docked panel it was torn off. +/// +/// Args: +/// detached: Whether this panel instance is hosted in a detached window. +/// +/// Returns: +/// The context-qualified persistence key for the panel's presentation mode. +fn mode_ctx_id(detached: bool) -> String { + crate::persistence::table_persist::ctx_id("core-status-mode", detached) +} + /// Group-scoped Core Status panel for a dock tab or detached window. pub struct CoreStatusView { pub(super) backend: Entity, @@ -124,9 +178,13 @@ pub struct CoreStatusView { chart_core: Option, cached_rows: Rc>, cached_groups: Rc>, - /// Servers whose IP is momentarily revealed by the eye control. Transient: cleared on blur, - /// never persisted, so IPs return to masked when focus leaves the panel. - revealed_ips: HashSet, + /// Whether the By-IP address column is hidden behind its mask. + /// + /// Panel-wide rather than per-server, and it starts FALSE: this view exists to show addresses, + /// so masking is a deliberate act before sharing a screen, not the resting state. One control + /// in the column header owns it, so a fleet of servers costs one click instead of one each. + /// Transient — never persisted, so a fresh panel always comes up showing addresses. + ip_masked: bool, /// Server whose name is being renamed inline, if any. editing: Option, /// Input state backing the inline rename field while [`Self::editing`] is set. @@ -134,9 +192,16 @@ pub struct CoreStatusView { /// Active flat-table sort as `(column key, ascending)`, or `None` for the default /// attention-first order. flat_sort: Option<(String, bool)>, + /// Whether the exchange logos have finished decoding off-thread. + /// + /// The Flat view's exchange headings gate on this: drawing before the prewarm lands would make + /// the first frame block on an SVG decode. + exchange_logos_ready: bool, /// Active By IP column sort as `(field, ascending)`. Default `(Name, ascending)` reproduces the /// former fixed order; warnings always pin to the top regardless of the field or direction. group_sort: (ordering::GroupSortField, bool), + /// Presentation the mode strip is on. Restored from and written back to `layout.toml` under + /// [`mode_ctx_id`], so this panel reopens in the mode the user last selected. mode: CoreStatusMode, tree_state: Entity, table_state: Entity, @@ -152,6 +217,22 @@ pub struct CoreStatusView { by_ip_width: f32, /// Context-qualified column-width persistence ID (`core-status-table:dock` or `:win`). widths_id: String, + /// User-dragged column widths for the By IP tree, keyed by [`by_ip_widths::ByIpCol::key`]. + /// + /// A `MoonDataTableState` used purely as a persistence-shaped BAG, never as a table: By IP is a + /// tree, not a `MoonDataTable`. Reusing the type is what lets [`crate::persistence::table_persist`] + /// store, restore and reset these widths with no storage code of its own — including the + /// toolbar's existing reset button, which takes exactly this entity. + by_ip_col_widths: Entity, + /// Context-qualified persistence ID for [`Self::by_ip_col_widths`]. + by_ip_widths_id: String, + /// Pointer x and LOGICAL column width captured when a header divider drag started. + /// + /// The drag cannot be computed from the live cell origin: the `flex_1` spacer in the header + /// right-anchors every column after IP, so growing one moves its own left edge and the delta + /// compounds every frame. Anchoring once, at the grab, makes the arithmetic independent of + /// relayout. `None` whenever no drag is in flight. + by_ip_drag: Option, dock: Option>, focus: FocusHandle, } @@ -181,17 +262,17 @@ impl CoreStatusView { /// Args: /// backend: Shared terminal backend. /// group: Window group that defines the core scope. - /// detached: Whether column widths use the detached-window persistence key. + /// detached: Whether widths and presentation mode use the detached-window persistence keys. /// _window: Host window reserved for panel construction symmetry. /// cx: View context used for observers and child entities. /// /// Returns: - /// A panel whose default presentation is server-by-IP. + /// A panel restored to its saved presentation, or By IP when no usable mode was stored. fn new( backend: Entity, group: String, detached: bool, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Self { // This fires on every backend notify (event-driven, ≤4 Hz — not a timer/poll), but the @@ -226,6 +307,20 @@ impl CoreStatusView { }) .detach(); + // Off-thread, like every other exchange-logo call site: `prewarm` decodes the shipped SVGs + // and would block the first frame if it ran on the render thread. + cx.spawn(async move |view, cx| { + cx.background_spawn(async { crate::media::exchange_logos::prewarm() }) + .await; + cx.update(|cx| { + let _ = view.update(cx, |this, cx| { + this.exchange_logos_ready = true; + cx.notify(); + }); + }); + }) + .detach(); + let widths_id = crate::persistence::table_persist::ctx_id("core-status-table", detached); let by_ip_sort_id = crate::persistence::table_persist::ctx_id("core-status-by-ip", detached); @@ -236,6 +331,13 @@ impl CoreStatusView { let group_sort = ordering::restore_group_sort( crate::persistence::table_persist::saved_sort(backend.read(cx), &by_ip_sort_id), ); + // Restore only: writing the resolved code back here would insert an entry for every context + // on first launch and arm a layout flush merely by opening the panel. + let mode = crate::persistence::table_persist::core_status_mode( + backend.read(cx), + &mode_ctx_id(detached), + ) + .map_or_else(CoreStatusMode::default, CoreStatusMode::from_code); let saved_widths = crate::persistence::table_persist::saved(backend.read(cx), &widths_id); let table_state = cx.new(|_| { let mut s = MoonDataTableState::new(); @@ -249,20 +351,36 @@ impl CoreStatusView { crate::persistence::table_persist::persist(&this.backend, &this.widths_id, &state, cx); }) .detach(); + // The By IP width bag. Its own `ctx_id` base, so a docked tab and a detached window keep + // separate By-IP widths exactly as they already keep separate flat-table widths, and neither + // can collide with `core-status-table`. + let by_ip_widths_id = + crate::persistence::table_persist::ctx_id("core-status-by-ip-widths", detached); + let saved_by_ip = + crate::persistence::table_persist::saved(backend.read(cx), &by_ip_widths_id); + let by_ip_col_widths = cx.new(|_| { + let mut s = MoonDataTableState::new(); + s.column_widths = saved_by_ip; + s + }); + cx.observe(&by_ip_col_widths, |this, state, cx| { + crate::persistence::table_persist::persist( + &this.backend, + &this.by_ip_widths_id, + &state, + cx, + ); + // Unlike `table_state`, NOTHING else observes this bag: a `MoonDataTable` observes its + // own state, but the By IP header and rows read these widths during THIS view's render. + // Without the notify the toolbar reset appears to do nothing until some unrelated + // repaint happens to arrive. + cx.notify(); + }) + .detach(); let warn_table_state = cx.new(|_| MoonDataTableState::new()); let tree_state = cx.new(|cx| MoonTreeState::new(cx)); let focus = cx.focus_handle(); - // A revealed IP is momentary: when focus leaves the panel it re-masks. The eye control - // focuses this handle on reveal, so this blur fires when the user clicks away. - cx.on_blur(&focus, window, |this, _window, cx| { - if !this.revealed_ips.is_empty() { - this.revealed_ips.clear(); - cx.notify(); - } - }) - .detach(); - let mut this = Self { backend, group, @@ -275,18 +393,22 @@ impl CoreStatusView { chart_core: None, cached_rows: Rc::new(Vec::new()), cached_groups: Rc::new(Vec::new()), - revealed_ips: HashSet::new(), + ip_masked: false, editing: None, edit_input: None, flat_sort, + exchange_logos_ready: false, group_sort, - mode: CoreStatusMode::default(), + mode, tree_state, table_state, warn_table_state, warn_cfg_open: false, by_ip_width: 0.0, widths_id, + by_ip_col_widths, + by_ip_widths_id, + by_ip_drag: None, dock: None, focus, }; @@ -413,9 +535,27 @@ impl Panel for CoreStatusView { _window: &mut Window, _cx: &mut Context, ) -> Option> { + // ONE button, pointed at whatever grid the panel is currently showing: a user sees one set + // of columns and expects one reset. By IP has its own width bag because it is a tree, not + // the flat table. + // + // The DETACHED window does not come through here. `panels/registry.rs` resolves + // `table_state()` ONCE at window construction into `DetachedContent.widths_reset`, so its + // header button keeps resetting the flat table whatever the mode; there, By-IP reset is + // reachable by double-clicking a divider (Shift+double-click for all of them). Fixing that + // means giving `widths_reset` a closure instead of an entity, which is a change to two + // files outside this panel. + let state = match self.mode { + CoreStatusMode::ByIp => &self.by_ip_col_widths, + CoreStatusMode::Flat => &self.table_state, + // Warnings is its OWN table with its own widths (`warnings_table` is handed + // `warn_table_state`), so it must not be folded in with Flat: doing that resets the + // hidden grid and leaves the visible one untouched. + CoreStatusMode::Warnings => &self.warn_table_state, + }; Some(vec![crate::persistence::table_persist::reset_button( "core-status-reset-widths", - &self.table_state, + state, )]) } } @@ -441,7 +581,7 @@ impl Render for CoreStatusView { let content = match self.mode { CoreStatusMode::ByIp => server_view::grouped_server_view( groups.clone(), - Rc::new(self.revealed_ips.clone()), + self.ip_masked, self.editing, self.edit_input.clone(), self.chart_server, @@ -451,7 +591,14 @@ impl Render for CoreStatusView { // Row insets are `rems`, so the By-IP width budget needs the window's rem size — // MoonUI's Root sets it from the theme font size, which the Font slider moves. f32::from(window.rem_size()), + // The user's dragged widths, BORROWED: the callee resolves them into `Copy` + // geometries synchronously and nothing in the render tree holds the map, so a + // per-frame clone of it would buy nothing on a path that repaints on every hover. + &self.by_ip_col_widths.read(cx).column_widths, &self.tree_state, + // `&Window` is enough: `Window::listener_for` takes `&self`, so the header's + // drag-move listener needs no mutable borrow. + window, cx, ), CoreStatusMode::Flat => { @@ -459,10 +606,14 @@ impl Render for CoreStatusView { .iter() .map(|group| (group.key, group.display_name.clone())) .collect(); + let (flat_rows, flat_lines) = self.flat_view(cx); table::core_status_table( "core-status-table", - Rc::new(self.sorted_flat_rows(&rows)), + flat_rows, + flat_lines, Rc::new(server_names), + self.exchange_logos_ready, + self.flat_sort.is_some(), &self.table_state, cx, ) diff --git a/crates/moon-ui-gpui/src/panels/core_status/model.rs b/crates/moon-ui-gpui/src/panels/core_status/model.rs index 1f14f87e..81d2406e 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/model.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/model.rs @@ -48,6 +48,15 @@ pub(super) struct CoreStatusRow { /// Whether this core's key is inside the configured warning horizon, decided by the engine so /// the cell mark and the episode agree. pub(super) api_warn: bool, + /// MoonBot build most recently reported for this core, or `None`. + /// + /// `None` is UNINTERPRETABLE and must be rendered as a plain absence: the store clears the + /// report after every non-Ready status, and it also covers both a core that has not reported + /// yet and one too old to report a build at all. Those report-absence cases are byte-identical + /// at the wire — see `moon_core::feed::CoreIdentityFacts`. Nothing here may turn absence into + /// an age claim; the one age claim the terminal makes is `Diagnosis::legacy_core`, which is + /// about a missing PROTOCOL version and lives in the fault tooltip. + pub(super) server_version: Option, } /// What is known about one core's exchange API key, as of a given moment. @@ -140,6 +149,53 @@ impl ApiKeyState { } } +/// What one server's cores agree on about their MoonBot build. +/// +/// An AGREEMENT rollup, not an attention-first or urgency-min one like [`group_startup`] and +/// [`soonest_key`]. Those need an ordering in which one value is WORSE; there is none here, because +/// no minimum-version constant exists anywhere in this workspace and inventing one was specifically +/// rejected (`moon_core::feed::conn_verdict`). A build number is a fact, not a severity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum GroupVersion { + /// Every core on the server reported, and all reported the same build. + Uniform(u32), + /// At least one core reported, but they do not all agree — INCLUDING the case where one + /// reported and a sibling is silent. A collapsed group must not assert a build on behalf of a + /// process nobody could ask, which is `soonest_key`'s own discipline applied here. + Mixed, + /// No core on the server reported anything. + Absent, +} + +/// Roll one server's reported builds up into the value its collapsed row shows. +/// +/// Args: +/// cores: The group's core rows, already collected. +/// +/// Returns: +/// `Uniform` only when every core reported and all agree, `Mixed` on any disagreement or any +/// silent sibling beside a reporting one, `Absent` when nothing was reported at all. +pub(super) fn group_version(cores: &[CoreStatusRow]) -> GroupVersion { + let mut reported: Option = None; + let mut any_silent = false; + for core in cores { + match core.server_version { + None => any_silent = true, + Some(version) => match reported { + Some(seen) if seen != version => return GroupVersion::Mixed, + _ => reported = Some(version), + }, + } + } + match (reported, any_silent) { + // Nothing was reported at all — including the empty group, which must not panic. + (None, _) => GroupVersion::Absent, + // A silent sibling beside a reporting core: the group agrees on nothing it can vouch for. + (Some(_), true) => GroupVersion::Mixed, + (Some(version), false) => GroupVersion::Uniform(version), + } +} + /// Stable grouping identity for a known host or one isolated unknown core. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(super) enum ServerKey { @@ -216,6 +272,9 @@ pub(super) struct ServerStatusGroup { /// A real day count wins whenever any core has one; `Unknown` when none does and at least one /// core is unaccounted for; `Perpetual` only when every core is unlimited. pub(super) api_key: ApiKeyState, + /// What this server's cores agree on about their MoonBot build — see [`group_version`]. A + /// collapsed group shows this, so it must never speak for a core that reported nothing. + pub(super) version: GroupVersion, /// Shared endpoint address, or `None` for an isolated unknown endpoint. pub(super) address: Option, /// Cores ordered attention-first, retaining canonical input order within each partition. @@ -280,6 +339,7 @@ pub(super) fn aggregate_servers(rows: &[CoreStatusRow]) -> Vec Some(address), ServerKey::Unknown(_) => None, @@ -397,6 +457,7 @@ fn finish_group(group: &mut ServerStatusGroup) { group.api_key = soonest_key(&group.cores); group.api_warn = group.cores.iter().any(|core| core.api_warn); group.startup = group_startup(&group.cores); + group.version = group_version(&group.cores); let mut has_process_memory = false; let mut process_memory_mb = 0u64; diff --git a/crates/moon-ui-gpui/src/panels/core_status/model/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/model/tests.rs index 71f57795..64bc6121 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/model/tests.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/model/tests.rs @@ -5,7 +5,9 @@ use std::net::{IpAddr, Ipv4Addr}; use moon_core::feed::{ConnStatus, CoreEndpoint}; use moon_core::session::{CoreStartupState, CoreStartupStatus, CoreSysStatus}; -use super::{CoreStatusRow, ServerConnectivity, ServerKey, aggregate_servers}; +use super::{ + aggregate_servers, group_version, CoreStatusRow, GroupVersion, ServerConnectivity, ServerKey, +}; /// Build one core snapshot for aggregation tests. fn row( @@ -32,6 +34,7 @@ fn row( api_key: crate::panels::core_status::model::ApiKeyState::Unknown, api_warn: false, startup: CoreStartupStatus::default(), + server_version: None, } } @@ -73,6 +76,28 @@ fn same_ip_with_different_ports_forms_one_server() { ); } +/// `model.rs:group_version` must treat a silent sibling as disagreement; changing that branch to +/// `Uniform` would make a collapsed server row claim a build that one of its cores never reported. +#[test] +fn group_version_requires_every_core_to_report_the_same_build() { + let mut v734 = row(1, None, 0, ConnStatus::Ready, CoreSysStatus::default()); + v734.server_version = Some(734); + let mut same_v734 = row(2, None, 0, ConnStatus::Ready, CoreSysStatus::default()); + same_v734.server_version = Some(734); + let mut v735 = row(3, None, 0, ConnStatus::Ready, CoreSysStatus::default()); + v735.server_version = Some(735); + let silent = row(4, None, 0, ConnStatus::Connecting, CoreSysStatus::default()); + + assert_eq!( + group_version(&[v734.clone(), same_v734]), + GroupVersion::Uniform(734) + ); + assert_eq!(group_version(&[v734.clone(), v735]), GroupVersion::Mixed); + assert_eq!(group_version(&[v734, silent.clone()]), GroupVersion::Mixed); + assert_eq!(group_version(&[silent]), GroupVersion::Absent); + assert_eq!(group_version(&[]), GroupVersion::Absent); +} + /// `model.rs:finish_group` must use the newest sample independently per machine metric and a `u64` /// process-memory sum; one whole sample or a `u16` sum shows stale data or overflows. #[test] @@ -434,6 +459,7 @@ fn startup_row(status: ConnStatus, startup: CoreStartupStatus) -> CoreStatusRow api_key: crate::panels::core_status::model::ApiKeyState::Unknown, api_warn: false, startup, + server_version: None, } } 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 61e49bc5..8683bfca 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/ordering.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/ordering.rs @@ -7,10 +7,13 @@ use std::collections::HashMap; use std::net::IpAddr; use moon_core::feed::ConnStatus; -use moon_core::session::CoreSysStatus; +use moon_core::session::{CoreId, CoreSysStatus}; +use moon_core::venue::{Brand, CoreVenue}; use rust_i18n::t; -use super::model::{CoreStatusRow, ServerStatusGroup}; +use crate::core_order::ExchangeSection; + +use super::model::{CoreStatusRow, GroupVersion, ServerStatusGroup}; use super::startup::{StartupCell, startup_cell}; /// Which By IP column the server list is sorted on. Warnings always pin to the top regardless of the @@ -35,6 +38,9 @@ pub(super) enum GroupSortField { /// Startup: still-coming-up servers first, then the ones that took longest. One header click /// answers "which machines are slow to come up", which is the question the column exists for. Startup, + /// The server's rolled-up MoonBot build, by [`version_group_rank`] — the agreed build, or + /// neither when its cores disagree or none reported. + Version, } impl GroupSortField { @@ -49,6 +55,7 @@ impl GroupSortField { Self::Cores => "cores", Self::ApiKey => "api_key", Self::Startup => "startup", + Self::Version => "version", } } @@ -63,6 +70,7 @@ impl GroupSortField { "cores" => Some(Self::Cores), "api_key" => Some(Self::ApiKey), "startup" => Some(Self::Startup), + "version" => Some(Self::Version), _ => None, } } @@ -72,7 +80,7 @@ impl GroupSortField { pub(super) fn restore_flat_sort( preference: Option, ) -> Option<(String, bool)> { - const KEYS: [&str; 12] = [ + const KEYS: [&str; 13] = [ "server", "core", "status", @@ -85,6 +93,7 @@ pub(super) fn restore_flat_sort( "cpus", "api_key", "startup", + "version", ]; preference.and_then(|preference| { KEYS.contains(&preference.column.as_str()) @@ -167,6 +176,12 @@ pub(super) fn compare_groups( // show another. `startup_rank` puts unfinished startups first because they are the ones // still costing the user time. GroupSortField::Startup => startup_rank(a.startup).cmp(&startup_rank(b.startup)), + // Ranks the SAME rolled-up value the server row displays, by the same rule as every other + // heading here. Not Ready-gated: the store already drops a build the moment its core leaves + // Ready, so a stale one cannot reach this comparison. + GroupSortField::Version => { + version_group_rank(a.version).cmp(&version_group_rank(b.version)) + } } .then_with(|| natural_cmp(&a.display_name, &b.display_name)) } @@ -192,6 +207,36 @@ fn startup_rank(cell: Option) -> (u8, i64, i64) { } } +/// Rank one core's reported build for sorting: reported builds first, ascending, then the rows +/// with nothing to show. +/// +/// A tagged tuple rather than the bare `Option`, for [`ApiKeyState::urgency`]'s stated reason: +/// `None` sorts FIRST as an `Option`, which is the opposite of what this column is scanned for. +/// The scan is "which cores run an odd or old build", so the numbers lead. +/// +/// KNOWN AND ACCEPTED: `sorted_flat_rows` reverses the whole comparator, so descending leads with +/// the blanks. `api_key` behaves identically for the same reason, and matching it keeps one rule in +/// the panel rather than making this the single column that behaves differently. +fn version_rank(version: Option) -> (u8, u32) { + match version { + Some(version) => (0, version), + None => (1, 0), + } +} + +/// Rank a server's rolled-up build: an agreed build first, ascending, then disagreement, then +/// nothing reported. +/// +/// `Mixed` outranks `Absent` because a mixed group has something to look at — expanding it shows +/// real numbers — while an absent one does not. +fn version_group_rank(version: GroupVersion) -> (u8, u32) { + match version { + GroupVersion::Uniform(version) => (0, version), + GroupVersion::Mixed => (1, 0), + GroupVersion::Absent => (2, 0), + } +} + /// Fill each group's display name from a custom name or a stable `Server N` ordinal. /// /// Ordinals rank address servers by sorted address so a name stays put under attention-first @@ -340,10 +385,142 @@ pub(super) fn compare_flat_rows(a: &CoreStatusRow, b: &CoreStatusRow, key: &str) // disagree about which core is slower to come up. "startup" => startup_rank(Some(startup_cell(&a.status, &a.startup))) .cmp(&startup_rank(Some(startup_cell(&b.status, &b.startup)))), + // The reported build, numerically rather than lexically, with the blanks kept off the head + // of the ascending scan — see `version_rank`. + "version" => version_rank(a.server_version).cmp(&version_rank(b.server_version)), // "core" and any unknown key sort by name. _ => a.name.cmp(&b.name), } } +/// One line of the Flat presentation, in render order. +/// +/// The flat table draws cores AND the exchange headings that introduce them from one list, because +/// [`MoonDataTable`] has no notion of a group row: every line it draws is a row of uniform height, +/// so a heading has to BE a row. Keeping both in one enum makes a table index resolve to the line +/// it draws, without a second list that could fall out of step with the core rows. +/// +/// [`MoonDataTable`]: moon_ui::MoonDataTable +pub(super) enum FlatLine { + /// An exchange heading, introducing the cores that follow it. + Section(FlatSection), + /// Index into the sorted row slice this line draws. + Core(usize), +} + +/// What one exchange heading draws. +/// +/// Owned rather than borrowed: the table's row closure is `'static`, so it cannot hold a +/// `&CoreVenue` borrowed out of the session's venue map. +pub(super) struct FlatSection { + /// The bucket's IDENTITY. The heading's element id is built from this and never from + /// [`Self::label`]: an id built from rendered text changes with the interface language and with + /// a core build's spelling, which makes GPUI treat one heading as a different element and drop + /// its hover and tooltip state. + pub(super) section: ExchangeSection, + /// Caption, from [`crate::controls::venue_section_label`]. + pub(super) label: String, + /// Brand whose logo the heading shows, when the directory names one. `None` draws no logo and + /// deliberately no placeholder glyph. + pub(super) brand: Option, + /// How many cores this section holds. + /// + /// Drawn right-aligned at the far end of the band, unless that is also the caption cell, where + /// it follows the caption. This makes a heading read unmistakably as a GROUP rather than as + /// another core: MoonUI's sort arrow cannot say that it orders rows WITHIN a section, so the + /// heading has to carry that meaning itself. + pub(super) members: usize, +} + +/// Lay already-sorted flat rows out as exchange sections followed by their members. +/// +/// Sorting orders rows; grouping PARTITIONS that order. The caller applies the active column sort +/// (or the default attention-first order) to `rows` first, and this function only cuts the result +/// into sections — so a descending click reverses rows WITHIN each section and never moves a +/// section. Section order comes from the shared directory ordering in +/// [`crate::core_order::exchange_sections`], the same one the left rail, the Strategies tree and +/// the Assets panel already use, so a sort click here can never make this panel disagree with them +/// about where an exchange sits. +/// +/// Bucketing is by venue IDENTITY, not by the caption a core reported, so two cores of different +/// vintage on one venue share a section however their builds spell its name. +/// +/// Args: +/// rows: Flat rows in their final display order. +/// venues: What each core reported it is connected to, keyed by core. +/// +/// Returns: +/// Heading and member lines in render order; empty when `rows` is empty. A section is emitted +/// only when it has members, so a heading with nothing under it is not representable. +pub(super) fn flat_lines( + rows: &[CoreStatusRow], + venues: &HashMap, +) -> Vec { + let sections = + crate::core_order::exchange_sections(rows.iter().enumerate().map(|(index, row)| { + let venue = venues.get(&row.id); + (index, venue) + })); + // One heading plus every member, so the exact final length is known up front. + let mut lines = Vec::with_capacity(rows.len() + sections.len()); + for (venue, members) in sections { + lines.push(FlatLine::Section(FlatSection { + // Through the shared bucketing rule rather than re-deciding here what "unidentified" + // means, so the heading and the partition it heads cannot drift apart. + section: crate::core_order::section_of(venue), + label: stable_section_label(venue, &members, rows, venues), + // Identity, not caption: every member of one section shares an `ExchangeId`, so the + // brand is the same whichever member the partition handed back. + brand: venue.and_then(CoreVenue::brand), + members: members.len(), + })); + lines.extend(members.into_iter().map(FlatLine::Core)); + } + lines +} + +/// Caption a section so that the active row sort cannot rename it. +/// +/// A venue the directory NAMES captions from the directory, so every member spells it identically. +/// A venue nothing names falls back to the core's own wire text, and members of one ordinal can +/// disagree about it — two cores on the same unknown platform can report two spellings. +/// [`crate::core_order::exchange_sections`] hands back the FIRST member's venue, and "first" moves +/// with the active column sort, so captioning from it would make such a heading rename itself when +/// the user clicks a sort arrow. +/// +/// The smallest RENDERED caption is stable under every row order. It is compared as the caption +/// rather than as the underlying wire field on purpose: a core build's own spelling belongs to +/// [`crate::controls::venue_label`] and is not this module's to read — a rule the theme contract +/// enforces across the whole crate. +/// +/// Args: +/// venue: The section's representative venue, or `None` for the unidentified group. +/// members: Row indices belonging to this section. +/// rows: The rows those indices address. +/// venues: What each core reported it is connected to, keyed by core. +/// +/// Returns: +/// The caption to draw, never empty. +fn stable_section_label( + venue: Option<&CoreVenue>, + members: &[usize], + rows: &[CoreStatusRow], + venues: &HashMap, +) -> String { + let label = crate::controls::venue_section_label(venue); + // Only an unnameable ordinal can disagree between members; everything else is already stable, + // and formatting one caption per member would be waste. + if venue.is_none_or(|venue| venue.resolved().is_some()) { + return label; + } + members + .iter() + .filter_map(|index| rows.get(*index)) + .filter_map(|row| venues.get(&row.id)) + .map(|venue| crate::controls::venue_section_label(Some(venue))) + .min() + .unwrap_or(label) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-ui-gpui/src/panels/core_status/ordering/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/ordering/tests.rs index aed31d28..c06884e1 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/ordering/tests.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/ordering/tests.rs @@ -4,17 +4,20 @@ //! re-exports `gpui::*`, whose own `test` would shadow the built-in attribute. use std::cmp::Ordering; +use std::collections::HashMap; use moon_core::config::TableSortPreference; use moon_core::feed::ConnStatus; use moon_core::session::{CoreStartupStatus, CoreSysStatus}; +use moon_core::venue::CoreVenue; use super::{ - GroupSortField, compare_flat_rows, compare_groups, restore_flat_sort, restore_group_sort, + FlatLine, GroupSortField, compare_flat_rows, compare_groups, flat_lines, restore_flat_sort, + restore_group_sort, }; use crate::backend::core_warn::LatencySeverity; use crate::panels::core_status::model::{ - ApiKeyState, CoreStatusRow, ServerConnectivity, ServerKey, ServerStatusGroup, + ApiKeyState, CoreStatusRow, GroupVersion, ServerConnectivity, ServerKey, ServerStatusGroup, }; /// Build one core row carrying only an API-key state: `Some(days)` is a dated key, `None` is a core @@ -34,6 +37,7 @@ fn row_with_key(id: u64, days: Option) -> CoreStatusRow { api_key: days.map_or(ApiKeyState::Unknown, ApiKeyState::Days), api_warn: false, startup: CoreStartupStatus::default(), + server_version: None, } } @@ -70,6 +74,7 @@ fn group( api_key: ApiKeyState::Unknown, api_warn: false, startup: CoreStartupStatus::default(), + server_version: None, }) .collect::>(); let ready_count = cores @@ -86,6 +91,7 @@ fn group( exch_warn: false, api_warn: false, api_key: ApiKeyState::Unknown, + version: GroupVersion::Absent, address: None, cores, ready_count, @@ -235,18 +241,19 @@ fn a_server_sorts_by_the_key_it_displays() { ); } -/// `ordering.rs:restore_flat_sort` must retain valid keys/directions and reject retired keys. +/// `ordering.rs:restore_flat_sort` must retain the version key and direction, and reject retired +/// keys. Removing `"version"` from `KEYS` silently discards a user's selected build sort at restart. /// -/// Mutation: accept every string or force ascending. Flat mode would reopen with an invisible -/// active key or the wrong arrow, and one of these assertions reddens. +/// Mutation: drop `"version"` from `KEYS`. Flat mode would reopen in attention order, and the +/// retained-version assertion reddens without relying on `KEYS`' implementation length. #[test] fn flat_sort_restore_validates_key_and_direction() { assert_eq!( restore_flat_sort(Some(TableSortPreference { - column: "ping_exch".to_string(), + column: "version".to_string(), ascending: false, })), - Some(("ping_exch".to_string(), false)) + Some(("version".to_string(), false)) ); assert_eq!( restore_flat_sort(Some(TableSortPreference { @@ -278,3 +285,54 @@ fn by_ip_sort_restore_keeps_valid_choice_and_historical_default() { (GroupSortField::Name, true) ); } + +/// `ordering.rs:flat_lines` must retain every source row once while partitioning by venue identity. +/// +/// Mutation: remove `lines.extend(members.into_iter().map(FlatLine::Core));`. A core would silently +/// vanish from the operator's fleet list, or a future broken partition could duplicate it. +#[test] +fn flat_lines_partitions_each_input_row_once_by_venue_identity() { + let rows = (0..6).map(|id| row_with_key(id, None)).collect::>(); + let venues = HashMap::from([ + (1, CoreVenue::identify(200, "", None)), + (2, CoreVenue::identify(2, "", Some("Bybit legacy spelling"))), + ( + 3, + CoreVenue::identify(2, "", Some("Bybit current spelling")), + ), + (4, CoreVenue::identify(13, "dex-a", Some("Hyperliquid"))), + (5, CoreVenue::identify(13, "dex-b", Some("Hyperliquid"))), + ]); + + let lines = flat_lines(&rows, &venues); + let core_indices = lines + .iter() + .filter_map(|line| match line { + FlatLine::Core(index) => Some(*index), + FlatLine::Section(_) => None, + }) + .collect::>(); + let sections = lines + .iter() + .filter_map(|line| match line { + FlatLine::Section(section) => Some((section.section, section.members)), + FlatLine::Core(_) => None, + }) + .collect::>(); + + assert_eq!( + core_indices, + (0..rows.len()).collect::>(), + "the flattened member indices must cover each input row exactly once" + ); + assert_eq!( + sections, + vec![ + (crate::core_order::ExchangeSection::Unidentified, 2), + (crate::core_order::ExchangeSection::Venue(venues[&2].id), 2,), + (crate::core_order::ExchangeSection::Venue(venues[&4].id), 1,), + (crate::core_order::ExchangeSection::Venue(venues[&5].id), 1,), + ], + "unidentified cores lead, shared venue identities merge, and HIP-3 DEX identities stay distinct" + ); +} diff --git a/crates/moon-ui-gpui/src/panels/core_status/presentation.rs b/crates/moon-ui-gpui/src/panels/core_status/presentation.rs index 29f4fdf2..cfa5d486 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/presentation.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/presentation.rs @@ -4,7 +4,7 @@ use moon_core::feed::{ConnStatus, Diagnosis}; use moon_ui::MoonPalette; use rust_i18n::t; -use super::model::ApiKeyState; +use super::model::{ApiKeyState, GroupVersion}; use crate::backend::core_warn::LatencySeverity; use crate::conn_diag::fault_short; @@ -132,6 +132,49 @@ pub(super) fn api_expiry_text(state: ApiKeyState) -> String { } } +/// Format one core's reported MoonBot build for its column. +/// +/// No noun — that lives in the column heading, exactly as [`api_expiry_text`] drops "дн" and +/// [`ping_plain`] drops "ms"; the fault tooltip spells out "MoonBot %{server}" because that one is +/// a sentence and a column is not. +/// +/// The number ITSELF is dotted, through [`moon_core::util::fmt::core_build`]: the wire payload is a +/// flat `u32`, but the product names its builds `7.69` and `7.70`, so printing the raw `769` makes +/// the reader convert. That convention is the formatter's to state and is documented there — do not +/// re-derive it here. The terminal's own `vX.Y.Z` release version is real SemVer and a different +/// fact entirely. +/// +/// Args: +/// version: The build this core reported, when it reported one. +/// +/// Returns: +/// Decimal text, or the panel's ASCII unavailable marker. +pub(super) fn version_text(version: Option) -> String { + version + .map(moon_core::util::fmt::core_build) + .unwrap_or_else(|| "-".to_string()) +} + +/// Format a server row's rolled-up build. +/// +/// `Mixed` is an ellipsis rather than a number or a dash: it carries exactly one instruction to the +/// user — expand the group — and neither a number nor a blank would. The glyph lives here rather +/// than in the dictionaries, per `locales/README.md`, the same reason `∞` lives in +/// [`api_expiry_text`]. +/// +/// Args: +/// version: The group's agreement state. +/// +/// Returns: +/// The agreed build, an ellipsis, or the unavailable marker. +pub(super) fn version_group_text(version: GroupVersion) -> String { + match version { + GroupVersion::Uniform(version) => moon_core::util::fmt::core_build(version), + GroupVersion::Mixed => "\u{2026}".to_string(), + GroupVersion::Absent => "-".to_string(), + } +} + /// Colour for an API-key cell. /// /// The WARNING decision is the engine's — it owns the user's day threshold — so this reads that 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 79deec5e..145d3461 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 @@ -3,7 +3,7 @@ //! Servers start collapsed. `MoonTree` owns expansion, keyboard navigation, and virtual row //! layout; the renderer distinguishes a server header (root) from a core row (child) by item id. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::rc::Rc; use gpui::prelude::FluentBuilder; @@ -23,19 +23,27 @@ use moon_core::session::CoreId; use super::CoreStatusView; use super::by_ip_widths::{ByIpWidths, CELL_GAP_W, CHEVRON_W, ROW_GAP_W, TREE_SCROLLBAR_W}; -use super::model::{CoreStatusRow, ServerConnectivity, ServerKey, ServerStatusGroup}; +use super::ip_cell::{IpCell, ip_cell}; +use super::model::{CoreStatusRow, GroupVersion, ServerConnectivity, ServerKey, ServerStatusGroup}; use super::ordering::GroupSortField; use super::presentation::{ LoadLevel, api_expiry_level, api_expiry_text, cpu_level, cpu_load, free_mem_level, lat_level, - level_color, memory_free, memory_u16, percent, ping_plain, + level_color, memory_free, memory_u16, percent, ping_plain, version_group_text, version_text, }; use super::startup::{ StartupCell, startup_cell, startup_cell_text, startup_facts, startup_tooltip, }; -/// IP mask shown until the eye reveals the address; a fixed run avoids leaking the address length. +/// IP mask shown while the column is hidden; a fixed run avoids leaking the address length. const IP_MASK: &str = "************"; +/// Stand-in for a server whose address the terminal does not have. +/// +/// The house glyph for an absent value (the calendar and summary cells use the same one), so a +/// server the terminal cannot name reads like every other missing number instead of like a cell +/// that failed to draw. +const IP_ABSENT: &str = "\u{2014}"; + /// Build server roots, each with one collapsed folder of core children. /// /// Args: @@ -66,7 +74,8 @@ pub(super) fn tree_items(groups: &[ServerStatusGroup]) -> Vec { /// /// Args: /// groups: Immutable frame snapshot used by virtual row callbacks. -/// revealed: Servers whose IP is momentarily shown by the eye control. +/// ip_masked: Whether the whole IP column is hidden, one panel-wide state set from the +/// column header. /// 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. @@ -74,7 +83,11 @@ pub(super) fn tree_items(groups: &[ServerStatusGroup]) -> Vec { /// 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. +/// overrides: The user's dragged column widths, keyed by `ByIpCol::key`; empty until one drag. +/// Borrowed, not owned: both reads below are synchronous and yield `Copy` geometries, so +/// nothing in the render tree outlives this call and a per-frame clone would be pure waste. /// state: MoonTree state that owns scrolling and selection. +/// window: Host window, for the header's divider drag listener. /// cx: Panel context used to create a weak action callback. /// /// Returns: @@ -82,7 +95,7 @@ pub(super) fn tree_items(groups: &[ServerStatusGroup]) -> Vec { #[allow(clippy::too_many_arguments)] pub(super) fn grouped_server_view( groups: Rc>, - revealed: Rc>, + ip_masked: bool, editing: Option, edit_input: Option>, chart_selected: Option, @@ -90,7 +103,9 @@ pub(super) fn grouped_server_view( sort: (GroupSortField, bool), measured_width: f32, rem_size: f32, + overrides: &HashMap, state: &Entity, + window: &Window, cx: &Context, ) -> AnyElement { let groups_empty = groups.is_empty(); @@ -131,10 +146,17 @@ pub(super) fn grouped_server_view( // The right edge must stay clear of the status dot AND of the tree's scrollbar: MoonUI draws // that scrollbar as an OVERLAY, taking no layout width, so a row sized to the full measurement // would slide its last column underneath it. + // + // Two geometries, and the difference matters: `logical` is what the user's dragged widths say + // the columns are, and `widths` is what actually paints this frame after the shrink. The header + // needs both — it draws with `widths` but anchors each divider drag in `logical`, so a drag on a + // shrunk panel does not write back an already-scaled value for the resolver to scale again. + let logical = ByIpWidths::resolved(overrides); let widths = ByIpWidths::for_width( measured_width, crate::design::status_dot_w(cx) + TREE_SCROLLBAR_W, rem_size, + overrides, ); // Headless tree: it installs no row click/expand handlers and does not override `.selected`, so // the panel drives selection (body click) and expansion (chevron click) itself. @@ -151,7 +173,7 @@ pub(super) fn grouped_server_view( .selected(chart_selected == Some(group.key)) .child(server_row( group, - revealed.contains(&group.key), + ip_masked, entry.is_expanded(), editing_input, widths, @@ -239,8 +261,11 @@ pub(super) fn grouped_server_view( root.child(super::by_ip_header::server_header( header_palette, sort, + ip_masked, widths, + logical, &header_weak, + window, cx, )) .child(tree.flex_1().min_h_0()) @@ -252,7 +277,7 @@ pub(super) fn grouped_server_view( /// /// Args: /// group: Aggregated server snapshot. -/// revealed: Whether the IP is currently shown. +/// masked: Whether the IP column is currently hidden behind its mask. /// edit_input: Present only while this server's name is being renamed inline. /// w: Shared column widths for this frame, already shrunk to the measured row width. /// weak_view: Non-owning panel handle for the row actions. @@ -266,7 +291,7 @@ pub(super) fn grouped_server_view( #[allow(clippy::too_many_arguments)] fn server_row( group: &ServerStatusGroup, - revealed: bool, + masked: bool, expanded: bool, edit_input: Option>, w: ByIpWidths, @@ -308,7 +333,7 @@ fn server_row( .child(MoonDisclosure::glyph(expanded).size(design::DISCLOSURE_GLYPH_MARKER)) }) // Clicking the body selects this server for the chart AND blocks the row's expand toggle - // (only the chevron expands). Eye/pencil stop the event earlier, so they keep their own act. + // (only the chevron expands). The pencil stops propagation first, preserving the rename action. .child( h_flex() .flex_1() @@ -327,7 +352,7 @@ fn server_row( } }) .child(server_identity(group, edit_input, w, weak_view, p)) - .child(ip_column(group, revealed, w, weak_view, p)) + .child(ip_column(group, masked, w, p)) .child(div().flex_1()) .child(metric_cell( cpu_load(group.system_cpu_percent, group.logical_cpu_count), @@ -373,7 +398,7 @@ fn server_row( }); metric_cell( ping_plain(value), - w.ping, + w.exch, w.icon, level_color(level, p), group.exch_warn, @@ -390,6 +415,16 @@ fn server_row( group.api_warn, p, )) + // Rolled up from this server's cores so a COLLAPSED group still tells the truth: a + // build is shown only when EVERY core reported and all agree, because a silent + // sibling is a process this row cannot vouch for. Disagreement reads as an + // ellipsis, whose whole message is "expand me". + .child(version_slot( + version_group_text(group.version), + matches!(group.version, GroupVersion::Uniform(_)), + w.version, + p, + )) // Rolled up from this server's cores so a COLLAPSED group still tells the // truth: an unfinished core wins, otherwise the longest any of them took. .child(startup_text_cell( @@ -530,7 +565,7 @@ fn core_row( // This core's core→exchange order latency, also relative to its own baseline. .child(metric_cell( ping_plain(core.sys.order_api_latency_ms.map(u32::from)), - w.ping, + w.exch, w.icon, level_color(exch_lvl, p), core.exch_warn, @@ -545,6 +580,17 @@ fn core_row( core.api_warn, p, )) + // This core's own reported build. No warning treatment and no colour ramp: a ramp + // needs a threshold, and this workspace defines no minimum version by explicit + // decision. The one age claim the terminal makes — `legacy_core`, about a missing + // PROTOCOL version — stays in the fault hover one cell to the right, where it can + // co-occur with this number instead of being duplicated by it. + .child(version_slot( + version_text(core.server_version), + core.server_version.is_some(), + w.version, + p, + )) // The per-core cell carries the full detail behind it; the server row above only // summarises, so the hover lives here where there is one snapshot to describe. // @@ -554,9 +600,15 @@ fn core_row( // one row-hover away on every core that is actually starting. .child( match diagnose(&core.status, core.fault.as_ref(), &core.startup) { - Some(d) => startup_slot(fault_short(&d.class), w.startup, p.red).tooltip( - crate::panels::common::text_tooltip(fault_tooltip(&fault_facts(&d))), - ), + Some(d) => plain_slot( + "core-status-startup", + fault_short(&d.class), + w.startup, + p.red, + ) + .tooltip(crate::panels::common::text_tooltip(fault_tooltip( + &fault_facts(&d), + ))), None => startup_text_cell( startup_cell(&core.status, &core.startup), w.startup, @@ -675,115 +727,54 @@ fn server_identity( .into_any_element() } -/// Render the IP column: the masked (or revealed) address plus the eye toggle, in a fixed-width slot -/// so every server's address lines up under the "IP" header. A server with no known endpoint gets an -/// empty slot of the same width, so its metric columns still align. +/// Render the IP column: the address, its fixed-length mask, or a visible "no address" glyph, in a +/// fixed-width slot so every server's cell lines up under the "IP" header. +/// +/// All THREE states draw something, which is the point. A server with no known endpoint used to get +/// an EMPTY slot with no glyph and no control, so "the terminal does not have this address" was +/// indistinguishable from a panel that had failed to render. The mask control itself now lives ONCE +/// in the column header rather than once per row, so the row carries only the value: one click masks +/// a fleet of servers instead of one click each, and the freed width goes back to the address. /// /// Args: -/// group: Server snapshot supplying the address and identity key. -/// revealed: Whether the IP is currently shown. +/// group: Server snapshot supplying the address. +/// masked: Whether the user has hidden the whole IP column. /// w: Shared column widths, supplying the IP column. -/// weak_view: Non-owning panel handle for the reveal callback. /// p: Active Moon palette. /// /// Returns: /// A fixed-width IP cell. fn ip_column( group: &ServerStatusGroup, - revealed: bool, + masked: bool, w: ByIpWidths, - weak_view: &WeakEntity, p: MoonPalette, ) -> impl IntoElement { - let has_ip = group.address.is_some(); - let ip_text = if revealed { - group - .address - .map(|address| address.to_string()) - .unwrap_or_default() - } else { - IP_MASK.to_string() + let cell = ip_cell(group.address, masked); + // Only the unknown state carries a tooltip: the other two are self-explanatory, and a tooltip on + // every row of a 56-server fleet is noise. + let unknown = matches!(cell, IpCell::Unknown); + let (text, color) = match cell { + IpCell::Shown(address) => (address.to_string(), p.text_soft), + IpCell::Masked => (IP_MASK.to_string(), p.text_muted), + IpCell::Unknown => (IP_ABSENT.to_string(), p.text_muted), }; - h_flex() + // A constant id shared by every row, like `startup_slot`: the id exists to make the element + // stateful enough to carry a tooltip, not to identify the row. + div() + .id("core-status-ip") .w(px(w.ip)) .flex_none() - .items_center() - .gap_1() - .overflow_hidden() - .when(has_ip, |row| { - row.child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_color(rgb(if revealed { p.text_soft } else { p.text_muted })) - .child(ip_text), - ) - .child(eye_action(group.key, revealed, weak_view)) + .truncate() + .text_color(rgb(color)) + .when(unknown, |cell| { + cell.tooltip(crate::panels::common::text_tooltip( + t!("core_status.ip_unknown").to_string(), + )) }) + .child(text) } -/// Render the eye control that momentarily reveals a masked server IP. -/// -/// Args: -/// key: Server identity toggled by the control. -/// revealed: Whether the IP is currently shown. -/// w: Shared column widths, supplying the IP column. -/// weak_view: Non-owning panel handle for the reveal callback. -/// -/// Returns: -/// A ghost icon button that stops the row's selection mouse-down. -fn eye_action( - key: ServerKey, - revealed: bool, - weak_view: &WeakEntity, -) -> impl IntoElement { - let weak_view = weak_view.clone(); - div() - .on_mouse_down(MouseButton::Left, |_, _, app| app.stop_propagation()) - .child( - MoonButton::new(SharedString::from(format!( - "core-status-eye-{}", - key.tree_id() - ))) - .xsmall() - .ghost() - .icon(if revealed { - "icons/eye.svg" - } else { - "icons/eye-off.svg" - }) - .tooltip(if revealed { - t!("core_status.hide_ip").to_string() - } else { - t!("core_status.show_ip").to_string() - }) - .on_click(move |_, window, app| { - let Some(view) = weak_view.upgrade() else { - return; - }; - view.update(app, |this, cx| this.toggle_reveal(key, window, cx)); - }), - ) -} - -/// Render one inline metric value in a fixed-width box, with a leading slot for its warning mark. -/// -/// No decorative icon: the icon lead holds the SUSTAINED-warning triangle for THIS metric when set, -/// so the mark sits directly left of its own value instead of trailing the fixed box into the next -/// column (which read as the neighbour's warning). Empty otherwise, so every column stays aligned and -/// matches the header caption's lead. -/// -/// Args: -/// value: Preformatted localized metric text. -/// value_w: Width in pixels for the value box, from the frame's shared widths. -/// icon_w: Width of the warning-icon lead, from the same widths. -/// color: Threshold color of the number. -/// warn: Whether this metric has an open sustained-warning episode. -/// p: Active Moon palette. -/// -/// Returns: -/// A compact metric cell with a stable footprint. /// One startup cell: fixed width, clipped, never wrapping. /// /// Deliberately NOT a [`metric_cell`]: that helper reserves a warning-icon lead driven by a @@ -805,26 +796,56 @@ fn startup_text_cell(cell: StartupCell, value_w: f32, p: MoonPalette) -> Statefu StartupCell::Done { .. } => p.text_soft, StartupCell::Absent => p.text_muted, }; - startup_slot(startup_cell_text(cell), value_w, color) + plain_slot( + "core-status-startup", + startup_cell_text(cell), + value_w, + color, + ) } -/// The startup column's chrome, independent of what is written in it. +/// One reported-build cell, in the same slot chrome the startup column uses. /// -/// Extracted so the per-core row can put a connection VERDICT in the same slot without the group -/// row above it changing at all: a group summarises several cores and has no single reason to -/// state, so it keeps rendering its rolled-up figure through [`startup_text_cell`]. One owner for -/// the width, clipping and no-wrap behaviour means the two can never drift apart. +/// A reported build is a frozen identity fact rather than a live measurement, so it is subordinate +/// to the metrics beside it — `text_soft`, the same treatment [`startup_text_cell`] gives a +/// FINISHED startup. Absence and disagreement are `text_muted`: they mean "no answer", and painting +/// them amber or red would turn a fact this terminal cannot establish into an accusation. /// /// Args: +/// text: The already-composed cell text. +/// reported: Whether there is a build to show, as opposed to absence or disagreement. +/// value_w: Current column width from [`ByIpWidths`]. +/// p: Active Moon palette. +/// +/// Returns: +/// A compact build cell with a stable footprint. +fn version_slot(text: String, reported: bool, value_w: f32, p: MoonPalette) -> Stateful
{ + let color = if reported { p.text_soft } else { p.text_muted }; + plain_slot("core-status-version", text, value_w, color) +} + +/// A fixed-width text cell with NO warning-icon lead, for a column that has no `WarnAxis` behind +/// it. +/// +/// Deliberately NOT [`metric_cell`]: that helper reserves a lead driven by a `*_warn` bool, and a +/// column with no warning source would reserve space that can never light. The startup column, the +/// per-core connection VERDICT that replaces it, and the reported build all render through this one +/// owner, so their width, clipping and no-wrap behaviour cannot drift apart. +/// +/// The id is a PARAMETER because a row now draws more than one of these; two children sharing a +/// stateful element id is a real GPUI hazard, not a style point. +/// +/// Args: +/// id: Stable element identity, unique among the cells of one row. /// text: Already-composed cell text. /// value_w: Current column width from [`ByIpWidths`]. /// color: Packed theme colour for the text. /// /// Returns: /// A compact cell with a stable footprint. -fn startup_slot(text: String, value_w: f32, color: u32) -> Stateful
{ +fn plain_slot(id: &'static str, text: String, value_w: f32, color: u32) -> Stateful
{ div() - .id("core-status-startup") + .id(id) .w(px(value_w)) .flex_none() .overflow_hidden() @@ -833,6 +854,23 @@ fn startup_slot(text: String, value_w: f32, color: u32) -> Stateful
{ .child(text) } +/// Render one inline metric value in a fixed-width box, with a leading slot for its warning mark. +/// +/// No decorative icon: the icon lead holds the SUSTAINED-warning triangle for THIS metric when set, +/// so the mark sits directly left of its own value instead of trailing the fixed box into the next +/// column (which read as the neighbour's warning). Empty otherwise, so every column stays aligned and +/// matches the header caption's lead. +/// +/// Args: +/// value: Preformatted localized metric text. +/// value_w: Width in pixels for the value box, from the frame's shared widths. +/// icon_w: Width of the warning-icon lead, from the same widths. +/// color: Threshold color of the number. +/// warn: Whether this metric has an open sustained-warning episode. +/// p: Active Moon palette. +/// +/// Returns: +/// A compact metric cell with a stable footprint. fn metric_cell( value: String, value_w: f32, diff --git a/crates/moon-ui-gpui/src/panels/core_status/server_view/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/server_view/tests.rs index ed77d9bf..2f61ab55 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/server_view/tests.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/server_view/tests.rs @@ -6,7 +6,7 @@ use moon_core::feed::{ConnStatus, CoreEndpoint}; use moon_core::session::{CoreStartupStatus, CoreSysStatus}; use super::tree_items; -use crate::panels::core_status::model::{CoreStatusRow, aggregate_servers}; +use crate::panels::core_status::model::{aggregate_servers, CoreStatusRow}; /// Build one ready core snapshot at an address. fn row(id: u64, address: IpAddr, port: u16) -> CoreStatusRow { @@ -24,6 +24,7 @@ fn row(id: u64, address: IpAddr, port: u16) -> CoreStatusRow { api_key: crate::panels::core_status::model::ApiKeyState::Unknown, api_warn: false, startup: CoreStartupStatus::default(), + server_version: None, } } 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 6a99a7bc..52796ad8 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/table.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/table.rs @@ -1,22 +1,28 @@ //! Flat-mode Core Status table: one core per row with plain, sortable columns. //! -//! A numeric metric renders as a dash until its `Event::KernelHealth` field has arrived at least -//! once. Header clicks sort through the panel, like every other data table. +//! Telemetry metrics render as a dash until their `Event::KernelHealth` fields have arrived at +//! least once; the build column does the same until the core reports its build. Header clicks sort +//! through the panel, like every other data table. use std::collections::HashMap; use super::model::ServerKey; -use super::presentation::{api_expiry_text, connection_presentation, memory_u16, percent, ping}; +use super::ordering::{FlatLine, FlatSection}; +use super::presentation::{ + api_expiry_text, connection_presentation, memory_u16, percent, ping, version_text, +}; use super::startup::{startup_cell, startup_cell_text, startup_facts, startup_tooltip}; use super::*; use crate::conn_diag::{fault_facts, fault_tooltip}; +use gpui::prelude::FluentBuilder; use moon_core::feed::{Diagnosis, diagnose}; use moon_ui::{MoonDataCell, MoonDataRow, MoonDataTable, MoonDataTableColumn}; /// Build the fixed set of sortable server, core, connection, and telemetry columns. /// /// Returns: -/// Left-aligned identity columns followed by right-aligned numeric metric columns. +/// Left-aligned identity columns followed by the right-aligned build and numeric telemetry +/// columns. fn columns() -> Vec { let numeric = |key: &'static str, title: String, w: f32| { MoonDataTableColumn::new(key, title, w) @@ -30,6 +36,12 @@ fn columns() -> Vec { .sortable(true), MoonDataTableColumn::new("status", t!("core_status.col.status").to_string(), 110.0) .sortable(true), + // Right-aligned like the metrics: a column of 3-5 digit build numbers has to align on the + // digit, and the one word form ("-") is short enough to sit right. It follows `status` + // because it completes the identity block — what this core IS — rather than reporting how + // it is doing. Mid-list insertion costs nothing: persisted widths are keyed by column key, + // never by index. + numeric("version", t!("core_status.col.version").to_string(), 96.0), numeric("cpu_proc", t!("core_status.col.cpu_proc").to_string(), 90.0), numeric("cpu_sys", t!("core_status.col.cpu_sys").to_string(), 90.0), numeric( @@ -60,29 +72,49 @@ fn columns() -> Vec { ] } -/// Render the one-row-per-core telemetry table. +/// Render the telemetry table: one row per core, under a heading per exchange. +/// +/// `MoonDataTable` has no group-row concept — every line it draws is a row of one uniform height — +/// so an exchange heading IS a row, and `lines` rather than `rows` is what indexes the table. That +/// makes the row index address a LINE, not a core: `state.selected_row` and every +/// `MoonDataTableEvent` index are line indices here. Nothing in this panel reads them today, but a +/// row action added later must map back through `lines` instead of indexing `rows`. /// /// Args: /// id: Stable table element identity. /// rows: Immutable, already-sorted visible-core snapshot. +/// lines: Headings and member rows in render order, addressing `rows` by index. /// server_names: Server display name per server key, for the "server" column. +/// logos_ready: Whether the off-thread logo prewarm has landed. +/// sorted: Whether a column sort is active, which the headings explain. /// state: Persisted table interaction state. /// cx: Panel context used for palette, empty-state localization, and the sort callback. /// /// Returns: /// Full-size data-table host with real, sortable columns. +#[allow(clippy::too_many_arguments)] pub(super) fn core_status_table( id: &'static str, rows: Rc>, + lines: Rc>, server_names: Rc>, + logos_ready: bool, + sorted: bool, state: &Entity, cx: &Context, ) -> impl IntoElement { + // Keyed on the CORES, not the lines: a table holding nothing but headings is not representable + // (a section is emitted only when it has members), and the empty message must not appear + // beneath a heading. let empty = rows.is_empty(); - let row_count = rows.len(); + let row_count = lines.len(); let table_rows = rows.clone(); let p = MoonPalette::active(cx); let view = cx.entity(); + // Taken from `columns()` rather than written as a literal: a heading row must emit EXACTLY as + // many cells as there are columns, or `MoonDataTable` skips the whole cell permutation for it. + // Deriving the count keeps a column added elsewhere in this table a no-op for the headings. + let section_columns = columns(); crate::panels::common::data_table_host( SharedString::from(format!("{id}-host")), @@ -90,8 +122,14 @@ pub(super) fn core_status_table( t!("core_status.empty").to_string(), p, cx, - MoonDataTable::new(id, row_count, move |ix, _window, _app| { - core_status_row(&table_rows[ix], &server_names) + MoonDataTable::new(id, row_count, move |ix, _window, app| match &lines[ix] { + FlatLine::Section(section) => { + // The column COUNT is all a heading needs now. It used to need the column + // ORDER too, to pick which cell hosted the caption; the banner spans the row, + // so which column sits leftmost stopped mattering. + section_row(section, logos_ready, sorted, section_columns.len(), p, app) + } + FlatLine::Core(row) => core_status_row(&table_rows[*row], &server_names), }) .columns(columns()) .state(state) @@ -111,8 +149,7 @@ pub(super) fn core_status_table( /// server_names: Server display name per server key. /// /// Returns: -/// One row with server, core, connection, and the numeric metric cells (CPU, memory, both -/// pings, logical CPUs). +/// One row in column order, with server, core, connection, build, and telemetry cells. fn core_status_row(r: &CoreStatusRow, server_names: &HashMap) -> MoonDataRow { let sys = &r.sys; let server = server_names @@ -126,6 +163,7 @@ fn core_status_row(r: &CoreStatusRow, server_names: &HashMap) MoonDataCell::text(server), MoonDataCell::text(r.name.clone()), MoonDataCell::element(status_cell(r, diag.as_ref())), + MoonDataCell::text(version_text(r.server_version)), MoonDataCell::text(percent(sys.process_cpu_percent)), MoonDataCell::text(percent(sys.system_cpu_percent)), MoonDataCell::text(memory_u16(sys.used_memory_mb)), @@ -138,6 +176,133 @@ fn core_status_row(r: &CoreStatusRow, server_names: &HashMap) ]) } +/// Left padding `MoonDataTable` puts inside every cell, mirrored from MoonUI's own +/// `MoonTableColumn::cell_pad_left` default. Only the section band needs it: it has to paint the +/// area the cell reserves for padding, which is the space between two columns. +const CELL_PAD_LEFT: f32 = 12.0; + +/// Right padding `MoonDataTable` puts inside every cell, mirroring `cell_pad_right`. +const CELL_PAD_RIGHT: f32 = 8.0; + +/// Render one exchange heading as a full-width band across the table. +/// +/// Same tokens as the Assets panel's exchange heading, so the two read as one component — with one +/// deliberate difference: the height is the TABLE ROW height rather than Assets' 23 px, because +/// `MoonDataTable` draws a uniform-height virtual list and a heading here is one of its rows. Do +/// not "fix" that to match Assets. +/// +/// The band is an ABSOLUTELY POSITIONED child of each cell rather than a plain full-size one: a +/// cell carries 12 px of left and 8 px of right padding and clips its overflow, so an in-flow child +/// would paint the content box only and leave a 20 px gap at every column boundary — the band would +/// read as dashes rather than as a stripe. +/// +/// Args: +/// section: The heading's identity, caption, brand and member count. +/// logos_ready: Whether the off-thread logo prewarm has landed. +/// sorted: Whether a column sort is active, which the hover explains. +/// column_count: How many cells the row must emit; anything else disables cell ordering. +/// p: Active palette. +/// cx: Application context, for font-scaled geometry. +/// +/// Returns: +/// One row whose every cell paints the band, with the caption on a row-wide banner above them. +fn section_row( + section: &FlatSection, + logos_ready: bool, + sorted: bool, + column_count: usize, + p: MoonPalette, + cx: &App, +) -> MoonDataRow { + // Keyed on the venue IDENTITY, never on the caption: an element id built from rendered text + // changes with the interface language and with a core build's spelling, which makes GPUI treat + // one heading as a different element and drop its hover state. + let key = match section.section { + crate::core_order::ExchangeSection::Venue(id) => format!("{}-{}", id.code, id.dex), + crate::core_order::ExchangeSection::Unidentified => "unknown".to_string(), + }; + let logo = logos_ready + .then_some(section.brand) + .flatten() + .and_then(crate::media::exchange_logos::exchange_logo); + let count = t!("core_status.cores_n", n = section.members).to_string(); + // The sort arrow MoonUI draws says nothing about being section-scoped, so the hover says it. + // The caption itself no longer needs the hover to be readable — the banner gives it the row. + let mut hover = format!("{} - {}", section.label, count); + if sorted { + hover.push('\n'); + hover.push_str(&t!("core_status.section_sort_hint")); + } + + MoonDataRow::new((0..column_count).map(|_index| { + // Inset NEGATIVELY by the cell's own padding, which is what makes the band read as one + // stripe: a cell pads its content by 12 px left and 8 px right and clips its overflow, so a + // band bounded by that content box would leave 20 px unpainted at every column boundary and + // the heading would look like separated segments. Pulling the edges back out covers the + // padding, and the cell's `overflow_hidden` trims whatever overshoots -- so this lands + // correctly whether the absolute box resolves against the content box or the padding box. + // + // The band stays PER CELL for the SAME reason it always did, and for no reason involving + // scroll: a cell clips its own overflow, so painting the stripe inside each one is what + // covers the 20 px of padding at every column boundary. (An earlier version of this comment + // claimed the band had to stay per cell because a banner-wide stripe would sit still during + // horizontal scroll. That was WRONG and three independent reviews said so: the banner and + // the cells are children of the same natively-scrolled content subtree in + // `MoonDataTable`, so they move together. Collapsing the band into the banner is therefore + // an option, not a hazard — it is simply not this change.) + MoonDataCell::element( + div().relative().size_full().child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(-CELL_PAD_LEFT)) + .right(px(-CELL_PAD_RIGHT)) + .bg(design::moon_alpha(p.panel_high, 0.72)) + .border_b_1() + .border_color(rgb(p.border_soft)), + ), + ) + })) + // The heading's own content spans the WHOLE row instead of living in one cell. It used to sit + // in whichever column the user had dragged leftmost, where "BitGet Futures" was cut to + // "BitGet Futu..." by a ~110 px column while the rest of the row sat empty -- a caption is the + // one thing on this line that has to be readable. `MoonDataRow::banner` is MoonUI's escape from + // the per-cell clipping that caused it, so the caption is bounded by the ROW now, and the count + // rides the same flex line at the far end rather than needing a cell of its own. + .banner( + h_flex() + .id(SharedString::from(format!("cs-exchange-{key}"))) + .size_full() + .items_center() + .justify_between() + .pl(px(CELL_PAD_LEFT)) + .pr(px(CELL_PAD_RIGHT)) + .gap(design::ui_px(cx, 6.0)) + .text_size(design::t_caption(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(p.text_muted)) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(design::ui_px(cx, 6.0)) + .when_some(logo, |row, logo| { + row.child( + img(logo) + .flex_none() + .w(design::ui_px(cx, 13.0)) + .h(design::ui_px(cx, 13.0)) + .rounded(design::ui_px(cx, 2.0)), + ) + }) + .child(div().min_w_0().truncate().child(section.label.clone())), + ) + .child(div().flex_none().child(count)) + .tooltip(crate::panels::common::text_tooltip(hover)), + ) +} + /// The status cell: the short verdict, with reason and next step behind a hover. /// /// The cell text alone is two words, because the column is 110 px wide; the action a user needs is diff --git a/crates/moon-ui-gpui/src/panels/core_status/tests.rs b/crates/moon-ui-gpui/src/panels/core_status/tests.rs index a2784688..a29f61f4 100644 --- a/crates/moon-ui-gpui/src/panels/core_status/tests.rs +++ b/crates/moon-ui-gpui/src/panels/core_status/tests.rs @@ -3,8 +3,8 @@ //! The warning detectors (sustained CPU, memory growth) moved to `backend::core_warn`; their tests //! live beside that engine now. -use super::CoreStatusMode; use super::ordering::natural_cmp; +use super::{mode_ctx_id, CoreStatusMode}; /// `mod.rs:CoreStatusMode::default` must remain By IP; changing it to Flat makes every newly opened /// Core Status panel bypass the server overview. @@ -13,6 +13,69 @@ fn new_core_status_panels_default_to_by_ip() { assert_eq!(CoreStatusMode::default(), CoreStatusMode::ByIp); } +/// `mod.rs:mode_ctx_id` must preserve the dock/window suffix chosen by its argument. Hard-coding +/// the detached flag would let a dock tab silently overwrite the mode a detached Core Status window +/// restores. +#[test] +fn core_status_mode_contexts_are_distinct_for_dock_and_window() { + assert_eq!(mode_ctx_id(false), "core-status-mode:dock"); + assert_eq!(mode_ctx_id(true), "core-status-mode:win"); + assert_ne!(mode_ctx_id(false), mode_ctx_id(true)); +} + +/// `mod.rs:CoreStatusMode::code/from_code` must retain the documented stable vocabulary. Changing +/// a code mapping or rejecting surrounding whitespace would reopen a persisted Flat or Warnings +/// panel on By IP after restart. +#[test] +fn core_status_mode_codes_round_trip_and_unknown_codes_fall_back() { + for (mode, code) in [ + (CoreStatusMode::ByIp, "by-ip"), + (CoreStatusMode::Flat, "flat"), + (CoreStatusMode::Warnings, "warnings"), + ] { + assert_eq!(mode.code(), code); + assert_eq!(CoreStatusMode::from_code(code), mode); + } + + assert_eq!(CoreStatusMode::from_code(" flat "), CoreStatusMode::Flat); + for code in ["", "garbage", "tree"] { + assert_eq!(CoreStatusMode::from_code(code), CoreStatusMode::ByIp); + } +} + +/// `CoreStatusView::new` must restore without calling `set_core_status_mode`, while it and +/// `interactions.rs:set_mode` both derive the key from their real detached state. A constructor +/// write would mark fresh layouts dirty, and a literal context flag would merge dock and window tabs. +#[test] +fn core_status_mode_restore_is_read_only_and_uses_each_real_context() { + let panel_source = include_str!("mod.rs"); + let constructor = panel_source + .split_once("fn new(") + .expect("Core Status constructor must remain present") + .1; + let restore_start = constructor + .find("let mode =") + .expect("constructor must restore the Core Status mode"); + let restore_end = constructor[restore_start..] + .find("let saved_widths") + .expect("mode restoration must precede table-state setup") + + restore_start; + let restore = &constructor[restore_start..restore_end]; + assert!(restore.contains("&mode_ctx_id(detached)")); + assert!(!restore.contains("set_core_status_mode")); + assert!(!restore.contains("mode_ctx_id(false)")); + assert!(!restore.contains("mode_ctx_id(true)")); + + let interactions_source = include_str!("interactions.rs"); + let set_mode = interactions_source + .split_once("fn set_mode(") + .expect("Core Status mode writer must remain present") + .1; + assert!(set_mode.contains("&super::mode_ctx_id(self.detached)")); + assert!(!set_mode.contains("mode_ctx_id(false)")); + assert!(!set_mode.contains("mode_ctx_id(true)")); +} + /// `ordering.rs:natural_cmp` sorts server names as a human reads them: numbers by value (so `Server 2` /// precedes `Server 10`, not the lexical reverse) and custom names alphabetically. #[test] diff --git a/crates/moon-ui-gpui/src/persistence/table_persist.rs b/crates/moon-ui-gpui/src/persistence/table_persist.rs index 270d62b0..eda92717 100644 --- a/crates/moon-ui-gpui/src/persistence/table_persist.rs +++ b/crates/moon-ui-gpui/src/persistence/table_persist.rs @@ -13,9 +13,11 @@ //! another table's columns or sort requires no table-specific storage code here. //! //! The module also owns the SIBLING per-context preferences of those tables — [`report_filters`] -//! and [`set_report_filters`] — because they are keyed by the same [`ctx_id`] and written under the -//! same compare-then-mark-dirty contract. Keeping every writer of that contract in one file is the -//! point; a panel that reaches into `layout` directly is the drift this prevents. +//! and [`set_report_filters`], [`core_status_mode`] and [`set_core_status_mode`] — because they are +//! keyed by the same [`ctx_id`] and written under the same compare-then-mark-dirty contract. That +//! last pair is not a table preference at all; it is here because it is keyed and written exactly +//! like one. Keeping every writer of that contract in one file is the point; a panel that reaches +//! into `layout` directly is the drift this prevents. use std::collections::HashMap; @@ -183,6 +185,64 @@ pub fn set_report_filters( }); } +/// Returns the stored Core Status presentation code for `id`, borrowed from the live layout. +/// +/// The code is OPAQUE here: this module never spells the panel's vocabulary, so a value written by +/// a newer build, or by hand, comes back verbatim. `None` means nothing was ever stored for that +/// context. The caller resolves the code and must fall back to its own default on anything it does +/// not recognise. Pass the same context-qualified id used for widths. +/// +/// Args: +/// backend: Live backend whose layout holds the persisted code. +/// id: Context-qualified Core Status mode key. +/// +/// Returns: +/// The stored opaque code, or `None` when that context has no entry. +pub fn core_status_mode<'a>(backend: &'a Backend, id: &str) -> Option<&'a str> { + backend.layout.core_status_mode.get(id).map(String::as_str) +} + +/// Stores the Core Status presentation code for `id` when it differs from what is already there. +/// +/// The same compare-then-mark-dirty rule as [`set_visible`]: re-selecting the mode already stored +/// writes nothing and leaves `layout_dirty` alone, so a repeated click cannot arm a layout flush. +/// +/// Args: +/// backend: Shared backend whose layout receives a changed code. +/// id: Context-qualified Core Status mode key. +/// code: Opaque stable code owned by the Core Status panel. +/// cx: GPUI application context for the backend update. +/// +/// Returns: +/// Nothing; only a changed code marks the layout dirty. +pub fn set_core_status_mode(backend: &Entity, id: &str, code: &str, cx: &mut App) { + backend.update(cx, |b, _| { + if update_core_status_mode(&mut b.layout.core_status_mode, id, code) { + b.layout_dirty = true; + } + }); +} + +/// Apply one presentation code to the shared map and report whether it changed. +/// +/// Kept pure for the same reason as [`update_sort_preferences`]: insert, switch and no-op then have +/// a direct regression test without constructing a GPUI application context. +/// +/// Args: +/// modes: Shared per-context map to update. +/// id: Context-qualified Core Status mode key. +/// code: Opaque stable code to store for the context. +/// +/// Returns: +/// `true` when the map changed, otherwise `false`. +fn update_core_status_mode(modes: &mut HashMap, id: &str, code: &str) -> bool { + if modes.get(id).map(String::as_str) == Some(code) { + return false; + } + modes.insert(id.to_string(), code.to_string()); + true +} + /// Stores the table's current column widths for `id` when they change. /// /// Intended for a render/change observer: it only compares maps unless an update is needed. diff --git a/crates/moon-ui-gpui/src/persistence/table_persist/tests.rs b/crates/moon-ui-gpui/src/persistence/table_persist/tests.rs index f53398a2..c8d0e0ca 100644 --- a/crates/moon-ui-gpui/src/persistence/table_persist/tests.rs +++ b/crates/moon-ui-gpui/src/persistence/table_persist/tests.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use moon_core::config::TableSortPreference; -use super::update_sort_preferences; +use super::{update_core_status_mode, update_sort_preferences}; /// `table_persist.rs:update_sort_preferences` must preserve a descending choice verbatim. /// @@ -64,3 +64,40 @@ fn unchanged_sort_is_a_noop_and_default_removes_the_entry() { )); assert!(preferences.is_empty()); } + +/// `table_persist.rs:update_core_status_mode` must change only the requested context and report +/// no-op repeats. Returning true for an identical code, or overwriting `:win` from `:dock`, would +/// schedule needless layout flushes or silently replace a detached panel's remembered tab. +#[test] +fn core_status_mode_updates_are_context_isolated_and_compare_then_mark() { + let mut modes = HashMap::new(); + assert!(update_core_status_mode( + &mut modes, + "core-status-mode:win", + "warnings", + )); + assert!(update_core_status_mode( + &mut modes, + "core-status-mode:dock", + "flat", + )); + assert!(!update_core_status_mode( + &mut modes, + "core-status-mode:dock", + "flat", + )); + assert!(update_core_status_mode( + &mut modes, + "core-status-mode:dock", + "by-ip", + )); + + assert_eq!( + modes.get("core-status-mode:dock").map(String::as_str), + Some("by-ip") + ); + assert_eq!( + modes.get("core-status-mode:win").map(String::as_str), + Some("warnings") + ); +} diff --git a/crates/moon-ui-gpui/tests/theme_contract/core_status.rs b/crates/moon-ui-gpui/tests/theme_contract/core_status.rs new file mode 100644 index 00000000..c9c13426 --- /dev/null +++ b/crates/moon-ui-gpui/tests/theme_contract/core_status.rs @@ -0,0 +1,24 @@ +//! Source-level Core Status table contracts for the binary-only GPUI crate. + +use super::support::{braced_body, code_only, read_src}; + +/// `table.rs:section_row` must create one cell per declared table column through `column_count`. +/// +/// Mutation: replace `0..column_count` with a literal count. MoonUI would skip the heading-row +/// cell permutation after a column is added, leaving a misplaced, partially unpainted section band. +#[test] +fn core_status_section_row_derives_its_cell_count_from_declared_columns() { + let table = read_src("panels/core_status/table.rs"); + let host = code_only(braced_body(&table, "pub(super) fn core_status_table(")); + let section = code_only(braced_body(&table, "fn section_row(")); + + assert!( + host.contains("let section_columns = columns();") + && host.contains("section_columns.len(),"), + "the section-row argument must derive from the table's declared columns" + ); + assert!( + section.contains("MoonDataRow::new((0..column_count).map(|_index| {"), + "the heading row must emit exactly one cell for every declared column" + ); +} diff --git a/crates/moon-ui-gpui/tests/theme_contract/main.rs b/crates/moon-ui-gpui/tests/theme_contract/main.rs index cbf480aa..64e4ff79 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/main.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/main.rs @@ -17,6 +17,7 @@ mod support; mod analytics; mod chart; mod core_pick; +mod core_status; mod naming; mod shell; mod shot; diff --git a/locales/core_status.yml b/locales/core_status.yml index 219f1b01..6cd6a980 100644 --- a/locales/core_status.yml +++ b/locales/core_status.yml @@ -11,6 +11,12 @@ core_status.cores_n: ru: "Ядер: %{n}" en: "Cores: %{n}" es: "Núcleos: %{n}" +# Hover on a Flat-view exchange heading while a column sort is on. MoonUI draws one unqualified +# sort arrow for the whole table, so this is where the section-scoped meaning is spelled out. +core_status.section_sort_hint: + ru: "Сортировка — внутри каждой биржи" + en: "Sorted within each exchange" + es: "Ordenado dentro de cada bolsa" core_status.cores: ru: "Ядра" en: "Cores" @@ -74,6 +80,10 @@ core_status.hide_ip: ru: "Скрыть IP" en: "Hide IP" es: "Ocultar IP" +core_status.ip_unknown: + ru: "Терминал ещё не получил адрес этого сервера" + en: "The terminal has no address for this server yet" + es: "El terminal aún no tiene la dirección de este servidor" core_status.cpu_load: ru: "%{value} (%{n})" en: "%{value} (%{n})" @@ -174,6 +184,14 @@ core_status.col.updated: es: "Actualizado" # Exchange API-key lifetime column (flat table). The unit is in the heading, so the cells hold a # bare number. +# MoonBot build the core reported. The product noun IS the heading, so the cells hold +# a bare number; it is a proper name and is not translated. BOTH views read this one key -- +# they would never legitimately differ, and two identical values are one translator edit away +# from disagreeing. `core_status.col.startup` is shared the same way. +core_status.col.version: + ru: "MoonBot" + en: "MoonBot" + es: "MoonBot" core_status.col.api_key: ru: "АПИ (дн)" en: "API (d)" @@ -195,6 +213,12 @@ core_status.hdr.ip: ru: "IP" en: "IP" es: "IP" +# Hover on a By-IP column divider. It has to name the double-click gestures: nothing else in the +# panel advertises them, and the Shift variant is the only reset a detached window offers. +core_status.col_resize: + ru: "Потяните, чтобы изменить ширину. Двойной клик сбрасывает колонку, Shift+двойной клик — все" + en: "Drag to resize. Double-click resets this column, Shift+double-click resets all" + es: "Arrastre para redimensionar. Doble clic restablece esta columna, Shift+doble clic todas" # API-key column heading in the By-IP tree; carries the unit its cells omit. core_status.hdr.api_key: ru: "АПИ (дн)" From cdb68f8ee5ee601b2873d1023356b61d657244b5 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:42:48 +0200 Subject: [PATCH 2/2] test(core-status): pin the panel's new columns in the shell contract The By-IP and flat tables gained a build column, an exchange section band and a mask control. The existing telemetry-column contract asserted the old cell set, so it described a table that no longer exists; extend it rather than let it pass vacuously. --- .../tests/theme_contract/shell.rs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/moon-ui-gpui/tests/theme_contract/shell.rs b/crates/moon-ui-gpui/tests/theme_contract/shell.rs index 51145883..d310c857 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/shell.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/shell.rs @@ -1072,6 +1072,7 @@ fn core_status_table_binds_scoped_telemetry_columns() { for key in [ "core_status.col.server", + "core_status.col.version", "core_status.col.cpu_proc", "core_status.col.cpu_sys", "core_status.col.cpus", @@ -1101,7 +1102,7 @@ fn core_status_table_binds_scoped_telemetry_columns() { "count(sys.logical_cpu_count)", ] { assert!( - flat_row.contains(binding), + code_only(flat_row).contains(binding), "the Flat Core Status row lost the scoped telemetry binding `{binding}`" ); } @@ -1110,7 +1111,7 @@ fn core_status_table_binds_scoped_telemetry_columns() { "memory_free(group.process_memory_mb, group.free_physical_memory_mb)", ] { assert!( - server_row.contains(binding), + code_only(server_row).contains(binding), "the By IP server row lost the scoped telemetry binding `{binding}`" ); } @@ -1119,10 +1120,32 @@ fn core_status_table_binds_scoped_telemetry_columns() { "memory_u16(core.sys.used_memory_mb)", ] { assert!( - process_row.contains(binding), + code_only(process_row).contains(binding), "the By IP process row lost the scoped telemetry binding `{binding}`" ); } + for (body, binding, row) in [ + ( + flat_row, + "MoonDataCell::text(version_text(r.server_version))", + "Flat", + ), + ( + server_row, + "version_group_text(group.version)", + "By IP server", + ), + ( + process_row, + "version_text(core.server_version)", + "By IP core", + ), + ] { + assert!( + code_only(body).contains(binding), + "the {row} row lost the Core Status build binding `{binding}`" + ); + } } /// `core_status/server_view.rs:grouped_server_view` must not capture a strong