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}