terminal.rs

  1pub mod color_translation;
  2pub mod gpui_func_tools;
  3mod modal;
  4pub mod terminal_element;
  5
  6use alacritty_terminal::{
  7    config::{Config, PtyConfig},
  8    event::{Event as AlacTermEvent, EventListener, Notify},
  9    event_loop::{EventLoop, Msg, Notifier},
 10    grid::Scroll,
 11    sync::FairMutex,
 12    term::SizeInfo,
 13    tty::{self, setup_env},
 14    Term,
 15};
 16use color_translation::{get_color_at_index, to_alac_rgb};
 17use dirs::home_dir;
 18use futures::{
 19    channel::mpsc::{unbounded, UnboundedSender},
 20    StreamExt,
 21};
 22use gpui::{
 23    actions, elements::*, impl_internal_actions, platform::CursorStyle, ClipboardItem, Entity,
 24    MutableAppContext, View, ViewContext,
 25};
 26use modal::deploy_modal;
 27use project::{LocalWorktree, Project, ProjectPath};
 28use settings::Settings;
 29use smallvec::SmallVec;
 30use std::{collections::HashMap, path::PathBuf, sync::Arc};
 31use workspace::{Item, Workspace};
 32
 33use crate::terminal_element::TerminalEl;
 34
 35//ASCII Control characters on a keyboard
 36const ETX_CHAR: char = 3_u8 as char; //'End of text', the control code for 'ctrl-c'
 37const TAB_CHAR: char = 9_u8 as char;
 38const CARRIAGE_RETURN_CHAR: char = 13_u8 as char;
 39const ESC_CHAR: char = 27_u8 as char; // == \x1b
 40const DEL_CHAR: char = 127_u8 as char;
 41const LEFT_SEQ: &str = "\x1b[D";
 42const RIGHT_SEQ: &str = "\x1b[C";
 43const UP_SEQ: &str = "\x1b[A";
 44const DOWN_SEQ: &str = "\x1b[B";
 45const DEFAULT_TITLE: &str = "Terminal";
 46const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
 47const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
 48const DEBUG_CELL_WIDTH: f32 = 5.;
 49const DEBUG_LINE_HEIGHT: f32 = 5.;
 50
 51///Action for carrying the input to the PTY
 52#[derive(Clone, Default, Debug, PartialEq, Eq)]
 53pub struct Input(pub String);
 54
 55///Event to transmit the scroll from the element to the view
 56#[derive(Clone, Debug, PartialEq)]
 57pub struct ScrollTerminal(pub i32);
 58
 59actions!(
 60    terminal,
 61    [
 62        Sigint,
 63        Escape,
 64        Del,
 65        Return,
 66        Left,
 67        Right,
 68        Up,
 69        Down,
 70        Tab,
 71        Clear,
 72        Copy,
 73        Paste,
 74        Deploy,
 75        Quit,
 76        DeployModal,
 77    ]
 78);
 79impl_internal_actions!(terminal, [Input, ScrollTerminal]);
 80
 81///Initialize and register all of our action handlers
 82pub fn init(cx: &mut MutableAppContext) {
 83    cx.add_action(Terminal::deploy);
 84    cx.add_action(Terminal::write_to_pty);
 85    cx.add_action(Terminal::send_sigint);
 86    cx.add_action(Terminal::escape);
 87    cx.add_action(Terminal::quit);
 88    cx.add_action(Terminal::del);
 89    cx.add_action(Terminal::carriage_return);
 90    cx.add_action(Terminal::left);
 91    cx.add_action(Terminal::right);
 92    cx.add_action(Terminal::up);
 93    cx.add_action(Terminal::down);
 94    cx.add_action(Terminal::tab);
 95    cx.add_action(Terminal::copy);
 96    cx.add_action(Terminal::paste);
 97    cx.add_action(Terminal::scroll_terminal);
 98    cx.add_action(deploy_modal);
 99}
