From 60dff45e08ea0866aca97e49eb361ede3b39bbff Mon Sep 17 00:00:00 2001 From: Apoorva Verma <76636952+apoorva-01@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:59:27 +0530 Subject: [PATCH] terminal: Start a selection on shift+drag when mouse tracking is active (#60880) run htop or btm and you can't select terminal text with shift+drag, the usual escape hatch does nothing. #25143 made shift+click only ever extend a selection, never start one. so with nothing selected there's no anchor and the drag has nothing to grab. now it drops an anchor when there's no selection, and still extends when there is. added two event-level tests (harness can't sync without a window): shift+drag under mouse tracking, which fails without the fix, and shift+click still extending. Fixes #60254. Release Notes: - Fixed Shift+click-drag not selecting terminal text while an app has mouse tracking enabled --- crates/terminal/src/terminal.rs | 122 +++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index a1e1c1c937..e41a2dbe7b 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -2513,8 +2513,18 @@ impl Terminal { }; if selection_type == Some(SelectionType::Simple) && e.modifiers.shift { - self.events - .push_back(InternalEvent::UpdateSelection(position)); + if self.last_content.selection.is_some() { + // Shift+click extends the existing selection to this point. + self.events + .push_back(InternalEvent::UpdateSelection(position)); + } else { + // With no selection yet, Shift is the escape hatch for + // selecting text while an app has mouse tracking enabled, + // so anchor a selection here for the drag to extend. + self.events.push_back(InternalEvent::SetSelection(Some( + Selection::new(SelectionType::Simple, point, side), + ))); + } return; } @@ -3760,6 +3770,114 @@ mod tests { }); } + /// With mouse tracking active (e.g. htop), Shift is the escape hatch to + /// select terminal text. Shift+drag must start a selection rather than being + /// swallowed as a "extend existing selection" no-op. Regression test for #60254. + #[gpui::test] + async fn test_terminal_shift_drag_selects_while_mouse_tracking(cx: &mut TestAppContext) { + // `?1002h` enables button-event mouse tracking, `?1006h` selects SGR encoding. + let terminal = init_ctrl_click_hyperlink_test(cx, b"\x1b[?1002h\x1b[?1006hhello world\r\n"); + + terminal.update(cx, |terminal, cx| { + assert!( + terminal.last_content.mode.intersects(Modes::MOUSE_MODE), + "mouse tracking should be active" + ); + + let shift = Modifiers { + shift: true, + ..Modifiers::none() + }; + terminal.mouse_down( + &MouseDownEvent { + button: MouseButton::Left, + position: point(px(50.0), px(10.0)), + modifiers: shift, + click_count: 1, + first_mouse: true, + }, + cx, + ); + + // With no selection yet, the shift press must anchor a new selection + // so the following drag has something to extend. + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::SetSelection(Some(_)))), + "shift+click with no existing selection should anchor a selection" + ); + terminal.events.clear(); + + let region = terminal.last_content.terminal_bounds.bounds; + terminal.mouse_drag( + &MouseMoveEvent { + position: point(px(90.0), px(10.0)), + pressed_button: Some(MouseButton::Left), + modifiers: shift, + }, + region, + cx, + ); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::UpdateSelection(_))), + "shift+drag should extend the selection while mouse tracking is active" + ); + assert!(terminal.selection_phase == SelectionPhase::Selecting); + }); + } + + /// Shift+click with a selection already on screen must keep extending it + /// (the behavior added in #25143), not re-anchor a fresh one. + #[gpui::test] + async fn test_terminal_shift_click_extends_existing_selection(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"hello world\r\n"); + + terminal.update(cx, |terminal, cx| { + // A visible selection, as a sync would have populated in production. + terminal.last_content.selection = Some(SelectionRange { + start: Point::new(0, 0), + end: Point::new(0, 5), + is_block: false, + }); + terminal.events.clear(); + + terminal.mouse_down( + &MouseDownEvent { + button: MouseButton::Left, + position: point(px(90.0), px(10.0)), + modifiers: Modifiers { + shift: true, + ..Modifiers::none() + }, + click_count: 1, + first_mouse: true, + }, + cx, + ); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::UpdateSelection(_))), + "shift+click with an existing selection should extend it" + ); + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::SetSelection(Some(_)))), + "shift+click should extend, not re-anchor, an existing selection" + ); + }); + } + #[gpui::test] async fn test_basic_terminal(cx: &mut TestAppContext) { cx.executor().allow_parking();