diff --git a/.agents/skills/cross-platform-gui-e2e-test/references/linux.md b/.agents/skills/cross-platform-gui-e2e-test/references/linux.md index 52239c7..29eaba0 100644 --- a/.agents/skills/cross-platform-gui-e2e-test/references/linux.md +++ b/.agents/skills/cross-platform-gui-e2e-test/references/linux.md @@ -9,7 +9,7 @@ Create a fresh AgentBay Linux Computer Use desktop with a reachable X server and - Inspect `uname -m` and select the matching GNU/Linux artifact. Use `x86_64-unknown-linux-gnu` for an x86-64 `linux_latest` image. - Use an ARM64 custom image for `aarch64-unknown-linux-gnu`; never execute it through implicit emulation. - Require a non-empty `DISPLAY` and verify the connection with `xdpyinfo` or an equivalent X11 query. -- Accept XWayland only when the X11 connection is reachable and Ropy's window, hotkey, and clipboard behavior work. Do not claim native Wayland support. +- Accept XWayland only when the X11 connection is reachable and Ropy's window, hotkey, and clipboard behavior work. Start test clients without `WAYLAND_DISPLAY` or `WAYLAND_SOCKET`; Ropy selects Wayland when `WAYLAND_DISPLAY` is set, even if `DISPLAY` is also available. Do not claim native Wayland support. - Check that a desktop window manager is running. Require a desktop panel and session bus only for tray-specific assertions. ## Transfer and launch @@ -31,7 +31,9 @@ test -n "${DISPLAY:-}" xdpyinfo >/dev/null ldd "$exe" | tee "$root/ldd.txt" if grep -q 'not found' "$root/ldd.txt"; then exit 64; fi -nohup "$exe" >"$root/ropy.stdout.log" 2>"$root/ropy.stderr.log" & +nohup env -u WAYLAND_DISPLAY -u WAYLAND_SOCKET \ + XDG_SESSION_TYPE=x11 GDK_BACKEND=x11 \ + "$exe" >"$root/ropy.stdout.log" 2>"$root/ropy.stderr.log" & pid=$! sleep 3 kill -0 "$pid" @@ -40,7 +42,7 @@ printf '%s\n' "$pid" If `ldd` reports missing libraries, classify the stock image as infrastructure-blocked or use an approved custom image containing the required runtime libraries. The current Linux build dependencies include `libxdo-dev` and `libxkbcommon-x11-dev`; the runtime image needs their shared libraries and X11 dependencies, as reported by `ldd`. The KSNI tray backend requires a D-Bus session and a StatusNotifierItem host for tray assertions; it does not require GTK3 or AppIndicator. Do not install arbitrary packages silently into a supposedly reproducible compatibility image. -Discover an installed graphical text editor and use it for the shared marker scenario. Send `Control+Shift+D` to reveal Ropy. Use AgentBay screenshot, keyboard, application, and window tools rather than `xdotool` for the assertions unless a lower-level diagnostic is specifically required. +Discover an installed graphical text editor, launch it with the same X11 environment shown above, and use it for the shared marker scenario. Send `Control+Shift+D` to reveal Ropy. Identify the popup's X11 window with `xwininfo -root -tree` or an equivalent X11 query, then verify that `xprop -id _NET_WM_PID` matches the recorded Ropy PID. A reachable X server alone does not prove that Ropy uses it. Use AgentBay screenshot, keyboard, application, and window tools for the interaction assertions rather than `xdotool` unless a lower-level diagnostic is specifically required. ## Troubleshoot and clean up diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index 6782327..c485fba 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -1,4 +1,6 @@ -use gpui_kit::{AsKeystroke, Bounds, Context, Focusable, KeyUpEvent, Pixels, Window}; +use gpui_kit::{ + AsKeystroke, Bounds, Context, Focusable, KeyUpEvent, Pixels, Window, component::WindowExt, +}; /// History shortcut catalog, validation and registration. pub mod shortcuts; @@ -19,13 +21,14 @@ use crate::{ }; impl RopyBoard { - /// Clear the search input content and blur it + /// Clear the search input without taking focus from an active dialog. pub(crate) fn clear_search(&self, window: &mut Window, cx: &mut Context<'_, Self>) { self.search_input.update(cx, |input, cx| { input.set_value("", window, cx); }); - // Blur the search input to remove focus - window.focus(&self.focus_handle, cx); + if !window.has_active_dialog(cx) { + window.focus(&self.focus_handle, cx); + } } } @@ -308,6 +311,12 @@ impl RopyBoard { return; } + // A resident popup can be hidden and reopened with its dialog still alive. + if window.has_active_dialog(cx) { + self.activate_popup(window, cx); + return; + } + self.selected_index = 0; self.ui_state.hide_preview(); if self.active_panel == ActivePanel::Settings { @@ -329,7 +338,9 @@ impl RopyBoard { pub(crate) fn activate_popup(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) { self.invalidate_copy_confirmation(); self.activated = true; - window.focus(&self.focus_handle, cx); + if !window.has_active_dialog(cx) { + window.focus(&self.focus_handle, cx); + } cx.notify(); active_window(window, cx); } diff --git a/src/gui/board/settings_handler.rs b/src/gui/board/settings_handler.rs index 35f7555..0f9597b 100644 --- a/src/gui/board/settings_handler.rs +++ b/src/gui/board/settings_handler.rs @@ -356,27 +356,6 @@ mod tests { assert_eq!(resolved, "cmd+shift+v"); } - #[test] - fn test_normalize_hotkey_for_save_empty_uses_current_hotkey() { - let normalized = RopyBoard::normalize_hotkey_for_save("", "cmd+shift+c"); - - assert_eq!(normalized, "cmd+shift+c"); - } - - #[test] - fn test_normalize_hotkey_for_save_empty_and_current_empty_uses_default() { - let normalized = RopyBoard::normalize_hotkey_for_save("", ""); - - assert_eq!(normalized, Settings::default().hotkey.activation_key); - } - - #[test] - fn test_normalize_hotkey_for_save_explicit_value_preserves_candidate() { - let normalized = RopyBoard::normalize_hotkey_for_save("cmd+shift+v", "cmd+shift+c"); - - assert_eq!(normalized, "cmd+shift+v"); - } - #[test] fn test_is_valid_storage_pair_storage_below_history_returns_false() { assert!(!RopyBoard::is_valid_storage_pair(10, 9)); diff --git a/src/gui/board/settings_handler/hotkey.rs b/src/gui/board/settings_handler/hotkey.rs index 0c0d13b..dc8aade 100644 --- a/src/gui/board/settings_handler/hotkey.rs +++ b/src/gui/board/settings_handler/hotkey.rs @@ -42,23 +42,6 @@ impl RopyBoard { current_hotkey.trim().to_string() } - pub(super) fn normalize_hotkey_for_save( - candidate_hotkey: &str, - current_hotkey: &str, - ) -> String { - let trimmed_candidate = candidate_hotkey.trim(); - if !trimmed_candidate.is_empty() { - return trimmed_candidate.to_string(); - } - - let trimmed_current = current_hotkey.trim(); - if !trimmed_current.is_empty() { - return trimmed_current.to_string(); - } - - Settings::default().hotkey.activation_key - } - pub(crate) fn refresh_activation_key_placeholder( &self, window: &mut Window, @@ -105,7 +88,7 @@ impl RopyBoard { candidate.trim() }; - self.sync_activation_key_input(value, ¤t_hotkey, window, cx); + self.sync_activation_key_input(value, candidate, window, cx); } pub(crate) fn resolve_activation_key_input(&self, cx: &Context<'_, Self>) -> String { @@ -128,7 +111,7 @@ impl RopyBoard { let current_hotkey = GlobalSettings::read(cx, |s| s.hotkey.activation_key.clone()); let candidate_hotkey = self.resolve_activation_key_input(cx); - Self::normalize_hotkey_for_save(&candidate_hotkey, ¤t_hotkey) != current_hotkey + candidate_hotkey != current_hotkey } pub(crate) fn save_hotkey(&mut self, cx: &mut Context<'_, Self>, window: &mut Window) { @@ -136,11 +119,7 @@ impl RopyBoard { Self::notify_settings_warning(window, cx, t!("settings_recovery_required")); return; } - let current_hotkey = GlobalSettings::read(cx, |s| s.hotkey.activation_key.clone()); - let activation_key = Self::normalize_hotkey_for_save( - &self.resolve_activation_key_input(cx), - ¤t_hotkey, - ); + let activation_key = self.resolve_activation_key_input(cx); if !activation_key.is_empty() && !Self::is_valid_hotkey_input(&activation_key) { Self::notify_settings_warning(window, cx, t!("settings_hotkey_invalid_warning")); diff --git a/src/gui/board/ui_tests.rs b/src/gui/board/ui_tests.rs index b7dba8e..5afb195 100644 --- a/src/gui/board/ui_tests.rs +++ b/src/gui/board/ui_tests.rs @@ -1201,13 +1201,21 @@ fn test_appearance_recovery_rejects_switch_and_theme_without_changing_palette( } #[gpui_kit::test] -fn test_clear_dialog_keyboard_does_not_copy_background_record(cx: &mut TestAppContext) { +fn test_clear_dialog_reactivation_keeps_keyboard_off_background_record(cx: &mut TestAppContext) { let (handle, board, rx) = open_board(cx, vec![record(1), record(2)]); cx.update_window(handle, |_, window, cx| { board.update(cx, |board, cx| { board.open_clear_confirm(ClearConfirmAction::AllHistory, window, cx); }); window.render_frame(cx); + let dialog_focus = window.focused(cx).expect("clear dialog focus"); + board.update(cx, |board, cx| { + board.clear_search(window, cx); + board.on_active_action(&super::Active, window, cx); + }); + window.render_frame(cx); + assert_eq!(window.focused(cx), Some(dialog_focus)); + assert!(board.read(cx).ui_state.clear_confirm_visible()); window.press("shift-enter", cx); window.press("down", cx); assert_eq!(board.read(cx).selected_index, 0); @@ -1219,9 +1227,7 @@ fn test_clear_dialog_keyboard_does_not_copy_background_record(cx: &mut TestAppCo } #[gpui_kit::test] -fn test_delete_dialog_typing_d_keeps_record_and_escape_restores_search_focus( - cx: &mut TestAppContext, -) { +fn test_delete_dialog_reactivation_preserves_escape_and_search_focus(cx: &mut TestAppContext) { let (handle, board, _) = open_board(cx, vec![record(1)]); cx.update_window(handle, |_, window, cx| { board.update(cx, |board, cx| { @@ -1236,6 +1242,13 @@ fn test_delete_dialog_typing_d_keeps_record_and_escape_restores_search_focus( .focus_handle(cx) .is_focused(window) ); + let dialog_focus = window.focused(cx).expect("delete dialog focus"); + board.update(cx, |board, cx| { + board.clear_search(window, cx); + board.on_active_action(&super::Active, window, cx); + }); + window.render_frame(cx); + assert_eq!(window.focused(cx), Some(dialog_focus)); window.input("d", cx); assert!(board.read(cx).ui_state.delete_confirm_visible()); assert_eq!(board.read(cx).pending_delete_id, Some(1)); diff --git a/src/gui/hotkey.rs b/src/gui/hotkey.rs index 445f5d0..4c89523 100644 --- a/src/gui/hotkey.rs +++ b/src/gui/hotkey.rs @@ -67,13 +67,10 @@ impl HotkeyListenerState { fn persist_hotkey(hotkey: &str, cx: &AsyncApp) -> Result<(), HotkeyUpdateError> { cx.update(|cx| { - let mut settings = GlobalSettings::read(cx, Clone::clone); - settings.hotkey.activation_key = hotkey.to_string(); - settings - .save() - .map_err(|error| HotkeyUpdateError::Persistence(error.to_string()))?; - cx.set_global(GlobalSettings::new(settings)); - Ok(()) + GlobalSettings::persist(cx, |settings| { + settings.hotkey.activation_key = hotkey.to_string(); + }) + .map_err(|error| HotkeyUpdateError::Persistence(error.to_string())) }) } @@ -452,6 +449,101 @@ mod tests { }) } + #[gpui_kit::test] + fn test_hotkey_clear_saves_and_remains_disabled_after_reload( + cx: &mut gpui_kit::TestAppContext, + ) { + use std::sync::{Arc, Mutex, RwLock}; + + use gpui_kit::{AppContext, test::TestWindowExt}; + + use crate::{ + clipboard::CopyTracker, + config::Settings, + gui::{board::RopyBoard, repository::GlobalRepository}, + }; + + let directory = tempfile::tempdir().expect("settings directory"); + let settings = Settings::default(); + let initial_hotkey = settings.hotkey.activation_key.clone(); + let (updates, requests) = async_channel::unbounded(); + let (copy_tx, _copy_rx) = async_channel::unbounded(); + cx.update(|cx| { + gpui_kit::init(cx); + cx.set_reduce_motion(true); + cx.set_global(GlobalSettings::at_path( + settings, + Some(directory.path().join("config.toml")), + )); + cx.set_global(GlobalRepository::new(None)); + crate::gui::board::bind_application_keys(cx); + }); + let (handle, board) = cx + .update(|cx| { + gpui_kit::open_window(gpui_kit::WindowOptions::default(), cx, |window, cx| { + cx.new(|cx| { + let mut board = RopyBoard::new( + Arc::new(RwLock::new(Vec::new())), + Arc::new(Mutex::new(CopyTracker::default())), + copy_tx, + window, + cx, + ); + board.activated = true; + board.pinned = true; + board.set_hotkey_tx(updates); + board + }) + }) + }) + .expect("open board"); + cx.update_window(handle, |_, window, cx| { + board.update(cx, |board, cx| board.open_settings_panel(window, cx)); + window.render_frame(cx); + window.click("hotkey-record-button", cx); + window.press("delete", cx); + board.update(cx, |board, cx| assert!(board.has_pending_hotkey(cx))); + window.click("hotkey-save-button", cx); + }) + .expect("clear and save hotkey"); + + let request = requests.try_recv().expect("hotkey update request"); + assert_eq!(request.hotkey, ""); + let mut state = HotkeyListenerState { + current_hotkey: initial_hotkey, + manager: Some(()), + }; + let async_app = cx.to_async(); + let mut registered = false; + let mut activated = false; + process_listener_message( + &mut state, + ListenerMessage::UpdateHotkey(request), + &mut |_| { + registered = true; + Some(()) + }, + &mut |hotkey| persist_hotkey(hotkey, &async_app), + &mut |_| activated = true, + ); + cx.run_until_parked(); + assert!(!registered); + assert!(!activated); + assert!(state.manager.is_none()); + assert_eq!(state.current_hotkey, ""); + cx.update(|cx| { + assert_eq!( + GlobalSettings::read(cx, |settings| settings.hotkey.activation_key.clone()), + "" + ); + board.update(cx, |board, cx| assert!(!board.has_pending_hotkey(cx))); + }); + + let mut reloaded = Settings::load_from_dir(directory.path()).expect("reload settings"); + crate::gui::settings::validate_hotkey(&mut reloaded); + assert_eq!(reloaded.hotkey.activation_key, ""); + } + #[gpui_kit::test] fn test_pending_registration_dispatches_existing_hotkey_before_completion( cx: &gpui_kit::TestAppContext, diff --git a/src/gui/settings.rs b/src/gui/settings.rs index 2b26512..85f5c83 100644 --- a/src/gui/settings.rs +++ b/src/gui/settings.rs @@ -85,10 +85,10 @@ pub(crate) fn language_display_name(language: &Language) -> SharedString { t!("language_name", locale = language.code()).into() } -/// Validate hotkey and reset to default if invalid. +/// Keep an explicitly disabled hotkey; reset invalid nonempty shortcuts to the default. pub(crate) fn validate_hotkey(settings: &mut Settings) { - if settings.hotkey.activation_key.is_empty() - || global_hotkey::hotkey::HotKey::from_str(&settings.hotkey.activation_key).is_err() + if !settings.hotkey.activation_key.is_empty() + && global_hotkey::hotkey::HotKey::from_str(&settings.hotkey.activation_key).is_err() { settings.hotkey.activation_key = Settings::default().hotkey.activation_key; } @@ -232,18 +232,13 @@ mod tests { } #[test] - fn test_validate_hotkey_empty() { + fn test_validate_hotkey_empty_preserves_disabled_shortcut() { let mut settings = Settings::default(); settings.hotkey.activation_key = String::new(); validate_hotkey(&mut settings); - // Empty hotkey should be reset to default - let default = Settings::default(); - assert_eq!( - settings.hotkey.activation_key, - default.hotkey.activation_key - ); + assert_eq!(settings.hotkey.activation_key, ""); } #[test] diff --git a/src/gui/tray.rs b/src/gui/tray.rs index 3f33d3d..a198d25 100644 --- a/src/gui/tray.rs +++ b/src/gui/tray.rs @@ -1,4 +1,4 @@ -use gpui_kit::{App, BorrowAppContext, Global, ReadGlobal}; +use gpui_kit::{App, AppContext, BorrowAppContext, Global, ReadGlobal, component::WindowExt}; use rust_i18n::t; #[cfg(not(target_os = "linux"))] use tray_icon::TrayIconEvent; @@ -189,9 +189,13 @@ fn open_panel(panel: super::board::ActivePanel, cx: &mut App) { let Some(window_handle) = super::window::ensure_window(cx) else { return; }; - let _ = window_handle.update(cx, |root, window, cx| { - if let Ok(board) = root.view().clone().downcast::() { + let _ = cx.update_window(window_handle.into(), |_, window, cx| { + if let Some(board) = super::window::board(cx) { board.update(cx, |board, cx| { + if window.has_active_dialog(cx) { + board.activate_popup(window, cx); + return; + } board.cancel_shortcut_recording(cx); if board.settings_editor.hotkey.recording { board.cancel_hotkey_recording(window, cx); diff --git a/src/gui/window.rs b/src/gui/window.rs index 3eaf95e..e81e771 100644 --- a/src/gui/window.rs +++ b/src/gui/window.rs @@ -6,7 +6,9 @@ use std::cfg_select; -use gpui_kit::{App, AsyncApp, Context, Entity, Global, Window, WindowHandle, component::Root}; +use gpui_kit::{ + App, AppContext, AsyncApp, Context, Entity, Global, Window, WindowHandle, component::Root, +}; #[cfg(any(target_os = "linux", target_os = "windows"))] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; #[cfg(target_os = "windows")] @@ -160,8 +162,8 @@ pub(crate) fn show_with_token(token: Option<&str>, cx: &mut App) { #[cfg(not(target_os = "linux"))] let _ = token; if let Some(handle) = ensure_window(cx) { - let _ = handle.update(cx, |root, window, cx| { - if let Ok(board) = root.view().clone().downcast::() { + let _ = cx.update_window(handle.into(), |_, window, cx| { + if let Some(board) = board(cx) { board.update(cx, |board, cx| { board.on_active_action(&super::board::Active, window, cx); }); @@ -202,19 +204,18 @@ pub(crate) fn activate_from_hotkey(cx: &AsyncApp, token: Option<&str>) { cx.update(|cx| { let handle = cx.try_global::().and_then(|popup| popup.handle); let handled = handle.is_some_and(|handle| { - handle - .update(cx, |_, window, cx| { - if !has_native_focus(window) { - return false; - } - if let Some(board) = board(cx) { - board.update(cx, |board, cx| { - board.dismiss_from_hotkey(window, cx); - }); - } - true - }) - .unwrap_or(false) + cx.update_window(handle.into(), |_, window, cx| { + if !has_native_focus(window) { + return false; + } + if let Some(board) = board(cx) { + board.update(cx, |board, cx| { + board.dismiss_from_hotkey(window, cx); + }); + } + true + }) + .unwrap_or(false) }); if !handled { show_with_token(token, cx); @@ -347,14 +348,16 @@ mod tests { sync::{Arc, Mutex, RwLock}, }; - use gpui_kit::{AppContext, TestAppContext, test::TestWindowExt}; + use gpui_kit::{TestAppContext, component::WindowExt, test::TestWindowExt}; use super::*; use crate::{ clipboard::CopyTracker, config::Settings, gui::{ - board::bind_application_keys, repository::GlobalRepository, settings::GlobalSettings, + board::{bind_application_keys, filtering::ClearConfirmAction}, + repository::GlobalRepository, + settings::GlobalSettings, }, repository::ClipboardRecord, updater::models::UpdateStatus, @@ -418,26 +421,38 @@ mod tests { } #[gpui_kit::test] - fn test_hotkey_refocuses_unfocused_pinned_popup(cx: &mut TestAppContext) { + fn test_popup_reactivation_preserves_dialog_focus_and_panel(cx: &mut TestAppContext) { install_hidden_test_popup(cx); activate_from_hotkey(&cx.to_async(), None); cx.run_until_parked(); let handle = cx.update(|cx| ensure_window(cx).unwrap()); - cx.update_window(handle.into(), |_, window, cx| { - board(cx).unwrap().update(cx, |board, _| { - board.set_window_pinned(window, true); - }); - }) - .unwrap(); + let dialog_focus = cx + .update_window(handle.into(), |_, window, cx| { + board(cx).unwrap().update(cx, |board, cx| { + board.set_window_pinned(window, true); + board.open_clear_confirm(ClearConfirmAction::AllHistory, window, cx); + }); + window.render_frame(cx); + window.focused(cx).expect("clear dialog focus") + }) + .unwrap(); gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window(); activate_from_hotkey(&cx.to_async(), None); cx.run_until_parked(); + cx.update(crate::gui::tray::send_open_settings); cx.update_window(handle.into(), |_, window, cx| { assert!( window.is_window_active(), "unfocused pinned popup must regain focus" ); assert!(board(cx).unwrap().read(cx).pinned); + assert!(window.has_active_dialog(cx)); + assert_eq!(window.focused(cx), Some(dialog_focus)); + assert!(board(cx).unwrap().read(cx).ui_state.clear_confirm_visible()); + assert_eq!( + board(cx).unwrap().read(cx).active_panel, + crate::gui::board::ActivePanel::ClipboardList + ); }) .unwrap(); }