100
101///A translation struct for Alacritty to communicate with us from their event loop
102#[derive(Clone)]
103pub struct ZedListener(UnboundedSender<AlacTermEvent>);
104
105impl EventListener for ZedListener {
106    fn send_event(&self, event: AlacTermEvent) {
107        self.0.unbounded_send(event).ok();
108    }
109}
110
111///A terminal view, maintains the PTY's file handles and communicates with the terminal
112pub struct Terminal {
113    pty_tx: Notifier,
114    term: Arc<FairMutex<Term<ZedListener>>>,
115    title: String,
116    has_new_content: bool,
117    has_bell: bool, //Currently using iTerm bell, show bell emoji in tab until input is received
118    cur_size: SizeInfo,
119    modal: bool,
120    associated_directory: Option<PathBuf>,
121}
122
123///Upward flowing events, for changing the title and such
124pub enum Event {
125    TitleChanged,
126    CloseTerminal,
127    Activate,
128}
129
130impl Entity for Terminal {
131    type Event = Event;
132}
133
134impl Terminal {
135    ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
136    fn new(cx: &mut ViewContext<Self>, working_directory: Option<PathBuf>, modal: bool) -> Self {
137        //Spawn a task so the Alacritty EventLoop can communicate with us in a view context
138        let (events_tx, mut events_rx) = unbounded();
139        cx.spawn_weak(|this, mut cx| async move {
140            while let Some(event) = events_rx.next().await {
141                match this.upgrade(&cx) {
142                    Some(handle) => {
143                        handle.update(&mut cx, |this, cx| {
144                            this.process_terminal_event(event, cx);
145                            cx.notify();
146                        });
147                    }
148                    None => break,
149                }
150            }
151        })
152        .detach();
153
154        let pty_config = PtyConfig {
155            shell: None, //Use the users default shell
156            working_directory: working_directory.clone(),
157            hold: false,
158        };
159
160        let mut env: HashMap<String, String> = HashMap::new();
161        //TODO: Properly set the current locale,
162        env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
163
164        let config = Config {
165            pty_config: pty_config.clone(),
166            env,
167            ..Default::default()
168        };
169
170        setup_env(&config);
171
172        //The details here don't matter, the terminal will be resized on the first layout
173        let size_info = SizeInfo::new(
174            DEBUG_TERMINAL_WIDTH,
175            DEBUG_TERMINAL_HEIGHT,
176            DEBUG_CELL_WIDTH,
177            DEBUG_LINE_HEIGHT,
178            0.,
179            0.,
180            false,
181        );
182
183        //Set up the terminal...
184        let term = Term::new(&config, size_info, ZedListener(events_tx.clone()));
185        let term = Arc::new(FairMutex::new(term));
186
187        //Setup the pty...
188        let pty = tty::new(&pty_config, &size_info, None).expect("Could not create tty");
189
190        //And connect them together
191        let event_loop = EventLoop::new(
192            term.clone(),
193            ZedListener(events_tx.clone()),
194            pty,
195            pty_config.hold,
196            false,
197        );
198
199        //Kick things off
200        let pty_tx = Notifier(event_loop.channel());
201        let _io_thread = event_loop.spawn();
202        Terminal {
203            title: DEFAULT_TITLE.to_string(),
204            term,
205            pty_tx,
206            has_new_content: false,
207            has_bell: false,
208            cur_size: size_info,
209            modal,
210            associated_directory: working_directory,
211        }
212    }
213
214    ///Takes events from Alacritty and translates them to behavior on this view
215    fn process_terminal_event(
216        &mut self,
217        event: alacritty_terminal::event::Event,
218        cx: &mut ViewContext<Self>,
219    ) {
220        match event {
221            AlacTermEvent::Wakeup => {
222                if !cx.is_self_focused() {
223                    self.has_new_content = true; //Change tab content
224                    cx.emit(Event::TitleChanged);
225                } else {
226                    cx.notify()
227                }
228            }
229            AlacTermEvent::PtyWrite(out) => self.write_to_pty(&Input(out), cx),
230            AlacTermEvent::MouseCursorDirty => {
231                //Calculate new cursor style.
232                //TODO
233                //Check on correctly handling mouse events for terminals
234                cx.platform().set_cursor_style(CursorStyle::Arrow); //???
235            }
236            AlacTermEvent::Title(title) => {
237                self.title = title;
238                cx.emit(Event::TitleChanged);
239            }
240            AlacTermEvent::ResetTitle => {
241                self.title = DEFAULT_TITLE.to_string();
242                cx.emit(Event::TitleChanged);
243            }
244            AlacTermEvent::ClipboardStore(_, data) => {
245                cx.write_to_clipboard(ClipboardItem::new(data))
246            }
247            AlacTermEvent::ClipboardLoad(_, format) => self.write_to_pty(
248                &Input(format(
249                    &cx.read_from_clipboard()
250                        .map(|ci| ci.text().to_string())
251                        .unwrap_or("".to_string()),
252                )),
253                cx,
254            ),
255            AlacTermEvent::ColorRequest(index, format) => {
256                let color = self.term.lock().colors()[index].unwrap_or_else(|| {
257                    let term_style = &cx.global::<Settings>().theme.terminal;
258                    to_alac_rgb(get_color_at_index(&index, &term_style.colors))
259                });
260                self.write_to_pty(&Input(format(color)), cx)
261            }
262            AlacTermEvent::CursorBlinkingChange => {
263                //TODO: Set a timer to blink the cursor on and off
264            }
265            AlacTermEvent::Bell => {
266                self.has_bell = true;
267                cx.emit(Event::TitleChanged);
268            }
269            AlacTermEvent::Exit => self.quit(&Quit, cx),
270        }
271    }
272
273    ///Resize the terminal and the PTY. This locks the terminal.
274    fn set_size(&mut self, new_size: SizeInfo) {
275        if new_size != self.cur_size {
276            self.pty_tx.0.send(Msg::Resize(new_size)).ok();
277            self.term.lock().resize(new_size);
278            self.cur_size = new_size;
279        }
280    }
281
282    ///Scroll the terminal. This locks the terminal
283    fn scroll_terminal(&mut self, scroll: &ScrollTerminal, _: &mut ViewContext<Self>) {
284        self.term.lock().scroll_display(Scroll::Delta(scroll.0));
285    }
286
287    ///Create a new Terminal in the current working directory or the user's home directory
288    fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
289        let project = workspace.project().read(cx);
290
291        let abs_path = project
292            .active_entry()
293            .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
294            .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
295            .and_then(get_working_directory);
296
297        workspace.add_item(
298            Box::new(cx.add_view(|cx| Terminal::new(cx, abs_path, false))),
299            cx,
300        );
301    }
302
303    ///Send the shutdown message to Alacritty
304    fn shutdown_pty(&mut self) {
305        self.pty_tx.0.send(Msg::Shutdown).ok();
306    }
307
308    ///Tell Zed to close us
309    fn quit(&mut self, _: &Quit, cx: &mut ViewContext<Self>) {
310        cx.emit(Event::CloseTerminal);
311    }
312
313    ///Attempt to paste the clipboard into the terminal
314    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
315        let term = self.term.lock();
316        let copy_text = term.selection_to_string();
317        match copy_text {
318            Some(s) => cx.write_to_clipboard(ClipboardItem::new(s)),
319            None => (),
320        }
321    }
322
323    ///Attempt to paste the clipboard into the terminal
324    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
325        if let Some(item) = cx.read_from_clipboard() {
326            self.write_to_pty(&Input(item.text().to_owned()), cx);
327        }
328    }
329
330    ///Write the Input payload to the tty. This locks the terminal so we can scroll it.
331    fn write_to_pty(&mut self, input: &Input, cx: &mut ViewContext<Self>) {
332        self.write_bytes_to_pty(input.0.clone().into_bytes(), cx);
333    }
334
335    ///Write the Input payload to the tty. This locks the terminal so we can scroll it.
336    fn write_bytes_to_pty(&mut self, input: Vec<u8>, cx: &mut ViewContext<Self>) {
337        //iTerm bell behavior, bell stays until terminal is interacted with
338        self.has_bell = false;
339        cx.emit(Event::TitleChanged);
340        self.term.lock().scroll_display(Scroll::Bottom);
341        self.pty_tx.notify(input);
342    }
343
344    ///Send the `up` key
345    fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
346        self.write_to_pty(&Input(UP_SEQ.to_string()), cx);
347    }
348
349    ///Send the `down` key
350    fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
351        self.write_to_pty(&Input(DOWN_SEQ.to_string()), cx);
352    }
353
354    ///Send the `tab` key
355    fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
356        self.write_to_pty(&Input(TAB_CHAR.to_string()), cx);
357    }
358
359    ///Send `SIGINT` (`ctrl-c`)
360    fn send_sigint(&mut self, _: &Sigint, cx: &mut ViewContext<Self>) {
361        self.write_to_pty(&Input(ETX_CHAR.to_string()), cx);
362    }
363
364    ///Send the `escape` key
365    fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
366        self.write_to_pty(&Input(ESC_CHAR.to_string()), cx);
367    }
368
369    ///Send the `delete` key. TODO: Difference between this and backspace?
370    fn del(&mut self, _: &Del, cx: &mut ViewContext<Self>) {
371        // self.write_to_pty(&Input("\x1b[3~".to_string()), cx)
372        self.write_to_pty(&Input(DEL_CHAR.to_string()), cx);
373    }
374
375    ///Send a carriage return. TODO: May need to check the terminal mode.
376    fn carriage_return(&mut self, _: &Return, cx: &mut ViewContext<Self>) {
377        self.write_to_pty(&Input(CARRIAGE_RETURN_CHAR.to_string()), cx);
378    }
379
380    //Send the `left` key
381    fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
382        self.write_to_pty(&Input(LEFT_SEQ.to_string()), cx);
383    }
384
385    //Send the `right` key
386    fn right(&mut self, _: &Right, cx: &mut ViewContext<Self>) {
387        self.write_to_pty(&Input(RIGHT_SEQ.to_string()), cx);
388    }
389}
390
391impl Drop for Terminal {
392    fn drop(&mut self) {
393        self.shutdown_pty();
394    }
395}
396
397impl View for Terminal {
398    fn ui_name() -> &'static str {
399        "Terminal"
400    }
401
402    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
403        let element = TerminalEl::new(cx.handle()).contained();
404        if self.modal {
405            let settings = cx.global::<Settings>();
406            let container_style = settings.theme.terminal.modal_container;
407            element.with_style(container_style).boxed()
408        } else {
409            element.boxed()
410        }
411    }
412
413    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
414        cx.emit(Event::Activate);
415        self.has_new_content = false;
416    }
417
418    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
419        let mut context = Self::default_keymap_context();
420        context.set.insert("ModalTerminal".into());
421        context
422    }
423}
424
425impl Item for Terminal {
426    fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
427        let settings = cx.global::<Settings>();
428        let search_theme = &settings.theme.search; //TODO properly integrate themes
429
430        let mut flex = Flex::row();
431
432        if self.has_bell {
433            flex.add_child(
434                Svg::new("icons/zap.svg") //TODO: Swap out for a better icon, or at least resize this
435                    .with_color(tab_theme.label.text.color)
436                    .constrained()
437                    .with_width(search_theme.tab_icon_width)
438                    .aligned()
439                    .boxed(),
440            );
441        };
442
443        flex.with_child(
444            Label::new(self.title.clone(), tab_theme.label.clone())
445                .aligned()
446                .contained()
447                .with_margin_left(if self.has_bell {
448                    search_theme.tab_icon_spacing
449                } else {
450                    0.
451                })
452                .boxed(),
453        )
454        .boxed()
455    }
456
457    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
458        //From what I can tell, there's no  way to tell the current working
459        //Directory of the terminal from outside the terminal. There might be
460        //solutions to this, but they are non-trivial and require more IPC
461        Some(Terminal::new(cx, self.associated_directory.clone(), false))
462    }
463
464    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
465        None
466    }
467
468    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
469        SmallVec::new()
470    }
471
472    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
473        false
474    }
475
476    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
477
478    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
479        false
480    }
481
482    fn save(
483        &mut self,
484        _project: gpui::ModelHandle<Project>,
485        _cx: &mut ViewContext<Self>,
486    ) -> gpui::Task<gpui::anyhow::Result<()>> {
487        unreachable!("save should not have been called");
488    }
489
490    fn save_as(
491        &mut self,
492        _project: gpui::ModelHandle<Project>,
493        _abs_path: std::path::PathBuf,
494        _cx: &mut ViewContext<Self>,
495    ) -> gpui::Task<gpui::anyhow::Result<()>> {
496        unreachable!("save_as should not have been called");
497    }
498
499    fn reload(
500        &mut self,
501        _project: gpui::ModelHandle<Project>,
502        _cx: &mut ViewContext<Self>,
503    ) -> gpui::Task<gpui::anyhow::Result<()>> {
504        gpui::Task::ready(Ok(()))
505    }
506
507    fn is_dirty(&self, _: &gpui::AppContext) -> bool {
508        self.has_new_content
509    }
510
511    fn should_update_tab_on_event(event: &Self::Event) -> bool {
512        matches!(event, &Event::TitleChanged)
513    }
514
515    fn should_close_item_on_event(event: &Self::Event) -> bool {
516        matches!(event, &Event::CloseTerminal)
517    }
518
519    fn should_activate_item_on_event(event: &Self::Event) -> bool {
520        matches!(event, &Event::Activate)
521    }
522}
523
524fn get_working_directory(wt: &LocalWorktree) -> Option<PathBuf> {
525    Some(wt.abs_path().to_path_buf())
526        .filter(|path| path.is_dir())
527        .or_else(|| home_dir())
528}
529
530#[cfg(test)]
531mod tests {
532
533    use super::*;
534    use alacritty_terminal::{
535        grid::GridIterator,
536        index::{Column, Line, Point, Side},
537        selection::{Selection, SelectionType},
538        term::cell::Cell,
539    };
540    use gpui::TestAppContext;
541    use itertools::Itertools;
542    use project::{FakeFs, Fs, RealFs, RemoveOptions, Worktree};
543    use std::{path::Path, sync::atomic::AtomicUsize, time::Duration};
544
545    ///Basic integration test, can we get the terminal to show up, execute a command,
546    //and produce noticable output?
547    #[gpui::test]
548    async fn test_terminal(cx: &mut TestAppContext) {
549        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(cx, None, false));
550        cx.set_condition_duration(Duration::from_secs(2));
551        terminal.update(cx, |terminal, cx| {
552            terminal.write_to_pty(&Input(("expr 3 + 4".to_string()).to_string()), cx);
553            terminal.carriage_return(&Return, cx);
554        });
555
556        terminal
557            .condition(cx, |terminal, _cx| {
558                let term = terminal.term.clone();
559                let content = grid_as_str(term.lock().renderable_content().display_iter);
560                dbg!(&content);
561                content.contains("7")
562            })
563            .await;
564    }
565
566    #[gpui::test]
567    async fn single_file_worktree(cx: &mut TestAppContext) {
568        let mut async_cx = cx.to_async();
569        let http_client = client::test::FakeHttpClient::with_404_response();
570        let client = client::Client::new(http_client.clone());
571        let fake_fs = FakeFs::new(cx.background().clone());
572
573        let path = Path::new("/file/");
574        fake_fs.insert_file(path, "a".to_string()).await;
575
576        let worktree_handle = Worktree::local(
577            client,
578            path,
579            true,
580            fake_fs,
581            Arc::new(AtomicUsize::new(0)),
582            &mut async_cx,
583        )
584        .await
585        .ok()
586        .unwrap();
587
588        async_cx.update(|cx| {
589            let wt = worktree_handle.read(cx).as_local().unwrap();
590            let wd = get_working_directory(wt);
591            assert!(wd.is_some());
592            let path = wd.unwrap();
593            //This should be the system's working directory, so querying the real file system is probably ok.
594            assert!(path.is_dir());
595            assert_eq!(path, home_dir().unwrap());
596        });
597    }
598
599    #[gpui::test]
600    async fn test_worktree_directory(cx: &mut TestAppContext) {
601        let mut async_cx = cx.to_async();
602        let http_client = client::test::FakeHttpClient::with_404_response();
603        let client = client::Client::new(http_client.clone());
604
605        let fs = RealFs;
606        let mut test_wd = home_dir().unwrap();
607        test_wd.push("dir");
608
609        fs.create_dir(test_wd.as_path())
610            .await
611            .expect("File could not be created");
612
613        let worktree_handle = Worktree::local(
614            client,
615            test_wd.clone(),
616            true,
617            Arc::new(RealFs),
618            Arc::new(AtomicUsize::new(0)),
619            &mut async_cx,
620        )
621        .await
622        .ok()
623        .unwrap();
624
625        async_cx.update(|cx| {
626            let wt = worktree_handle.read(cx).as_local().unwrap();
627            let wd = get_working_directory(wt);
628            assert!(wd.is_some());
629            let path = wd.unwrap();
630            assert!(path.is_dir());
631            assert_eq!(path, test_wd);
632        });
633
634        //Clean up after ourselves.
635        fs.remove_dir(
636            test_wd.as_path(),
637            RemoveOptions {
638                recursive: false,
639                ignore_if_not_exists: true,
640            },
641        )
642        .await
643        .ok()
644        .expect("Could not remove test directory");
645    }
646
647    ///If this test is failing for you, check that DEBUG_TERMINAL_WIDTH is wide enough to fit your entire command prompt!
648    #[gpui::test]
649    async fn test_copy(cx: &mut TestAppContext) {
650        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(cx, None, false));
651        cx.set_condition_duration(Duration::from_secs(2));
652
653        terminal.update(cx, |terminal, cx| {
654            terminal.write_to_pty(&Input(("expr 3 + 4".to_string()).to_string()), cx);
655            terminal.carriage_return(&Return, cx);
656        });
657
658        terminal
659            .condition(cx, |terminal, _cx| {
660                let term = terminal.term.clone();
661                let content = grid_as_str(term.lock().renderable_content().display_iter);
662                content.contains("7")
663            })
664            .await;
665
666        terminal.update(cx, |terminal, cx| {
667            let mut term = terminal.term.lock();
668            term.selection = Some(Selection::new(
669                SelectionType::Semantic,
670                Point::new(Line(2), Column(0)),
671                Side::Right,
672            ));
673            drop(term);
674            terminal.copy(&Copy, cx)
675        });
676
677        cx.assert_clipboard_content(Some(&"7"));
678    }
679
680    pub(crate) fn grid_as_str(grid_iterator: GridIterator<Cell>) -> String {
681        let lines = grid_iterator.group_by(|i| i.point.line.0);
682        lines
683            .into_iter()
684            .map(|(_, line)| line.map(|i| i.c).collect::<String>())
685            .collect::<Vec<String>>()
686            .join("\n")
687    }
688}