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:
Ben Brandt authored and GitHub committed 2026-09-21 15:37:37 +00:00
1 parent a18b1a1026
commit 50061c9cbd
5 files changed
+678 -152

No files matched your search

+123
View File
@@ -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);
+3 -1
View File
@@ -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
View File
@@ -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!(
+133 -102
View File
@@ -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)
+245 -23
View File
@@ -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| {