Files
zed/crates/terminal/src/terminal.rs
T
tidely 570d9095a7 Use Duration to improve type safety and unit correctness (#62969)
# Objective

- Prefer using `Duration` instead of integers when dealing with spans of
time. This prevents confusing different units of time and comparing
arbitrary integers with durations.
- Remove `_ms`/`_secs` suffixes from variables which are `Duration`,
since their unit is inside of the type.
- Initialize large integer constants using `Duration`, which has more
descriptive constructors, while maintaining the resulting integer type.

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-08-21 07:28:05 +00:00

5852 lines
209 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod mappings;
mod alacritty;
mod pty_info;
pub mod terminal_settings;
#[cfg(not(windows))]
use anyhow::Context as _;
use anyhow::{Result, bail};
use futures_lite::future::yield_now;
use log::trace;
use futures::{
FutureExt,
channel::mpsc::{UnboundedReceiver, unbounded},
};
use alacritty_terminal::grid::Dimensions as _;
use itertools::Itertools as _;
use mappings::mouse::{
alt_scroll, grid_point, grid_point_and_side, mouse_button_report, mouse_moved_report,
scroll_report,
};
use async_channel::{Receiver, Sender};
use collections::{HashMap, VecDeque};
use futures::StreamExt;
use pty_info::{ProcessIdGetter, PtyProcessInfo};
use serde::{Deserialize, Serialize};
use settings::Settings;
use task::{HideStrategy, Shell, ShellKind, SpawnInTerminal};
use terminal_settings::{AlternateScroll, CursorShape as SettingsCursorShape, TerminalSettings};
use theme::{ActiveTheme, Theme};
use urlencoding;
use util::{ResultExt as _, paths::PathStyle, truncate_and_trailoff};
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::{
borrow::Cow,
cmp::{self, min},
fmt::{self, Display, Formatter},
ops::{BitOr, BitOrAssign, Deref, Range as StdRange},
path::{Path, PathBuf},
process::ExitStatus,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};
use thiserror::Error;
use vte::ansi::{Attr, Handler, Processor, StdSyncHandler};
pub use vte::ansi::{Color, NamedColor, Rgb};
use gpui::{
App, AppContext as _, BackgroundExecutor, Bounds, ClipboardItem, Context, EventEmitter, Hsla,
Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
Point as GpuiPoint, Rgba, ScrollWheelEvent, Size, Task, TouchPhase, Window, actions, black, px,
};
#[cfg(not(windows))]
use crate::alacritty::current_child_signal_mask;
use crate::alacritty::{
AlacrittyCell, AlacrittyGridIterator, AlacrittyHyperlink, AlacrittySearch, AlacrittyTerm,
AlacrittyTermConfig, AlacrittyTermLock, HyperlinkMatch, PtySender, RegexSearches,
append_text_to_term, apply_config, clear_saved_screen, content_text, display_offset,
display_only_term_config, find_from_terminal_point, full_content_range, last_non_empty_lines,
make_content, new_term, open_pty, pty_options, pty_term_config, resize, screen_lines,
scroll_display, scroll_to_point, search_matches, selection_text, set_default_cursor_style,
set_selection as set_term_selection, shrink_to_used, spawn_event_loop,
toggle_vi_mode as toggle_term_vi_mode, total_lines, update_selection as update_term_selection,
update_selection_to_vi_cursor, update_vi_cursor_for_scroll, used_lines, vi_goto_point,
vi_motion,
};
use crate::mappings::colors::to_vte_rgb;
use crate::mappings::keys::to_esc_str;
/// Process-wide flag set by headless hosts (e.g. the eval CLI) that have no
/// controlling TTY. In such sandboxes PTY allocation and acquiring a
/// controlling terminal fail with `ENOTTY`, so when this is set terminals run
/// their command as a plain subprocess with piped output instead of through a
/// PTY. The normal editor leaves it unset to preserve the interactive PTY
/// experience.
#[derive(Clone, Copy, Default)]
pub struct HeadlessTerminal(pub bool);
impl gpui::Global for HeadlessTerminal {}
impl HeadlessTerminal {
pub fn is_enabled(cx: &App) -> bool {
cx.try_global::<Self>().is_some_and(|headless| headless.0)
}
}
#[derive(Clone, Copy, Debug)]
enum Scroll {
Delta(i32),
PageUp,
PageDown,
Top,
Bottom,
}
#[derive(Clone, Copy, Debug)]
enum ViMotion {
Up,
Down,
Left,
Right,
First,
Last,
FirstOccupied,
High,
Middle,
Low,
WordLeft,
WordRight,
WordRightEnd,
Bracket,
ParagraphUp,
ParagraphDown,
}
#[derive(Clone, Debug)]
pub struct Search {
search: AlacrittySearch,
}
#[derive(Clone, Debug)]
struct Selection {
ty: SelectionType,
start: SelectionAnchor,
end: SelectionAnchor,
head: Point,
}
#[derive(Clone, Copy, Debug)]
struct SelectionAnchor {
point: Point,
side: SelectionSide,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SelectionSide {
Left,
Right,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SelectionType {
Simple,
Semantic,
Lines,
}
impl Selection {
fn new(selection_type: SelectionType, point: Point, side: SelectionSide) -> Self {
let anchor = SelectionAnchor { point, side };
Self {
ty: selection_type,
start: anchor,
end: anchor,
head: point,
}
}
fn simple_range(range: Range) -> Self {
let mut selection = Self::new(SelectionType::Simple, range.start(), SelectionSide::Left);
selection.update(range.end(), SelectionSide::Right);
selection
}
fn update(&mut self, point: Point, side: SelectionSide) {
self.end = SelectionAnchor { point, side };
self.head = point;
}
}
pub fn is_default_background_color(color: Color) -> bool {
matches!(color, Color::Named(NamedColor::Background))
}
pub fn is_app_chosen_exact_color(color: Color) -> bool {
matches!(color, Color::Spec(_) | Color::Indexed(16..=255))
}
pub type AnsiSpans = Vec<(StdRange<usize>, Option<Color>)>;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ParsedAnsiText {
pub text: String,
pub foreground_spans: AnsiSpans,
pub background_spans: AnsiSpans,
}
pub fn parse_ansi_text(input: &[u8]) -> ParsedAnsiText {
let mut handler = StyledAnsiTextHandler::default();
let mut processor = Processor::<StdSyncHandler>::default();
processor.advance(&mut handler, input);
handler.finish()
}
pub fn strip_ansi_text(input: &[u8]) -> String {
let mut handler = PlainAnsiTextHandler::default();
let mut processor = Processor::<StdSyncHandler>::default();
processor.advance(&mut handler, input);
handler.text
}
#[derive(Default)]
struct StyledAnsiTextHandler {
text: String,
foreground_spans: AnsiSpans,
background_spans: AnsiSpans,
current_foreground_range_start: usize,
current_background_range_start: usize,
current_foreground_color: Option<Color>,
current_background_color: Option<Color>,
}
impl StyledAnsiTextHandler {
fn finish(mut self) -> ParsedAnsiText {
if self.current_foreground_range_start < self.text.len() {
self.foreground_spans.push((
self.current_foreground_range_start..self.text.len(),
self.current_foreground_color,
));
}
if self.current_background_range_start < self.text.len() {
self.background_spans.push((
self.current_background_range_start..self.text.len(),
self.current_background_color,
));
}
ParsedAnsiText {
text: self.text,
foreground_spans: self.foreground_spans,
background_spans: self.background_spans,
}
}
fn break_foreground_span(&mut self, color: Option<Color>) {
self.foreground_spans.push((
self.current_foreground_range_start..self.text.len(),
self.current_foreground_color,
));
self.current_foreground_color = color;
self.current_foreground_range_start = self.text.len();
}
fn break_background_span(&mut self, color: Option<Color>) {
self.background_spans.push((
self.current_background_range_start..self.text.len(),
self.current_background_color,
));
self.current_background_color = color;
self.current_background_range_start = self.text.len();
}
}
impl Handler for StyledAnsiTextHandler {
fn input(&mut self, c: char) {
self.text.push(c);
}
fn linefeed(&mut self) {
self.text.push('\n');
}
fn put_tab(&mut self, count: u16) {
self.text.extend(std::iter::repeat_n('\t', count as usize));
}
fn terminal_attribute(&mut self, attr: Attr) {
match attr {
Attr::Foreground(color) => {
self.break_foreground_span(Some(color));
}
Attr::Background(color) => {
self.break_background_span(Some(color));
}
Attr::Reset => {
self.break_foreground_span(None);
self.break_background_span(None);
}
_ => {}
}
}
}
#[derive(Default)]
struct PlainAnsiTextHandler {
text: String,
line_start: usize,
}
impl Handler for PlainAnsiTextHandler {
fn input(&mut self, c: char) {
self.text.push(c);
}
fn linefeed(&mut self) {
self.text.push('\n');
self.line_start = self.text.len();
}
fn carriage_return(&mut self) {
self.text.truncate(self.line_start);
}
fn put_tab(&mut self, count: u16) {
self.text.extend(std::iter::repeat_n('\t', count as usize));
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Hyperlink {
data: HyperlinkData,
}
#[derive(Debug, Clone, Eq, PartialEq)]
enum HyperlinkData {
Alacritty(AlacrittyHyperlink),
Owned { id: Option<Arc<str>>, uri: Arc<str> },
}
#[derive(Default, Debug, Clone, Eq, PartialEq)]
pub struct Cell {
cell: AlacrittyCell,
}
pub struct RenderableCells<'a> {
cells: AlacrittyGridIterator<'a>,
}
#[derive(Debug, Clone)]
pub struct IndexedCell {
pub point: Point,
pub cell: Cell,
}
impl Deref for IndexedCell {
type Target = Cell;
#[inline]
fn deref(&self) -> &Cell {
&self.cell
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct Modes(u32);
impl Modes {
pub const NONE: Self = Self(0);
pub const APP_CURSOR: Self = Self(1 << 0);
pub const APP_KEYPAD: Self = Self(1 << 1);
pub const SHOW_CURSOR: Self = Self(1 << 2);
pub const LINE_WRAP: Self = Self(1 << 3);
pub const ORIGIN: Self = Self(1 << 4);
pub const INSERT: Self = Self(1 << 5);
pub const LINE_FEED_NEW_LINE: Self = Self(1 << 6);
pub const FOCUS_IN_OUT: Self = Self(1 << 7);
pub const ALTERNATE_SCROLL: Self = Self(1 << 8);
pub const BRACKETED_PASTE: Self = Self(1 << 9);
pub const SGR_MOUSE: Self = Self(1 << 10);
pub const UTF8_MOUSE: Self = Self(1 << 11);
pub const ALT_SCREEN: Self = Self(1 << 12);
pub const MOUSE_REPORT_CLICK: Self = Self(1 << 13);
pub const MOUSE_DRAG: Self = Self(1 << 14);
pub const MOUSE_MOTION: Self = Self(1 << 15);
pub const VI: Self = Self(1 << 16);
pub const MOUSE_MODE: Self =
Self(Self::MOUSE_REPORT_CLICK.0 | Self::MOUSE_DRAG.0 | Self::MOUSE_MOTION.0);
pub const fn empty() -> Self {
Self::NONE
}
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
pub const fn intersects(self, other: Self) -> bool {
self.0 & other.0 != 0
}
pub fn insert(&mut self, other: Self) {
self.0 |= other.0;
}
pub fn remove(&mut self, other: Self) {
self.0 &= !other.0;
}
}
impl BitOr for Modes {
type Output = Self;
fn bitor(self, rhs: Self) -> Self::Output {
Self(self.0 | rhs.0)
}
}
impl BitOrAssign for Modes {
fn bitor_assign(&mut self, rhs: Self) {
self.insert(rhs);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Cursor {
pub shape: CursorShape,
pub point: Point,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CursorShape {
Block,
Underline,
Bar,
HollowBlock,
Hidden,
}
impl From<SettingsCursorShape> for CursorShape {
fn from(shape: SettingsCursorShape) -> Self {
match shape {
SettingsCursorShape::Block => Self::Block,
SettingsCursorShape::Underline => Self::Underline,
SettingsCursorShape::Bar => Self::Bar,
SettingsCursorShape::Hollow => Self::HollowBlock,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Point {
pub line: i32,
pub column: usize,
}
impl Point {
pub fn new(line: i32, column: usize) -> Self {
Self { line, column }
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Range {
start: Point,
end: Point,
}
impl Range {
pub fn new(start: Point, end: Point) -> Self {
Self { start, end }
}
pub fn start(&self) -> Point {
self.start
}
pub fn end(&self) -> Point {
self.end
}
pub fn contains(&self, point: Point) -> bool {
self.start <= point && point <= self.end
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SelectionRange {
pub start: Point,
pub end: Point,
pub is_block: bool,
}
impl SelectionRange {
pub fn point_range(self) -> Range {
Range::new(self.start, self.end)
}
}
// TODO: Un-pub
#[derive(Clone)]
pub struct Content {
pub cells: Vec<IndexedCell>,
pub mode: Modes,
pub total_lines: usize,
pub display_offset: usize,
pub columns: usize,
pub screen_lines: usize,
pub selection_text: Option<String>,
pub selection: Option<SelectionRange>,
pub cursor: Cursor,
pub cursor_char: char,
pub terminal_bounds: TerminalBounds,
pub last_hovered_word: Option<HoveredWord>,
pub grid_lines_change: GridLinesChange,
pub scrolled_to_top: bool,
pub scrolled_to_bottom: bool,
pub bottom_row_occupied: bool,
}
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub enum GridLinesChange {
#[default]
Unchanged,
Changed,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct HoveredWord {
pub word: String,
pub word_match: Range,
pub id: usize,
}
impl Default for Content {
fn default() -> Self {
Content {
cells: Default::default(),
mode: Default::default(),
total_lines: Default::default(),
display_offset: Default::default(),
columns: Default::default(),
screen_lines: Default::default(),
selection_text: Default::default(),
selection: Default::default(),
cursor: Cursor {
shape: CursorShape::Block,
point: Point::new(0, 0),
},
cursor_char: Default::default(),
terminal_bounds: Default::default(),
last_hovered_word: None,
grid_lines_change: Default::default(),
scrolled_to_top: false,
scrolled_to_bottom: false,
bottom_row_occupied: false,
}
}
}
#[derive(PartialEq, Eq)]
enum SelectionPhase {
Selecting,
Ended,
}
#[cfg(test)]
mod domain_tests {
use super::*;
#[test]
fn strip_ansi_text_removes_ansi_and_handles_carriage_returns() {
let cases = [
("no escape codes here\n", "no escape codes here\n"),
("\x1b[31mhello\x1b[0m", "hello"),
("\x1b[1;32mfoo\x1b[0m bar", "foo bar"),
("progress 10%\rprogress 100%\n", "progress 100%\n"),
];
for (input, expected) in cases {
assert_eq!(strip_ansi_text(input.as_bytes()), expected);
}
}
#[test]
fn parse_ansi_text_records_foreground_and_background_spans() {
let parsed = parse_ansi_text(b"\x1b[31mred\x1b[44mblue-bg\x1b[0mplain");
assert_eq!(parsed.text, "redblue-bgplain");
assert_eq!(
parsed.foreground_spans,
vec![
(0..0, None),
(0..10, Some(Color::Named(NamedColor::Red))),
(10..15, None),
]
);
assert_eq!(
parsed.background_spans,
vec![
(0..3, None),
(3..10, Some(Color::Named(NamedColor::Blue))),
(10..15, None),
]
);
}
#[test]
fn terminal_cell_clone_shares_extra_storage() {
let mut cell = Cell::default();
cell.push_zerowidth('a');
let clone = cell.clone();
match (&cell.cell.extra, &clone.cell.extra) {
(Some(extra), Some(clone_extra)) => assert!(Arc::ptr_eq(extra, clone_extra)),
_ => panic!("expected extra storage on both cells"),
}
}
}
actions!(
terminal,
[
/// Clears the terminal screen.
Clear,
/// Copies selected text to the clipboard.
Copy,
/// Pastes from the clipboard.
Paste,
/// Pastes the text from the clipboard.
PasteText,
/// Shows the character palette for special characters.
ShowCharacterPalette,
/// Searches for text in the terminal.
SearchTest,
/// Scrolls up by one line.
ScrollLineUp,
/// Scrolls down by one line.
ScrollLineDown,
/// Scrolls up by one page.
ScrollPageUp,
/// Scrolls down by one page.
ScrollPageDown,
/// Scrolls up by half a page.
ScrollHalfPageUp,
/// Scrolls down by half a page.
ScrollHalfPageDown,
/// Scrolls to the top of the terminal buffer.
ScrollToTop,
/// Scrolls to the bottom of the terminal buffer.
ScrollToBottom,
/// Toggles vi mode in the terminal.
ToggleViMode,
/// Selects all text in the terminal.
SelectAll,
]
);
const DEBUG_TERMINAL_WIDTH: Pixels = px(500.);
const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.);
const DEBUG_CELL_WIDTH: Pixels = px(5.);
const DEBUG_LINE_HEIGHT: Pixels = px(5.);
/// Inserts Zed-specific environment variables for terminal sessions.
/// Used by both local terminals and remote terminals (via SSH).
pub fn insert_zed_terminal_env(
env: &mut HashMap<String, String>,
version: &impl std::fmt::Display,
) {
env.insert("ZED_TERM".to_string(), "true".to_string());
env.insert("TERM_PROGRAM".to_string(), "zed".to_string());
env.insert("TERM".to_string(), "xterm-256color".to_string());
env.insert("COLORTERM".to_string(), "truecolor".to_string());
env.insert("TERM_PROGRAM_VERSION".to_string(), version.to_string());
}
///Upward flowing events, for changing the title and such
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
TitleChanged,
BreadcrumbsChanged,
CloseTerminal,
Bell,
Wakeup,
BlinkChanged(bool),
SelectionsChanged,
NewNavigationTarget(Option<MaybeNavigationTarget>),
Open(MaybeNavigationTarget),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathLikeTarget {
/// File system path, absolute or relative, existing or not.
/// Might have line and column number(s) attached as `file.rs:1:23`
pub maybe_path: String,
/// Current working directory of the terminal
pub working_directory: Option<PathBuf>,
}
/// A string inside terminal, potentially useful as a URI that can be opened.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MaybeNavigationTarget {
/// HTTP, git, etc. string determined by the `URL_REGEX` regex.
Url(String),
/// File system path, absolute or relative, existing or not.
/// Might have line and column number(s) attached as `file.rs:1:23`
PathLike(PathLikeTarget),
}
#[derive(Clone)]
enum InternalEvent {
Resize(TerminalBounds),
Clear,
// FocusNextMatch,
Scroll(Scroll),
ScrollToPoint(Point),
SetSelection(Option<Selection>),
UpdateSelection(GpuiPoint<Pixels>),
FindHyperlink(GpuiPoint<Pixels>, bool),
ProcessHyperlink(HyperlinkMatch, bool),
// Whether keep selection when copy
Copy(Option<bool>),
// Vi mode events
ToggleViMode,
ViMotion(ViMotion),
MoveViCursorToPoint(Point),
}
type ClipboardFormatter = Arc<dyn Fn(&str) -> String + Sync + Send + 'static>;
type ColorFormatter = Arc<dyn Fn(Rgb) -> String + Sync + Send + 'static>;
type TextAreaSizeFormatter = Arc<dyn Fn(TerminalBounds) -> String + Sync + Send + 'static>;
#[derive(Clone)]
pub(crate) enum TerminalBackendEvent {
MouseCursorDirty,
Title(String),
ResetTitle,
ClipboardStore(String),
ClipboardLoad(ClipboardFormatter),
ColorRequest(usize, ColorFormatter),
PtyWrite(String),
TextAreaSizeRequest(TextAreaSizeFormatter),
CursorBlinkingChange,
Wakeup,
Bell,
Exit,
ChildExit(ExitStatus),
}
impl fmt::Debug for TerminalBackendEvent {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::MouseCursorDirty => f.write_str("MouseCursorDirty"),
Self::Title(title) => write!(f, "Title({title})"),
Self::ResetTitle => f.write_str("ResetTitle"),
Self::ClipboardStore(data) => write!(f, "ClipboardStore({data})"),
Self::ClipboardLoad(_) => f.write_str("ClipboardLoad"),
Self::ColorRequest(index, _) => write!(f, "ColorRequest({index})"),
Self::PtyWrite(output) => write!(f, "PtyWrite({output})"),
Self::TextAreaSizeRequest(_) => f.write_str("TextAreaSizeRequest"),
Self::CursorBlinkingChange => f.write_str("CursorBlinkingChange"),
Self::Wakeup => f.write_str("Wakeup"),
Self::Bell => f.write_str("Bell"),
Self::Exit => f.write_str("Exit"),
Self::ChildExit(status) => write!(f, "ChildExit({status})"),
}
}
}
enum PtyEvent {
Event(TerminalBackendEvent),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalBounds {
pub cell_width: Pixels,
pub line_height: Pixels,
pub bounds: Bounds<Pixels>,
}
impl TerminalBounds {
pub fn new(line_height: Pixels, cell_width: Pixels, bounds: Bounds<Pixels>) -> Self {
TerminalBounds {
cell_width,
line_height,
bounds,
}
}
pub fn num_lines(&self) -> usize {
// Tolerance to prevent f32 precision from losing a row:
// `N * line_height / line_height` can be N-epsilon, which floor()
// would round down, pushing the first line into invisible scrollback.
let raw = self.bounds.size.height / self.line_height;
raw.next_up().floor() as usize
}
pub fn num_columns(&self) -> usize {
let raw = self.bounds.size.width / self.cell_width;
raw.next_up().floor() as usize
}
pub fn height(&self) -> Pixels {
self.bounds.size.height
}
pub fn width(&self) -> Pixels {
self.bounds.size.width
}
pub fn cell_width(&self) -> Pixels {
self.cell_width
}
pub fn line_height(&self) -> Pixels {
self.line_height
}
}
impl Default for TerminalBounds {
fn default() -> Self {
TerminalBounds::new(
DEBUG_LINE_HEIGHT,
DEBUG_CELL_WIDTH,
Bounds {
origin: GpuiPoint::default(),
size: Size {
width: DEBUG_TERMINAL_WIDTH,
height: DEBUG_TERMINAL_HEIGHT,
},
},
)
}
}
fn normalize_terminal_bounds(mut bounds: TerminalBounds) -> TerminalBounds {
bounds.bounds.size.height = cmp::max(bounds.line_height, bounds.height());
bounds.bounds.size.width = cmp::max(bounds.cell_width, bounds.width());
bounds
}
#[derive(Error, Debug)]
pub struct TerminalError {
pub directory: Option<PathBuf>,
pub program: Option<String>,
pub args: Option<Vec<String>>,
pub title_override: Option<String>,
pub source: std::io::Error,
}
impl TerminalError {
fn fmt_directory(&self) -> String {
self.directory
.clone()
.map(|path| {
match path
.into_os_string()
.into_string()
.map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
{
Ok(s) => s,
Err(s) => s,
}
})
.unwrap_or_else(|| "<none specified>".to_string())
}
fn fmt_shell(&self) -> String {
if let Some(title_override) = &self.title_override {
format!(
"{} {} ({})",
self.program.as_deref().unwrap_or("<system defined shell>"),
self.args.as_ref().into_iter().flatten().format(" "),
title_override
)
} else {
format!(
"{} {}",
self.program.as_deref().unwrap_or("<system defined shell>"),
self.args.as_ref().into_iter().flatten().format(" ")
)
}
}
}
impl Display for TerminalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let dir_string: String = self.fmt_directory();
let shell = self.fmt_shell();
write!(
f,
"Working directory: {} Shell command: `{}`, IOError: {}",
dir_string, shell, self.source
)
}
}
// https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213
const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000;
pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
static NEXT_INIT_COMMAND_STARTUP_MARKER_ID: AtomicU64 = AtomicU64::new(1);
const INIT_COMMAND_STARTUP_MARKER_PREFIX: &str = "__zed_init_command_ready_";
const INIT_COMMAND_STARTUP_MARKER_SUFFIX: &str = "__";
const INIT_COMMAND_STARTUP_MARKER_SEARCH_LINES: usize = 64;
fn init_command_startup_marker(marker_id: u64) -> String {
format!("{INIT_COMMAND_STARTUP_MARKER_PREFIX}{marker_id}{INIT_COMMAND_STARTUP_MARKER_SUFFIX}")
}
fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) -> String {
// Split the marker across the command so its echo can't satisfy the
// handshake; only the command's output contains the contiguous marker.
match shell_kind {
ShellKind::PowerShell | ShellKind::Pwsh => format!(
"Write-Output ('{INIT_COMMAND_STARTUP_MARKER_PREFIX}' + '{marker_id}' + '{INIT_COMMAND_STARTUP_MARKER_SUFFIX}')"
),
ShellKind::Cmd => {
format!(
"<nul set /p zed_init_ready={INIT_COMMAND_STARTUP_MARKER_PREFIX}&echo {marker_id}{INIT_COMMAND_STARTUP_MARKER_SUFFIX}"
)
}
ShellKind::Nushell => {
format!(
"print $\"{INIT_COMMAND_STARTUP_MARKER_PREFIX}({marker_id}){INIT_COMMAND_STARTUP_MARKER_SUFFIX}\""
)
}
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Xonsh
| ShellKind::Elvish => format!(
"printf '%s%s%s\\n' {INIT_COMMAND_STARTUP_MARKER_PREFIX} {marker_id} {INIT_COMMAND_STARTUP_MARKER_SUFFIX}"
),
}
}
pub struct TerminalBuilder {
terminal: Terminal,
events_rx: UnboundedReceiver<PtyEvent>,
}
impl TerminalBuilder {
pub fn new_display_only(
cursor_shape: SettingsCursorShape,
alternate_scroll: AlternateScroll,
max_scroll_history_lines: Option<usize>,
window_id: u64,
background_executor: &BackgroundExecutor,
path_style: PathStyle,
) -> TerminalBuilder {
Self::new_display_only_with_bounds(
cursor_shape,
alternate_scroll,
max_scroll_history_lines,
window_id,
background_executor,
path_style,
TerminalBounds::default(),
)
}
pub fn new_display_only_with_bounds(
cursor_shape: SettingsCursorShape,
alternate_scroll: AlternateScroll,
max_scroll_history_lines: Option<usize>,
window_id: u64,
background_executor: &BackgroundExecutor,
path_style: PathStyle,
terminal_bounds: TerminalBounds,
) -> TerminalBuilder {
let terminal_bounds = normalize_terminal_bounds(terminal_bounds);
let scrolling_history = max_scroll_history_lines
.unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
.min(MAX_SCROLL_HISTORY_LINES);
let config = display_only_term_config(scrolling_history, cursor_shape);
let (events_tx, events_rx) = unbounded();
let term = new_term(&config, terminal_bounds, events_tx, alternate_scroll);
let terminal = Terminal {
task: None,
terminal_type: TerminalType::DisplayOnly,
subprocess: None,
completion_tx: None,
term,
term_config: config,
output_processor: Processor::<StdSyncHandler>::new(),
title_override: None,
events: VecDeque::with_capacity(10),
last_content: Content {
terminal_bounds,
..Default::default()
},
last_mouse: None,
mouse_down_position: None,
matches: Vec::new(),
selection_head: None,
breadcrumb_text: String::new(),
scroll_px: px(0.),
next_link_id: 0,
selection_phase: SelectionPhase::Ended,
hyperlink_regex_searches: RegexSearches::default(),
vi_mode_enabled: false,
is_remote_terminal: false,
last_mouse_move_time: Instant::now(),
last_hyperlink_search_position: None,
mouse_down_hyperlink: None,
#[cfg(windows)]
shell_program: None,
activation_script: Vec::new(),
template: CopyTemplate {
shell: Shell::System,
env: HashMap::default(),
cursor_shape,
alternate_scroll,
max_scroll_history_lines,
path_hyperlink_regexes: Vec::default(),
path_hyperlink_timeout: Duration::ZERO,
window_id,
},
child_exited: None,
keyboard_input_sent: false,
init_command_startup_marker: None,
init_command_startup_tx: None,
event_loop_task: Task::ready(Ok(())),
background_executor: background_executor.clone(),
path_style,
cwd_history: Vec::new(),
pending_cwd_boundary: None,
#[cfg(any(test, feature = "test-support"))]
input_log: Vec::new(),
#[cfg(test)]
suppress_hyperlink_throttle_once: false,
#[cfg(any(test, feature = "test-support"))]
pty_write_log: Default::default(),
};
TerminalBuilder {
terminal,
events_rx,
}
}
pub fn new(
working_directory: Option<PathBuf>,
task: Option<TaskState>,
shell: Shell,
mut env: HashMap<String, String>,
cursor_shape: SettingsCursorShape,
alternate_scroll: AlternateScroll,
max_scroll_history_lines: Option<usize>,
path_hyperlink_regexes: Vec<String>,
path_hyperlink_timeout: Duration,
is_remote_terminal: bool,
window_id: u64,
completion_tx: Option<Sender<Option<ExitStatus>>>,
cx: &App,
activation_script: Vec<String>,
path_style: PathStyle,
) -> Task<Result<TerminalBuilder>> {
let version = release_channel::AppVersion::global(cx);
let background_executor = cx.background_executor().clone();
// Headless hosts (e.g. the eval CLI) have no controlling TTY, so PTY
// allocation / acquiring a controlling terminal fails with `ENOTTY`.
// When set, run the command as a plain subprocess instead.
let no_pty = HeadlessTerminal::is_enabled(cx);
#[cfg(not(windows))]
let child_signal_mask = match current_child_signal_mask()
.context("failed to capture terminal child signal mask")
{
Ok(signal_mask) => Some(signal_mask),
Err(error) => return Task::ready(Err(error)),
};
let fut = async move {
// Remove SHLVL so the spawned shell initializes it to 1, matching
// the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty.
env.remove("SHLVL");
// If the parent environment doesn't have a locale set
// (As is the case when launched from a .app on MacOS),
// and the Project doesn't have a locale set, then
// set a fallback for our child environment to use.
if std::env::var("LANG").is_err() {
env.entry("LANG".to_string())
.or_insert_with(|| "en_US.UTF-8".to_string());
}
insert_zed_terminal_env(&mut env, &version);
#[derive(Default)]
struct ShellParams {
program: String,
args: Option<Vec<String>>,
title_override: Option<String>,
}
impl ShellParams {
fn new(
program: String,
args: Option<Vec<String>>,
title_override: Option<String>,
) -> Self {
log::debug!("Using {program} as shell");
Self {
program,
args,
title_override,
}
}
}
let shell_params = match shell.clone() {
Shell::System => {
if cfg!(windows) {
Some(ShellParams::new(
util::shell::get_windows_system_shell(),
None,
None,
))
} else {
None
}
}
Shell::Program(program) => Some(ShellParams::new(program, None, None)),
Shell::WithArguments {
program,
args,
title_override,
} => Some(ShellParams::new(program, Some(args), title_override)),
};
let terminal_title_override =
shell_params.as_ref().and_then(|e| e.title_override.clone());
#[cfg(windows)]
let shell_program = shell_params.as_ref().map(|params| {
use util::ResultExt;
Self::resolve_path(&params.program)
.log_err()
.unwrap_or(params.program.clone())
});
// Note: when remoting, this shell_kind will scrutinize `ssh` or
// `wsl.exe` as a shell and fall back to posix or powershell based on
// the compilation target. This is fine right now due to the restricted
// way we use the return value, but would become incorrect if we
// supported remoting into windows.
let shell_kind = shell.shell_kind(cfg!(windows));
let scrolling_history = if task.is_some() {
// Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
// After the task finishes, we do not allow appending to that terminal, so small tasks output should not
// cause excessive memory usage over time.
MAX_SCROLL_HISTORY_LINES
} else {
max_scroll_history_lines
.unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
.min(MAX_SCROLL_HISTORY_LINES)
};
let config = pty_term_config(scrolling_history, cursor_shape);
//Spawn a task so the Alacritty EventLoop (or the subprocess reader) can communicate with us
//TODO: Remove with a bounded sender which can be dispatched on &self
let (events_tx, events_rx) = unbounded();
//Set up the terminal...
let term = new_term(
&config,
TerminalBounds::default(),
events_tx.clone(),
alternate_scroll,
);
// When `no_pty` is set (headless hosts), run the task as a plain
// subprocess and pump its piped output into the same emulator the
// PTY path would feed.
let (terminal_type, subprocess) = if no_pty {
let (program, args) = match &shell_params {
Some(params) => (
params.program.clone(),
params.args.clone().unwrap_or_default(),
),
None => (util::shell::get_system_shell(), Vec::new()),
};
let subprocess = match spawn_task_subprocess(
program,
args,
env.clone(),
working_directory.clone(),
term.clone(),
events_tx,
&background_executor,
) {
Ok(subprocess) => subprocess,
Err(error) => {
bail!(TerminalError {
directory: working_directory,
program: shell_params.as_ref().map(|params| params.program.clone()),
args: shell_params.as_ref().and_then(|params| params.args.clone()),
title_override: terminal_title_override,
source: std::io::Error::other(format!("{error:#}")),
});
}
};
(TerminalType::DisplayOnly, Some(subprocess))
} else {
let alacritty_shell = shell_params.as_ref().map(|params| {
(
params.program.clone(),
params.args.clone().unwrap_or_default(),
)
});
let pty_options = pty_options(
alacritty_shell,
working_directory.clone(),
env.clone(),
// We pass in the foreground thread's signal mask to the child process via pty_options,
// so terminal construction can run on a background thread without breaking Ctrl-C and other signals
// otherwise the terminal would inherit the background executor's signal mask which blocks
// some terminal signals
#[cfg(not(windows))]
child_signal_mask,
#[cfg(windows)]
shell_kind.tty_escape_args(),
);
//Setup the pty...
let pty = match open_pty(&pty_options, TerminalBounds::default(), window_id) {
Ok(pty) => pty,
Err(error) => {
bail!(TerminalError {
directory: working_directory,
program: shell_params.as_ref().map(|params| params.program.clone()),
args: shell_params.as_ref().and_then(|params| params.args.clone()),
title_override: terminal_title_override,
source: error,
});
}
};
let pty_info = PtyProcessInfo::new(ProcessIdGetter::from(&pty));
//And connect them together
let pty_tx =
spawn_event_loop(term.clone(), events_tx, pty, pty_options.drain_on_exit)?;
(
TerminalType::Pty {
pty_tx,
info: Arc::new(pty_info),
},
None,
)
};
let no_task = task.is_none();
let terminal = Terminal {
task,
terminal_type,
subprocess,
completion_tx,
term,
term_config: config,
output_processor: Processor::<StdSyncHandler>::new(),
title_override: terminal_title_override,
events: VecDeque::with_capacity(10), //Should never get this high.
last_content: Default::default(),
last_mouse: None,
mouse_down_position: None,
matches: Vec::new(),
selection_head: None,
breadcrumb_text: String::new(),
scroll_px: px(0.),
next_link_id: 0,
selection_phase: SelectionPhase::Ended,
hyperlink_regex_searches: RegexSearches::new(
&path_hyperlink_regexes,
path_hyperlink_timeout,
),
vi_mode_enabled: false,
is_remote_terminal,
last_mouse_move_time: Instant::now(),
last_hyperlink_search_position: None,
mouse_down_hyperlink: None,
#[cfg(windows)]
shell_program,
activation_script: activation_script.clone(),
template: CopyTemplate {
shell,
env,
cursor_shape,
alternate_scroll,
max_scroll_history_lines,
path_hyperlink_regexes,
path_hyperlink_timeout,
window_id,
},
child_exited: None,
keyboard_input_sent: false,
init_command_startup_marker: None,
init_command_startup_tx: None,
event_loop_task: Task::ready(Ok(())),
background_executor,
path_style,
cwd_history: if is_remote_terminal {
Vec::new()
} else {
working_directory
.as_ref()
.map(|working_directory| {
vec![CwdHistoryEntry {
scrollback_position: i32::MIN,
working_directory: working_directory.clone(),
}]
})
.unwrap_or_default()
},
pending_cwd_boundary: None,
#[cfg(any(test, feature = "test-support"))]
input_log: Vec::new(),
#[cfg(test)]
suppress_hyperlink_throttle_once: false,
#[cfg(any(test, feature = "test-support"))]
pty_write_log: Default::default(),
};
if !activation_script.is_empty() && no_task {
for activation_script in activation_script {
terminal.write_to_pty(activation_script.into_bytes());
// Simulate enter key press
// NOTE(PowerShell): using `\r\n` will put PowerShell in a continuation mode (infamous >> character)
// and generally mess up the rendering.
terminal.write_to_pty(b"\x0d");
}
// In order to clear the screen at this point, we have two options:
// 1. We can send a shell-specific command such as "clear" or "cls"
// 2. We can "echo" a marker message that we will then catch when handling a Wakeup event
// and clear the screen using `terminal.clear()` method
// We cannot issue a `terminal.clear()` command at this point as alacritty is evented
// and while we have sent the activation script to the pty, it will be executed asynchronously.
// Therefore, we somehow need to wait for the activation script to finish executing before we
// can proceed with clearing the screen.
terminal.write_to_pty(shell_kind.clear_screen_command().as_bytes());
// Simulate enter key press
terminal.write_to_pty(b"\x0d");
}
Ok(TerminalBuilder {
terminal,
events_rx,
})
};
cx.background_spawn(fut)
}
pub fn subscribe(mut self, cx: &Context<Terminal>) -> Terminal {
//Event loop
self.terminal.event_loop_task = cx.spawn(async move |terminal, cx| {
while let Some(event) = self.events_rx.next().await {
terminal.update(cx, |terminal, cx| {
//Process the first event immediately for lowered latency
terminal.process_pty_event(event, cx);
})?;
'outer: loop {
let mut events = Vec::new();
#[cfg(any(test, feature = "test-support"))]
let mut timer = cx.background_executor().simulate_random_delay().fuse();
#[cfg(not(any(test, feature = "test-support")))]
let mut timer = cx
.background_executor()
.timer(std::time::Duration::from_millis(4))
.fuse();
let mut wakeup = false;
loop {
futures::select_biased! {
_ = timer => break,
event = self.events_rx.next() => {
if let Some(event) = event {
if matches!(event, PtyEvent::Event(TerminalBackendEvent::Wakeup))
{
wakeup = true;
} else {
events.push(event);
}
if events.len() > 100 {
break;
}
} else {
break;
}
},
}
}
if events.is_empty() && !wakeup {
yield_now().await;
break 'outer;
}
terminal.update(cx, |this, cx| {
if wakeup {
this.process_event(TerminalBackendEvent::Wakeup, cx);
}
for event in events {
this.process_pty_event(event, cx);
}
})?;
yield_now().await;
}
}
anyhow::Ok(())
});
self.terminal
}
#[cfg(windows)]
fn resolve_path(path: &str) -> Result<String> {
use windows::Win32::Storage::FileSystem::SearchPathW;
use windows::core::HSTRING;
let path = if path.starts_with(r"\\?\") || !path.contains(&['/', '\\']) {
path.to_string()
} else {
r"\\?\".to_string() + path
};
let required_length = unsafe { SearchPathW(None, &HSTRING::from(&path), None, None, None) };
let mut buf = vec![0u16; required_length as usize];
let size = unsafe { SearchPathW(None, &HSTRING::from(&path), None, Some(&mut buf), None) };
Ok(String::from_utf16(&buf[..size as usize])?)
}
}
enum TerminalType {
Pty {
pty_tx: PtySender,
info: Arc<PtyProcessInfo>,
},
DisplayOnly,
}
pub struct Terminal {
terminal_type: TerminalType,
/// Set for non-PTY terminals (see [`HeadlessTerminal`]); owns the spawned
/// subprocess and the task pumping its output into the grid.
subprocess: Option<SubprocessHandle>,
completion_tx: Option<Sender<Option<ExitStatus>>>,
term: Arc<AlacrittyTermLock>,
term_config: AlacrittyTermConfig,
output_processor: Processor<StdSyncHandler>,
events: VecDeque<InternalEvent>,
/// This is only used for mouse mode cell change detection
last_mouse: Option<(Point, SelectionSide)>,
/// Window-relative position of the most recent left mouse-down. Used to
/// apply a drag threshold before starting a selection (see #58970).
mouse_down_position: Option<GpuiPoint<Pixels>>,
pub matches: Vec<Range>,
pub last_content: Content,
pub selection_head: Option<Point>,
pub breadcrumb_text: String,
title_override: Option<String>,
scroll_px: Pixels,
next_link_id: usize,
selection_phase: SelectionPhase,
hyperlink_regex_searches: RegexSearches,
task: Option<TaskState>,
vi_mode_enabled: bool,
is_remote_terminal: bool,
last_mouse_move_time: Instant,
last_hyperlink_search_position: Option<GpuiPoint<Pixels>>,
mouse_down_hyperlink: Option<HyperlinkMatch>,
#[cfg(windows)]
shell_program: Option<String>,
template: CopyTemplate,
activation_script: Vec<String>,
child_exited: Option<ExitStatus>,
keyboard_input_sent: bool,
init_command_startup_marker: Option<String>,
init_command_startup_tx: Option<Sender<()>>,
event_loop_task: Task<Result<(), anyhow::Error>>,
background_executor: BackgroundExecutor,
path_style: PathStyle,
cwd_history: Vec<CwdHistoryEntry>,
pending_cwd_boundary: Option<i32>,
#[cfg(any(test, feature = "test-support"))]
input_log: Vec<Vec<u8>>,
#[cfg(test)]
suppress_hyperlink_throttle_once: bool,
#[cfg(any(test, feature = "test-support"))]
pty_write_log: std::cell::RefCell<Vec<Vec<u8>>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CwdHistoryEntry {
/// Line offset in the retained scrollback buffer.
scrollback_position: i32,
working_directory: PathBuf,
}
struct CopyTemplate {
shell: Shell,
env: HashMap<String, String>,
cursor_shape: SettingsCursorShape,
alternate_scroll: AlternateScroll,
max_scroll_history_lines: Option<usize>,
path_hyperlink_regexes: Vec<String>,
path_hyperlink_timeout: Duration,
window_id: u64,
}
#[derive(Debug)]
pub struct TaskState {
pub status: TaskStatus,
pub completion_rx: Receiver<Option<ExitStatus>>,
pub spawned_task: SpawnInTerminal,
}
/// A status of the current terminal tab's task.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskStatus {
/// The task had been started, but got cancelled or somehow otherwise it did not
/// report its exit code before the terminal event loop was shut down.
Unknown,
/// The task is started and running currently.
Running,
/// After the start, the task stopped running and reported its error code back.
Completed { success: bool },
}
impl TaskStatus {
fn register_terminal_exit(&mut self) {
if self == &Self::Running {
*self = Self::Unknown;
}
}
fn register_task_exit(&mut self, error_code: i32) {
*self = TaskStatus::Completed {
success: error_code == 0,
};
}
}
const FIND_HYPERLINK_THROTTLE_PX: Pixels = px(5.0);
const FIND_HYPERLINK_THROTTLE: Duration = Duration::from_millis(100);
/// Minimum pointer movement before a left click begins a selection. This keeps
/// a click that jitters by a pixel or two (such as the window-focusing click)
/// from starting a selection and, with `copy_on_select` enabled, clobbering the
/// clipboard. Mirrors the drag threshold used by gpui's `div` element.
const SELECTION_DRAG_THRESHOLD: f64 = 2.0;
impl Terminal {
fn process_pty_event(&mut self, event: PtyEvent, cx: &mut Context<Self>) {
match event {
PtyEvent::Event(event) => self.process_event(event, cx),
}
}
fn process_event(&mut self, event: TerminalBackendEvent, cx: &mut Context<Self>) {
match event {
TerminalBackendEvent::Title(title) => {
// ignore default shell program title change as windows always sends those events
// and it would end up showing the shell executable path in breadcrumbs
#[cfg(windows)]
if self
.shell_program
.as_ref()
.map(|e| *e == title)
.unwrap_or(false)
{
return;
}
self.breadcrumb_text = title;
cx.emit(Event::BreadcrumbsChanged);
}
TerminalBackendEvent::ResetTitle => {
self.breadcrumb_text = String::new();
cx.emit(Event::BreadcrumbsChanged);
}
TerminalBackendEvent::ClipboardStore(data) => {
cx.write_to_clipboard(ClipboardItem::new_string(data))
}
TerminalBackendEvent::ClipboardLoad(format) => {
self.write_to_pty(
match &cx.read_from_clipboard().and_then(|item| item.text()) {
// The terminal only supports pasting strings, not images.
Some(text) => format(text),
_ => format(""),
}
.into_bytes(),
)
}
TerminalBackendEvent::PtyWrite(out) => self.write_to_pty(out.into_bytes()),
TerminalBackendEvent::TextAreaSizeRequest(format) => {
self.write_to_pty(format(self.last_content.terminal_bounds).into_bytes())
}
TerminalBackendEvent::CursorBlinkingChange => {
let terminal = self.term.lock();
let blinking = terminal.cursor_style().blinking;
cx.emit(Event::BlinkChanged(blinking));
}
TerminalBackendEvent::Bell => {
cx.emit(Event::Bell);
}
TerminalBackendEvent::Exit => self.register_task_finished(None, cx),
TerminalBackendEvent::MouseCursorDirty => {
//NOOP, Handled in render
}
TerminalBackendEvent::Wakeup => {
self.detect_init_command_startup_marker();
cx.emit(Event::Wakeup);
if let TerminalType::Pty { info, .. } = &self.terminal_type {
info.emit_title_changed_if_changed(cx);
}
}
TerminalBackendEvent::ColorRequest(index, format) => {
// It's important that the color request is processed here to retain relative order
// with other PTY writes. Otherwise applications might witness out-of-order
// responses to requests. For example: An application sending `OSC 11 ; ? ST`
// (color request) followed by `CSI c` (request device attributes) would receive
// the response to `CSI c` first.
// Instead of locking, we could store the colors in `self.last_content`. But then
// we might respond with out of date value if a "set color" sequence is immediately
// followed by a color request sequence.
let color = self.term.lock().colors()[index]
.unwrap_or_else(|| to_vte_rgb(get_color_at_index(index, cx.theme().as_ref())));
self.write_to_pty(format(color).into_bytes());
}
TerminalBackendEvent::ChildExit(exit_status) => {
self.register_task_finished(Some(exit_status), cx);
}
}
}
pub fn selection_started(&self) -> bool {
self.selection_phase == SelectionPhase::Selecting
}
fn process_terminal_event(
&mut self,
event: &InternalEvent,
term: &mut AlacrittyTerm,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
&InternalEvent::Resize(new_bounds) => {
let new_bounds = normalize_terminal_bounds(new_bounds);
trace!("Resizing: new_bounds={new_bounds:?}");
let columns_changed =
self.last_content.terminal_bounds.num_columns() != new_bounds.num_columns();
self.last_content.terminal_bounds = new_bounds;
if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
pty_tx.resize(new_bounds);
}
resize(term, new_bounds);
if columns_changed {
self.reset_cwd_history();
}
// If there are matches we need to emit a wake up event to
// invalidate the matches and recalculate their locations
// in the new terminal layout
if !self.matches.is_empty() {
cx.emit(Event::Wakeup);
}
}
InternalEvent::Clear => {
trace!("Clearing");
clear_saved_screen(term);
self.reset_cwd_history();
cx.emit(Event::Wakeup);
}
InternalEvent::Scroll(scroll) => {
trace!("Scrolling: scroll={scroll:?}");
scroll_display(term, *scroll);
self.refresh_hovered_word(window, cx);
if self.vi_mode_enabled {
update_vi_cursor_for_scroll(term, *scroll);
if let Some(selection_head) = update_selection_to_vi_cursor(term) {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if let Some(selection_text) = selection_text(term) {
cx.write_to_primary(ClipboardItem::new_string(selection_text));
}
self.selection_head = Some(selection_head);
cx.emit(Event::SelectionsChanged)
}
}
}
InternalEvent::SetSelection(selection) => {
trace!("Setting selection: selection={selection:?}");
set_term_selection(term, selection.as_ref());
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if let Some(selection_text) = selection_text(term) {
cx.write_to_primary(ClipboardItem::new_string(selection_text));
}
if let Some(selection) = selection {
self.selection_head = Some(selection.head);
}
cx.emit(Event::SelectionsChanged)
}
InternalEvent::UpdateSelection(position) => {
trace!("Updating selection: position={position:?}");
let (point, side) = grid_point_and_side(
*position,
self.last_content.terminal_bounds,
display_offset(term),
);
if update_term_selection(term, point, side) {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if let Some(selection_text) = selection_text(term) {
cx.write_to_primary(ClipboardItem::new_string(selection_text));
}
self.selection_head = Some(point);
cx.emit(Event::SelectionsChanged)
}
}
InternalEvent::Copy(keep_selection) => {
trace!("Copying selection: keep_selection={keep_selection:?}");
if let Some(txt) = selection_text(term) {
cx.write_to_clipboard(ClipboardItem::new_string(txt));
if !keep_selection.unwrap_or_else(|| {
let settings = TerminalSettings::get_global(cx);
settings.keep_selection_on_copy
}) {
self.events.push_back(InternalEvent::SetSelection(None));
}
}
}
InternalEvent::ScrollToPoint(point) => {
trace!("Scrolling to point: point={point:?}");
scroll_to_point(term, *point);
self.refresh_hovered_word(window, cx);
}
InternalEvent::MoveViCursorToPoint(point) => {
trace!("Move vi cursor to point: point={point:?}");
vi_goto_point(term, *point);
self.refresh_hovered_word(window, cx);
}
InternalEvent::ToggleViMode => {
trace!("Toggling vi mode");
self.vi_mode_enabled = !self.vi_mode_enabled;
toggle_term_vi_mode(term);
}
InternalEvent::ViMotion(motion) => {
trace!("Performing vi motion: motion={motion:?}");
vi_motion(term, *motion);
}
InternalEvent::FindHyperlink(position, open) => {
trace!("Finding hyperlink at position: position={position:?}, open={open:?}");
let point = grid_point(
*position,
self.last_content.terminal_bounds,
display_offset(term),
);
match find_from_terminal_point(
term,
point,
&mut self.hyperlink_regex_searches,
self.path_style,
) {
Some(hyperlink) => {
let history_size = term.history_size();
self.process_hyperlink(hyperlink, *open, history_size, cx);
}
None => {
self.clear_hyperlink(cx);
}
}
}
InternalEvent::ProcessHyperlink(hyperlink, open) => {
// history_size must be read here since process_hyperlink cannot lock term
// (sync() already holds the lock when dispatching events)
let history_size = term.history_size();
self.process_hyperlink(hyperlink.clone(), *open, history_size, cx);
}
}
}
fn process_hyperlink(
&mut self,
hyperlink: HyperlinkMatch,
open: bool,
history_size: usize,
cx: &mut Context<Self>,
) {
let HyperlinkMatch {
text: maybe_url_or_path,
is_url,
range,
} = hyperlink;
let prev_hovered_word = self.last_content.last_hovered_word.take();
let match_line = range.start().line;
let working_directory = self.cwd_at_line(match_line, history_size);
let target = if is_url {
if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
let decoded_path = urlencoding::decode(path)
.map(|decoded| decoded.into_owned())
.unwrap_or(path.to_owned());
MaybeNavigationTarget::PathLike(PathLikeTarget {
maybe_path: decoded_path,
working_directory,
})
} else {
MaybeNavigationTarget::Url(maybe_url_or_path.clone())
}
} else {
MaybeNavigationTarget::PathLike(PathLikeTarget {
maybe_path: maybe_url_or_path.clone(),
working_directory,
})
};
if open {
cx.emit(Event::Open(target));
} else {
self.update_selected_word(prev_hovered_word, range, maybe_url_or_path, target, cx);
}
}
fn clear_hyperlink(&mut self, cx: &mut Context<Self>) {
if self.last_content.last_hovered_word.take().is_some() {
cx.emit(Event::NewNavigationTarget(None));
}
}
fn find_hyperlink_at_point(&mut self, point: Point) -> Option<HyperlinkMatch> {
let term_lock = self.term.lock();
find_from_terminal_point(
&term_lock,
point,
&mut self.hyperlink_regex_searches,
self.path_style,
)
}
fn update_selected_word(
&mut self,
prev_word: Option<HoveredWord>,
word_match: Range,
word: String,
navigation_target: MaybeNavigationTarget,
cx: &mut Context<Self>,
) {
if let Some(prev_word) = prev_word
&& prev_word.word == word
&& prev_word.word_match == word_match
{
self.last_content.last_hovered_word = Some(prev_word);
return;
}
self.last_content.last_hovered_word = Some(HoveredWord {
word,
word_match,
id: self.next_link_id(),
});
cx.emit(Event::NewNavigationTarget(Some(navigation_target)));
cx.notify()
}
fn next_link_id(&mut self) -> usize {
let res = self.next_link_id;
self.next_link_id = self.next_link_id.wrapping_add(1);
res
}
pub fn last_content(&self) -> &Content {
&self.last_content
}
pub fn set_cursor_shape(&mut self, cursor_shape: SettingsCursorShape) {
set_default_cursor_style(&mut self.term_config, cursor_shape);
apply_config(&self.term, &self.term_config);
}
pub fn write_output(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
// Inject bytes directly into the terminal emulator and refresh the UI.
// This bypasses the PTY/event loop for display-only terminals.
let mut previous_byte_was_cr = false;
let converted = convert_lf_to_crlf(bytes, &mut previous_byte_was_cr);
let mut term = self.term.lock();
self.output_processor.advance(&mut *term, &converted);
drop(term);
self.detect_init_command_startup_marker();
cx.emit(Event::Wakeup);
}
pub fn total_lines(&self) -> usize {
total_lines(&self.term.lock_unfair())
}
pub fn viewport_lines(&self) -> usize {
screen_lines(&self.term.lock_unfair())
}
pub fn used_lines(&self) -> usize {
used_lines(&self.term.lock_unfair())
}
//To test:
//- Activate match on terminal (scrolling and selection)
//- Editor search snapping behavior
pub fn activate_match(&mut self, index: usize) {
if let Some(search_match) = self.matches.get(index).cloned() {
self.set_selection(Some(Selection::simple_range(search_match)));
if self.vi_mode_enabled {
self.events
.push_back(InternalEvent::MoveViCursorToPoint(search_match.end()));
} else {
self.events
.push_back(InternalEvent::ScrollToPoint(search_match.start()));
}
}
}
pub fn select_matches(&mut self, matches: &[Range]) {
let matches_to_select = self
.matches
.iter()
.filter(|self_match| matches.contains(self_match))
.cloned()
.collect::<Vec<_>>();
for match_to_select in matches_to_select {
self.set_selection(Some(Selection::simple_range(match_to_select)));
}
}
pub fn select_all(&mut self) {
let term = self.term.lock();
let range = full_content_range(&term);
drop(term);
self.set_selection(Some(Selection::simple_range(range)));
}
fn set_selection(&mut self, selection: Option<Selection>) {
self.events
.push_back(InternalEvent::SetSelection(selection));
}
pub fn copy(&mut self, keep_selection: Option<bool>) {
self.events.push_back(InternalEvent::Copy(keep_selection));
}
pub fn clear(&mut self) {
self.events.push_back(InternalEvent::Clear)
}
pub fn shrink_to_used(&mut self) {
shrink_to_used(&mut self.term.lock());
}
pub fn scroll_line_up(&mut self) {
self.events
.push_back(InternalEvent::Scroll(Scroll::Delta(1)));
}
pub fn scroll_up_by(&mut self, lines: usize) {
self.events
.push_back(InternalEvent::Scroll(Scroll::Delta(lines as i32)));
}
pub fn scroll_line_down(&mut self) {
self.events
.push_back(InternalEvent::Scroll(Scroll::Delta(-1)));
}
pub fn scroll_down_by(&mut self, lines: usize) {
self.events
.push_back(InternalEvent::Scroll(Scroll::Delta(-(lines as i32))));
}
pub fn scroll_page_up(&mut self) {
self.events.push_back(InternalEvent::Scroll(Scroll::PageUp));
}
pub fn scroll_page_down(&mut self) {
self.events
.push_back(InternalEvent::Scroll(Scroll::PageDown));
}
pub fn scroll_to_top(&mut self) {
self.events.push_back(InternalEvent::Scroll(Scroll::Top));
}
pub fn scroll_to_bottom(&mut self) {
self.events.push_back(InternalEvent::Scroll(Scroll::Bottom));
}
pub fn scrolled_to_top(&self) -> bool {
self.last_content.scrolled_to_top
}
pub fn scrolled_to_bottom(&self) -> bool {
self.last_content.scrolled_to_bottom
}
///Resize the terminal and the PTY.
pub fn set_size(&mut self, new_bounds: TerminalBounds) {
let new_bounds = normalize_terminal_bounds(new_bounds);
let old_bounds = self.last_content.terminal_bounds;
self.last_content.terminal_bounds = new_bounds;
// Avoid spamming PTY resizes on pixel-level size changes (e.g. while dragging edges),
// since those can generate excessive SIGWINCH/reflows and cause visible flicker.
let requires_resize = old_bounds.num_lines() != new_bounds.num_lines()
|| old_bounds.num_columns() != new_bounds.num_columns()
|| old_bounds.cell_width != new_bounds.cell_width
|| old_bounds.line_height != new_bounds.line_height;
if !requires_resize {
return;
}
match self.events.back_mut() {
Some(InternalEvent::Resize(pending_bounds)) => *pending_bounds = new_bounds,
_ => self.events.push_back(InternalEvent::Resize(new_bounds)),
}
}
/// Write the Input payload to the PTY, if applicable.
/// (This is a no-op for display-only terminals.)
fn write_to_pty(&self, input: impl Into<Cow<'static, [u8]>>) {
let input = input.into();
#[cfg(any(test, feature = "test-support"))]
self.pty_write_log.borrow_mut().push(input.to_vec());
if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
if log::log_enabled!(log::Level::Debug) {
if let Ok(str) = str::from_utf8(&input) {
log::debug!("Writing to PTY: {:?}", str);
} else {
log::debug!("Writing to PTY: {:?}", input);
}
}
pty_tx.notify(input);
}
}
pub fn input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
self.keyboard_input_sent = true;
self.complete_init_command_startup_handshake();
self.write_input(input);
}
/// Sends a shell-level marker command and returns a task that completes when
/// the marker appears in terminal output. Already complete for non-PTY
/// terminals or those whose child has exited.
///
/// Call at most once per terminal: a second handshake drops the previous
/// `Sender`, which would write the init command twice.
pub fn start_init_command_startup_handshake(&mut self) -> Task<()> {
if !self.is_pty() || self.child_exited.is_some() {
return Task::ready(());
}
debug_assert!(
self.init_command_startup_tx.is_none(),
"start_init_command_startup_handshake called while a handshake is already in flight"
);
let (startup_tx, startup_rx) = async_channel::bounded(1);
let startup_task = self.background_executor.spawn(async move {
match startup_rx.recv().await {
Ok(()) | Err(_) => {}
}
});
let marker_id = NEXT_INIT_COMMAND_STARTUP_MARKER_ID.fetch_add(1, Ordering::Relaxed);
self.init_command_startup_marker = Some(init_command_startup_marker(marker_id));
self.init_command_startup_tx = Some(startup_tx);
let shell_kind = self.template.shell.shell_kind(self.path_style.is_windows());
let mut input = init_command_startup_marker_command(shell_kind, marker_id).into_bytes();
input.push(b'\x0d');
self.write_to_pty(input);
startup_task
}
fn detect_init_command_startup_marker(&mut self) {
let Some(marker) = self.init_command_startup_marker.as_deref() else {
return;
};
let has_marker = {
let term = self.term.lock_unfair();
last_non_empty_lines(&term, INIT_COMMAND_STARTUP_MARKER_SEARCH_LINES)
.iter()
.any(|line| line.contains(marker))
};
if has_marker {
self.complete_init_command_startup_handshake();
}
}
fn complete_init_command_startup_handshake(&mut self) {
self.init_command_startup_marker = None;
if let Some(startup_tx) = self.init_command_startup_tx.take() {
match startup_tx.try_send(()) {
Ok(()) | Err(async_channel::TrySendError::Full(())) => {}
Err(async_channel::TrySendError::Closed(())) => {}
}
}
}
/// Write a programmatically-generated command to the PTY as if it had been
/// typed, without marking the terminal as having received user keyboard
/// input.
pub fn write_init_command(&mut self, input: impl Into<Cow<'static, [u8]>>) {
self.write_input(input);
}
pub fn is_pty(&self) -> bool {
matches!(self.terminal_type, TerminalType::Pty { .. })
}
pub fn write_init_command_after_startup(
&mut self,
input: impl Into<Cow<'static, [u8]>>,
cx: &mut Context<Self>,
) -> bool {
// Ends the handshake even if the marker was never seen (timeout
// fallback), so detection stops scanning on every wakeup.
self.complete_init_command_startup_handshake();
if self.keyboard_input_sent || self.child_exited.is_some() {
return false;
}
self.clear_for_init_command(cx);
self.write_init_command(input);
true
}
fn clear_for_init_command(&mut self, cx: &mut Context<Self>) {
let mut term = self.term.lock_unfair();
clear_saved_screen(&mut term);
self.last_content = make_content(&term, &self.last_content);
drop(term);
self.reset_cwd_history();
cx.emit(Event::Wakeup);
}
fn write_input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
let input = input.into();
if !self.is_remote_terminal && input.contains(&b'\r') {
let term = self.term.lock_unfair();
self.pending_cwd_boundary = Some(Self::scrollback_position(
term.grid().cursor.point.line.0,
term.history_size(),
));
}
self.events.push_back(InternalEvent::Scroll(Scroll::Bottom));
self.events.push_back(InternalEvent::SetSelection(None));
#[cfg(any(test, feature = "test-support"))]
self.input_log.push(input.to_vec());
self.write_to_pty(input);
}
#[cfg(any(test, feature = "test-support"))]
pub fn take_input_log(&mut self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.input_log)
}
#[cfg(any(test, feature = "test-support"))]
pub fn take_pty_write_log(&mut self) -> Vec<Vec<u8>> {
std::mem::take(self.pty_write_log.get_mut())
}
#[cfg(any(test, feature = "test-support"))]
pub fn keyboard_input_sent(&self) -> bool {
self.keyboard_input_sent
}
pub fn toggle_vi_mode(&mut self) {
self.events.push_back(InternalEvent::ToggleViMode);
}
pub fn vi_motion(&mut self, keystroke: &Keystroke) {
if !self.vi_mode_enabled {
return;
}
let key: Cow<'_, str> = if keystroke.modifiers.shift {
Cow::Owned(keystroke.key.to_uppercase())
} else {
Cow::Borrowed(keystroke.key.as_str())
};
let motion: Option<ViMotion> = match key.as_ref() {
"h" | "left" => Some(ViMotion::Left),
"j" | "down" => Some(ViMotion::Down),
"k" | "up" => Some(ViMotion::Up),
"l" | "right" => Some(ViMotion::Right),
"w" => Some(ViMotion::WordRight),
"b" if !keystroke.modifiers.control => Some(ViMotion::WordLeft),
"e" => Some(ViMotion::WordRightEnd),
"%" => Some(ViMotion::Bracket),
"$" => Some(ViMotion::Last),
"0" => Some(ViMotion::First),
"^" => Some(ViMotion::FirstOccupied),
"H" => Some(ViMotion::High),
"M" => Some(ViMotion::Middle),
"L" => Some(ViMotion::Low),
"{" => Some(ViMotion::ParagraphUp),
"}" => Some(ViMotion::ParagraphDown),
_ => None,
};
if let Some(motion) = motion {
let cursor = self.last_content.cursor.point;
let cursor_pos = GpuiPoint {
x: cursor.column as f32 * self.last_content.terminal_bounds.cell_width,
y: cursor.line as f32 * self.last_content.terminal_bounds.line_height,
};
self.events
.push_back(InternalEvent::UpdateSelection(cursor_pos));
self.events.push_back(InternalEvent::ViMotion(motion));
return;
}
let scroll_motion = match key.as_ref() {
"g" => Some(Scroll::Top),
"G" => Some(Scroll::Bottom),
"b" if keystroke.modifiers.control => Some(Scroll::PageUp),
"f" if keystroke.modifiers.control => Some(Scroll::PageDown),
"d" if keystroke.modifiers.control => {
let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
Some(Scroll::Delta(-amount))
}
"u" if keystroke.modifiers.control => {
let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
Some(Scroll::Delta(amount))
}
_ => None,
};
if let Some(scroll_motion) = scroll_motion {
self.events.push_back(InternalEvent::Scroll(scroll_motion));
return;
}
match key.as_ref() {
"v" => {
let point = self.last_content.cursor.point;
let selection_type = SelectionType::Simple;
let side = SelectionSide::Right;
let selection = Selection::new(selection_type, point, side);
self.events
.push_back(InternalEvent::SetSelection(Some(selection)));
}
"escape" => {
self.events.push_back(InternalEvent::SetSelection(None));
}
"y" => {
self.copy(Some(false));
}
"i" => {
self.scroll_to_bottom();
self.toggle_vi_mode();
}
_ => {}
}
}
pub fn try_keystroke(&mut self, keystroke: &Keystroke, option_as_meta: bool) -> bool {
if self.vi_mode_enabled {
self.vi_motion(keystroke);
return true;
}
// Keep default terminal behavior
let esc = to_esc_str(keystroke, self.last_content.mode, option_as_meta);
if let Some(esc) = esc {
match esc {
Cow::Borrowed(string) => self.input(string.as_bytes()),
Cow::Owned(string) => self.input(string.into_bytes()),
};
true
} else {
false
}
}
pub fn try_modifiers_change(
&mut self,
modifiers: &Modifiers,
window: &Window,
cx: &mut Context<Self>,
) {
self.schedule_find_hyperlink(*modifiers, window.mouse_position(), cx);
}
///Paste text into the terminal
pub fn paste(&mut self, text: &str) {
let paste_text = if self.last_content.mode.contains(Modes::BRACKETED_PASTE) {
format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
} else {
text.replace("\r\n", "\r").replace('\n', "\r")
};
self.input(paste_text.into_bytes());
}
pub fn sync(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let term = self.term.clone();
let mut terminal = term.lock_unfair();
//Note that the ordering of events matters for event processing
while let Some(e) = self.events.pop_front() {
self.process_terminal_event(&e, &mut terminal, window, cx)
}
self.last_content = make_content(&terminal, &self.last_content);
if self.last_content.grid_lines_change == GridLinesChange::Changed {
debug_assert!(self.last_content.last_hovered_word.is_none());
self.refresh_hovered_word(window, cx);
// Because refresh_hovered_word() may result
// in new events, but will not trigger a repaint
if !self.events.is_empty() {
cx.emit(Event::Wakeup);
}
}
}
pub fn with_renderable_cells<R>(&self, f: impl for<'a> FnOnce(RenderableCells<'a>) -> R) -> R {
let term = self.term.lock_unfair();
let content = term.renderable_content();
f(RenderableCells::new(content.display_iter))
}
pub fn get_content(&self) -> String {
let term = self.term.lock_unfair();
content_text(&term)
}
pub fn last_n_non_empty_lines(&self, n: usize) -> Vec<String> {
let terminal = self.term.lock_unfair();
last_non_empty_lines(&terminal, n)
}
pub fn focus_in(&self) {
if self.last_content.mode.contains(Modes::FOCUS_IN_OUT) {
self.write_to_pty("\x1b[I".as_bytes());
}
}
pub fn focus_out(&mut self) {
if self.last_content.mode.contains(Modes::FOCUS_IN_OUT) {
self.write_to_pty("\x1b[O".as_bytes());
}
}
fn mouse_changed(&mut self, point: Point, side: SelectionSide) -> bool {
match self.last_mouse {
Some((old_point, old_side)) => {
if old_point == point && old_side == side {
false
} else {
self.last_mouse = Some((point, side));
true
}
}
None => {
self.last_mouse = Some((point, side));
true
}
}
}
pub fn mouse_mode(&self, shift: bool) -> bool {
self.last_content.mode.intersects(Modes::MOUSE_MODE) && !shift
}
pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if self.mouse_mode(e.modifiers.shift) {
// 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
// reports, which would be a press-less (malformed) sequence.
// `mouse_up` resolves it: release on the same link opens it,
// otherwise the gesture is dropped.
if self.mouse_down_hyperlink.is_none() {
let (point, side) = grid_point_and_side(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if self.mouse_changed(point, side) {
let bytes = mouse_moved_report(
point,
e.pressed_button,
e.modifiers,
self.last_content.mode,
);
if let Some(bytes) = bytes {
self.write_to_pty(bytes);
}
}
}
} else {
self.schedule_find_hyperlink(e.modifiers, e.position, cx);
}
cx.notify();
}
fn schedule_find_hyperlink(
&mut self,
modifiers: Modifiers,
position: GpuiPoint<Pixels>,
cx: &mut Context<Self>,
) {
if self.selection_phase == SelectionPhase::Selecting
|| !modifiers.secondary()
|| !self.last_content.terminal_bounds.bounds.contains(&position)
{
self.clear_hyperlink(cx);
return;
}
// Throttle hyperlink searches to avoid excessive processing
let now = Instant::now();
let throttle = !self
.last_hyperlink_search_position
.map_or(true, |last_pos| {
// Only search if mouse moved significantly or enough time passed
let distance_moved = ((position.x - last_pos.x).abs()
+ (position.y - last_pos.y).abs())
> FIND_HYPERLINK_THROTTLE_PX;
let time_elapsed =
now.duration_since(self.last_mouse_move_time) > FIND_HYPERLINK_THROTTLE;
distance_moved || time_elapsed
});
#[cfg(test)]
let throttle = if self.suppress_hyperlink_throttle_once {
self.suppress_hyperlink_throttle_once = false;
false
} else {
throttle
};
if throttle {
return;
}
self.last_mouse_move_time = now;
self.last_hyperlink_search_position = Some(position);
self.events.push_back(InternalEvent::FindHyperlink(
position - self.last_content.terminal_bounds.bounds.origin,
false,
));
cx.notify();
}
pub fn select_word_at_event_position(&mut self, e: &MouseDownEvent) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
let (point, side) = grid_point_and_side(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
let selection = Selection::new(SelectionType::Semantic, point, side);
self.events
.push_back(InternalEvent::SetSelection(Some(selection)));
}
pub fn mouse_drag(
&mut self,
e: &MouseMoveEvent,
region: Bounds<Pixels>,
cx: &mut Context<Self>,
) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if !self.mouse_mode(e.modifiers.shift) {
if let Some(hyperlink) = &self.mouse_down_hyperlink {
let point = grid_point(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if !hyperlink.range.contains(point) {
self.mouse_down_hyperlink = None;
} else {
return;
}
}
// Ignore tiny pointer movements so that a click that jitters by a
// pixel or two (e.g. the window-focusing click) does not begin a
// selection. Mirrors the drag threshold used by gpui's `div`.
if self.selection_phase != SelectionPhase::Selecting
&& let Some(mouse_down_position) = self.mouse_down_position
&& (e.position - mouse_down_position).magnitude() <= SELECTION_DRAG_THRESHOLD
{
return;
}
self.selection_phase = SelectionPhase::Selecting;
// Alacritty has the same ordering, of first updating the selection
// then scrolling 15ms later
self.events
.push_back(InternalEvent::UpdateSelection(position));
// Doesn't make sense to scroll the alt screen
if !self.last_content.mode.contains(Modes::ALT_SCREEN) {
let scroll_lines = match self.drag_line_delta(e, region) {
Some(value) => value,
None => return,
};
self.events
.push_back(InternalEvent::Scroll(Scroll::Delta(scroll_lines)));
}
cx.notify();
}
}
fn drag_line_delta(&self, e: &MouseMoveEvent, region: Bounds<Pixels>) -> Option<i32> {
let top = region.origin.y;
let bottom = region.bottom_left().y;
let scroll_lines = if e.position.y < top {
let scroll_delta = (top - e.position.y).pow(1.1);
(scroll_delta / self.last_content.terminal_bounds.line_height).ceil() as i32
} else if e.position.y > bottom {
let scroll_delta = -((e.position.y - bottom).pow(1.1));
(scroll_delta / self.last_content.terminal_bounds.line_height).floor() as i32
} else {
return None;
};
Some(scroll_lines.clamp(-3, 3))
}
pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context<Self>) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
let point = grid_point(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
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_down_hyperlink = self.find_hyperlink_at_point(point);
if self.mouse_down_hyperlink.is_some() {
return;
}
}
if self.mouse_mode(e.modifiers.shift) {
let bytes =
mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode);
if let Some(bytes) = bytes {
self.write_to_pty(bytes);
}
} else {
match e.button {
MouseButton::Left => {
self.mouse_down_position = Some(e.position);
let (point, side) = grid_point_and_side(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
let selection_type = match e.click_count {
0 => return, //This is a release
1 => Some(SelectionType::Simple),
2 => Some(SelectionType::Semantic),
3 => Some(SelectionType::Lines),
_ => None,
};
if selection_type == Some(SelectionType::Simple) && e.modifiers.shift {
if self.last_content.selection.is_some() {
// Shift+click extends the existing selection to this point.
self.events
.push_back(InternalEvent::UpdateSelection(position));
} else {
// With no selection yet, Shift is the escape hatch for
// selecting text while an app has mouse tracking enabled,
// so anchor a selection here for the drag to extend.
self.events.push_back(InternalEvent::SetSelection(Some(
Selection::new(SelectionType::Simple, point, side),
)));
}
return;
}
let selection = selection_type
.map(|selection_type| Selection::new(selection_type, point, side));
if let Some(selection) = selection {
self.events
.push_back(InternalEvent::SetSelection(Some(selection)));
}
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
MouseButton::Middle => {
if let Some(item) = cx.read_from_primary() {
let text = item.text().unwrap_or_default();
self.paste(&text);
}
}
_ => {}
}
}
}
pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context<Self>) {
let setting = TerminalSettings::get_global(cx);
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
let point = grid_point(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if self
.find_hyperlink_at_point(point)
.is_some_and(|mouse_up_hyperlink| mouse_up_hyperlink == mouse_down_hyperlink)
{
self.events
.push_back(InternalEvent::ProcessHyperlink(mouse_down_hyperlink, true));
self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None;
self.mouse_down_position = None;
return;
}
if self.mouse_mode(e.modifiers.shift) {
self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None;
self.mouse_down_position = None;
return;
}
}
if self.mouse_mode(e.modifiers.shift) {
let point = grid_point(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
let bytes =
mouse_button_report(point, e.button, e.modifiers, false, self.last_content.mode);
if let Some(bytes) = bytes {
self.write_to_pty(bytes);
}
} else {
if e.button == MouseButton::Left && setting.copy_on_select {
self.copy(Some(true));
}
//Hyperlinks
if self.selection_phase == SelectionPhase::Ended {
let mouse_cell_index =
content_index_for_mouse(position, &self.last_content.terminal_bounds);
if let Some(link) = self
.last_content
.cells
.get(mouse_cell_index)
.and_then(|cell| cell.hyperlink())
{
cx.open_url(link.uri());
} else if e.modifiers.secondary() {
self.events
.push_back(InternalEvent::FindHyperlink(position, true));
}
}
}
self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None;
self.mouse_down_position = None;
}
///Scroll the terminal
pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) {
let mouse_mode = self.mouse_mode(e.shift);
let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier };
if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier)
&& scroll_lines != 0
{
if mouse_mode {
let point = grid_point(
e.position - self.last_content.terminal_bounds.bounds.origin,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if let Some(scrolls) = scroll_report(point, scroll_lines, e, self.last_content.mode)
{
for scroll in scrolls {
self.write_to_pty(scroll);
}
};
} else if self
.last_content
.mode
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
&& !e.shift
{
self.write_to_pty(alt_scroll(scroll_lines));
} else {
self.events
.push_back(InternalEvent::Scroll(Scroll::Delta(scroll_lines)));
}
}
}
fn refresh_hovered_word(&mut self, window: &Window, cx: &mut Context<Self>) {
self.schedule_find_hyperlink(window.modifiers(), window.mouse_position(), cx);
}
fn determine_scroll_lines(
&mut self,
e: &ScrollWheelEvent,
scroll_multiplier: f32,
) -> Option<i32> {
let line_height = self.last_content.terminal_bounds.line_height;
match e.touch_phase {
/* Reset scroll state on started */
TouchPhase::Started => {
self.scroll_px = px(0.);
None
}
/* Calculate the appropriate scroll lines */
TouchPhase::Moved => {
let old_offset = (self.scroll_px / line_height) as i32;
self.scroll_px += e.delta.pixel_delta(line_height).y * scroll_multiplier;
let new_offset = (self.scroll_px / line_height) as i32;
// Whenever we hit the edges, reset our stored scroll to 0
// so we can respond to changes in direction quickly
self.scroll_px %= self.last_content.terminal_bounds.height();
Some(new_offset - old_offset)
}
// Cancellation does not commit a scroll, same as a plain end.
TouchPhase::Ended | TouchPhase::Cancelled => None,
}
}
pub fn find_matches(&self, searcher: Search, cx: &Context<Self>) -> Task<Vec<Range>> {
let term = self.term.clone();
cx.background_spawn(async move {
let term = term.lock();
search_matches(&term, searcher)
})
}
pub fn working_directory(&self) -> Option<PathBuf> {
if self.is_remote_terminal {
// We can't yet reliably detect the working directory of a shell on the
// SSH host. Until we can do that, it doesn't make sense to display
// the working directory on the client and persist that.
None
} else {
self.client_side_working_directory()
}
}
/// Normalizes the command name of the foreground process, if one is known.
pub fn foreground_process_command_name(&self) -> Option<String> {
match &self.terminal_type {
TerminalType::Pty { info, .. } => info
.current
.read()
.as_ref()
.and_then(|process| foreground_process_command_from_argv(&process.argv)),
TerminalType::DisplayOnly => None,
}
}
/// Returns the working directory of the process that's connected to the PTY.
/// That means it returns the working directory of the local shell or program
/// that's running inside the terminal.
///
/// This does *not* return the working directory of the shell that runs on the
/// remote host, in case Zed is connected to a remote host.
fn client_side_working_directory(&self) -> Option<PathBuf> {
match &self.terminal_type {
TerminalType::Pty { info, .. } => info
.current
.read()
.as_ref()
.map(|process| process.cwd.clone()),
TerminalType::DisplayOnly => None,
}
}
pub(crate) fn record_cwd_change(&mut self, new_working_directory: PathBuf) {
if self.is_remote_terminal {
return;
}
let scrollback_position = self.pending_cwd_boundary.take().unwrap_or_else(|| {
let term = self.term.lock_unfair();
Self::scrollback_position(term.grid().cursor.point.line.0, term.history_size())
});
self.cwd_history.push(CwdHistoryEntry {
scrollback_position,
working_directory: new_working_directory,
});
}
fn reset_cwd_history(&mut self) {
self.pending_cwd_boundary = None;
self.cwd_history = self
.working_directory()
.map(|working_directory| {
vec![CwdHistoryEntry {
scrollback_position: i32::MIN,
working_directory,
}]
})
.unwrap_or_default();
}
fn cwd_at_line(&self, line: i32, history_size: usize) -> Option<PathBuf> {
// Once the scrollback cap is reached, evictions move retained lines without changing
// `history_size`, so stored row offsets no longer identify their original lines.
if self.is_remote_terminal
|| self.cwd_history.is_empty()
|| history_size >= self.term_config.scrolling_history
{
return self.working_directory();
}
let scrollback_position = Self::scrollback_position(line, history_size);
self.cwd_history
.iter()
.rev()
.find(|entry| entry.scrollback_position <= scrollback_position)
.map(|entry| entry.working_directory.clone())
.or_else(|| self.working_directory())
}
fn scrollback_position(line: i32, history_size: usize) -> i32 {
let history_size = i32::try_from(history_size).unwrap_or(i32::MAX);
history_size.saturating_add(line)
}
pub fn title(&self, truncate: bool) -> String {
const MAX_CHARS: usize = 25;
match &self.task {
Some(task_state) => {
if truncate {
truncate_and_trailoff(&task_state.spawned_task.label, MAX_CHARS)
} else {
task_state.spawned_task.full_label.clone()
}
}
None => self
.title_override
.as_ref()
.map(|title_override| title_override.to_string())
.unwrap_or_else(|| match &self.terminal_type {
TerminalType::Pty { info, .. } => info
.current
.read()
.as_ref()
.map(|fpi| {
let process_file = fpi
.cwd
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let argv = fpi.argv.as_slice();
let process_name = format!(
"{}{}",
fpi.name,
if !argv.is_empty() {
format!(" {}", (argv[1..]).join(" "))
} else {
"".to_string()
}
);
let (process_file, process_name) = if truncate {
(
truncate_and_trailoff(&process_file, MAX_CHARS),
truncate_and_trailoff(&process_name, MAX_CHARS),
)
} else {
(process_file, process_name)
};
format!("{process_file} — {process_name}")
})
.unwrap_or_else(|| "Terminal".to_string()),
TerminalType::DisplayOnly => "Terminal".to_string(),
}),
}
}
pub fn kill_active_task(&mut self) {
if let Some(task) = self.task()
&& task.status == TaskStatus::Running
{
match &self.terminal_type {
TerminalType::Pty { info, .. } => {
// First kill the foreground process group (the command running in the shell)
info.kill_current_process();
// Then kill the shell itself so that the terminal exits properly
// and wait_for_completed_task can complete
info.kill_child_process();
}
TerminalType::DisplayOnly => {
// Non-PTY task terminals own their subprocess directly.
if let Some(subprocess) = &self.subprocess {
subprocess.kill();
}
}
}
}
}
pub fn pid(&self) -> Option<sysinfo::Pid> {
match &self.terminal_type {
TerminalType::Pty { info, .. } => info.pid(),
TerminalType::DisplayOnly => None,
}
}
pub fn pid_getter(&self) -> Option<&ProcessIdGetter> {
match &self.terminal_type {
TerminalType::Pty { info, .. } => Some(info.pid_getter()),
TerminalType::DisplayOnly => None,
}
}
pub fn task(&self) -> Option<&TaskState> {
self.task.as_ref()
}
pub fn wait_for_completed_task(&self, cx: &App) -> Task<Option<ExitStatus>> {
if let Some(task) = self.task() {
if task.status == TaskStatus::Running {
let completion_receiver = task.completion_rx.clone();
return cx.spawn(async move |_| completion_receiver.recv().await.ok().flatten());
} else if let Ok(status) = task.completion_rx.try_recv() {
return Task::ready(status);
}
}
Task::ready(None)
}
fn register_task_finished(
&mut self,
exit_status: Option<ExitStatus>,
cx: &mut Context<Terminal>,
) {
if let Some(tx) = &self.completion_tx {
tx.try_send(exit_status).ok();
}
if let Some(e) = exit_status {
self.child_exited = Some(e);
}
self.complete_init_command_startup_handshake();
let task = match &mut self.task {
Some(task) => task,
None => {
// For interactive shells (no task), we need to differentiate:
// 1. User-initiated exits (typed "exit", Ctrl+D, etc.) - always close,
// even if the shell exits with a non-zero code (e.g. after `false`).
// 2. Shell spawn failures (bad $SHELL) - don't close, so the user sees
// the error. Spawn failures never receive keyboard input.
let should_close = if self.keyboard_input_sent {
true
} else {
self.child_exited.is_none_or(|e| e.code() == Some(0))
};
if should_close {
cx.emit(Event::CloseTerminal);
}
return;
}
};
if task.status != TaskStatus::Running {
return;
}
match exit_status.and_then(|e| e.code()) {
Some(error_code) => {
task.status.register_task_exit(error_code);
}
None => {
task.status.register_terminal_exit();
}
};
let (finished_successfully, task_line, command_line) = task_summary(task, exit_status);
let mut lines_to_show = Vec::new();
if task.spawned_task.show_summary {
lines_to_show.push(task_line.as_str());
}
if task.spawned_task.show_command {
lines_to_show.push(command_line.as_str());
}
let hide = task.spawned_task.hide;
if !lines_to_show.is_empty() {
// SAFETY: the invocation happens on non `TaskStatus::Running` tasks, once,
// after either `AlacTermEvent::Exit` or `AlacTermEvent::ChildExit` events that are spawned
// when Zed task finishes and no more output is made.
// After the task summary is output once, no more text is appended to the terminal.
unsafe { append_text_to_term(&mut self.term.lock(), &lines_to_show) };
}
match hide {
HideStrategy::Never => {}
HideStrategy::Always => {
cx.emit(Event::CloseTerminal);
}
HideStrategy::OnSuccess => {
if finished_successfully {
cx.emit(Event::CloseTerminal);
}
}
}
}
pub fn vi_mode_enabled(&self) -> bool {
self.vi_mode_enabled
}
pub fn clone_builder(&self, cx: &App, cwd: Option<PathBuf>) -> Task<Result<TerminalBuilder>> {
let working_directory = self.working_directory().or_else(|| cwd);
TerminalBuilder::new(
working_directory,
None,
self.template.shell.clone(),
self.template.env.clone(),
self.template.cursor_shape,
self.template.alternate_scroll,
self.template.max_scroll_history_lines,
self.template.path_hyperlink_regexes.clone(),
self.template.path_hyperlink_timeout,
self.is_remote_terminal,
self.template.window_id,
None,
cx,
self.activation_script.clone(),
self.path_style,
)
}
}
const TASK_DELIMITER: &str = "⏵ ";
fn task_summary(task: &TaskState, exit_status: Option<ExitStatus>) -> (bool, String, String) {
let escaped_full_label = task
.spawned_task
.full_label
.replace("\r\n", "\r")
.replace('\n', "\r");
let task_label = |suffix: &str| format!("{TASK_DELIMITER}Task `{escaped_full_label}` {suffix}");
let (success, task_line) = match exit_status {
Some(status) => {
let code = status.code();
#[cfg(unix)]
let signal = status.signal();
#[cfg(not(unix))]
let signal: Option<i32> = None;
match (code, signal) {
(Some(0), _) => (true, task_label("finished successfully")),
(Some(code), _) => (
false,
task_label(&format!("finished with exit code: {code}")),
),
(None, Some(signal)) => (
false,
task_label(&format!("terminated by signal: {signal}")),
),
(None, None) => (false, task_label("finished")),
}
}
None => (false, task_label("finished")),
};
let escaped_command_label = task
.spawned_task
.command_label
.replace("\r\n", "\r")
.replace('\n', "\r");
let command_line = format!("{TASK_DELIMITER}Command: {escaped_command_label}");
(success, task_line, command_line)
}
/// Converts bare LFs into CRLFs so output captured from a pipe (rather than a
/// PTY) wraps correctly in Alacritty. A PTY's line discipline performs this
/// `ONLCR` translation for us; piped output (e.g. `ls` run outside a PTY) only
/// emits `\n`, which moves Alacritty's cursor down without returning it to
/// column zero and makes the rendered output look misaligned. Alacritty has no
/// setting for this, so we insert a `\r` before each `\n` that lacks one.
fn convert_lf_to_crlf(bytes: &[u8], previous_byte_was_cr: &mut bool) -> Vec<u8> {
let mut converted = Vec::with_capacity(bytes.len());
for &byte in bytes {
if byte == b'\n' && !*previous_byte_was_cr {
converted.push(b'\r');
}
converted.push(byte);
*previous_byte_was_cr = byte == b'\r';
}
converted
}
/// Owns a non-PTY task subprocess and the background task pumping its output
/// into the terminal emulator. Used by headless hosts (e.g. the eval CLI) where
/// PTY allocation fails with `ENOTTY`. Dropping this kills the child.
struct SubprocessHandle {
child: Arc<parking_lot::Mutex<Option<util::process::Child>>>,
_reader: Task<()>,
}
impl SubprocessHandle {
fn kill(&self) {
if let Some(child) = self.child.lock().as_mut() {
child.kill().log_err();
}
}
}
/// Spawns `program`/`args` as a plain subprocess with piped stdout/stderr and
/// drives its output into `term`, mirroring what the Alacritty event loop does
/// for a PTY but without one. Used when [`HeadlessTerminal`] is enabled.
fn spawn_task_subprocess(
program: String,
args: Vec<String>,
env: HashMap<String, String>,
working_directory: Option<PathBuf>,
term: Arc<AlacrittyTermLock>,
events_tx: futures::channel::mpsc::UnboundedSender<PtyEvent>,
executor: &BackgroundExecutor,
) -> Result<SubprocessHandle> {
use futures::io::AsyncReadExt as _;
use std::process::Stdio;
let mut command = util::command::new_std_command(&program);
command.args(&args);
command.envs(&env);
if let Some(directory) = &working_directory {
command.current_dir(directory);
}
let mut child =
util::process::Child::spawn(command, Stdio::null(), Stdio::piped(), Stdio::piped())?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let child = Arc::new(parking_lot::Mutex::new(Some(child)));
let reader = executor.spawn({
let child = child.clone();
let executor = executor.clone();
async move {
// stdout and stderr are pumped concurrently, each through its own
// parser; the shared term mutex serializes grid mutation.
type BoxedReader = Box<dyn futures::io::AsyncRead + Unpin + Send>;
let pump = |reader: Option<BoxedReader>| {
let term = term.clone();
let events_tx = events_tx.clone();
async move {
let Some(mut reader) = reader else { return };
let mut processor = Processor::<StdSyncHandler>::new();
let mut buffer = [0u8; 8192];
let mut previous_byte_was_cr = false;
loop {
match reader.read(&mut buffer).await {
Ok(0) => return,
Err(error) => {
log::warn!("failed to read subprocess output: {error}");
return;
}
Ok(count) => {
let converted =
convert_lf_to_crlf(&buffer[..count], &mut previous_byte_was_cr);
{
let mut term = term.lock();
processor.advance(&mut *term, &converted);
}
events_tx
.unbounded_send(PtyEvent::Event(TerminalBackendEvent::Wakeup))
.ok();
}
}
}
}
};
let stdout = stdout.map(|reader| Box::new(reader) as BoxedReader);
let stderr = stderr.map(|reader| Box::new(reader) as BoxedReader);
futures::future::join(pump(stdout), pump(stderr)).await;
// Both pipes are closed, so the child has exited or is about to.
// Poll for its status without holding the lock across an await.
let status = loop {
let status = match child.lock().as_mut() {
Some(child) => match child.try_status() {
Ok(status) => status,
Err(error) => {
log::warn!("failed to get subprocess exit status: {error}");
break None;
}
},
None => Some(ExitStatus::default()),
};
match status {
Some(status) => break Some(status),
None => executor.timer(Duration::from_millis(20)).await,
}
};
child.lock().take();
let event = match status {
Some(status) => TerminalBackendEvent::ChildExit(status),
None => TerminalBackendEvent::Exit,
};
events_tx.unbounded_send(PtyEvent::Event(event)).ok();
}
});
Ok(SubprocessHandle {
child,
_reader: reader,
})
}
impl Drop for Terminal {
fn drop(&mut self) {
if let Some(subprocess) = self.subprocess.take() {
subprocess.kill();
}
if let TerminalType::Pty { pty_tx, info } =
std::mem::replace(&mut self.terminal_type, TerminalType::DisplayOnly)
{
pty_tx.shutdown();
info.terminate_child_process();
let timer = self.background_executor.timer(Duration::from_millis(100));
self.background_executor
.spawn(async move {
timer.await;
info.kill_child_process();
})
.detach();
}
}
}
impl EventEmitter<Event> for Terminal {}
fn normalize_path_command_name(command: &str) -> Option<String> {
const MAX_COMMAND_NAME_LENGTH: usize = 64;
let command = command.trim();
if command.is_empty()
|| command.len() > MAX_COMMAND_NAME_LENGTH
|| command.starts_with('.')
|| command.starts_with('-')
|| command.contains('/')
|| command.contains('\\')
{
return None;
}
let mut command = command.to_ascii_lowercase();
for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
if command.ends_with(suffix) {
command.truncate(command.len() - suffix.len());
break;
}
}
if command.is_empty()
|| !command.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
{
return None;
}
Some(command)
}
fn foreground_process_command_from_argv(argv: &[String]) -> Option<String> {
let command = argv
.first()
.and_then(|command| normalize_path_command_name(command));
if !matches!(
command.as_deref(),
Some("node" | "python" | "python3" | "bun" | "deno")
) {
return command;
}
argv.iter()
.skip(1)
.filter_map(|argument| normalize_script_command_name(argument))
.next()
.or(command)
}
fn normalize_script_command_name(argument: &str) -> Option<String> {
let path = Path::new(argument);
let file_stem = path
.file_stem()
.and_then(|file_stem| file_stem.to_str())
.and_then(normalize_path_command_name)?;
if file_stem != "index" {
return Some(file_stem);
}
path.parent()
.and_then(|parent| parent.parent())
.and_then(|package_path| package_path.file_name())
.and_then(|package_name| package_name.to_str())
.and_then(|package_name| package_name.strip_suffix("-cli").or(Some(package_name)))
.and_then(normalize_path_command_name)
}
fn content_index_for_mouse(pos: GpuiPoint<Pixels>, terminal_bounds: &TerminalBounds) -> usize {
let col = (pos.x / terminal_bounds.cell_width()).round() as usize;
let clamped_col = min(col, terminal_bounds.num_columns().saturating_sub(1));
let row = (pos.y / terminal_bounds.line_height()).round() as usize;
let clamped_row = min(row, terminal_bounds.num_lines().saturating_sub(1));
clamped_row * terminal_bounds.num_columns() + clamped_col
}
/// Converts an 8 bit ANSI color to its GPUI equivalent.
/// Accepts `usize` for compatibility with the `alacritty::Colors` interface,
/// Other than that use case, should only be called with values in the `[0,255]` range
pub fn get_color_at_index(index: usize, theme: &Theme) -> Hsla {
let colors = theme.colors();
match index {
// 0-15 are the same as the named colors above
0 => colors.terminal_ansi_black,
1 => colors.terminal_ansi_red,
2 => colors.terminal_ansi_green,
3 => colors.terminal_ansi_yellow,
4 => colors.terminal_ansi_blue,
5 => colors.terminal_ansi_magenta,
6 => colors.terminal_ansi_cyan,
7 => colors.terminal_ansi_white,
8 => colors.terminal_ansi_bright_black,
9 => colors.terminal_ansi_bright_red,
10 => colors.terminal_ansi_bright_green,
11 => colors.terminal_ansi_bright_yellow,
12 => colors.terminal_ansi_bright_blue,
13 => colors.terminal_ansi_bright_magenta,
14 => colors.terminal_ansi_bright_cyan,
15 => colors.terminal_ansi_bright_white,
// 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm.
// See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl
16..=231 => {
let (r, g, b) = rgb_for_index(index as u8);
rgba_color(
if r == 0 { 0 } else { r * 40 + 55 },
if g == 0 { 0 } else { g * 40 + 55 },
if b == 0 { 0 } else { b * 40 + 55 },
)
}
// 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238).
232..=255 => {
let i = index as u8 - 232; // Align index to 0..24
let value = i * 10 + 8;
rgba_color(value, value, value)
}
// For compatibility with the alacritty::Colors interface
// See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs
256 => colors.terminal_foreground,
257 => colors.terminal_background,
258 => theme.players().local().cursor,
259 => colors.terminal_ansi_dim_black,
260 => colors.terminal_ansi_dim_red,
261 => colors.terminal_ansi_dim_green,
262 => colors.terminal_ansi_dim_yellow,
263 => colors.terminal_ansi_dim_blue,
264 => colors.terminal_ansi_dim_magenta,
265 => colors.terminal_ansi_dim_cyan,
266 => colors.terminal_ansi_dim_white,
267 => colors.terminal_bright_foreground,
268 => colors.terminal_ansi_black, // 'Dim Background', non-standard color
_ => black(),
}
}
/// Generates the RGB channels in [0, 5] for a given index into the 6x6x6 ANSI color cube.
///
/// See: [8 bit ANSI color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit).
///
/// Wikipedia gives a formula for calculating the index for a given color:
///
/// ```text
/// index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
/// ```
///
/// This function does the reverse, calculating the `r`, `g`, and `b` components from a given index.
fn rgb_for_index(i: u8) -> (u8, u8, u8) {
debug_assert!((16..=231).contains(&i));
let i = i - 16;
let r = (i - (i % 36)) / 36;
let g = ((i % 36) - (i % 6)) / 6;
let b = (i % 36) % 6;
(r, g, b)
}
pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla {
Rgba {
r: (r as f32 / 255.),
g: (g as f32 / 255.),
b: (b as f32 / 255.),
a: 1.,
}
.into()
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::{
Cell, Content, IndexedCell, TerminalBounds, TerminalBuilder, content_index_for_mouse,
rgb_for_index,
};
use async_channel::Receiver;
use collections::HashMap;
use gpui::{
ClipboardItem, Entity, Pixels, TestAppContext, VisualTestContext, bounds, point, size,
};
use parking_lot::Mutex;
use rand::{Rng, distr, rngs::StdRng};
use task::{Shell, ShellBuilder};
#[test]
fn test_init_command_startup_marker_commands_do_not_contain_marker() {
let marker_id = 42;
let marker = init_command_startup_marker(marker_id);
for shell_kind in [
ShellKind::Posix,
ShellKind::Csh,
ShellKind::Tcsh,
ShellKind::Rc,
ShellKind::Fish,
ShellKind::PowerShell,
ShellKind::Pwsh,
ShellKind::Nushell,
ShellKind::Cmd,
ShellKind::Xonsh,
ShellKind::Elvish,
] {
let command = init_command_startup_marker_command(shell_kind, marker_id);
assert!(
!command.contains(&marker),
"startup marker command for {shell_kind:?} should not contain the full marker, got {command:?}"
);
}
}
#[gpui::test]
async fn test_init_command_startup_marker_ignores_echoed_command(cx: &mut TestAppContext) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
let marker_id = 4242;
let marker = init_command_startup_marker(marker_id);
let command = init_command_startup_marker_command(ShellKind::Posix, marker_id);
let (startup_tx, startup_rx) = async_channel::bounded(1);
terminal.update(cx, |terminal, cx| {
terminal.init_command_startup_marker = Some(marker.clone());
terminal.init_command_startup_tx = Some(startup_tx);
terminal.write_output(command.as_bytes(), cx);
});
assert!(matches!(
startup_rx.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
terminal.update(cx, |terminal, cx| {
terminal.write_output(marker.as_bytes(), cx);
});
assert!(startup_rx.try_recv().is_ok());
}
#[test]
fn test_normalize_path_command_name() {
assert_eq!(normalize_path_command_name("claude"), Some("claude".into()));
assert_eq!(normalize_path_command_name("Cargo"), Some("cargo".into()));
assert_eq!(normalize_path_command_name("node.exe"), Some("node".into()));
assert_eq!(
normalize_path_command_name("my-agent_cli.1"),
Some("my-agent_cli.1".into())
);
assert_eq!(normalize_path_command_name("./local-agent"), None);
assert_eq!(normalize_path_command_name("../local-agent"), None);
assert_eq!(normalize_path_command_name("/usr/local/bin/cargo"), None);
assert_eq!(
normalize_path_command_name("target\\debug\\agent.exe"),
None
);
assert_eq!(normalize_path_command_name(".hidden-agent"), None);
assert_eq!(normalize_path_command_name("agent with spaces"), None);
assert_eq!(normalize_path_command_name("zsh"), Some("zsh".into()));
assert_eq!(normalize_path_command_name("-zsh"), None);
assert_eq!(normalize_path_command_name("pwsh.exe"), Some("pwsh".into()));
}
#[test]
fn test_foreground_process_command_from_interpreter_wrapper() {
assert_eq!(
foreground_process_command_from_argv(&[
"node".to_string(),
"/opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js".to_string(),
]),
Some("gemini".to_string())
);
assert_eq!(
foreground_process_command_from_argv(&[
"python3".to_string(),
"/Users/me/.local/bin/codex.py".to_string(),
]),
Some("codex".to_string())
);
assert_eq!(
foreground_process_command_from_argv(&[
"node".to_string(),
"/Users/me/private-project/scripts/customer-data-export.js".to_string(),
]),
Some("customer-data-export".to_string())
);
}
#[cfg(not(target_os = "windows"))]
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
});
}
/// Helper to build a test terminal running a shell command.
/// Returns the terminal entity and a receiver for the completion signal.
async fn build_test_terminal(
cx: &mut TestAppContext,
command: &str,
args: &[&str],
) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (program, args) =
ShellBuilder::new(&Shell::System, false).build(Some(command.to_owned()), &args);
build_test_terminal_with_arguments(cx, program, args).await
}
async fn build_test_terminal_with_arguments(
cx: &mut TestAppContext,
program: String,
args: Vec<String>,
) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
let (completion_tx, completion_rx) = async_channel::unbounded();
let builder = cx
.update(|cx| {
TerminalBuilder::new(
None,
None,
task::Shell::WithArguments {
program,
args,
title_override: None,
},
HashMap::default(),
SettingsCursorShape::default(),
AlternateScroll::On,
None,
vec![],
Duration::ZERO,
false,
0,
Some(completion_tx),
cx,
vec![],
PathStyle::local(),
)
})
.await
.unwrap();
let terminal = cx.new(|cx| builder.subscribe(cx));
(terminal, completion_rx)
}
/// Builds a non-PTY (`no_pty`) task terminal, exercising the path used by
/// headless hosts (e.g. the eval CLI) where PTY allocation fails with
/// `ENOTTY`. The command runs as a plain subprocess whose piped output is
/// pumped into the emulator.
#[cfg(not(target_os = "windows"))]
async fn build_test_subprocess_terminal(
cx: &mut TestAppContext,
program: String,
args: Vec<String>,
) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
let (completion_tx, completion_rx) = async_channel::unbounded();
let task_state = TaskState {
status: TaskStatus::Running,
completion_rx: completion_rx.clone(),
spawned_task: SpawnInTerminal {
command: Some(program.clone()),
args: args.clone(),
..Default::default()
},
};
let builder = cx
.update(|cx| {
cx.set_global(HeadlessTerminal(true));
TerminalBuilder::new(
None,
Some(task_state),
task::Shell::WithArguments {
program,
args,
title_override: None,
},
HashMap::default(),
SettingsCursorShape::default(),
AlternateScroll::On,
None,
vec![],
Duration::ZERO,
false,
0,
Some(completion_tx),
cx,
vec![],
PathStyle::local(),
)
})
.await
.unwrap();
let terminal = cx.new(|cx| builder.subscribe(cx));
(terminal, completion_rx)
}
#[test]
fn test_convert_lf_to_crlf_preserves_split_crlf() {
let mut previous_byte_was_cr = false;
assert_eq!(
convert_lf_to_crlf(b"one\n", &mut previous_byte_was_cr),
b"one\r\n"
);
assert!(!previous_byte_was_cr);
let mut previous_byte_was_cr = false;
assert_eq!(
convert_lf_to_crlf(b"two\r", &mut previous_byte_was_cr),
b"two\r"
);
assert!(previous_byte_was_cr);
assert_eq!(
convert_lf_to_crlf(b"\nthree", &mut previous_byte_was_cr),
b"\nthree"
);
assert!(!previous_byte_was_cr);
}
/// Regression test for the agent terminal failing with `Not a tty (os error
/// 25)` in headless/eval sandboxes: a `no_pty` task terminal must run
/// without a PTY, capture stdout, and report its exit status.
#[cfg(not(target_os = "windows"))]
#[gpui::test]
async fn test_no_pty_task_terminal_captures_output(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let (program, args) = ShellBuilder::new(&Shell::System, false)
.non_interactive()
.build(Some("echo hello-from-subprocess".to_owned()), &[]);
let (terminal, completion_rx) = build_test_subprocess_terminal(cx, program, args).await;
assert!(
!terminal.update(cx, |term, _| term.is_pty()),
"no_pty terminal should not be PTY-backed"
);
assert_eq!(
completion_rx.recv().await.unwrap(),
Some(ExitStatus::default())
);
assert_content_eventually(&terminal, "hello-from-subprocess", cx).await;
}
fn init_terminal_test(cx: &mut TestAppContext, output: &[u8]) -> Entity<Terminal> {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
});
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
terminal.update(cx, |terminal, cx| {
terminal.write_output(output, cx);
});
cx.run_until_parked();
terminal.update(cx, |terminal, _cx| {
let term_lock = terminal.term.lock();
terminal.last_content = make_content(&term_lock, &terminal.last_content);
drop(term_lock);
let terminal_bounds = TerminalBounds::new(
px(20.0),
px(10.0),
bounds(point(px(0.0), px(0.0)), size(px(400.0), px(400.0))),
);
terminal.last_content.terminal_bounds = terminal_bounds;
terminal.events.clear();
terminal.take_pty_write_log();
});
terminal
}
fn init_terminal_test_with_window<'a>(
cx: &'a mut TestAppContext,
initial_content: &[u8],
) -> (Entity<Terminal>, &'a mut VisualTestContext) {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
});
cx.executor().allow_parking();
let window = cx.add_empty_window();
let builder = window.update(|window, cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
window.window_handle().window_id().as_u64(),
cx.background_executor(),
PathStyle::local(),
)
});
let terminal = window.new(|cx| builder.subscribe(cx));
terminal.update(window, |term, cx| {
term.write_output(initial_content, cx);
});
(terminal, window)
}
fn left_mouse_down_at(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let mouse_down = MouseDownEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: true,
};
terminal.mouse_down(&mouse_down, cx);
}
fn left_mouse_up_at(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let mouse_up = MouseUpEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::none(),
click_count: 1,
};
terminal.mouse_up(&mouse_up, cx);
}
fn left_mouse_drag_to(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let region = terminal.last_content.terminal_bounds.bounds;
let drag_event = MouseMoveEvent {
position,
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::none(),
};
terminal.mouse_drag(&drag_event, region, cx);
}
/// A left click that jitters by a pixel or two (e.g. the window-focusing
/// click) must not begin a selection, otherwise `copy_on_select` would
/// overwrite the clipboard. Regression test for #58970.
#[gpui::test]
async fn test_terminal_click_jitter_does_not_start_selection(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"hello world\r\n");
terminal.update(cx, |terminal, cx| {
left_mouse_down_at(terminal, point(px(50.0), px(10.0)), cx);
terminal.events.clear();
// One pixel of movement is below the drag threshold.
left_mouse_drag_to(terminal, point(px(51.0), px(10.0)), cx);
assert!(
!terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
"a sub-threshold click jitter should not start a selection"
);
assert!(terminal.selection_phase == SelectionPhase::Ended);
});
}
/// A deliberate drag past the threshold must still start a selection.
#[gpui::test]
async fn test_terminal_deliberate_drag_starts_selection(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"hello world\r\n");
terminal.update(cx, |terminal, cx| {
left_mouse_down_at(terminal, point(px(50.0), px(10.0)), cx);
terminal.events.clear();
// Well beyond the drag threshold.
left_mouse_drag_to(terminal, point(px(90.0), px(10.0)), cx);
assert!(
terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
"a deliberate drag should start a selection"
);
assert!(terminal.selection_phase == SelectionPhase::Selecting);
});
}
/// With mouse tracking active (e.g. htop), Shift is the escape hatch to
/// select terminal text. Shift+drag must start a selection rather than being
/// swallowed as a "extend existing selection" no-op. Regression test for #60254.
#[gpui::test]
async fn test_terminal_shift_drag_selects_while_mouse_tracking(cx: &mut TestAppContext) {
// `?1002h` enables button-event mouse tracking, `?1006h` selects SGR encoding.
let terminal = init_terminal_test(cx, b"\x1b[?1002h\x1b[?1006hhello world\r\n");
terminal.update(cx, |terminal, cx| {
assert!(
terminal.last_content.mode.intersects(Modes::MOUSE_MODE),
"mouse tracking should be active"
);
let shift = Modifiers {
shift: true,
..Modifiers::none()
};
terminal.mouse_down(
&MouseDownEvent {
button: MouseButton::Left,
position: point(px(50.0), px(10.0)),
modifiers: shift,
click_count: 1,
first_mouse: true,
},
cx,
);
// With no selection yet, the shift press must anchor a new selection
// so the following drag has something to extend.
assert!(
terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::SetSelection(Some(_)))),
"shift+click with no existing selection should anchor a selection"
);
terminal.events.clear();
let region = terminal.last_content.terminal_bounds.bounds;
terminal.mouse_drag(
&MouseMoveEvent {
position: point(px(90.0), px(10.0)),
pressed_button: Some(MouseButton::Left),
modifiers: shift,
},
region,
cx,
);
assert!(
terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
"shift+drag should extend the selection while mouse tracking is active"
);
assert!(terminal.selection_phase == SelectionPhase::Selecting);
});
}
/// Shift+click with a selection already on screen must keep extending it
/// (the behavior added in #25143), not re-anchor a fresh one.
#[gpui::test]
async fn test_terminal_shift_click_extends_existing_selection(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"hello world\r\n");
terminal.update(cx, |terminal, cx| {
// A visible selection, as a sync would have populated in production.
terminal.last_content.selection = Some(SelectionRange {
start: Point::new(0, 0),
end: Point::new(0, 5),
is_block: false,
});
terminal.events.clear();
terminal.mouse_down(
&MouseDownEvent {
button: MouseButton::Left,
position: point(px(90.0), px(10.0)),
modifiers: Modifiers {
shift: true,
..Modifiers::none()
},
click_count: 1,
first_mouse: true,
},
cx,
);
assert!(
terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
"shift+click with an existing selection should extend it"
);
assert!(
!terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::SetSelection(Some(_)))),
"shift+click should extend, not re-anchor, an existing selection"
);
});
}
#[gpui::test]
async fn test_basic_terminal(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["hello"]).await;
assert_eq!(
completion_rx.recv().await.unwrap(),
Some(ExitStatus::default())
);
assert_content_eventually(&terminal, "hello", cx).await;
// Inject additional output directly into the emulator (display-only path)
terminal.update(cx, |term, cx| {
term.write_output(b"\nfrom_injection", cx);
});
let content_after = terminal.update(cx, |term, _| term.get_content());
assert!(
content_after.contains("from_injection"),
"expected injected output to appear, got: {content_after}"
);
}
#[cfg(unix)]
#[gpui::test]
async fn test_foreground_process_command_tracks_path_command(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let (terminal, completion_rx) =
build_test_terminal_with_arguments(cx, "sleep".to_string(), vec!["1".to_string()])
.await;
assert_foreground_process_command_eventually(&terminal, "sleep", cx).await;
assert!(
completion_rx.recv().await.is_ok(),
"expected terminal completion after sleep exits"
);
}
// TODO should be tested on Linux too, but does not work there well
#[cfg(target_os = "macos")]
#[gpui::test(iterations = 10)]
async fn test_terminal_eof(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let (completion_tx, completion_rx) = async_channel::unbounded();
let builder = cx
.update(|cx| {
TerminalBuilder::new(
None,
None,
task::Shell::System,
HashMap::default(),
SettingsCursorShape::default(),
AlternateScroll::On,
None,
vec![],
Duration::ZERO,
false,
0,
Some(completion_tx),
cx,
Vec::new(),
PathStyle::local(),
)
})
.await
.unwrap();
// Build an empty command, which will result in a tty shell spawned.
let terminal = cx.new(|cx| builder.subscribe(cx));
let (event_tx, event_rx) = async_channel::unbounded::<Event>();
cx.update(|cx| {
cx.subscribe(&terminal, move |_, e, _| {
event_tx.send_blocking(e.clone()).unwrap();
})
})
.detach();
cx.background_spawn(async move {
assert_eq!(
completion_rx.recv().await.unwrap(),
Some(ExitStatus::default()),
"EOF should result in the tty shell exiting successfully",
);
})
.detach();
let first_event = event_rx.recv().await.expect("No wakeup event received");
terminal.update(cx, |terminal, _| {
let success = terminal.try_keystroke(&Keystroke::parse("ctrl-d").unwrap(), false);
assert!(success, "Should have registered ctrl-d sequence");
});
let mut all_events = vec![first_event];
while let Ok(new_event) = event_rx.recv().await {
all_events.push(new_event.clone());
if new_event == Event::CloseTerminal {
break;
}
}
assert!(
all_events.contains(&Event::CloseTerminal),
"EOF command sequence should have triggered a TTY terminal exit, but got events: {all_events:?}",
);
}
#[cfg(not(target_os = "windows"))]
#[gpui::test(iterations = 10)]
async fn test_terminal_closes_after_nonzero_exit(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let builder = cx
.update(|cx| {
TerminalBuilder::new(
None,
None,
task::Shell::System,
HashMap::default(),
SettingsCursorShape::default(),
AlternateScroll::On,
None,
vec![],
Duration::ZERO,
false,
0,
None,
cx,
Vec::new(),
PathStyle::local(),
)
})
.await
.unwrap();
let terminal = cx.new(|cx| builder.subscribe(cx));
let (event_tx, event_rx) = async_channel::unbounded::<Event>();
cx.update(|cx| {
cx.subscribe(&terminal, move |_, e, _| {
event_tx.send_blocking(e.clone()).unwrap();
})
})
.detach();
let first_event = event_rx.recv().await.expect("No wakeup event received");
terminal.update(cx, |terminal, _| {
terminal.input(b"false\r".to_vec());
});
cx.executor().timer(Duration::from_millis(500)).await;
terminal.update(cx, |terminal, _| {
terminal.input(b"exit\r".to_vec());
});
let mut all_events = vec![first_event];
while let Ok(new_event) = event_rx.recv().await {
all_events.push(new_event.clone());
if new_event == Event::CloseTerminal {
break;
}
}
assert!(
all_events.contains(&Event::CloseTerminal),
"Shell exiting after `false && exit` should close terminal, but got events: {all_events:?}",
);
}
#[gpui::test(iterations = 10)]
async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let (completion_tx, completion_rx) = async_channel::unbounded();
let (program, args) = ShellBuilder::new(&Shell::System, false)
.build(Some("asdasdasdasd".to_owned()), &["@@@@@".to_owned()]);
let builder = cx
.update(|cx| {
TerminalBuilder::new(
None,
None,
task::Shell::WithArguments {
program,
args,
title_override: None,
},
HashMap::default(),
SettingsCursorShape::default(),
AlternateScroll::On,
None,
Vec::new(),
Duration::ZERO,
false,
0,
Some(completion_tx),
cx,
Vec::new(),
PathStyle::local(),
)
})
.await
.unwrap();
let terminal = cx.new(|cx| builder.subscribe(cx));
let all_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
cx.update({
let all_events = all_events.clone();
|cx| {
cx.subscribe(&terminal, move |_, e, _| {
all_events.lock().push(e.clone());
})
}
})
.detach();
let completion_check_task = cx.background_spawn(async move {
// The channel may be closed if the terminal is dropped before sending
// the completion signal, which can happen with certain task scheduling orders.
let exit_status = completion_rx.recv().await.ok().flatten();
if let Some(exit_status) = exit_status {
assert!(
!exit_status.success(),
"Wrong shell command should result in a failure"
);
#[cfg(target_os = "windows")]
assert_eq!(exit_status.code(), Some(1));
#[cfg(not(target_os = "windows"))]
assert_eq!(exit_status.code(), Some(127)); // code 127 means "command not found" on Unix
}
});
completion_check_task.await;
cx.executor().timer(Duration::from_millis(500)).await;
assert!(
!all_events
.lock()
.iter()
.any(|event| event == &Event::CloseTerminal),
"Wrong shell command should update the title but not should not close the terminal to show the error message, but got events: {all_events:?}",
);
}
#[test]
fn test_rgb_for_index() {
// Test every possible value in the color cube.
for i in 16..=231 {
let (r, g, b) = rgb_for_index(i);
assert_eq!(i, 16 + 36 * r + 6 * g + b);
}
}
#[gpui::test]
fn test_mouse_to_cell_test(mut rng: StdRng) {
const ITERATIONS: usize = 10;
const PRECISION: usize = 1000;
for _ in 0..ITERATIONS {
let viewport_cells = rng.random_range(15..20);
let cell_size =
rng.random_range(5 * PRECISION..20 * PRECISION) as f32 / PRECISION as f32;
let size = crate::TerminalBounds {
cell_width: Pixels::from(cell_size),
line_height: Pixels::from(cell_size),
bounds: bounds(
GpuiPoint::default(),
size(
Pixels::from(cell_size * (viewport_cells as f32)),
Pixels::from(cell_size * (viewport_cells as f32)),
),
),
};
let cells = get_cells(size, &mut rng);
let content = convert_cells_to_content(size, &cells);
for row in 0..(viewport_cells - 1) {
let row = row as usize;
for col in 0..(viewport_cells - 1) {
let col = col as usize;
let row_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
let col_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
let mouse_pos = point(
Pixels::from(col as f32 * cell_size + col_offset),
Pixels::from(row as f32 * cell_size + row_offset),
);
let content_index =
content_index_for_mouse(mouse_pos, &content.terminal_bounds);
let mouse_cell = content.cells[content_index].character();
let real_cell = cells[row][col];
assert_eq!(mouse_cell, real_cell);
}
}
}
}
#[gpui::test]
fn test_mouse_to_cell_clamp(mut rng: StdRng) {
let size = crate::TerminalBounds {
cell_width: Pixels::from(10.),
line_height: Pixels::from(10.),
bounds: bounds(
GpuiPoint::default(),
size(Pixels::from(100.), Pixels::from(100.)),
),
};
let cells = get_cells(size, &mut rng);
let content = convert_cells_to_content(size, &cells);
assert_eq!(
content.cells[content_index_for_mouse(
point(Pixels::from(-10.), Pixels::from(-10.)),
&content.terminal_bounds,
)]
.character(),
cells[0][0]
);
assert_eq!(
content.cells[content_index_for_mouse(
point(Pixels::from(1000.), Pixels::from(1000.)),
&content.terminal_bounds,
)]
.character(),
cells[9][9]
);
}
#[gpui::test]
async fn test_set_size_coalesces_pixel_only_changes(cx: &mut TestAppContext) {
let builder = cx.update(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::Block,
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
});
let mut terminal = builder.terminal;
let base_bounds = TerminalBounds {
cell_width: Pixels::from(10.),
line_height: Pixels::from(10.),
bounds: bounds(
GpuiPoint::default(),
size(Pixels::from(100.), Pixels::from(100.)),
),
};
terminal.set_size(base_bounds);
terminal.events.clear();
assert_eq!(terminal.last_content.terminal_bounds, base_bounds);
// Pixel-only change: height grows by 1px but still the same number of rows/cols.
let mut pixel_changed = base_bounds;
pixel_changed.bounds.size.height = Pixels::from(101.);
terminal.set_size(pixel_changed);
assert!(terminal.events.is_empty());
assert_eq!(terminal.last_content.terminal_bounds, pixel_changed);
// Grid change: height increases enough to add a row.
let mut grid_changed = base_bounds;
grid_changed.bounds.size.height = Pixels::from(110.);
terminal.set_size(grid_changed);
assert!(matches!(
terminal.events.back(),
Some(InternalEvent::Resize(_))
));
}
fn get_cells(size: TerminalBounds, rng: &mut StdRng) -> Vec<Vec<char>> {
let mut cells = Vec::new();
for _ in 0..size.num_lines() {
let mut row_vec = Vec::new();
for _ in 0..size.num_columns() {
let cell_char = rng.sample(distr::Alphanumeric) as char;
row_vec.push(cell_char)
}
cells.push(row_vec)
}
cells
}
fn convert_cells_to_content(terminal_bounds: TerminalBounds, cells: &[Vec<char>]) -> Content {
let mut ic = Vec::new();
for (index, row) in cells.iter().enumerate() {
for (cell_index, cell_char) in row.iter().enumerate() {
let mut cell = Cell::default();
cell.set_character(*cell_char);
ic.push(IndexedCell {
point: Point::new(index as i32, cell_index),
cell,
});
}
}
Content {
cells: ic,
terminal_bounds,
..Default::default()
}
}
#[gpui::test]
async fn test_write_init_command_after_startup_clears_without_shell_command(
cx: &mut TestAppContext,
) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"startup output\nprompt", cx);
});
let wrote = terminal.update(cx, |terminal, cx| {
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 42,
working_directory: PathBuf::from("/stale/cwd"),
});
terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx)
});
assert!(wrote);
let (content, cwd_history) = terminal.update(cx, |terminal, _| {
(terminal.get_content(), terminal.cwd_history.clone())
});
assert!(cwd_history.is_empty());
assert!(
!content.contains("startup output"),
"startup output should be cleared internally before writing the init command"
);
let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
assert_eq!(input_log, vec![b"agent\r".to_vec()]);
}
#[gpui::test]
async fn test_write_init_command_after_startup_skips_after_keyboard_input(
cx: &mut TestAppContext,
) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
let wrote = terminal.update(cx, |terminal, cx| {
terminal.write_output(b"startup output\nprompt", cx);
terminal.input(b"user input".to_vec());
terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx)
});
assert!(!wrote);
let content = terminal.update(cx, |terminal, _| terminal.get_content());
assert!(
content.contains("startup output"),
"startup output should be left alone when the init command is skipped"
);
let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
assert_eq!(input_log, vec![b"user input".to_vec()]);
}
#[gpui::test]
async fn test_write_init_command_after_startup_skips_after_child_exit(cx: &mut TestAppContext) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"shell failed to start\nprompt", cx);
#[cfg(unix)]
let exit_status =
<ExitStatus as std::os::unix::process::ExitStatusExt>::from_raw(1 << 8);
#[cfg(windows)]
let exit_status = <ExitStatus as std::os::windows::process::ExitStatusExt>::from_raw(1);
terminal.register_task_finished(Some(exit_status), cx);
});
let wrote = terminal.update(cx, |terminal, cx| {
terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx)
});
assert!(!wrote);
let content = terminal.update(cx, |terminal, _| terminal.get_content());
assert!(
content.contains("shell failed to start"),
"startup failure output should be preserved when the init command is skipped"
);
let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
assert!(
input_log.is_empty(),
"init command should not be written after the child has exited, got {input_log:?}"
);
}
#[gpui::test]
async fn test_write_output_converts_lf_to_crlf(cx: &mut TestAppContext) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
// Test simple LF conversion
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"line1\nline2\n", cx);
});
// Get the content by directly accessing the term
let content = terminal.update(cx, |terminal, _cx| {
let term = terminal.term.lock_unfair();
make_content(&term, &terminal.last_content)
});
// If LF is properly converted to CRLF, each line should start at column 0
// The diagonal staircase bug would cause increasing column positions
// Get the cells and check that lines start at column 0
let cells = &content.cells;
let mut line1_col0 = false;
let mut line2_col0 = false;
for cell in cells {
if cell.character() == 'l' && cell.point.column == 0 {
if cell.point.line == 0 && !line1_col0 {
line1_col0 = true;
} else if cell.point.line == 1 && !line2_col0 {
line2_col0 = true;
}
}
}
assert!(line1_col0, "First line should start at column 0");
assert!(line2_col0, "Second line should start at column 0");
}
#[gpui::test]
async fn test_write_output_preserves_existing_crlf(cx: &mut TestAppContext) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
// Test that existing CRLF doesn't get doubled
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"line1\r\nline2\r\n", cx);
});
// Get the content by directly accessing the term
let content = terminal.update(cx, |terminal, _cx| {
let term = terminal.term.lock_unfair();
make_content(&term, &terminal.last_content)
});
let cells = &content.cells;
// Check that both lines start at column 0
let mut found_lines_at_column_0 = 0;
for cell in cells {
if cell.character() == 'l' && cell.point.column == 0 {
found_lines_at_column_0 += 1;
}
}
assert!(
found_lines_at_column_0 >= 2,
"Both lines should start at column 0"
);
}
#[gpui::test]
async fn test_write_output_preserves_bare_cr(cx: &mut TestAppContext) {
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
// Test that bare CR (without LF) is preserved
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"hello\rworld", cx);
});
// Get the content by directly accessing the term
let content = terminal.update(cx, |terminal, _cx| {
let term = terminal.term.lock_unfair();
make_content(&term, &terminal.last_content)
});
let cells = &content.cells;
// Check that we have "world" at the beginning of the line
let mut text = String::new();
for cell in cells.iter().take(5) {
if cell.point.line == 0 {
text.push(cell.character());
}
}
assert!(
text.starts_with("world"),
"Bare CR should allow overwriting: got '{}'",
text
);
}
#[gpui::test]
async fn test_display_only_write_output_ignores_osc52(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
cx.write_to_clipboard(ClipboardItem::new_string("original".to_string()));
});
let terminal = cx.new(|cx| {
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.subscribe(cx)
});
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"\x1b]52;c;b3ZlcndyaXR0ZW4=\x07", cx);
});
cx.run_until_parked();
let clipboard_text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
assert_eq!(clipboard_text.as_deref(), Some("original"));
}
mod hyperlinks {
use super::{
init_terminal_test, init_terminal_test_with_window, left_mouse_down_at,
left_mouse_up_at,
};
use crate::*;
use gpui::{
Context, Entity, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Subscription, TestAppContext, VisualContext, VisualTestContext, point,
};
use std::ops::RangeInclusive;
use util::default;
fn ctrl_mouse_down_at(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let mouse_down = MouseDownEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::secondary_key(),
click_count: 1,
first_mouse: true,
};
terminal.mouse_down(&mouse_down, cx);
}
fn ctrl_mouse_drag_to(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let terminal_bounds = terminal.last_content.terminal_bounds.bounds;
let drag_event = MouseMoveEvent {
position,
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::secondary_key(),
};
terminal.mouse_drag(&drag_event, terminal_bounds, cx);
}
fn ctrl_mouse_up_at(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let mouse_up = MouseUpEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::secondary_key(),
click_count: 1,
};
terminal.mouse_up(&mouse_up, cx);
}
macro_rules! any_event_matches {
($terminal:ident, $event:pat) => {
$terminal.events.iter().any(|event| matches!(event, $event))
};
}
#[gpui::test]
async fn test_ctrl_click_same_position(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"Visit https://zed.dev/ for more\r\n");
terminal.update(cx, |terminal, cx| {
let click_position = point(px(80.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, true)),
"Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position"
);
});
}
#[gpui::test]
async fn test_hyperlink_ctrl_click_same_position_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 click_position = point(px(80.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, true)),
"Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position in mouse mode"
);
assert!(
terminal.take_pty_write_log().is_empty(),
"a consumed link click must not be reported to the PTY"
);
});
}
#[gpui::test]
async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture(
cx: &mut TestAppContext,
) {
let terminal = init_terminal_test(
cx,
b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
);
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
terminal.take_pty_write_log();
let down_position = point(px(80.0), px(10.0));
let up_position = point(px(10.0), px(30.0));
ctrl_mouse_down_at(terminal, down_position, cx);
terminal.mouse_move(
&MouseMoveEvent {
position: up_position,
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::secondary_key(),
},
cx,
);
ctrl_mouse_up_at(terminal, up_position, cx);
assert!(
!any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, _)),
"Should NOT open a link when press and release land on different hyperlinks"
);
let pty_writes = terminal.take_pty_write_log();
assert!(
pty_writes.is_empty(),
"a captured press must consume the whole gesture, but reports leaked to the PTY: {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_plain_click_on_hyperlink_in_mouse_mode_is_reported(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;
terminal.take_pty_write_log();
let click_position = point(px(80.0), px(10.0));
left_mouse_down_at(terminal, click_position, cx);
left_mouse_up_at(terminal, click_position, cx);
assert!(
!any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, _)),
"a plain click must not open a link"
);
let pty_writes = terminal.take_pty_write_log();
assert_eq!(
pty_writes.len(),
2,
"expected press and release reports, got {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_ctrl_click_on_non_hyperlink_in_mouse_mode_is_reported(
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;
terminal.take_pty_write_log();
// Past the end of the line: nothing link-like under the cursor.
let click_position = point(px(370.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
!any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, _)),
"a secondary click off a link must not open anything"
);
let pty_writes = terminal.take_pty_write_log();
assert_eq!(
pty_writes.len(),
2,
"expected press and release reports, got {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_ctrl_click_in_mouse_mode_forwards_when_setting_disabled(
cx: &mut TestAppContext,
) {
let terminal = init_terminal_test(cx, b"Visit https://zed.dev/ for more\r\n");
cx.update_global(|store: &mut settings::SettingsStore, cx| {
store.update_user_settings(cx, |settings| {
settings
.terminal
.get_or_insert_default()
.open_links_in_mouse_mode = Some(false);
});
});
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
let click_position = point(px(80.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
!any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, _)),
"with the setting disabled, ctrl+click must not open links in mouse mode"
);
let pty_writes = terminal.take_pty_write_log();
assert_eq!(
pty_writes.len(),
2,
"expected press and release reports, got {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) {
let terminal = init_terminal_test(
cx,
b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
);
terminal.update(cx, |terminal, cx| {
let down_position = point(px(80.0), px(10.0));
let up_position = point(px(10.0), px(50.0));
ctrl_mouse_down_at(terminal, down_position, cx);
ctrl_mouse_drag_to(terminal, up_position, cx);
ctrl_mouse_up_at(terminal, up_position, cx);
assert!(
!any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, _)),
"Should NOT have ProcessHyperlink event when dragging outside the hyperlink"
);
});
}
#[gpui::test]
async fn test_ctrl_click_drag_within_bounds(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"Visit https://zed.dev/ for more\r\n");
terminal.update(cx, |terminal, cx| {
let down_position = point(px(70.0), px(10.0));
let up_position = point(px(130.0), px(10.0));
ctrl_mouse_down_at(terminal, down_position, cx);
ctrl_mouse_drag_to(terminal, up_position, cx);
ctrl_mouse_up_at(terminal, up_position, cx);
assert!(
any_event_matches!(terminal, InternalEvent::ProcessHyperlink(_, true)),
"Should have ProcessHyperlink event when dragging within hyperlink bounds"
);
});
}
impl<T: AsRef<str>> From<(T, RangeInclusive<(i32, usize)>, usize)> for HoveredWord {
fn from(value: (T, RangeInclusive<(i32, usize)>, usize)) -> Self {
let match_start = Point::new(value.1.start().0, value.1.start().1);
let match_end = Point::new(value.1.end().0, value.1.end().1);
Self {
word: value.0.as_ref().to_string(),
word_match: Range::new(match_start, match_end),
id: value.2,
}
}
}
trait WithLineAndId {
fn with_line(&self, line: i32) -> Self;
fn with_id(&self, id: usize) -> Self;
fn with_line_and_id(&self, line: i32, id: usize) -> Self;
}
impl WithLineAndId for HoveredWord {
fn with_line(&self, line: i32) -> Self {
Self::from((
self.word.clone(),
(line, self.word_match.start().column)..=(line, self.word_match.end().column),
self.id,
))
}
fn with_id(&self, id: usize) -> Self {
Self {
word: self.word.clone(),
word_match: self.word_match,
id,
}
}
fn with_line_and_id(&self, line: i32, id: usize) -> Self {
Self::from((
self.word.clone(),
(line, self.word_match.start().column)..=(line, self.word_match.end().column),
id,
))
}
}
struct HyperlinkVisualTestContext<'a, 'b> {
terminal: &'a mut Terminal,
window: &'a mut Window,
cx: &'a mut Context<'b, Terminal>,
}
impl<'a, 'b> HyperlinkVisualTestContext<'a, 'b> {
fn new(
terminal: &'a mut Terminal,
window: &'a mut Window,
cx: &'a mut Context<'b, Terminal>,
) -> Self {
Self {
terminal,
window,
cx,
}
}
#[track_caller]
fn assert_visible_lines_match(
&self,
expected_lines: impl IntoIterator<Item = &'static str>,
) {
fn visible_lines(terminal: &Terminal) -> Vec<String> {
terminal.with_renderable_cells(|cells| {
let mut text_lines = Vec::new();
let linegroups = cells.into_iter().chunk_by(|cell| cell.point.line);
for (_, (_, line)) in linegroups.into_iter().enumerate() {
let mut previous_cell_had_extras = false;
let mut text = String::new();
for IndexedCell { cell, .. } in line {
// Skip wide character spacers - they're just placeholders for the second cell of wide characters
if cell.is_wide_char_spacer() {
continue;
}
// Skip spaces that follow cells with extras (emoji variation sequences)
if cell.character() == ' ' && previous_cell_had_extras {
previous_cell_had_extras = false;
continue;
}
// Update tracking for next iteration
previous_cell_had_extras =
matches!(cell.zerowidth(), Some(chars) if !chars.is_empty());
text.push(cell.character());
}
text_lines.push(text.trim_end().to_string());
}
text_lines
})
}
let lines = visible_lines(self.terminal);
let mut expected_lines = expected_lines.into_iter();
for (line, text) in lines.into_iter().enumerate() {
let Some(expected_text) = expected_lines.next() else {
// More actual lines than expected lines, ignore
return;
};
assert_eq!(text, expected_text, "Mismatched text at line {line}");
}
assert!(expected_lines.next().is_none(), "Extra expected lines")
}
#[track_caller]
fn assert_display_offset(&self, expected_display_offset: usize) {
assert_eq!(
self.terminal.last_content().display_offset,
expected_display_offset,
"Mismatched display offset"
);
}
#[track_caller]
fn assert_hovered_word(&self, expected_hovered_word: Option<&HoveredWord>) {
assert_eq!(
self.terminal.last_content().last_hovered_word.as_ref(),
expected_hovered_word,
"Mismatched hovered word"
);
}
fn ctrl_mouse_move_to(&mut self, position: GpuiPoint<Pixels>) {
let modifiers = Modifiers::secondary_key();
let move_event = MouseMoveEvent {
position,
modifiers,
..default()
};
self.window.simulate_mouse_move(position, self.cx);
self.unthrottle();
self.terminal.mouse_move(&move_event, self.cx);
}
fn try_modifiers_change(&mut self, modifiers: Modifiers) {
self.window.set_modifiers(modifiers);
self.unthrottle();
self.terminal
.try_modifiers_change(&modifiers, self.window, self.cx);
}
fn write_output_lines(&mut self, output: &str, repeat: usize) {
for _ in 0..repeat {
self.terminal.write_output(output.as_bytes(), self.cx);
self.terminal.write_output(b"\n", self.cx);
}
}
fn scroll_up_by_and_sync(&mut self, lines: usize) {
self.terminal.scroll_up_by(lines);
self.sync();
}
fn set_size_and_sync(&mut self, new_bounds: TerminalBounds) {
self.terminal.set_size(new_bounds);
self.sync();
}
fn sync(&mut self) {
self.unthrottle();
self.terminal.sync(self.window, self.cx);
}
fn unthrottle(&mut self) {
// Suppress hyperlink throttling for testing
self.terminal.suppress_hyperlink_throttle_once = true;
}
fn set_window_secondary_key(&mut self) {
self.window.set_modifiers(Modifiers::secondary_key());
}
fn clear_window_secondary_key(&mut self) {
self.window.set_modifiers(default());
}
}
struct TestView {
wakeups: usize,
notifies: usize,
_terminal_subscriptions: Vec<Subscription>,
}
impl TestView {
fn new(
terminal: &Entity<Terminal>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
wakeups: 0,
notifies: 0,
_terminal_subscriptions: Self::subscribe_for_terminal_events(
terminal, window, cx,
),
}
}
fn subscribe_for_terminal_events(
terminal: &Entity<Terminal>,
window: &mut Window,
cx: &mut Context<TestView>,
) -> Vec<Subscription> {
let terminal_subscription =
cx.observe_in(terminal, window, |test_view, terminal, window, cx| {
test_view.notifies += 1;
cx.update_entity(&terminal, |terminal, cx| {
terminal.suppress_hyperlink_throttle_once = true;
terminal.sync(window, cx)
})
});
let terminal_events_subscription = cx.subscribe_in(
terminal,
window,
|test_view, terminal, event, window, cx| match event {
Event::Wakeup => {
test_view.wakeups += 1;
cx.update_entity(terminal, |terminal, cx| {
terminal.suppress_hyperlink_throttle_once = true;
terminal.sync(window, cx)
})
}
_ => {}
},
);
vec![terminal_subscription, terminal_events_subscription]
}
}
struct Wakeups(usize);
struct Notifies(usize);
struct Expected(Wakeups, Notifies);
#[track_caller]
fn assert_wakeups_and_notifies(
Expected(Wakeups(wakeups), Notifies(notifies)): Expected,
test_view: &Entity<TestView>,
cx: &mut VisualTestContext,
) {
let (actual_wakeups, actual_notifies) = cx.update_entity(&test_view, |test_view, _| {
(
std::mem::take(&mut test_view.wakeups),
std::mem::take(&mut test_view.notifies),
)
});
assert_eq!(actual_wakeups, wakeups, "Mismatched wakeups");
assert_eq!(actual_notifies, notifies, "Mismatced notifies");
}
struct TestEntities {
terminal: Entity<Terminal>,
test_view: Entity<TestView>,
}
impl TestEntities {
fn new(terminal: &Entity<Terminal>, test_view: &Entity<TestView>) -> Self {
Self {
terminal: terminal.clone(),
test_view: test_view.clone(),
}
}
}
#[track_caller]
fn update_test_entities(
TestEntities {
terminal,
test_view,
}: &TestEntities,
cx: &mut VisualTestContext,
update: impl FnOnce(&mut HyperlinkVisualTestContext) -> Option<Expected>,
) {
let expected = cx.update_window_entity(terminal, |terminal, window, cx| {
update(&mut HyperlinkVisualTestContext::new(terminal, window, cx))
});
cx.run_until_parked();
if let Some(expected) = expected {
assert_wakeups_and_notifies(expected, test_view, cx)
}
}
async fn init_ctrl_hover_hyperlink_test_with_window(
cx: &mut TestAppContext,
) -> (TestEntities, HoveredWord, &mut VisualTestContext) {
let (terminal, cx) = init_terminal_test_with_window(cx, b"");
let test_view = cx.new_window_entity(|window, cx| TestView::new(&terminal, window, cx));
let test_entities = TestEntities::new(&terminal, &test_view);
let expected_hovered_word: HoveredWord = (ZED_DEV_STR, (0, 6)..=(0, 21), 0).into();
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
// Set initial expected hovered word
cx.write_output_lines(OUTPUT_ZED_DEV, 1);
Some(Expected(Wakeups(2), Notifies(0)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.assert_display_offset(0);
cx.assert_visible_lines_match(vec![OUTPUT_ZED_DEV]);
cx.set_window_secondary_key();
cx.ctrl_mouse_move_to(ZED_DEV_PT);
Some(Expected(Wakeups(0), Notifies(2)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.assert_hovered_word(Some(&expected_hovered_word));
None
});
(test_entities, expected_hovered_word, cx)
}
const OUTPUT_ZED_DEV: &str = "Visit https://zed.dev/ for more";
const OUTPUT_NONE: &str = "None";
const ZED_DEV_STR: &str = "https://zed.dev/";
const ZED_DEV_PT: GpuiPoint<Pixels> = point(px(30.0), px(2.5));
#[gpui::test]
async fn test_ctrl_hover_with_changing_content(cx: &mut TestAppContext) {
let (test_entities, mut expected_hovered_word, cx) =
init_ctrl_hover_hyperlink_test_with_window(cx).await;
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.write_output_lines(OUTPUT_ZED_DEV, 1);
Some(Expected(Wakeups(2), Notifies(0)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.assert_display_offset(0);
cx.assert_visible_lines_match(vec![OUTPUT_ZED_DEV, OUTPUT_ZED_DEV]);
// Existing hovered_word IS reused when viewport is static
cx.assert_hovered_word(Some(&expected_hovered_word));
cx.write_output_lines(OUTPUT_ZED_DEV, 8);
// Explicitly sync here, because the next sync will emit a wake up, which will
// trigger another sync, and we need to check in-between them.
cx.sync();
cx.assert_display_offset(0);
cx.assert_visible_lines_match(vec![
OUTPUT_ZED_DEV,
OUTPUT_ZED_DEV,
OUTPUT_ZED_DEV,
OUTPUT_ZED_DEV,
OUTPUT_ZED_DEV,
]);
// Existing hovered_word IS NOT reused when viewport is changing (total lines changed,
// but display offset did not have a corresponding change)
cx.assert_hovered_word(None);
// NOTE: 16 of these wakeups are from write_output() (2x8). The 17th is the important
// one we are testing that comes from sync() and results in the hovered word being
// set again, which we asserted below.
Some(Expected(Wakeups(17), Notifies(1)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.clear_window_secondary_key();
// ...AND new hovered_word is set if secondary was held.
expected_hovered_word = expected_hovered_word.with_id(expected_hovered_word.id + 1);
cx.assert_hovered_word(Some(&expected_hovered_word));
cx.set_window_secondary_key();
for _ in 0..5 {
cx.write_output_lines(OUTPUT_ZED_DEV, 1);
cx.write_output_lines(OUTPUT_NONE, 1);
}
Some(Expected(Wakeups(21), Notifies(1)))
});
for _ in 0..5 {
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
// Should not have a hovered word from previous iteration
cx.assert_hovered_word(None);
cx.write_output_lines(OUTPUT_ZED_DEV, 1);
// Explicitly sync here, because the next sync will emit a wake up, which will
// trigger another sync, and we need to check in-between them.
cx.sync();
cx.assert_hovered_word(None);
None
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
expected_hovered_word =
expected_hovered_word.with_id(expected_hovered_word.id + 1);
cx.assert_hovered_word(Some(&expected_hovered_word));
cx.write_output_lines(OUTPUT_NONE, 1);
// Explicitly sync here, because the next sync will emit a wake up, which will
// trigger another sync, and we need to check in-between them.
cx.sync();
cx.assert_hovered_word(None);
None
});
}
assert_wakeups_and_notifies(
Expected(Wakeups(30), Notifies(10)),
&test_entities.test_view,
cx,
);
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.scroll_up_by_and_sync(3);
// Existing hovered word IS NOT reused when scrolling
cx.assert_hovered_word(None);
cx.ctrl_mouse_move_to(ZED_DEV_PT);
Some(Expected(Wakeups(0), Notifies(2)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.assert_display_offset(3);
cx.assert_visible_lines_match(vec![
OUTPUT_ZED_DEV,
OUTPUT_NONE,
OUTPUT_ZED_DEV,
OUTPUT_NONE,
OUTPUT_ZED_DEV,
OUTPUT_NONE,
]);
None
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
expected_hovered_word =
expected_hovered_word.with_line_and_id(-3, expected_hovered_word.id + 2);
cx.assert_hovered_word(Some(&expected_hovered_word));
// Use an odd number of lines so the previous grid coordinate contains
// OUTPUT_NONE after the scrollback coordinates shift.
cx.write_output_lines(OUTPUT_ZED_DEV, 1);
// All wakeups here are from write_output()
Some(Expected(Wakeups(2), Notifies(0)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.assert_display_offset(4);
cx.assert_visible_lines_match(vec![
OUTPUT_ZED_DEV,
OUTPUT_NONE,
OUTPUT_ZED_DEV,
OUTPUT_NONE,
OUTPUT_ZED_DEV,
OUTPUT_NONE,
]);
// Existing hovered word IS reused (and adjusted) when total lines changed, but
// visible lines unchanged
expected_hovered_word = expected_hovered_word.with_line(-4);
cx.assert_hovered_word(Some(&expected_hovered_word));
None
});
}
#[gpui::test]
async fn test_ctrl_hover_with_changing_bounds(cx: &mut TestAppContext) {
let (test_entities, mut expected_hovered_word, cx) =
init_ctrl_hover_hyperlink_test_with_window(cx).await;
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.set_size_and_sync(TerminalBounds::new(
px(5.0),
px(5.0),
Bounds {
origin: GpuiPoint::default(),
size: Size {
width: px(161.8),
height: px(61.8),
},
},
));
cx.assert_display_offset(0);
cx.assert_visible_lines_match(vec![OUTPUT_ZED_DEV]);
// Existing hovered word IS NOT reused when bounds change
cx.assert_hovered_word(None);
Some(Expected(Wakeups(1), Notifies(2)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.clear_window_secondary_key();
// ...AND new hovered_word is set if secondary was held.
expected_hovered_word = expected_hovered_word.with_id(expected_hovered_word.id + 1);
cx.assert_hovered_word(Some(&expected_hovered_word));
Some(Expected(Wakeups(0), Notifies(0)))
});
}
#[gpui::test]
async fn test_ctrl_hover_with_modifier_change_only(cx: &mut TestAppContext) {
let (test_entities, mut expected_hovered_word, cx) =
init_ctrl_hover_hyperlink_test_with_window(cx).await;
for _ in 0..10 {
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
cx.try_modifiers_change(default());
Some(Expected(Wakeups(0), Notifies(0)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
// Existing hovered_word cleared when secondary not held
cx.assert_hovered_word(None);
cx.try_modifiers_change(Modifiers::secondary_key());
Some(Expected(Wakeups(0), Notifies(2)))
});
update_test_entities(&test_entities, cx, |cx: &mut HyperlinkVisualTestContext| {
expected_hovered_word =
expected_hovered_word.with_id(expected_hovered_word.id + 1);
// hovered_word set when secondary is held
cx.assert_hovered_word(Some(&expected_hovered_word));
None
});
}
}
}
/// Polls the terminal content until `expected` appears, or panics after ~1s.
/// The PTY IO thread writes into the terminal grid independently of the
/// GPUI executor, so we need a real-time polling loop to synchronize.
async fn assert_content_eventually(
terminal: &Entity<Terminal>,
expected: &str,
cx: &mut TestAppContext,
) {
let mut content = String::new();
for _ in 0..100 {
content = terminal.update(cx, |term, _| term.get_content());
if content.contains(expected) {
return;
}
cx.background_executor
.timer(Duration::from_millis(10))
.await;
}
panic!("Expected terminal content to contain {expected:?}, got: {content}");
}
#[cfg(unix)]
async fn assert_foreground_process_command_eventually(
terminal: &Entity<Terminal>,
expected: &str,
cx: &mut TestAppContext,
) {
let mut command_name = None;
for _ in 0..100 {
terminal.update(cx, |terminal, _| {
if let TerminalType::Pty { info, .. } = &terminal.terminal_type {
info.load_for_test();
}
});
command_name =
terminal.update(cx, |terminal, _| terminal.foreground_process_command_name());
if command_name.as_deref() == Some(expected) {
return;
}
cx.background_executor
.timer(Duration::from_millis(10))
.await;
}
let process_info = terminal.update(cx, |terminal, _| match &terminal.terminal_type {
TerminalType::Pty { info, .. } => format!(
"pid={:?}, fallback_pid={:?}, has_current_info={}",
info.pid(),
info.pid_getter().fallback_pid(),
info.current.read().is_some()
),
TerminalType::DisplayOnly => "display-only".to_string(),
});
panic!(
"Expected foreground process command name to be {expected:?}, got {command_name:?}; process info: {process_info:?}"
);
}
/// Test that kill_active_task properly terminates both the foreground process
/// and the shell, allowing wait_for_completed_task to complete and output to be captured.
#[cfg(unix)]
#[gpui::test]
async fn test_kill_active_task_completes_and_captures_output(cx: &mut TestAppContext) {
cx.executor().allow_parking();
// Run a command that prints output then sleeps for a long time
// The echo ensures we have output to capture before killing
let (terminal, completion_rx) =
build_test_terminal(cx, "echo", &["test_output_before_kill; sleep 60"]).await;
assert_content_eventually(&terminal, "test_output_before_kill", cx).await;
// Kill the active task
terminal.update(cx, |term, _cx| {
term.kill_active_task();
});
// wait_for_completed_task should complete within a reasonable time (not hang)
let completion_result = completion_rx.recv().await;
assert!(
completion_result.is_ok(),
"wait_for_completed_task should complete after kill_active_task, but it timed out"
);
// The exit status should indicate the process was killed (not a clean exit)
let exit_status = completion_result.unwrap();
assert!(
exit_status.is_some(),
"Should have received an exit status after killing"
);
// Verify that output captured before killing is still available
let content = terminal.update(cx, |term, _| term.get_content());
assert!(
content.contains("test_output_before_kill"),
"Output from before kill should be captured, got: {content}"
);
}
/// Test that kill_active_task on a task that's not running is a no-op
#[gpui::test]
async fn test_kill_active_task_on_completed_task_is_noop(cx: &mut TestAppContext) {
cx.executor().allow_parking();
// Run a command that exits immediately
let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["done"]).await;
// Wait for the command to complete naturally
let exit_status = completion_rx
.recv()
.await
.expect("Should receive exit status");
assert_eq!(exit_status, Some(ExitStatus::default()));
assert_content_eventually(&terminal, "done", cx).await;
// Now try to kill - should be a no-op since task already completed
terminal.update(cx, |term, _cx| {
term.kill_active_task();
});
// Content should still be there
let content = terminal.update(cx, |term, _| term.get_content());
assert!(
content.contains("done"),
"Output should still be present after no-op kill, got: {content}"
);
}
mod perf {
use super::{super::*, init_terminal_test_with_window};
use gpui::{ScrollDelta, ScrollWheelEvent, TestAppContext, VisualContext, point};
use util::default;
use util_macros::perf;
#[perf]
#[gpui::test]
async fn scroll_long_line_benchmark(cx: &mut TestAppContext) {
let (terminal, cx) =
init_terminal_test_with_window(cx, "long line ".repeat(1000).as_bytes());
let wobble = point(FIND_HYPERLINK_THROTTLE_PX, px(0.0));
let mut scroll_by = |lines: i32| {
cx.update_window_entity(&terminal, |terminal, window, cx| {
let bounds = terminal.last_content.terminal_bounds.bounds;
let center = bounds.origin + bounds.center();
let position = center + wobble * lines as f32;
terminal.mouse_move(
&MouseMoveEvent {
position,
..default()
},
cx,
);
terminal.scroll_wheel(
&ScrollWheelEvent {
position,
delta: ScrollDelta::Lines(GpuiPoint::new(0.0, lines as f32)),
..default()
},
1.0,
);
assert!(
terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::Scroll(_))),
"Should have Scroll event when scrolling within terminal bounds"
);
terminal.sync(window, cx);
});
};
for _ in 0..20000 {
scroll_by(1);
scroll_by(-1);
}
}
#[test]
fn test_num_lines_float_precision() {
let line_heights = [
20.1f32, 16.7, 18.3, 22.9, 14.1, 15.6, 17.8, 19.4, 21.3, 23.7,
];
for &line_height in &line_heights {
for n in 1..=100 {
let height = n as f32 * line_height;
let bounds = TerminalBounds::new(
px(line_height),
px(8.0),
Bounds {
origin: GpuiPoint::default(),
size: Size {
width: px(800.0),
height: px(height),
},
},
);
assert_eq!(
bounds.num_lines(),
n,
"num_lines() should be {n} for height={height}, line_height={line_height}"
);
}
}
}
#[test]
fn test_num_columns_float_precision() {
let cell_widths = [8.1f32, 7.3, 9.7, 6.9, 10.1];
for &cell_width in &cell_widths {
for n in 1..=200 {
let width = n as f32 * cell_width;
let bounds = TerminalBounds::new(
px(20.0),
px(cell_width),
Bounds {
origin: GpuiPoint::default(),
size: Size {
width: px(width),
height: px(400.0),
},
},
);
assert_eq!(
bounds.num_columns(),
n,
"num_columns() should be {n} for width={width}, cell_width={cell_width}"
);
}
}
}
}
fn make_display_only_terminal() -> Terminal {
let dispatcher = gpui::TestDispatcher::new(rand::random());
let executor = gpui::BackgroundExecutor::new(std::sync::Arc::new(dispatcher));
TerminalBuilder::new_display_only(
SettingsCursorShape::default(),
AlternateScroll::On,
None,
0,
&executor,
PathStyle::local(),
)
.terminal
}
#[test]
fn test_cwd_at_line_empty_history_returns_none() {
let terminal = make_display_only_terminal();
assert_eq!(terminal.cwd_at_line(0, 0), None);
}
#[test]
fn test_cwd_at_line_returns_cwd_for_line_at_or_after_recorded_position() {
let mut terminal = make_display_only_terminal();
let working_directory_a = PathBuf::from("/home/user/project_a");
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 5,
working_directory: working_directory_a.clone(),
});
// click_pos = history_size(5) + line(3) = 8 >= 5
assert_eq!(
terminal.cwd_at_line(3, 5),
Some(working_directory_a.clone())
);
// click_pos = history_size(5) + line(0) = 5 == 5 (exact match)
assert_eq!(terminal.cwd_at_line(0, 5), Some(working_directory_a));
}
#[test]
fn test_cwd_at_line_ignores_history_at_scrollback_cap() {
let mut terminal = make_display_only_terminal();
terminal.term_config.scrolling_history = 10;
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 0,
working_directory: PathBuf::from("/stale/cwd"),
});
assert_eq!(terminal.cwd_at_line(-5, 10), None);
}
#[test]
fn test_cwd_at_line_returns_none_when_line_is_before_any_recorded_cwd() {
let mut terminal = make_display_only_terminal();
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 10,
working_directory: PathBuf::from("/home/user/project_a"),
});
// click_pos = 0 + 3 = 3 < 10, no match, falls back to working_directory (None)
assert_eq!(terminal.cwd_at_line(3, 0), None);
}
#[test]
fn test_cwd_at_line_selects_most_recent_cwd_before_click() {
let mut terminal = make_display_only_terminal();
let working_directory_a = PathBuf::from("/home/user/project_a");
let working_directory_b = PathBuf::from("/home/user/project_b");
let working_directory_c = PathBuf::from("/home/user/project_c");
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 0,
working_directory: working_directory_a.clone(),
});
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 10,
working_directory: working_directory_b.clone(),
});
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 20,
working_directory: working_directory_c.clone(),
});
// click_pos=5: between 0 and 10, working_directory_a
assert_eq!(terminal.cwd_at_line(5, 0), Some(working_directory_a));
// click_pos=15: between 10 and 20, working_directory_b
assert_eq!(terminal.cwd_at_line(15, 0), Some(working_directory_b));
// click_pos=25: after 20, working_directory_c
assert_eq!(terminal.cwd_at_line(25, 0), Some(working_directory_c));
}
#[test]
fn test_record_cwd_change_stores_entry_at_current_cursor_position() {
let mut terminal = make_display_only_terminal();
let working_directory = PathBuf::from("/tmp/test");
terminal.record_cwd_change(working_directory.clone());
assert_eq!(terminal.cwd_history.len(), 1);
let entry = &terminal.cwd_history[0];
assert_eq!(entry.scrollback_position, 0);
assert_eq!(entry.working_directory, working_directory);
}
#[test]
fn test_record_cwd_change_uses_command_boundary() {
let mut terminal = make_display_only_terminal();
terminal.write_input(b"\r".to_vec());
assert_eq!(terminal.pending_cwd_boundary, Some(0));
let working_directory = PathBuf::from("/tmp/test");
terminal.record_cwd_change(working_directory.clone());
assert_eq!(terminal.pending_cwd_boundary, None);
assert_eq!(
terminal.cwd_history,
vec![CwdHistoryEntry {
scrollback_position: 0,
working_directory,
}]
);
}
#[test]
fn test_remote_terminal_does_not_record_local_cwd() {
let mut terminal = make_display_only_terminal();
terminal.is_remote_terminal = true;
terminal.write_input(b"\r".to_vec());
terminal.record_cwd_change(PathBuf::from("/local/ssh/cwd"));
assert_eq!(terminal.pending_cwd_boundary, None);
assert!(terminal.cwd_history.is_empty());
assert_eq!(terminal.cwd_at_line(0, 0), None);
}
#[test]
fn test_reset_cwd_history_discards_stale_coordinates() {
let mut terminal = make_display_only_terminal();
terminal.cwd_history.push(CwdHistoryEntry {
scrollback_position: 42,
working_directory: PathBuf::from("/tmp/test"),
});
terminal.pending_cwd_boundary = Some(43);
terminal.reset_cwd_history();
assert!(terminal.cwd_history.is_empty());
assert_eq!(terminal.pending_cwd_boundary, None);
}
}