From 24155d3b272076cdaea87c8e9e4f71c935051318 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 22 Aug 2022 16:49:01 -0700 Subject: [PATCH] Renamed all the terminal files --- crates/terminal/src/connected_view.rs | 449 --------- crates/terminal/src/modal.rs | 10 +- crates/terminal/src/terminal.rs | 6 +- .../terminal/src/terminal_container_view.rs | 513 +++++++++++ .../{connected_el.rs => terminal_element.rs} | 38 +- crates/terminal/src/terminal_view.rs | 872 ++++++++---------- 6 files changed, 945 insertions(+), 943 deletions(-) delete mode 100644 crates/terminal/src/connected_view.rs create mode 100644 crates/terminal/src/terminal_container_view.rs rename crates/terminal/src/{connected_el.rs => terminal_element.rs} (96%) diff --git a/crates/terminal/src/connected_view.rs b/crates/terminal/src/connected_view.rs deleted file mode 100644 index 639f5f5d8e..0000000000 --- a/crates/terminal/src/connected_view.rs +++ /dev/null @@ -1,449 +0,0 @@ -use std::time::Duration; - -use alacritty_terminal::term::TermMode; -use context_menu::{ContextMenu, ContextMenuItem}; -use gpui::{ - actions, - elements::{ChildView, ParentElement, Stack}, - geometry::vector::Vector2F, - impl_internal_actions, - keymap::Keystroke, - AnyViewHandle, AppContext, Element, ElementBox, ModelHandle, MutableAppContext, View, - ViewContext, ViewHandle, -}; -use settings::{Settings, TerminalBlink}; -use smol::Timer; -use workspace::pane; - -use crate::{connected_el::TerminalEl, Event, Terminal}; - -const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); - -///Event to transmit the scroll from the element to the view -#[derive(Clone, Debug, PartialEq)] -pub struct ScrollTerminal(pub i32); - -#[derive(Clone, PartialEq)] -pub struct DeployContextMenu { - pub position: Vector2F, -} - -actions!( - terminal, - [ - Up, - Down, - CtrlC, - Escape, - Enter, - Clear, - Copy, - Paste, - ShowCharacterPalette, - ] -); -impl_internal_actions!(project_panel, [DeployContextMenu]); - -pub fn init(cx: &mut MutableAppContext) { - //Global binding overrrides - cx.add_action(ConnectedView::ctrl_c); - cx.add_action(ConnectedView::up); - cx.add_action(ConnectedView::down); - cx.add_action(ConnectedView::escape); - cx.add_action(ConnectedView::enter); - //Useful terminal views - cx.add_action(ConnectedView::deploy_context_menu); - cx.add_action(ConnectedView::copy); - cx.add_action(ConnectedView::paste); - cx.add_action(ConnectedView::clear); - cx.add_action(ConnectedView::show_character_palette); -} - -///A terminal view, maintains the PTY's file handles and communicates with the terminal -pub struct ConnectedView { - terminal: ModelHandle, - has_new_content: bool, - //Currently using iTerm bell, show bell emoji in tab until input is received - has_bell: bool, - // Only for styling purposes. Doesn't effect behavior - modal: bool, - context_menu: ViewHandle, - blink_state: bool, - blinking_on: bool, - blinking_paused: bool, - blink_epoch: usize, -} - -impl ConnectedView { - pub fn from_terminal( - terminal: ModelHandle, - modal: bool, - cx: &mut ViewContext, - ) -> Self { - cx.observe(&terminal, |_, _, cx| cx.notify()).detach(); - cx.subscribe(&terminal, |this, _, event, cx| match event { - Event::Wakeup => { - if !cx.is_self_focused() { - this.has_new_content = true; - cx.notify(); - cx.emit(Event::Wakeup); - } - } - Event::Bell => { - this.has_bell = true; - cx.emit(Event::Wakeup); - } - Event::BlinkChanged => this.blinking_on = !this.blinking_on, - _ => cx.emit(*event), - }) - .detach(); - - Self { - terminal, - has_new_content: true, - has_bell: false, - modal, - context_menu: cx.add_view(ContextMenu::new), - blink_state: true, - blinking_on: false, - blinking_paused: false, - blink_epoch: 0, - } - } - - pub fn handle(&self) -> ModelHandle { - self.terminal.clone() - } - - pub fn has_new_content(&self) -> bool { - self.has_new_content - } - - pub fn has_bell(&self) -> bool { - self.has_bell - } - - pub fn clear_bel(&mut self, cx: &mut ViewContext) { - self.has_bell = false; - cx.emit(Event::Wakeup); - } - - pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext) { - let menu_entries = vec![ - ContextMenuItem::item("Clear Buffer", Clear), - ContextMenuItem::item("Close Terminal", pane::CloseActiveItem), - ]; - - self.context_menu - .update(cx, |menu, cx| menu.show(action.position, menu_entries, cx)); - - cx.notify(); - } - - fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext) { - if !self - .terminal - .read(cx) - .last_mode - .contains(TermMode::ALT_SCREEN) - { - cx.show_character_palette(); - } else { - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("ctrl-cmd-space").unwrap()) - }); - } - } - - fn clear(&mut self, _: &Clear, cx: &mut ViewContext) { - self.terminal.update(cx, |term, _| term.clear()); - cx.notify(); - } - - pub fn should_show_cursor( - &self, - focused: bool, - cx: &mut gpui::RenderContext<'_, Self>, - ) -> bool { - //Don't blink the cursor when not focused, blinking is disabled, or paused - if !focused - || !self.blinking_on - || self.blinking_paused - || self - .terminal - .read(cx) - .last_mode - .contains(TermMode::ALT_SCREEN) - { - return true; - } - - let setting = { - let settings = cx.global::(); - settings - .terminal_overrides - .blinking - .clone() - .unwrap_or(TerminalBlink::TerminalControlled) - }; - - match setting { - //If the user requested to never blink, don't blink it. - TerminalBlink::Off => true, - //If the terminal is controlling it, check terminal mode - TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state, - } - } - - fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext) { - if epoch == self.blink_epoch && !self.blinking_paused { - self.blink_state = !self.blink_state; - cx.notify(); - - let epoch = self.next_blink_epoch(); - cx.spawn(|this, mut cx| { - let this = this.downgrade(); - async move { - Timer::after(CURSOR_BLINK_INTERVAL).await; - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx)); - } - } - }) - .detach(); - } - } - - pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext) { - self.blink_state = true; - cx.notify(); - - let epoch = self.next_blink_epoch(); - cx.spawn(|this, mut cx| { - let this = this.downgrade(); - async move { - Timer::after(CURSOR_BLINK_INTERVAL).await; - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx)) - } - } - }) - .detach(); - } - - fn next_blink_epoch(&mut self) -> usize { - self.blink_epoch += 1; - self.blink_epoch - } - - fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext) { - if epoch == self.blink_epoch { - self.blinking_paused = false; - self.blink_cursors(epoch, cx); - } - } - - ///Attempt to paste the clipboard into the terminal - fn copy(&mut self, _: &Copy, cx: &mut ViewContext) { - self.terminal.update(cx, |term, _| term.copy()) - } - - ///Attempt to paste the clipboard into the terminal - fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { - if let Some(item) = cx.read_from_clipboard() { - self.terminal - .update(cx, |terminal, _cx| terminal.paste(item.text())); - } - } - - ///Synthesize the keyboard event corresponding to 'up' - fn up(&mut self, _: &Up, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("up").unwrap()) - }); - } - - ///Synthesize the keyboard event corresponding to 'down' - fn down(&mut self, _: &Down, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("down").unwrap()) - }); - } - - ///Synthesize the keyboard event corresponding to 'ctrl-c' - fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("ctrl-c").unwrap()) - }); - } - - ///Synthesize the keyboard event corresponding to 'escape' - fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("escape").unwrap()) - }); - } - - ///Synthesize the keyboard event corresponding to 'enter' - fn enter(&mut self, _: &Enter, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("enter").unwrap()) - }); - } -} - -impl View for ConnectedView { - fn ui_name() -> &'static str { - "Terminal" - } - - fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { - let terminal_handle = self.terminal.clone().downgrade(); - - let self_id = cx.view_id(); - let focused = cx - .focused_view_id(cx.window_id()) - .filter(|view_id| *view_id == self_id) - .is_some(); - - Stack::new() - .with_child( - TerminalEl::new( - cx.handle(), - terminal_handle, - self.modal, - focused, - self.should_show_cursor(focused, cx), - ) - .contained() - .boxed(), - ) - .with_child(ChildView::new(&self.context_menu).boxed()) - .boxed() - } - - fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.has_new_content = false; - self.terminal.read(cx).focus_in(); - self.blink_cursors(self.blink_epoch, cx); - cx.notify(); - } - - fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.terminal.read(cx).focus_out(); - cx.notify(); - } - - //IME stuff - fn selected_text_range(&self, cx: &AppContext) -> Option> { - if self - .terminal - .read(cx) - .last_mode - .contains(TermMode::ALT_SCREEN) - { - None - } else { - Some(0..0) - } - } - - fn replace_text_in_range( - &mut self, - _: Option>, - text: &str, - cx: &mut ViewContext, - ) { - self.terminal.update(cx, |terminal, _| { - terminal.input(text.into()); - }); - } - - fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context { - let mut context = Self::default_keymap_context(); - if self.modal { - context.set.insert("ModalTerminal".into()); - } - let mode = self.terminal.read(cx).last_mode; - context.map.insert( - "screen".to_string(), - (if mode.contains(TermMode::ALT_SCREEN) { - "alt" - } else { - "normal" - }) - .to_string(), - ); - - if mode.contains(TermMode::APP_CURSOR) { - context.set.insert("DECCKM".to_string()); - } - if mode.contains(TermMode::APP_KEYPAD) { - context.set.insert("DECPAM".to_string()); - } - //Note the ! here - if !mode.contains(TermMode::APP_KEYPAD) { - context.set.insert("DECPNM".to_string()); - } - if mode.contains(TermMode::SHOW_CURSOR) { - context.set.insert("DECTCEM".to_string()); - } - if mode.contains(TermMode::LINE_WRAP) { - context.set.insert("DECAWM".to_string()); - } - if mode.contains(TermMode::ORIGIN) { - context.set.insert("DECOM".to_string()); - } - if mode.contains(TermMode::INSERT) { - context.set.insert("IRM".to_string()); - } - //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html - if mode.contains(TermMode::LINE_FEED_NEW_LINE) { - context.set.insert("LNM".to_string()); - } - if mode.contains(TermMode::FOCUS_IN_OUT) { - context.set.insert("report_focus".to_string()); - } - if mode.contains(TermMode::ALTERNATE_SCROLL) { - context.set.insert("alternate_scroll".to_string()); - } - if mode.contains(TermMode::BRACKETED_PASTE) { - context.set.insert("bracketed_paste".to_string()); - } - if mode.intersects(TermMode::MOUSE_MODE) { - context.set.insert("any_mouse_reporting".to_string()); - } - { - let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) { - "click" - } else if mode.contains(TermMode::MOUSE_DRAG) { - "drag" - } else if mode.contains(TermMode::MOUSE_MOTION) { - "motion" - } else { - "off" - }; - context - .map - .insert("mouse_reporting".to_string(), mouse_reporting.to_string()); - } - { - let format = if mode.contains(TermMode::SGR_MOUSE) { - "sgr" - } else if mode.contains(TermMode::UTF8_MOUSE) { - "utf8" - } else { - "normal" - }; - context - .map - .insert("mouse_format".to_string(), format.to_string()); - } - context - } -} diff --git a/crates/terminal/src/modal.rs b/crates/terminal/src/modal.rs index 936a4091b6..1aea96277a 100644 --- a/crates/terminal/src/modal.rs +++ b/crates/terminal/src/modal.rs @@ -3,7 +3,9 @@ use settings::{Settings, WorkingDirectory}; use workspace::Workspace; use crate::{ - terminal_view::{get_working_directory, DeployModal, TerminalContent, TerminalView}, + terminal_container_view::{ + get_working_directory, DeployModal, TerminalContainer, TerminalContent, + }, Event, Terminal, }; @@ -20,7 +22,7 @@ pub fn deploy_modal(workspace: &mut Workspace, _: &DeployModal, cx: &mut ViewCon if let Some(StoredTerminal(stored_terminal)) = possible_terminal { workspace.toggle_modal(cx, |_, cx| { // Create a view from the stored connection if the terminal modal is not already shown - cx.add_view(|cx| TerminalView::from_terminal(stored_terminal.clone(), true, cx)) + cx.add_view(|cx| TerminalContainer::from_terminal(stored_terminal.clone(), true, cx)) }); // Toggle Modal will dismiss the terminal modal if it is currently shown, so we must // store the terminal back in the global @@ -38,7 +40,7 @@ pub fn deploy_modal(workspace: &mut Workspace, _: &DeployModal, cx: &mut ViewCon let working_directory = get_working_directory(workspace, cx, wd_strategy); - let this = cx.add_view(|cx| TerminalView::new(working_directory, true, cx)); + let this = cx.add_view(|cx| TerminalContainer::new(working_directory, true, cx)); if let TerminalContent::Connected(connected) = &this.read(cx).content { let terminal_handle = connected.read(cx).handle(); @@ -73,7 +75,7 @@ pub fn on_event( // Dismiss the modal if the terminal quit if let Event::CloseTerminal = event { cx.set_global::>(None); - if workspace.modal::().is_some() { + if workspace.modal::().is_some() { workspace.dismiss_modal(cx) } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 134e5af7cc..3adaf7fa7c 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1,7 +1,7 @@ -pub mod connected_el; -pub mod connected_view; pub mod mappings; pub mod modal; +pub mod terminal_container_view; +pub mod terminal_element; pub mod terminal_view; use alacritty_terminal::{ @@ -52,7 +52,7 @@ pub fn init(cx: &mut MutableAppContext) { } terminal_view::init(cx); - connected_view::init(cx); + terminal_container_view::init(cx); } ///Scrolling is unbearably sluggish by default. Alacritty supports a configurable diff --git a/crates/terminal/src/terminal_container_view.rs b/crates/terminal/src/terminal_container_view.rs new file mode 100644 index 0000000000..02c018c82b --- /dev/null +++ b/crates/terminal/src/terminal_container_view.rs @@ -0,0 +1,513 @@ +use crate::terminal_view::TerminalView; +use crate::{Event, Terminal, TerminalBuilder, TerminalError}; + +use dirs::home_dir; +use gpui::{ + actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, View, + ViewContext, ViewHandle, +}; +use workspace::{Item, Workspace}; + +use crate::TerminalSize; +use project::{LocalWorktree, Project, ProjectPath}; +use settings::{AlternateScroll, Settings, WorkingDirectory}; +use smallvec::SmallVec; +use std::path::{Path, PathBuf}; + +use crate::terminal_element::TerminalElement; + +actions!(terminal, [DeployModal]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(TerminalContainer::deploy); +} + +//Make terminal view an enum, that can give you views for the error and non-error states +//Take away all the result unwrapping in the current TerminalView by making it 'infallible' +//Bubble up to deploy(_modal)() calls + +pub enum TerminalContent { + Connected(ViewHandle), + Error(ViewHandle), +} + +impl TerminalContent { + fn handle(&self) -> AnyViewHandle { + match self { + Self::Connected(handle) => handle.into(), + Self::Error(handle) => handle.into(), + } + } +} + +pub struct TerminalContainer { + modal: bool, + pub content: TerminalContent, + associated_directory: Option, +} + +pub struct ErrorView { + error: TerminalError, +} + +impl Entity for TerminalContainer { + type Event = Event; +} + +impl Entity for ErrorView { + type Event = Event; +} + +impl TerminalContainer { + ///Create a new Terminal in the current working directory or the user's home directory + pub fn deploy( + workspace: &mut Workspace, + _: &workspace::NewTerminal, + cx: &mut ViewContext, + ) { + let strategy = cx + .global::() + .terminal_overrides + .working_directory + .clone() + .unwrap_or(WorkingDirectory::CurrentProjectDirectory); + + let working_directory = get_working_directory(workspace, cx, strategy); + let view = cx.add_view(|cx| TerminalContainer::new(working_directory, false, cx)); + workspace.add_item(Box::new(view), cx); + } + + ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices + pub fn new( + working_directory: Option, + modal: bool, + cx: &mut ViewContext, + ) -> Self { + //The exact size here doesn't matter, the terminal will be resized on the first layout + let size_info = TerminalSize::default(); + + let settings = cx.global::(); + let shell = settings.terminal_overrides.shell.clone(); + let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap. + + //TODO: move this pattern to settings + let scroll = settings + .terminal_overrides + .alternate_scroll + .as_ref() + .unwrap_or( + settings + .terminal_defaults + .alternate_scroll + .as_ref() + .unwrap_or_else(|| &AlternateScroll::On), + ); + + let content = match TerminalBuilder::new( + working_directory.clone(), + shell, + envs, + size_info, + settings.terminal_overrides.blinking.clone(), + scroll, + ) { + Ok(terminal) => { + let terminal = cx.add_model(|cx| terminal.subscribe(cx)); + let view = cx.add_view(|cx| TerminalView::from_terminal(terminal, modal, cx)); + cx.subscribe(&view, |_this, _content, event, cx| cx.emit(*event)) + .detach(); + TerminalContent::Connected(view) + } + Err(error) => { + let view = cx.add_view(|_| ErrorView { + error: error.downcast::().unwrap(), + }); + TerminalContent::Error(view) + } + }; + cx.focus(content.handle()); + + TerminalContainer { + modal, + content, + associated_directory: working_directory, + } + } + + pub fn from_terminal( + terminal: ModelHandle, + modal: bool, + cx: &mut ViewContext, + ) -> Self { + let connected_view = cx.add_view(|cx| TerminalView::from_terminal(terminal, modal, cx)); + TerminalContainer { + modal, + content: TerminalContent::Connected(connected_view), + associated_directory: None, + } + } +} + +impl View for TerminalContainer { + fn ui_name() -> &'static str { + "Terminal" + } + + fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { + let child_view = match &self.content { + TerminalContent::Connected(connected) => ChildView::new(connected), + TerminalContent::Error(error) => ChildView::new(error), + }; + if self.modal { + let settings = cx.global::(); + let container_style = settings.theme.terminal.modal_container; + child_view.contained().with_style(container_style).boxed() + } else { + child_view.boxed() + } + } + + fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + if cx.is_self_focused() { + cx.focus(self.content.handle()); + } + } + + fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context { + let mut context = Self::default_keymap_context(); + if self.modal { + context.set.insert("ModalTerminal".into()); + } + context + } +} + +impl View for ErrorView { + fn ui_name() -> &'static str { + "Terminal Error" + } + + fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { + let settings = cx.global::(); + let style = TerminalElement::make_text_style(cx.font_cache(), settings); + + //TODO: + //We want markdown style highlighting so we can format the program and working directory with `` + //We want a max-width of 75% with word-wrap + //We want to be able to select the text + //Want to be able to scroll if the error message is massive somehow (resiliency) + + let program_text = { + match self.error.shell_to_string() { + Some(shell_txt) => format!("Shell Program: `{}`", shell_txt), + None => "No program specified".to_string(), + } + }; + + let directory_text = { + match self.error.directory.as_ref() { + Some(path) => format!("Working directory: `{}`", path.to_string_lossy()), + None => "No working directory specified".to_string(), + } + }; + + let error_text = self.error.source.to_string(); + + Flex::column() + .with_child( + Text::new("Failed to open the terminal.".to_string(), style.clone()) + .contained() + .boxed(), + ) + .with_child(Text::new(program_text, style.clone()).contained().boxed()) + .with_child(Text::new(directory_text, style.clone()).contained().boxed()) + .with_child(Text::new(error_text, style).contained().boxed()) + .aligned() + .boxed() + } +} + +impl Item for TerminalContainer { + fn tab_content( + &self, + _detail: Option, + tab_theme: &theme::Tab, + cx: &gpui::AppContext, + ) -> ElementBox { + let title = match &self.content { + TerminalContent::Connected(connected) => { + connected.read(cx).handle().read(cx).title.to_string() + } + TerminalContent::Error(_) => "Terminal".to_string(), + }; + + Flex::row() + .with_child( + Label::new(title, tab_theme.label.clone()) + .aligned() + .contained() + .boxed(), + ) + .boxed() + } + + fn clone_on_split(&self, cx: &mut ViewContext) -> Option { + //From what I can tell, there's no way to tell the current working + //Directory of the terminal from outside the shell. There might be + //solutions to this, but they are non-trivial and require more IPC + Some(TerminalContainer::new( + self.associated_directory.clone(), + false, + cx, + )) + } + + fn project_path(&self, _cx: &gpui::AppContext) -> Option { + None + } + + fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> { + SmallVec::new() + } + + fn is_singleton(&self, _cx: &gpui::AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext) {} + + fn can_save(&self, _cx: &gpui::AppContext) -> bool { + false + } + + fn save( + &mut self, + _project: gpui::ModelHandle, + _cx: &mut ViewContext, + ) -> gpui::Task> { + unreachable!("save should not have been called"); + } + + fn save_as( + &mut self, + _project: gpui::ModelHandle, + _abs_path: std::path::PathBuf, + _cx: &mut ViewContext, + ) -> gpui::Task> { + unreachable!("save_as should not have been called"); + } + + fn reload( + &mut self, + _project: gpui::ModelHandle, + _cx: &mut ViewContext, + ) -> gpui::Task> { + gpui::Task::ready(Ok(())) + } + + fn is_dirty(&self, cx: &gpui::AppContext) -> bool { + if let TerminalContent::Connected(connected) = &self.content { + connected.read(cx).has_new_content() + } else { + false + } + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + if let TerminalContent::Connected(connected) = &self.content { + connected.read(cx).has_bell() + } else { + false + } + } + + fn should_update_tab_on_event(event: &Self::Event) -> bool { + matches!(event, &Event::TitleChanged | &Event::Wakeup) + } + + fn should_close_item_on_event(event: &Self::Event) -> bool { + matches!(event, &Event::CloseTerminal) + } +} + +///Get's the working directory for the given workspace, respecting the user's settings. +pub fn get_working_directory( + workspace: &Workspace, + cx: &AppContext, + strategy: WorkingDirectory, +) -> Option { + let res = match strategy { + WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx) + .or_else(|| first_project_directory(workspace, cx)), + WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), + WorkingDirectory::AlwaysHome => None, + WorkingDirectory::Always { directory } => { + shellexpand::full(&directory) //TODO handle this better + .ok() + .map(|dir| Path::new(&dir.to_string()).to_path_buf()) + .filter(|dir| dir.is_dir()) + } + }; + res.or_else(home_dir) +} + +///Get's the first project's home directory, or the home directory +fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option { + workspace + .worktrees(cx) + .next() + .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) + .and_then(get_path_from_wt) +} + +///Gets the intuitively correct working directory from the given workspace +///If there is an active entry for this project, returns that entry's worktree root. +///If there's no active entry but there is a worktree, returns that worktrees root. +///If either of these roots are files, or if there are any other query failures, +/// returns the user's home directory +fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option { + let project = workspace.project().read(cx); + + project + .active_entry() + .and_then(|entry_id| project.worktree_for_entry(entry_id, cx)) + .or_else(|| workspace.worktrees(cx).next()) + .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) + .and_then(get_path_from_wt) +} + +fn get_path_from_wt(wt: &LocalWorktree) -> Option { + wt.root_entry() + .filter(|re| re.is_dir()) + .map(|_| wt.abs_path().to_path_buf()) +} + +#[cfg(test)] +mod tests { + + use super::*; + use gpui::TestAppContext; + + use std::path::Path; + + use crate::tests::terminal_test_context::TerminalTestContext; + + ///Working directory calculation tests + + ///No Worktrees in project -> home_dir() + #[gpui::test] + async fn no_worktree(cx: &mut TestAppContext) { + //Setup variables + let mut cx = TerminalTestContext::new(cx); + let (project, workspace) = cx.blank_workspace().await; + //Test + cx.cx.read(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + //Make sure enviroment is as expeted + assert!(active_entry.is_none()); + assert!(workspace.worktrees(cx).next().is_none()); + + let res = current_project_directory(workspace, cx); + assert_eq!(res, None); + let res = first_project_directory(workspace, cx); + assert_eq!(res, None); + }); + } + + ///No active entry, but a worktree, worktree is a file -> home_dir() + #[gpui::test] + async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) { + //Setup variables + + let mut cx = TerminalTestContext::new(cx); + let (project, workspace) = cx.blank_workspace().await; + cx.create_file_wt(project.clone(), "/root.txt").await; + + cx.cx.read(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + //Make sure enviroment is as expeted + assert!(active_entry.is_none()); + assert!(workspace.worktrees(cx).next().is_some()); + + let res = current_project_directory(workspace, cx); + assert_eq!(res, None); + let res = first_project_directory(workspace, cx); + assert_eq!(res, None); + }); + } + + //No active entry, but a worktree, worktree is a folder -> worktree_folder + #[gpui::test] + async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) { + //Setup variables + let mut cx = TerminalTestContext::new(cx); + let (project, workspace) = cx.blank_workspace().await; + let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await; + + //Test + cx.cx.update(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + assert!(active_entry.is_none()); + assert!(workspace.worktrees(cx).next().is_some()); + + let res = current_project_directory(workspace, cx); + assert_eq!(res, Some((Path::new("/root/")).to_path_buf())); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some((Path::new("/root/")).to_path_buf())); + }); + } + + //Active entry with a work tree, worktree is a file -> home_dir() + #[gpui::test] + async fn active_entry_worktree_is_file(cx: &mut TestAppContext) { + //Setup variables + let mut cx = TerminalTestContext::new(cx); + let (project, workspace) = cx.blank_workspace().await; + let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await; + let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await; + cx.insert_active_entry_for(wt2, entry2, project.clone()); + + //Test + cx.cx.update(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + assert!(active_entry.is_some()); + + let res = current_project_directory(workspace, cx); + assert_eq!(res, None); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some((Path::new("/root1/")).to_path_buf())); + }); + } + + //Active entry, with a worktree, worktree is a folder -> worktree_folder + #[gpui::test] + async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) { + //Setup variables + let mut cx = TerminalTestContext::new(cx); + let (project, workspace) = cx.blank_workspace().await; + let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await; + let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await; + cx.insert_active_entry_for(wt2, entry2, project.clone()); + + //Test + cx.cx.update(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + assert!(active_entry.is_some()); + + let res = current_project_directory(workspace, cx); + assert_eq!(res, Some((Path::new("/root2/")).to_path_buf())); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some((Path::new("/root1/")).to_path_buf())); + }); + } +} diff --git a/crates/terminal/src/connected_el.rs b/crates/terminal/src/terminal_element.rs similarity index 96% rename from crates/terminal/src/connected_el.rs rename to crates/terminal/src/terminal_element.rs index 20a97b53d3..66820c727f 100644 --- a/crates/terminal/src/connected_el.rs +++ b/crates/terminal/src/terminal_element.rs @@ -35,8 +35,8 @@ use std::{ }; use crate::{ - connected_view::{ConnectedView, DeployContextMenu}, mappings::colors::convert_color, + terminal_view::{DeployContextMenu, TerminalView}, Terminal, TerminalSize, }; @@ -193,23 +193,23 @@ impl RelativeHighlightedRange { ///The GPUI element that paints the terminal. ///We need to keep a reference to the view for mouse events, do we need it for any other terminal stuff, or can we move that to connection? -pub struct TerminalEl { +pub struct TerminalElement { terminal: WeakModelHandle, - view: WeakViewHandle, + view: WeakViewHandle, modal: bool, focused: bool, cursor_visible: bool, } -impl TerminalEl { +impl TerminalElement { pub fn new( - view: WeakViewHandle, + view: WeakViewHandle, terminal: WeakModelHandle, modal: bool, focused: bool, cursor_visible: bool, - ) -> TerminalEl { - TerminalEl { + ) -> TerminalElement { + TerminalElement { view, terminal, modal, @@ -302,7 +302,7 @@ impl TerminalEl { { let cell_text = &cell.c.to_string(); if cell_text != " " { - let cell_style = TerminalEl::cell_style( + let cell_style = TerminalElement::cell_style( &cell, fg, terminal_theme, @@ -444,7 +444,7 @@ impl TerminalEl { // Start selections .on_down( MouseButton::Left, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -466,7 +466,7 @@ impl TerminalEl { // Copy on up behavior .on_up( MouseButton::Left, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -477,7 +477,7 @@ impl TerminalEl { // Handle click based selections .on_click( MouseButton::Left, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -507,7 +507,7 @@ impl TerminalEl { region = region .on_down( MouseButton::Right, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -517,7 +517,7 @@ impl TerminalEl { ) .on_down( MouseButton::Middle, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -527,7 +527,7 @@ impl TerminalEl { ) .on_up( MouseButton::Right, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -537,7 +537,7 @@ impl TerminalEl { ) .on_up( MouseButton::Middle, - TerminalEl::generic_button_handler( + TerminalElement::generic_button_handler( connection, origin, move |terminal, origin, e, _cx| { @@ -598,7 +598,7 @@ impl TerminalEl { } } -impl Element for TerminalEl { +impl Element for TerminalElement { type LayoutState = LayoutState; type PaintState = (); @@ -612,7 +612,7 @@ impl Element for TerminalEl { //Setup layout information let terminal_theme = settings.theme.terminal.clone(); //TODO: Try to minimize this clone. - let text_style = TerminalEl::make_text_style(font_cache, settings); + let text_style = TerminalElement::make_text_style(font_cache, settings); let selection_color = settings.theme.editor.selection.selection; let dimensions = { let line_height = font_cache.line_height(text_style.font_size); @@ -660,7 +660,7 @@ impl Element for TerminalEl { }) }); - let (cells, rects, highlights) = TerminalEl::layout_grid( + let (cells, rects, highlights) = TerminalElement::layout_grid( cells, &text_style, &terminal_theme, @@ -699,7 +699,7 @@ impl Element for TerminalEl { ) }; - TerminalEl::shape_cursor(cursor_point, dimensions, &cursor_text).map( + TerminalElement::shape_cursor(cursor_point, dimensions, &cursor_text).map( move |(cursor_position, block_width)| { let shape = match cursor.shape { AlacCursorShape::Block if !self.focused => CursorShape::Hollow, diff --git a/crates/terminal/src/terminal_view.rs b/crates/terminal/src/terminal_view.rs index 8041eafb9c..e5117604c5 100644 --- a/crates/terminal/src/terminal_view.rs +++ b/crates/terminal/src/terminal_view.rs @@ -1,155 +1,304 @@ -use crate::connected_view::ConnectedView; -use crate::{Event, Terminal, TerminalBuilder, TerminalError}; +use std::time::Duration; -use dirs::home_dir; +use alacritty_terminal::term::TermMode; +use context_menu::{ContextMenu, ContextMenuItem}; use gpui::{ - actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, View, + actions, + elements::{ChildView, ParentElement, Stack}, + geometry::vector::Vector2F, + impl_internal_actions, + keymap::Keystroke, + AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle, }; -use workspace::{Item, Workspace}; +use settings::{Settings, TerminalBlink}; +use smol::Timer; +use workspace::pane; -use crate::TerminalSize; -use project::{LocalWorktree, Project, ProjectPath}; -use settings::{AlternateScroll, Settings, WorkingDirectory}; -use smallvec::SmallVec; -use std::path::{Path, PathBuf}; +use crate::{terminal_element::TerminalElement, Event, Terminal}; -use crate::connected_el::TerminalEl; +const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); -actions!(terminal, [DeployModal]); +///Event to transmit the scroll from the element to the view +#[derive(Clone, Debug, PartialEq)] +pub struct ScrollTerminal(pub i32); + +#[derive(Clone, PartialEq)] +pub struct DeployContextMenu { + pub position: Vector2F, +} + +actions!( + terminal, + [ + Up, + Down, + CtrlC, + Escape, + Enter, + Clear, + Copy, + Paste, + ShowCharacterPalette, + ] +); +impl_internal_actions!(project_panel, [DeployContextMenu]); pub fn init(cx: &mut MutableAppContext) { - cx.add_action(TerminalView::deploy); -} - -//Make terminal view an enum, that can give you views for the error and non-error states -//Take away all the result unwrapping in the current TerminalView by making it 'infallible' -//Bubble up to deploy(_modal)() calls - -pub enum TerminalContent { - Connected(ViewHandle), - Error(ViewHandle), -} - -impl TerminalContent { - fn handle(&self) -> AnyViewHandle { - match self { - Self::Connected(handle) => handle.into(), - Self::Error(handle) => handle.into(), - } - } + //Global binding overrrides + cx.add_action(TerminalView::ctrl_c); + cx.add_action(TerminalView::up); + cx.add_action(TerminalView::down); + cx.add_action(TerminalView::escape); + cx.add_action(TerminalView::enter); + //Useful terminal views + cx.add_action(TerminalView::deploy_context_menu); + cx.add_action(TerminalView::copy); + cx.add_action(TerminalView::paste); + cx.add_action(TerminalView::clear); + cx.add_action(TerminalView::show_character_palette); } +///A terminal view, maintains the PTY's file handles and communicates with the terminal pub struct TerminalView { + terminal: ModelHandle, + has_new_content: bool, + //Currently using iTerm bell, show bell emoji in tab until input is received + has_bell: bool, + // Only for styling purposes. Doesn't effect behavior modal: bool, - pub content: TerminalContent, - associated_directory: Option, -} - -pub struct ErrorView { - error: TerminalError, + context_menu: ViewHandle, + blink_state: bool, + blinking_on: bool, + blinking_paused: bool, + blink_epoch: usize, } impl Entity for TerminalView { type Event = Event; } -impl Entity for ConnectedView { - type Event = Event; -} - -impl Entity for ErrorView { - type Event = Event; -} - impl TerminalView { - ///Create a new Terminal in the current working directory or the user's home directory - pub fn deploy( - workspace: &mut Workspace, - _: &workspace::NewTerminal, - cx: &mut ViewContext, - ) { - let strategy = cx - .global::() - .terminal_overrides - .working_directory - .clone() - .unwrap_or(WorkingDirectory::CurrentProjectDirectory); - - let working_directory = get_working_directory(workspace, cx, strategy); - let view = cx.add_view(|cx| TerminalView::new(working_directory, false, cx)); - workspace.add_item(Box::new(view), cx); - } - - ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices - pub fn new( - working_directory: Option, - modal: bool, - cx: &mut ViewContext, - ) -> Self { - //The exact size here doesn't matter, the terminal will be resized on the first layout - let size_info = TerminalSize::default(); - - let settings = cx.global::(); - let shell = settings.terminal_overrides.shell.clone(); - let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap. - - //TODO: move this pattern to settings - let scroll = settings - .terminal_overrides - .alternate_scroll - .as_ref() - .unwrap_or( - settings - .terminal_defaults - .alternate_scroll - .as_ref() - .unwrap_or_else(|| &AlternateScroll::On), - ); - - let content = match TerminalBuilder::new( - working_directory.clone(), - shell, - envs, - size_info, - settings.terminal_overrides.blinking.clone(), - scroll, - ) { - Ok(terminal) => { - let terminal = cx.add_model(|cx| terminal.subscribe(cx)); - let view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx)); - cx.subscribe(&view, |_this, _content, event, cx| cx.emit(*event)) - .detach(); - TerminalContent::Connected(view) - } - Err(error) => { - let view = cx.add_view(|_| ErrorView { - error: error.downcast::().unwrap(), - }); - TerminalContent::Error(view) - } - }; - cx.focus(content.handle()); - - TerminalView { - modal, - content, - associated_directory: working_directory, - } - } - pub fn from_terminal( terminal: ModelHandle, modal: bool, cx: &mut ViewContext, ) -> Self { - let connected_view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx)); - TerminalView { + cx.observe(&terminal, |_, _, cx| cx.notify()).detach(); + cx.subscribe(&terminal, |this, _, event, cx| match event { + Event::Wakeup => { + if !cx.is_self_focused() { + this.has_new_content = true; + cx.notify(); + cx.emit(Event::Wakeup); + } + } + Event::Bell => { + this.has_bell = true; + cx.emit(Event::Wakeup); + } + Event::BlinkChanged => this.blinking_on = !this.blinking_on, + _ => cx.emit(*event), + }) + .detach(); + + Self { + terminal, + has_new_content: true, + has_bell: false, modal, - content: TerminalContent::Connected(connected_view), - associated_directory: None, + context_menu: cx.add_view(ContextMenu::new), + blink_state: true, + blinking_on: false, + blinking_paused: false, + blink_epoch: 0, } } + + pub fn handle(&self) -> ModelHandle { + self.terminal.clone() + } + + pub fn has_new_content(&self) -> bool { + self.has_new_content + } + + pub fn has_bell(&self) -> bool { + self.has_bell + } + + pub fn clear_bel(&mut self, cx: &mut ViewContext) { + self.has_bell = false; + cx.emit(Event::Wakeup); + } + + pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext) { + let menu_entries = vec![ + ContextMenuItem::item("Clear Buffer", Clear), + ContextMenuItem::item("Close Terminal", pane::CloseActiveItem), + ]; + + self.context_menu + .update(cx, |menu, cx| menu.show(action.position, menu_entries, cx)); + + cx.notify(); + } + + fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext) { + if !self + .terminal + .read(cx) + .last_mode + .contains(TermMode::ALT_SCREEN) + { + cx.show_character_palette(); + } else { + self.terminal.update(cx, |term, _| { + term.try_keystroke(&Keystroke::parse("ctrl-cmd-space").unwrap()) + }); + } + } + + fn clear(&mut self, _: &Clear, cx: &mut ViewContext) { + self.terminal.update(cx, |term, _| term.clear()); + cx.notify(); + } + + pub fn should_show_cursor( + &self, + focused: bool, + cx: &mut gpui::RenderContext<'_, Self>, + ) -> bool { + //Don't blink the cursor when not focused, blinking is disabled, or paused + if !focused + || !self.blinking_on + || self.blinking_paused + || self + .terminal + .read(cx) + .last_mode + .contains(TermMode::ALT_SCREEN) + { + return true; + } + + let setting = { + let settings = cx.global::(); + settings + .terminal_overrides + .blinking + .clone() + .unwrap_or(TerminalBlink::TerminalControlled) + }; + + match setting { + //If the user requested to never blink, don't blink it. + TerminalBlink::Off => true, + //If the terminal is controlling it, check terminal mode + TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state, + } + } + + fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext) { + if epoch == self.blink_epoch && !self.blinking_paused { + self.blink_state = !self.blink_state; + cx.notify(); + + let epoch = self.next_blink_epoch(); + cx.spawn(|this, mut cx| { + let this = this.downgrade(); + async move { + Timer::after(CURSOR_BLINK_INTERVAL).await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx)); + } + } + }) + .detach(); + } + } + + pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext) { + self.blink_state = true; + cx.notify(); + + let epoch = self.next_blink_epoch(); + cx.spawn(|this, mut cx| { + let this = this.downgrade(); + async move { + Timer::after(CURSOR_BLINK_INTERVAL).await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx)) + } + } + }) + .detach(); + } + + fn next_blink_epoch(&mut self) -> usize { + self.blink_epoch += 1; + self.blink_epoch + } + + fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext) { + if epoch == self.blink_epoch { + self.blinking_paused = false; + self.blink_cursors(epoch, cx); + } + } + + ///Attempt to paste the clipboard into the terminal + fn copy(&mut self, _: &Copy, cx: &mut ViewContext) { + self.terminal.update(cx, |term, _| term.copy()) + } + + ///Attempt to paste the clipboard into the terminal + fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { + if let Some(item) = cx.read_from_clipboard() { + self.terminal + .update(cx, |terminal, _cx| terminal.paste(item.text())); + } + } + + ///Synthesize the keyboard event corresponding to 'up' + fn up(&mut self, _: &Up, cx: &mut ViewContext) { + self.clear_bel(cx); + self.terminal.update(cx, |term, _| { + term.try_keystroke(&Keystroke::parse("up").unwrap()) + }); + } + + ///Synthesize the keyboard event corresponding to 'down' + fn down(&mut self, _: &Down, cx: &mut ViewContext) { + self.clear_bel(cx); + self.terminal.update(cx, |term, _| { + term.try_keystroke(&Keystroke::parse("down").unwrap()) + }); + } + + ///Synthesize the keyboard event corresponding to 'ctrl-c' + fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext) { + self.clear_bel(cx); + self.terminal.update(cx, |term, _| { + term.try_keystroke(&Keystroke::parse("ctrl-c").unwrap()) + }); + } + + ///Synthesize the keyboard event corresponding to 'escape' + fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { + self.clear_bel(cx); + self.terminal.update(cx, |term, _| { + term.try_keystroke(&Keystroke::parse("escape").unwrap()) + }); + } + + ///Synthesize the keyboard event corresponding to 'enter' + fn enter(&mut self, _: &Enter, cx: &mut ViewContext) { + self.clear_bel(cx); + self.terminal.update(cx, |term, _| { + term.try_keystroke(&Keystroke::parse("enter").unwrap()) + }); + } } impl View for TerminalView { @@ -158,360 +307,147 @@ impl View for TerminalView { } fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { - let child_view = match &self.content { - TerminalContent::Connected(connected) => ChildView::new(connected), - TerminalContent::Error(error) => ChildView::new(error), - }; - if self.modal { - let settings = cx.global::(); - let container_style = settings.theme.terminal.modal_container; - child_view.contained().with_style(container_style).boxed() - } else { - child_view.boxed() - } + let terminal_handle = self.terminal.clone().downgrade(); + + let self_id = cx.view_id(); + let focused = cx + .focused_view_id(cx.window_id()) + .filter(|view_id| *view_id == self_id) + .is_some(); + + Stack::new() + .with_child( + TerminalElement::new( + cx.handle(), + terminal_handle, + self.modal, + focused, + self.should_show_cursor(focused, cx), + ) + .contained() + .boxed(), + ) + .with_child(ChildView::new(&self.context_menu).boxed()) + .boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - if cx.is_self_focused() { - cx.focus(self.content.handle()); + self.has_new_content = false; + self.terminal.read(cx).focus_in(); + self.blink_cursors(self.blink_epoch, cx); + cx.notify(); + } + + fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + self.terminal.read(cx).focus_out(); + cx.notify(); + } + + //IME stuff + fn selected_text_range(&self, cx: &AppContext) -> Option> { + if self + .terminal + .read(cx) + .last_mode + .contains(TermMode::ALT_SCREEN) + { + None + } else { + Some(0..0) } } - fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context { + fn replace_text_in_range( + &mut self, + _: Option>, + text: &str, + cx: &mut ViewContext, + ) { + self.terminal.update(cx, |terminal, _| { + terminal.input(text.into()); + }); + } + + fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context { let mut context = Self::default_keymap_context(); if self.modal { context.set.insert("ModalTerminal".into()); } + let mode = self.terminal.read(cx).last_mode; + context.map.insert( + "screen".to_string(), + (if mode.contains(TermMode::ALT_SCREEN) { + "alt" + } else { + "normal" + }) + .to_string(), + ); + + if mode.contains(TermMode::APP_CURSOR) { + context.set.insert("DECCKM".to_string()); + } + if mode.contains(TermMode::APP_KEYPAD) { + context.set.insert("DECPAM".to_string()); + } + //Note the ! here + if !mode.contains(TermMode::APP_KEYPAD) { + context.set.insert("DECPNM".to_string()); + } + if mode.contains(TermMode::SHOW_CURSOR) { + context.set.insert("DECTCEM".to_string()); + } + if mode.contains(TermMode::LINE_WRAP) { + context.set.insert("DECAWM".to_string()); + } + if mode.contains(TermMode::ORIGIN) { + context.set.insert("DECOM".to_string()); + } + if mode.contains(TermMode::INSERT) { + context.set.insert("IRM".to_string()); + } + //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html + if mode.contains(TermMode::LINE_FEED_NEW_LINE) { + context.set.insert("LNM".to_string()); + } + if mode.contains(TermMode::FOCUS_IN_OUT) { + context.set.insert("report_focus".to_string()); + } + if mode.contains(TermMode::ALTERNATE_SCROLL) { + context.set.insert("alternate_scroll".to_string()); + } + if mode.contains(TermMode::BRACKETED_PASTE) { + context.set.insert("bracketed_paste".to_string()); + } + if mode.intersects(TermMode::MOUSE_MODE) { + context.set.insert("any_mouse_reporting".to_string()); + } + { + let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) { + "click" + } else if mode.contains(TermMode::MOUSE_DRAG) { + "drag" + } else if mode.contains(TermMode::MOUSE_MOTION) { + "motion" + } else { + "off" + }; + context + .map + .insert("mouse_reporting".to_string(), mouse_reporting.to_string()); + } + { + let format = if mode.contains(TermMode::SGR_MOUSE) { + "sgr" + } else if mode.contains(TermMode::UTF8_MOUSE) { + "utf8" + } else { + "normal" + }; + context + .map + .insert("mouse_format".to_string(), format.to_string()); + } context } } - -impl View for ErrorView { - fn ui_name() -> &'static str { - "Terminal Error" - } - - fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { - let settings = cx.global::(); - let style = TerminalEl::make_text_style(cx.font_cache(), settings); - - //TODO: - //We want markdown style highlighting so we can format the program and working directory with `` - //We want a max-width of 75% with word-wrap - //We want to be able to select the text - //Want to be able to scroll if the error message is massive somehow (resiliency) - - let program_text = { - match self.error.shell_to_string() { - Some(shell_txt) => format!("Shell Program: `{}`", shell_txt), - None => "No program specified".to_string(), - } - }; - - let directory_text = { - match self.error.directory.as_ref() { - Some(path) => format!("Working directory: `{}`", path.to_string_lossy()), - None => "No working directory specified".to_string(), - } - }; - - let error_text = self.error.source.to_string(); - - Flex::column() - .with_child( - Text::new("Failed to open the terminal.".to_string(), style.clone()) - .contained() - .boxed(), - ) - .with_child(Text::new(program_text, style.clone()).contained().boxed()) - .with_child(Text::new(directory_text, style.clone()).contained().boxed()) - .with_child(Text::new(error_text, style).contained().boxed()) - .aligned() - .boxed() - } -} - -impl Item for TerminalView { - fn tab_content( - &self, - _detail: Option, - tab_theme: &theme::Tab, - cx: &gpui::AppContext, - ) -> ElementBox { - let title = match &self.content { - TerminalContent::Connected(connected) => { - connected.read(cx).handle().read(cx).title.to_string() - } - TerminalContent::Error(_) => "Terminal".to_string(), - }; - - Flex::row() - .with_child( - Label::new(title, tab_theme.label.clone()) - .aligned() - .contained() - .boxed(), - ) - .boxed() - } - - fn clone_on_split(&self, cx: &mut ViewContext) -> Option { - //From what I can tell, there's no way to tell the current working - //Directory of the terminal from outside the shell. There might be - //solutions to this, but they are non-trivial and require more IPC - Some(TerminalView::new( - self.associated_directory.clone(), - false, - cx, - )) - } - - fn project_path(&self, _cx: &gpui::AppContext) -> Option { - None - } - - fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> { - SmallVec::new() - } - - fn is_singleton(&self, _cx: &gpui::AppContext) -> bool { - false - } - - fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext) {} - - fn can_save(&self, _cx: &gpui::AppContext) -> bool { - false - } - - fn save( - &mut self, - _project: gpui::ModelHandle, - _cx: &mut ViewContext, - ) -> gpui::Task> { - unreachable!("save should not have been called"); - } - - fn save_as( - &mut self, - _project: gpui::ModelHandle, - _abs_path: std::path::PathBuf, - _cx: &mut ViewContext, - ) -> gpui::Task> { - unreachable!("save_as should not have been called"); - } - - fn reload( - &mut self, - _project: gpui::ModelHandle, - _cx: &mut ViewContext, - ) -> gpui::Task> { - gpui::Task::ready(Ok(())) - } - - fn is_dirty(&self, cx: &gpui::AppContext) -> bool { - if let TerminalContent::Connected(connected) = &self.content { - connected.read(cx).has_new_content() - } else { - false - } - } - - fn has_conflict(&self, cx: &AppContext) -> bool { - if let TerminalContent::Connected(connected) = &self.content { - connected.read(cx).has_bell() - } else { - false - } - } - - fn should_update_tab_on_event(event: &Self::Event) -> bool { - matches!(event, &Event::TitleChanged | &Event::Wakeup) - } - - fn should_close_item_on_event(event: &Self::Event) -> bool { - matches!(event, &Event::CloseTerminal) - } -} - -///Get's the working directory for the given workspace, respecting the user's settings. -pub fn get_working_directory( - workspace: &Workspace, - cx: &AppContext, - strategy: WorkingDirectory, -) -> Option { - let res = match strategy { - WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx) - .or_else(|| first_project_directory(workspace, cx)), - WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), - WorkingDirectory::AlwaysHome => None, - WorkingDirectory::Always { directory } => { - shellexpand::full(&directory) //TODO handle this better - .ok() - .map(|dir| Path::new(&dir.to_string()).to_path_buf()) - .filter(|dir| dir.is_dir()) - } - }; - res.or_else(home_dir) -} - -///Get's the first project's home directory, or the home directory -fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option { - workspace - .worktrees(cx) - .next() - .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) - .and_then(get_path_from_wt) -} - -///Gets the intuitively correct working directory from the given workspace -///If there is an active entry for this project, returns that entry's worktree root. -///If there's no active entry but there is a worktree, returns that worktrees root. -///If either of these roots are files, or if there are any other query failures, -/// returns the user's home directory -fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option { - let project = workspace.project().read(cx); - - project - .active_entry() - .and_then(|entry_id| project.worktree_for_entry(entry_id, cx)) - .or_else(|| workspace.worktrees(cx).next()) - .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) - .and_then(get_path_from_wt) -} - -fn get_path_from_wt(wt: &LocalWorktree) -> Option { - wt.root_entry() - .filter(|re| re.is_dir()) - .map(|_| wt.abs_path().to_path_buf()) -} - -#[cfg(test)] -mod tests { - - use super::*; - use gpui::TestAppContext; - - use std::path::Path; - - use crate::tests::terminal_test_context::TerminalTestContext; - - ///Working directory calculation tests - - ///No Worktrees in project -> home_dir() - #[gpui::test] - async fn no_worktree(cx: &mut TestAppContext) { - //Setup variables - let mut cx = TerminalTestContext::new(cx); - let (project, workspace) = cx.blank_workspace().await; - //Test - cx.cx.read(|cx| { - let workspace = workspace.read(cx); - let active_entry = project.read(cx).active_entry(); - - //Make sure enviroment is as expeted - assert!(active_entry.is_none()); - assert!(workspace.worktrees(cx).next().is_none()); - - let res = current_project_directory(workspace, cx); - assert_eq!(res, None); - let res = first_project_directory(workspace, cx); - assert_eq!(res, None); - }); - } - - ///No active entry, but a worktree, worktree is a file -> home_dir() - #[gpui::test] - async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) { - //Setup variables - - let mut cx = TerminalTestContext::new(cx); - let (project, workspace) = cx.blank_workspace().await; - cx.create_file_wt(project.clone(), "/root.txt").await; - - cx.cx.read(|cx| { - let workspace = workspace.read(cx); - let active_entry = project.read(cx).active_entry(); - - //Make sure enviroment is as expeted - assert!(active_entry.is_none()); - assert!(workspace.worktrees(cx).next().is_some()); - - let res = current_project_directory(workspace, cx); - assert_eq!(res, None); - let res = first_project_directory(workspace, cx); - assert_eq!(res, None); - }); - } - - //No active entry, but a worktree, worktree is a folder -> worktree_folder - #[gpui::test] - async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) { - //Setup variables - let mut cx = TerminalTestContext::new(cx); - let (project, workspace) = cx.blank_workspace().await; - let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await; - - //Test - cx.cx.update(|cx| { - let workspace = workspace.read(cx); - let active_entry = project.read(cx).active_entry(); - - assert!(active_entry.is_none()); - assert!(workspace.worktrees(cx).next().is_some()); - - let res = current_project_directory(workspace, cx); - assert_eq!(res, Some((Path::new("/root/")).to_path_buf())); - let res = first_project_directory(workspace, cx); - assert_eq!(res, Some((Path::new("/root/")).to_path_buf())); - }); - } - - //Active entry with a work tree, worktree is a file -> home_dir() - #[gpui::test] - async fn active_entry_worktree_is_file(cx: &mut TestAppContext) { - //Setup variables - let mut cx = TerminalTestContext::new(cx); - let (project, workspace) = cx.blank_workspace().await; - let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await; - let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await; - cx.insert_active_entry_for(wt2, entry2, project.clone()); - - //Test - cx.cx.update(|cx| { - let workspace = workspace.read(cx); - let active_entry = project.read(cx).active_entry(); - - assert!(active_entry.is_some()); - - let res = current_project_directory(workspace, cx); - assert_eq!(res, None); - let res = first_project_directory(workspace, cx); - assert_eq!(res, Some((Path::new("/root1/")).to_path_buf())); - }); - } - - //Active entry, with a worktree, worktree is a folder -> worktree_folder - #[gpui::test] - async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) { - //Setup variables - let mut cx = TerminalTestContext::new(cx); - let (project, workspace) = cx.blank_workspace().await; - let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await; - let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await; - cx.insert_active_entry_for(wt2, entry2, project.clone()); - - //Test - cx.cx.update(|cx| { - let workspace = workspace.read(cx); - let active_entry = project.read(cx).active_entry(); - - assert!(active_entry.is_some()); - - let res = current_project_directory(workspace, cx); - assert_eq!(res, Some((Path::new("/root2/")).to_path_buf())); - let res = first_project_directory(workspace, cx); - assert_eq!(res, Some((Path::new("/root1/")).to_path_buf())); - }); - } -}