1mod color_translation;
2pub mod connection;
3mod modal;
4pub mod terminal_element;
5
6use alacritty_terminal::term::SizeInfo;
7use connection::{Event, TerminalConnection};
8use dirs::home_dir;
9use gpui::{
10 actions, elements::*, keymap::Keystroke, AppContext, ClipboardItem, Entity, ModelHandle,
11 MutableAppContext, View, ViewContext,
12};
13use modal::deploy_modal;
14
15use project::{LocalWorktree, Project, ProjectPath};
16use settings::{Settings, WorkingDirectory};
17use smallvec::SmallVec;
18use std::path::{Path, PathBuf};
19use workspace::{Item, Workspace};
20
21use crate::terminal_element::TerminalEl;
22
23const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
24const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
25const DEBUG_CELL_WIDTH: f32 = 5.;
26const DEBUG_LINE_HEIGHT: f32 = 5.;
27
28///Event to transmit the scroll from the element to the view
29#[derive(Clone, Debug, PartialEq)]
30pub struct ScrollTerminal(pub i32);
31
32actions!(
33 terminal,
34 [
35 Deploy,
36 Up,
37 Down,
38 CtrlC,
39 Escape,
40 Enter,
41 Clear,
42 Copy,
43 Paste,
44 DeployModal
45 ]
46);
47
48///Initialize and register all of our action handlers
49pub fn init(cx: &mut MutableAppContext) {
50 //Global binding overrrides
51 cx.add_action(TerminalView::ctrl_c);
52 cx.add_action(TerminalView::up);
53 cx.add_action(TerminalView::down);
54 cx.add_action(TerminalView::escape);
55 cx.add_action(TerminalView::enter);
56 //Useful terminal actions
57 cx.add_action(TerminalView::deploy);
58 cx.add_action(deploy_modal);
59 cx.add_action(TerminalView::copy);
60 cx.add_action(TerminalView::paste);
61 cx.add_action(TerminalView::clear);
62}
63
64///A terminal view, maintains the PTY's file handles and communicates with the terminal
65pub struct TerminalView {
66 connection: ModelHandle<TerminalConnection>,
67 has_new_content: bool,
68 //Currently using iTerm bell, show bell emoji in tab until input is received
69 has_bell: bool,
70 // Only for styling purposes. Doesn't effect behavior
71 modal: bool,
72}
73
74impl Entity for TerminalView {
75 type Event = Event;
76}
77
78impl TerminalView {
79 ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
80 ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
81 fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
82 //The details here don't matter, the terminal will be resized on the first layout
83 let size_info = SizeInfo::new(
84 DEBUG_TERMINAL_WIDTH,
85 DEBUG_TERMINAL_HEIGHT,
86 DEBUG_CELL_WIDTH,
87 DEBUG_LINE_HEIGHT,
88 0.,
89 0.,
90 false,
91 );
92
93 let (shell, envs) = {
94 let settings = cx.global::<Settings>();
95 let shell = settings.terminal_overrides.shell.clone();
96 let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
97 (shell, envs)
98 };
99
100 let connection = cx
101 .add_model(|cx| TerminalConnection::new(working_directory, shell, envs, size_info, cx));
102
103 TerminalView::from_connection(connection, modal, cx)
104 }
105
106 fn from_connection(
107 connection: ModelHandle<TerminalConnection>,
108 modal: bool,
109 cx: &mut ViewContext<Self>,
110 ) -> TerminalView {
111 cx.observe(&connection, |_, _, cx| cx.notify()).detach();
112 cx.subscribe(&connection, |this, _, event, cx| match event {
113 Event::Wakeup => {
114 if cx.is_self_focused() {
115 cx.notify()
116 } else {
117 this.has_new_content = true;
118 cx.emit(Event::TitleChanged);
119 }
120 }
121 Event::Bell => {
122 this.has_bell = true;
123 cx.emit(Event::TitleChanged);
124 }
125 // Event::Input => {
126 // this.has_bell = false;
127 // cx.emit(Event::TitleChanged);
128 // }
129 _ => cx.emit(*event),
130 })
131 .detach();
132
133 TerminalView {
134 connection,
135 has_new_content: true,
136 has_bell: false,
137 modal,
138 }
139 }
140
141 fn clear_bel(&mut self, cx: &mut ViewContext<Self>) {
142 self.has_bell = false;
143 cx.emit(Event::TitleChanged);
144 }
145
146 fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
147 self.connection
148 .read(cx)
149 .get_terminal()
150 .map(|term| term.clear());
151 }
152
153 ///Create a new Terminal in the current working directory or the user's home directory
154 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
155 let wd = get_wd_for_workspace(workspace, cx);
156 let view = cx.add_view(|cx| TerminalView::new(wd, false, cx));
157 workspace.add_item(Box::new(view), cx);
158 }
159
160 ///Attempt to paste the clipboard into the terminal
161 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
162 self.connection
163 .read(cx)
164 .get_terminal()
165 .and_then(|term| term.copy())
166 .map(|text| cx.write_to_clipboard(ClipboardItem::new(text)));
167 }
168
169 ///Attempt to paste the clipboard into the terminal
170 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
171 cx.read_from_clipboard().map(|item| {
172 self.connection
173 .read(cx)
174 .get_terminal()
175 .map(|term| term.paste(item.text()));
176 });
177 }
178
179 ///Synthesize the keyboard event corresponding to 'up'
180 fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
181 self.connection
182 .read(cx)
183 .get_terminal()
184 .map(|term| term.try_keystroke(&Keystroke::parse("up").unwrap()));
185 }
186
187 ///Synthesize the keyboard event corresponding to 'down'
188 fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
189 self.connection
190 .read(cx)
191 .get_terminal()
192 .map(|term| term.try_keystroke(&Keystroke::parse("down").unwrap()));
193 }
194
195 ///Synthesize the keyboard event corresponding to 'ctrl-c'
196 fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext<Self>) {
197 self.connection
198 .read(cx)
199 .get_terminal()
200 .map(|term| term.try_keystroke(&Keystroke::parse("ctrl-c").unwrap()));
201 }
202
203 ///Synthesize the keyboard event corresponding to 'escape'
204 fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
205 self.connection
206 .read(cx)
207 .get_terminal()
208 .map(|term| term.try_keystroke(&Keystroke::parse("escape").unwrap()));
209 }
210
211 ///Synthesize the keyboard event corresponding to 'enter'
212 fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
213 self.connection
214 .read(cx)
215 .get_terminal()
216 .map(|term| term.try_keystroke(&Keystroke::parse("enter").unwrap()));
217 }
218}
219
220impl View for TerminalView {
221 fn ui_name() -> &'static str {
222 "Terminal"
223 }
224
225 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
226 let element = {
227 let connection_handle = self.connection.clone().downgrade();
228 TerminalEl::new(cx.handle(), connection_handle, self.modal).contained()
229 };
230
231 if self.modal {
232 let settings = cx.global::<Settings>();
233 let container_style = settings.theme.terminal.modal_container;
234 element.with_style(container_style).boxed()
235 } else {
236 element.boxed()
237 }
238 }
239
240 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
241 cx.emit(Event::Activate);
242 self.has_new_content = false;
243 }
244
245 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
246 let mut context = Self::default_keymap_context();
247 if self.modal {
248 context.set.insert("ModalTerminal".into());
249 }
250 context
251 }
252}
253
254impl Item for TerminalView {
255 fn tab_content(
256 &self,
257 _detail: Option<usize>,
258 tab_theme: &theme::Tab,
259 cx: &gpui::AppContext,
260 ) -> ElementBox {
261 let title = match self.connection.read(cx) {
262 TerminalConnection::Connected(conn) => conn.title.clone(),
263 TerminalConnection::Disconnected { .. } => "Terminal".to_string(), //TODO ask nate about htis
264 };
265
266 Flex::row()
267 .with_child(
268 Label::new(title, tab_theme.label.clone())
269 .aligned()
270 .contained()
271 .boxed(),
272 )
273 .boxed()
274 }
275
276 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
277 //From what I can tell, there's no way to tell the current working
278 //Directory of the terminal from outside the shell. There might be
279 //solutions to this, but they are non-trivial and require more IPC
280
281 let wd = self
282 .connection
283 .read(cx)
284 .get_terminal()
285 .and_then(|term| term.associated_directory.clone())
286 .clone();
287
288 Some(TerminalView::new(wd, false, cx))
289 }
290
291 fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
292 None
293 }
294
295 fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
296 SmallVec::new()
297 }
298
299 fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
300 false
301 }
302
303 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
304
305 fn can_save(&self, _cx: &gpui::AppContext) -> bool {
306 false
307 }
308
309 fn save(
310 &mut self,
311 _project: gpui::ModelHandle<Project>,
312 _cx: &mut ViewContext<Self>,
313 ) -> gpui::Task<gpui::anyhow::Result<()>> {
314 unreachable!("save should not have been called");
315 }
316
317 fn save_as(
318 &mut self,
319 _project: gpui::ModelHandle<Project>,
320 _abs_path: std::path::PathBuf,
321 _cx: &mut ViewContext<Self>,
322 ) -> gpui::Task<gpui::anyhow::Result<()>> {
323 unreachable!("save_as should not have been called");
324 }
325
326 fn reload(
327 &mut self,
328 _project: gpui::ModelHandle<Project>,
329 _cx: &mut ViewContext<Self>,
330 ) -> gpui::Task<gpui::anyhow::Result<()>> {
331 gpui::Task::ready(Ok(()))
332 }
333
334 fn is_dirty(&self, _: &gpui::AppContext) -> bool {
335 self.has_new_content
336 }
337
338 fn has_conflict(&self, _: &AppContext) -> bool {
339 self.has_bell
340 }
341
342 fn should_update_tab_on_event(event: &Self::Event) -> bool {
343 matches!(event, &Event::TitleChanged)
344 }
345
346 fn should_close_item_on_event(event: &Self::Event) -> bool {
347 matches!(event, &Event::CloseTerminal)
348 }
349
350 fn should_activate_item_on_event(event: &Self::Event) -> bool {
351 matches!(event, &Event::Activate)
352 }
353}
354
355///Get's the working directory for the given workspace, respecting the user's settings.
356fn get_wd_for_workspace(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
357 let wd_setting = cx
358 .global::<Settings>()
359 .terminal_overrides
360 .working_directory
361 .clone()
362 .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
363 let res = match wd_setting {
364 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
365 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
366 WorkingDirectory::AlwaysHome => None,
367 WorkingDirectory::Always { directory } => shellexpand::full(&directory)
368 .ok()
369 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
370 .filter(|dir| dir.is_dir()),
371 };
372 res.or_else(|| home_dir())
373}
374
375///Get's the first project's home directory, or the home directory
376fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
377 workspace
378 .worktrees(cx)
379 .next()
380 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
381 .and_then(get_path_from_wt)
382}
383
384///Gets the intuitively correct working directory from the given workspace
385///If there is an active entry for this project, returns that entry's worktree root.
386///If there's no active entry but there is a worktree, returns that worktrees root.
387///If either of these roots are files, or if there are any other query failures,
388/// returns the user's home directory
389fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
390 let project = workspace.project().read(cx);
391
392 project
393 .active_entry()
394 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
395 .or_else(|| workspace.worktrees(cx).next())
396 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
397 .and_then(get_path_from_wt)
398}
399
400fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
401 wt.root_entry()
402 .filter(|re| re.is_dir())
403 .map(|_| wt.abs_path().to_path_buf())
404}
405
406#[cfg(test)]
407mod tests {
408
409 use crate::tests::terminal_test_context::TerminalTestContext;
410
411 use super::*;
412 use gpui::TestAppContext;
413
414 use std::path::Path;
415 use workspace::AppState;
416
417 mod terminal_test_context;
418
419 ///Basic integration test, can we get the terminal to show up, execute a command,
420 //and produce noticable output?
421 #[gpui::test(retries = 5)]
422 async fn test_terminal(cx: &mut TestAppContext) {
423 let mut cx = TerminalTestContext::new(cx);
424
425 cx.execute_and_wait("expr 3 + 4", |content, _cx| content.contains("7"))
426 .await;
427 }
428
429 ///Working directory calculation tests
430
431 ///No Worktrees in project -> home_dir()
432 #[gpui::test]
433 async fn no_worktree(cx: &mut TestAppContext) {
434 //Setup variables
435 let params = cx.update(AppState::test);
436 let project = Project::test(params.fs.clone(), [], cx).await;
437 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
438
439 //Test
440 cx.read(|cx| {
441 let workspace = workspace.read(cx);
442 let active_entry = project.read(cx).active_entry();
443
444 //Make sure enviroment is as expeted
445 assert!(active_entry.is_none());
446 assert!(workspace.worktrees(cx).next().is_none());
447
448 let res = current_project_directory(workspace, cx);
449 assert_eq!(res, None);
450 let res = first_project_directory(workspace, cx);
451 assert_eq!(res, None);
452 });
453 }
454
455 ///No active entry, but a worktree, worktree is a file -> home_dir()
456 #[gpui::test]
457 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
458 //Setup variables
459 let params = cx.update(AppState::test);
460 let project = Project::test(params.fs.clone(), [], cx).await;
461 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
462 let (wt, _) = project
463 .update(cx, |project, cx| {
464 project.find_or_create_local_worktree("/root.txt", true, cx)
465 })
466 .await
467 .unwrap();
468
469 cx.update(|cx| {
470 wt.update(cx, |wt, cx| {
471 wt.as_local()
472 .unwrap()
473 .create_entry(Path::new(""), false, cx)
474 })
475 })
476 .await
477 .unwrap();
478
479 //Test
480 cx.read(|cx| {
481 let workspace = workspace.read(cx);
482 let active_entry = project.read(cx).active_entry();
483
484 //Make sure enviroment is as expeted
485 assert!(active_entry.is_none());
486 assert!(workspace.worktrees(cx).next().is_some());
487
488 let res = current_project_directory(workspace, cx);
489 assert_eq!(res, None);
490 let res = first_project_directory(workspace, cx);
491 assert_eq!(res, None);
492 });
493 }
494
495 //No active entry, but a worktree, worktree is a folder -> worktree_folder
496 #[gpui::test]
497 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
498 //Setup variables
499 let params = cx.update(AppState::test);
500 let project = Project::test(params.fs.clone(), [], cx).await;
501 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
502 let (wt, _) = project
503 .update(cx, |project, cx| {
504 project.find_or_create_local_worktree("/root/", true, cx)
505 })
506 .await
507 .unwrap();
508
509 //Setup root folder
510 cx.update(|cx| {
511 wt.update(cx, |wt, cx| {
512 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
513 })
514 })
515 .await
516 .unwrap();
517
518 //Test
519 cx.update(|cx| {
520 let workspace = workspace.read(cx);
521 let active_entry = project.read(cx).active_entry();
522
523 assert!(active_entry.is_none());
524 assert!(workspace.worktrees(cx).next().is_some());
525
526 let res = current_project_directory(workspace, cx);
527 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
528 let res = first_project_directory(workspace, cx);
529 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
530 });
531 }
532
533 //Active entry with a work tree, worktree is a file -> home_dir()
534 #[gpui::test]
535 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
536 //Setup variables
537 let params = cx.update(AppState::test);
538 let project = Project::test(params.fs.clone(), [], cx).await;
539 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
540 let (wt1, _) = project
541 .update(cx, |project, cx| {
542 project.find_or_create_local_worktree("/root1/", true, cx)
543 })
544 .await
545 .unwrap();
546
547 let (wt2, _) = project
548 .update(cx, |project, cx| {
549 project.find_or_create_local_worktree("/root2.txt", true, cx)
550 })
551 .await
552 .unwrap();
553
554 //Setup root
555 let _ = cx
556 .update(|cx| {
557 wt1.update(cx, |wt, cx| {
558 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
559 })
560 })
561 .await
562 .unwrap();
563 let entry2 = cx
564 .update(|cx| {
565 wt2.update(cx, |wt, cx| {
566 wt.as_local()
567 .unwrap()
568 .create_entry(Path::new(""), false, cx)
569 })
570 })
571 .await
572 .unwrap();
573
574 cx.update(|cx| {
575 let p = ProjectPath {
576 worktree_id: wt2.read(cx).id(),
577 path: entry2.path,
578 };
579 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
580 });
581
582 //Test
583 cx.update(|cx| {
584 let workspace = workspace.read(cx);
585 let active_entry = project.read(cx).active_entry();
586
587 assert!(active_entry.is_some());
588
589 let res = current_project_directory(workspace, cx);
590 assert_eq!(res, None);
591 let res = first_project_directory(workspace, cx);
592 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
593 });
594 }
595
596 //Active entry, with a worktree, worktree is a folder -> worktree_folder
597 #[gpui::test]
598 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
599 //Setup variables
600 let params = cx.update(AppState::test);
601 let project = Project::test(params.fs.clone(), [], cx).await;
602 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
603 let (wt1, _) = project
604 .update(cx, |project, cx| {
605 project.find_or_create_local_worktree("/root1/", true, cx)
606 })
607 .await
608 .unwrap();
609
610 let (wt2, _) = project
611 .update(cx, |project, cx| {
612 project.find_or_create_local_worktree("/root2/", true, cx)
613 })
614 .await
615 .unwrap();
616
617 //Setup root
618 let _ = cx
619 .update(|cx| {
620 wt1.update(cx, |wt, cx| {
621 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
622 })
623 })
624 .await
625 .unwrap();
626 let entry2 = cx
627 .update(|cx| {
628 wt2.update(cx, |wt, cx| {
629 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
630 })
631 })
632 .await
633 .unwrap();
634
635 cx.update(|cx| {
636 let p = ProjectPath {
637 worktree_id: wt2.read(cx).id(),
638 path: entry2.path,
639 };
640 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
641 });
642
643 //Test
644 cx.update(|cx| {
645 let workspace = workspace.read(cx);
646 let active_entry = project.read(cx).active_entry();
647
648 assert!(active_entry.is_some());
649
650 let res = current_project_directory(workspace, cx);
651 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
652 let res = first_project_directory(workspace, cx);
653 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
654 });
655 }
656}