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