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 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        if self.modal {
323            context.set.insert("ModalTerminal".into());
324        }
325        context
326    }
327}
328
329impl Item for Terminal {
330    fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
331        let settings = cx.global::<Settings>();
332        let search_theme = &settings.theme.search; //TODO properly integrate themes
333
334        let mut flex = Flex::row();
335
336        if self.has_bell {
337            flex.add_child(
338                Svg::new("icons/zap.svg") //TODO: Swap out for a better icon, or at least resize this
339                    .with_color(tab_theme.label.text.color)
340                    .constrained()
341                    .with_width(search_theme.tab_icon_width)
342                    .aligned()
343                    .boxed(),
344            );
345        };
346
347        flex.with_child(
348            Label::new(
349                self.connection.read(cx).title.clone(),
350                tab_theme.label.clone(),
351            )
352            .aligned()
353            .contained()
354            .with_margin_left(if self.has_bell {
355                search_theme.tab_icon_spacing
356            } else {
357                0.
358            })
359            .boxed(),
360        )
361        .boxed()
362    }
363
364    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
365        //From what I can tell, there's no  way to tell the current working
366        //Directory of the terminal from outside the terminal. There might be
367        //solutions to this, but they are non-trivial and require more IPC
368        Some(Terminal::new(
369            self.connection.read(cx).associated_directory.clone(),
370            false,
371            cx,
372        ))
373    }
374
375    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
376        None
377    }
378
379    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
380        SmallVec::new()
381    }
382
383    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
384        false
385    }
386
387    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
388
389    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
390        false
391    }
392
393    fn save(
394        &mut self,
395        _project: gpui::ModelHandle<Project>,
396        _cx: &mut ViewContext<Self>,
397    ) -> gpui::Task<gpui::anyhow::Result<()>> {
398        unreachable!("save should not have been called");
399    }
400
401    fn save_as(
402        &mut self,
403        _project: gpui::ModelHandle<Project>,
404        _abs_path: std::path::PathBuf,
405        _cx: &mut ViewContext<Self>,
406    ) -> gpui::Task<gpui::anyhow::Result<()>> {
407        unreachable!("save_as should not have been called");
408    }
409
410    fn reload(
411        &mut self,
412        _project: gpui::ModelHandle<Project>,
413        _cx: &mut ViewContext<Self>,
414    ) -> gpui::Task<gpui::anyhow::Result<()>> {
415        gpui::Task::ready(Ok(()))
416    }
417
418    fn is_dirty(&self, _: &gpui::AppContext) -> bool {
419        self.has_new_content
420    }
421
422    fn should_update_tab_on_event(event: &Self::Event) -> bool {
423        matches!(event, &Event::TitleChanged)
424    }
425
426    fn should_close_item_on_event(event: &Self::Event) -> bool {
427        matches!(event, &Event::CloseTerminal)
428    }
429
430    fn should_activate_item_on_event(event: &Self::Event) -> bool {
431        matches!(event, &Event::Activate)
432    }
433}
434
435fn get_working_directory(wt: &LocalWorktree) -> Option<PathBuf> {
436    Some(wt.abs_path().to_path_buf())
437        .filter(|path| path.is_dir())
438        .or_else(|| home_dir())
439}
440
441#[cfg(test)]
442mod tests {
443
444    use super::*;
445    use alacritty_terminal::{
446        grid::GridIterator,
447        index::{Column, Line, Point, Side},
448        selection::{Selection, SelectionType},
449        term::cell::Cell,
450    };
451    use gpui::TestAppContext;
452    use itertools::Itertools;
453    use project::{FakeFs, Fs, RealFs, RemoveOptions, Worktree};
454    use std::{
455        path::Path,
456        sync::{atomic::AtomicUsize, Arc},
457        time::Duration,
458    };
459
460    ///Basic integration test, can we get the terminal to show up, execute a command,
461    //and produce noticable output?
462    #[gpui::test]
463    async fn test_terminal(cx: &mut TestAppContext) {
464        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
465        cx.set_condition_duration(Duration::from_secs(2));
466        terminal.update(cx, |terminal, cx| {
467            terminal.connection.update(cx, |connection, cx| {
468                connection.write_to_pty("expr 3 + 4".to_string(), cx);
469            });
470            terminal.carriage_return(&Return, cx);
471        });
472
473        terminal
474            .condition(cx, |terminal, cx| {
475                let term = terminal.connection.read(cx).term.clone();
476                let content = grid_as_str(term.lock().renderable_content().display_iter);
477                content.contains("7")
478            })
479            .await;
480    }
481
482    #[gpui::test]
483    async fn single_file_worktree(cx: &mut TestAppContext) {
484        let mut async_cx = cx.to_async();
485        let http_client = client::test::FakeHttpClient::with_404_response();
486        let client = client::Client::new(http_client.clone());
487        let fake_fs = FakeFs::new(cx.background().clone());
488
489        let path = Path::new("/file/");
490        fake_fs.insert_file(path, "a".to_string()).await;
491
492        let worktree_handle = Worktree::local(
493            client,
494            path,
495            true,
496            fake_fs,
497            Arc::new(AtomicUsize::new(0)),
498            &mut async_cx,
499        )
500        .await
501        .ok()
502        .unwrap();
503
504        async_cx.update(|cx| {
505            let wt = worktree_handle.read(cx).as_local().unwrap();
506            let wd = get_working_directory(wt);
507            assert!(wd.is_some());
508            let path = wd.unwrap();
509            //This should be the system's working directory, so querying the real file system is probably ok.
510            assert!(path.is_dir());
511            assert_eq!(path, home_dir().unwrap());
512        });
513    }
514
515    #[gpui::test]
516    async fn test_worktree_directory(cx: &mut TestAppContext) {
517        let mut async_cx = cx.to_async();
518        let http_client = client::test::FakeHttpClient::with_404_response();
519        let client = client::Client::new(http_client.clone());
520
521        let fs = RealFs;
522        let mut test_wd = home_dir().unwrap();
523        test_wd.push("dir");
524
525        fs.create_dir(test_wd.as_path())
526            .await
527            .expect("File could not be created");
528
529        let worktree_handle = Worktree::local(
530            client,
531            test_wd.clone(),
532            true,
533            Arc::new(RealFs),
534            Arc::new(AtomicUsize::new(0)),
535            &mut async_cx,
536        )
537        .await
538        .ok()
539        .unwrap();
540
541        async_cx.update(|cx| {
542            let wt = worktree_handle.read(cx).as_local().unwrap();
543            let wd = get_working_directory(wt);
544            assert!(wd.is_some());
545            let path = wd.unwrap();
546            assert!(path.is_dir());
547            assert_eq!(path, test_wd);
548        });
549
550        //Clean up after ourselves.
551        fs.remove_dir(
552            test_wd.as_path(),
553            RemoveOptions {
554                recursive: false,
555                ignore_if_not_exists: true,
556            },
557        )
558        .await
559        .ok()
560        .expect("Could not remove test directory");
561    }
562
563    ///If this test is failing for you, check that DEBUG_TERMINAL_WIDTH is wide enough to fit your entire command prompt!
564    #[gpui::test]
565    async fn test_copy(cx: &mut TestAppContext) {
566        let mut result_line: i32 = 0;
567        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
568        cx.set_condition_duration(Duration::from_secs(2));
569
570        terminal.update(cx, |terminal, cx| {
571            terminal.connection.update(cx, |connection, cx| {
572                connection.write_to_pty("expr 3 + 4".to_string(), cx);
573            });
574            terminal.carriage_return(&Return, cx);
575        });
576
577        terminal
578            .condition(cx, |terminal, cx| {
579                let term = terminal.connection.read(cx).term.clone();
580                let content = grid_as_str(term.lock().renderable_content().display_iter);
581
582                if content.contains("7") {
583                    let idx = content.chars().position(|c| c == '7').unwrap();
584                    result_line = content.chars().take(idx).filter(|c| *c == '\n').count() as i32;
585                    true
586                } else {
587                    false
588                }
589            })
590            .await;
591
592        terminal.update(cx, |terminal, cx| {
593            let mut term = terminal.connection.read(cx).term.lock();
594            term.selection = Some(Selection::new(
595                SelectionType::Semantic,
596                Point::new(Line(2), Column(0)),
597                Side::Right,
598            ));
599            drop(term);
600            terminal.copy(&Copy, cx)
601        });
602
603        cx.assert_clipboard_content(Some(&"7"));
604    }
605
606    pub(crate) fn grid_as_str(grid_iterator: GridIterator<Cell>) -> String {
607        let lines = grid_iterator.group_by(|i| i.point.line.0);
608        lines
609            .into_iter()
610            .map(|(_, line)| line.map(|i| i.c).collect::<String>())
611            .collect::<Vec<String>>()
612            .join("\n")
613    }
614}