From 74d40b103bc803ff532885619f9a8a07ef097353 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:56:36 +0100 Subject: [PATCH 1/2] fix(core-expert): copy the Telegram channel PIN --- .../src/core_expert/pages/special/remote.rs | 49 ++++- .../core_expert/pages/special/remote/tests.rs | 180 ++++++++++++++++++ 2 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs diff --git a/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs b/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs index d8e03dfd5..f40e90f3c 100644 --- a/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs +++ b/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs @@ -232,20 +232,51 @@ pub(super) fn own_bot(view: &Entity, rm: &RemoteCtx<'_>, cx: &Ap .into_any_element() } -/// The PIN to type into the channel, as the core generated it, or a dash before it did. +/// The PIN to type into the channel, with a copy action while the core has supplied one. pub(super) fn pin(rm: &RemoteCtx<'_>, p: MoonPalette, cx: &App) -> AnyElement { let pin = rm .snapshot .and_then(|s| s.channel_pin.clone()) .filter(|pin| !pin.is_empty()); let shown = pin.is_some(); - div() - .font_family(design::mono()) - .child(caption( - pin.unwrap_or_else(|| "—".to_string()), - shown && rm.fresh, - p, - cx, - )) + h_flex() + .debug_selector(|| "exp-sp-pin-row".to_string()) + .w_full() + .items_center() + .gap(design::ui_px(cx, 6.0)) + .child( + div() + .debug_selector(|| "exp-sp-pin-value".to_string()) + .flex_none() + .font_family(design::mono()) + .child(caption( + pin.clone().unwrap_or_else(|| "—".to_string()), + shown && rm.fresh, + p, + cx, + )), + ) + .children(pin.map(|pin| { + div() + .debug_selector(|| "exp-sp-copy-pin".to_string()) + .flex_none() + .child( + MoonButton::new("exp-sp-copy-pin-button") + .xsmall() + .mono(false) + .label(t!("core_cmds.common.copy").to_string()) + .variant(MoonButtonVariant::Neutral) + // Keep the full localized label beside the PIN at the window minimum. + .padding_x(4.0) + .on_click(move |_, _window, app| { + app.write_to_clipboard(ClipboardItem::new_string(pin.clone())); + }) + .render(), + ) + .into_any_element() + })) .into_any_element() } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs b/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs new file mode 100644 index 000000000..dbcb62275 --- /dev/null +++ b/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs @@ -0,0 +1,180 @@ +//! PIN-row interaction and narrow-window layout in every shipped locale. + +use gpui::{ + Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels, Render, + Styled, VisualTestContext, Window, div, px, +}; +use moon_core::{ + config::{Language, UiThemeMode}, + feed::CoreTelegramState, +}; +use moon_ui::{MoonPalette, MoonTheme, h_flex, v_flex}; +use rust_i18n::t; + +use crate::{ + core_expert::{MIN_SIZE, remote::RemoteBlock}, + design, +}; + +use super::{RemoteCtx, pin}; + +const PIN: &str = "739182"; + +/// Render the production PIN row in the narrowest column its real three-column page can give it. +struct Host { + pin: Option<&'static str>, +} + +impl Render for Host { + /// Reserve the expert rail's maximum share of the minimum window, then lay out three equal + /// remote-page columns with the production PIN row in the middle one. + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let snapshot = CoreTelegramState { + settings_supported: true, + channel_pin: self.pin.map(str::to_string), + ..Default::default() + }; + let block = RemoteBlock::default(); + let remote = RemoteCtx { + block: &block, + snapshot: Some(&snapshot), + fresh: true, + track: None, + core_name: None, + selected_cores: 1, + }; + h_flex() + .size_full() + .child(div().flex_none().w(px(MIN_SIZE.0 * 0.45))) + .child( + v_flex() + .flex_1() + .min_w_0() + .px(design::ui_px(cx, design::HEADER_PAD_X)) + .child( + h_flex() + .w_full() + .items_start() + .gap(design::ui_px(cx, 10.0)) + .child(v_flex().flex_1().min_w_0()) + .child( + v_flex() + .debug_selector(|| "exp-sp-pin-test-column".to_string()) + .flex_1() + .min_w_0() + .child(pin(&remote, MoonPalette::active(cx), cx)), + ) + .child(v_flex().flex_1().min_w_0()), + ), + ) + } +} + +/// Open and paint the PIN host at the expert window's real minimum dimensions. +fn open_pin_window(cx: &mut gpui::TestAppContext, pin: Option<&'static str>) -> VisualTestContext { + let handle = cx.open_window(gpui::size(px(MIN_SIZE.0), px(MIN_SIZE.1)), |_, _| Host { + pin, + }); + let mut visual = VisualTestContext::from_window(handle.into(), cx); + for _ in 0..3 { + visual.update(|window, app| { + let _ = window.draw(app); + }); + } + visual +} + +/// Whether one laid-out rectangle stays inside another, allowing only fractional-pixel rounding. +fn inside(child: Bounds, parent: Bounds) -> bool { + child.origin.x + px(0.5) >= parent.origin.x + && child.right() <= parent.right() + px(0.5) + && child.origin.y + px(0.5) >= parent.origin.y + && child.bottom() <= parent.bottom() + px(0.5) +} + +/// A wide translation or missing flex constraint would push the PIN or its copy action out of the +/// narrowest column at the window minimum. +#[gpui::test] +fn pin_and_copy_fit_every_locale_at_minimum_window_width(cx: &mut gpui::TestAppContext) { + cx.update(moon_ui::init); + cx.update(|app| { + MoonTheme::install_config( + crate::startup::moon_theme_config_for_presentation(UiThemeMode::Light, 1.0), + app, + ) + }); + for language in Language::ALL { + let _locale = crate::test_locale::force(language.code()); + let mut visual = open_pin_window(cx, Some(PIN)); + let at = language.code(); + let column = visual + .debug_bounds("exp-sp-pin-test-column") + .unwrap_or_else(|| panic!("PIN column not laid out ({at})")); + let row = visual + .debug_bounds("exp-sp-pin-row") + .unwrap_or_else(|| panic!("PIN row not laid out ({at})")); + let value = visual + .debug_bounds("exp-sp-pin-value") + .unwrap_or_else(|| panic!("PIN value not laid out ({at})")); + let button = visual + .debug_bounds("exp-sp-copy-pin") + .unwrap_or_else(|| panic!("copy button not laid out ({at})")); + assert!(inside(row, column), "PIN row escaped its column ({at})"); + assert!(inside(value, row), "PIN escaped its row ({at})"); + assert!( + inside(button, row), + "copy button escaped its row ({at}): column {column:?}, row {row:?}, value {value:?}, button {button:?}" + ); + assert!( + value.right() <= button.origin.x + px(0.5), + "PIN and copy button overlap ({at})" + ); + let required_button_width = visual.update(|_window, app| { + design::ui_caption_text_width(app, &t!("core_cmds.common.copy"), 400.0) + 2.0 * 4.0 + }); + assert!( + f32::from(button.size.width) + 0.5 >= required_button_width, + "copy label is clipped ({at}): button {}, required {required_button_width}", + f32::from(button.size.width) + ); + visual.update(|window, _| window.remove_window()); + } +} + +/// Missing and empty snapshots must offer no copy control whose payload could be blank. +#[gpui::test] +fn absent_pin_has_no_copy_button(cx: &mut gpui::TestAppContext) { + cx.update(moon_ui::init); + let _locale = crate::test_locale::force("en"); + for supplied in [None, Some("")] { + let mut visual = open_pin_window(cx, supplied); + assert!( + visual.debug_bounds("exp-sp-pin-row").is_some(), + "dash placeholder row missing" + ); + assert!( + visual.debug_bounds("exp-sp-copy-pin").is_none(), + "copy offered without a real PIN" + ); + visual.update(|window, _| window.remove_window()); + } +} + +/// Clicking the production MoonButton must put the exact snapshot text, not a placeholder or a +/// reformatted value, on the clipboard. +#[gpui::test] +fn copy_button_writes_the_exact_pin(cx: &mut gpui::TestAppContext) { + cx.update(moon_ui::init); + let _locale = crate::test_locale::force("en"); + let mut visual = open_pin_window(cx, Some(PIN)); + let button = visual + .debug_bounds("exp-sp-copy-pin") + .expect("copy button with a real PIN"); + visual.simulate_click(button.center(), Modifiers::none()); + let copied = visual + .read_from_clipboard() + .and_then(|item| item.text()) + .expect("PIN copied to clipboard"); + assert_eq!(copied, PIN); + visual.update(|window, _| window.remove_window()); +} From 12eaa411c5d4732b375ee6084e737f2236aeb14b Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:25:52 +0100 Subject: [PATCH 2/2] fix(core-expert): confirm Telegram PIN copy --- .../src/core_expert/pages/special/remote.rs | 11 +++++++++-- .../core_expert/pages/special/remote/tests.rs | 16 ++++++++++------ locales/en/core_expert.en.yml | 1 + locales/es/core_expert.es.yml | 1 + locales/pt/core_expert.pt.yml | 1 + locales/ru/core_expert.ru.yml | 1 + locales/tr/core_expert.tr.yml | 1 + locales/uk/core_expert.uk.yml | 1 + locales/vi/core_expert.vi.yml | 1 + 9 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs b/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs index f40e90f3c..54f6fe187 100644 --- a/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs +++ b/crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs @@ -11,7 +11,8 @@ use gpui::*; use moon_ui::{ - MoonAlert, MoonButton, MoonButtonVariant, MoonCheckbox, MoonInput, MoonPalette, h_flex, v_flex, + MoonAlert, MoonButton, MoonButtonVariant, MoonCheckbox, MoonInput, MoonNotification, + MoonPalette, MoonWindowExt as _, h_flex, v_flex, }; use rust_i18n::t; @@ -268,8 +269,14 @@ pub(super) fn pin(rm: &RemoteCtx<'_>, p: MoonPalette, cx: &App) -> AnyElement { .variant(MoonButtonVariant::Neutral) // Keep the full localized label beside the PIN at the window minimum. .padding_x(4.0) - .on_click(move |_, _window, app| { + .on_click(move |_, window, app| { app.write_to_clipboard(ClipboardItem::new_string(pin.clone())); + window.push_notification( + MoonNotification::success( + t!("core_expert.remote_pin_copied").to_string(), + ), + app, + ); }) .render(), ) diff --git a/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs b/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs index dbcb62275..d91f71e30 100644 --- a/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs +++ b/crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs @@ -1,14 +1,14 @@ //! PIN-row interaction and narrow-window layout in every shipped locale. use gpui::{ - Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels, Render, - Styled, VisualTestContext, Window, div, px, + AppContext, Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels, + Render, Styled, VisualTestContext, Window, div, px, }; use moon_core::{ config::{Language, UiThemeMode}, feed::CoreTelegramState, }; -use moon_ui::{MoonPalette, MoonTheme, h_flex, v_flex}; +use moon_ui::{MoonPalette, MoonTheme, Root, h_flex, v_flex}; use rust_i18n::t; use crate::{ @@ -72,8 +72,9 @@ impl Render for Host { /// Open and paint the PIN host at the expert window's real minimum dimensions. fn open_pin_window(cx: &mut gpui::TestAppContext, pin: Option<&'static str>) -> VisualTestContext { - let handle = cx.open_window(gpui::size(px(MIN_SIZE.0), px(MIN_SIZE.1)), |_, _| Host { - pin, + let handle = cx.open_window(gpui::size(px(MIN_SIZE.0), px(MIN_SIZE.1)), |window, cx| { + let host = cx.new(|_| Host { pin }); + Root::new(host, window, cx) }); let mut visual = VisualTestContext::from_window(handle.into(), cx); for _ in 0..3 { @@ -161,7 +162,7 @@ fn absent_pin_has_no_copy_button(cx: &mut gpui::TestAppContext) { } /// Clicking the production MoonButton must put the exact snapshot text, not a placeholder or a -/// reformatted value, on the clipboard. +/// reformatted value, on the clipboard and confirm the action through the existing notification layer. #[gpui::test] fn copy_button_writes_the_exact_pin(cx: &mut gpui::TestAppContext) { cx.update(moon_ui::init); @@ -176,5 +177,8 @@ fn copy_button_writes_the_exact_pin(cx: &mut gpui::TestAppContext) { .and_then(|item| item.text()) .expect("PIN copied to clipboard"); assert_eq!(copied, PIN); + let notifications = + visual.update(|window, app| Root::read(window, app).notification_count(app)); + assert_eq!(notifications, 1, "copy gave no success notification"); visual.update(|window, _| window.remove_window()); } diff --git a/locales/en/core_expert.en.yml b/locales/en/core_expert.en.yml index a47d0bb74..7de694ba6 100644 --- a/locales/en/core_expert.en.yml +++ b/locales/en/core_expert.en.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Your bot's token" core_expert.remote_own_bot_needs_token: "Your own bot needs a token: the core does not hold one yet." core_expert.remote_bot_saved: "The core saved the bot settings." core_expert.remote_pin_ready: "The core issued a PIN — send it to your channel." +core_expert.remote_pin_copied: "PIN copied to clipboard" core_expert.remote_channel_reset: "The core unbound the channel." core_expert.remote_waiting: "Waiting for the core's Telegram state. If it never arrives, the core does not send one, and the channel and own bot are not available here." core_expert.sp_udp_server: "UDP server: %{v}" diff --git a/locales/es/core_expert.es.yml b/locales/es/core_expert.es.yml index f47db916c..da23f06ee 100644 --- a/locales/es/core_expert.es.yml +++ b/locales/es/core_expert.es.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Token de su bot" core_expert.remote_own_bot_needs_token: "Su propio bot necesita un token: el núcleo aún no guarda ninguno." core_expert.remote_bot_saved: "El núcleo guardó los ajustes del bot." core_expert.remote_pin_ready: "El núcleo emitió un PIN: envíelo a su canal." +core_expert.remote_pin_copied: "PIN copiado al portapapeles" core_expert.remote_channel_reset: "El núcleo desvinculó el canal." core_expert.remote_waiting: "Esperando el estado de Telegram del núcleo. Si nunca llega, el núcleo no lo envía y el canal y el bot propio no están disponibles aquí." core_expert.sp_udp_server: "Servidor UDP: %{v}" diff --git a/locales/pt/core_expert.pt.yml b/locales/pt/core_expert.pt.yml index 6e0eb4dd0..a8578d033 100644 --- a/locales/pt/core_expert.pt.yml +++ b/locales/pt/core_expert.pt.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Token do seu bot" core_expert.remote_own_bot_needs_token: "O seu próprio bot precisa de um token: o núcleo ainda não guarda nenhum." core_expert.remote_bot_saved: "O núcleo guardou as definições do bot." core_expert.remote_pin_ready: "O núcleo emitiu um PIN — envie-o para o seu canal." +core_expert.remote_pin_copied: "PIN copiado para a área de transferência" core_expert.remote_channel_reset: "O núcleo desvinculou o canal." core_expert.remote_waiting: "A aguardar o estado do Telegram do núcleo. Se nunca chegar, o núcleo não o envia, e o canal e o bot próprio não estão disponíveis aqui." core_expert.sp_udp_server: "Servidor UDP: %{v}" diff --git a/locales/ru/core_expert.ru.yml b/locales/ru/core_expert.ru.yml index 7f2f0f736..8ff2d0509 100644 --- a/locales/ru/core_expert.ru.yml +++ b/locales/ru/core_expert.ru.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Токен своего бота" core_expert.remote_own_bot_needs_token: "Для своего бота нужен токен: ядро его ещё не хранит." core_expert.remote_bot_saved: "Ядро сохранило настройки бота." core_expert.remote_pin_ready: "Ядро выдало PIN — отправьте его в свой канал." +core_expert.remote_pin_copied: "PIN-код скопирован в буфер обмена" core_expert.remote_channel_reset: "Ядро отвязало канал." core_expert.remote_waiting: "Ждём состояние Telegram от ядра. Если оно так и не придёт, ядро его не присылает — канал и свой бот отсюда недоступны." core_expert.sp_udp_server: "UDP сервер: %{v}" diff --git a/locales/tr/core_expert.tr.yml b/locales/tr/core_expert.tr.yml index 5191ba09c..5d0235b95 100644 --- a/locales/tr/core_expert.tr.yml +++ b/locales/tr/core_expert.tr.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Botunuzun tokeni" core_expert.remote_own_bot_needs_token: "Kendi botunuz için token gerekir: çekirdekte henüz yok." core_expert.remote_bot_saved: "Çekirdek bot ayarlarını kaydetti." core_expert.remote_pin_ready: "Çekirdek bir PIN verdi — kanalınıza gönderin." +core_expert.remote_pin_copied: "PIN panoya kopyalandı" core_expert.remote_channel_reset: "Çekirdek kanal bağını kaldırdı." core_expert.remote_waiting: "Çekirdeğin Telegram durumu bekleniyor. Hiç gelmezse çekirdek bunu göndermiyor demektir; kanal ve kendi bot buradan kullanılamaz." core_expert.sp_udp_server: "UDP sunucusu: %{v}" diff --git a/locales/uk/core_expert.uk.yml b/locales/uk/core_expert.uk.yml index 0c3e89a96..a8567e9c7 100644 --- a/locales/uk/core_expert.uk.yml +++ b/locales/uk/core_expert.uk.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Токен свого бота" core_expert.remote_own_bot_needs_token: "Для свого бота потрібен токен: ядро його ще не зберігає." core_expert.remote_bot_saved: "Ядро зберегло налаштування бота." core_expert.remote_pin_ready: "Ядро видало PIN — надішліть його у свій канал." +core_expert.remote_pin_copied: "PIN-код скопійовано в буфер обміну" core_expert.remote_channel_reset: "Ядро відв'язало канал." core_expert.remote_waiting: "Чекаємо стан Telegram від ядра. Якщо він так і не прийде, ядро його не надсилає — канал і свій бот звідси недоступні." core_expert.sp_udp_server: "UDP-сервер: %{v}" diff --git a/locales/vi/core_expert.vi.yml b/locales/vi/core_expert.vi.yml index 172356e3f..c97b1cada 100644 --- a/locales/vi/core_expert.vi.yml +++ b/locales/vi/core_expert.vi.yml @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Token bot của bạn" core_expert.remote_own_bot_needs_token: "Bot riêng cần token: lõi chưa lưu token nào." core_expert.remote_bot_saved: "Lõi đã lưu cài đặt bot." core_expert.remote_pin_ready: "Lõi đã cấp PIN — hãy gửi nó vào kênh của bạn." +core_expert.remote_pin_copied: "Đã sao chép PIN vào clipboard" core_expert.remote_channel_reset: "Lõi đã hủy liên kết kênh." core_expert.remote_waiting: "Đang chờ trạng thái Telegram từ lõi. Nếu không bao giờ đến, lõi không gửi nó, và kênh cùng bot riêng không khả dụng ở đây." core_expert.sp_udp_server: "Máy chủ UDP: %{v}"