1pub mod mappings;
2
3pub use alacritty_terminal;
4
5mod pty_info;
6mod terminal_hyperlinks;
7pub mod terminal_settings;
8
9#[cfg(unix)]
10pub use sandbox::sandbox_exec_main;
11
12use alacritty_terminal::{
13 Term,
14 event::{Event as AlacTermEvent, EventListener, Notify, WindowSize},
15 event_loop::{EventLoop, Msg, Notifier},
16 grid::{Dimensions, Grid, Row, Scroll as AlacScroll},
17 index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint},
18 selection::{Selection, SelectionRange, SelectionType},
19 sync::FairMutex,
20 term::{
21 Config, RenderableCursor, TermMode,
22 cell::{Cell, Flags},
23 search::{Match, RegexIter, RegexSearch},
24 },
25 tty::{self},
26 vi_mode::{ViModeCursor, ViMotion},
27 vte::ansi::{
28 ClearMode, CursorStyle as AlacCursorStyle, Handler, NamedPrivateMode, PrivateMode,
29 },
30};
31use anyhow::{Context as _, Result, bail};
32use log::trace;
33
34use futures::{
35 FutureExt,
36 channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded},
37};
38
39use itertools::Itertools as _;
40use mappings::mouse::{
41 alt_scroll, grid_point, grid_point_and_side, mouse_button_report, mouse_moved_report,
42 scroll_report,
43};
44
45use collections::{HashMap, VecDeque};
46use futures::StreamExt;
47use pty_info::{ProcessIdGetter, PtyProcessInfo};
48use serde::{Deserialize, Serialize};
49use settings::Settings;
50use smol::channel::{Receiver, Sender};
51use task::{HideStrategy, Shell, SpawnInTerminal};
52use terminal_hyperlinks::RegexSearches;
53use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
54use theme::{ActiveTheme, Theme};
55use urlencoding;
56use util::{paths::PathStyle, truncate_and_trailoff};
57
58#[cfg(unix)]
59use std::os::unix::process::ExitStatusExt;
60use std::{
61 borrow::Cow,
62 cmp::{self, min},
63 fmt::Display,
64 ops::{Deref, RangeInclusive},
65 path::PathBuf,
66 process::ExitStatus,
67 sync::Arc,
68 time::{Duration, Instant},
69};
70use thiserror::Error;
71
72use gpui::{
73 App, AppContext as _, BackgroundExecutor, Bounds, ClipboardItem, Context, EventEmitter, Hsla,
74 Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
75 Rgba, ScrollWheelEvent, Size, Task, TouchPhase, Window, actions, black, px,
76};
77
78use crate::mappings::{colors::to_alac_rgb, keys::to_esc_str};
79
80actions!(
81 terminal,
82 [
83 /// Clears the terminal screen.
84 Clear,
85 /// Copies selected text to the clipboard.
86 Copy,
87 /// Pastes from the clipboard.
88 Paste,
89 /// Shows the character palette for special characters.
90 ShowCharacterPalette,
91 /// Searches for text in the terminal.
92 SearchTest,
93 /// Scrolls up by one line.
94 ScrollLineUp,
95 /// Scrolls down by one line.
96 ScrollLineDown,
97 /// Scrolls up by one page.
98 ScrollPageUp,
99 /// Scrolls down by one page.
100 ScrollPageDown,
101 /// Scrolls up by half a page.
102 ScrollHalfPageUp,
103 /// Scrolls down by half a page.
104 ScrollHalfPageDown,
105 /// Scrolls to the top of the terminal buffer.
106 ScrollToTop,
107 /// Scrolls to the bottom of the terminal buffer.
108 ScrollToBottom,
109 /// Toggles vi mode in the terminal.
110 ToggleViMode,
111 /// Selects all text in the terminal.
112 SelectAll,
113 ]
114);
115
116const DEBUG_TERMINAL_WIDTH: Pixels = px(500.);
117const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.);
118const DEBUG_CELL_WIDTH: Pixels = px(5.);
119const DEBUG_LINE_HEIGHT: Pixels = px(5.);
120
121/// Inserts Zed-specific environment variables for terminal sessions.
122/// Used by both local terminals and remote terminals (via SSH).
123pub fn insert_zed_terminal_env(
124 env: &mut HashMap<String, String>,
125 version: &impl std::fmt::Display,
126) {
127 env.insert("ZED_TERM".to_string(), "true".to_string());
128 env.insert("TERM_PROGRAM".to_string(), "zed".to_string());
129 env.insert("TERM".to_string(), "xterm-256color".to_string());
130 env.insert("COLORTERM".to_string(), "truecolor".to_string());
131 env.insert("TERM_PROGRAM_VERSION".to_string(), version.to_string());
132}
133
134///Upward flowing events, for changing the title and such
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub enum Event {
137 TitleChanged,
138 BreadcrumbsChanged,
139 CloseTerminal,
140 Bell,
141 Wakeup,
142 BlinkChanged(bool),
143 SelectionsChanged,
144 NewNavigationTarget(Option<MaybeNavigationTarget>),
145 Open(MaybeNavigationTarget),
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct PathLikeTarget {
150 /// File system path, absolute or relative, existing or not.
151 /// Might have line and column number(s) attached as `file.rs:1:23`
152 pub maybe_path: String,
153 /// Current working directory of the terminal
154 pub terminal_dir: Option<PathBuf>,
155}
156
157/// A string inside terminal, potentially useful as a URI that can be opened.
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub enum MaybeNavigationTarget {
160 /// HTTP, git, etc. string determined by the `URL_REGEX` regex.
161 Url(String),
162 /// File system path, absolute or relative, existing or not.
163 /// Might have line and column number(s) attached as `file.rs:1:23`
164 PathLike(PathLikeTarget),
165}
166
167#[derive(Clone)]
168enum InternalEvent {
169 Resize(TerminalBounds),
170 Clear,
171 // FocusNextMatch,
172 Scroll(AlacScroll),
173 ScrollToAlacPoint(AlacPoint),
174 SetSelection(Option<(Selection, AlacPoint)>),
175 UpdateSelection(Point<Pixels>),
176 FindHyperlink(Point<Pixels>, bool),
177 ProcessHyperlink((String, bool, Match), bool),
178 // Whether keep selection when copy
179 Copy(Option<bool>),
180 // Vi mode events
181 ToggleViMode,
182 ViMotion(ViMotion),
183 MoveViCursorToAlacPoint(AlacPoint),
184}
185
186///A translation struct for Alacritty to communicate with us from their event loop
187#[derive(Clone)]
188pub struct ZedListener(pub UnboundedSender<AlacTermEvent>);
189
190impl EventListener for ZedListener {
191 fn send_event(&self, event: AlacTermEvent) {
192 self.0.unbounded_send(event).ok();
193 }
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub struct TerminalBounds {
198 pub cell_width: Pixels,
199 pub line_height: Pixels,
200 pub bounds: Bounds<Pixels>,
201}
202
203impl TerminalBounds {
204 pub fn new(line_height: Pixels, cell_width: Pixels, bounds: Bounds<Pixels>) -> Self {
205 TerminalBounds {
206 cell_width,
207 line_height,
208 bounds,
209 }
210 }
211
212 pub fn num_lines(&self) -> usize {
213 (self.bounds.size.height / self.line_height).floor() as usize
214 }
215
216 pub fn num_columns(&self) -> usize {
217 (self.bounds.size.width / self.cell_width).floor() as usize
218 }
219
220 pub fn height(&self) -> Pixels {
221 self.bounds.size.height
222 }
223
224 pub fn width(&self) -> Pixels {
225 self.bounds.size.width
226 }
227
228 pub fn cell_width(&self) -> Pixels {
229 self.cell_width
230 }
231
232 pub fn line_height(&self) -> Pixels {
233 self.line_height
234 }
235}
236
237impl Default for TerminalBounds {
238 fn default() -> Self {
239 TerminalBounds::new(
240 DEBUG_LINE_HEIGHT,
241 DEBUG_CELL_WIDTH,
242 Bounds {
243 origin: Point::default(),
244 size: Size {
245 width: DEBUG_TERMINAL_WIDTH,
246 height: DEBUG_TERMINAL_HEIGHT,
247 },
248 },
249 )
250 }
251}
252
253impl From<TerminalBounds> for WindowSize {
254 fn from(val: TerminalBounds) -> Self {
255 WindowSize {
256 num_lines: val.num_lines() as u16,
257 num_cols: val.num_columns() as u16,
258 cell_width: f32::from(val.cell_width()) as u16,
259 cell_height: f32::from(val.line_height()) as u16,
260 }
261 }
262}
263
264impl Dimensions for TerminalBounds {
265 /// Note: this is supposed to be for the back buffer's length,
266 /// but we exclusively use it to resize the terminal, which does not
267 /// use this method. We still have to implement it for the trait though,
268 /// hence, this comment.
269 fn total_lines(&self) -> usize {
270 self.screen_lines()
271 }
272
273 fn screen_lines(&self) -> usize {
274 self.num_lines()
275 }
276
277 fn columns(&self) -> usize {
278 self.num_columns()
279 }
280}
281
282#[derive(Error, Debug)]
283pub struct TerminalError {
284 pub directory: Option<PathBuf>,
285 pub program: Option<String>,
286 pub args: Option<Vec<String>>,
287 pub title_override: Option<String>,
288 pub source: std::io::Error,
289}
290
291impl TerminalError {
292 pub fn fmt_directory(&self) -> String {
293 self.directory
294 .clone()
295 .map(|path| {
296 match path
297 .into_os_string()
298 .into_string()
299 .map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
300 {
301 Ok(s) => s,
302 Err(s) => s,
303 }
304 })
305 .unwrap_or_else(|| "<none specified>".to_string())
306 }
307
308 pub fn fmt_shell(&self) -> String {
309 if let Some(title_override) = &self.title_override {
310 format!(
311 "{} {} ({})",
312 self.program.as_deref().unwrap_or("<system defined shell>"),
313 self.args.as_ref().into_iter().flatten().format(" "),
314 title_override
315 )
316 } else {
317 format!(
318 "{} {}",
319 self.program.as_deref().unwrap_or("<system defined shell>"),
320 self.args.as_ref().into_iter().flatten().format(" ")
321 )
322 }
323 }
324}
325
326impl Display for TerminalError {
327 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 let dir_string: String = self.fmt_directory();
329 let shell = self.fmt_shell();
330
331 write!(
332 f,
333 "Working directory: {} Shell command: `{}`, IOError: {}",
334 dir_string, shell, self.source
335 )
336 }
337}
338
339// https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213
340const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000;
341pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
342
343pub struct TerminalBuilder {
344 terminal: Terminal,
345 events_rx: UnboundedReceiver<AlacTermEvent>,
346}
347
348impl TerminalBuilder {
349 pub fn new_display_only(
350 cursor_shape: CursorShape,
351 alternate_scroll: AlternateScroll,
352 max_scroll_history_lines: Option<usize>,
353 window_id: u64,
354 background_executor: &BackgroundExecutor,
355 path_style: PathStyle,
356 ) -> Result<TerminalBuilder> {
357 // Create a display-only terminal (no actual PTY).
358 let default_cursor_style = AlacCursorStyle::from(cursor_shape);
359 let scrolling_history = max_scroll_history_lines
360 .unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
361 .min(MAX_SCROLL_HISTORY_LINES);
362 let config = Config {
363 scrolling_history,
364 default_cursor_style,
365 ..Config::default()
366 };
367
368 let (events_tx, events_rx) = unbounded();
369 let mut term = Term::new(
370 config.clone(),
371 &TerminalBounds::default(),
372 ZedListener(events_tx),
373 );
374
375 if let AlternateScroll::Off = alternate_scroll {
376 term.unset_private_mode(PrivateMode::Named(NamedPrivateMode::AlternateScroll));
377 }
378
379 let term = Arc::new(FairMutex::new(term));
380
381 let terminal = Terminal {
382 task: None,
383 terminal_type: TerminalType::DisplayOnly,
384 completion_tx: None,
385 term,
386 term_config: config,
387 title_override: None,
388 events: VecDeque::with_capacity(10),
389 last_content: Default::default(),
390 last_mouse: None,
391 matches: Vec::new(),
392
393 selection_head: None,
394 breadcrumb_text: String::new(),
395 scroll_px: px(0.),
396 next_link_id: 0,
397 selection_phase: SelectionPhase::Ended,
398 hyperlink_regex_searches: RegexSearches::default(),
399 vi_mode_enabled: false,
400 is_remote_terminal: false,
401 last_mouse_move_time: Instant::now(),
402 last_hyperlink_search_position: None,
403 mouse_down_hyperlink: None,
404 #[cfg(windows)]
405 shell_program: None,
406 activation_script: Vec::new(),
407 template: CopyTemplate {
408 shell: Shell::System,
409 env: HashMap::default(),
410 cursor_shape,
411 alternate_scroll,
412 max_scroll_history_lines,
413 path_hyperlink_regexes: Vec::default(),
414 path_hyperlink_timeout_ms: 0,
415 window_id,
416 },
417 child_exited: None,
418 event_loop_task: Task::ready(Ok(())),
419 background_executor: background_executor.clone(),
420 path_style,
421 #[cfg(unix)]
422 session_tracker: None,
423 #[cfg(any(test, feature = "test-support"))]
424 input_log: Vec::new(),
425 };
426
427 Ok(TerminalBuilder {
428 terminal,
429 events_rx,
430 })
431 }
432
433 pub fn new(
434 working_directory: Option<PathBuf>,
435 task: Option<TaskState>,
436 shell: Shell,
437 mut env: HashMap<String, String>,
438 cursor_shape: CursorShape,
439 alternate_scroll: AlternateScroll,
440 max_scroll_history_lines: Option<usize>,
441 path_hyperlink_regexes: Vec<String>,
442 path_hyperlink_timeout_ms: u64,
443 is_remote_terminal: bool,
444 window_id: u64,
445 completion_tx: Option<Sender<Option<ExitStatus>>>,
446 cx: &App,
447 activation_script: Vec<String>,
448 path_style: PathStyle,
449 sandbox_config: Option<terminal_settings::SandboxConfig>,
450 ) -> Task<Result<TerminalBuilder>> {
451 let version = release_channel::AppVersion::global(cx);
452 let background_executor = cx.background_executor().clone();
453 let fut = async move {
454 // Remove SHLVL so the spawned shell initializes it to 1, matching
455 // the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty.
456 env.remove("SHLVL");
457
458 // If the parent environment doesn't have a locale set
459 // (As is the case when launched from a .app on MacOS),
460 // and the Project doesn't have a locale set, then
461 // set a fallback for our child environment to use.
462 if std::env::var("LANG").is_err() {
463 env.entry("LANG".to_string())
464 .or_insert_with(|| "en_US.UTF-8".to_string());
465 }
466
467 insert_zed_terminal_env(&mut env, &version);
468
469 #[derive(Default)]
470 struct ShellParams {
471 program: String,
472 args: Option<Vec<String>>,
473 title_override: Option<String>,
474 }
475
476 impl ShellParams {
477 fn new(
478 program: String,
479 args: Option<Vec<String>>,
480 title_override: Option<String>,
481 ) -> Self {
482 log::debug!("Using {program} as shell");
483 Self {
484 program,
485 args,
486 title_override,
487 }
488 }
489 }
490
491 let shell_params = match shell.clone() {
492 Shell::System => {
493 if cfg!(windows) {
494 Some(ShellParams::new(
495 util::shell::get_windows_system_shell(),
496 None,
497 None,
498 ))
499 } else {
500 None
501 }
502 }
503 Shell::Program(program) => Some(ShellParams::new(program, None, None)),
504 Shell::WithArguments {
505 program,
506 args,
507 title_override,
508 } => Some(ShellParams::new(program, Some(args), title_override)),
509 };
510 let terminal_title_override =
511 shell_params.as_ref().and_then(|e| e.title_override.clone());
512
513 #[cfg(windows)]
514 let shell_program = shell_params.as_ref().map(|params| {
515 use util::ResultExt;
516
517 Self::resolve_path(¶ms.program)
518 .log_err()
519 .unwrap_or(params.program.clone())
520 });
521
522 // Note: when remoting, this shell_kind will scrutinize `ssh` or
523 // `wsl.exe` as a shell and fall back to posix or powershell based on
524 // the compilation target. This is fine right now due to the restricted
525 // way we use the return value, but would become incorrect if we
526 // supported remoting into windows.
527 let shell_kind = shell.shell_kind(cfg!(windows));
528
529 let alac_shell = shell_params.as_ref().map(|params| {
530 alacritty_terminal::tty::Shell::new(
531 params.program.clone(),
532 params.args.clone().unwrap_or_default(),
533 )
534 });
535
536 // Always wrap the shell with the Zed binary invoked as
537 // `--sandbox-exec <config> -- <shell> [args...]` on Unix.
538 // This enables process lifetime tracking (via Seatbelt
539 // fingerprinting on macOS, cgroups on Linux) even when no
540 // sandbox restrictions are configured.
541 #[cfg(unix)]
542 let (alac_shell, session_tracker) = {
543 // In test mode, the current exe is the test binary, not Zed,
544 // so the --sandbox-exec wrapper cannot be used.
545 #[cfg(not(any(test, feature = "test-support")))]
546 let session_tracker = match sandbox::SessionTracker::new() {
547 Ok(tracker) => Some(tracker),
548 Err(err) => {
549 log::warn!("Failed to create session tracker: {err}");
550 None
551 }
552 };
553 #[cfg(any(test, feature = "test-support"))]
554 let session_tracker: Option<sandbox::SessionTracker> = None;
555
556 let shell = if session_tracker.is_some() || sandbox_config.is_some() {
557 let mut exec_config = if let Some(ref sandbox) = sandbox_config {
558 sandbox::SandboxExecConfig::from_sandbox_config(sandbox)
559 } else {
560 sandbox::SandboxExecConfig {
561 project_dir: working_directory
562 .as_ref()
563 .map(|p| p.to_string_lossy().into_owned())
564 .unwrap_or_else(|| ".".to_string()),
565 executable_paths: Vec::new(),
566 read_only_paths: Vec::new(),
567 read_write_paths: Vec::new(),
568 additional_executable_paths: Vec::new(),
569 additional_read_only_paths: Vec::new(),
570 additional_read_write_paths: Vec::new(),
571 allow_network: true,
572 allowed_env_vars: Vec::new(),
573 fingerprint_uuid: None,
574 tracking_only: true,
575 cgroup_path: None,
576 }
577 };
578
579 if let Some(ref tracker) = session_tracker {
580 exec_config.fingerprint_uuid = tracker.fingerprint_uuid();
581 exec_config.cgroup_path = tracker.cgroup_path();
582 if sandbox_config.is_none() {
583 exec_config.tracking_only = true;
584 }
585 }
586
587 let config_json = exec_config.to_json();
588 let zed_binary = std::env::current_exe()
589 .map_err(|e| anyhow::anyhow!("failed to get current exe: {e}"))?;
590
591 let mut args =
592 vec!["--sandbox-exec".to_string(), config_json, "--".to_string()];
593
594 if let Some(ref params) = shell_params {
595 args.push(params.program.clone());
596 if let Some(ref shell_args) = params.args {
597 args.extend(shell_args.clone());
598 }
599 } else {
600 let system_shell = util::get_default_system_shell();
601 args.push(system_shell);
602 args.push("-l".to_string());
603 }
604
605 Some(alacritty_terminal::tty::Shell::new(
606 zed_binary.to_string_lossy().into_owned(),
607 args,
608 ))
609 } else {
610 alac_shell
611 };
612
613 (shell, session_tracker)
614 };
615
616 let pty_options = alacritty_terminal::tty::Options {
617 shell: alac_shell,
618 working_directory: working_directory.clone(),
619 drain_on_exit: true,
620 env: env.clone().into_iter().collect(),
621 #[cfg(windows)]
622 escape_args: shell_kind.tty_escape_args(),
623 };
624
625 let default_cursor_style = AlacCursorStyle::from(cursor_shape);
626 let scrolling_history = if task.is_some() {
627 // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
628 // After the task finishes, we do not allow appending to that terminal, so small tasks output should not
629 // cause excessive memory usage over time.
630 MAX_SCROLL_HISTORY_LINES
631 } else {
632 max_scroll_history_lines
633 .unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
634 .min(MAX_SCROLL_HISTORY_LINES)
635 };
636 let config = Config {
637 scrolling_history,
638 default_cursor_style,
639 ..Config::default()
640 };
641
642 //Setup the pty...
643 let pty = match tty::new(&pty_options, TerminalBounds::default().into(), window_id) {
644 Ok(pty) => pty,
645 Err(error) => {
646 bail!(TerminalError {
647 directory: working_directory,
648 program: shell_params.as_ref().map(|params| params.program.clone()),
649 args: shell_params.as_ref().and_then(|params| params.args.clone()),
650 title_override: terminal_title_override,
651 source: error,
652 });
653 }
654 };
655
656 //Spawn a task so the Alacritty EventLoop can communicate with us
657 //TODO: Remove with a bounded sender which can be dispatched on &self
658 let (events_tx, events_rx) = unbounded();
659 //Set up the terminal...
660 let mut term = Term::new(
661 config.clone(),
662 &TerminalBounds::default(),
663 ZedListener(events_tx.clone()),
664 );
665
666 //Alacritty defaults to alternate scrolling being on, so we just need to turn it off.
667 if let AlternateScroll::Off = alternate_scroll {
668 term.unset_private_mode(PrivateMode::Named(NamedPrivateMode::AlternateScroll));
669 }
670
671 let term = Arc::new(FairMutex::new(term));
672
673 let pty_info = PtyProcessInfo::new(&pty);
674
675 //And connect them together
676 let event_loop = EventLoop::new(
677 term.clone(),
678 ZedListener(events_tx),
679 pty,
680 pty_options.drain_on_exit,
681 false,
682 )
683 .context("failed to create event loop")?;
684
685 let pty_tx = event_loop.channel();
686 let _io_thread = event_loop.spawn(); // DANGER
687
688 let no_task = task.is_none();
689 let terminal = Terminal {
690 task,
691 terminal_type: TerminalType::Pty {
692 pty_tx: Notifier(pty_tx),
693 info: Arc::new(pty_info),
694 },
695 completion_tx,
696 term,
697 term_config: config,
698 title_override: terminal_title_override,
699 events: VecDeque::with_capacity(10), //Should never get this high.
700 last_content: Default::default(),
701 last_mouse: None,
702 matches: Vec::new(),
703
704 selection_head: None,
705 breadcrumb_text: String::new(),
706 scroll_px: px(0.),
707 next_link_id: 0,
708 selection_phase: SelectionPhase::Ended,
709 hyperlink_regex_searches: RegexSearches::new(
710 &path_hyperlink_regexes,
711 path_hyperlink_timeout_ms,
712 ),
713 vi_mode_enabled: false,
714 is_remote_terminal,
715 last_mouse_move_time: Instant::now(),
716 last_hyperlink_search_position: None,
717 mouse_down_hyperlink: None,
718 #[cfg(windows)]
719 shell_program,
720 activation_script: activation_script.clone(),
721 template: CopyTemplate {
722 shell,
723 env,
724 cursor_shape,
725 alternate_scroll,
726 max_scroll_history_lines,
727 path_hyperlink_regexes,
728 path_hyperlink_timeout_ms,
729 window_id,
730 },
731 child_exited: None,
732 event_loop_task: Task::ready(Ok(())),
733 background_executor,
734 path_style,
735 #[cfg(unix)]
736 session_tracker,
737 #[cfg(any(test, feature = "test-support"))]
738 input_log: Vec::new(),
739 };
740
741 if !activation_script.is_empty() && no_task {
742 for activation_script in activation_script {
743 terminal.write_to_pty(activation_script.into_bytes());
744 // Simulate enter key press
745 // NOTE(PowerShell): using `\r\n` will put PowerShell in a continuation mode (infamous >> character)
746 // and generally mess up the rendering.
747 terminal.write_to_pty(b"\x0d");
748 }
749 // In order to clear the screen at this point, we have two options:
750 // 1. We can send a shell-specific command such as "clear" or "cls"
751 // 2. We can "echo" a marker message that we will then catch when handling a Wakeup event
752 // and clear the screen using `terminal.clear()` method
753 // We cannot issue a `terminal.clear()` command at this point as alacritty is evented
754 // and while we have sent the activation script to the pty, it will be executed asynchronously.
755 // Therefore, we somehow need to wait for the activation script to finish executing before we
756 // can proceed with clearing the screen.
757 terminal.write_to_pty(shell_kind.clear_screen_command().as_bytes());
758 // Simulate enter key press
759 terminal.write_to_pty(b"\x0d");
760 }
761
762 Ok(TerminalBuilder {
763 terminal,
764 events_rx,
765 })
766 };
767 // the thread we spawn things on has an effect on signal handling
768 if !cfg!(target_os = "windows") {
769 cx.spawn(async move |_| fut.await)
770 } else {
771 cx.background_spawn(fut)
772 }
773 }
774
775 pub fn subscribe(mut self, cx: &Context<Terminal>) -> Terminal {
776 //Event loop
777 self.terminal.event_loop_task = cx.spawn(async move |terminal, cx| {
778 while let Some(event) = self.events_rx.next().await {
779 terminal.update(cx, |terminal, cx| {
780 //Process the first event immediately for lowered latency
781 terminal.process_event(event, cx);
782 })?;
783
784 'outer: loop {
785 let mut events = Vec::new();
786
787 #[cfg(any(test, feature = "test-support"))]
788 let mut timer = cx.background_executor().simulate_random_delay().fuse();
789 #[cfg(not(any(test, feature = "test-support")))]
790 let mut timer = cx
791 .background_executor()
792 .timer(std::time::Duration::from_millis(4))
793 .fuse();
794
795 let mut wakeup = false;
796 loop {
797 futures::select_biased! {
798 _ = timer => break,
799 event = self.events_rx.next() => {
800 if let Some(event) = event {
801 if matches!(event, AlacTermEvent::Wakeup) {
802 wakeup = true;
803 } else {
804 events.push(event);
805 }
806
807 if events.len() > 100 {
808 break;
809 }
810 } else {
811 break;
812 }
813 },
814 }
815 }
816
817 if events.is_empty() && !wakeup {
818 smol::future::yield_now().await;
819 break 'outer;
820 }
821
822 terminal.update(cx, |this, cx| {
823 if wakeup {
824 this.process_event(AlacTermEvent::Wakeup, cx);
825 }
826
827 for event in events {
828 this.process_event(event, cx);
829 }
830 })?;
831 smol::future::yield_now().await;
832 }
833 }
834 anyhow::Ok(())
835 });
836 self.terminal
837 }
838
839 #[cfg(windows)]
840 fn resolve_path(path: &str) -> Result<String> {
841 use windows::Win32::Storage::FileSystem::SearchPathW;
842 use windows::core::HSTRING;
843
844 let path = if path.starts_with(r"\\?\") || !path.contains(&['/', '\\']) {
845 path.to_string()
846 } else {
847 r"\\?\".to_string() + path
848 };
849
850 let required_length = unsafe { SearchPathW(None, &HSTRING::from(&path), None, None, None) };
851 let mut buf = vec![0u16; required_length as usize];
852 let size = unsafe { SearchPathW(None, &HSTRING::from(&path), None, Some(&mut buf), None) };
853
854 Ok(String::from_utf16(&buf[..size as usize])?)
855 }
856}
857
858#[derive(Debug, Clone, Deserialize, Serialize)]
859pub struct IndexedCell {
860 pub point: AlacPoint,
861 pub cell: Cell,
862}
863
864impl Deref for IndexedCell {
865 type Target = Cell;
866
867 #[inline]
868 fn deref(&self) -> &Cell {
869 &self.cell
870 }
871}
872
873// TODO: Un-pub
874#[derive(Clone)]
875pub struct TerminalContent {
876 pub cells: Vec<IndexedCell>,
877 pub mode: TermMode,
878 pub display_offset: usize,
879 pub selection_text: Option<String>,
880 pub selection: Option<SelectionRange>,
881 pub cursor: RenderableCursor,
882 pub cursor_char: char,
883 pub terminal_bounds: TerminalBounds,
884 pub last_hovered_word: Option<HoveredWord>,
885 pub scrolled_to_top: bool,
886 pub scrolled_to_bottom: bool,
887}
888
889#[derive(Debug, Clone, Eq, PartialEq)]
890pub struct HoveredWord {
891 pub word: String,
892 pub word_match: RangeInclusive<AlacPoint>,
893 pub id: usize,
894}
895
896impl Default for TerminalContent {
897 fn default() -> Self {
898 TerminalContent {
899 cells: Default::default(),
900 mode: Default::default(),
901 display_offset: Default::default(),
902 selection_text: Default::default(),
903 selection: Default::default(),
904 cursor: RenderableCursor {
905 shape: alacritty_terminal::vte::ansi::CursorShape::Block,
906 point: AlacPoint::new(Line(0), Column(0)),
907 },
908 cursor_char: Default::default(),
909 terminal_bounds: Default::default(),
910 last_hovered_word: None,
911 scrolled_to_top: false,
912 scrolled_to_bottom: false,
913 }
914 }
915}
916
917#[derive(PartialEq, Eq)]
918pub enum SelectionPhase {
919 Selecting,
920 Ended,
921}
922
923enum TerminalType {
924 Pty {
925 pty_tx: Notifier,
926 info: Arc<PtyProcessInfo>,
927 },
928 DisplayOnly,
929}
930
931pub struct Terminal {
932 terminal_type: TerminalType,
933 completion_tx: Option<Sender<Option<ExitStatus>>>,
934 term: Arc<FairMutex<Term<ZedListener>>>,
935 term_config: Config,
936 events: VecDeque<InternalEvent>,
937 /// This is only used for mouse mode cell change detection
938 last_mouse: Option<(AlacPoint, AlacDirection)>,
939 pub matches: Vec<RangeInclusive<AlacPoint>>,
940 pub last_content: TerminalContent,
941 pub selection_head: Option<AlacPoint>,
942
943 pub breadcrumb_text: String,
944 title_override: Option<String>,
945 scroll_px: Pixels,
946 next_link_id: usize,
947 selection_phase: SelectionPhase,
948 hyperlink_regex_searches: RegexSearches,
949 task: Option<TaskState>,
950 vi_mode_enabled: bool,
951 is_remote_terminal: bool,
952 last_mouse_move_time: Instant,
953 last_hyperlink_search_position: Option<Point<Pixels>>,
954 mouse_down_hyperlink: Option<(String, bool, Match)>,
955 #[cfg(windows)]
956 shell_program: Option<String>,
957 template: CopyTemplate,
958 activation_script: Vec<String>,
959 child_exited: Option<ExitStatus>,
960 event_loop_task: Task<Result<(), anyhow::Error>>,
961 background_executor: BackgroundExecutor,
962 path_style: PathStyle,
963 #[cfg(unix)]
964 session_tracker: Option<sandbox::SessionTracker>,
965 #[cfg(any(test, feature = "test-support"))]
966 input_log: Vec<Vec<u8>>,
967}
968
969struct CopyTemplate {
970 shell: Shell,
971 env: HashMap<String, String>,
972 cursor_shape: CursorShape,
973 alternate_scroll: AlternateScroll,
974 max_scroll_history_lines: Option<usize>,
975 path_hyperlink_regexes: Vec<String>,
976 path_hyperlink_timeout_ms: u64,
977 window_id: u64,
978}
979
980#[derive(Debug)]
981pub struct TaskState {
982 pub status: TaskStatus,
983 pub completion_rx: Receiver<Option<ExitStatus>>,
984 pub spawned_task: SpawnInTerminal,
985}
986
987/// A status of the current terminal tab's task.
988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
989pub enum TaskStatus {
990 /// The task had been started, but got cancelled or somehow otherwise it did not
991 /// report its exit code before the terminal event loop was shut down.
992 Unknown,
993 /// The task is started and running currently.
994 Running,
995 /// After the start, the task stopped running and reported its error code back.
996 Completed { success: bool },
997}
998
999impl TaskStatus {
1000 fn register_terminal_exit(&mut self) {
1001 if self == &Self::Running {
1002 *self = Self::Unknown;
1003 }
1004 }
1005
1006 fn register_task_exit(&mut self, error_code: i32) {
1007 *self = TaskStatus::Completed {
1008 success: error_code == 0,
1009 };
1010 }
1011}
1012
1013const FIND_HYPERLINK_THROTTLE_PX: Pixels = px(5.0);
1014
1015impl Terminal {
1016 fn process_event(&mut self, event: AlacTermEvent, cx: &mut Context<Self>) {
1017 match event {
1018 AlacTermEvent::Title(title) => {
1019 // ignore default shell program title change as windows always sends those events
1020 // and it would end up showing the shell executable path in breadcrumbs
1021 #[cfg(windows)]
1022 {
1023 if self
1024 .shell_program
1025 .as_ref()
1026 .map(|e| *e == title)
1027 .unwrap_or(false)
1028 {
1029 return;
1030 }
1031 }
1032
1033 self.breadcrumb_text = title;
1034 cx.emit(Event::BreadcrumbsChanged);
1035 }
1036 AlacTermEvent::ResetTitle => {
1037 self.breadcrumb_text = String::new();
1038 cx.emit(Event::BreadcrumbsChanged);
1039 }
1040 AlacTermEvent::ClipboardStore(_, data) => {
1041 cx.write_to_clipboard(ClipboardItem::new_string(data))
1042 }
1043 AlacTermEvent::ClipboardLoad(_, format) => {
1044 self.write_to_pty(
1045 match &cx.read_from_clipboard().and_then(|item| item.text()) {
1046 // The terminal only supports pasting strings, not images.
1047 Some(text) => format(text),
1048 _ => format(""),
1049 }
1050 .into_bytes(),
1051 )
1052 }
1053 AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.into_bytes()),
1054 AlacTermEvent::TextAreaSizeRequest(format) => {
1055 self.write_to_pty(format(self.last_content.terminal_bounds.into()).into_bytes())
1056 }
1057 AlacTermEvent::CursorBlinkingChange => {
1058 let terminal = self.term.lock();
1059 let blinking = terminal.cursor_style().blinking;
1060 cx.emit(Event::BlinkChanged(blinking));
1061 }
1062 AlacTermEvent::Bell => {
1063 cx.emit(Event::Bell);
1064 }
1065 AlacTermEvent::Exit => self.register_task_finished(Some(9), cx),
1066 AlacTermEvent::MouseCursorDirty => {
1067 //NOOP, Handled in render
1068 }
1069 AlacTermEvent::Wakeup => {
1070 cx.emit(Event::Wakeup);
1071
1072 if let TerminalType::Pty { info, .. } = &self.terminal_type {
1073 info.emit_title_changed_if_changed(cx);
1074 }
1075 }
1076 AlacTermEvent::ColorRequest(index, format) => {
1077 // It's important that the color request is processed here to retain relative order
1078 // with other PTY writes. Otherwise applications might witness out-of-order
1079 // responses to requests. For example: An application sending `OSC 11 ; ? ST`
1080 // (color request) followed by `CSI c` (request device attributes) would receive
1081 // the response to `CSI c` first.
1082 // Instead of locking, we could store the colors in `self.last_content`. But then
1083 // we might respond with out of date value if a "set color" sequence is immediately
1084 // followed by a color request sequence.
1085 let color = self.term.lock().colors()[index]
1086 .unwrap_or_else(|| to_alac_rgb(get_color_at_index(index, cx.theme().as_ref())));
1087 self.write_to_pty(format(color).into_bytes());
1088 }
1089 AlacTermEvent::ChildExit(raw_status) => {
1090 self.register_task_finished(Some(raw_status), cx);
1091 }
1092 }
1093 }
1094
1095 pub fn selection_started(&self) -> bool {
1096 self.selection_phase == SelectionPhase::Selecting
1097 }
1098
1099 fn process_terminal_event(
1100 &mut self,
1101 event: &InternalEvent,
1102 term: &mut Term<ZedListener>,
1103 window: &mut Window,
1104 cx: &mut Context<Self>,
1105 ) {
1106 match event {
1107 &InternalEvent::Resize(mut new_bounds) => {
1108 trace!("Resizing: new_bounds={new_bounds:?}");
1109 new_bounds.bounds.size.height =
1110 cmp::max(new_bounds.line_height, new_bounds.height());
1111 new_bounds.bounds.size.width = cmp::max(new_bounds.cell_width, new_bounds.width());
1112
1113 self.last_content.terminal_bounds = new_bounds;
1114
1115 if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
1116 pty_tx.0.send(Msg::Resize(new_bounds.into())).ok();
1117 }
1118
1119 term.resize(new_bounds);
1120 // If there are matches we need to emit a wake up event to
1121 // invalidate the matches and recalculate their locations
1122 // in the new terminal layout
1123 if !self.matches.is_empty() {
1124 cx.emit(Event::Wakeup);
1125 }
1126 }
1127 InternalEvent::Clear => {
1128 trace!("Clearing");
1129 // Clear back buffer
1130 term.clear_screen(ClearMode::Saved);
1131
1132 let cursor = term.grid().cursor.point;
1133
1134 // Clear the lines above
1135 term.grid_mut().reset_region(..cursor.line);
1136
1137 // Copy the current line up
1138 let line = term.grid()[cursor.line][..Column(term.grid().columns())]
1139 .iter()
1140 .cloned()
1141 .enumerate()
1142 .collect::<Vec<(usize, Cell)>>();
1143
1144 for (i, cell) in line {
1145 term.grid_mut()[Line(0)][Column(i)] = cell;
1146 }
1147
1148 // Reset the cursor
1149 term.grid_mut().cursor.point =
1150 AlacPoint::new(Line(0), term.grid_mut().cursor.point.column);
1151 let new_cursor = term.grid().cursor.point;
1152
1153 // Clear the lines below the new cursor
1154 if (new_cursor.line.0 as usize) < term.screen_lines() - 1 {
1155 term.grid_mut().reset_region((new_cursor.line + 1)..);
1156 }
1157
1158 cx.emit(Event::Wakeup);
1159 }
1160 InternalEvent::Scroll(scroll) => {
1161 trace!("Scrolling: scroll={scroll:?}");
1162 term.scroll_display(*scroll);
1163 self.refresh_hovered_word(window);
1164
1165 if self.vi_mode_enabled {
1166 match *scroll {
1167 AlacScroll::Delta(delta) => {
1168 term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, delta);
1169 }
1170 AlacScroll::PageUp => {
1171 let lines = term.screen_lines() as i32;
1172 term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, lines);
1173 }
1174 AlacScroll::PageDown => {
1175 let lines = -(term.screen_lines() as i32);
1176 term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, lines);
1177 }
1178 AlacScroll::Top => {
1179 let point = AlacPoint::new(term.topmost_line(), Column(0));
1180 term.vi_mode_cursor = ViModeCursor::new(point);
1181 }
1182 AlacScroll::Bottom => {
1183 let point = AlacPoint::new(term.bottommost_line(), Column(0));
1184 term.vi_mode_cursor = ViModeCursor::new(point);
1185 }
1186 }
1187 if let Some(mut selection) = term.selection.take() {
1188 let point = term.vi_mode_cursor.point;
1189 selection.update(point, AlacDirection::Right);
1190 term.selection = Some(selection);
1191
1192 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1193 if let Some(selection_text) = term.selection_to_string() {
1194 cx.write_to_primary(ClipboardItem::new_string(selection_text));
1195 }
1196
1197 self.selection_head = Some(point);
1198 cx.emit(Event::SelectionsChanged)
1199 }
1200 }
1201 }
1202 InternalEvent::SetSelection(selection) => {
1203 trace!("Setting selection: selection={selection:?}");
1204 term.selection = selection.as_ref().map(|(sel, _)| sel.clone());
1205
1206 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1207 if let Some(selection_text) = term.selection_to_string() {
1208 cx.write_to_primary(ClipboardItem::new_string(selection_text));
1209 }
1210
1211 if let Some((_, head)) = selection {
1212 self.selection_head = Some(*head);
1213 }
1214 cx.emit(Event::SelectionsChanged)
1215 }
1216 InternalEvent::UpdateSelection(position) => {
1217 trace!("Updating selection: position={position:?}");
1218 if let Some(mut selection) = term.selection.take() {
1219 let (point, side) = grid_point_and_side(
1220 *position,
1221 self.last_content.terminal_bounds,
1222 term.grid().display_offset(),
1223 );
1224
1225 selection.update(point, side);
1226 term.selection = Some(selection);
1227
1228 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1229 if let Some(selection_text) = term.selection_to_string() {
1230 cx.write_to_primary(ClipboardItem::new_string(selection_text));
1231 }
1232
1233 self.selection_head = Some(point);
1234 cx.emit(Event::SelectionsChanged)
1235 }
1236 }
1237
1238 InternalEvent::Copy(keep_selection) => {
1239 trace!("Copying selection: keep_selection={keep_selection:?}");
1240 if let Some(txt) = term.selection_to_string() {
1241 cx.write_to_clipboard(ClipboardItem::new_string(txt));
1242 if !keep_selection.unwrap_or_else(|| {
1243 let settings = TerminalSettings::get_global(cx);
1244 settings.keep_selection_on_copy
1245 }) {
1246 self.events.push_back(InternalEvent::SetSelection(None));
1247 }
1248 }
1249 }
1250 InternalEvent::ScrollToAlacPoint(point) => {
1251 trace!("Scrolling to point: point={point:?}");
1252 term.scroll_to_point(*point);
1253 self.refresh_hovered_word(window);
1254 }
1255 InternalEvent::MoveViCursorToAlacPoint(point) => {
1256 trace!("Move vi cursor to point: point={point:?}");
1257 term.vi_goto_point(*point);
1258 self.refresh_hovered_word(window);
1259 }
1260 InternalEvent::ToggleViMode => {
1261 trace!("Toggling vi mode");
1262 self.vi_mode_enabled = !self.vi_mode_enabled;
1263 term.toggle_vi_mode();
1264 }
1265 InternalEvent::ViMotion(motion) => {
1266 trace!("Performing vi motion: motion={motion:?}");
1267 term.vi_motion(*motion);
1268 }
1269 InternalEvent::FindHyperlink(position, open) => {
1270 trace!("Finding hyperlink at position: position={position:?}, open={open:?}");
1271
1272 let point = grid_point(
1273 *position,
1274 self.last_content.terminal_bounds,
1275 term.grid().display_offset(),
1276 )
1277 .grid_clamp(term, Boundary::Grid);
1278
1279 match terminal_hyperlinks::find_from_grid_point(
1280 term,
1281 point,
1282 &mut self.hyperlink_regex_searches,
1283 self.path_style,
1284 ) {
1285 Some(hyperlink) => {
1286 self.process_hyperlink(hyperlink, *open, cx);
1287 }
1288 None => {
1289 self.last_content.last_hovered_word = None;
1290 cx.emit(Event::NewNavigationTarget(None));
1291 }
1292 }
1293 }
1294 InternalEvent::ProcessHyperlink(hyperlink, open) => {
1295 self.process_hyperlink(hyperlink.clone(), *open, cx);
1296 }
1297 }
1298 }
1299
1300 fn process_hyperlink(
1301 &mut self,
1302 hyperlink: (String, bool, Match),
1303 open: bool,
1304 cx: &mut Context<Self>,
1305 ) {
1306 let (maybe_url_or_path, is_url, url_match) = hyperlink;
1307 let prev_hovered_word = self.last_content.last_hovered_word.take();
1308
1309 let target = if is_url {
1310 if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
1311 let decoded_path = urlencoding::decode(path)
1312 .map(|decoded| decoded.into_owned())
1313 .unwrap_or(path.to_owned());
1314
1315 MaybeNavigationTarget::PathLike(PathLikeTarget {
1316 maybe_path: decoded_path,
1317 terminal_dir: self.working_directory(),
1318 })
1319 } else {
1320 MaybeNavigationTarget::Url(maybe_url_or_path.clone())
1321 }
1322 } else {
1323 MaybeNavigationTarget::PathLike(PathLikeTarget {
1324 maybe_path: maybe_url_or_path.clone(),
1325 terminal_dir: self.working_directory(),
1326 })
1327 };
1328
1329 if open {
1330 cx.emit(Event::Open(target));
1331 } else {
1332 self.update_selected_word(prev_hovered_word, url_match, maybe_url_or_path, target, cx);
1333 }
1334 }
1335
1336 fn update_selected_word(
1337 &mut self,
1338 prev_word: Option<HoveredWord>,
1339 word_match: RangeInclusive<AlacPoint>,
1340 word: String,
1341 navigation_target: MaybeNavigationTarget,
1342 cx: &mut Context<Self>,
1343 ) {
1344 if let Some(prev_word) = prev_word
1345 && prev_word.word == word
1346 && prev_word.word_match == word_match
1347 {
1348 self.last_content.last_hovered_word = Some(HoveredWord {
1349 word,
1350 word_match,
1351 id: prev_word.id,
1352 });
1353 return;
1354 }
1355
1356 self.last_content.last_hovered_word = Some(HoveredWord {
1357 word,
1358 word_match,
1359 id: self.next_link_id(),
1360 });
1361 cx.emit(Event::NewNavigationTarget(Some(navigation_target)));
1362 cx.notify()
1363 }
1364
1365 fn next_link_id(&mut self) -> usize {
1366 let res = self.next_link_id;
1367 self.next_link_id = self.next_link_id.wrapping_add(1);
1368 res
1369 }
1370
1371 pub fn last_content(&self) -> &TerminalContent {
1372 &self.last_content
1373 }
1374
1375 pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape) {
1376 self.term_config.default_cursor_style = cursor_shape.into();
1377 self.term.lock().set_options(self.term_config.clone());
1378 }
1379
1380 pub fn write_output(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
1381 // Inject bytes directly into the terminal emulator and refresh the UI.
1382 // This bypasses the PTY/event loop for display-only terminals.
1383 //
1384 // We first convert LF to CRLF, to get the expected line wrapping in Alacritty.
1385 // When output comes from piped commands (not a PTY) such as codex-acp, and that
1386 // output only contains LF (\n) without a CR (\r) after it, such as the output
1387 // of the `ls` command when running outside a PTY, Alacritty moves the cursor
1388 // cursor down a line but does not move it back to the initial column. This makes
1389 // the rendered output look ridiculous. To prevent this, we insert a CR (\r) before
1390 // each LF that didn't already have one. (Alacritty doesn't have a setting for this.)
1391 let mut converted = Vec::with_capacity(bytes.len());
1392 let mut prev_byte = 0u8;
1393 for &byte in bytes {
1394 if byte == b'\n' && prev_byte != b'\r' {
1395 converted.push(b'\r');
1396 }
1397 converted.push(byte);
1398 prev_byte = byte;
1399 }
1400
1401 let mut processor = alacritty_terminal::vte::ansi::Processor::<
1402 alacritty_terminal::vte::ansi::StdSyncHandler,
1403 >::new();
1404 {
1405 let mut term = self.term.lock();
1406 processor.advance(&mut *term, &converted);
1407 }
1408 cx.emit(Event::Wakeup);
1409 }
1410
1411 pub fn total_lines(&self) -> usize {
1412 self.term.lock_unfair().total_lines()
1413 }
1414
1415 pub fn viewport_lines(&self) -> usize {
1416 self.term.lock_unfair().screen_lines()
1417 }
1418
1419 //To test:
1420 //- Activate match on terminal (scrolling and selection)
1421 //- Editor search snapping behavior
1422
1423 pub fn activate_match(&mut self, index: usize) {
1424 if let Some(search_match) = self.matches.get(index).cloned() {
1425 self.set_selection(Some((make_selection(&search_match), *search_match.end())));
1426 if self.vi_mode_enabled {
1427 self.events
1428 .push_back(InternalEvent::MoveViCursorToAlacPoint(*search_match.end()));
1429 } else {
1430 self.events
1431 .push_back(InternalEvent::ScrollToAlacPoint(*search_match.start()));
1432 }
1433 }
1434 }
1435
1436 pub fn select_matches(&mut self, matches: &[RangeInclusive<AlacPoint>]) {
1437 let matches_to_select = self
1438 .matches
1439 .iter()
1440 .filter(|self_match| matches.contains(self_match))
1441 .cloned()
1442 .collect::<Vec<_>>();
1443 for match_to_select in matches_to_select {
1444 self.set_selection(Some((
1445 make_selection(&match_to_select),
1446 *match_to_select.end(),
1447 )));
1448 }
1449 }
1450
1451 pub fn select_all(&mut self) {
1452 let term = self.term.lock();
1453 let start = AlacPoint::new(term.topmost_line(), Column(0));
1454 let end = AlacPoint::new(term.bottommost_line(), term.last_column());
1455 drop(term);
1456 self.set_selection(Some((make_selection(&(start..=end)), end)));
1457 }
1458
1459 fn set_selection(&mut self, selection: Option<(Selection, AlacPoint)>) {
1460 self.events
1461 .push_back(InternalEvent::SetSelection(selection));
1462 }
1463
1464 pub fn copy(&mut self, keep_selection: Option<bool>) {
1465 self.events.push_back(InternalEvent::Copy(keep_selection));
1466 }
1467
1468 pub fn clear(&mut self) {
1469 self.events.push_back(InternalEvent::Clear)
1470 }
1471
1472 pub fn scroll_line_up(&mut self) {
1473 self.events
1474 .push_back(InternalEvent::Scroll(AlacScroll::Delta(1)));
1475 }
1476
1477 pub fn scroll_up_by(&mut self, lines: usize) {
1478 self.events
1479 .push_back(InternalEvent::Scroll(AlacScroll::Delta(lines as i32)));
1480 }
1481
1482 pub fn scroll_line_down(&mut self) {
1483 self.events
1484 .push_back(InternalEvent::Scroll(AlacScroll::Delta(-1)));
1485 }
1486
1487 pub fn scroll_down_by(&mut self, lines: usize) {
1488 self.events
1489 .push_back(InternalEvent::Scroll(AlacScroll::Delta(-(lines as i32))));
1490 }
1491
1492 pub fn scroll_page_up(&mut self) {
1493 self.events
1494 .push_back(InternalEvent::Scroll(AlacScroll::PageUp));
1495 }
1496
1497 pub fn scroll_page_down(&mut self) {
1498 self.events
1499 .push_back(InternalEvent::Scroll(AlacScroll::PageDown));
1500 }
1501
1502 pub fn scroll_to_top(&mut self) {
1503 self.events
1504 .push_back(InternalEvent::Scroll(AlacScroll::Top));
1505 }
1506
1507 pub fn scroll_to_bottom(&mut self) {
1508 self.events
1509 .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
1510 }
1511
1512 pub fn scrolled_to_top(&self) -> bool {
1513 self.last_content.scrolled_to_top
1514 }
1515
1516 pub fn scrolled_to_bottom(&self) -> bool {
1517 self.last_content.scrolled_to_bottom
1518 }
1519
1520 ///Resize the terminal and the PTY.
1521 pub fn set_size(&mut self, new_bounds: TerminalBounds) {
1522 if self.last_content.terminal_bounds != new_bounds {
1523 self.events.push_back(InternalEvent::Resize(new_bounds))
1524 }
1525 }
1526
1527 /// Write the Input payload to the PTY, if applicable.
1528 /// (This is a no-op for display-only terminals.)
1529 fn write_to_pty(&self, input: impl Into<Cow<'static, [u8]>>) {
1530 if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
1531 let input = input.into();
1532 if log::log_enabled!(log::Level::Debug) {
1533 if let Ok(str) = str::from_utf8(&input) {
1534 log::debug!("Writing to PTY: {:?}", str);
1535 } else {
1536 log::debug!("Writing to PTY: {:?}", input);
1537 }
1538 }
1539 pty_tx.notify(input);
1540 }
1541 }
1542
1543 pub fn input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
1544 self.events
1545 .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
1546 self.events.push_back(InternalEvent::SetSelection(None));
1547
1548 let input = input.into();
1549 #[cfg(any(test, feature = "test-support"))]
1550 self.input_log.push(input.to_vec());
1551
1552 self.write_to_pty(input);
1553 }
1554
1555 #[cfg(any(test, feature = "test-support"))]
1556 pub fn take_input_log(&mut self) -> Vec<Vec<u8>> {
1557 std::mem::take(&mut self.input_log)
1558 }
1559
1560 pub fn toggle_vi_mode(&mut self) {
1561 self.events.push_back(InternalEvent::ToggleViMode);
1562 }
1563
1564 pub fn vi_motion(&mut self, keystroke: &Keystroke) {
1565 if !self.vi_mode_enabled {
1566 return;
1567 }
1568
1569 let key: Cow<'_, str> = if keystroke.modifiers.shift {
1570 Cow::Owned(keystroke.key.to_uppercase())
1571 } else {
1572 Cow::Borrowed(keystroke.key.as_str())
1573 };
1574
1575 let motion: Option<ViMotion> = match key.as_ref() {
1576 "h" | "left" => Some(ViMotion::Left),
1577 "j" | "down" => Some(ViMotion::Down),
1578 "k" | "up" => Some(ViMotion::Up),
1579 "l" | "right" => Some(ViMotion::Right),
1580 "w" => Some(ViMotion::WordRight),
1581 "b" if !keystroke.modifiers.control => Some(ViMotion::WordLeft),
1582 "e" => Some(ViMotion::WordRightEnd),
1583 "%" => Some(ViMotion::Bracket),
1584 "$" => Some(ViMotion::Last),
1585 "0" => Some(ViMotion::First),
1586 "^" => Some(ViMotion::FirstOccupied),
1587 "H" => Some(ViMotion::High),
1588 "M" => Some(ViMotion::Middle),
1589 "L" => Some(ViMotion::Low),
1590 _ => None,
1591 };
1592
1593 if let Some(motion) = motion {
1594 let cursor = self.last_content.cursor.point;
1595 let cursor_pos = Point {
1596 x: cursor.column.0 as f32 * self.last_content.terminal_bounds.cell_width,
1597 y: cursor.line.0 as f32 * self.last_content.terminal_bounds.line_height,
1598 };
1599 self.events
1600 .push_back(InternalEvent::UpdateSelection(cursor_pos));
1601 self.events.push_back(InternalEvent::ViMotion(motion));
1602 return;
1603 }
1604
1605 let scroll_motion = match key.as_ref() {
1606 "g" => Some(AlacScroll::Top),
1607 "G" => Some(AlacScroll::Bottom),
1608 "b" if keystroke.modifiers.control => Some(AlacScroll::PageUp),
1609 "f" if keystroke.modifiers.control => Some(AlacScroll::PageDown),
1610 "d" if keystroke.modifiers.control => {
1611 let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
1612 Some(AlacScroll::Delta(-amount))
1613 }
1614 "u" if keystroke.modifiers.control => {
1615 let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
1616 Some(AlacScroll::Delta(amount))
1617 }
1618 _ => None,
1619 };
1620
1621 if let Some(scroll_motion) = scroll_motion {
1622 self.events.push_back(InternalEvent::Scroll(scroll_motion));
1623 return;
1624 }
1625
1626 match key.as_ref() {
1627 "v" => {
1628 let point = self.last_content.cursor.point;
1629 let selection_type = SelectionType::Simple;
1630 let side = AlacDirection::Right;
1631 let selection = Selection::new(selection_type, point, side);
1632 self.events
1633 .push_back(InternalEvent::SetSelection(Some((selection, point))));
1634 }
1635
1636 "escape" => {
1637 self.events.push_back(InternalEvent::SetSelection(None));
1638 }
1639
1640 "y" => {
1641 self.copy(Some(false));
1642 }
1643
1644 "i" => {
1645 self.scroll_to_bottom();
1646 self.toggle_vi_mode();
1647 }
1648 _ => {}
1649 }
1650 }
1651
1652 pub fn try_keystroke(&mut self, keystroke: &Keystroke, option_as_meta: bool) -> bool {
1653 if self.vi_mode_enabled {
1654 self.vi_motion(keystroke);
1655 return true;
1656 }
1657
1658 // Keep default terminal behavior
1659 let esc = to_esc_str(keystroke, &self.last_content.mode, option_as_meta);
1660 if let Some(esc) = esc {
1661 match esc {
1662 Cow::Borrowed(string) => self.input(string.as_bytes()),
1663 Cow::Owned(string) => self.input(string.into_bytes()),
1664 };
1665 true
1666 } else {
1667 false
1668 }
1669 }
1670
1671 pub fn try_modifiers_change(
1672 &mut self,
1673 modifiers: &Modifiers,
1674 window: &Window,
1675 cx: &mut Context<Self>,
1676 ) {
1677 if self
1678 .last_content
1679 .terminal_bounds
1680 .bounds
1681 .contains(&window.mouse_position())
1682 && modifiers.secondary()
1683 {
1684 self.refresh_hovered_word(window);
1685 }
1686 cx.notify();
1687 }
1688
1689 ///Paste text into the terminal
1690 pub fn paste(&mut self, text: &str) {
1691 let paste_text = if self.last_content.mode.contains(TermMode::BRACKETED_PASTE) {
1692 format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
1693 } else {
1694 text.replace("\r\n", "\r").replace('\n', "\r")
1695 };
1696
1697 self.input(paste_text.into_bytes());
1698 }
1699
1700 pub fn sync(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1701 let term = self.term.clone();
1702 let mut terminal = term.lock_unfair();
1703 //Note that the ordering of events matters for event processing
1704 while let Some(e) = self.events.pop_front() {
1705 self.process_terminal_event(&e, &mut terminal, window, cx)
1706 }
1707
1708 self.last_content = Self::make_content(&terminal, &self.last_content);
1709 }
1710
1711 fn make_content(term: &Term<ZedListener>, last_content: &TerminalContent) -> TerminalContent {
1712 let content = term.renderable_content();
1713
1714 // Pre-allocate with estimated size to reduce reallocations
1715 let estimated_size = content.display_iter.size_hint().0;
1716 let mut cells = Vec::with_capacity(estimated_size);
1717
1718 cells.extend(content.display_iter.map(|ic| IndexedCell {
1719 point: ic.point,
1720 cell: ic.cell.clone(),
1721 }));
1722
1723 let selection_text = if content.selection.is_some() {
1724 term.selection_to_string()
1725 } else {
1726 None
1727 };
1728
1729 TerminalContent {
1730 cells,
1731 mode: content.mode,
1732 display_offset: content.display_offset,
1733 selection_text,
1734 selection: content.selection,
1735 cursor: content.cursor,
1736 cursor_char: term.grid()[content.cursor.point].c,
1737 terminal_bounds: last_content.terminal_bounds,
1738 last_hovered_word: last_content.last_hovered_word.clone(),
1739 scrolled_to_top: content.display_offset == term.history_size(),
1740 scrolled_to_bottom: content.display_offset == 0,
1741 }
1742 }
1743
1744 pub fn get_content(&self) -> String {
1745 let term = self.term.lock_unfair();
1746 let start = AlacPoint::new(term.topmost_line(), Column(0));
1747 let end = AlacPoint::new(term.bottommost_line(), term.last_column());
1748 term.bounds_to_string(start, end)
1749 }
1750
1751 pub fn last_n_non_empty_lines(&self, n: usize) -> Vec<String> {
1752 let term = self.term.clone();
1753 let terminal = term.lock_unfair();
1754 let grid = terminal.grid();
1755 let mut lines = Vec::new();
1756
1757 let mut current_line = grid.bottommost_line().0;
1758 let topmost_line = grid.topmost_line().0;
1759
1760 while current_line >= topmost_line && lines.len() < n {
1761 let logical_line_start = self.find_logical_line_start(grid, current_line, topmost_line);
1762 let logical_line = self.construct_logical_line(grid, logical_line_start, current_line);
1763
1764 if let Some(line) = self.process_line(logical_line) {
1765 lines.push(line);
1766 }
1767
1768 // Move to the line above the start of the current logical line
1769 current_line = logical_line_start - 1;
1770 }
1771
1772 lines.reverse();
1773 lines
1774 }
1775
1776 fn find_logical_line_start(&self, grid: &Grid<Cell>, current: i32, topmost: i32) -> i32 {
1777 let mut line_start = current;
1778 while line_start > topmost {
1779 let prev_line = Line(line_start - 1);
1780 let last_cell = &grid[prev_line][Column(grid.columns() - 1)];
1781 if !last_cell.flags.contains(Flags::WRAPLINE) {
1782 break;
1783 }
1784 line_start -= 1;
1785 }
1786 line_start
1787 }
1788
1789 fn construct_logical_line(&self, grid: &Grid<Cell>, start: i32, end: i32) -> String {
1790 let mut logical_line = String::new();
1791 for row in start..=end {
1792 let grid_row = &grid[Line(row)];
1793 logical_line.push_str(&row_to_string(grid_row));
1794 }
1795 logical_line
1796 }
1797
1798 fn process_line(&self, line: String) -> Option<String> {
1799 let trimmed = line.trim_end().to_string();
1800 if !trimmed.is_empty() {
1801 Some(trimmed)
1802 } else {
1803 None
1804 }
1805 }
1806
1807 pub fn focus_in(&self) {
1808 if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
1809 self.write_to_pty("\x1b[I".as_bytes());
1810 }
1811 }
1812
1813 pub fn focus_out(&mut self) {
1814 if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
1815 self.write_to_pty("\x1b[O".as_bytes());
1816 }
1817 }
1818
1819 pub fn mouse_changed(&mut self, point: AlacPoint, side: AlacDirection) -> bool {
1820 match self.last_mouse {
1821 Some((old_point, old_side)) => {
1822 if old_point == point && old_side == side {
1823 false
1824 } else {
1825 self.last_mouse = Some((point, side));
1826 true
1827 }
1828 }
1829 None => {
1830 self.last_mouse = Some((point, side));
1831 true
1832 }
1833 }
1834 }
1835
1836 pub fn mouse_mode(&self, shift: bool) -> bool {
1837 self.last_content.mode.intersects(TermMode::MOUSE_MODE) && !shift
1838 }
1839
1840 pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) {
1841 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1842 if self.mouse_mode(e.modifiers.shift) {
1843 let (point, side) = grid_point_and_side(
1844 position,
1845 self.last_content.terminal_bounds,
1846 self.last_content.display_offset,
1847 );
1848
1849 if self.mouse_changed(point, side)
1850 && let Some(bytes) =
1851 mouse_moved_report(point, e.pressed_button, e.modifiers, self.last_content.mode)
1852 {
1853 self.write_to_pty(bytes);
1854 }
1855 } else {
1856 self.schedule_find_hyperlink(e.modifiers, e.position);
1857 }
1858 cx.notify();
1859 }
1860
1861 fn schedule_find_hyperlink(&mut self, modifiers: Modifiers, position: Point<Pixels>) {
1862 if self.selection_phase == SelectionPhase::Selecting
1863 || !modifiers.secondary()
1864 || !self.last_content.terminal_bounds.bounds.contains(&position)
1865 {
1866 self.last_content.last_hovered_word = None;
1867 return;
1868 }
1869
1870 // Throttle hyperlink searches to avoid excessive processing
1871 let now = Instant::now();
1872 if self
1873 .last_hyperlink_search_position
1874 .map_or(true, |last_pos| {
1875 // Only search if mouse moved significantly or enough time passed
1876 let distance_moved = ((position.x - last_pos.x).abs()
1877 + (position.y - last_pos.y).abs())
1878 > FIND_HYPERLINK_THROTTLE_PX;
1879 let time_elapsed = now.duration_since(self.last_mouse_move_time).as_millis() > 100;
1880 distance_moved || time_elapsed
1881 })
1882 {
1883 self.last_mouse_move_time = now;
1884 self.last_hyperlink_search_position = Some(position);
1885 self.events.push_back(InternalEvent::FindHyperlink(
1886 position - self.last_content.terminal_bounds.bounds.origin,
1887 false,
1888 ));
1889 }
1890 }
1891
1892 pub fn select_word_at_event_position(&mut self, e: &MouseDownEvent) {
1893 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1894 let (point, side) = grid_point_and_side(
1895 position,
1896 self.last_content.terminal_bounds,
1897 self.last_content.display_offset,
1898 );
1899 let selection = Selection::new(SelectionType::Semantic, point, side);
1900 self.events
1901 .push_back(InternalEvent::SetSelection(Some((selection, point))));
1902 }
1903
1904 pub fn mouse_drag(
1905 &mut self,
1906 e: &MouseMoveEvent,
1907 region: Bounds<Pixels>,
1908 cx: &mut Context<Self>,
1909 ) {
1910 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1911 if !self.mouse_mode(e.modifiers.shift) {
1912 if let Some((.., hyperlink_range)) = &self.mouse_down_hyperlink {
1913 let point = grid_point(
1914 position,
1915 self.last_content.terminal_bounds,
1916 self.last_content.display_offset,
1917 );
1918
1919 if !hyperlink_range.contains(&point) {
1920 self.mouse_down_hyperlink = None;
1921 } else {
1922 return;
1923 }
1924 }
1925
1926 self.selection_phase = SelectionPhase::Selecting;
1927 // Alacritty has the same ordering, of first updating the selection
1928 // then scrolling 15ms later
1929 self.events
1930 .push_back(InternalEvent::UpdateSelection(position));
1931
1932 // Doesn't make sense to scroll the alt screen
1933 if !self.last_content.mode.contains(TermMode::ALT_SCREEN) {
1934 let scroll_lines = match self.drag_line_delta(e, region) {
1935 Some(value) => value,
1936 None => return,
1937 };
1938
1939 self.events
1940 .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines)));
1941 }
1942
1943 cx.notify();
1944 }
1945 }
1946
1947 fn drag_line_delta(&self, e: &MouseMoveEvent, region: Bounds<Pixels>) -> Option<i32> {
1948 let top = region.origin.y;
1949 let bottom = region.bottom_left().y;
1950
1951 let scroll_lines = if e.position.y < top {
1952 let scroll_delta = (top - e.position.y).pow(1.1);
1953 (scroll_delta / self.last_content.terminal_bounds.line_height).ceil() as i32
1954 } else if e.position.y > bottom {
1955 let scroll_delta = -((e.position.y - bottom).pow(1.1));
1956 (scroll_delta / self.last_content.terminal_bounds.line_height).floor() as i32
1957 } else {
1958 return None;
1959 };
1960
1961 Some(scroll_lines.clamp(-3, 3))
1962 }
1963
1964 pub fn mouse_down(&mut self, e: &MouseDownEvent, _cx: &mut Context<Self>) {
1965 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1966 let point = grid_point(
1967 position,
1968 self.last_content.terminal_bounds,
1969 self.last_content.display_offset,
1970 );
1971
1972 if e.button == MouseButton::Left
1973 && e.modifiers.secondary()
1974 && !self.mouse_mode(e.modifiers.shift)
1975 {
1976 let term_lock = self.term.lock();
1977 self.mouse_down_hyperlink = terminal_hyperlinks::find_from_grid_point(
1978 &term_lock,
1979 point,
1980 &mut self.hyperlink_regex_searches,
1981 self.path_style,
1982 );
1983 drop(term_lock);
1984
1985 if self.mouse_down_hyperlink.is_some() {
1986 return;
1987 }
1988 }
1989
1990 if self.mouse_mode(e.modifiers.shift) {
1991 if let Some(bytes) =
1992 mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode)
1993 {
1994 self.write_to_pty(bytes);
1995 }
1996 } else {
1997 match e.button {
1998 MouseButton::Left => {
1999 let (point, side) = grid_point_and_side(
2000 position,
2001 self.last_content.terminal_bounds,
2002 self.last_content.display_offset,
2003 );
2004
2005 let selection_type = match e.click_count {
2006 0 => return, //This is a release
2007 1 => Some(SelectionType::Simple),
2008 2 => Some(SelectionType::Semantic),
2009 3 => Some(SelectionType::Lines),
2010 _ => None,
2011 };
2012
2013 if selection_type == Some(SelectionType::Simple) && e.modifiers.shift {
2014 self.events
2015 .push_back(InternalEvent::UpdateSelection(position));
2016 return;
2017 }
2018
2019 let selection = selection_type
2020 .map(|selection_type| Selection::new(selection_type, point, side));
2021
2022 if let Some(sel) = selection {
2023 self.events
2024 .push_back(InternalEvent::SetSelection(Some((sel, point))));
2025 }
2026 }
2027 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
2028 MouseButton::Middle => {
2029 if let Some(item) = _cx.read_from_primary() {
2030 let text = item.text().unwrap_or_default();
2031 self.input(text.into_bytes());
2032 }
2033 }
2034 _ => {}
2035 }
2036 }
2037 }
2038
2039 pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context<Self>) {
2040 let setting = TerminalSettings::get_global(cx);
2041
2042 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
2043 if self.mouse_mode(e.modifiers.shift) {
2044 let point = grid_point(
2045 position,
2046 self.last_content.terminal_bounds,
2047 self.last_content.display_offset,
2048 );
2049
2050 if let Some(bytes) =
2051 mouse_button_report(point, e.button, e.modifiers, false, self.last_content.mode)
2052 {
2053 self.write_to_pty(bytes);
2054 }
2055 } else {
2056 if e.button == MouseButton::Left && setting.copy_on_select {
2057 self.copy(Some(true));
2058 }
2059
2060 if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
2061 let point = grid_point(
2062 position,
2063 self.last_content.terminal_bounds,
2064 self.last_content.display_offset,
2065 );
2066
2067 if let Some(mouse_up_hyperlink) = {
2068 let term_lock = self.term.lock();
2069 terminal_hyperlinks::find_from_grid_point(
2070 &term_lock,
2071 point,
2072 &mut self.hyperlink_regex_searches,
2073 self.path_style,
2074 )
2075 } {
2076 if mouse_down_hyperlink == mouse_up_hyperlink {
2077 self.events
2078 .push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true));
2079 self.selection_phase = SelectionPhase::Ended;
2080 self.last_mouse = None;
2081 return;
2082 }
2083 }
2084 }
2085
2086 //Hyperlinks
2087 if self.selection_phase == SelectionPhase::Ended {
2088 let mouse_cell_index =
2089 content_index_for_mouse(position, &self.last_content.terminal_bounds);
2090 if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() {
2091 cx.open_url(link.uri());
2092 } else if e.modifiers.secondary() {
2093 self.events
2094 .push_back(InternalEvent::FindHyperlink(position, true));
2095 }
2096 }
2097 }
2098
2099 self.selection_phase = SelectionPhase::Ended;
2100 self.last_mouse = None;
2101 }
2102
2103 ///Scroll the terminal
2104 pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) {
2105 let mouse_mode = self.mouse_mode(e.shift);
2106 let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier };
2107
2108 if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier)
2109 && scroll_lines != 0
2110 {
2111 if mouse_mode {
2112 let point = grid_point(
2113 e.position - self.last_content.terminal_bounds.bounds.origin,
2114 self.last_content.terminal_bounds,
2115 self.last_content.display_offset,
2116 );
2117
2118 if let Some(scrolls) = scroll_report(point, scroll_lines, e, self.last_content.mode)
2119 {
2120 for scroll in scrolls {
2121 self.write_to_pty(scroll);
2122 }
2123 };
2124 } else if self
2125 .last_content
2126 .mode
2127 .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
2128 && !e.shift
2129 {
2130 self.write_to_pty(alt_scroll(scroll_lines));
2131 } else {
2132 let scroll = AlacScroll::Delta(scroll_lines);
2133
2134 self.events.push_back(InternalEvent::Scroll(scroll));
2135 }
2136 }
2137 }
2138
2139 fn refresh_hovered_word(&mut self, window: &Window) {
2140 self.schedule_find_hyperlink(window.modifiers(), window.mouse_position());
2141 }
2142
2143 fn determine_scroll_lines(
2144 &mut self,
2145 e: &ScrollWheelEvent,
2146 scroll_multiplier: f32,
2147 ) -> Option<i32> {
2148 let line_height = self.last_content.terminal_bounds.line_height;
2149 match e.touch_phase {
2150 /* Reset scroll state on started */
2151 TouchPhase::Started => {
2152 self.scroll_px = px(0.);
2153 None
2154 }
2155 /* Calculate the appropriate scroll lines */
2156 TouchPhase::Moved => {
2157 let old_offset = (self.scroll_px / line_height) as i32;
2158
2159 self.scroll_px += e.delta.pixel_delta(line_height).y * scroll_multiplier;
2160
2161 let new_offset = (self.scroll_px / line_height) as i32;
2162
2163 // Whenever we hit the edges, reset our stored scroll to 0
2164 // so we can respond to changes in direction quickly
2165 self.scroll_px %= self.last_content.terminal_bounds.height();
2166
2167 Some(new_offset - old_offset)
2168 }
2169 TouchPhase::Ended => None,
2170 }
2171 }
2172
2173 pub fn find_matches(
2174 &self,
2175 mut searcher: RegexSearch,
2176 cx: &Context<Self>,
2177 ) -> Task<Vec<RangeInclusive<AlacPoint>>> {
2178 let term = self.term.clone();
2179 cx.background_spawn(async move {
2180 let term = term.lock();
2181
2182 all_search_matches(&term, &mut searcher).collect()
2183 })
2184 }
2185
2186 pub fn working_directory(&self) -> Option<PathBuf> {
2187 if self.is_remote_terminal {
2188 // We can't yet reliably detect the working directory of a shell on the
2189 // SSH host. Until we can do that, it doesn't make sense to display
2190 // the working directory on the client and persist that.
2191 None
2192 } else {
2193 self.client_side_working_directory()
2194 }
2195 }
2196
2197 /// Returns the working directory of the process that's connected to the PTY.
2198 /// That means it returns the working directory of the local shell or program
2199 /// that's running inside the terminal.
2200 ///
2201 /// This does *not* return the working directory of the shell that runs on the
2202 /// remote host, in case Zed is connected to a remote host.
2203 fn client_side_working_directory(&self) -> Option<PathBuf> {
2204 match &self.terminal_type {
2205 TerminalType::Pty { info, .. } => info
2206 .current
2207 .read()
2208 .as_ref()
2209 .map(|process| process.cwd.clone()),
2210 TerminalType::DisplayOnly => None,
2211 }
2212 }
2213
2214 pub fn title(&self, truncate: bool) -> String {
2215 const MAX_CHARS: usize = 25;
2216 match &self.task {
2217 Some(task_state) => {
2218 if truncate {
2219 truncate_and_trailoff(&task_state.spawned_task.label, MAX_CHARS)
2220 } else {
2221 task_state.spawned_task.full_label.clone()
2222 }
2223 }
2224 None => self
2225 .title_override
2226 .as_ref()
2227 .map(|title_override| title_override.to_string())
2228 .unwrap_or_else(|| match &self.terminal_type {
2229 TerminalType::Pty { info, .. } => info
2230 .current
2231 .read()
2232 .as_ref()
2233 .map(|fpi| {
2234 let process_file = fpi
2235 .cwd
2236 .file_name()
2237 .map(|name| name.to_string_lossy().into_owned())
2238 .unwrap_or_default();
2239
2240 let argv = fpi.argv.as_slice();
2241 let process_name = format!(
2242 "{}{}",
2243 fpi.name,
2244 if !argv.is_empty() {
2245 format!(" {}", (argv[1..]).join(" "))
2246 } else {
2247 "".to_string()
2248 }
2249 );
2250 let (process_file, process_name) = if truncate {
2251 (
2252 truncate_and_trailoff(&process_file, MAX_CHARS),
2253 truncate_and_trailoff(&process_name, MAX_CHARS),
2254 )
2255 } else {
2256 (process_file, process_name)
2257 };
2258 format!("{process_file} — {process_name}")
2259 })
2260 .unwrap_or_else(|| "Terminal".to_string()),
2261 TerminalType::DisplayOnly => "Terminal".to_string(),
2262 }),
2263 }
2264 }
2265
2266 pub fn kill_active_task(&mut self) {
2267 if let Some(task) = self.task()
2268 && task.status == TaskStatus::Running
2269 {
2270 if let TerminalType::Pty { info, .. } = &self.terminal_type {
2271 // First kill the foreground process group (the command running in the shell)
2272 info.kill_current_process();
2273 // Then kill the shell itself so that the terminal exits properly
2274 // and wait_for_completed_task can complete
2275 info.kill_child_process();
2276 }
2277 }
2278 }
2279
2280 pub fn pid(&self) -> Option<sysinfo::Pid> {
2281 match &self.terminal_type {
2282 TerminalType::Pty { info, .. } => info.pid(),
2283 TerminalType::DisplayOnly => None,
2284 }
2285 }
2286
2287 pub fn pid_getter(&self) -> Option<&ProcessIdGetter> {
2288 match &self.terminal_type {
2289 TerminalType::Pty { info, .. } => Some(info.pid_getter()),
2290 TerminalType::DisplayOnly => None,
2291 }
2292 }
2293
2294 pub fn task(&self) -> Option<&TaskState> {
2295 self.task.as_ref()
2296 }
2297
2298 pub fn wait_for_completed_task(&self, cx: &App) -> Task<Option<ExitStatus>> {
2299 if let Some(task) = self.task() {
2300 if task.status == TaskStatus::Running {
2301 let completion_receiver = task.completion_rx.clone();
2302 return cx.spawn(async move |_| completion_receiver.recv().await.ok().flatten());
2303 } else if let Ok(status) = task.completion_rx.try_recv() {
2304 return Task::ready(status);
2305 }
2306 }
2307 Task::ready(None)
2308 }
2309
2310 fn register_task_finished(&mut self, raw_status: Option<i32>, cx: &mut Context<Terminal>) {
2311 let exit_status: Option<ExitStatus> = raw_status.map(|value| {
2312 #[cfg(unix)]
2313 {
2314 std::os::unix::process::ExitStatusExt::from_raw(value)
2315 }
2316 #[cfg(windows)]
2317 {
2318 std::os::windows::process::ExitStatusExt::from_raw(value as u32)
2319 }
2320 });
2321
2322 if let Some(tx) = &self.completion_tx {
2323 tx.try_send(exit_status).ok();
2324 }
2325 if let Some(e) = exit_status {
2326 self.child_exited = Some(e);
2327 }
2328 let task = match &mut self.task {
2329 Some(task) => task,
2330 None => {
2331 if self.child_exited.is_none_or(|e| e.code() == Some(0)) {
2332 cx.emit(Event::CloseTerminal);
2333 }
2334 return;
2335 }
2336 };
2337 if task.status != TaskStatus::Running {
2338 return;
2339 }
2340 match exit_status.and_then(|e| e.code()) {
2341 Some(error_code) => {
2342 task.status.register_task_exit(error_code);
2343 }
2344 None => {
2345 task.status.register_terminal_exit();
2346 }
2347 };
2348
2349 let (finished_successfully, task_line, command_line) = task_summary(task, exit_status);
2350 let mut lines_to_show = Vec::new();
2351 if task.spawned_task.show_summary {
2352 lines_to_show.push(task_line.as_str());
2353 }
2354 if task.spawned_task.show_command {
2355 lines_to_show.push(command_line.as_str());
2356 }
2357
2358 if !lines_to_show.is_empty() {
2359 // SAFETY: the invocation happens on non `TaskStatus::Running` tasks, once,
2360 // after either `AlacTermEvent::Exit` or `AlacTermEvent::ChildExit` events that are spawned
2361 // when Zed task finishes and no more output is made.
2362 // After the task summary is output once, no more text is appended to the terminal.
2363 unsafe { append_text_to_term(&mut self.term.lock(), &lines_to_show) };
2364 }
2365
2366 match task.spawned_task.hide {
2367 HideStrategy::Never => {}
2368 HideStrategy::Always => {
2369 cx.emit(Event::CloseTerminal);
2370 }
2371 HideStrategy::OnSuccess => {
2372 if finished_successfully {
2373 cx.emit(Event::CloseTerminal);
2374 }
2375 }
2376 }
2377 }
2378
2379 pub fn vi_mode_enabled(&self) -> bool {
2380 self.vi_mode_enabled
2381 }
2382
2383 pub fn clone_builder(&self, cx: &App, cwd: Option<PathBuf>) -> Task<Result<TerminalBuilder>> {
2384 let working_directory = self.working_directory().or_else(|| cwd);
2385 TerminalBuilder::new(
2386 working_directory,
2387 None,
2388 self.template.shell.clone(),
2389 self.template.env.clone(),
2390 self.template.cursor_shape,
2391 self.template.alternate_scroll,
2392 self.template.max_scroll_history_lines,
2393 self.template.path_hyperlink_regexes.clone(),
2394 self.template.path_hyperlink_timeout_ms,
2395 self.is_remote_terminal,
2396 self.template.window_id,
2397 None,
2398 cx,
2399 self.activation_script.clone(),
2400 self.path_style,
2401 None,
2402 )
2403 }
2404}
2405
2406// Helper function to convert a grid row to a string
2407pub fn row_to_string(row: &Row<Cell>) -> String {
2408 row[..Column(row.len())]
2409 .iter()
2410 .map(|cell| cell.c)
2411 .collect::<String>()
2412}
2413
2414const TASK_DELIMITER: &str = "⏵ ";
2415fn task_summary(task: &TaskState, exit_status: Option<ExitStatus>) -> (bool, String, String) {
2416 let escaped_full_label = task
2417 .spawned_task
2418 .full_label
2419 .replace("\r\n", "\r")
2420 .replace('\n', "\r");
2421 let task_label = |suffix: &str| format!("{TASK_DELIMITER}Task `{escaped_full_label}` {suffix}");
2422 let (success, task_line) = match exit_status {
2423 Some(status) => {
2424 let code = status.code();
2425 #[cfg(unix)]
2426 let signal = status.signal();
2427 #[cfg(not(unix))]
2428 let signal: Option<i32> = None;
2429
2430 match (code, signal) {
2431 (Some(0), _) => (true, task_label("finished successfully")),
2432 (Some(code), _) => (
2433 false,
2434 task_label(&format!("finished with exit code: {code}")),
2435 ),
2436 (None, Some(signal)) => (
2437 false,
2438 task_label(&format!("terminated by signal: {signal}")),
2439 ),
2440 (None, None) => (false, task_label("finished")),
2441 }
2442 }
2443 None => (false, task_label("finished")),
2444 };
2445 let escaped_command_label = task
2446 .spawned_task
2447 .command_label
2448 .replace("\r\n", "\r")
2449 .replace('\n', "\r");
2450 let command_line = format!("{TASK_DELIMITER}Command: {escaped_command_label}");
2451 (success, task_line, command_line)
2452}
2453
2454/// Appends a stringified task summary to the terminal, after its output.
2455///
2456/// SAFETY: This function should only be called after terminal's PTY is no longer alive.
2457/// New text being added to the terminal here, uses "less public" APIs,
2458/// which are not maintaining the entire terminal state intact.
2459///
2460///
2461/// The library
2462///
2463/// * does not increment inner grid cursor's _lines_ on `input` calls
2464/// (but displaying the lines correctly and incrementing cursor's columns)
2465///
2466/// * ignores `\n` and \r` character input, requiring the `newline` call instead
2467///
2468/// * does not alter grid state after `newline` call
2469/// so its `bottommost_line` is always the same additions, and
2470/// the cursor's `point` is not updated to the new line and column values
2471///
2472/// * ??? there could be more consequences, and any further "proper" streaming from the PTY might bug and/or panic.
2473/// Still, subsequent `append_text_to_term` invocations are possible and display the contents correctly.
2474///
2475/// Despite the quirks, this is the simplest approach to appending text to the terminal: its alternative, `grid_mut` manipulations,
2476/// do not properly set the scrolling state and display odd text after appending; also those manipulations are more tedious and error-prone.
2477/// The function achieves proper display and scrolling capabilities, at a cost of grid state not properly synchronized.
2478/// This is enough for printing moderately-sized texts like task summaries, but might break or perform poorly for larger texts.
2479unsafe fn append_text_to_term(term: &mut Term<ZedListener>, text_lines: &[&str]) {
2480 term.newline();
2481 term.grid_mut().cursor.point.column = Column(0);
2482 for line in text_lines {
2483 for c in line.chars() {
2484 term.input(c);
2485 }
2486 term.newline();
2487 term.grid_mut().cursor.point.column = Column(0);
2488 }
2489}
2490
2491impl Drop for Terminal {
2492 fn drop(&mut self) {
2493 if let TerminalType::Pty { pty_tx, info } =
2494 std::mem::replace(&mut self.terminal_type, TerminalType::DisplayOnly)
2495 {
2496 pty_tx.0.send(Msg::Shutdown).ok();
2497
2498 #[cfg(unix)]
2499 {
2500 if let Some(tracker) = self.session_tracker.take() {
2501 let process_group_id = info.pid().map(|pid| pid.as_u32() as libc::pid_t);
2502 // Use a dedicated thread for cleanup that outlives the executor,
2503 // ensuring processes are killed even if Zed is exiting.
2504 std::thread::spawn(move || {
2505 tracker.kill_all_processes(process_group_id);
2506 });
2507 return;
2508 }
2509 }
2510
2511 let timer = self.background_executor.timer(Duration::from_millis(100));
2512 self.background_executor
2513 .spawn(async move {
2514 timer.await;
2515 info.kill_child_process();
2516 })
2517 .detach();
2518 }
2519 }
2520}
2521
2522impl EventEmitter<Event> for Terminal {}
2523
2524fn make_selection(range: &RangeInclusive<AlacPoint>) -> Selection {
2525 let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left);
2526 selection.update(*range.end(), AlacDirection::Right);
2527 selection
2528}
2529
2530fn all_search_matches<'a, T>(
2531 term: &'a Term<T>,
2532 regex: &'a mut RegexSearch,
2533) -> impl Iterator<Item = Match> + 'a {
2534 let start = AlacPoint::new(term.grid().topmost_line(), Column(0));
2535 let end = AlacPoint::new(term.grid().bottommost_line(), term.grid().last_column());
2536 RegexIter::new(start, end, AlacDirection::Right, term, regex)
2537}
2538
2539fn content_index_for_mouse(pos: Point<Pixels>, terminal_bounds: &TerminalBounds) -> usize {
2540 let col = (pos.x / terminal_bounds.cell_width()).round() as usize;
2541 let clamped_col = min(col, terminal_bounds.columns() - 1);
2542 let row = (pos.y / terminal_bounds.line_height()).round() as usize;
2543 let clamped_row = min(row, terminal_bounds.screen_lines() - 1);
2544 clamped_row * terminal_bounds.columns() + clamped_col
2545}
2546
2547/// Converts an 8 bit ANSI color to its GPUI equivalent.
2548/// Accepts `usize` for compatibility with the `alacritty::Colors` interface,
2549/// Other than that use case, should only be called with values in the `[0,255]` range
2550pub fn get_color_at_index(index: usize, theme: &Theme) -> Hsla {
2551 let colors = theme.colors();
2552
2553 match index {
2554 // 0-15 are the same as the named colors above
2555 0 => colors.terminal_ansi_black,
2556 1 => colors.terminal_ansi_red,
2557 2 => colors.terminal_ansi_green,
2558 3 => colors.terminal_ansi_yellow,
2559 4 => colors.terminal_ansi_blue,
2560 5 => colors.terminal_ansi_magenta,
2561 6 => colors.terminal_ansi_cyan,
2562 7 => colors.terminal_ansi_white,
2563 8 => colors.terminal_ansi_bright_black,
2564 9 => colors.terminal_ansi_bright_red,
2565 10 => colors.terminal_ansi_bright_green,
2566 11 => colors.terminal_ansi_bright_yellow,
2567 12 => colors.terminal_ansi_bright_blue,
2568 13 => colors.terminal_ansi_bright_magenta,
2569 14 => colors.terminal_ansi_bright_cyan,
2570 15 => colors.terminal_ansi_bright_white,
2571 // 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm.
2572 // See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl
2573 16..=231 => {
2574 let (r, g, b) = rgb_for_index(index as u8);
2575 rgba_color(
2576 if r == 0 { 0 } else { r * 40 + 55 },
2577 if g == 0 { 0 } else { g * 40 + 55 },
2578 if b == 0 { 0 } else { b * 40 + 55 },
2579 )
2580 }
2581 // 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238).
2582 232..=255 => {
2583 let i = index as u8 - 232; // Align index to 0..24
2584 let value = i * 10 + 8;
2585 rgba_color(value, value, value)
2586 }
2587 // For compatibility with the alacritty::Colors interface
2588 // See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs
2589 256 => colors.terminal_foreground,
2590 257 => colors.terminal_background,
2591 258 => theme.players().local().cursor,
2592 259 => colors.terminal_ansi_dim_black,
2593 260 => colors.terminal_ansi_dim_red,
2594 261 => colors.terminal_ansi_dim_green,
2595 262 => colors.terminal_ansi_dim_yellow,
2596 263 => colors.terminal_ansi_dim_blue,
2597 264 => colors.terminal_ansi_dim_magenta,
2598 265 => colors.terminal_ansi_dim_cyan,
2599 266 => colors.terminal_ansi_dim_white,
2600 267 => colors.terminal_bright_foreground,
2601 268 => colors.terminal_ansi_black, // 'Dim Background', non-standard color
2602
2603 _ => black(),
2604 }
2605}
2606
2607/// Generates the RGB channels in [0, 5] for a given index into the 6x6x6 ANSI color cube.
2608///
2609/// See: [8 bit ANSI color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit).
2610///
2611/// Wikipedia gives a formula for calculating the index for a given color:
2612///
2613/// ```text
2614/// index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
2615/// ```
2616///
2617/// This function does the reverse, calculating the `r`, `g`, and `b` components from a given index.
2618fn rgb_for_index(i: u8) -> (u8, u8, u8) {
2619 debug_assert!((16..=231).contains(&i));
2620 let i = i - 16;
2621 let r = (i - (i % 36)) / 36;
2622 let g = ((i % 36) - (i % 6)) / 6;
2623 let b = (i % 36) % 6;
2624 (r, g, b)
2625}
2626
2627pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla {
2628 Rgba {
2629 r: (r as f32 / 255.),
2630 g: (g as f32 / 255.),
2631 b: (b as f32 / 255.),
2632 a: 1.,
2633 }
2634 .into()
2635}
2636
2637#[cfg(test)]
2638mod tests {
2639 use std::time::Duration;
2640
2641 use super::*;
2642 use crate::{
2643 IndexedCell, TerminalBounds, TerminalBuilder, TerminalContent, content_index_for_mouse,
2644 rgb_for_index,
2645 };
2646 use alacritty_terminal::{
2647 index::{Column, Line, Point as AlacPoint},
2648 term::cell::Cell,
2649 };
2650 use collections::HashMap;
2651 use gpui::{
2652 Entity, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
2653 Point, TestAppContext, bounds, point, size,
2654 };
2655 use parking_lot::Mutex;
2656 use rand::{Rng, distr, rngs::ThreadRng};
2657 use smol::channel::Receiver;
2658 use task::{Shell, ShellBuilder};
2659
2660 #[cfg(target_os = "macos")]
2661 fn init_test(cx: &mut TestAppContext) {
2662 cx.update(|cx| {
2663 let settings_store = settings::SettingsStore::test(cx);
2664 cx.set_global(settings_store);
2665 theme::init(theme::LoadThemes::JustBase, cx);
2666 });
2667 }
2668
2669 /// Helper to build a test terminal running a shell command.
2670 /// Returns the terminal entity and a receiver for the completion signal.
2671 async fn build_test_terminal(
2672 cx: &mut TestAppContext,
2673 command: &str,
2674 args: &[&str],
2675 ) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
2676 let (completion_tx, completion_rx) = smol::channel::unbounded();
2677 let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
2678 let (program, args) =
2679 ShellBuilder::new(&Shell::System, false).build(Some(command.to_owned()), &args);
2680 let builder = cx
2681 .update(|cx| {
2682 TerminalBuilder::new(
2683 None,
2684 None,
2685 task::Shell::WithArguments {
2686 program,
2687 args,
2688 title_override: None,
2689 },
2690 HashMap::default(),
2691 CursorShape::default(),
2692 AlternateScroll::On,
2693 None,
2694 vec![],
2695 0,
2696 false,
2697 0,
2698 Some(completion_tx),
2699 cx,
2700 vec![],
2701 PathStyle::local(),
2702 None,
2703 )
2704 })
2705 .await
2706 .unwrap();
2707 let terminal = cx.new(|cx| builder.subscribe(cx));
2708 (terminal, completion_rx)
2709 }
2710
2711 fn init_ctrl_click_hyperlink_test(cx: &mut TestAppContext, output: &[u8]) -> Entity<Terminal> {
2712 cx.update(|cx| {
2713 let settings_store = settings::SettingsStore::test(cx);
2714 cx.set_global(settings_store);
2715 });
2716
2717 let terminal = cx.new(|cx| {
2718 TerminalBuilder::new_display_only(
2719 CursorShape::default(),
2720 AlternateScroll::On,
2721 None,
2722 0,
2723 cx.background_executor(),
2724 PathStyle::local(),
2725 )
2726 .unwrap()
2727 .subscribe(cx)
2728 });
2729
2730 terminal.update(cx, |terminal, cx| {
2731 terminal.write_output(output, cx);
2732 });
2733
2734 cx.run_until_parked();
2735
2736 terminal.update(cx, |terminal, _cx| {
2737 let term_lock = terminal.term.lock();
2738 terminal.last_content = Terminal::make_content(&term_lock, &terminal.last_content);
2739 drop(term_lock);
2740
2741 let terminal_bounds = TerminalBounds::new(
2742 px(20.0),
2743 px(10.0),
2744 bounds(point(px(0.0), px(0.0)), size(px(400.0), px(400.0))),
2745 );
2746 terminal.last_content.terminal_bounds = terminal_bounds;
2747 terminal.events.clear();
2748 });
2749
2750 terminal
2751 }
2752
2753 fn ctrl_mouse_down_at(
2754 terminal: &mut Terminal,
2755 position: Point<Pixels>,
2756 cx: &mut Context<Terminal>,
2757 ) {
2758 let mouse_down = MouseDownEvent {
2759 button: MouseButton::Left,
2760 position,
2761 modifiers: Modifiers::secondary_key(),
2762 click_count: 1,
2763 first_mouse: true,
2764 };
2765 terminal.mouse_down(&mouse_down, cx);
2766 }
2767
2768 fn ctrl_mouse_move_to(
2769 terminal: &mut Terminal,
2770 position: Point<Pixels>,
2771 cx: &mut Context<Terminal>,
2772 ) {
2773 let terminal_bounds = terminal.last_content.terminal_bounds.bounds;
2774 let drag_event = MouseMoveEvent {
2775 position,
2776 pressed_button: Some(MouseButton::Left),
2777 modifiers: Modifiers::secondary_key(),
2778 };
2779 terminal.mouse_drag(&drag_event, terminal_bounds, cx);
2780 }
2781
2782 fn ctrl_mouse_up_at(
2783 terminal: &mut Terminal,
2784 position: Point<Pixels>,
2785 cx: &mut Context<Terminal>,
2786 ) {
2787 let mouse_up = MouseUpEvent {
2788 button: MouseButton::Left,
2789 position,
2790 modifiers: Modifiers::secondary_key(),
2791 click_count: 1,
2792 };
2793 terminal.mouse_up(&mouse_up, cx);
2794 }
2795
2796 #[gpui::test]
2797 async fn test_basic_terminal(cx: &mut TestAppContext) {
2798 cx.executor().allow_parking();
2799
2800 let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["hello"]).await;
2801 assert_eq!(
2802 completion_rx.recv().await.unwrap(),
2803 Some(ExitStatus::default())
2804 );
2805 assert_eq!(
2806 terminal.update(cx, |term, _| term.get_content()).trim(),
2807 "hello"
2808 );
2809
2810 // Inject additional output directly into the emulator (display-only path)
2811 terminal.update(cx, |term, cx| {
2812 term.write_output(b"\nfrom_injection", cx);
2813 });
2814
2815 let content_after = terminal.update(cx, |term, _| term.get_content());
2816 assert!(
2817 content_after.contains("from_injection"),
2818 "expected injected output to appear, got: {content_after}"
2819 );
2820 }
2821
2822 // TODO should be tested on Linux too, but does not work there well
2823 #[cfg(target_os = "macos")]
2824 #[gpui::test(iterations = 10)]
2825 async fn test_terminal_eof(cx: &mut TestAppContext) {
2826 init_test(cx);
2827
2828 cx.executor().allow_parking();
2829
2830 let (completion_tx, completion_rx) = smol::channel::unbounded();
2831 let builder = cx
2832 .update(|cx| {
2833 TerminalBuilder::new(
2834 None,
2835 None,
2836 task::Shell::System,
2837 HashMap::default(),
2838 CursorShape::default(),
2839 AlternateScroll::On,
2840 None,
2841 vec![],
2842 0,
2843 false,
2844 0,
2845 Some(completion_tx),
2846 cx,
2847 Vec::new(),
2848 PathStyle::local(),
2849 None,
2850 )
2851 })
2852 .await
2853 .unwrap();
2854 // Build an empty command, which will result in a tty shell spawned.
2855 let terminal = cx.new(|cx| builder.subscribe(cx));
2856
2857 let (event_tx, event_rx) = smol::channel::unbounded::<Event>();
2858 cx.update(|cx| {
2859 cx.subscribe(&terminal, move |_, e, _| {
2860 event_tx.send_blocking(e.clone()).unwrap();
2861 })
2862 })
2863 .detach();
2864 cx.background_spawn(async move {
2865 assert_eq!(
2866 completion_rx.recv().await.unwrap(),
2867 Some(ExitStatus::default()),
2868 "EOF should result in the tty shell exiting successfully",
2869 );
2870 })
2871 .detach();
2872
2873 let first_event = event_rx.recv().await.expect("No wakeup event received");
2874
2875 terminal.update(cx, |terminal, _| {
2876 let success = terminal.try_keystroke(&Keystroke::parse("ctrl-c").unwrap(), false);
2877 assert!(success, "Should have registered ctrl-c sequence");
2878 });
2879 terminal.update(cx, |terminal, _| {
2880 let success = terminal.try_keystroke(&Keystroke::parse("ctrl-d").unwrap(), false);
2881 assert!(success, "Should have registered ctrl-d sequence");
2882 });
2883
2884 let mut all_events = vec![first_event];
2885 while let Ok(new_event) = event_rx.recv().await {
2886 all_events.push(new_event.clone());
2887 if new_event == Event::CloseTerminal {
2888 break;
2889 }
2890 }
2891 assert!(
2892 all_events.contains(&Event::CloseTerminal),
2893 "EOF command sequence should have triggered a TTY terminal exit, but got events: {all_events:?}",
2894 );
2895 }
2896
2897 #[gpui::test(iterations = 10)]
2898 async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) {
2899 cx.executor().allow_parking();
2900
2901 let (completion_tx, completion_rx) = smol::channel::unbounded();
2902 let (program, args) = ShellBuilder::new(&Shell::System, false)
2903 .build(Some("asdasdasdasd".to_owned()), &["@@@@@".to_owned()]);
2904 let builder = cx
2905 .update(|cx| {
2906 TerminalBuilder::new(
2907 None,
2908 None,
2909 task::Shell::WithArguments {
2910 program,
2911 args,
2912 title_override: None,
2913 },
2914 HashMap::default(),
2915 CursorShape::default(),
2916 AlternateScroll::On,
2917 None,
2918 Vec::new(),
2919 0,
2920 false,
2921 0,
2922 Some(completion_tx),
2923 cx,
2924 Vec::new(),
2925 PathStyle::local(),
2926 None,
2927 )
2928 })
2929 .await
2930 .unwrap();
2931 let terminal = cx.new(|cx| builder.subscribe(cx));
2932
2933 let all_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
2934 cx.update({
2935 let all_events = all_events.clone();
2936 |cx| {
2937 cx.subscribe(&terminal, move |_, e, _| {
2938 all_events.lock().push(e.clone());
2939 })
2940 }
2941 })
2942 .detach();
2943 let completion_check_task = cx.background_spawn(async move {
2944 // The channel may be closed if the terminal is dropped before sending
2945 // the completion signal, which can happen with certain task scheduling orders.
2946 let exit_status = completion_rx.recv().await.ok().flatten();
2947 if let Some(exit_status) = exit_status {
2948 assert!(
2949 !exit_status.success(),
2950 "Wrong shell command should result in a failure"
2951 );
2952 #[cfg(target_os = "windows")]
2953 assert_eq!(exit_status.code(), Some(1));
2954 #[cfg(not(target_os = "windows"))]
2955 assert_eq!(exit_status.code(), Some(127)); // code 127 means "command not found" on Unix
2956 }
2957 });
2958
2959 completion_check_task.await;
2960 cx.executor().timer(Duration::from_millis(500)).await;
2961
2962 assert!(
2963 !all_events
2964 .lock()
2965 .iter()
2966 .any(|event| event == &Event::CloseTerminal),
2967 "Wrong shell command should update the title but not should not close the terminal to show the error message, but got events: {all_events:?}",
2968 );
2969 }
2970
2971 #[test]
2972 fn test_rgb_for_index() {
2973 // Test every possible value in the color cube.
2974 for i in 16..=231 {
2975 let (r, g, b) = rgb_for_index(i);
2976 assert_eq!(i, 16 + 36 * r + 6 * g + b);
2977 }
2978 }
2979
2980 #[test]
2981 fn test_mouse_to_cell_test() {
2982 let mut rng = rand::rng();
2983 const ITERATIONS: usize = 10;
2984 const PRECISION: usize = 1000;
2985
2986 for _ in 0..ITERATIONS {
2987 let viewport_cells = rng.random_range(15..20);
2988 let cell_size =
2989 rng.random_range(5 * PRECISION..20 * PRECISION) as f32 / PRECISION as f32;
2990
2991 let size = crate::TerminalBounds {
2992 cell_width: Pixels::from(cell_size),
2993 line_height: Pixels::from(cell_size),
2994 bounds: bounds(
2995 Point::default(),
2996 size(
2997 Pixels::from(cell_size * (viewport_cells as f32)),
2998 Pixels::from(cell_size * (viewport_cells as f32)),
2999 ),
3000 ),
3001 };
3002
3003 let cells = get_cells(size, &mut rng);
3004 let content = convert_cells_to_content(size, &cells);
3005
3006 for row in 0..(viewport_cells - 1) {
3007 let row = row as usize;
3008 for col in 0..(viewport_cells - 1) {
3009 let col = col as usize;
3010
3011 let row_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
3012 let col_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
3013
3014 let mouse_pos = point(
3015 Pixels::from(col as f32 * cell_size + col_offset),
3016 Pixels::from(row as f32 * cell_size + row_offset),
3017 );
3018
3019 let content_index =
3020 content_index_for_mouse(mouse_pos, &content.terminal_bounds);
3021 let mouse_cell = content.cells[content_index].c;
3022 let real_cell = cells[row][col];
3023
3024 assert_eq!(mouse_cell, real_cell);
3025 }
3026 }
3027 }
3028 }
3029
3030 #[test]
3031 fn test_mouse_to_cell_clamp() {
3032 let mut rng = rand::rng();
3033
3034 let size = crate::TerminalBounds {
3035 cell_width: Pixels::from(10.),
3036 line_height: Pixels::from(10.),
3037 bounds: bounds(
3038 Point::default(),
3039 size(Pixels::from(100.), Pixels::from(100.)),
3040 ),
3041 };
3042
3043 let cells = get_cells(size, &mut rng);
3044 let content = convert_cells_to_content(size, &cells);
3045
3046 assert_eq!(
3047 content.cells[content_index_for_mouse(
3048 point(Pixels::from(-10.), Pixels::from(-10.)),
3049 &content.terminal_bounds,
3050 )]
3051 .c,
3052 cells[0][0]
3053 );
3054 assert_eq!(
3055 content.cells[content_index_for_mouse(
3056 point(Pixels::from(1000.), Pixels::from(1000.)),
3057 &content.terminal_bounds,
3058 )]
3059 .c,
3060 cells[9][9]
3061 );
3062 }
3063
3064 fn get_cells(size: TerminalBounds, rng: &mut ThreadRng) -> Vec<Vec<char>> {
3065 let mut cells = Vec::new();
3066
3067 for _ in 0..((size.height() / size.line_height()) as usize) {
3068 let mut row_vec = Vec::new();
3069 for _ in 0..((size.width() / size.cell_width()) as usize) {
3070 let cell_char = rng.sample(distr::Alphanumeric) as char;
3071 row_vec.push(cell_char)
3072 }
3073 cells.push(row_vec)
3074 }
3075
3076 cells
3077 }
3078
3079 fn convert_cells_to_content(
3080 terminal_bounds: TerminalBounds,
3081 cells: &[Vec<char>],
3082 ) -> TerminalContent {
3083 let mut ic = Vec::new();
3084
3085 for (index, row) in cells.iter().enumerate() {
3086 for (cell_index, cell_char) in row.iter().enumerate() {
3087 ic.push(IndexedCell {
3088 point: AlacPoint::new(Line(index as i32), Column(cell_index)),
3089 cell: Cell {
3090 c: *cell_char,
3091 ..Default::default()
3092 },
3093 });
3094 }
3095 }
3096
3097 TerminalContent {
3098 cells: ic,
3099 terminal_bounds,
3100 ..Default::default()
3101 }
3102 }
3103
3104 #[gpui::test]
3105 async fn test_write_output_converts_lf_to_crlf(cx: &mut TestAppContext) {
3106 let terminal = cx.new(|cx| {
3107 TerminalBuilder::new_display_only(
3108 CursorShape::default(),
3109 AlternateScroll::On,
3110 None,
3111 0,
3112 cx.background_executor(),
3113 PathStyle::local(),
3114 )
3115 .unwrap()
3116 .subscribe(cx)
3117 });
3118
3119 // Test simple LF conversion
3120 terminal.update(cx, |terminal, cx| {
3121 terminal.write_output(b"line1\nline2\n", cx);
3122 });
3123
3124 // Get the content by directly accessing the term
3125 let content = terminal.update(cx, |terminal, _cx| {
3126 let term = terminal.term.lock_unfair();
3127 Terminal::make_content(&term, &terminal.last_content)
3128 });
3129
3130 // If LF is properly converted to CRLF, each line should start at column 0
3131 // The diagonal staircase bug would cause increasing column positions
3132
3133 // Get the cells and check that lines start at column 0
3134 let cells = &content.cells;
3135 let mut line1_col0 = false;
3136 let mut line2_col0 = false;
3137
3138 for cell in cells {
3139 if cell.c == 'l' && cell.point.column.0 == 0 {
3140 if cell.point.line.0 == 0 && !line1_col0 {
3141 line1_col0 = true;
3142 } else if cell.point.line.0 == 1 && !line2_col0 {
3143 line2_col0 = true;
3144 }
3145 }
3146 }
3147
3148 assert!(line1_col0, "First line should start at column 0");
3149 assert!(line2_col0, "Second line should start at column 0");
3150 }
3151
3152 #[gpui::test]
3153 async fn test_write_output_preserves_existing_crlf(cx: &mut TestAppContext) {
3154 let terminal = cx.new(|cx| {
3155 TerminalBuilder::new_display_only(
3156 CursorShape::default(),
3157 AlternateScroll::On,
3158 None,
3159 0,
3160 cx.background_executor(),
3161 PathStyle::local(),
3162 )
3163 .unwrap()
3164 .subscribe(cx)
3165 });
3166
3167 // Test that existing CRLF doesn't get doubled
3168 terminal.update(cx, |terminal, cx| {
3169 terminal.write_output(b"line1\r\nline2\r\n", cx);
3170 });
3171
3172 // Get the content by directly accessing the term
3173 let content = terminal.update(cx, |terminal, _cx| {
3174 let term = terminal.term.lock_unfair();
3175 Terminal::make_content(&term, &terminal.last_content)
3176 });
3177
3178 let cells = &content.cells;
3179
3180 // Check that both lines start at column 0
3181 let mut found_lines_at_column_0 = 0;
3182 for cell in cells {
3183 if cell.c == 'l' && cell.point.column.0 == 0 {
3184 found_lines_at_column_0 += 1;
3185 }
3186 }
3187
3188 assert!(
3189 found_lines_at_column_0 >= 2,
3190 "Both lines should start at column 0"
3191 );
3192 }
3193
3194 #[gpui::test]
3195 async fn test_write_output_preserves_bare_cr(cx: &mut TestAppContext) {
3196 let terminal = cx.new(|cx| {
3197 TerminalBuilder::new_display_only(
3198 CursorShape::default(),
3199 AlternateScroll::On,
3200 None,
3201 0,
3202 cx.background_executor(),
3203 PathStyle::local(),
3204 )
3205 .unwrap()
3206 .subscribe(cx)
3207 });
3208
3209 // Test that bare CR (without LF) is preserved
3210 terminal.update(cx, |terminal, cx| {
3211 terminal.write_output(b"hello\rworld", cx);
3212 });
3213
3214 // Get the content by directly accessing the term
3215 let content = terminal.update(cx, |terminal, _cx| {
3216 let term = terminal.term.lock_unfair();
3217 Terminal::make_content(&term, &terminal.last_content)
3218 });
3219
3220 let cells = &content.cells;
3221
3222 // Check that we have "world" at the beginning of the line
3223 let mut text = String::new();
3224 for cell in cells.iter().take(5) {
3225 if cell.point.line.0 == 0 {
3226 text.push(cell.c);
3227 }
3228 }
3229
3230 assert!(
3231 text.starts_with("world"),
3232 "Bare CR should allow overwriting: got '{}'",
3233 text
3234 );
3235 }
3236
3237 #[gpui::test]
3238 async fn test_hyperlink_ctrl_click_same_position(cx: &mut TestAppContext) {
3239 let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
3240
3241 terminal.update(cx, |terminal, cx| {
3242 let click_position = point(px(80.0), px(10.0));
3243 ctrl_mouse_down_at(terminal, click_position, cx);
3244 ctrl_mouse_up_at(terminal, click_position, cx);
3245
3246 assert!(
3247 terminal
3248 .events
3249 .iter()
3250 .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
3251 "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position"
3252 );
3253 });
3254 }
3255
3256 #[gpui::test]
3257 async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) {
3258 let terminal = init_ctrl_click_hyperlink_test(
3259 cx,
3260 b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
3261 );
3262
3263 terminal.update(cx, |terminal, cx| {
3264 let down_position = point(px(80.0), px(10.0));
3265 let up_position = point(px(10.0), px(50.0));
3266
3267 ctrl_mouse_down_at(terminal, down_position, cx);
3268 ctrl_mouse_move_to(terminal, up_position, cx);
3269 ctrl_mouse_up_at(terminal, up_position, cx);
3270
3271 assert!(
3272 !terminal
3273 .events
3274 .iter()
3275 .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
3276 "Should NOT have ProcessHyperlink event when dragging outside the hyperlink"
3277 );
3278 });
3279 }
3280
3281 #[gpui::test]
3282 async fn test_hyperlink_ctrl_click_drag_within_bounds(cx: &mut TestAppContext) {
3283 let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
3284
3285 terminal.update(cx, |terminal, cx| {
3286 let down_position = point(px(70.0), px(10.0));
3287 let up_position = point(px(130.0), px(10.0));
3288
3289 ctrl_mouse_down_at(terminal, down_position, cx);
3290 ctrl_mouse_move_to(terminal, up_position, cx);
3291 ctrl_mouse_up_at(terminal, up_position, cx);
3292
3293 assert!(
3294 terminal
3295 .events
3296 .iter()
3297 .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
3298 "Should have ProcessHyperlink event when dragging within hyperlink bounds"
3299 );
3300 });
3301 }
3302
3303 /// Test that kill_active_task properly terminates both the foreground process
3304 /// and the shell, allowing wait_for_completed_task to complete and output to be captured.
3305 #[cfg(unix)]
3306 #[gpui::test]
3307 async fn test_kill_active_task_completes_and_captures_output(cx: &mut TestAppContext) {
3308 cx.executor().allow_parking();
3309
3310 // Run a command that prints output then sleeps for a long time
3311 // The echo ensures we have output to capture before killing
3312 let (terminal, completion_rx) =
3313 build_test_terminal(cx, "echo", &["test_output_before_kill; sleep 60"]).await;
3314
3315 // Wait a bit for the echo to execute and produce output
3316 cx.background_executor
3317 .timer(Duration::from_millis(200))
3318 .await;
3319
3320 // Kill the active task
3321 terminal.update(cx, |term, _cx| {
3322 term.kill_active_task();
3323 });
3324
3325 // wait_for_completed_task should complete within a reasonable time (not hang)
3326 let completion_result = completion_rx.recv().await;
3327 assert!(
3328 completion_result.is_ok(),
3329 "wait_for_completed_task should complete after kill_active_task, but it timed out"
3330 );
3331
3332 // The exit status should indicate the process was killed (not a clean exit)
3333 let exit_status = completion_result.unwrap();
3334 assert!(
3335 exit_status.is_some(),
3336 "Should have received an exit status after killing"
3337 );
3338
3339 // Verify that output captured before killing is still available
3340 let content = terminal.update(cx, |term, _| term.get_content());
3341 assert!(
3342 content.contains("test_output_before_kill"),
3343 "Output from before kill should be captured, got: {content}"
3344 );
3345 }
3346
3347 /// Test that kill_active_task on a task that's not running is a no-op
3348 #[gpui::test]
3349 async fn test_kill_active_task_on_completed_task_is_noop(cx: &mut TestAppContext) {
3350 cx.executor().allow_parking();
3351
3352 // Run a command that exits immediately
3353 let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["done"]).await;
3354
3355 // Wait for the command to complete naturally
3356 let exit_status = completion_rx
3357 .recv()
3358 .await
3359 .expect("Should receive exit status");
3360 assert_eq!(exit_status, Some(ExitStatus::default()));
3361
3362 // Now try to kill - should be a no-op since task already completed
3363 terminal.update(cx, |term, _cx| {
3364 term.kill_active_task();
3365 });
3366
3367 // Content should still be there
3368 let content = terminal.update(cx, |term, _| term.get_content());
3369 assert!(
3370 content.contains("done"),
3371 "Output should still be present after no-op kill, got: {content}"
3372 );
3373 }
3374
3375 mod perf {
3376 use super::super::*;
3377 use gpui::{
3378 Entity, Point, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualContext,
3379 VisualTestContext, point,
3380 };
3381 use util::default;
3382 use util_macros::perf;
3383
3384 async fn init_scroll_perf_test(
3385 cx: &mut TestAppContext,
3386 ) -> (Entity<Terminal>, &mut VisualTestContext) {
3387 cx.update(|cx| {
3388 let settings_store = settings::SettingsStore::test(cx);
3389 cx.set_global(settings_store);
3390 });
3391
3392 cx.executor().allow_parking();
3393
3394 let window = cx.add_empty_window();
3395 let builder = window
3396 .update(|window, cx| {
3397 let settings = TerminalSettings::get_global(cx);
3398 let test_path_hyperlink_timeout_ms = 100;
3399 TerminalBuilder::new(
3400 None,
3401 None,
3402 task::Shell::System,
3403 HashMap::default(),
3404 CursorShape::default(),
3405 AlternateScroll::On,
3406 None,
3407 settings.path_hyperlink_regexes.clone(),
3408 test_path_hyperlink_timeout_ms,
3409 false,
3410 window.window_handle().window_id().as_u64(),
3411 None,
3412 cx,
3413 vec![],
3414 PathStyle::local(),
3415 None,
3416 )
3417 })
3418 .await
3419 .unwrap();
3420 let terminal = window.new(|cx| builder.subscribe(cx));
3421
3422 terminal.update(window, |term, cx| {
3423 term.write_output("long line ".repeat(1000).as_bytes(), cx);
3424 });
3425
3426 (terminal, window)
3427 }
3428
3429 #[perf]
3430 #[gpui::test]
3431 async fn scroll_long_line_benchmark(cx: &mut TestAppContext) {
3432 let (terminal, window) = init_scroll_perf_test(cx).await;
3433 let wobble = point(FIND_HYPERLINK_THROTTLE_PX, px(0.0));
3434 let mut scroll_by = |lines: i32| {
3435 window.update_window_entity(&terminal, |terminal, window, cx| {
3436 let bounds = terminal.last_content.terminal_bounds.bounds;
3437 let center = bounds.origin + bounds.center();
3438 let position = center + wobble * lines as f32;
3439
3440 terminal.mouse_move(
3441 &MouseMoveEvent {
3442 position,
3443 ..default()
3444 },
3445 cx,
3446 );
3447
3448 terminal.scroll_wheel(
3449 &ScrollWheelEvent {
3450 position,
3451 delta: ScrollDelta::Lines(Point::new(0.0, lines as f32)),
3452 ..default()
3453 },
3454 1.0,
3455 );
3456
3457 assert!(
3458 terminal
3459 .events
3460 .iter()
3461 .any(|event| matches!(event, InternalEvent::Scroll(_))),
3462 "Should have Scroll event when scrolling within terminal bounds"
3463 );
3464 terminal.sync(window, cx);
3465 });
3466 };
3467
3468 for _ in 0..20000 {
3469 scroll_by(1);
3470 scroll_by(-1);
3471 }
3472 }
3473 }
3474}