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/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 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: "АПИ (дн)"