terminal.rs

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