1pub mod connected_el;
2pub mod connected_view;
3pub mod mappings;
4pub mod modal_view;
5pub mod model;
6
7use connected_view::ConnectedView;
8use dirs::home_dir;
9use gpui::{
10 actions, elements::*, geometry::vector::vec2f, AnyViewHandle, AppContext, Entity, ModelHandle,
11 MutableAppContext, View, ViewContext, ViewHandle,
12};
13use modal_view::deploy_modal;
14use model::{Event, Terminal, TerminalBuilder, TerminalError};
15
16use connected_el::TermDimensions;
17use project::{LocalWorktree, Project, ProjectPath};
18use settings::{Settings, WorkingDirectory};
19use smallvec::SmallVec;
20use std::path::{Path, PathBuf};
21use workspace::{Item, Workspace};
22
23use crate::connected_el::TerminalEl;
24
25const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
26const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
27const DEBUG_CELL_WIDTH: f32 = 5.;
28const DEBUG_LINE_HEIGHT: f32 = 5.;
29
30actions!(terminal, [Deploy, DeployModal]);
31
32///Initialize and register all of our action handlers
33pub fn init(cx: &mut MutableAppContext) {
34 cx.add_action(TerminalView::deploy);
35 cx.add_action(deploy_modal);
36
37 connected_view::init(cx);
38}
39
40//Make terminal view an enum, that can give you views for the error and non-error states
41//Take away all the result unwrapping in the current TerminalView by making it 'infallible'
42//Bubble up to deploy(_modal)() calls
43
44enum TerminalContent {
45 Connected(ViewHandle<ConnectedView>),
46 Error(ViewHandle<ErrorView>),
47}
48
49impl TerminalContent {
50 fn handle(&self) -> AnyViewHandle {
51 match self {
52 Self::Connected(handle) => handle.into(),
53 Self::Error(handle) => handle.into(),
54 }
55 }
56}
57
58pub struct TerminalView {
59 modal: bool,
60 content: TerminalContent,
61 associated_directory: Option<PathBuf>,
62}
63
64pub struct ErrorView {
65 error: TerminalError,
66}
67
68impl Entity for TerminalView {
69 type Event = Event;
70}
71
72impl Entity for ConnectedView {
73 type Event = Event;
74}
75
76impl Entity for ErrorView {
77 type Event = Event;
78}
79
80impl TerminalView {
81 ///Create a new Terminal in the current working directory or the user's home directory
82 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
83 let working_directory = get_working_directory(workspace, cx);
84 let view = cx.add_view(|cx| TerminalView::new(working_directory, false, cx));
85 workspace.add_item(Box::new(view), cx);
86 }
87
88 ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
89 ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
90 fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
91 //The details here don't matter, the terminal will be resized on the first layout
92 let size_info = TermDimensions::new(
93 DEBUG_LINE_HEIGHT,
94 DEBUG_CELL_WIDTH,
95 vec2f(DEBUG_TERMINAL_WIDTH, DEBUG_TERMINAL_HEIGHT),
96 );
97
98 let settings = cx.global::<Settings>();
99 let shell = settings.terminal_overrides.shell.clone();
100 let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
101
102 let content = match TerminalBuilder::new(working_directory.clone(), shell, envs, size_info)
103 {
104 Ok(terminal) => {
105 let terminal = cx.add_model(|cx| terminal.subscribe(cx));
106 let view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
107 cx.subscribe(&view, |_this, _content, event, cx| cx.emit(event.clone()))
108 .detach();
109 TerminalContent::Connected(view)
110 }
111 Err(error) => {
112 let view = cx.add_view(|_| ErrorView {
113 error: error.downcast::<TerminalError>().unwrap(),
114 });
115 TerminalContent::Error(view)
116 }
117 };
118 cx.focus(content.handle());
119
120 TerminalView {
121 modal,
122 content,
123 associated_directory: working_directory,
124 }
125 }
126
127 fn from_terminal(
128 terminal: ModelHandle<Terminal>,
129 modal: bool,
130 cx: &mut ViewContext<Self>,
131 ) -> Self {
132 let connected_view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
133 TerminalView {
134 modal,
135 content: TerminalContent::Connected(connected_view),
136 associated_directory: None,
137 }
138 }
139}
140
141impl View for TerminalView {
142 fn ui_name() -> &'static str {
143 "Terminal View"
144 }
145
146 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
147 let child_view = match &self.content {
148 TerminalContent::Connected(connected) => ChildView::new(connected),
149 TerminalContent::Error(error) => ChildView::new(error),
150 };
151
152 if self.modal {
153 let settings = cx.global::<Settings>();
154 let container_style = settings.theme.terminal.modal_container;
155 child_view.contained().with_style(container_style).boxed()
156 } else {
157 child_view.boxed()
158 }
159 }
160
161 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
162 cx.emit(Event::Activate);
163 cx.defer(|view, cx| {
164 cx.focus(view.content.handle());
165 });
166 }
167
168 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
169 let mut context = Self::default_keymap_context();
170 if self.modal {
171 context.set.insert("ModalTerminal".into());
172 }
173 context
174 }
175}
176
177impl View for ErrorView {
178 fn ui_name() -> &'static str {
179 "DisconnectedTerminal"
180 }
181
182 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
183 let settings = cx.global::<Settings>();
184 let style = TerminalEl::make_text_style(cx.font_cache(), settings);
185
186 //TODO:
187 //We want markdown style highlighting so we can format the program and working directory with ``
188 //We want a max-width of 75% with word-wrap
189 //We want to be able to select the text
190 //Want to be able to scroll if the error message is massive somehow (resiliency)
191
192 let program_text = {
193 match self.error.shell_to_string() {
194 Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
195 None => "No program specified".to_string(),
196 }
197 };
198
199 let directory_text = {
200 match self.error.directory.as_ref() {
201 Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
202 None => "No working directory specified".to_string(),
203 }
204 };
205
206 let error_text = self.error.source.to_string();
207
208 Flex::column()
209 .with_child(
210 Text::new("Failed to open the terminal.".to_string(), style.clone())
211 .contained()
212 .boxed(),
213 )
214 .with_child(Text::new(program_text, style.clone()).contained().boxed())
215 .with_child(Text::new(directory_text, style.clone()).contained().boxed())
216 .with_child(Text::new(error_text, style.clone()).contained().boxed())
217 .aligned()
218 .boxed()
219 }
220}
221
222impl Item for TerminalView {
223 fn tab_content(
224 &self,
225 _detail: Option<usize>,
226 tab_theme: &theme::Tab,
227 cx: &gpui::AppContext,
228 ) -> ElementBox {
229 let title = match &self.content {
230 TerminalContent::Connected(connected) => {
231 connected.read(cx).handle().read(cx).title.clone()
232 }
233 TerminalContent::Error(_) => "Terminal".to_string(),
234 };
235
236 Flex::row()
237 .with_child(
238 Label::new(title, tab_theme.label.clone())
239 .aligned()
240 .contained()
241 .boxed(),
242 )
243 .boxed()
244 }
245
246 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
247 //From what I can tell, there's no way to tell the current working
248 //Directory of the terminal from outside the shell. There might be
249 //solutions to this, but they are non-trivial and require more IPC
250 Some(TerminalView::new(
251 self.associated_directory.clone(),
252 false,
253 cx,
254 ))
255 }
256
257 fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
258 None
259 }
260
261 fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
262 SmallVec::new()
263 }
264
265 fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
266 false
267 }
268
269 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
270
271 fn can_save(&self, _cx: &gpui::AppContext) -> bool {
272 false
273 }
274
275 fn save(
276 &mut self,
277 _project: gpui::ModelHandle<Project>,
278 _cx: &mut ViewContext<Self>,
279 ) -> gpui::Task<gpui::anyhow::Result<()>> {
280 unreachable!("save should not have been called");
281 }
282
283 fn save_as(
284 &mut self,
285 _project: gpui::ModelHandle<Project>,
286 _abs_path: std::path::PathBuf,
287 _cx: &mut ViewContext<Self>,
288 ) -> gpui::Task<gpui::anyhow::Result<()>> {
289 unreachable!("save_as should not have been called");
290 }
291
292 fn reload(
293 &mut self,
294 _project: gpui::ModelHandle<Project>,
295 _cx: &mut ViewContext<Self>,
296 ) -> gpui::Task<gpui::anyhow::Result<()>> {
297 gpui::Task::ready(Ok(()))
298 }
299
300 fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
301 if let TerminalContent::Connected(connected) = &self.content {
302 connected.read(cx).has_new_content()
303 } else {
304 false
305 }
306 }
307
308 fn has_conflict(&self, cx: &AppContext) -> bool {
309 if let TerminalContent::Connected(connected) = &self.content {
310 connected.read(cx).has_bell()
311 } else {
312 false
313 }
314 }
315
316 fn should_update_tab_on_event(event: &Self::Event) -> bool {
317 matches!(event, &Event::TitleChanged)
318 }
319
320 fn should_close_item_on_event(event: &Self::Event) -> bool {
321 matches!(event, &Event::CloseTerminal)
322 }
323
324 fn should_activate_item_on_event(event: &Self::Event) -> bool {
325 matches!(event, &Event::Activate)
326 }
327}
328
329///Get's the working directory for the given workspace, respecting the user's settings.
330fn get_working_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
331 let wd_setting = cx
332 .global::<Settings>()
333 .terminal_overrides
334 .working_directory
335 .clone()
336 .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
337 let res = match wd_setting {
338 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
339 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
340 WorkingDirectory::AlwaysHome => None,
341 WorkingDirectory::Always { directory } => {
342 shellexpand::full(&directory) //TODO handle this better
343 .ok()
344 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
345 .filter(|dir| dir.is_dir())
346 }
347 };
348 res.or_else(|| home_dir())
349}
350
351///Get's the first project's home directory, or the home directory
352fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
353 workspace
354 .worktrees(cx)
355 .next()
356 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
357 .and_then(get_path_from_wt)
358}
359
360///Gets the intuitively correct working directory from the given workspace
361///If there is an active entry for this project, returns that entry's worktree root.
362///If there's no active entry but there is a worktree, returns that worktrees root.
363///If either of these roots are files, or if there are any other query failures,
364/// returns the user's home directory
365fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
366 let project = workspace.project().read(cx);
367
368 project
369 .active_entry()
370 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
371 .or_else(|| workspace.worktrees(cx).next())
372 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
373 .and_then(get_path_from_wt)
374}
375
376fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
377 wt.root_entry()
378 .filter(|re| re.is_dir())
379 .map(|_| wt.abs_path().to_path_buf())
380}
381
382#[cfg(test)]
383mod tests {
384
385 use crate::tests::terminal_test_context::TerminalTestContext;
386
387 use super::*;
388 use gpui::TestAppContext;
389
390 use std::path::Path;
391
392 mod terminal_test_context;
393
394 ///Basic integration test, can we get the terminal to show up, execute a command,
395 //and produce noticable output?
396 #[gpui::test(retries = 5)]
397 async fn test_terminal(cx: &mut TestAppContext) {
398 let mut cx = TerminalTestContext::new(cx, true);
399
400 cx.execute_and_wait("expr 3 + 4", |content, _cx| content.contains("7"))
401 .await;
402 }
403
404 ///Working directory calculation tests
405
406 ///No Worktrees in project -> home_dir()
407 #[gpui::test]
408 async fn no_worktree(cx: &mut TestAppContext) {
409 //Setup variables
410 let mut cx = TerminalTestContext::new(cx, true);
411 let (project, workspace) = cx.blank_workspace().await;
412 //Test
413 cx.cx.read(|cx| {
414 let workspace = workspace.read(cx);
415 let active_entry = project.read(cx).active_entry();
416
417 //Make sure enviroment is as expeted
418 assert!(active_entry.is_none());
419 assert!(workspace.worktrees(cx).next().is_none());
420
421 let res = current_project_directory(workspace, cx);
422 assert_eq!(res, None);
423 let res = first_project_directory(workspace, cx);
424 assert_eq!(res, None);
425 });
426 }
427
428 ///No active entry, but a worktree, worktree is a file -> home_dir()
429 #[gpui::test]
430 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
431 //Setup variables
432
433 let mut cx = TerminalTestContext::new(cx, true);
434 let (project, workspace) = cx.blank_workspace().await;
435 cx.create_file_wt(project.clone(), "/root.txt").await;
436
437 cx.cx.read(|cx| {
438 let workspace = workspace.read(cx);
439 let active_entry = project.read(cx).active_entry();
440
441 //Make sure enviroment is as expeted
442 assert!(active_entry.is_none());
443 assert!(workspace.worktrees(cx).next().is_some());
444
445 let res = current_project_directory(workspace, cx);
446 assert_eq!(res, None);
447 let res = first_project_directory(workspace, cx);
448 assert_eq!(res, None);
449 });
450 }
451
452 //No active entry, but a worktree, worktree is a folder -> worktree_folder
453 #[gpui::test]
454 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
455 //Setup variables
456 let mut cx = TerminalTestContext::new(cx, true);
457 let (project, workspace) = cx.blank_workspace().await;
458 let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await;
459
460 //Test
461 cx.cx.update(|cx| {
462 let workspace = workspace.read(cx);
463 let active_entry = project.read(cx).active_entry();
464
465 assert!(active_entry.is_none());
466 assert!(workspace.worktrees(cx).next().is_some());
467
468 let res = current_project_directory(workspace, cx);
469 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
470 let res = first_project_directory(workspace, cx);
471 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
472 });
473 }
474
475 //Active entry with a work tree, worktree is a file -> home_dir()
476 #[gpui::test]
477 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
478 //Setup variables
479 let mut cx = TerminalTestContext::new(cx, true);
480 let (project, workspace) = cx.blank_workspace().await;
481 let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
482 let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
483 cx.insert_active_entry_for(wt2, entry2, project.clone());
484
485 //Test
486 cx.cx.update(|cx| {
487 let workspace = workspace.read(cx);
488 let active_entry = project.read(cx).active_entry();
489
490 assert!(active_entry.is_some());
491
492 let res = current_project_directory(workspace, cx);
493 assert_eq!(res, None);
494 let res = first_project_directory(workspace, cx);
495 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
496 });
497 }
498
499 //Active entry, with a worktree, worktree is a folder -> worktree_folder
500 #[gpui::test]
501 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
502 //Setup variables
503 let mut cx = TerminalTestContext::new(cx, true);
504 let (project, workspace) = cx.blank_workspace().await;
505 let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
506 let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await;
507 cx.insert_active_entry_for(wt2, entry2, project.clone());
508
509 //Test
510 cx.cx.update(|cx| {
511 let workspace = workspace.read(cx);
512 let active_entry = project.read(cx).active_entry();
513
514 assert!(active_entry.is_some());
515
516 let res = current_project_directory(workspace, cx);
517 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
518 let res = first_project_directory(workspace, cx);
519 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
520 });
521 }
522}