Detailed changes
@@ -62,7 +62,7 @@ dependencies = [
[[package]]
name = "alacritty_config_derive"
version = "0.1.0"
-source = "git+https://github.com/zed-industries/alacritty?rev=e9b864860ec79cc1b70042aafce100cdd6985a0a#e9b864860ec79cc1b70042aafce100cdd6985a0a"
+source = "git+https://github.com/zed-industries/alacritty?rev=a274ff43c38c76f9766a908ff86a4e10a8998a6f#a274ff43c38c76f9766a908ff86a4e10a8998a6f"
dependencies = [
"proc-macro2",
"quote",
@@ -72,7 +72,7 @@ dependencies = [
[[package]]
name = "alacritty_terminal"
version = "0.17.0-dev"
-source = "git+https://github.com/zed-industries/alacritty?rev=e9b864860ec79cc1b70042aafce100cdd6985a0a#e9b864860ec79cc1b70042aafce100cdd6985a0a"
+source = "git+https://github.com/zed-industries/alacritty?rev=a274ff43c38c76f9766a908ff86a4e10a8998a6f#a274ff43c38c76f9766a908ff86a4e10a8998a6f"
dependencies = [
"alacritty_config_derive",
"base64 0.13.0",
@@ -8,7 +8,7 @@ path = "src/terminal.rs"
doctest = false
[dependencies]
-alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "e9b864860ec79cc1b70042aafce100cdd6985a0a"}
+alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "a274ff43c38c76f9766a908ff86a4e10a8998a6f"}
editor = { path = "../editor" }
util = { path = "../util" }
gpui = { path = "../gpui" }
@@ -0,0 +1,8 @@
+Design notes:
+
+This crate is split into two conceptual halves:
+- The terminal.rs file and the ./src/mappings/ folder, these contain the code for interacting with Alacritty and maintaining the pty event loop. Some behavior in this file is constrained by terminal protocols and standards. The Zed init function is also placed here.
+- Everything else. These other files integrate the `Terminal` struct created in terminal.rs into the rest of GPUI. The main entry point for GPUI is the terminal_view.rs file and the modal.rs file.
+
+Terminals are created externally, and so can fail in unexpected ways However, GPUI currently does not have an API for models than can fail to instantiate. `TerminalBuilder` solves this by using Rust's type system to split `Terminal` instantiation into a 2 step process: first attempt to create the file handles with `TerminalBuilder::new()`, check the result, then call `TerminalBuilder::subscribe(cx)` from within a model context.
+The TerminalView struct abstracts over failed and successful terminals, and provides other constructs a standardized way of instantiating an always-successful terminal view.
@@ -1,7 +1,6 @@
use alacritty_terminal::{
ansi::{Color::Named, NamedColor},
- event::WindowSize,
- grid::{Dimensions, GridIterator, Indexed, Scroll},
+ grid::{Dimensions, Scroll},
index::{Column as GridCol, Line as GridLine, Point, Side},
selection::SelectionRange,
term::cell::{Cell, Flags},
@@ -26,15 +25,20 @@ use settings::Settings;
use theme::TerminalStyle;
use util::ResultExt;
-use std::{cmp::min, ops::Range};
+use std::{
+ cmp::min,
+ ops::{Deref, Range},
+};
use std::{fmt::Debug, ops::Sub};
-use crate::{mappings::colors::convert_color, model::Terminal, ConnectedView};
+use crate::{
+ connected_view::ConnectedView, mappings::colors::convert_color, Terminal, TerminalSize,
+};
///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
///Scroll multiplier that is set to 3 by default. This will be removed when I
///Implement scroll bars.
-const ALACRITTY_SCROLL_MULTIPLIER: f32 = 3.;
+pub const ALACRITTY_SCROLL_MULTIPLIER: f32 = 3.;
///The information generated during layout that is nescessary for painting
pub struct LayoutState {
@@ -44,7 +48,22 @@ pub struct LayoutState {
cursor: Option<Cursor>,
background_color: Color,
selection_color: Color,
- size: TermDimensions,
+ size: TerminalSize,
+ display_offset: usize,
+}
+
+struct IndexedCell {
+ point: Point,
+ cell: Cell,
+}
+
+impl Deref for IndexedCell {
+ type Target = Cell;
+
+ #[inline]
+ fn deref(&self) -> &Cell {
+ &self.cell
+ }
}
///Helper struct for converting data between alacritty's cursor points, and displayed cursor points
@@ -70,74 +89,6 @@ impl DisplayCursor {
}
}
-#[derive(Clone, Copy, Debug)]
-pub struct TermDimensions {
- cell_width: f32,
- line_height: f32,
- height: f32,
- width: f32,
-}
-
-impl TermDimensions {
- pub fn new(line_height: f32, cell_width: f32, size: Vector2F) -> Self {
- TermDimensions {
- cell_width,
- line_height,
- width: size.x(),
- height: size.y(),
- }
- }
-
- pub fn num_lines(&self) -> usize {
- (self.height / self.line_height).floor() as usize
- }
-
- pub fn num_columns(&self) -> usize {
- (self.width / self.cell_width).floor() as usize
- }
-
- pub fn height(&self) -> f32 {
- self.height
- }
-
- pub fn width(&self) -> f32 {
- self.width
- }
-
- pub fn cell_width(&self) -> f32 {
- self.cell_width
- }
-
- pub fn line_height(&self) -> f32 {
- self.line_height
- }
-}
-
-impl Into<WindowSize> for TermDimensions {
- fn into(self) -> WindowSize {
- WindowSize {
- num_lines: self.num_lines() as u16,
- num_cols: self.num_columns() as u16,
- cell_width: self.cell_width() as u16,
- cell_height: self.line_height() as u16,
- }
- }
-}
-
-impl Dimensions for TermDimensions {
- fn total_lines(&self) -> usize {
- self.screen_lines() //TODO: Check that this is fine. This is supposed to be for the back buffer...
- }
-
- fn screen_lines(&self) -> usize {
- self.num_lines()
- }
-
- fn columns(&self) -> usize {
- self.num_columns()
- }
-}
-
#[derive(Clone, Debug, Default)]
struct LayoutCell {
point: Point<i32, i32>,
@@ -256,7 +207,7 @@ impl TerminalEl {
}
fn layout_grid(
- grid: GridIterator<Cell>,
+ grid: Vec<IndexedCell>,
text_style: &TextStyle,
terminal_theme: &TerminalStyle,
text_layout_cache: &TextLayoutCache,
@@ -275,7 +226,7 @@ impl TerminalEl {
let mut cur_alac_color = None;
let mut highlighted_range = None;
- let linegroups = grid.group_by(|i| i.point.line);
+ let linegroups = grid.into_iter().group_by(|i| i.point.line);
for (line_index, (_, line)) in linegroups.into_iter().enumerate() {
for (x_index, cell) in line.enumerate() {
//Increase selection range
@@ -302,7 +253,7 @@ impl TerminalEl {
} else {
match cur_alac_color {
Some(cur_color) => {
- if cell.bg == cur_color {
+ if cell.cell.bg == cur_color {
cur_rect = cur_rect.take().map(|rect| rect.extend());
} else {
cur_alac_color = Some(cell.bg);
@@ -368,7 +319,7 @@ impl TerminalEl {
// the same position for sequential indexes. Use em_width instead
fn shape_cursor(
cursor_point: DisplayCursor,
- size: TermDimensions,
+ size: TerminalSize,
text_fragment: &Line,
) -> Option<(Vector2F, f32)> {
if cursor_point.line() < size.total_lines() as i32 {
@@ -392,7 +343,7 @@ impl TerminalEl {
///Convert the Alacritty cell styles to GPUI text styles and background color
fn cell_style(
- indexed: &Indexed<&Cell>,
+ indexed: &IndexedCell,
style: &TerminalStyle,
text_style: &TextStyle,
modal: bool,
@@ -421,7 +372,8 @@ impl TerminalEl {
origin: Vector2F,
view_id: usize,
visible_bounds: RectF,
- cur_size: TermDimensions,
+ cur_size: TerminalSize,
+ display_offset: usize,
cx: &mut PaintContext,
) {
let mouse_down_connection = self.terminal.clone();
@@ -438,7 +390,7 @@ impl TerminalEl {
position,
origin,
cur_size,
- terminal.get_display_offset(),
+ display_offset,
);
terminal.mouse_down(point, side);
@@ -463,7 +415,7 @@ impl TerminalEl {
position,
origin,
cur_size,
- terminal.get_display_offset(),
+ display_offset,
);
terminal.click(point, side, click_count);
@@ -482,7 +434,7 @@ impl TerminalEl {
position,
origin,
cur_size,
- terminal.get_display_offset(),
+ display_offset,
);
terminal.drag(point, side);
@@ -530,7 +482,7 @@ impl TerminalEl {
pub fn mouse_to_cell_data(
pos: Vector2F,
origin: Vector2F,
- cur_size: TermDimensions,
+ cur_size: TerminalSize,
display_offset: usize,
) -> (Point, alacritty_terminal::index::Direction) {
let pos = pos.sub(origin);
@@ -579,73 +531,87 @@ impl Element for TerminalEl {
cx: &mut gpui::LayoutContext,
) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
let settings = cx.global::<Settings>();
- let font_cache = &cx.font_cache();
+ let font_cache = cx.font_cache();
//Setup layout information
- let terminal_theme = &settings.theme.terminal;
+ let terminal_theme = settings.theme.terminal.clone(); //TODO: Try to minimize this clone.
let text_style = TerminalEl::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);
let cell_width = font_cache.em_advance(text_style.font_id, text_style.font_size);
- TermDimensions::new(line_height, cell_width, constraint.max)
+ TerminalSize::new(line_height, cell_width, constraint.max)
};
- let terminal = self.terminal.upgrade(cx).unwrap().read(cx);
-
- let (cursor, cells, rects, highlights) =
- terminal.render_lock(Some(dimensions.clone()), |content, cursor_text| {
- let (cells, rects, highlights) = TerminalEl::layout_grid(
- content.display_iter,
- &text_style,
- terminal_theme,
- cx.text_layout_cache,
- self.modal,
- content.selection,
- );
-
- //Layout cursor
- let cursor = {
- let cursor_point =
- DisplayCursor::from(content.cursor.point, content.display_offset);
- let cursor_text = {
- let str_trxt = cursor_text.to_string();
- cx.text_layout_cache.layout_str(
- &str_trxt,
- text_style.font_size,
- &[(
- str_trxt.len(),
- RunStyle {
- font_id: text_style.font_id,
- color: terminal_theme.colors.background,
- underline: Default::default(),
- },
- )],
- )
- };
+ let background_color = if self.modal {
+ terminal_theme.colors.modal_background.clone()
+ } else {
+ terminal_theme.colors.background.clone()
+ };
- TerminalEl::shape_cursor(cursor_point, dimensions, &cursor_text).map(
- move |(cursor_position, block_width)| {
- Cursor::new(
- cursor_position,
- block_width,
- dimensions.line_height,
- terminal_theme.colors.cursor,
- CursorShape::Block,
- Some(cursor_text.clone()),
- )
- },
+ let (cells, selection, cursor, display_offset, cursor_text) = self
+ .terminal
+ .upgrade(cx)
+ .unwrap()
+ .update(cx.app, |terminal, mcx| {
+ terminal.set_size(dimensions);
+ terminal.render_lock(mcx, |content, cursor_text| {
+ let mut cells = vec![];
+ cells.extend(content.display_iter.map(|ic| IndexedCell {
+ point: ic.point.clone(),
+ cell: ic.cell.clone(),
+ }));
+
+ (
+ cells,
+ content.selection.clone(),
+ content.cursor.clone(),
+ content.display_offset.clone(),
+ cursor_text.clone(),
)
- };
-
- (cursor, cells, rects, highlights)
+ })
});
- //Select background color
- let background_color = if self.modal {
- terminal_theme.colors.modal_background
- } else {
- terminal_theme.colors.background
+ let (cells, rects, highlights) = TerminalEl::layout_grid(
+ cells,
+ &text_style,
+ &terminal_theme,
+ cx.text_layout_cache,
+ self.modal,
+ selection,
+ );
+
+ //Layout cursor
+ let cursor = {
+ let cursor_point = DisplayCursor::from(cursor.point, display_offset);
+ let cursor_text = {
+ let str_trxt = cursor_text.to_string();
+ cx.text_layout_cache.layout_str(
+ &str_trxt,
+ text_style.font_size,
+ &[(
+ str_trxt.len(),
+ RunStyle {
+ font_id: text_style.font_id,
+ color: terminal_theme.colors.background,
+ underline: Default::default(),
+ },
+ )],
+ )
+ };
+
+ TerminalEl::shape_cursor(cursor_point, dimensions, &cursor_text).map(
+ move |(cursor_position, block_width)| {
+ Cursor::new(
+ cursor_position,
+ block_width,
+ dimensions.line_height,
+ terminal_theme.colors.cursor,
+ CursorShape::Block,
+ Some(cursor_text.clone()),
+ )
+ },
+ )
};
//Done!
@@ -659,6 +625,7 @@ impl Element for TerminalEl {
size: dimensions,
rects,
highlights,
+ display_offset,
},
)
}
@@ -677,7 +644,14 @@ impl Element for TerminalEl {
let origin = bounds.origin() + vec2f(layout.size.cell_width, 0.);
//Elements are ephemeral, only at paint time do we know what could be clicked by a mouse
- self.attach_mouse_handlers(origin, self.view.id(), visible_bounds, layout.size, cx);
+ self.attach_mouse_handlers(
+ origin,
+ self.view.id(),
+ visible_bounds,
+ layout.size,
+ layout.display_offset,
+ cx,
+ );
cx.paint_layer(clip_bounds, |cx| {
//Start with a background color
@@ -755,9 +729,9 @@ impl Element for TerminalEl {
(delta.y() / layout.size.line_height) * ALACRITTY_SCROLL_MULTIPLIER;
self.terminal.upgrade(cx.app).map(|terminal| {
- terminal
- .read(cx.app)
- .scroll(Scroll::Delta(vertical_scroll.round() as i32));
+ terminal.update(cx.app, |term, _| {
+ term.scroll(Scroll::Delta(vertical_scroll.round() as i32))
+ });
});
})
.is_some(),
@@ -773,8 +747,9 @@ impl Element for TerminalEl {
self.terminal
.upgrade(cx.app)
- .map(|model_handle| model_handle.read(cx.app))
- .map(|term| term.try_keystroke(keystroke))
+ .map(|model_handle| {
+ model_handle.update(cx.app, |term, _| term.try_keystroke(keystroke))
+ })
.unwrap_or(false)
}
_ => false,
@@ -832,7 +807,7 @@ mod test {
let origin_x = 10.;
let origin_y = 20.;
- let cur_size = crate::connected_el::TermDimensions::new(
+ let cur_size = crate::connected_el::TerminalSize::new(
line_height,
cell_width,
gpui::geometry::vector::vec2f(term_width, term_height),
@@ -1,12 +1,10 @@
+use alacritty_terminal::term::TermMode;
use gpui::{
- actions, keymap::Keystroke, AppContext, ClipboardItem, Element, ElementBox, ModelHandle,
- MutableAppContext, View, ViewContext,
+ actions, keymap::Keystroke, AppContext, Element, ElementBox, ModelHandle, MutableAppContext,
+ View, ViewContext,
};
-use crate::{
- connected_el::TerminalEl,
- model::{Event, Terminal},
-};
+use crate::{connected_el::TerminalEl, Event, Terminal};
///Event to transmit the scroll from the element to the view
#[derive(Clone, Debug, PartialEq)]
@@ -49,17 +47,17 @@ impl ConnectedView {
cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
cx.subscribe(&terminal, |this, _, event, cx| match event {
Event::Wakeup => {
- if cx.is_self_focused() {
- cx.notify()
- } else {
+ if !cx.is_self_focused() {
this.has_new_content = true;
- cx.emit(Event::TitleChanged);
+ cx.notify();
+ cx.emit(Event::Wakeup);
}
}
Event::Bell => {
this.has_bell = true;
- cx.emit(Event::TitleChanged);
+ cx.emit(Event::Wakeup);
}
+
_ => cx.emit(*event),
})
.detach();
@@ -86,19 +84,16 @@ impl ConnectedView {
pub fn clear_bel(&mut self, cx: &mut ViewContext<ConnectedView>) {
self.has_bell = false;
- cx.emit(Event::TitleChanged);
+ cx.emit(Event::Wakeup);
}
fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
- self.terminal.read(cx).clear();
+ self.terminal.update(cx, |term, _| term.clear());
}
///Attempt to paste the clipboard into the terminal
fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
- self.terminal
- .read(cx)
- .copy()
- .map(|text| cx.write_to_clipboard(ClipboardItem::new(text)));
+ self.terminal.update(cx, |term, _| term.copy())
}
///Attempt to paste the clipboard into the terminal
@@ -110,6 +105,7 @@ impl ConnectedView {
///Synthesize the keyboard event corresponding to 'up'
fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
+ self.clear_bel(cx);
self.terminal
.read(cx)
.try_keystroke(&Keystroke::parse("up").unwrap());
@@ -117,6 +113,7 @@ impl ConnectedView {
///Synthesize the keyboard event corresponding to 'down'
fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
+ self.clear_bel(cx);
self.terminal
.read(cx)
.try_keystroke(&Keystroke::parse("down").unwrap());
@@ -124,6 +121,7 @@ impl ConnectedView {
///Synthesize the keyboard event corresponding to 'ctrl-c'
fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext<Self>) {
+ self.clear_bel(cx);
self.terminal
.read(cx)
.try_keystroke(&Keystroke::parse("ctrl-c").unwrap());
@@ -131,6 +129,7 @@ impl ConnectedView {
///Synthesize the keyboard event corresponding to 'escape'
fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
+ self.clear_bel(cx);
self.terminal
.read(cx)
.try_keystroke(&Keystroke::parse("escape").unwrap());
@@ -138,6 +137,7 @@ impl ConnectedView {
///Synthesize the keyboard event corresponding to 'enter'
fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
+ self.clear_bel(cx);
self.terminal
.read(cx)
.try_keystroke(&Keystroke::parse("enter").unwrap());
@@ -160,8 +160,17 @@ impl View for ConnectedView {
self.has_new_content = false;
}
- fn selected_text_range(&self, _: &AppContext) -> Option<std::ops::Range<usize>> {
- Some(0..0)
+ fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
+ if self
+ .terminal
+ .read(cx)
+ .last_mode
+ .contains(TermMode::ALT_SCREEN)
+ {
+ None
+ } else {
+ Some(0..0)
+ }
}
fn replace_text_in_range(
@@ -35,6 +35,18 @@ impl Modifiers {
}
}
+///This function checks if to_esc_str would work, assuming all terminal settings are off.
+///Note that this function is conservative. It can fail in cases where the actual to_esc_str succeeds.
+///This is unavoidable for our use case. GPUI cannot wait until we acquire the terminal
+///lock to determine whether we could actually send the keystroke with the current settings. Therefore,
+///This conservative guess is used instead. Note that in practice the case where this method
+///Returns false when the actual terminal would consume the keystroke never happens. All keystrokes
+///that depend on terminal modes also have a mapping that doesn't depend on the terminal mode.
+///This is fragile, but as these mappings are locked up in legacy compatibility, it's probably good enough
+pub fn might_convert(keystroke: &Keystroke) -> bool {
+ to_esc_str(keystroke, &TermMode::NONE).is_some()
+}
+
pub fn to_esc_str(keystroke: &Keystroke, mode: &TermMode) -> Option<String> {
let modifiers = Modifiers::new(&keystroke);
@@ -3,7 +3,8 @@ use settings::{Settings, WorkingDirectory};
use workspace::Workspace;
use crate::{
- get_working_directory, model::Terminal, DeployModal, Event, TerminalContent, TerminalView,
+ terminal_view::{get_working_directory, DeployModal, TerminalContent, TerminalView},
+ Event, Terminal,
};
#[derive(Debug)]
@@ -1,522 +0,0 @@
-use alacritty_terminal::{
- ansi::{ClearMode, Handler},
- config::{Config, Program, PtyConfig},
- event::{Event as AlacTermEvent, EventListener, Notify, WindowSize},
- event_loop::{EventLoop, Msg, Notifier},
- grid::Scroll,
- index::{Direction, Point},
- selection::{Selection, SelectionType},
- sync::FairMutex,
- term::{test::TermSize, RenderableContent, TermMode},
- tty::{self, setup_env},
- Term,
-};
-use anyhow::{bail, Result};
-use futures::{
- channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender},
- StreamExt,
-};
-use settings::{Settings, Shell};
-use std::{collections::HashMap, fmt::Display, path::PathBuf, sync::Arc};
-use thiserror::Error;
-
-use gpui::{keymap::Keystroke, ClipboardItem, CursorStyle, Entity, ModelContext};
-
-use crate::{
- connected_el::TermDimensions,
- mappings::{
- colors::{get_color_at_index, to_alac_rgb},
- keys::to_esc_str,
- },
-};
-
-const DEFAULT_TITLE: &str = "Terminal";
-
-///Upward flowing events, for changing the title and such
-#[derive(Copy, Clone, Debug)]
-pub enum Event {
- TitleChanged,
- CloseTerminal,
- Activate,
- Wakeup,
- Bell,
- KeyInput,
-}
-
-///A translation struct for Alacritty to communicate with us from their event loop
-#[derive(Clone)]
-pub struct ZedListener(UnboundedSender<AlacTermEvent>);
-
-impl EventListener for ZedListener {
- fn send_event(&self, event: AlacTermEvent) {
- self.0.unbounded_send(event).ok();
- }
-}
-
-#[derive(Error, Debug)]
-pub struct TerminalError {
- pub directory: Option<PathBuf>,
- pub shell: Option<Shell>,
- pub source: std::io::Error,
-}
-
-impl TerminalError {
- pub fn fmt_directory(&self) -> String {
- self.directory
- .clone()
- .map(|path| {
- match path
- .into_os_string()
- .into_string()
- .map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
- {
- Ok(s) => s,
- Err(s) => s,
- }
- })
- .unwrap_or_else(|| {
- let default_dir =
- dirs::home_dir().map(|buf| buf.into_os_string().to_string_lossy().to_string());
- match default_dir {
- Some(dir) => format!("<none specified, using home directory> {}", dir),
- None => "<none specified, could not find home directory>".to_string(),
- }
- })
- }
-
- pub fn shell_to_string(&self) -> Option<String> {
- self.shell.as_ref().map(|shell| match shell {
- Shell::System => "<system shell>".to_string(),
- Shell::Program(p) => p.to_string(),
- Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
- })
- }
-
- pub fn fmt_shell(&self) -> String {
- self.shell
- .clone()
- .map(|shell| match shell {
- Shell::System => {
- let mut buf = [0; 1024];
- let pw = alacritty_unix::get_pw_entry(&mut buf).ok();
-
- match pw {
- Some(pw) => format!("<system defined shell> {}", pw.shell),
- None => "<could not access the password file>".to_string(),
- }
- }
- Shell::Program(s) => s,
- Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
- })
- .unwrap_or_else(|| {
- let mut buf = [0; 1024];
- let pw = alacritty_unix::get_pw_entry(&mut buf).ok();
- match pw {
- Some(pw) => {
- format!("<none specified, using system defined shell> {}", pw.shell)
- }
- None => "<none specified, could not access the password file> {}".to_string(),
- }
- })
- }
-}
-
-impl Display for TerminalError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- let dir_string: String = self.fmt_directory();
-
- let shell = self.fmt_shell();
-
- write!(
- f,
- "Working directory: {} Shell command: `{}`, IOError: {}",
- dir_string, shell, self.source
- )
- }
-}
-
-pub struct TerminalBuilder {
- terminal: Terminal,
- events_rx: UnboundedReceiver<AlacTermEvent>,
-}
-
-impl TerminalBuilder {
- pub fn new(
- working_directory: Option<PathBuf>,
- shell: Option<Shell>,
- env: Option<HashMap<String, String>>,
- initial_size: TermDimensions,
- ) -> Result<TerminalBuilder> {
- let pty_config = {
- let alac_shell = shell.clone().and_then(|shell| match shell {
- Shell::System => None,
- Shell::Program(program) => Some(Program::Just(program)),
- Shell::WithArguments { program, args } => Some(Program::WithArgs { program, args }),
- });
-
- PtyConfig {
- shell: alac_shell,
- working_directory: working_directory.clone(),
- hold: false,
- }
- };
-
- let mut env = env.unwrap_or_else(|| HashMap::new());
-
- //TODO: Properly set the current locale,
- env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
-
- let config = Config {
- pty_config: pty_config.clone(),
- env,
- ..Default::default()
- };
-
- setup_env(&config);
-
- //Spawn a task so the Alacritty EventLoop can communicate with us in a view context
- let (events_tx, events_rx) = unbounded();
-
- //Set up the terminal...
- let term = Term::new(&config, &initial_size, ZedListener(events_tx.clone()));
- let term = Arc::new(FairMutex::new(term));
-
- //Setup the pty...
- let pty = match tty::new(&pty_config, initial_size.into(), None) {
- Ok(pty) => pty,
- Err(error) => {
- bail!(TerminalError {
- directory: working_directory,
- shell,
- source: error,
- });
- }
- };
-
- let shell_txt = {
- match shell {
- Some(Shell::System) | None => {
- let mut buf = [0; 1024];
- let pw = alacritty_unix::get_pw_entry(&mut buf).unwrap();
- pw.shell.to_string()
- }
- Some(Shell::Program(program)) => program,
- Some(Shell::WithArguments { program, args }) => {
- format!("{} {}", program, args.join(" "))
- }
- }
- };
-
- //And connect them together
- let event_loop = EventLoop::new(
- term.clone(),
- ZedListener(events_tx.clone()),
- pty,
- pty_config.hold,
- false,
- );
-
- //Kick things off
- let pty_tx = event_loop.channel();
- let _io_thread = event_loop.spawn();
-
- let terminal = Terminal {
- pty_tx: Notifier(pty_tx),
- term,
- title: shell_txt.to_string(),
- };
-
- Ok(TerminalBuilder {
- terminal,
- events_rx,
- })
- }
-
- pub fn subscribe(mut self, cx: &mut ModelContext<Terminal>) -> Terminal {
- cx.spawn_weak(|this, mut cx| async move {
- //Listen for terminal events
- while let Some(event) = self.events_rx.next().await {
- match this.upgrade(&cx) {
- Some(this) => {
- this.update(&mut cx, |this, cx| {
- this.process_terminal_event(event, cx);
-
- cx.notify();
- });
- }
- None => break,
- }
- }
- })
- .detach();
-
- self.terminal
- }
-}
-
-pub struct Terminal {
- pty_tx: Notifier,
- term: Arc<FairMutex<Term<ZedListener>>>,
- pub title: String,
-}
-
-impl Terminal {
- ///Takes events from Alacritty and translates them to behavior on this view
- fn process_terminal_event(
- &mut self,
- event: alacritty_terminal::event::Event,
- cx: &mut ModelContext<Terminal>,
- ) {
- match event {
- // TODO: Handle is_self_focused in subscription on terminal view
- AlacTermEvent::Wakeup => {
- cx.emit(Event::Wakeup);
- }
- AlacTermEvent::PtyWrite(out) => self.write_to_pty(out),
- AlacTermEvent::MouseCursorDirty => {
- //Calculate new cursor style.
- //TODO: alacritty/src/input.rs:L922-L939
- //Check on correctly handling mouse events for terminals
- cx.platform().set_cursor_style(CursorStyle::Arrow); //???
- }
- AlacTermEvent::Title(title) => {
- self.title = title;
- cx.emit(Event::TitleChanged);
- }
- AlacTermEvent::ResetTitle => {
- self.title = DEFAULT_TITLE.to_string();
- cx.emit(Event::TitleChanged);
- }
- AlacTermEvent::ClipboardStore(_, data) => {
- cx.write_to_clipboard(ClipboardItem::new(data))
- }
- AlacTermEvent::ClipboardLoad(_, format) => self.write_to_pty(format(
- &cx.read_from_clipboard()
- .map(|ci| ci.text().to_string())
- .unwrap_or("".to_string()),
- )),
- AlacTermEvent::ColorRequest(index, format) => {
- let color = self.term.lock().colors()[index].unwrap_or_else(|| {
- let term_style = &cx.global::<Settings>().theme.terminal;
- to_alac_rgb(get_color_at_index(&index, &term_style.colors))
- });
- self.write_to_pty(format(color))
- }
- AlacTermEvent::CursorBlinkingChange => {
- //TODO: Set a timer to blink the cursor on and off
- }
- AlacTermEvent::Bell => {
- cx.emit(Event::Bell);
- }
- AlacTermEvent::Exit => cx.emit(Event::CloseTerminal),
- AlacTermEvent::TextAreaSizeRequest(_) => println!("Received text area resize request"),
- }
- }
-
- ///Write the Input payload to the tty. This locks the terminal so we can scroll it.
- pub fn write_to_pty(&self, input: String) {
- self.write_bytes_to_pty(input.into_bytes());
- }
-
- ///Write the Input payload to the tty. This locks the terminal so we can scroll it.
- fn write_bytes_to_pty(&self, input: Vec<u8>) {
- self.term.lock().scroll_display(Scroll::Bottom);
- self.pty_tx.notify(input);
- }
-
- ///Resize the terminal and the PTY. This locks the terminal.
- pub fn set_size(&self, new_size: WindowSize) {
- self.pty_tx.0.send(Msg::Resize(new_size)).ok();
-
- let term_size = TermSize::new(new_size.num_cols as usize, new_size.num_lines as usize);
- self.term.lock().resize(term_size);
- }
-
- pub fn clear(&self) {
- self.write_to_pty("\x0c".into());
- self.term.lock().clear_screen(ClearMode::Saved);
- }
-
- pub fn try_keystroke(&self, keystroke: &Keystroke) -> bool {
- let guard = self.term.lock();
- let mode = guard.mode();
- let esc = to_esc_str(keystroke, mode);
- drop(guard);
- if esc.is_some() {
- self.write_to_pty(esc.unwrap());
- true
- } else {
- false
- }
- }
-
- ///Paste text into the terminal
- pub fn paste(&self, text: &str) {
- if self.term.lock().mode().contains(TermMode::BRACKETED_PASTE) {
- self.write_to_pty("\x1b[200~".to_string());
- self.write_to_pty(text.replace('\x1b', "").to_string());
- self.write_to_pty("\x1b[201~".to_string());
- } else {
- self.write_to_pty(text.replace("\r\n", "\r").replace('\n', "\r"));
- }
- }
-
- pub fn copy(&self) -> Option<String> {
- let term = self.term.lock();
- term.selection_to_string()
- }
-
- ///Takes the selection out of the terminal
- pub fn take_selection(&self) -> Option<Selection> {
- self.term.lock().selection.take()
- }
- ///Sets the selection object on the terminal
- pub fn set_selection(&self, sel: Option<Selection>) {
- self.term.lock().selection = sel;
- }
-
- pub fn render_lock<F, T>(&self, new_size: Option<TermDimensions>, f: F) -> T
- where
- F: FnOnce(RenderableContent, char) -> T,
- {
- if let Some(new_size) = new_size {
- self.pty_tx.0.send(Msg::Resize(new_size.into())).ok(); //Give the PTY a chance to react to the new size
- //TODO: Is this bad for performance?
- }
-
- let mut term = self.term.lock(); //Lock
-
- if let Some(new_size) = new_size {
- term.resize(new_size); //Reflow
- }
-
- let content = term.renderable_content();
- let cursor_text = term.grid()[content.cursor.point].c;
-
- f(content, cursor_text)
- }
-
- pub fn get_display_offset(&self) -> usize {
- self.term.lock().renderable_content().display_offset
- }
-
- ///Scroll the terminal
- pub fn scroll(&self, scroll: Scroll) {
- self.term.lock().scroll_display(scroll)
- }
-
- pub fn click(&self, point: Point, side: Direction, clicks: usize) {
- let selection_type = match clicks {
- 0 => return, //This is a release
- 1 => Some(SelectionType::Simple),
- 2 => Some(SelectionType::Semantic),
- 3 => Some(SelectionType::Lines),
- _ => None,
- };
-
- let selection =
- selection_type.map(|selection_type| Selection::new(selection_type, point, side));
-
- self.set_selection(selection);
- }
-
- pub fn drag(&self, point: Point, side: Direction) {
- if let Some(mut selection) = self.take_selection() {
- selection.update(point, side);
- self.set_selection(Some(selection));
- }
- }
-
- pub fn mouse_down(&self, point: Point, side: Direction) {
- self.set_selection(Some(Selection::new(SelectionType::Simple, point, side)));
- }
-}
-
-impl Drop for Terminal {
- fn drop(&mut self) {
- self.pty_tx.0.send(Msg::Shutdown).ok();
- }
-}
-
-impl Entity for Terminal {
- type Event = Event;
-}
-
-//TODO Move this around
-mod alacritty_unix {
- use alacritty_terminal::config::Program;
- use gpui::anyhow::{bail, Result};
- use libc;
- use std::ffi::CStr;
- use std::mem::MaybeUninit;
- use std::ptr;
-
- #[derive(Debug)]
- pub struct Passwd<'a> {
- _name: &'a str,
- _dir: &'a str,
- pub shell: &'a str,
- }
-
- /// Return a Passwd struct with pointers into the provided buf.
- ///
- /// # Unsafety
- ///
- /// If `buf` is changed while `Passwd` is alive, bad thing will almost certainly happen.
- pub fn get_pw_entry(buf: &mut [i8; 1024]) -> Result<Passwd<'_>> {
- // Create zeroed passwd struct.
- let mut entry: MaybeUninit<libc::passwd> = MaybeUninit::uninit();
-
- let mut res: *mut libc::passwd = ptr::null_mut();
-
- // Try and read the pw file.
- let uid = unsafe { libc::getuid() };
- let status = unsafe {
- libc::getpwuid_r(
- uid,
- entry.as_mut_ptr(),
- buf.as_mut_ptr() as *mut _,
- buf.len(),
- &mut res,
- )
- };
- let entry = unsafe { entry.assume_init() };
-
- if status < 0 {
- bail!("getpwuid_r failed");
- }
-
- if res.is_null() {
- bail!("pw not found");
- }
-
- // Sanity check.
- assert_eq!(entry.pw_uid, uid);
-
- // Build a borrowed Passwd struct.
- Ok(Passwd {
- _name: unsafe { CStr::from_ptr(entry.pw_name).to_str().unwrap() },
- _dir: unsafe { CStr::from_ptr(entry.pw_dir).to_str().unwrap() },
- shell: unsafe { CStr::from_ptr(entry.pw_shell).to_str().unwrap() },
- })
- }
-
- #[cfg(target_os = "macos")]
- pub fn _default_shell(pw: &Passwd<'_>) -> Program {
- let shell_name = pw.shell.rsplit('/').next().unwrap();
- let argv = vec![
- String::from("-c"),
- format!("exec -a -{} {}", shell_name, pw.shell),
- ];
-
- Program::WithArgs {
- program: "/bin/bash".to_owned(),
- args: argv,
- }
- }
-
- #[cfg(not(target_os = "macos"))]
- pub fn default_shell(pw: &Passwd<'_>) -> Program {
- Program::Just(env::var("SHELL").unwrap_or_else(|_| pw.shell.to_owned()))
- }
-}
@@ -1,33 +1,45 @@
pub mod connected_el;
pub mod connected_view;
pub mod mappings;
-pub mod modal_view;
-pub mod model;
-
-use connected_view::ConnectedView;
-use dirs::home_dir;
-use gpui::{
- actions, elements::*, geometry::vector::vec2f, AnyViewHandle, AppContext, Entity, ModelHandle,
- MutableAppContext, View, ViewContext, ViewHandle,
+pub mod modal;
+pub mod terminal_view;
+
+use alacritty_terminal::{
+ ansi::{ClearMode, Handler},
+ config::{Config, Program, PtyConfig, Scrolling},
+ event::{Event as AlacTermEvent, EventListener, Notify, WindowSize},
+ event_loop::{EventLoop, Msg, Notifier, READ_BUFFER_SIZE},
+ grid::{Dimensions, Scroll},
+ index::{Direction, Point},
+ selection::{Selection, SelectionType},
+ sync::FairMutex,
+ term::{RenderableContent, TermMode},
+ tty::{self, setup_env},
+ Term,
};
-use modal_view::deploy_modal;
-use model::{Event, Terminal, TerminalBuilder, TerminalError};
+use anyhow::{bail, Result};
-use connected_el::TermDimensions;
-use project::{LocalWorktree, Project, ProjectPath};
-use settings::{Settings, WorkingDirectory};
-use smallvec::SmallVec;
-use std::path::{Path, PathBuf};
-use workspace::{Item, Workspace};
+use futures::{
+ channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender},
+ future,
+};
-use crate::connected_el::TerminalEl;
+use modal::deploy_modal;
+use settings::{Settings, Shell};
+use std::{collections::HashMap, fmt::Display, path::PathBuf, sync::Arc, time::Duration};
+use terminal_view::TerminalView;
+use thiserror::Error;
-const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
-const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
-const DEBUG_CELL_WIDTH: f32 = 5.;
-const DEBUG_LINE_HEIGHT: f32 = 5.;
+use gpui::{
+ geometry::vector::{vec2f, Vector2F},
+ keymap::Keystroke,
+ ClipboardItem, Entity, ModelContext, MutableAppContext,
+};
-actions!(terminal, [Deploy, DeployModal]);
+use crate::mappings::{
+ colors::{get_color_at_index, to_alac_rgb},
+ keys::to_esc_str,
+};
///Initialize and register all of our action handlers
pub fn init(cx: &mut MutableAppContext) {
@@ -37,498 +49,671 @@ pub fn init(cx: &mut MutableAppContext) {
connected_view::init(cx);
}
-//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
+const DEBUG_TERMINAL_WIDTH: f32 = 500.;
+const DEBUG_TERMINAL_HEIGHT: f32 = 30.; //This needs to be wide enough that the CI & a local dev's prompt can fill the whole space.
+const DEBUG_CELL_WIDTH: f32 = 5.;
+const DEBUG_LINE_HEIGHT: f32 = 5.;
+const MAX_FRAME_RATE: f32 = 60.;
+const BACK_BUFFER_SIZE: usize = 5000;
+
+///Upward flowing events, for changing the title and such
+#[derive(Clone, Copy, Debug)]
+pub enum Event {
+ TitleChanged,
+ CloseTerminal,
+ Activate,
+ Bell,
+ Wakeup,
+}
-enum TerminalContent {
- Connected(ViewHandle<ConnectedView>),
- Error(ViewHandle<ErrorView>),
+#[derive(Clone, Debug)]
+enum InternalEvent {
+ TermEvent(AlacTermEvent),
+ Resize(TerminalSize),
+ Clear,
+ Scroll(Scroll),
+ SetSelection(Option<Selection>),
+ UpdateSelection((Point, Direction)),
+ Copy,
}
-impl TerminalContent {
- fn handle(&self) -> AnyViewHandle {
- match self {
- Self::Connected(handle) => handle.into(),
- Self::Error(handle) => handle.into(),
- }
+///A translation struct for Alacritty to communicate with us from their event loop
+#[derive(Clone)]
+pub struct ZedListener(UnboundedSender<AlacTermEvent>);
+
+impl EventListener for ZedListener {
+ fn send_event(&self, event: AlacTermEvent) {
+ self.0.unbounded_send(event).ok();
}
}
-pub struct TerminalView {
- modal: bool,
- content: TerminalContent,
- associated_directory: Option<PathBuf>,
+#[derive(Clone, Copy, Debug)]
+pub struct TerminalSize {
+ cell_width: f32,
+ line_height: f32,
+ height: f32,
+ width: f32,
}
-pub struct ErrorView {
- error: TerminalError,
-}
+impl TerminalSize {
+ pub fn new(line_height: f32, cell_width: f32, size: Vector2F) -> Self {
+ TerminalSize {
+ cell_width,
+ line_height,
+ width: size.x(),
+ height: size.y(),
+ }
+ }
-impl Entity for TerminalView {
- type Event = Event;
-}
+ pub fn num_lines(&self) -> usize {
+ (self.height / self.line_height).floor() as usize
+ }
-impl Entity for ConnectedView {
- type Event = Event;
-}
+ pub fn num_columns(&self) -> usize {
+ (self.width / self.cell_width).floor() as usize
+ }
-impl Entity for ErrorView {
- type Event = Event;
-}
+ pub fn height(&self) -> f32 {
+ self.height
+ }
-impl TerminalView {
- ///Create a new Terminal in the current working directory or the user's home directory
- fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
- let wd_strategy = cx
- .global::<Settings>()
- .terminal_overrides
- .working_directory
- .clone()
- .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
+ pub fn width(&self) -> f32 {
+ self.width
+ }
- let working_directory = get_working_directory(workspace, cx, wd_strategy);
- let view = cx.add_view(|cx| TerminalView::new(working_directory, false, cx));
- workspace.add_item(Box::new(view), cx);
+ pub fn cell_width(&self) -> f32 {
+ self.cell_width
}
- ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
- ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
- fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
- //The details here don't matter, the terminal will be resized on the first layout
- let size_info = TermDimensions::new(
+ pub fn line_height(&self) -> f32 {
+ self.line_height
+ }
+}
+impl Default for TerminalSize {
+ fn default() -> Self {
+ TerminalSize::new(
DEBUG_LINE_HEIGHT,
DEBUG_CELL_WIDTH,
vec2f(DEBUG_TERMINAL_WIDTH, DEBUG_TERMINAL_HEIGHT),
- );
-
- let settings = cx.global::<Settings>();
- let shell = settings.terminal_overrides.shell.clone();
- let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
-
- let content = match TerminalBuilder::new(working_directory.clone(), shell, envs, size_info)
- {
- 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.clone()))
- .detach();
- TerminalContent::Connected(view)
- }
- Err(error) => {
- let view = cx.add_view(|_| ErrorView {
- error: error.downcast::<TerminalError>().unwrap(),
- });
- TerminalContent::Error(view)
- }
- };
- cx.focus(content.handle());
-
- TerminalView {
- modal,
- content,
- associated_directory: working_directory,
- }
+ )
}
+}
- fn from_terminal(
- terminal: ModelHandle<Terminal>,
- modal: bool,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- let connected_view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
- TerminalView {
- modal,
- content: TerminalContent::Connected(connected_view),
- associated_directory: None,
+impl Into<WindowSize> for TerminalSize {
+ fn into(self) -> WindowSize {
+ WindowSize {
+ num_lines: self.num_lines() as u16,
+ num_cols: self.num_columns() as u16,
+ cell_width: self.cell_width() as u16,
+ cell_height: self.line_height() as u16,
}
}
}
-impl View for TerminalView {
- fn ui_name() -> &'static str {
- "Terminal View"
+impl Dimensions for TerminalSize {
+ fn total_lines(&self) -> usize {
+ self.screen_lines() //TODO: Check that this is fine. This is supposed to be for the back buffer...
}
- 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),
- };
+ fn screen_lines(&self) -> usize {
+ self.num_lines()
+ }
- if self.modal {
- let settings = cx.global::<Settings>();
- let container_style = settings.theme.terminal.modal_container;
- child_view.contained().with_style(container_style).boxed()
- } else {
- child_view.boxed()
- }
+ fn columns(&self) -> usize {
+ self.num_columns()
}
+}
- fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
- cx.emit(Event::Activate);
- cx.defer(|view, cx| {
- cx.focus(view.content.handle());
- });
+#[derive(Error, Debug)]
+pub struct TerminalError {
+ pub directory: Option<PathBuf>,
+ pub shell: Option<Shell>,
+ pub source: std::io::Error,
+}
+
+impl TerminalError {
+ pub fn fmt_directory(&self) -> String {
+ self.directory
+ .clone()
+ .map(|path| {
+ match path
+ .into_os_string()
+ .into_string()
+ .map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
+ {
+ Ok(s) => s,
+ Err(s) => s,
+ }
+ })
+ .unwrap_or_else(|| {
+ let default_dir =
+ dirs::home_dir().map(|buf| buf.into_os_string().to_string_lossy().to_string());
+ match default_dir {
+ Some(dir) => format!("<none specified, using home directory> {}", dir),
+ None => "<none specified, could not find home directory>".to_string(),
+ }
+ })
+ }
+
+ pub fn shell_to_string(&self) -> Option<String> {
+ self.shell.as_ref().map(|shell| match shell {
+ Shell::System => "<system shell>".to_string(),
+ Shell::Program(p) => p.to_string(),
+ Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
+ })
+ }
+
+ pub fn fmt_shell(&self) -> String {
+ self.shell
+ .clone()
+ .map(|shell| match shell {
+ Shell::System => {
+ let mut buf = [0; 1024];
+ let pw = alacritty_unix::get_pw_entry(&mut buf).ok();
+
+ match pw {
+ Some(pw) => format!("<system defined shell> {}", pw.shell),
+ None => "<could not access the password file>".to_string(),
+ }
+ }
+ Shell::Program(s) => s,
+ Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
+ })
+ .unwrap_or_else(|| {
+ let mut buf = [0; 1024];
+ let pw = alacritty_unix::get_pw_entry(&mut buf).ok();
+ match pw {
+ Some(pw) => {
+ format!("<none specified, using system defined shell> {}", pw.shell)
+ }
+ None => "<none specified, could not access the password file> {}".to_string(),
+ }
+ })
}
}
-impl View for ErrorView {
- fn ui_name() -> &'static str {
- "DisconnectedTerminal"
+impl Display for TerminalError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let dir_string: String = self.fmt_directory();
+ let shell = self.fmt_shell();
+
+ write!(
+ f,
+ "Working directory: {} Shell command: `{}`, IOError: {}",
+ dir_string, shell, self.source
+ )
}
+}
- fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
- let settings = cx.global::<Settings>();
- let style = TerminalEl::make_text_style(cx.font_cache(), settings);
+pub struct TerminalBuilder {
+ terminal: Terminal,
+ events_rx: UnboundedReceiver<AlacTermEvent>,
+}
- //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)
+impl TerminalBuilder {
+ pub fn new(
+ working_directory: Option<PathBuf>,
+ shell: Option<Shell>,
+ env: Option<HashMap<String, String>>,
+ initial_size: TerminalSize,
+ ) -> Result<TerminalBuilder> {
+ let pty_config = {
+ let alac_shell = shell.clone().and_then(|shell| match shell {
+ Shell::System => None,
+ Shell::Program(program) => Some(Program::Just(program)),
+ Shell::WithArguments { program, args } => Some(Program::WithArgs { program, args }),
+ });
+
+ PtyConfig {
+ shell: alac_shell,
+ working_directory: working_directory.clone(),
+ hold: false,
+ }
+ };
- let program_text = {
- match self.error.shell_to_string() {
- Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
- None => "No program specified".to_string(),
+ let mut env = env.unwrap_or_else(|| HashMap::new());
+
+ //TODO: Properly set the current locale,
+ env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
+
+ let mut alac_scrolling = Scrolling::default();
+ alac_scrolling.set_history((BACK_BUFFER_SIZE * 2) as u32);
+
+ let config = Config {
+ pty_config: pty_config.clone(),
+ env,
+ scrolling: alac_scrolling,
+ ..Default::default()
+ };
+
+ setup_env(&config);
+
+ //Spawn a task so the Alacritty EventLoop can communicate with us in a view context
+ let (events_tx, events_rx) = unbounded();
+ //Set up the terminal...
+ let term = Term::new(&config, &initial_size, ZedListener(events_tx.clone()));
+ let term = Arc::new(FairMutex::new(term));
+
+ //Setup the pty...
+ let pty = match tty::new(&pty_config, initial_size.clone().into(), None) {
+ Ok(pty) => pty,
+ Err(error) => {
+ bail!(TerminalError {
+ directory: working_directory,
+ shell,
+ source: error,
+ });
}
};
- 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 shell_txt = {
+ match shell {
+ Some(Shell::System) | None => {
+ let mut buf = [0; 1024];
+ let pw = alacritty_unix::get_pw_entry(&mut buf).unwrap();
+ pw.shell.to_string()
+ }
+ Some(Shell::Program(program)) => program,
+ Some(Shell::WithArguments { program, args }) => {
+ format!("{} {}", program, args.join(" "))
+ }
}
};
- let error_text = self.error.source.to_string();
+ //And connect them together
+ let event_loop = EventLoop::new(
+ term.clone(),
+ ZedListener(events_tx.clone()),
+ pty,
+ pty_config.hold,
+ false,
+ );
- 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.clone()).contained().boxed())
- .aligned()
- .boxed()
+ //Kick things off
+ let pty_tx = event_loop.channel();
+ let _io_thread = event_loop.spawn();
+
+ let terminal = Terminal {
+ pty_tx: Notifier(pty_tx),
+ term,
+ events: vec![],
+ title: shell_txt.clone(),
+ default_title: shell_txt,
+ last_mode: TermMode::NONE,
+ cur_size: initial_size,
+ utilization: 0.,
+ };
+
+ Ok(TerminalBuilder {
+ terminal,
+ events_rx,
+ })
}
-}
-impl Item for TerminalView {
- fn tab_content(
- &self,
- _detail: Option<usize>,
- tab_theme: &theme::Tab,
- cx: &gpui::AppContext,
- ) -> ElementBox {
- let title = match &self.content {
- TerminalContent::Connected(connected) => {
- connected.read(cx).handle().read(cx).title.clone()
+ pub fn subscribe(self, cx: &mut ModelContext<Terminal>) -> Terminal {
+ //Event loop
+ cx.spawn_weak(|this, mut cx| async move {
+ use futures::StreamExt;
+
+ self.events_rx
+ .for_each(|event| {
+ match this.upgrade(&cx) {
+ Some(this) => {
+ this.update(&mut cx, |this, cx| {
+ this.process_event(&event, cx);
+ });
+ }
+ None => {}
+ }
+
+ future::ready(())
+ })
+ .await;
+ })
+ .detach();
+
+ //Render loop
+ cx.spawn_weak(|this, mut cx| async move {
+ loop {
+ let utilization = match this.upgrade(&cx) {
+ Some(this) => this.update(&mut cx, |this, cx| {
+ cx.notify();
+ this.utilization()
+ }),
+ None => break,
+ };
+
+ let utilization = (1. - utilization).clamp(0.1, 1.);
+ let delay = cx.background().timer(Duration::from_secs_f32(
+ 1.0 / (Terminal::default_fps() * utilization),
+ ));
+
+ delay.await;
}
- TerminalContent::Error(_) => "Terminal".to_string(),
- };
+ })
+ .detach();
- Flex::row()
- .with_child(
- Label::new(title, tab_theme.label.clone())
- .aligned()
- .contained()
- .boxed(),
- )
- .boxed()
+ self.terminal
}
+}
- fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
- //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,
- ))
- }
+pub struct Terminal {
+ pty_tx: Notifier,
+ term: Arc<FairMutex<Term<ZedListener>>>,
+ events: Vec<InternalEvent>,
+ default_title: String,
+ title: String,
+ cur_size: TerminalSize,
+ last_mode: TermMode,
+ //Percentage, between 0 and 1
+ utilization: f32,
+}
- fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
- None
+impl Terminal {
+ fn default_fps() -> f32 {
+ MAX_FRAME_RATE
}
- fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
- SmallVec::new()
+ fn utilization(&self) -> f32 {
+ self.utilization
}
- fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
- false
+ fn process_event(&mut self, event: &AlacTermEvent, cx: &mut ModelContext<Self>) {
+ match event {
+ AlacTermEvent::Title(title) => {
+ self.title = title.to_string();
+ cx.emit(Event::TitleChanged);
+ }
+ AlacTermEvent::ResetTitle => {
+ self.title = self.default_title.clone();
+ cx.emit(Event::TitleChanged);
+ }
+ AlacTermEvent::ClipboardStore(_, data) => {
+ cx.write_to_clipboard(ClipboardItem::new(data.to_string()))
+ }
+ AlacTermEvent::ClipboardLoad(_, format) => self.notify_pty(format(
+ &cx.read_from_clipboard()
+ .map(|ci| ci.text().to_string())
+ .unwrap_or("".to_string()),
+ )),
+ AlacTermEvent::PtyWrite(out) => self.notify_pty(out.clone()),
+ AlacTermEvent::TextAreaSizeRequest(format) => {
+ self.notify_pty(format(self.cur_size.clone().into()))
+ }
+ AlacTermEvent::CursorBlinkingChange => {
+ //TODO whatever state we need to set to get the cursor blinking
+ }
+ AlacTermEvent::Bell => {
+ cx.emit(Event::Bell);
+ }
+ AlacTermEvent::Exit => cx.emit(Event::CloseTerminal),
+ AlacTermEvent::MouseCursorDirty => {
+ //NOOP, Handled in render
+ }
+ AlacTermEvent::Wakeup => {
+ cx.emit(Event::Wakeup);
+ }
+ AlacTermEvent::ColorRequest(_, _) => {
+ self.events.push(InternalEvent::TermEvent(event.clone()))
+ }
+ }
}
- fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
+ // fn process_events(&mut self, events: Vec<AlacTermEvent>, cx: &mut ModelContext<Self>) {
+ // for event in events.into_iter() {
+ // self.process_event(&event, cx);
+ // }
+ // }
- fn can_save(&self, _cx: &gpui::AppContext) -> bool {
- false
+ ///Takes events from Alacritty and translates them to behavior on this view
+ fn process_terminal_event(
+ &mut self,
+ event: &InternalEvent,
+ term: &mut Term<ZedListener>,
+ cx: &mut ModelContext<Self>,
+ ) {
+ // TODO: Handle is_self_focused in subscription on terminal view
+ match event {
+ InternalEvent::TermEvent(term_event) => match term_event {
+ //Needs to lock
+ AlacTermEvent::ColorRequest(index, format) => {
+ let color = term.colors()[*index].unwrap_or_else(|| {
+ let term_style = &cx.global::<Settings>().theme.terminal;
+ to_alac_rgb(get_color_at_index(index, &term_style.colors))
+ });
+ self.notify_pty(format(color))
+ }
+ _ => {} //Other events are handled in the event loop
+ },
+ InternalEvent::Resize(new_size) => {
+ self.cur_size = new_size.clone();
+
+ self.pty_tx
+ .0
+ .send(Msg::Resize(new_size.clone().into()))
+ .ok();
+
+ term.resize(*new_size);
+ }
+ InternalEvent::Clear => {
+ self.notify_pty("\x0c".to_string());
+ term.clear_screen(ClearMode::Saved);
+ }
+ InternalEvent::Scroll(scroll) => term.scroll_display(*scroll),
+ InternalEvent::SetSelection(sel) => term.selection = sel.clone(),
+ InternalEvent::UpdateSelection((point, side)) => {
+ if let Some(mut selection) = term.selection.take() {
+ selection.update(*point, *side);
+ term.selection = Some(selection);
+ }
+ }
+
+ InternalEvent::Copy => {
+ if let Some(txt) = term.selection_to_string() {
+ cx.write_to_clipboard(ClipboardItem::new(txt))
+ }
+ }
+ }
}
- fn save(
- &mut self,
- _project: gpui::ModelHandle<Project>,
- _cx: &mut ViewContext<Self>,
- ) -> gpui::Task<gpui::anyhow::Result<()>> {
- unreachable!("save should not have been called");
+ pub fn notify_pty(&self, txt: String) {
+ self.pty_tx.notify(txt.into_bytes());
}
- fn save_as(
- &mut self,
- _project: gpui::ModelHandle<Project>,
- _abs_path: std::path::PathBuf,
- _cx: &mut ViewContext<Self>,
- ) -> gpui::Task<gpui::anyhow::Result<()>> {
- unreachable!("save_as should not have been called");
+ ///Write the Input payload to the tty.
+ pub fn write_to_pty(&mut self, input: String) {
+ self.pty_tx.notify(input.into_bytes());
}
- fn reload(
- &mut self,
- _project: gpui::ModelHandle<Project>,
- _cx: &mut ViewContext<Self>,
- ) -> gpui::Task<gpui::anyhow::Result<()>> {
- gpui::Task::ready(Ok(()))
+ ///Resize the terminal and the PTY.
+ pub fn set_size(&mut self, new_size: TerminalSize) {
+ self.events.push(InternalEvent::Resize(new_size.into()))
+ }
+
+ pub fn clear(&mut self) {
+ self.events.push(InternalEvent::Clear)
}
- fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
- if let TerminalContent::Connected(connected) = &self.content {
- connected.read(cx).has_new_content()
+ pub fn try_keystroke(&self, keystroke: &Keystroke) -> bool {
+ let esc = to_esc_str(keystroke, &self.last_mode);
+ if let Some(esc) = esc {
+ self.notify_pty(esc);
+ true
} else {
false
}
}
- fn has_conflict(&self, cx: &AppContext) -> bool {
- if let TerminalContent::Connected(connected) = &self.content {
- connected.read(cx).has_bell()
+ ///Paste text into the terminal
+ pub fn paste(&self, text: &str) {
+ if self.last_mode.contains(TermMode::BRACKETED_PASTE) {
+ self.notify_pty("\x1b[200~".to_string());
+ self.notify_pty(text.replace('\x1b', "").to_string());
+ self.notify_pty("\x1b[201~".to_string());
} else {
- false
+ self.notify_pty(text.replace("\r\n", "\r").replace('\n', "\r"));
}
}
- fn should_update_tab_on_event(event: &Self::Event) -> bool {
- matches!(event, &Event::TitleChanged)
+ pub fn copy(&mut self) {
+ self.events.push(InternalEvent::Copy);
}
- fn should_close_item_on_event(event: &Self::Event) -> bool {
- matches!(event, &Event::CloseTerminal)
+ pub fn render_lock<F, T>(&mut self, cx: &mut ModelContext<Self>, f: F) -> T
+ where
+ F: FnOnce(RenderableContent, char) -> T,
+ {
+ let m = self.term.clone(); //Arc clone
+ let mut term = m.lock();
+
+ while let Some(e) = self.events.pop() {
+ self.process_terminal_event(&e, &mut term, cx)
+ }
+
+ self.utilization = Self::estimate_utilization(term.take_last_processed_bytes());
+
+ self.last_mode = term.mode().clone();
+
+ let content = term.renderable_content();
+
+ let cursor_text = term.grid()[content.cursor.point].c;
+
+ f(content, cursor_text)
}
- fn should_activate_item_on_event(event: &Self::Event) -> bool {
- matches!(event, &Event::Activate)
+ fn estimate_utilization(last_processed: usize) -> f32 {
+ let buffer_utilization = (last_processed as f32 / (READ_BUFFER_SIZE as f32)).clamp(0., 1.);
+
+ //Scale result to bias low, then high
+ buffer_utilization * buffer_utilization
}
-}
-///Get's the working directory for the given workspace, respecting the user's settings.
-fn get_working_directory(
- workspace: &Workspace,
- cx: &AppContext,
- strategy: WorkingDirectory,
-) -> Option<PathBuf> {
- 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())
-}
+ ///Scroll the terminal
+ pub fn scroll(&mut self, scroll: Scroll) {
+ self.events.push(InternalEvent::Scroll(scroll));
+ }
-///Get's the first project's home directory, or the home directory
-fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
- workspace
- .worktrees(cx)
- .next()
- .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
- .and_then(get_path_from_wt)
+ pub fn click(&mut self, point: Point, side: Direction, clicks: usize) {
+ let selection_type = match clicks {
+ 0 => return, //This is a release
+ 1 => Some(SelectionType::Simple),
+ 2 => Some(SelectionType::Semantic),
+ 3 => Some(SelectionType::Lines),
+ _ => None,
+ };
+
+ let selection =
+ selection_type.map(|selection_type| Selection::new(selection_type, point, side));
+
+ self.events.push(InternalEvent::SetSelection(selection));
+ }
+
+ pub fn drag(&mut self, point: Point, side: Direction) {
+ self.events
+ .push(InternalEvent::UpdateSelection((point, side)));
+ }
+
+ ///TODO: Check if the mouse_down-then-click assumption holds, so this code works as expected
+ pub fn mouse_down(&mut self, point: Point, side: Direction) {
+ self.events
+ .push(InternalEvent::SetSelection(Some(Selection::new(
+ SelectionType::Simple,
+ point,
+ side,
+ ))));
+ }
}
-///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<PathBuf> {
- 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)
+impl Drop for Terminal {
+ fn drop(&mut self) {
+ self.pty_tx.0.send(Msg::Shutdown).ok();
+ }
}
-fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
- wt.root_entry()
- .filter(|re| re.is_dir())
- .map(|_| wt.abs_path().to_path_buf())
+impl Entity for Terminal {
+ type Event = Event;
}
#[cfg(test)]
mod tests {
+ pub mod terminal_test_context;
+}
+
+//TODO Move this around and clean up the code
+mod alacritty_unix {
+ use alacritty_terminal::config::Program;
+ use gpui::anyhow::{bail, Result};
+ use libc;
+ use std::ffi::CStr;
+ use std::mem::MaybeUninit;
+ use std::ptr;
+
+ #[derive(Debug)]
+ pub struct Passwd<'a> {
+ _name: &'a str,
+ _dir: &'a str,
+ pub shell: &'a str,
+ }
- use crate::tests::terminal_test_context::TerminalTestContext;
-
- use super::*;
- use gpui::TestAppContext;
+ /// Return a Passwd struct with pointers into the provided buf.
+ ///
+ /// # Unsafety
+ ///
+ /// If `buf` is changed while `Passwd` is alive, bad thing will almost certainly happen.
+ pub fn get_pw_entry(buf: &mut [i8; 1024]) -> Result<Passwd<'_>> {
+ // Create zeroed passwd struct.
+ let mut entry: MaybeUninit<libc::passwd> = MaybeUninit::uninit();
+
+ let mut res: *mut libc::passwd = ptr::null_mut();
+
+ // Try and read the pw file.
+ let uid = unsafe { libc::getuid() };
+ let status = unsafe {
+ libc::getpwuid_r(
+ uid,
+ entry.as_mut_ptr(),
+ buf.as_mut_ptr() as *mut _,
+ buf.len(),
+ &mut res,
+ )
+ };
+ let entry = unsafe { entry.assume_init() };
+
+ if status < 0 {
+ bail!("getpwuid_r failed");
+ }
+
+ if res.is_null() {
+ bail!("pw not found");
+ }
+
+ // Sanity check.
+ assert_eq!(entry.pw_uid, uid);
+
+ // Build a borrowed Passwd struct.
+ Ok(Passwd {
+ _name: unsafe { CStr::from_ptr(entry.pw_name).to_str().unwrap() },
+ _dir: unsafe { CStr::from_ptr(entry.pw_dir).to_str().unwrap() },
+ shell: unsafe { CStr::from_ptr(entry.pw_shell).to_str().unwrap() },
+ })
+ }
+
+ #[cfg(target_os = "macos")]
+ pub fn _default_shell(pw: &Passwd<'_>) -> Program {
+ let shell_name = pw.shell.rsplit('/').next().unwrap();
+ let argv = vec![
+ String::from("-c"),
+ format!("exec -a -{} {}", shell_name, pw.shell),
+ ];
+
+ Program::WithArgs {
+ program: "/bin/bash".to_owned(),
+ args: argv,
+ }
+ }
- use std::path::Path;
-
- mod terminal_test_context;
-
- ///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()));
- });
- }
-
- //Active entry with a work tree, worktree is a file, integration test with the strategy interface
- #[gpui::test]
- async fn active_entry_worktree_is_file_int(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 =
- get_working_directory(workspace, cx, WorkingDirectory::CurrentProjectDirectory);
- let first = first_project_directory(workspace, cx);
- assert_eq!(res, first);
- });
+ #[cfg(not(target_os = "macos"))]
+ pub fn default_shell(pw: &Passwd<'_>) -> Program {
+ Program::Just(env::var("SHELL").unwrap_or_else(|_| pw.shell.to_owned()))
}
}
@@ -0,0 +1,495 @@
+use crate::connected_view::ConnectedView;
+use crate::{Event, Terminal, TerminalBuilder, TerminalError};
+use dirs::home_dir;
+use gpui::{
+ actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, View, ViewContext,
+ ViewHandle,
+};
+
+use crate::TerminalSize;
+use project::{LocalWorktree, Project, ProjectPath};
+use settings::{Settings, WorkingDirectory};
+use smallvec::SmallVec;
+use std::path::{Path, PathBuf};
+use workspace::{Item, Workspace};
+
+use crate::connected_el::TerminalEl;
+
+actions!(terminal, [Deploy, DeployModal]);
+
+//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<ConnectedView>),
+ Error(ViewHandle<ErrorView>),
+}
+
+impl TerminalContent {
+ fn handle(&self) -> AnyViewHandle {
+ match self {
+ Self::Connected(handle) => handle.into(),
+ Self::Error(handle) => handle.into(),
+ }
+ }
+}
+
+pub struct TerminalView {
+ modal: bool,
+ pub content: TerminalContent,
+ associated_directory: Option<PathBuf>,
+}
+
+pub struct ErrorView {
+ error: TerminalError,
+}
+
+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, _: &Deploy, cx: &mut ViewContext<Workspace>) {
+ let strategy = cx
+ .global::<Settings>()
+ .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<PathBuf>,
+ modal: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> 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::<Settings>();
+ let shell = settings.terminal_overrides.shell.clone();
+ let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
+
+ let content = match TerminalBuilder::new(working_directory.clone(), shell, envs, size_info)
+ {
+ 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.clone()))
+ .detach();
+ TerminalContent::Connected(view)
+ }
+ Err(error) => {
+ let view = cx.add_view(|_| ErrorView {
+ error: error.downcast::<TerminalError>().unwrap(),
+ });
+ TerminalContent::Error(view)
+ }
+ };
+ cx.focus(content.handle());
+
+ TerminalView {
+ modal,
+ content,
+ associated_directory: working_directory,
+ }
+ }
+
+ pub fn from_terminal(
+ terminal: ModelHandle<Terminal>,
+ modal: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ let connected_view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
+ TerminalView {
+ modal,
+ content: TerminalContent::Connected(connected_view),
+ associated_directory: None,
+ }
+ }
+}
+
+impl View for TerminalView {
+ 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::<Settings>();
+ let container_style = settings.theme.terminal.modal_container;
+ child_view.contained().with_style(container_style).boxed()
+ } else {
+ child_view.boxed()
+ }
+ }
+
+ fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+ cx.emit(Event::Activate);
+ cx.defer(|view, cx| {
+ cx.focus(view.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::<Settings>();
+ 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.clone()).contained().boxed())
+ .aligned()
+ .boxed()
+ }
+}
+
+impl Item for TerminalView {
+ fn tab_content(
+ &self,
+ _detail: Option<usize>,
+ 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<Self>) -> Option<Self> {
+ //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<ProjectPath> {
+ 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<Self>) {}
+
+ fn can_save(&self, _cx: &gpui::AppContext) -> bool {
+ false
+ }
+
+ fn save(
+ &mut self,
+ _project: gpui::ModelHandle<Project>,
+ _cx: &mut ViewContext<Self>,
+ ) -> gpui::Task<gpui::anyhow::Result<()>> {
+ unreachable!("save should not have been called");
+ }
+
+ fn save_as(
+ &mut self,
+ _project: gpui::ModelHandle<Project>,
+ _abs_path: std::path::PathBuf,
+ _cx: &mut ViewContext<Self>,
+ ) -> gpui::Task<gpui::anyhow::Result<()>> {
+ unreachable!("save_as should not have been called");
+ }
+
+ fn reload(
+ &mut self,
+ _project: gpui::ModelHandle<Project>,
+ _cx: &mut ViewContext<Self>,
+ ) -> gpui::Task<gpui::anyhow::Result<()>> {
+ 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)
+ }
+
+ fn should_activate_item_on_event(event: &Self::Event) -> bool {
+ matches!(event, &Event::Activate)
+ }
+}
+
+///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<PathBuf> {
+ 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<PathBuf> {
+ 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<PathBuf> {
+ 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<PathBuf> {
+ 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()));
+ });
+ }
+}
@@ -1,6 +1,8 @@
+use std::{path::Path, time::Duration};
+
use gpui::{ModelHandle, TestAppContext, ViewHandle};
+
use project::{Entry, Project, ProjectPath, Worktree};
-use std::{path::Path, time::Duration};
use workspace::{AppState, Workspace};
pub struct TerminalTestContext<'a> {
@@ -10,6 +12,7 @@ pub struct TerminalTestContext<'a> {
impl<'a> TerminalTestContext<'a> {
pub fn new(cx: &'a mut TestAppContext) -> Self {
cx.set_condition_duration(Some(Duration::from_secs(5)));
+
TerminalTestContext { cx }
}
@@ -5,7 +5,6 @@
"requires": true,
"packages": {
"": {
- "name": "styles",
"version": "1.0.0",
"license": "ISC",
"dependencies": {