Closes https://github.com/zed-industries/zed/issues/38690 Closes #37353 ### Background On Windows, paths are normally separated by `\`, unlike mac and linux where they are separated by `/`. When editing code in a project that uses a different path style than your local system (e.g. remoting from Windows to Linux, using WSL, and collaboration between windows and unix users), the correct separator for a path may differ from the "native" separator. Previously, to work around this, Zed converted paths' separators in numerous places. This was applied to both absolute and relative paths, leading to incorrect conversions in some cases. ### Solution Many code paths in Zed use paths that are *relative* to either a worktree root or a git repository. This PR introduces a dedicated type for these paths called `RelPath`, which stores the path in the same way regardless of host platform, and offers `Path`-like manipulation APIs. RelPath supports *displaying* the path using either separator, so that we can display paths in a style that is determined at runtime based on the current project. The representation of absolute paths is left untouched, for now. Absolute paths are different from relative paths because (except in contexts where we know that the path refers to the local filesystem) they should generally be treated as opaque strings. Currently we use a mix of types for these paths (std::path::Path, String, SanitizedPath). Release Notes: - N/A --------- Co-authored-by: Cole Miller <cole@zed.dev> Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: Peter Tripp <petertripp@gmail.com> Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com> Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
1765 lines
60 KiB
Rust
1765 lines
60 KiB
Rust
mod persistence;
|
|
pub mod terminal_element;
|
|
pub mod terminal_panel;
|
|
mod terminal_path_like_target;
|
|
pub mod terminal_scrollbar;
|
|
mod terminal_slash_command;
|
|
pub mod terminal_tab_tooltip;
|
|
|
|
use assistant_slash_command::SlashCommandRegistry;
|
|
use editor::{EditorSettings, actions::SelectAll};
|
|
use gpui::{
|
|
Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
|
KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
|
|
ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
|
|
};
|
|
use persistence::TERMINAL_DB;
|
|
use project::{Project, search::SearchQuery};
|
|
use schemars::JsonSchema;
|
|
use task::TaskId;
|
|
use terminal::{
|
|
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
|
|
ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
|
|
TaskStatus, Terminal, TerminalBounds, ToggleViMode,
|
|
alacritty_terminal::{
|
|
index::Point,
|
|
term::{TermMode, point_to_viewport, search::RegexSearch},
|
|
},
|
|
terminal_settings::{CursorShape, TerminalSettings},
|
|
};
|
|
use terminal_element::TerminalElement;
|
|
use terminal_panel::TerminalPanel;
|
|
use terminal_path_like_target::{hover_path_like_target, open_path_like_target};
|
|
use terminal_scrollbar::TerminalScrollHandle;
|
|
use terminal_slash_command::TerminalSlashCommand;
|
|
use terminal_tab_tooltip::TerminalTooltip;
|
|
use ui::{
|
|
ContextMenu, Icon, IconName, Label, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, h_flex,
|
|
prelude::*,
|
|
scrollbars::{self, GlobalSetting, ScrollbarVisibility},
|
|
};
|
|
use util::ResultExt;
|
|
use workspace::{
|
|
CloseActiveItem, NewCenterTerminal, NewTerminal, ToolbarItemLocation, Workspace, WorkspaceId,
|
|
delete_unloaded_items,
|
|
item::{
|
|
BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
|
|
},
|
|
register_serializable_item,
|
|
searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
|
|
};
|
|
|
|
use serde::Deserialize;
|
|
use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
|
|
use smol::Timer;
|
|
use zed_actions::assistant::InlineAssist;
|
|
|
|
use std::{
|
|
cmp,
|
|
ops::{Range, RangeInclusive},
|
|
path::{Path, PathBuf},
|
|
rc::Rc,
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
struct ImeState {
|
|
marked_text: String,
|
|
marked_range_utf16: Option<Range<usize>>,
|
|
}
|
|
|
|
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);
|
|
|
|
/// Sends the specified text directly to the terminal.
|
|
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
|
|
#[action(namespace = terminal)]
|
|
pub struct SendText(String);
|
|
|
|
/// Sends a keystroke sequence to the terminal.
|
|
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
|
|
#[action(namespace = terminal)]
|
|
pub struct SendKeystroke(String);
|
|
|
|
actions!(
|
|
terminal,
|
|
[
|
|
/// Reruns the last executed task in the terminal.
|
|
RerunTask
|
|
]
|
|
);
|
|
|
|
pub fn init(cx: &mut App) {
|
|
assistant_slash_command::init(cx);
|
|
terminal_panel::init(cx);
|
|
terminal::init(cx);
|
|
|
|
register_serializable_item::<TerminalView>(cx);
|
|
|
|
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
|
|
workspace.register_action(TerminalView::deploy);
|
|
})
|
|
.detach();
|
|
SlashCommandRegistry::global(cx).register_command(TerminalSlashCommand, true);
|
|
}
|
|
|
|
pub struct BlockProperties {
|
|
pub height: u8,
|
|
pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
|
|
}
|
|
|
|
pub struct BlockContext<'a, 'b> {
|
|
pub window: &'a mut Window,
|
|
pub context: &'b mut App,
|
|
pub dimensions: TerminalBounds,
|
|
}
|
|
|
|
///A terminal view, maintains the PTY's file handles and communicates with the terminal
|
|
pub struct TerminalView {
|
|
terminal: Entity<Terminal>,
|
|
workspace: WeakEntity<Workspace>,
|
|
project: WeakEntity<Project>,
|
|
focus_handle: FocusHandle,
|
|
//Currently using iTerm bell, show bell emoji in tab until input is received
|
|
has_bell: bool,
|
|
context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
|
|
cursor_shape: CursorShape,
|
|
blink_state: bool,
|
|
mode: TerminalMode,
|
|
blinking_terminal_enabled: bool,
|
|
cwd_serialized: bool,
|
|
blinking_paused: bool,
|
|
blink_epoch: usize,
|
|
hover: Option<HoverTarget>,
|
|
hover_tooltip_update: Task<()>,
|
|
workspace_id: Option<WorkspaceId>,
|
|
show_breadcrumbs: bool,
|
|
block_below_cursor: Option<Rc<BlockProperties>>,
|
|
scroll_top: Pixels,
|
|
scroll_handle: TerminalScrollHandle,
|
|
ime_state: Option<ImeState>,
|
|
_subscriptions: Vec<Subscription>,
|
|
_terminal_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
pub enum TerminalMode {
|
|
#[default]
|
|
Standalone,
|
|
Embedded {
|
|
max_lines_when_unfocused: Option<usize>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub enum ContentMode {
|
|
Scrollable,
|
|
Inline {
|
|
displayed_lines: usize,
|
|
total_lines: usize,
|
|
},
|
|
}
|
|
|
|
impl ContentMode {
|
|
pub fn is_limited(&self) -> bool {
|
|
match self {
|
|
ContentMode::Scrollable => false,
|
|
ContentMode::Inline {
|
|
displayed_lines,
|
|
total_lines,
|
|
} => displayed_lines < total_lines,
|
|
}
|
|
}
|
|
|
|
pub fn is_scrollable(&self) -> bool {
|
|
matches!(self, ContentMode::Scrollable)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
#[cfg_attr(test, derive(Clone, Eq, PartialEq))]
|
|
struct HoverTarget {
|
|
tooltip: String,
|
|
hovered_word: HoveredWord,
|
|
}
|
|
|
|
impl EventEmitter<Event> for TerminalView {}
|
|
impl EventEmitter<ItemEvent> for TerminalView {}
|
|
impl EventEmitter<SearchEvent> for TerminalView {}
|
|
|
|
impl Focusable for TerminalView {
|
|
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl TerminalView {
|
|
///Create a new Terminal in the current working directory or the user's home directory
|
|
pub fn deploy(
|
|
workspace: &mut Workspace,
|
|
_: &NewCenterTerminal,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
let working_directory = default_working_directory(workspace, cx);
|
|
TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| {
|
|
project.create_terminal_shell(working_directory, cx)
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
pub fn new(
|
|
terminal: Entity<Terminal>,
|
|
workspace: WeakEntity<Workspace>,
|
|
workspace_id: Option<WorkspaceId>,
|
|
project: WeakEntity<Project>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let workspace_handle = workspace.clone();
|
|
let terminal_subscriptions =
|
|
subscribe_for_terminal_events(&terminal, workspace, window, cx);
|
|
|
|
let focus_handle = cx.focus_handle();
|
|
let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| {
|
|
terminal_view.focus_in(window, cx);
|
|
});
|
|
let focus_out = cx.on_focus_out(
|
|
&focus_handle,
|
|
window,
|
|
|terminal_view, _event, window, cx| {
|
|
terminal_view.focus_out(window, cx);
|
|
},
|
|
);
|
|
let cursor_shape = TerminalSettings::get_global(cx)
|
|
.cursor_shape
|
|
.unwrap_or_default();
|
|
|
|
let scroll_handle = TerminalScrollHandle::new(terminal.read(cx));
|
|
|
|
Self {
|
|
terminal,
|
|
workspace: workspace_handle,
|
|
project,
|
|
has_bell: false,
|
|
focus_handle,
|
|
context_menu: None,
|
|
cursor_shape,
|
|
blink_state: true,
|
|
blinking_terminal_enabled: false,
|
|
blinking_paused: false,
|
|
blink_epoch: 0,
|
|
hover: None,
|
|
hover_tooltip_update: Task::ready(()),
|
|
mode: TerminalMode::Standalone,
|
|
workspace_id,
|
|
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
|
|
block_below_cursor: None,
|
|
scroll_top: Pixels::ZERO,
|
|
scroll_handle,
|
|
cwd_serialized: false,
|
|
ime_state: None,
|
|
_subscriptions: vec![
|
|
focus_in,
|
|
focus_out,
|
|
cx.observe_global::<SettingsStore>(Self::settings_changed),
|
|
],
|
|
_terminal_subscriptions: terminal_subscriptions,
|
|
}
|
|
}
|
|
|
|
/// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
|
|
pub fn set_embedded_mode(
|
|
&mut self,
|
|
max_lines_when_unfocused: Option<usize>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.mode = TerminalMode::Embedded {
|
|
max_lines_when_unfocused,
|
|
};
|
|
cx.notify();
|
|
}
|
|
|
|
const MAX_EMBEDDED_LINES: usize = 1_000;
|
|
|
|
/// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines
|
|
///
|
|
/// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES`
|
|
pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode {
|
|
match &self.mode {
|
|
TerminalMode::Standalone => ContentMode::Scrollable,
|
|
TerminalMode::Embedded {
|
|
max_lines_when_unfocused,
|
|
} => {
|
|
let total_lines = self.terminal.read(cx).total_lines();
|
|
|
|
if total_lines > Self::MAX_EMBEDDED_LINES {
|
|
ContentMode::Scrollable
|
|
} else {
|
|
let mut displayed_lines = total_lines;
|
|
|
|
if !self.focus_handle.is_focused(window)
|
|
&& let Some(max_lines) = max_lines_when_unfocused
|
|
{
|
|
displayed_lines = displayed_lines.min(*max_lines)
|
|
}
|
|
|
|
ContentMode::Inline {
|
|
displayed_lines,
|
|
total_lines,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sets the marked (pre-edit) text from the IME.
|
|
pub(crate) fn set_marked_text(
|
|
&mut self,
|
|
text: String,
|
|
range: Option<Range<usize>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.ime_state = Some(ImeState {
|
|
marked_text: text,
|
|
marked_range_utf16: range,
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
/// Gets the current marked range (UTF-16).
|
|
pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
|
|
self.ime_state
|
|
.as_ref()
|
|
.and_then(|state| state.marked_range_utf16.clone())
|
|
}
|
|
|
|
/// Clears the marked (pre-edit) text state.
|
|
pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
|
|
if self.ime_state.is_some() {
|
|
self.ime_state = None;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
|
|
pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
|
if !text.is_empty() {
|
|
self.terminal.update(cx, |term, _| {
|
|
term.input(text.to_string().into_bytes());
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds {
|
|
self.terminal.read(cx).last_content().terminal_bounds
|
|
}
|
|
|
|
pub fn entity(&self) -> &Entity<Terminal> {
|
|
&self.terminal
|
|
}
|
|
|
|
pub fn has_bell(&self) -> bool {
|
|
self.has_bell
|
|
}
|
|
|
|
pub fn clear_bell(&mut self, cx: &mut Context<TerminalView>) {
|
|
self.has_bell = false;
|
|
cx.emit(Event::Wakeup);
|
|
}
|
|
|
|
pub fn deploy_context_menu(
|
|
&mut self,
|
|
position: gpui::Point<Pixels>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let assistant_enabled = self
|
|
.workspace
|
|
.upgrade()
|
|
.and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
|
|
.is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled());
|
|
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
|
|
menu.context(self.focus_handle.clone())
|
|
.action("New Terminal", Box::new(NewTerminal))
|
|
.separator()
|
|
.action("Copy", Box::new(Copy))
|
|
.action("Paste", Box::new(Paste))
|
|
.action("Select All", Box::new(SelectAll))
|
|
.action("Clear", Box::new(Clear))
|
|
.when(assistant_enabled, |menu| {
|
|
menu.separator()
|
|
.action("Inline Assist", Box::new(InlineAssist::default()))
|
|
})
|
|
.separator()
|
|
.action(
|
|
"Close Terminal Tab",
|
|
Box::new(CloseActiveItem {
|
|
save_intent: None,
|
|
close_pinned: true,
|
|
}),
|
|
)
|
|
});
|
|
|
|
window.focus(&context_menu.focus_handle(cx));
|
|
let subscription = cx.subscribe_in(
|
|
&context_menu,
|
|
window,
|
|
|this, _, _: &DismissEvent, window, cx| {
|
|
if this.context_menu.as_ref().is_some_and(|context_menu| {
|
|
context_menu.0.focus_handle(cx).contains_focused(window, cx)
|
|
}) {
|
|
cx.focus_self(window);
|
|
}
|
|
this.context_menu.take();
|
|
cx.notify();
|
|
},
|
|
);
|
|
|
|
self.context_menu = Some((context_menu, position, subscription));
|
|
}
|
|
|
|
fn settings_changed(&mut self, cx: &mut Context<Self>) {
|
|
let settings = TerminalSettings::get_global(cx);
|
|
let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs;
|
|
self.show_breadcrumbs = settings.toolbar.breadcrumbs;
|
|
|
|
let new_cursor_shape = settings.cursor_shape.unwrap_or_default();
|
|
let old_cursor_shape = self.cursor_shape;
|
|
if old_cursor_shape != new_cursor_shape {
|
|
self.cursor_shape = new_cursor_shape;
|
|
self.terminal.update(cx, |term, _| {
|
|
term.set_cursor_shape(self.cursor_shape);
|
|
});
|
|
}
|
|
|
|
if breadcrumb_visibility_changed {
|
|
cx.emit(ItemEvent::UpdateBreadcrumbs);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn show_character_palette(
|
|
&mut self,
|
|
_: &ShowCharacterPalette,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self
|
|
.terminal
|
|
.read(cx)
|
|
.last_content
|
|
.mode
|
|
.contains(TermMode::ALT_SCREEN)
|
|
{
|
|
self.terminal.update(cx, |term, cx| {
|
|
term.try_keystroke(
|
|
&Keystroke::parse("ctrl-cmd-space").unwrap(),
|
|
TerminalSettings::get_global(cx).option_as_meta,
|
|
)
|
|
});
|
|
} else {
|
|
window.show_character_palette();
|
|
}
|
|
}
|
|
|
|
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |term, _| term.select_all());
|
|
cx.notify();
|
|
}
|
|
|
|
fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
|
|
let task = self
|
|
.terminal
|
|
.read(cx)
|
|
.task()
|
|
.map(|task| terminal_rerun_override(&task.spawned_task.id))
|
|
.unwrap_or_default();
|
|
window.dispatch_action(Box::new(task), cx);
|
|
}
|
|
|
|
fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.scroll_top = px(0.);
|
|
self.terminal.update(cx, |term, _| term.clear());
|
|
cx.notify();
|
|
}
|
|
|
|
fn max_scroll_top(&self, cx: &App) -> Pixels {
|
|
let terminal = self.terminal.read(cx);
|
|
|
|
let Some(block) = self.block_below_cursor.as_ref() else {
|
|
return Pixels::ZERO;
|
|
};
|
|
|
|
let line_height = terminal.last_content().terminal_bounds.line_height;
|
|
let viewport_lines = terminal.viewport_lines();
|
|
let cursor = point_to_viewport(
|
|
terminal.last_content.display_offset,
|
|
terminal.last_content.cursor.point,
|
|
)
|
|
.unwrap_or_default();
|
|
let max_scroll_top_in_lines =
|
|
(block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1));
|
|
|
|
max_scroll_top_in_lines as f32 * line_height
|
|
}
|
|
|
|
fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
|
|
let terminal_content = self.terminal.read(cx).last_content();
|
|
|
|
if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
|
|
let line_height = terminal_content.terminal_bounds.line_height;
|
|
let y_delta = event.delta.pixel_delta(line_height).y;
|
|
if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
|
|
self.scroll_top = cmp::max(
|
|
Pixels::ZERO,
|
|
cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
|
|
);
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
self.terminal.update(cx, |term, _| term.scroll_wheel(event));
|
|
}
|
|
|
|
fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context<Self>) {
|
|
let terminal_content = self.terminal.read(cx).last_content();
|
|
if self.block_below_cursor.is_some()
|
|
&& terminal_content.display_offset == 0
|
|
&& self.scroll_top > Pixels::ZERO
|
|
{
|
|
let line_height = terminal_content.terminal_bounds.line_height;
|
|
self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
|
|
return;
|
|
}
|
|
|
|
self.terminal.update(cx, |term, _| term.scroll_line_up());
|
|
cx.notify();
|
|
}
|
|
|
|
fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context<Self>) {
|
|
let terminal_content = self.terminal.read(cx).last_content();
|
|
if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
|
|
let max_scroll_top = self.max_scroll_top(cx);
|
|
if self.scroll_top < max_scroll_top {
|
|
let line_height = terminal_content.terminal_bounds.line_height;
|
|
self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
|
|
}
|
|
return;
|
|
}
|
|
|
|
self.terminal.update(cx, |term, _| term.scroll_line_down());
|
|
cx.notify();
|
|
}
|
|
|
|
fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.scroll_top == Pixels::ZERO {
|
|
self.terminal.update(cx, |term, _| term.scroll_page_up());
|
|
} else {
|
|
let line_height = self
|
|
.terminal
|
|
.read(cx)
|
|
.last_content
|
|
.terminal_bounds
|
|
.line_height();
|
|
let visible_block_lines = (self.scroll_top / line_height) as usize;
|
|
let viewport_lines = self.terminal.read(cx).viewport_lines();
|
|
let visible_content_lines = viewport_lines - visible_block_lines;
|
|
|
|
if visible_block_lines >= viewport_lines {
|
|
self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
|
|
} else {
|
|
self.scroll_top = px(0.);
|
|
self.terminal
|
|
.update(cx, |term, _| term.scroll_up_by(visible_content_lines));
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |term, _| term.scroll_page_down());
|
|
let terminal = self.terminal.read(cx);
|
|
if terminal.last_content().display_offset < terminal.viewport_lines() {
|
|
self.scroll_top = self.max_scroll_top(cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |term, _| term.scroll_to_top());
|
|
cx.notify();
|
|
}
|
|
|
|
fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |term, _| term.scroll_to_bottom());
|
|
if self.block_below_cursor.is_some() {
|
|
self.scroll_top = self.max_scroll_top(cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |term, _| term.toggle_vi_mode());
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn should_show_cursor(&self, focused: bool, cx: &mut Context<Self>) -> bool {
|
|
//Don't blink the cursor when not focused, blinking is disabled, or paused
|
|
if !focused
|
|
|| self.blinking_paused
|
|
|| self
|
|
.terminal
|
|
.read(cx)
|
|
.last_content
|
|
.mode
|
|
.contains(TermMode::ALT_SCREEN)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
match TerminalSettings::get_global(cx).blinking {
|
|
//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 => {
|
|
!self.blinking_terminal_enabled || self.blink_state
|
|
}
|
|
TerminalBlink::On => self.blink_state,
|
|
}
|
|
}
|
|
|
|
fn blink_cursors(&mut self, epoch: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
if epoch == self.blink_epoch && !self.blinking_paused {
|
|
self.blink_state = !self.blink_state;
|
|
cx.notify();
|
|
|
|
let epoch = self.next_blink_epoch();
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
Timer::after(CURSOR_BLINK_INTERVAL).await;
|
|
this.update_in(cx, |this, window, cx| this.blink_cursors(epoch, window, cx))
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
}
|
|
|
|
pub fn pause_cursor_blinking(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.blink_state = true;
|
|
cx.notify();
|
|
|
|
let epoch = self.next_blink_epoch();
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
Timer::after(CURSOR_BLINK_INTERVAL).await;
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.resume_cursor_blinking(epoch, window, cx)
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn terminal(&self) -> &Entity<Terminal> {
|
|
&self.terminal
|
|
}
|
|
|
|
pub fn set_block_below_cursor(
|
|
&mut self,
|
|
block: BlockProperties,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.block_below_cursor = Some(Rc::new(block));
|
|
self.scroll_to_bottom(&ScrollToBottom, window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn clear_block_below_cursor(&mut self, cx: &mut Context<Self>) {
|
|
self.block_below_cursor = None;
|
|
self.scroll_top = Pixels::ZERO;
|
|
cx.notify();
|
|
}
|
|
|
|
fn next_blink_epoch(&mut self) -> usize {
|
|
self.blink_epoch += 1;
|
|
self.blink_epoch
|
|
}
|
|
|
|
fn resume_cursor_blinking(
|
|
&mut self,
|
|
epoch: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if epoch == self.blink_epoch {
|
|
self.blinking_paused = false;
|
|
self.blink_cursors(epoch, window, cx);
|
|
}
|
|
}
|
|
|
|
///Attempt to paste the clipboard into the terminal
|
|
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |term, _| term.copy(None));
|
|
cx.notify();
|
|
}
|
|
|
|
///Attempt to paste the clipboard into the terminal
|
|
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
|
|
self.terminal
|
|
.update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
|
|
}
|
|
}
|
|
|
|
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.clear_bell(cx);
|
|
self.terminal.update(cx, |term, _| {
|
|
term.input(text.0.to_string().into_bytes());
|
|
});
|
|
}
|
|
|
|
fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
|
|
self.clear_bell(cx);
|
|
self.terminal.update(cx, |term, cx| {
|
|
let processed =
|
|
term.try_keystroke(&keystroke, TerminalSettings::get_global(cx).option_as_meta);
|
|
if processed && term.vi_mode_enabled() {
|
|
cx.notify();
|
|
}
|
|
processed
|
|
});
|
|
}
|
|
}
|
|
|
|
fn dispatch_context(&self, cx: &App) -> KeyContext {
|
|
let mut dispatch_context = KeyContext::new_with_defaults();
|
|
dispatch_context.add("Terminal");
|
|
|
|
if self.terminal.read(cx).vi_mode_enabled() {
|
|
dispatch_context.add("vi_mode");
|
|
}
|
|
|
|
let mode = self.terminal.read(cx).last_content.mode;
|
|
dispatch_context.set(
|
|
"screen",
|
|
if mode.contains(TermMode::ALT_SCREEN) {
|
|
"alt"
|
|
} else {
|
|
"normal"
|
|
},
|
|
);
|
|
|
|
if mode.contains(TermMode::APP_CURSOR) {
|
|
dispatch_context.add("DECCKM");
|
|
}
|
|
if mode.contains(TermMode::APP_KEYPAD) {
|
|
dispatch_context.add("DECPAM");
|
|
} else {
|
|
dispatch_context.add("DECPNM");
|
|
}
|
|
if mode.contains(TermMode::SHOW_CURSOR) {
|
|
dispatch_context.add("DECTCEM");
|
|
}
|
|
if mode.contains(TermMode::LINE_WRAP) {
|
|
dispatch_context.add("DECAWM");
|
|
}
|
|
if mode.contains(TermMode::ORIGIN) {
|
|
dispatch_context.add("DECOM");
|
|
}
|
|
if mode.contains(TermMode::INSERT) {
|
|
dispatch_context.add("IRM");
|
|
}
|
|
//LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
|
|
if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
|
|
dispatch_context.add("LNM");
|
|
}
|
|
if mode.contains(TermMode::FOCUS_IN_OUT) {
|
|
dispatch_context.add("report_focus");
|
|
}
|
|
if mode.contains(TermMode::ALTERNATE_SCROLL) {
|
|
dispatch_context.add("alternate_scroll");
|
|
}
|
|
if mode.contains(TermMode::BRACKETED_PASTE) {
|
|
dispatch_context.add("bracketed_paste");
|
|
}
|
|
if mode.intersects(TermMode::MOUSE_MODE) {
|
|
dispatch_context.add("any_mouse_reporting");
|
|
}
|
|
{
|
|
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"
|
|
};
|
|
dispatch_context.set("mouse_reporting", mouse_reporting);
|
|
}
|
|
{
|
|
let format = if mode.contains(TermMode::SGR_MOUSE) {
|
|
"sgr"
|
|
} else if mode.contains(TermMode::UTF8_MOUSE) {
|
|
"utf8"
|
|
} else {
|
|
"normal"
|
|
};
|
|
dispatch_context.set("mouse_format", format);
|
|
};
|
|
|
|
if self.terminal.read(cx).last_content.selection.is_some() {
|
|
dispatch_context.add("selection");
|
|
}
|
|
|
|
dispatch_context
|
|
}
|
|
|
|
fn set_terminal(
|
|
&mut self,
|
|
terminal: Entity<Terminal>,
|
|
window: &mut Window,
|
|
cx: &mut Context<TerminalView>,
|
|
) {
|
|
self._terminal_subscriptions =
|
|
subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx);
|
|
self.terminal = terminal;
|
|
}
|
|
|
|
fn rerun_button(task: &TaskState) -> Option<IconButton> {
|
|
if !task.spawned_task.show_rerun {
|
|
return None;
|
|
}
|
|
|
|
let task_id = task.spawned_task.id.clone();
|
|
Some(
|
|
IconButton::new("rerun-icon", IconName::Rerun)
|
|
.icon_size(IconSize::Small)
|
|
.size(ButtonSize::Compact)
|
|
.icon_color(Color::Default)
|
|
.shape(ui::IconButtonShape::Square)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::for_action("Rerun task", &RerunTask, window, cx)
|
|
})
|
|
.on_click(move |_, window, cx| {
|
|
window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx);
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun {
|
|
zed_actions::Rerun {
|
|
task_id: Some(task.0.clone()),
|
|
allow_concurrent_runs: Some(true),
|
|
use_new_terminal: Some(false),
|
|
reevaluate_context: false,
|
|
}
|
|
}
|
|
|
|
fn subscribe_for_terminal_events(
|
|
terminal: &Entity<Terminal>,
|
|
workspace: WeakEntity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<TerminalView>,
|
|
) -> Vec<Subscription> {
|
|
let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
|
|
let mut previous_cwd = None;
|
|
let terminal_events_subscription = cx.subscribe_in(
|
|
terminal,
|
|
window,
|
|
move |terminal_view, terminal, event, window, cx| {
|
|
let current_cwd = terminal.read(cx).working_directory();
|
|
if current_cwd != previous_cwd {
|
|
previous_cwd = current_cwd;
|
|
terminal_view.cwd_serialized = false;
|
|
}
|
|
|
|
match event {
|
|
Event::Wakeup => {
|
|
cx.notify();
|
|
cx.emit(Event::Wakeup);
|
|
cx.emit(ItemEvent::UpdateTab);
|
|
cx.emit(SearchEvent::MatchesInvalidated);
|
|
}
|
|
|
|
Event::Bell => {
|
|
terminal_view.has_bell = true;
|
|
cx.emit(Event::Wakeup);
|
|
}
|
|
|
|
Event::BlinkChanged(blinking) => {
|
|
if matches!(
|
|
TerminalSettings::get_global(cx).blinking,
|
|
TerminalBlink::TerminalControlled
|
|
) {
|
|
terminal_view.blinking_terminal_enabled = *blinking;
|
|
}
|
|
}
|
|
|
|
Event::TitleChanged => {
|
|
cx.emit(ItemEvent::UpdateTab);
|
|
}
|
|
|
|
Event::NewNavigationTarget(maybe_navigation_target) => {
|
|
match maybe_navigation_target
|
|
.as_ref()
|
|
.zip(terminal.read(cx).last_content.last_hovered_word.as_ref())
|
|
{
|
|
Some((MaybeNavigationTarget::Url(url), hovered_word)) => {
|
|
if Some(hovered_word)
|
|
!= terminal_view
|
|
.hover
|
|
.as_ref()
|
|
.map(|hover| &hover.hovered_word)
|
|
{
|
|
terminal_view.hover = Some(HoverTarget {
|
|
tooltip: url.clone(),
|
|
hovered_word: hovered_word.clone(),
|
|
});
|
|
terminal_view.hover_tooltip_update = Task::ready(());
|
|
cx.notify();
|
|
}
|
|
}
|
|
Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => {
|
|
if Some(hovered_word)
|
|
!= terminal_view
|
|
.hover
|
|
.as_ref()
|
|
.map(|hover| &hover.hovered_word)
|
|
{
|
|
terminal_view.hover = None;
|
|
terminal_view.hover_tooltip_update = hover_path_like_target(
|
|
&workspace,
|
|
hovered_word.clone(),
|
|
path_like_target,
|
|
cx,
|
|
);
|
|
cx.notify();
|
|
}
|
|
}
|
|
None => {
|
|
terminal_view.hover = None;
|
|
terminal_view.hover_tooltip_update = Task::ready(());
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
Event::Open(maybe_navigation_target) => match maybe_navigation_target {
|
|
MaybeNavigationTarget::Url(url) => cx.open_url(url),
|
|
MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target(
|
|
&workspace,
|
|
terminal_view,
|
|
path_like_target,
|
|
window,
|
|
cx,
|
|
),
|
|
},
|
|
Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
|
|
Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
|
|
Event::SelectionsChanged => {
|
|
window.invalidate_character_coordinates();
|
|
cx.emit(SearchEvent::ActiveMatchChanged)
|
|
}
|
|
}
|
|
},
|
|
);
|
|
vec![terminal_subscription, terminal_events_subscription]
|
|
}
|
|
|
|
fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
|
|
let str = query.as_str();
|
|
if query.is_regex() {
|
|
if str == "." {
|
|
return None;
|
|
}
|
|
RegexSearch::new(str).ok()
|
|
} else {
|
|
RegexSearch::new(®ex::escape(str)).ok()
|
|
}
|
|
}
|
|
|
|
struct TerminalScrollbarSettingsWrapper;
|
|
|
|
impl GlobalSetting for TerminalScrollbarSettingsWrapper {
|
|
fn get_value(_cx: &App) -> &Self {
|
|
&Self
|
|
}
|
|
}
|
|
|
|
impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper {
|
|
fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar {
|
|
TerminalSettings::get_global(cx)
|
|
.scrollbar
|
|
.show
|
|
.map(Into::into)
|
|
.unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
|
|
}
|
|
}
|
|
|
|
impl TerminalView {
|
|
fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.clear_bell(cx);
|
|
self.pause_cursor_blinking(window, cx);
|
|
|
|
self.terminal.update(cx, |term, cx| {
|
|
let handled = term.try_keystroke(
|
|
&event.keystroke,
|
|
TerminalSettings::get_global(cx).option_as_meta,
|
|
);
|
|
if handled {
|
|
cx.stop_propagation();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |terminal, _| {
|
|
terminal.set_cursor_shape(self.cursor_shape);
|
|
terminal.focus_in();
|
|
});
|
|
self.blink_cursors(self.blink_epoch, window, cx);
|
|
window.invalidate_character_coordinates();
|
|
cx.notify();
|
|
}
|
|
|
|
fn focus_out(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal.update(cx, |terminal, _| {
|
|
terminal.focus_out();
|
|
terminal.set_cursor_shape(CursorShape::Hollow);
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
impl Render for TerminalView {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
// TODO: this should be moved out of render
|
|
self.scroll_handle.update(self.terminal.read(cx));
|
|
|
|
if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
|
|
self.terminal.update(cx, |term, _| {
|
|
let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
|
|
match delta.cmp(&0) {
|
|
std::cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
|
|
std::cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
|
|
std::cmp::Ordering::Equal => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
let terminal_handle = self.terminal.clone();
|
|
let terminal_view_handle = cx.entity();
|
|
|
|
let focused = self.focus_handle.is_focused(window);
|
|
|
|
div()
|
|
.id("terminal-view")
|
|
.size_full()
|
|
.relative()
|
|
.track_focus(&self.focus_handle(cx))
|
|
.key_context(self.dispatch_context(cx))
|
|
.on_action(cx.listener(TerminalView::send_text))
|
|
.on_action(cx.listener(TerminalView::send_keystroke))
|
|
.on_action(cx.listener(TerminalView::copy))
|
|
.on_action(cx.listener(TerminalView::paste))
|
|
.on_action(cx.listener(TerminalView::clear))
|
|
.on_action(cx.listener(TerminalView::scroll_line_up))
|
|
.on_action(cx.listener(TerminalView::scroll_line_down))
|
|
.on_action(cx.listener(TerminalView::scroll_page_up))
|
|
.on_action(cx.listener(TerminalView::scroll_page_down))
|
|
.on_action(cx.listener(TerminalView::scroll_to_top))
|
|
.on_action(cx.listener(TerminalView::scroll_to_bottom))
|
|
.on_action(cx.listener(TerminalView::toggle_vi_mode))
|
|
.on_action(cx.listener(TerminalView::show_character_palette))
|
|
.on_action(cx.listener(TerminalView::select_all))
|
|
.on_action(cx.listener(TerminalView::rerun_task))
|
|
.on_key_down(cx.listener(Self::key_down))
|
|
.on_mouse_down(
|
|
MouseButton::Right,
|
|
cx.listener(|this, event: &MouseDownEvent, window, cx| {
|
|
if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
|
|
if this.terminal.read(cx).last_content.selection.is_none() {
|
|
this.terminal.update(cx, |terminal, _| {
|
|
terminal.select_word_at_event_position(event);
|
|
});
|
|
};
|
|
this.deploy_context_menu(event.position, window, cx);
|
|
cx.notify();
|
|
}
|
|
}),
|
|
)
|
|
.child(
|
|
// TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
|
|
div()
|
|
.id("terminal-view-container")
|
|
.size_full()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.child(TerminalElement::new(
|
|
terminal_handle,
|
|
terminal_view_handle,
|
|
self.workspace.clone(),
|
|
self.focus_handle.clone(),
|
|
focused,
|
|
self.should_show_cursor(focused, cx),
|
|
self.block_below_cursor.clone(),
|
|
self.mode.clone(),
|
|
))
|
|
.when(self.content_mode(window, cx).is_scrollable(), |div| {
|
|
div.custom_scrollbars(
|
|
Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
|
|
.show_along(ScrollAxes::Vertical)
|
|
.with_track_along(
|
|
ScrollAxes::Vertical,
|
|
cx.theme().colors().editor_background,
|
|
)
|
|
.tracked_scroll_handle(self.scroll_handle.clone()),
|
|
window,
|
|
cx,
|
|
)
|
|
}),
|
|
)
|
|
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
|
|
deferred(
|
|
anchored()
|
|
.position(*position)
|
|
.anchor(gpui::Corner::TopLeft)
|
|
.child(menu.clone()),
|
|
)
|
|
.with_priority(1)
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl Item for TerminalView {
|
|
type Event = ItemEvent;
|
|
|
|
fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
|
|
let terminal = self.terminal().read(cx);
|
|
let title = terminal.title(false);
|
|
let pid = terminal.pty_info.pid_getter().fallback_pid();
|
|
|
|
Some(TabTooltipContent::Custom(Box::new(move |_window, cx| {
|
|
cx.new(|_| TerminalTooltip::new(title.clone(), pid)).into()
|
|
})))
|
|
}
|
|
|
|
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
|
|
let terminal = self.terminal().read(cx);
|
|
let title = terminal.title(true);
|
|
|
|
let (icon, icon_color, rerun_button) = match terminal.task() {
|
|
Some(terminal_task) => match &terminal_task.status {
|
|
TaskStatus::Running => (
|
|
IconName::PlayFilled,
|
|
Color::Disabled,
|
|
TerminalView::rerun_button(terminal_task),
|
|
),
|
|
TaskStatus::Unknown => (
|
|
IconName::Warning,
|
|
Color::Warning,
|
|
TerminalView::rerun_button(terminal_task),
|
|
),
|
|
TaskStatus::Completed { success } => {
|
|
let rerun_button = TerminalView::rerun_button(terminal_task);
|
|
|
|
if *success {
|
|
(IconName::Check, Color::Success, rerun_button)
|
|
} else {
|
|
(IconName::XCircle, Color::Error, rerun_button)
|
|
}
|
|
}
|
|
},
|
|
None => (IconName::Terminal, Color::Muted, None),
|
|
};
|
|
|
|
h_flex()
|
|
.gap_1()
|
|
.group("term-tab-icon")
|
|
.child(
|
|
h_flex()
|
|
.group("term-tab-icon")
|
|
.child(
|
|
div()
|
|
.when(rerun_button.is_some(), |this| {
|
|
this.hover(|style| style.invisible().w_0())
|
|
})
|
|
.child(Icon::new(icon).color(icon_color)),
|
|
)
|
|
.when_some(rerun_button, |this, rerun_button| {
|
|
this.child(
|
|
div()
|
|
.absolute()
|
|
.visible_on_hover("term-tab-icon")
|
|
.child(rerun_button),
|
|
)
|
|
}),
|
|
)
|
|
.child(Label::new(title).color(params.text_color()))
|
|
.into_any()
|
|
}
|
|
|
|
fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
|
|
let terminal = self.terminal().read(cx);
|
|
terminal.title(detail == 0).into()
|
|
}
|
|
|
|
fn telemetry_event_text(&self) -> Option<&'static str> {
|
|
None
|
|
}
|
|
|
|
fn clone_on_split(
|
|
&self,
|
|
workspace_id: Option<WorkspaceId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Entity<Self>> {
|
|
let terminal = self
|
|
.project
|
|
.update(cx, |project, cx| {
|
|
let cwd = project
|
|
.active_project_directory(cx)
|
|
.map(|it| it.to_path_buf());
|
|
project.clone_terminal(self.terminal(), cx, || cwd)
|
|
})
|
|
.ok()?
|
|
.log_err()?;
|
|
|
|
Some(cx.new(|cx| {
|
|
TerminalView::new(
|
|
terminal,
|
|
self.workspace.clone(),
|
|
workspace_id,
|
|
self.project.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
}))
|
|
}
|
|
|
|
fn is_dirty(&self, cx: &gpui::App) -> bool {
|
|
match self.terminal.read(cx).task() {
|
|
Some(task) => task.status == TaskStatus::Running,
|
|
None => self.has_bell(),
|
|
}
|
|
}
|
|
|
|
fn has_conflict(&self, _cx: &App) -> bool {
|
|
false
|
|
}
|
|
|
|
fn can_save_as(&self, _cx: &App) -> bool {
|
|
false
|
|
}
|
|
|
|
fn is_singleton(&self, _cx: &App) -> bool {
|
|
true
|
|
}
|
|
|
|
fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
|
|
Some(Box::new(handle.clone()))
|
|
}
|
|
|
|
fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
|
|
if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
|
|
ToolbarItemLocation::PrimaryLeft
|
|
} else {
|
|
ToolbarItemLocation::Hidden
|
|
}
|
|
}
|
|
|
|
fn breadcrumbs(&self, _: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
|
|
Some(vec![BreadcrumbText {
|
|
text: self.terminal().read(cx).breadcrumb_text.clone(),
|
|
highlights: None,
|
|
font: None,
|
|
}])
|
|
}
|
|
|
|
fn added_to_workspace(
|
|
&mut self,
|
|
workspace: &mut Workspace,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.terminal().read(cx).task().is_none() {
|
|
if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
|
|
log::debug!(
|
|
"Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
|
|
);
|
|
cx.background_spawn(TERMINAL_DB.update_workspace_id(
|
|
new_id,
|
|
old_id,
|
|
cx.entity_id().as_u64(),
|
|
))
|
|
.detach();
|
|
}
|
|
self.workspace_id = workspace.database_id();
|
|
}
|
|
}
|
|
|
|
fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
|
|
f(*event)
|
|
}
|
|
}
|
|
|
|
impl SerializableItem for TerminalView {
|
|
fn serialized_item_kind() -> &'static str {
|
|
"Terminal"
|
|
}
|
|
|
|
fn cleanup(
|
|
workspace_id: WorkspaceId,
|
|
alive_items: Vec<workspace::ItemId>,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Task<anyhow::Result<()>> {
|
|
delete_unloaded_items(alive_items, workspace_id, "terminals", &TERMINAL_DB, cx)
|
|
}
|
|
|
|
fn serialize(
|
|
&mut self,
|
|
_workspace: &mut Workspace,
|
|
item_id: workspace::ItemId,
|
|
_closing: bool,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<anyhow::Result<()>>> {
|
|
let terminal = self.terminal().read(cx);
|
|
if terminal.task().is_some() {
|
|
return None;
|
|
}
|
|
|
|
if let Some((cwd, workspace_id)) = terminal.working_directory().zip(self.workspace_id) {
|
|
self.cwd_serialized = true;
|
|
Some(cx.background_spawn(async move {
|
|
TERMINAL_DB
|
|
.save_working_directory(item_id, workspace_id, cwd)
|
|
.await
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn should_serialize(&self, _: &Self::Event) -> bool {
|
|
!self.cwd_serialized
|
|
}
|
|
|
|
fn deserialize(
|
|
project: Entity<Project>,
|
|
workspace: WeakEntity<Workspace>,
|
|
workspace_id: workspace::WorkspaceId,
|
|
item_id: workspace::ItemId,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Task<anyhow::Result<Entity<Self>>> {
|
|
window.spawn(cx, async move |cx| {
|
|
let cwd = cx
|
|
.update(|_window, cx| {
|
|
let from_db = TERMINAL_DB
|
|
.get_working_directory(item_id, workspace_id)
|
|
.log_err()
|
|
.flatten();
|
|
if from_db
|
|
.as_ref()
|
|
.is_some_and(|from_db| !from_db.as_os_str().is_empty())
|
|
{
|
|
from_db
|
|
} else {
|
|
workspace
|
|
.upgrade()
|
|
.and_then(|workspace| default_working_directory(workspace.read(cx), cx))
|
|
}
|
|
})
|
|
.ok()
|
|
.flatten();
|
|
|
|
let terminal = project
|
|
.update(cx, |project, cx| project.create_terminal_shell(cwd, cx))?
|
|
.await?;
|
|
cx.update(|window, cx| {
|
|
cx.new(|cx| {
|
|
TerminalView::new(
|
|
terminal,
|
|
workspace,
|
|
Some(workspace_id),
|
|
project.downgrade(),
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SearchableItem for TerminalView {
|
|
type Match = RangeInclusive<Point>;
|
|
|
|
fn supported_options(&self) -> SearchOptions {
|
|
SearchOptions {
|
|
case: false,
|
|
word: false,
|
|
regex: true,
|
|
replacement: false,
|
|
selection: false,
|
|
find_in_results: false,
|
|
}
|
|
}
|
|
|
|
/// Clear stored matches
|
|
fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal().update(cx, |term, _| term.matches.clear())
|
|
}
|
|
|
|
/// Store matches returned from find_matches somewhere for rendering
|
|
fn update_matches(
|
|
&mut self,
|
|
matches: &[Self::Match],
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.terminal()
|
|
.update(cx, |term, _| term.matches = matches.to_vec())
|
|
}
|
|
|
|
/// Returns the selection content to pre-load into this search
|
|
fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
|
|
self.terminal()
|
|
.read(cx)
|
|
.last_content
|
|
.selection_text
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Focus match at given index into the Vec of matches
|
|
fn activate_match(
|
|
&mut self,
|
|
index: usize,
|
|
_: &[Self::Match],
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.terminal()
|
|
.update(cx, |term, _| term.activate_match(index));
|
|
cx.notify();
|
|
}
|
|
|
|
/// Add selections for all matches given.
|
|
fn select_matches(&mut self, matches: &[Self::Match], _: &mut Window, cx: &mut Context<Self>) {
|
|
self.terminal()
|
|
.update(cx, |term, _| term.select_matches(matches));
|
|
cx.notify();
|
|
}
|
|
|
|
/// Get all of the matches for this query, should be done on the background
|
|
fn find_matches(
|
|
&mut self,
|
|
query: Arc<SearchQuery>,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Vec<Self::Match>> {
|
|
if let Some(s) = regex_search_for_query(&query) {
|
|
self.terminal()
|
|
.update(cx, |term, cx| term.find_matches(s, cx))
|
|
} else {
|
|
Task::ready(vec![])
|
|
}
|
|
}
|
|
|
|
/// Reports back to the search toolbar what the active match should be (the selection)
|
|
fn active_match_index(
|
|
&mut self,
|
|
direction: Direction,
|
|
matches: &[Self::Match],
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<usize> {
|
|
// Selection head might have a value if there's a selection that isn't
|
|
// associated with a match. Therefore, if there are no matches, we should
|
|
// report None, no matter the state of the terminal
|
|
|
|
if !matches.is_empty() {
|
|
if let Some(selection_head) = self.terminal().read(cx).selection_head {
|
|
// If selection head is contained in a match. Return that match
|
|
match direction {
|
|
Direction::Prev => {
|
|
// If no selection before selection head, return the first match
|
|
Some(
|
|
matches
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.find(|(_, search_match)| {
|
|
search_match.contains(&selection_head)
|
|
|| search_match.start() < &selection_head
|
|
})
|
|
.map(|(ix, _)| ix)
|
|
.unwrap_or(0),
|
|
)
|
|
}
|
|
Direction::Next => {
|
|
// If no selection after selection head, return the last match
|
|
Some(
|
|
matches
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, search_match)| {
|
|
search_match.contains(&selection_head)
|
|
|| search_match.start() > &selection_head
|
|
})
|
|
.map(|(ix, _)| ix)
|
|
.unwrap_or(matches.len().saturating_sub(1)),
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// Matches found but no active selection, return the first last one (closest to cursor)
|
|
Some(matches.len().saturating_sub(1))
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
fn replace(
|
|
&mut self,
|
|
_: &Self::Match,
|
|
_: &SearchQuery,
|
|
_window: &mut Window,
|
|
_: &mut Context<Self>,
|
|
) {
|
|
// Replacement is not supported in terminal view, so this is a no-op.
|
|
}
|
|
}
|
|
|
|
///Gets the working directory for the given workspace, respecting the user's settings.
|
|
/// None implies "~" on whichever machine we end up on.
|
|
pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
|
|
match &TerminalSettings::get_global(cx).working_directory {
|
|
WorkingDirectory::CurrentProjectDirectory => workspace
|
|
.project()
|
|
.read(cx)
|
|
.active_project_directory(cx)
|
|
.as_deref()
|
|
.map(Path::to_path_buf),
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
///Gets the first project's home directory, or the home directory
|
|
fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
|
|
let worktree = workspace.worktrees(cx).next()?.read(cx);
|
|
if !worktree.root_entry()?.is_dir() {
|
|
return None;
|
|
}
|
|
Some(worktree.abs_path().to_path_buf())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use gpui::TestAppContext;
|
|
use project::{Entry, Project, ProjectPath, Worktree};
|
|
use std::path::Path;
|
|
use util::rel_path::RelPath;
|
|
use workspace::AppState;
|
|
|
|
// Working directory calculation tests
|
|
|
|
// No Worktrees in project -> home_dir()
|
|
#[gpui::test]
|
|
async fn no_worktree(cx: &mut TestAppContext) {
|
|
let (project, workspace) = init_test(cx).await;
|
|
cx.read(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
let active_entry = project.read(cx).active_entry();
|
|
|
|
//Make sure environment is as expected
|
|
assert!(active_entry.is_none());
|
|
assert!(workspace.worktrees(cx).next().is_none());
|
|
|
|
let res = default_working_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) {
|
|
let (project, workspace) = init_test(cx).await;
|
|
|
|
create_file_wt(project.clone(), "/root.txt", cx).await;
|
|
cx.read(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
let active_entry = project.read(cx).active_entry();
|
|
|
|
//Make sure environment is as expected
|
|
assert!(active_entry.is_none());
|
|
assert!(workspace.worktrees(cx).next().is_some());
|
|
|
|
let res = default_working_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) {
|
|
let (project, workspace) = init_test(cx).await;
|
|
|
|
let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
|
|
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 = default_working_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 -> worktree_folder()
|
|
#[gpui::test]
|
|
async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
|
|
let (project, workspace) = init_test(cx).await;
|
|
|
|
let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
|
|
let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
|
|
insert_active_entry_for(wt2, entry2, project.clone(), cx);
|
|
|
|
cx.update(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
let active_entry = project.read(cx).active_entry();
|
|
|
|
assert!(active_entry.is_some());
|
|
|
|
let res = default_working_directory(workspace, cx);
|
|
assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
|
|
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) {
|
|
let (project, workspace) = init_test(cx).await;
|
|
|
|
let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
|
|
let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
|
|
insert_active_entry_for(wt2, entry2, project.clone(), cx);
|
|
|
|
cx.update(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
let active_entry = project.read(cx).active_entry();
|
|
|
|
assert!(active_entry.is_some());
|
|
|
|
let res = default_working_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()));
|
|
});
|
|
}
|
|
|
|
/// Creates a worktree with 1 file: /root.txt
|
|
pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
|
|
let params = cx.update(AppState::test);
|
|
cx.update(|cx| {
|
|
terminal::init(cx);
|
|
theme::init(theme::LoadThemes::JustBase, cx);
|
|
Project::init_settings(cx);
|
|
language::init(cx);
|
|
});
|
|
|
|
let project = Project::test(params.fs.clone(), [], cx).await;
|
|
let workspace = cx
|
|
.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx))
|
|
.root(cx)
|
|
.unwrap();
|
|
|
|
(project, workspace)
|
|
}
|
|
|
|
/// Creates a worktree with 1 folder: /root{suffix}/
|
|
async fn create_folder_wt(
|
|
project: Entity<Project>,
|
|
path: impl AsRef<Path>,
|
|
cx: &mut TestAppContext,
|
|
) -> (Entity<Worktree>, Entry) {
|
|
create_wt(project, true, path, cx).await
|
|
}
|
|
|
|
/// Creates a worktree with 1 file: /root{suffix}.txt
|
|
async fn create_file_wt(
|
|
project: Entity<Project>,
|
|
path: impl AsRef<Path>,
|
|
cx: &mut TestAppContext,
|
|
) -> (Entity<Worktree>, Entry) {
|
|
create_wt(project, false, path, cx).await
|
|
}
|
|
|
|
async fn create_wt(
|
|
project: Entity<Project>,
|
|
is_dir: bool,
|
|
path: impl AsRef<Path>,
|
|
cx: &mut TestAppContext,
|
|
) -> (Entity<Worktree>, Entry) {
|
|
let (wt, _) = project
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree(path, true, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let entry = cx
|
|
.update(|cx| {
|
|
wt.update(cx, |wt, cx| {
|
|
wt.create_entry(RelPath::empty().into(), is_dir, None, cx)
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_included()
|
|
.unwrap();
|
|
|
|
(wt, entry)
|
|
}
|
|
|
|
pub fn insert_active_entry_for(
|
|
wt: Entity<Worktree>,
|
|
entry: Entry,
|
|
project: Entity<Project>,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
cx.update(|cx| {
|
|
let p = ProjectPath {
|
|
worktree_id: wt.read(cx).id(),
|
|
path: entry.path,
|
|
};
|
|
project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
|
|
});
|
|
}
|
|
}
|