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