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