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(working_directory.clone(), shell, envs, size_info)
98 {
99 Ok(terminal) => {
100 let terminal = cx.add_model(|cx| terminal.subscribe(cx));
101 let view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
102 cx.subscribe(&view, |_this, _content, event, cx| cx.emit(event.clone()))
103 .detach();
104 TerminalContent::Connected(view)
105 }
106 Err(error) => {
107 let view = cx.add_view(|_| ErrorView {
108 error: error.downcast::<TerminalError>().unwrap(),
109 });
110 TerminalContent::Error(view)
111 }
112 };
113 cx.focus(content.handle());
114
115 TerminalView {
116 modal,
117 content,
118 associated_directory: working_directory,
119 }
120 }
121
122 pub fn from_terminal(
123 terminal: ModelHandle<Terminal>,
124 modal: bool,
125 cx: &mut ViewContext<Self>,
126 ) -> Self {
127 let connected_view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
128 TerminalView {
129 modal,
130 content: TerminalContent::Connected(connected_view),
131 associated_directory: None,
132 }
133 }
134}
135
136impl View for TerminalView {
137 fn ui_name() -> &'static str {
138 "Terminal"
139 }
140
141 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
142 let child_view = match &self.content {
143 TerminalContent::Connected(connected) => ChildView::new(connected),
144 TerminalContent::Error(error) => ChildView::new(error),
145 };
146 if self.modal {
147 let settings = cx.global::<Settings>();
148 let container_style = settings.theme.terminal.modal_container;
149 child_view.contained().with_style(container_style).boxed()
150 } else {
151 child_view.boxed()
152 }
153 }
154
155 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
156 cx.emit(Event::Activate);
157 cx.defer(|view, cx| {
158 cx.focus(view.content.handle());
159 });
160 }
161
162 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
163 let mut context = Self::default_keymap_context();
164 if self.modal {
165 context.set.insert("ModalTerminal".into());
166 }
167 context
168 }
169}
170
171impl View for ErrorView {
172 fn ui_name() -> &'static str {
173 "Terminal Error"
174 }
175
176 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
177 let settings = cx.global::<Settings>();
178 let style = TerminalEl::make_text_style(cx.font_cache(), settings);
179
180 //TODO:
181 //We want markdown style highlighting so we can format the program and working directory with ``
182 //We want a max-width of 75% with word-wrap
183 //We want to be able to select the text
184 //Want to be able to scroll if the error message is massive somehow (resiliency)
185
186 let program_text = {
187 match self.error.shell_to_string() {
188 Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
189 None => "No program specified".to_string(),
190 }
191 };
192
193 let directory_text = {
194 match self.error.directory.as_ref() {
195 Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
196 None => "No working directory specified".to_string(),
197 }
198 };
199
200 let error_text = self.error.source.to_string();
201
202 Flex::column()
203 .with_child(
204 Text::new("Failed to open the terminal.".to_string(), style.clone())
205 .contained()
206 .boxed(),
207 )
208 .with_child(Text::new(program_text, style.clone()).contained().boxed())
209 .with_child(Text::new(directory_text, style.clone()).contained().boxed())
210 .with_child(Text::new(error_text, style.clone()).contained().boxed())
211 .aligned()
212 .boxed()
213 }
214}
215
216impl Item for TerminalView {
217 fn tab_content(
218 &self,
219 _detail: Option<usize>,
220 tab_theme: &theme::Tab,
221 cx: &gpui::AppContext,
222 ) -> ElementBox {
223 let title = match &self.content {
224 TerminalContent::Connected(connected) => {
225 connected.read(cx).handle().read(cx).title.to_string()
226 }
227 TerminalContent::Error(_) => "Terminal".to_string(),
228 };
229
230 Flex::row()
231 .with_child(
232 Label::new(title, tab_theme.label.clone())
233 .aligned()
234 .contained()
235 .boxed(),
236 )
237 .boxed()
238 }
239
240 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
241 //From what I can tell, there's no way to tell the current working
242 //Directory of the terminal from outside the shell. There might be
243 //solutions to this, but they are non-trivial and require more IPC
244 Some(TerminalView::new(
245 self.associated_directory.clone(),
246 false,
247 cx,
248 ))
249 }
250
251 fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
252 None
253 }
254
255 fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
256 SmallVec::new()
257 }
258
259 fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
260 false
261 }
262
263 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
264
265 fn can_save(&self, _cx: &gpui::AppContext) -> bool {
266 false
267 }
268
269 fn save(
270 &mut self,
271 _project: gpui::ModelHandle<Project>,
272 _cx: &mut ViewContext<Self>,
273 ) -> gpui::Task<gpui::anyhow::Result<()>> {
274 unreachable!("save should not have been called");
275 }
276
277 fn save_as(
278 &mut self,
279 _project: gpui::ModelHandle<Project>,
280 _abs_path: std::path::PathBuf,
281 _cx: &mut ViewContext<Self>,
282 ) -> gpui::Task<gpui::anyhow::Result<()>> {
283 unreachable!("save_as should not have been called");
284 }
285
286 fn reload(
287 &mut self,
288 _project: gpui::ModelHandle<Project>,
289 _cx: &mut ViewContext<Self>,
290 ) -> gpui::Task<gpui::anyhow::Result<()>> {
291 gpui::Task::ready(Ok(()))
292 }
293
294 fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
295 if let TerminalContent::Connected(connected) = &self.content {
296 connected.read(cx).has_new_content()
297 } else {
298 false
299 }
300 }
301
302 fn has_conflict(&self, cx: &AppContext) -> bool {
303 if let TerminalContent::Connected(connected) = &self.content {
304 connected.read(cx).has_bell()
305 } else {
306 false
307 }
308 }
309
310 fn should_update_tab_on_event(event: &Self::Event) -> bool {
311 matches!(event, &Event::TitleChanged | &Event::Wakeup)
312 }
313
314 fn should_close_item_on_event(event: &Self::Event) -> bool {
315 matches!(event, &Event::CloseTerminal)
316 }
317
318 fn should_activate_item_on_event(event: &Self::Event) -> bool {
319 matches!(event, &Event::Activate)
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}