agent_ui: Make provider-owned terminals read-only (#64550)
Make provider-owned terminal cards read-only for user input while preserving local inspection. This builds on the explicit terminal ownership introduced in #64544. - Add a construction-time `TerminalView::with_read_only` option, selected from ACP process ownership rather than PTY presence. Existing views remain interactive by default, including client-owned headless terminals. - Reject typing, send actions, paste, file/path drops, IME input, clear/rerun actions, and focus reports in read-only views. - Preserve selection, copying, links, scrolling, search, and local Vi navigation. Leaving Vi mode does not enable terminal input. - Pass an explicit mouse-routing policy to the existing lower terminal handlers so provider-requested mouse and alternate-scroll modes cannot consume inspection gestures. Real modifiers remain unchanged, and Linux/FreeBSD middle-click paste is suppressed. Release Notes: - N/A
This commit is contained in:
1 parent
a18b1a1026
commit
50061c9cbd
5 files changed
+678
-152
No files matched your search
@@ -9562,6 +9562,7 @@ pub(crate) mod tests {
|
||||
)
|
||||
.subscribe(cx)
|
||||
});
|
||||
let display_terminal = terminal.clone();
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
acp_thread::TerminalProviderEvent::Created {
|
||||
@@ -9593,6 +9594,49 @@ pub(crate) mod tests {
|
||||
state.expand_tool_call(tool_id.clone());
|
||||
cx.notify();
|
||||
});
|
||||
let acp_terminal = thread.read_with(cx, |thread, _| {
|
||||
thread
|
||||
.terminal(terminal_id.clone())
|
||||
.expect("terminal tool call should contain a terminal")
|
||||
});
|
||||
let terminal_view = entry_state.read_with(cx, |state, _| {
|
||||
state
|
||||
.entry(0)
|
||||
.and_then(|entry| entry.terminal(&acp_terminal))
|
||||
.expect("terminal tool card should contain a terminal view")
|
||||
});
|
||||
assert!(terminal_view.read_with(cx, |view, _| view.is_read_only()));
|
||||
|
||||
display_terminal.update(cx, |terminal, _| {
|
||||
assert!(terminal.take_input_log().is_empty());
|
||||
});
|
||||
terminal_view.update_in(cx, |view, window, cx| {
|
||||
window.focus(&view.focus_handle(cx), cx);
|
||||
});
|
||||
cx.simulate_keystrokes("a");
|
||||
assert!(
|
||||
display_terminal
|
||||
.update(cx, |terminal, _| terminal.take_input_log())
|
||||
.is_empty(),
|
||||
"input dispatched to a display-only terminal view must not reach the lower terminal",
|
||||
);
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
acp_thread::TerminalProviderEvent::Output {
|
||||
terminal_id: terminal_id.clone(),
|
||||
data: b"provider output remains visible".to_vec(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
display_terminal
|
||||
.read_with(cx, |terminal, _| terminal.get_content())
|
||||
.contains("provider output remains visible"),
|
||||
"provider output should continue to render in a read-only terminal",
|
||||
);
|
||||
assert!(cx.debug_bounds("ICON-Stop").is_none());
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread
|
||||
@@ -9627,6 +9671,85 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_acp_owned_terminal_tool_card_is_interactive(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (conversation_view, cx) =
|
||||
setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
|
||||
add_to_workspace_with_size(conversation_view.clone(), true, cx);
|
||||
let thread_view = active_thread(&conversation_view, cx);
|
||||
let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
|
||||
let terminal_id = acp::TerminalId::new("acp-owned-terminal");
|
||||
let tool_id = acp::ToolCallId::new("acp-owned-tool");
|
||||
let lower_terminal = cx.new(|cx| {
|
||||
terminal::TerminalBuilder::new_display_only(
|
||||
Default::default(),
|
||||
terminal::terminal_settings::AlternateScroll::On,
|
||||
None,
|
||||
0,
|
||||
cx.background_executor(),
|
||||
util::paths::PathStyle::local(),
|
||||
)
|
||||
.subscribe(cx)
|
||||
});
|
||||
assert!(!lower_terminal.read_with(cx, |terminal, _| terminal.is_pty()));
|
||||
|
||||
let acp_terminal = thread.update(cx, |thread, cx| {
|
||||
let acp_terminal = thread.register_terminal_created(
|
||||
terminal_id.clone(),
|
||||
"client-owned command".into(),
|
||||
None,
|
||||
None,
|
||||
lower_terminal.clone(),
|
||||
cx,
|
||||
);
|
||||
thread
|
||||
.handle_session_update(
|
||||
acp::SessionUpdate::ToolCall(
|
||||
acp::ToolCall::new(tool_id.clone(), "client-owned command")
|
||||
.kind(acp::ToolKind::Execute)
|
||||
.status(acp::ToolCallStatus::InProgress)
|
||||
.content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new(
|
||||
terminal_id,
|
||||
))]),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
.expect("terminal tool call");
|
||||
acp_terminal
|
||||
});
|
||||
assert!(
|
||||
acp_terminal.read_with(cx, |terminal, _| terminal.is_process_backed()),
|
||||
"registration ownership, not the lower renderer's PTY, marks this terminal process-backed",
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
let entry_state = thread_view.read_with(cx, |view, _| view.entry_view_state.clone());
|
||||
entry_state.update(cx, |state, cx| {
|
||||
state.expand_tool_call(tool_id);
|
||||
cx.notify();
|
||||
});
|
||||
let terminal_view = entry_state.read_with(cx, |state, _| {
|
||||
state
|
||||
.entry(0)
|
||||
.and_then(|entry| entry.terminal(&acp_terminal))
|
||||
.expect("terminal tool card should contain a terminal view")
|
||||
});
|
||||
assert!(!terminal_view.read_with(cx, |view, _| view.is_read_only()));
|
||||
|
||||
lower_terminal.update(cx, |terminal, _| {
|
||||
assert!(terminal.take_input_log().is_empty());
|
||||
});
|
||||
terminal_view.update_in(cx, |view, window, cx| {
|
||||
window.focus(&view.focus_handle(cx), cx);
|
||||
});
|
||||
cx.simulate_keystrokes("a");
|
||||
assert_eq!(
|
||||
lower_terminal.update(cx, |terminal, _| terminal.take_input_log()),
|
||||
vec![b"a".to_vec()],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -643,6 +643,7 @@ fn create_terminal(
|
||||
cx: &mut App,
|
||||
) -> Entity<TerminalView> {
|
||||
cx.new(|cx| {
|
||||
let read_only = !terminal.read(cx).is_process_backed();
|
||||
let mut view = TerminalView::new(
|
||||
terminal.read(cx).inner().clone(),
|
||||
workspace,
|
||||
@@ -650,7 +651,8 @@ fn create_terminal(
|
||||
project,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
)
|
||||
.with_read_only(read_only);
|
||||
view.set_embedded_mode(Some(1000), cx);
|
||||
view
|
||||
})
|
||||
|
||||
+174
-26
@@ -934,6 +934,12 @@ fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) ->
|
||||
/// sender and receiver remain paired.
|
||||
pub struct TerminalMode(TerminalModeKind);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MouseInputMode {
|
||||
ReportToTerminal,
|
||||
LocalSelection,
|
||||
}
|
||||
|
||||
enum TerminalModeKind {
|
||||
Interactive,
|
||||
InteractiveWithCompletion(Sender<Option<ExitStatus>>),
|
||||
@@ -2476,13 +2482,15 @@ impl Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mouse_mode(&self, shift: bool) -> bool {
|
||||
self.last_content.mode.intersects(Modes::MOUSE_MODE) && !shift
|
||||
pub fn mouse_mode(&self, shift: bool, mode: MouseInputMode) -> bool {
|
||||
mode == MouseInputMode::ReportToTerminal
|
||||
&& self.last_content.mode.intersects(Modes::MOUSE_MODE)
|
||||
&& !shift
|
||||
}
|
||||
|
||||
pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) {
|
||||
pub fn mouse_move(&mut self, e: &MouseMoveEvent, mode: MouseInputMode, cx: &mut Context<Self>) {
|
||||
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
|
||||
if self.mouse_mode(e.modifiers.shift) {
|
||||
if self.mouse_mode(e.modifiers.shift, mode) {
|
||||
// A ctrl/cmd press on a link suppressed its button-press report in
|
||||
// `mouse_down`. Since the app never saw the press, we must swallow
|
||||
// the whole gesture rather than forward later motion/release
|
||||
@@ -2580,10 +2588,11 @@ impl Terminal {
|
||||
&mut self,
|
||||
e: &MouseMoveEvent,
|
||||
region: Bounds<Pixels>,
|
||||
mode: MouseInputMode,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
|
||||
if !self.mouse_mode(e.modifiers.shift) {
|
||||
if !self.mouse_mode(e.modifiers.shift, mode) {
|
||||
if let Some(hyperlink) = &self.mouse_down_hyperlink {
|
||||
let point = grid_point(
|
||||
position,
|
||||
@@ -2646,7 +2655,7 @@ impl Terminal {
|
||||
Some(scroll_lines.clamp(-3, 3))
|
||||
}
|
||||
|
||||
pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context<Self>) {
|
||||
pub fn mouse_down(&mut self, e: &MouseDownEvent, mode: MouseInputMode, cx: &mut Context<Self>) {
|
||||
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
|
||||
let point = grid_point(
|
||||
position,
|
||||
@@ -2657,7 +2666,7 @@ impl Terminal {
|
||||
if e.button == MouseButton::Left
|
||||
&& e.modifiers.secondary()
|
||||
&& (TerminalSettings::get_global(cx).open_links_in_mouse_mode
|
||||
|| !self.mouse_mode(e.modifiers.shift))
|
||||
|| !self.mouse_mode(e.modifiers.shift, mode))
|
||||
{
|
||||
self.mouse_down_hyperlink = self.find_hyperlink_at_point(point);
|
||||
|
||||
@@ -2666,7 +2675,7 @@ impl Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
if self.mouse_mode(e.modifiers.shift) {
|
||||
if self.mouse_mode(e.modifiers.shift, mode) {
|
||||
let bytes =
|
||||
mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode);
|
||||
|
||||
@@ -2717,7 +2726,9 @@ impl Terminal {
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
MouseButton::Middle => {
|
||||
if let Some(item) = cx.read_from_primary() {
|
||||
if mode == MouseInputMode::ReportToTerminal
|
||||
&& let Some(item) = cx.read_from_primary()
|
||||
{
|
||||
let text = item.text().unwrap_or_default();
|
||||
self.paste(&text);
|
||||
}
|
||||
@@ -2727,7 +2738,7 @@ impl Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context<Self>) {
|
||||
pub fn mouse_up(&mut self, e: &MouseUpEvent, mode: MouseInputMode, cx: &Context<Self>) {
|
||||
let setting = TerminalSettings::get_global(cx);
|
||||
|
||||
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
|
||||
@@ -2750,7 +2761,7 @@ impl Terminal {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.mouse_mode(e.modifiers.shift) {
|
||||
if self.mouse_mode(e.modifiers.shift, mode) {
|
||||
self.selection_phase = SelectionPhase::Ended;
|
||||
self.last_mouse = None;
|
||||
self.mouse_down_position = None;
|
||||
@@ -2758,7 +2769,7 @@ impl Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
if self.mouse_mode(e.modifiers.shift) {
|
||||
if self.mouse_mode(e.modifiers.shift, mode) {
|
||||
let point = grid_point(
|
||||
position,
|
||||
self.last_content.terminal_bounds,
|
||||
@@ -2800,8 +2811,13 @@ impl Terminal {
|
||||
}
|
||||
|
||||
///Scroll the terminal
|
||||
pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) {
|
||||
let mouse_mode = self.mouse_mode(e.shift);
|
||||
pub fn scroll_wheel(
|
||||
&mut self,
|
||||
e: &ScrollWheelEvent,
|
||||
scroll_multiplier: f32,
|
||||
mode: MouseInputMode,
|
||||
) {
|
||||
let mouse_mode = self.mouse_mode(e.shift, mode);
|
||||
let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier };
|
||||
|
||||
if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier)
|
||||
@@ -2820,10 +2836,11 @@ impl Terminal {
|
||||
self.write_to_pty(scroll);
|
||||
}
|
||||
};
|
||||
} else if self
|
||||
.last_content
|
||||
.mode
|
||||
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
|
||||
} else if mode == MouseInputMode::ReportToTerminal
|
||||
&& self
|
||||
.last_content
|
||||
.mode
|
||||
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
|
||||
&& !e.shift
|
||||
{
|
||||
self.write_to_pty(alt_scroll(scroll_lines));
|
||||
@@ -3580,7 +3597,8 @@ mod tests {
|
||||
};
|
||||
use collections::HashMap;
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, Pixels, TestAppContext, VisualTestContext, bounds, point, size,
|
||||
ClipboardItem, Entity, Pixels, ScrollDelta, TestAppContext, VisualContext,
|
||||
VisualTestContext, bounds, point, size,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use rand::{Rng, distr, rngs::StdRng};
|
||||
@@ -3927,7 +3945,7 @@ mod tests {
|
||||
click_count: 1,
|
||||
first_mouse: true,
|
||||
};
|
||||
terminal.mouse_down(&mouse_down, cx);
|
||||
terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx);
|
||||
}
|
||||
|
||||
fn left_mouse_up_at(
|
||||
@@ -3941,7 +3959,7 @@ mod tests {
|
||||
modifiers: Modifiers::none(),
|
||||
click_count: 1,
|
||||
};
|
||||
terminal.mouse_up(&mouse_up, cx);
|
||||
terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx);
|
||||
}
|
||||
|
||||
fn left_mouse_drag_to(
|
||||
@@ -3955,7 +3973,7 @@ mod tests {
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::none(),
|
||||
};
|
||||
terminal.mouse_drag(&drag_event, region, cx);
|
||||
terminal.mouse_drag(&drag_event, region, MouseInputMode::ReportToTerminal, cx);
|
||||
}
|
||||
|
||||
/// A left click that jitters by a pixel or two (e.g. the window-focusing
|
||||
@@ -4006,6 +4024,92 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
#[gpui::test]
|
||||
async fn test_local_selection_does_not_paste_primary_selection(cx: &mut TestAppContext) {
|
||||
let terminal = init_terminal_test(cx, b"hello world\r\n");
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
cx.write_to_primary(ClipboardItem::new_string("primary selection".into()));
|
||||
let event = MouseDownEvent {
|
||||
button: MouseButton::Middle,
|
||||
position: point(px(50.), px(10.)),
|
||||
modifiers: Modifiers::none(),
|
||||
click_count: 1,
|
||||
first_mouse: true,
|
||||
};
|
||||
terminal.mouse_down(&event, MouseInputMode::LocalSelection, cx);
|
||||
assert!(terminal.take_input_log().is_empty());
|
||||
assert!(terminal.take_pty_write_log().is_empty());
|
||||
|
||||
terminal.mouse_down(&event, MouseInputMode::ReportToTerminal, cx);
|
||||
assert_eq!(
|
||||
terminal.take_input_log(),
|
||||
vec![b"primary selection".to_vec()]
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.take_pty_write_log(),
|
||||
vec![b"primary selection".to_vec()]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_local_selection_scrolls_without_terminal_reports(cx: &mut TestAppContext) {
|
||||
let mut output = Vec::new();
|
||||
output.extend_from_slice(b"\x1b[?1002h\x1b[?1006h");
|
||||
for line in 0..80 {
|
||||
output.extend_from_slice(format!("scrollback line {line}\r\n").as_bytes());
|
||||
}
|
||||
let (terminal, cx) = init_terminal_test_with_window(cx, &output);
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window_entity(&terminal, |terminal, window, cx| {
|
||||
terminal.sync(window, cx);
|
||||
assert!(terminal.last_content.mode.intersects(Modes::MOUSE_MODE));
|
||||
assert_eq!(display_offset(&terminal.term.lock()), 0);
|
||||
|
||||
let scroll_event = ScrollWheelEvent {
|
||||
position: point(px(50.0), px(10.0)),
|
||||
delta: ScrollDelta::Lines(point(0.0, 1.0)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::LocalSelection);
|
||||
terminal.sync(window, cx);
|
||||
assert!(display_offset(&terminal.term.lock()) > 0);
|
||||
assert!(terminal.take_input_log().is_empty());
|
||||
assert!(terminal.take_pty_write_log().is_empty());
|
||||
|
||||
terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::ReportToTerminal);
|
||||
assert!(!terminal.take_pty_write_log().is_empty());
|
||||
|
||||
terminal.write_output(b"\x1b[?1049h\x1b[?1007h", cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window_entity(&terminal, |terminal, window, cx| {
|
||||
terminal.sync(window, cx);
|
||||
assert!(
|
||||
terminal
|
||||
.last_content
|
||||
.mode
|
||||
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
|
||||
);
|
||||
assert_eq!(display_offset(&terminal.term.lock()), 0);
|
||||
|
||||
let scroll_event = ScrollWheelEvent {
|
||||
position: point(px(50.0), px(10.0)),
|
||||
delta: ScrollDelta::Lines(point(0.0, 1.0)),
|
||||
..Default::default()
|
||||
};
|
||||
terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::LocalSelection);
|
||||
terminal.sync(window, cx);
|
||||
assert_eq!(display_offset(&terminal.term.lock()), 0);
|
||||
assert!(terminal.take_input_log().is_empty());
|
||||
assert!(terminal.take_pty_write_log().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -4032,6 +4136,7 @@ mod tests {
|
||||
click_count: 1,
|
||||
first_mouse: true,
|
||||
},
|
||||
MouseInputMode::ReportToTerminal,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4054,6 +4159,7 @@ mod tests {
|
||||
modifiers: shift,
|
||||
},
|
||||
region,
|
||||
MouseInputMode::ReportToTerminal,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4094,6 +4200,7 @@ mod tests {
|
||||
click_count: 1,
|
||||
first_mouse: true,
|
||||
},
|
||||
MouseInputMode::ReportToTerminal,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4822,7 +4929,7 @@ mod tests {
|
||||
click_count: 1,
|
||||
first_mouse: true,
|
||||
};
|
||||
terminal.mouse_down(&mouse_down, cx);
|
||||
terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx);
|
||||
}
|
||||
|
||||
fn ctrl_mouse_drag_to(
|
||||
@@ -4836,7 +4943,12 @@ mod tests {
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::secondary_key(),
|
||||
};
|
||||
terminal.mouse_drag(&drag_event, terminal_bounds, cx);
|
||||
terminal.mouse_drag(
|
||||
&drag_event,
|
||||
terminal_bounds,
|
||||
MouseInputMode::ReportToTerminal,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn ctrl_mouse_up_at(
|
||||
@@ -4850,7 +4962,7 @@ mod tests {
|
||||
modifiers: Modifiers::secondary_key(),
|
||||
click_count: 1,
|
||||
};
|
||||
terminal.mouse_up(&mouse_up, cx);
|
||||
terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx);
|
||||
}
|
||||
|
||||
macro_rules! any_event_matches {
|
||||
@@ -4897,6 +5009,38 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_local_selection_activates_hyperlink_in_mouse_mode(cx: &mut TestAppContext) {
|
||||
let terminal = init_terminal_test(cx, b"Visit https://zed.dev/ for more\r\n");
|
||||
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.last_content.mode = Modes::MOUSE_MODE;
|
||||
let position = point(px(80.0), px(10.0));
|
||||
let mouse_down = MouseDownEvent {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
modifiers: Modifiers::secondary_key(),
|
||||
click_count: 1,
|
||||
first_mouse: true,
|
||||
};
|
||||
let mouse_up = MouseUpEvent {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
modifiers: Modifiers::secondary_key(),
|
||||
click_count: 1,
|
||||
};
|
||||
|
||||
terminal.mouse_down(&mouse_down, MouseInputMode::LocalSelection, cx);
|
||||
terminal.mouse_up(&mouse_up, MouseInputMode::LocalSelection, cx);
|
||||
|
||||
assert!(any_event_matches!(
|
||||
terminal,
|
||||
InternalEvent::ProcessHyperlink(_, true)
|
||||
));
|
||||
assert!(terminal.take_pty_write_log().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture(
|
||||
cx: &mut TestAppContext,
|
||||
@@ -4920,6 +5064,7 @@ mod tests {
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::secondary_key(),
|
||||
},
|
||||
MouseInputMode::ReportToTerminal,
|
||||
cx,
|
||||
);
|
||||
ctrl_mouse_up_at(terminal, up_position, cx);
|
||||
@@ -5205,7 +5350,8 @@ mod tests {
|
||||
};
|
||||
self.window.simulate_mouse_move(position, self.cx);
|
||||
self.unthrottle();
|
||||
self.terminal.mouse_move(&move_event, self.cx);
|
||||
self.terminal
|
||||
.mouse_move(&move_event, MouseInputMode::ReportToTerminal, self.cx);
|
||||
}
|
||||
|
||||
fn try_modifiers_change(&mut self, modifiers: Modifiers) {
|
||||
@@ -5752,6 +5898,7 @@ mod tests {
|
||||
position,
|
||||
..default()
|
||||
},
|
||||
MouseInputMode::ReportToTerminal,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -5762,6 +5909,7 @@ mod tests {
|
||||
..default()
|
||||
},
|
||||
1.0,
|
||||
MouseInputMode::ReportToTerminal,
|
||||
);
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine};
|
||||
use gpui::{
|
||||
AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase,
|
||||
Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight,
|
||||
GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity,
|
||||
IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels,
|
||||
AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element,
|
||||
ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, GlobalElementId,
|
||||
HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
|
||||
LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels,
|
||||
Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle,
|
||||
UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative,
|
||||
size,
|
||||
@@ -13,9 +13,10 @@ use language::CursorShape as EditorCursorShape;
|
||||
use settings::Settings;
|
||||
use std::time::Instant;
|
||||
use terminal::{
|
||||
Cell, Color, Content, CursorShape, IndexedCell, Modes, NamedColor, Point, Range, Terminal,
|
||||
TerminalBounds, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color,
|
||||
is_default_background_color, terminal_settings::TerminalSettings,
|
||||
Cell, Color, Content, CursorShape, IndexedCell, Modes, MouseInputMode, NamedColor, Point,
|
||||
Range, Terminal, TerminalBounds,
|
||||
is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, is_default_background_color,
|
||||
terminal_settings::TerminalSettings,
|
||||
};
|
||||
use theme::{ActiveTheme, Theme};
|
||||
use theme_settings::ThemeSettings;
|
||||
@@ -40,7 +41,6 @@ pub struct LayoutState {
|
||||
ime_cursor_bounds: Option<Bounds<Pixels>>,
|
||||
background_color: Hsla,
|
||||
dimensions: TerminalBounds,
|
||||
mode: Modes,
|
||||
display_offset: usize,
|
||||
hyperlink_tooltip: Option<AnyElement>,
|
||||
block_below_cursor_element: Option<AnyElement>,
|
||||
@@ -940,31 +940,11 @@ impl TerminalElement {
|
||||
result
|
||||
}
|
||||
|
||||
fn generic_button_handler<E>(
|
||||
connection: Entity<Terminal>,
|
||||
focus_handle: FocusHandle,
|
||||
steal_focus: bool,
|
||||
f: impl Fn(&mut Terminal, &E, &mut Context<Terminal>),
|
||||
) -> impl Fn(&E, &mut Window, &mut App) {
|
||||
move |event, window, cx| {
|
||||
if steal_focus {
|
||||
window.focus(&focus_handle, cx);
|
||||
} else if !focus_handle.is_focused(window) {
|
||||
return;
|
||||
}
|
||||
connection.update(cx, |terminal, cx| {
|
||||
f(terminal, event, cx);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn register_mouse_listeners(
|
||||
&mut self,
|
||||
mode: Modes,
|
||||
hitbox: &Hitbox,
|
||||
content_mode: &ContentMode,
|
||||
mouse_input_mode: MouseInputMode,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let focus = self.focus.clone();
|
||||
@@ -979,13 +959,14 @@ impl TerminalElement {
|
||||
move |e, window, cx| {
|
||||
window.focus(&focus, cx);
|
||||
|
||||
let scroll_top = terminal_view.read(cx).scroll_top;
|
||||
let view = terminal_view.read(cx);
|
||||
let scroll_top = view.scroll_top;
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
let mut adjusted_event = e.clone();
|
||||
if scroll_top > Pixels::ZERO {
|
||||
adjusted_event.position.y += scroll_top;
|
||||
}
|
||||
terminal.mouse_down(&adjusted_event, cx);
|
||||
terminal.mouse_down(&adjusted_event, mouse_input_mode, cx);
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
@@ -995,7 +976,6 @@ impl TerminalElement {
|
||||
let terminal = self.terminal.clone();
|
||||
let hitbox = hitbox.clone();
|
||||
let focus = focus.clone();
|
||||
let terminal_view = terminal_view;
|
||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
@@ -1011,7 +991,12 @@ impl TerminalElement {
|
||||
if scroll_top > Pixels::ZERO {
|
||||
adjusted_event.position.y += scroll_top;
|
||||
}
|
||||
terminal.mouse_drag(&adjusted_event, hitbox.bounds, cx);
|
||||
terminal.mouse_drag(
|
||||
&adjusted_event,
|
||||
hitbox.bounds,
|
||||
mouse_input_mode,
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
@@ -1019,34 +1004,60 @@ impl TerminalElement {
|
||||
|
||||
if hitbox.is_hovered(window) {
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.mouse_move(e, cx);
|
||||
terminal.mouse_move(e, mouse_input_mode, cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.interactivity.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
TerminalElement::generic_button_handler(
|
||||
terminal.clone(),
|
||||
focus.clone(),
|
||||
false,
|
||||
move |terminal, e, cx| {
|
||||
terminal.mouse_up(e, cx);
|
||||
},
|
||||
),
|
||||
);
|
||||
self.interactivity.on_mouse_down(
|
||||
MouseButton::Middle,
|
||||
TerminalElement::generic_button_handler(
|
||||
terminal.clone(),
|
||||
focus.clone(),
|
||||
true,
|
||||
move |terminal, e, cx| {
|
||||
terminal.mouse_down(e, cx);
|
||||
},
|
||||
),
|
||||
);
|
||||
for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
|
||||
self.interactivity.on_mouse_up(button, {
|
||||
let terminal = terminal.clone();
|
||||
let focus = focus.clone();
|
||||
move |event, window, cx| {
|
||||
if !focus.is_focused(window) {
|
||||
return;
|
||||
}
|
||||
if button != MouseButton::Left
|
||||
&& (mouse_input_mode == MouseInputMode::LocalSelection
|
||||
|| !terminal
|
||||
.read(cx)
|
||||
.last_content
|
||||
.mode
|
||||
.intersects(Modes::MOUSE_MODE))
|
||||
{
|
||||
return;
|
||||
}
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.mouse_up(event, mouse_input_mode, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
for button in [MouseButton::Middle, MouseButton::Right] {
|
||||
self.interactivity.on_mouse_down(button, {
|
||||
let terminal = terminal.clone();
|
||||
let focus = focus.clone();
|
||||
move |event, window, cx| {
|
||||
if mouse_input_mode == MouseInputMode::LocalSelection
|
||||
|| (button == MouseButton::Right
|
||||
&& !terminal
|
||||
.read(cx)
|
||||
.last_content
|
||||
.mode
|
||||
.intersects(Modes::MOUSE_MODE))
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.focus(&focus, cx);
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.mouse_down(event, mouse_input_mode, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if content_mode.is_scrollable() {
|
||||
self.interactivity.on_scroll_wheel({
|
||||
@@ -1065,44 +1076,6 @@ impl TerminalElement {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse mode handlers:
|
||||
// All mouse modes need the extra click handlers
|
||||
if mode.intersects(Modes::MOUSE_MODE) {
|
||||
self.interactivity.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
TerminalElement::generic_button_handler(
|
||||
terminal.clone(),
|
||||
focus.clone(),
|
||||
true,
|
||||
move |terminal, e, cx| {
|
||||
terminal.mouse_down(e, cx);
|
||||
},
|
||||
),
|
||||
);
|
||||
self.interactivity.on_mouse_up(
|
||||
MouseButton::Right,
|
||||
TerminalElement::generic_button_handler(
|
||||
terminal.clone(),
|
||||
focus.clone(),
|
||||
false,
|
||||
move |terminal, e, cx| {
|
||||
terminal.mouse_up(e, cx);
|
||||
},
|
||||
),
|
||||
);
|
||||
self.interactivity.on_mouse_up(
|
||||
MouseButton::Middle,
|
||||
TerminalElement::generic_button_handler(
|
||||
terminal,
|
||||
focus,
|
||||
false,
|
||||
move |terminal, e, cx| {
|
||||
terminal.mouse_up(e, cx);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rem_size(&self, cx: &mut App) -> Option<Pixels> {
|
||||
@@ -1395,14 +1368,12 @@ impl Element for TerminalElement {
|
||||
|
||||
let Content {
|
||||
cells,
|
||||
mode,
|
||||
display_offset,
|
||||
cursor_char,
|
||||
selection,
|
||||
cursor,
|
||||
..
|
||||
} = &self.terminal.read(cx).last_content;
|
||||
let mode = *mode;
|
||||
let display_offset = *display_offset;
|
||||
|
||||
// searches, highlights to a single range representations
|
||||
@@ -1589,7 +1560,6 @@ impl Element for TerminalElement {
|
||||
dimensions,
|
||||
rects,
|
||||
relative_highlighted_ranges,
|
||||
mode,
|
||||
display_offset,
|
||||
hyperlink_tooltip,
|
||||
block_below_cursor_element,
|
||||
@@ -1612,7 +1582,9 @@ impl Element for TerminalElement {
|
||||
) {
|
||||
let paint_start = Instant::now();
|
||||
window.with_content_mask(Some(ContentMask { bounds }), |window| {
|
||||
let scroll_top = self.terminal_view.read(cx).scroll_top;
|
||||
let terminal_view = self.terminal_view.read(cx);
|
||||
let scroll_top = terminal_view.scroll_top;
|
||||
let mouse_input_mode = terminal_view.mouse_input_mode();
|
||||
|
||||
window.paint_quad(fill(bounds, layout.background_color));
|
||||
let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top);
|
||||
@@ -1634,9 +1606,9 @@ impl Element for TerminalElement {
|
||||
};
|
||||
|
||||
self.register_mouse_listeners(
|
||||
layout.mode,
|
||||
&layout.hitbox,
|
||||
&layout.content_mode,
|
||||
mouse_input_mode,
|
||||
window,
|
||||
);
|
||||
if window.modifiers().secondary()
|
||||
@@ -1799,10 +1771,13 @@ struct TerminalInputHandler {
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
ignore_disabled_input: bool,
|
||||
_: &mut Window,
|
||||
_cx: &mut App,
|
||||
cx: &mut App,
|
||||
) -> Option<UTF16Selection> {
|
||||
if self.terminal_view.read(cx).is_read_only() && !ignore_disabled_input {
|
||||
return None;
|
||||
}
|
||||
// Always return a valid selection for IME positioning,
|
||||
// even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.)
|
||||
// The terminal still has a cursor position that should be used for IME candidate window placement.
|
||||
@@ -1837,6 +1812,9 @@ impl InputHandler for TerminalInputHandler {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if self.terminal_view.read(cx).is_read_only() {
|
||||
return;
|
||||
}
|
||||
self.terminal_view.update(cx, |view, view_cx| {
|
||||
view.clear_marked_text(view_cx);
|
||||
view.commit_text(text, view_cx);
|
||||
@@ -2030,9 +2008,62 @@ pub fn convert_color(fg: &Color, theme: &Theme) -> Hsla {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{AbsoluteLength, Hsla, font};
|
||||
use gpui::{AbsoluteLength, Hsla, TestAppContext, font};
|
||||
use ui::utils::apca_contrast;
|
||||
|
||||
#[gpui::test]
|
||||
async fn terminal_input_handler_respects_read_only_mode(cx: &mut TestAppContext) {
|
||||
let (project, workspace, window_handle) = crate::tests::init_test_with_window(cx).await;
|
||||
for read_only in [false, true] {
|
||||
let (_pane, terminal, terminal_view) = crate::tests::add_display_only_terminal(
|
||||
&project,
|
||||
window_handle,
|
||||
true,
|
||||
read_only,
|
||||
cx,
|
||||
);
|
||||
|
||||
window_handle
|
||||
.update(cx, |_multi_workspace, window, cx| {
|
||||
let mut input_handler = TerminalInputHandler {
|
||||
terminal_view,
|
||||
workspace: workspace.downgrade(),
|
||||
cursor_bounds: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
input_handler
|
||||
.selected_text_range(false, window, cx)
|
||||
.is_none(),
|
||||
read_only
|
||||
);
|
||||
assert!(
|
||||
input_handler
|
||||
.selected_text_range(true, window, cx)
|
||||
.is_some()
|
||||
);
|
||||
|
||||
input_handler.replace_and_mark_text_in_range(None, "あ", None, window, cx);
|
||||
assert_eq!(
|
||||
input_handler.marked_text_range(window, cx),
|
||||
(!read_only).then_some(0..1)
|
||||
);
|
||||
|
||||
input_handler.replace_text_in_range(None, "亜", window, cx);
|
||||
assert_eq!(input_handler.marked_text_range(window, cx), None);
|
||||
assert_eq!(
|
||||
terminal.update(cx, |terminal, _| terminal.take_input_log()),
|
||||
if read_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec!["亜".as_bytes().to_vec()]
|
||||
}
|
||||
);
|
||||
})
|
||||
.expect("test window should remain open");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_decorative_character() {
|
||||
// Box Drawing characters (U+2500 to U+257F)
|
||||
|
||||
@@ -33,9 +33,10 @@ use std::{
|
||||
};
|
||||
use task::TaskId;
|
||||
use terminal::{
|
||||
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, Paste, PasteText, Point, Range,
|
||||
ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop,
|
||||
Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode,
|
||||
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, MouseInputMode, Paste,
|
||||
PasteText, Point, Range, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp,
|
||||
ScrollToBottom, ScrollToTop, Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal,
|
||||
TerminalBounds, ToggleViMode,
|
||||
terminal_settings::{CursorShape, TerminalSettings},
|
||||
};
|
||||
use terminal_element::TerminalElement;
|
||||
@@ -138,6 +139,7 @@ pub struct TerminalView {
|
||||
cursor_shape: CursorShape,
|
||||
blink_manager: Entity<BlinkManager>,
|
||||
mode: TerminalMode,
|
||||
read_only: bool,
|
||||
// Explicit override for whether workspace-specific context menu actions are shown.
|
||||
// When `None`, visibility is derived from `mode` (hidden for embedded terminals).
|
||||
show_workspace_actions: Option<bool>,
|
||||
@@ -290,6 +292,7 @@ impl TerminalView {
|
||||
hover: None,
|
||||
hover_tooltip_update: Task::ready(()),
|
||||
mode: TerminalMode::Standalone,
|
||||
read_only: false,
|
||||
show_workspace_actions: None,
|
||||
workspace_id,
|
||||
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
|
||||
@@ -319,6 +322,25 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn is_read_only(&self) -> bool {
|
||||
self.read_only
|
||||
}
|
||||
|
||||
/// Fixes the view's input policy at construction, without restricting producer output
|
||||
/// or local navigation.
|
||||
pub fn with_read_only(mut self, read_only: bool) -> Self {
|
||||
self.read_only = read_only;
|
||||
self
|
||||
}
|
||||
|
||||
fn mouse_input_mode(&self) -> MouseInputMode {
|
||||
if self.read_only {
|
||||
MouseInputMode::LocalSelection
|
||||
} else {
|
||||
MouseInputMode::ReportToTerminal
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly override whether workspace-specific context menu actions (e.g. creating or
|
||||
/// closing terminal tabs, inline assist) are shown.
|
||||
///
|
||||
@@ -371,6 +393,9 @@ impl TerminalView {
|
||||
|
||||
/// Sets the marked (pre-edit) text from the IME.
|
||||
pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
if text.is_empty() {
|
||||
return self.clear_marked_text(cx);
|
||||
}
|
||||
@@ -395,7 +420,7 @@ impl TerminalView {
|
||||
|
||||
/// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
|
||||
pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
if !text.is_empty() {
|
||||
if !self.read_only && !text.is_empty() {
|
||||
self.terminal.update(cx, |term, _| {
|
||||
term.input(text.to_string().into_bytes());
|
||||
});
|
||||
@@ -543,7 +568,7 @@ impl TerminalView {
|
||||
})
|
||||
.action("Copy", Box::new(Copy))
|
||||
.when(
|
||||
!matches!(self.mode, TerminalMode::Embedded { .. }),
|
||||
!self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }),
|
||||
|menu| {
|
||||
menu.action("Paste", Box::new(Paste))
|
||||
.action("Paste Text", Box::new(PasteText))
|
||||
@@ -551,14 +576,16 @@ impl TerminalView {
|
||||
)
|
||||
.action("Select All", Box::new(SelectAll))
|
||||
.when(
|
||||
!matches!(self.mode, TerminalMode::Embedded { .. }),
|
||||
!self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }),
|
||||
|menu| menu.action("Clear", Box::new(Clear)),
|
||||
)
|
||||
.when(
|
||||
assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }),
|
||||
|menu| {
|
||||
menu.separator()
|
||||
.action("Inline Assist", Box::new(InlineAssist::default()))
|
||||
.when(!self.read_only, |menu| {
|
||||
menu.action("Inline Assist", Box::new(InlineAssist::default()))
|
||||
})
|
||||
.when(has_selection && self.shows_workspace_actions(), |menu| {
|
||||
menu.action("Add to Agent Thread", Box::new(AddSelectionToThread))
|
||||
})
|
||||
@@ -633,6 +660,9 @@ impl TerminalView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.terminal
|
||||
.read(cx)
|
||||
@@ -657,6 +687,9 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
let task = self
|
||||
.terminal
|
||||
.read(cx)
|
||||
@@ -667,6 +700,9 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
self.scroll_top = px(0.);
|
||||
self.terminal.update(cx, |term, _| term.clear());
|
||||
cx.notify();
|
||||
@@ -711,6 +747,7 @@ impl TerminalView {
|
||||
term.scroll_wheel(
|
||||
event,
|
||||
TerminalSettings::get_global(cx).scroll_multiplier.max(0.01),
|
||||
self.mouse_input_mode(),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -910,6 +947,9 @@ impl TerminalView {
|
||||
|
||||
///Attempt to paste the clipboard into the terminal
|
||||
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
let Some(clipboard) = cx.read_from_clipboard() else {
|
||||
return;
|
||||
};
|
||||
@@ -944,6 +984,9 @@ impl TerminalView {
|
||||
|
||||
///Attempt to paste the clipboard text into the terminal
|
||||
fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
let Some(clipboard) = cx.read_from_clipboard() else {
|
||||
return;
|
||||
};
|
||||
@@ -963,6 +1006,9 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
let mut text = paths
|
||||
.iter()
|
||||
.filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?)))
|
||||
@@ -975,6 +1021,9 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
self.clear_bell(cx);
|
||||
self.blink_manager.update(cx, BlinkManager::pause_blinking);
|
||||
self.terminal.update(cx, |term, _| {
|
||||
@@ -1085,8 +1134,8 @@ impl TerminalView {
|
||||
self.terminal = terminal;
|
||||
}
|
||||
|
||||
fn rerun_button(task: &TaskState) -> Option<IconButton> {
|
||||
if !task.spawned_task.show_rerun {
|
||||
fn rerun_button(&self, task: &TaskState) -> Option<IconButton> {
|
||||
if self.read_only || !task.spawned_task.show_rerun {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1271,6 +1320,9 @@ impl TerminalView {
|
||||
/// updates the cursor locally without sending data to the shell, so there's no
|
||||
/// shell output to automatically trigger a re-render.
|
||||
fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
|
||||
if self.read_only && !self.terminal.read(cx).vi_mode_enabled() {
|
||||
return false;
|
||||
}
|
||||
let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
|
||||
(
|
||||
term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta),
|
||||
@@ -1304,7 +1356,9 @@ impl TerminalView {
|
||||
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.terminal.update(cx, |terminal, _| {
|
||||
terminal.set_cursor_shape(self.cursor_shape);
|
||||
terminal.focus_in();
|
||||
if !self.read_only {
|
||||
terminal.focus_in();
|
||||
}
|
||||
});
|
||||
|
||||
let should_blink = match TerminalSettings::get_global(cx).blinking {
|
||||
@@ -1324,7 +1378,9 @@ impl TerminalView {
|
||||
fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.blink_manager.update(cx, BlinkManager::disable);
|
||||
self.terminal.update(cx, |terminal, _| {
|
||||
terminal.focus_out();
|
||||
if !self.read_only {
|
||||
terminal.focus_out();
|
||||
}
|
||||
terminal.set_cursor_shape(CursorShape::Hollow);
|
||||
});
|
||||
cx.notify();
|
||||
@@ -1381,7 +1437,11 @@ impl Render for TerminalView {
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(|this, event: &MouseDownEvent, window, cx| {
|
||||
if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
|
||||
if !this
|
||||
.terminal
|
||||
.read(cx)
|
||||
.mouse_mode(event.modifiers.shift, this.mouse_input_mode())
|
||||
{
|
||||
let had_selection = this.terminal.read(cx).last_content.selection.is_some();
|
||||
if !had_selection {
|
||||
this.terminal.update(cx, |terminal, _| {
|
||||
@@ -1482,15 +1542,15 @@ impl Item for TerminalView {
|
||||
TaskStatus::Running => (
|
||||
IconName::PlayFilled,
|
||||
Color::Disabled,
|
||||
TerminalView::rerun_button(terminal_task),
|
||||
self.rerun_button(terminal_task),
|
||||
),
|
||||
TaskStatus::Unknown => (
|
||||
IconName::Warning,
|
||||
Color::Warning,
|
||||
TerminalView::rerun_button(terminal_task),
|
||||
self.rerun_button(terminal_task),
|
||||
),
|
||||
TaskStatus::Completed { success } => {
|
||||
let rerun_button = TerminalView::rerun_button(terminal_task);
|
||||
let rerun_button = self.rerun_button(terminal_task);
|
||||
|
||||
if *success {
|
||||
(IconName::Check, Color::Success, rerun_button)
|
||||
@@ -1591,6 +1651,9 @@ impl Item for TerminalView {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
if self.read_only {
|
||||
return false;
|
||||
}
|
||||
let Some(project) = self.project.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
@@ -2233,7 +2296,7 @@ mod tests {
|
||||
) {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, cx);
|
||||
add_display_only_terminal(&project, window_handle, true, false, cx);
|
||||
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
@@ -2253,12 +2316,169 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn read_only_blocks_input_actions_and_preserves_output(cx: &mut TestAppContext) {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
cx.update(load_default_keymap);
|
||||
let rerun_count = Rc::new(std::cell::Cell::new(0));
|
||||
cx.update(|cx| {
|
||||
cx.on_action({
|
||||
let rerun_count = rerun_count.clone();
|
||||
move |_: &zed_actions::Rerun, _| rerun_count.set(rerun_count.get() + 1)
|
||||
});
|
||||
});
|
||||
let (pane, terminal, terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, true, cx);
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
|
||||
for output in [
|
||||
b"provider output".as_slice(),
|
||||
b"\x1b[?1049hprovider alternate-screen output".as_slice(),
|
||||
] {
|
||||
let content = cx.update(|window, cx| {
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.write_output(output, cx);
|
||||
terminal.sync(window, cx);
|
||||
terminal.get_content()
|
||||
})
|
||||
});
|
||||
cx.simulate_keystrokes("a enter ctrl-v shift-up");
|
||||
cx.update(|_, cx| {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("clipboard input".into()));
|
||||
});
|
||||
|
||||
let actions: Vec<Box<dyn Action>> = vec![
|
||||
Box::new(SendText("direct input".into())),
|
||||
Box::new(SendKeystroke("enter".into())),
|
||||
Box::new(Paste),
|
||||
Box::new(editor::actions::Paste),
|
||||
Box::new(PasteText),
|
||||
Box::new(Clear),
|
||||
Box::new(ShowCharacterPalette),
|
||||
Box::new(RerunTask),
|
||||
];
|
||||
for action in actions {
|
||||
cx.update(|window, cx| window.dispatch_action(action, cx));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.update(|window, cx| {
|
||||
let paths = ExternalPaths(vec![PathBuf::from(util::path!("/root.txt"))].into());
|
||||
assert!(!pane.update(cx, |pane, cx| {
|
||||
terminal_view.update(cx, |view, cx| view.handle_drop(pane, &paths, window, cx))
|
||||
}));
|
||||
terminal_view.update(cx, |view, cx| {
|
||||
view.add_paths_to_terminal(paths.paths(), window, cx);
|
||||
});
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.sync(window, cx);
|
||||
assert!(terminal.take_input_log().is_empty());
|
||||
assert!(terminal.take_pty_write_log().is_empty());
|
||||
assert_eq!(terminal.get_content(), content);
|
||||
});
|
||||
});
|
||||
}
|
||||
assert_eq!(rerun_count.get(), 0);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn read_only_preserves_vi_navigation_without_enabling_input(cx: &mut TestAppContext) {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, true, cx);
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.write_output(b"first line\nsecond line\n", cx);
|
||||
terminal.sync(window, cx);
|
||||
});
|
||||
window.dispatch_action(Box::new(ToggleViMode), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let cursor = terminal.read_with(&cx, |terminal, _| {
|
||||
assert!(terminal.vi_mode_enabled());
|
||||
terminal.last_content.cursor.point
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_action(Box::new(SendKeystroke("k".into())), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
terminal.read_with(&cx, |terminal, _| terminal.last_content.cursor.point.line),
|
||||
cursor.line - 1,
|
||||
);
|
||||
cx.simulate_keystrokes("v l");
|
||||
assert!(
|
||||
terminal
|
||||
.read_with(&cx, |terminal, _| terminal
|
||||
.last_content
|
||||
.selection_text
|
||||
.clone())
|
||||
.is_some_and(|text| !text.is_empty()),
|
||||
);
|
||||
cx.simulate_keystrokes("y");
|
||||
assert!(
|
||||
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
|
||||
.is_some_and(|text| !text.is_empty()),
|
||||
);
|
||||
cx.simulate_keystrokes("i");
|
||||
assert!(!terminal.read_with(&cx, |terminal, _| terminal.vi_mode_enabled()));
|
||||
cx.simulate_keystrokes("a enter");
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_action(Box::new(SendKeystroke("enter".into())), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
terminal
|
||||
.update(&mut cx, |terminal, _| terminal.take_input_log())
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn read_only_mouse_selection_and_focus_do_not_report_input(cx: &mut TestAppContext) {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, true, cx);
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.write_output(b"\x1b[?1003h\x1b[?1006h\x1b[?1004hhello world\n", cx);
|
||||
terminal.sync(window, cx);
|
||||
terminal.take_pty_write_log();
|
||||
});
|
||||
window.blur(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let bounds = terminal.read_with(&cx, |terminal, _| terminal.last_content.terminal_bounds);
|
||||
let start =
|
||||
bounds.bounds.origin + gpui::point(bounds.cell_width * 0.1, bounds.line_height * 0.5);
|
||||
let end =
|
||||
bounds.bounds.origin + gpui::point(bounds.cell_width * 5.1, bounds.line_height * 0.5);
|
||||
cx.simulate_mouse_move(start, None, gpui::Modifiers::default());
|
||||
cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default());
|
||||
cx.simulate_mouse_move(end, Some(MouseButton::Left), gpui::Modifiers::default());
|
||||
cx.simulate_mouse_up(end, MouseButton::Left, gpui::Modifiers::default());
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(Copy), cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())),
|
||||
Some("hello".into()),
|
||||
);
|
||||
cx.update(|window, cx| window.blur(cx));
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
terminal
|
||||
.update(&mut cx, |terminal, _| terminal.take_pty_write_log())
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
cx.update(load_default_keymap);
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, cx);
|
||||
add_display_only_terminal(&project, window_handle, true, false, cx);
|
||||
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
@@ -2303,7 +2523,7 @@ mod tests {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
cx.update(load_default_keymap);
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, cx);
|
||||
add_display_only_terminal(&project, window_handle, true, false, cx);
|
||||
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
@@ -2335,7 +2555,7 @@ mod tests {
|
||||
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
|
||||
cx.update(load_default_keymap);
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, cx);
|
||||
add_display_only_terminal(&project, window_handle, true, false, cx);
|
||||
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
@@ -2362,7 +2582,7 @@ mod tests {
|
||||
});
|
||||
});
|
||||
let (_pane, terminal, _terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, true, cx);
|
||||
add_display_only_terminal(&project, window_handle, true, false, cx);
|
||||
|
||||
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
|
||||
cx.update(|window, cx| {
|
||||
@@ -2597,10 +2817,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn add_display_only_terminal(
|
||||
pub(super) fn add_display_only_terminal(
|
||||
project: &Entity<Project>,
|
||||
window_handle: gpui::WindowHandle<MultiWorkspace>,
|
||||
focus: bool,
|
||||
read_only: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Pane>, Entity<Terminal>, Entity<TerminalView>) {
|
||||
let project = project.clone();
|
||||
@@ -2629,6 +2850,7 @@ mod tests {
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_read_only(read_only)
|
||||
});
|
||||
|
||||
active_pane.update(cx, |pane, cx| {
|
||||
@@ -2653,7 +2875,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle.
|
||||
async fn init_test_with_window(
|
||||
pub(super) async fn init_test_with_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
Entity<Project>,
|
||||
@@ -2853,7 +3075,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let (active_pane, terminal, terminal_view) =
|
||||
add_display_only_terminal(&project, window_handle, false, cx);
|
||||
add_display_only_terminal(&project, window_handle, false, false, cx);
|
||||
|
||||
let tab_item = window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
|
||||
Reference in new issue
Block a user