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