Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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 <window-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

Expand Down
21 changes: 16 additions & 5 deletions src/gui/board/actions.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down
21 changes: 0 additions & 21 deletions src/gui/board/settings_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
27 changes: 3 additions & 24 deletions src/gui/board/settings_handler/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -105,7 +88,7 @@ impl RopyBoard {
candidate.trim()
};

self.sync_activation_key_input(value, &current_hotkey, window, cx);
self.sync_activation_key_input(value, candidate, window, cx);
}

pub(crate) fn resolve_activation_key_input(&self, cx: &Context<'_, Self>) -> String {
Expand All @@ -128,19 +111,15 @@ 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, &current_hotkey) != current_hotkey
candidate_hotkey != current_hotkey
}

pub(crate) fn save_hotkey(&mut self, cx: &mut Context<'_, Self>, window: &mut Window) {
if GlobalSettings::read(cx, Settings::is_recovery_required) {
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),
&current_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"));
Expand Down
21 changes: 17 additions & 4 deletions src/gui/board/ui_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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| {
Expand All @@ -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));
Expand Down
106 changes: 99 additions & 7 deletions src/gui/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,10 @@ impl<Manager> HotkeyListenerState<Manager> {

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()))
})
}

Expand Down Expand Up @@ -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,
Expand Down
15 changes: 5 additions & 10 deletions src/gui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 7 additions & 3 deletions src/gui/tray.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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::<super::board::RopyBoard>() {
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);
Expand Down
Loading
Loading