fix(input): keep pending url clicks across host focus loss (#2291)

Opening a URL raises the browser, which takes focus away from the host
terminal before the mouse release arrives. release_input_source(_headless)
cleared pending_url_click_sources on that focus loss, so the release was
forwarded to the pane and the agent opened the same URL again.

Clear the set in clear_input_source instead, whose only production caller
is remove_client.

refs #2290

Co-authored-by: kataokatsuki <kataokatsuki@users.noreply.github.com>
This commit is contained in:
Kataoka Katsuki 2026-08-04 23:46:43 +09:00 committed by GitHub
parent cc9fa47540
commit 1997b88b3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 64 additions and 3 deletions

View File

@ -350,7 +350,7 @@ impl App {
}
pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
self.pending_url_click_sources.remove(&source_id);
// Pending URL clicks survive this call; see clear_input_source.
for pressed in self.take_pressed_keys_for_source(source_id) {
let release = pressed
.key
@ -360,7 +360,7 @@ impl App {
}
pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
self.pending_url_click_sources.remove(&source_id);
// Pending URL clicks survive this call; see clear_input_source.
for pressed in self.take_pressed_keys_for_source(source_id) {
let release = pressed
.key
@ -924,6 +924,61 @@ mod tests {
);
}
#[cfg(unix)]
#[tokio::test]
async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() {
let line = "see https://github.com/herdrdev/herdr/issues/1761";
let col = line.find("github").expect("url host") as u16;
let (mut app, info) = app_with_screen_bytes(b"");
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
screen.as_bytes(),
4,
);
app.state.insert_test_runtime(pane_id, runtime);
install_test_link_handler(&mut app);
let url_x = info.inner_rect.x + col;
app.handle_mouse_from_input_source(
41,
modified_mouse(
MouseEventKind::Down(MouseButton::Left),
url_x,
info.inner_rect.y,
KeyModifiers::CONTROL,
),
);
assert_eq!(app.state.plugin_command_logs.len(), 1);
// Opening the URL raises the browser, so the host terminal loses focus
// while the button is still down.
app.route_client_events_from(
41,
vec![crate::raw_input::RawInputEvent::OuterFocusLost],
false,
);
app.handle_mouse_from_input_source(
41,
modified_mouse(
MouseEventKind::Up(MouseButton::Left),
url_x,
info.inner_rect.y,
KeyModifiers::empty(),
),
);
assert!(
input_rx.try_recv().is_err(),
"focus loss must not clear a pending URL click, so its release must stay out of the pane"
);
}
#[cfg(unix)]
#[tokio::test]
async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {

View File

@ -1778,6 +1778,11 @@ impl App {
}
pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) {
// Call this only when the input source is gone for good. A pending URL
// click has to outlive a plain focus change, because opening the URL
// raises the browser and costs the host terminal its focus before the
// mouse release arrives.
self.pending_url_click_sources.remove(&source_id);
self.release_input_source_headless(source_id);
}

View File

@ -2710,7 +2710,8 @@ impl HeadlessServer {
.iter()
.any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost))
{
self.app.clear_input_source(client_id);
// Focus loss is not a teardown, so the pending URL click stays.
self.app.release_input_source_headless(client_id);
}
}
let events = events_for_app_routing(events, source_was_foreground, source_is_full_app);