Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,691 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
|
||||
use super::find::RegexDFAs;
|
||||
use super::grid::RespectDisplayedOutput;
|
||||
use super::image_map::StoredImageMetadata;
|
||||
use super::index::Direction;
|
||||
use super::kitty::{KittyAction, KittyResponse};
|
||||
use super::secrets::RespectObfuscatedSecrets;
|
||||
use super::selection::{ExpandedSelectionRange, ScrollDelta};
|
||||
use crate::terminal::event::Event as TerminalEvent;
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::grid_renderer::ColorSampler;
|
||||
use crate::terminal::model::ansi;
|
||||
use crate::terminal::model::ansi::{
|
||||
Attr, CharsetIndex, ClearMode, CommandFinishedValue, CursorShape, CursorStyle, LineClearMode,
|
||||
Mode, PrecmdValue, PreexecValue, StandardCharset, TabulationClearMode,
|
||||
};
|
||||
use crate::terminal::model::grid::grid_handler::{
|
||||
FragmentBoundary, GridHandler, Link, PerformResetGridChecks, PossiblePath, TermMode,
|
||||
};
|
||||
use crate::terminal::model::grid::{Dimensions, GridStorage};
|
||||
use crate::terminal::model::index::{Point, Side, VisibleRow};
|
||||
use crate::terminal::model::iterm_image::ITermImage;
|
||||
use crate::terminal::model::secrets::ObfuscateSecrets;
|
||||
use crate::terminal::model::selection::{Selection, SelectionRange};
|
||||
use crate::terminal::{SizeInfo, SizeUpdate};
|
||||
use itertools::Itertools;
|
||||
use num_traits::Float as _;
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::sync::Arc;
|
||||
use vec1::Vec1;
|
||||
use warp_core::semantic_selection::SemanticSelection;
|
||||
use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior};
|
||||
use warpui::text::SelectionType;
|
||||
use warpui::units::Lines;
|
||||
|
||||
pub struct AltScreen {
|
||||
grid_handler: GridHandler,
|
||||
// Number of fractional lines that have yet to scroll the alt grid.
|
||||
// This number is always between 1. and -1, since the alt grid only supports
|
||||
// line-based scrolling.
|
||||
pending_lines_to_scroll: Lines,
|
||||
/// The current alt screen selection.
|
||||
/// Do not set this value directly - use [`Self::set_selection`] and [`Self::clear_selection`] instead.
|
||||
selection: Option<Selection>,
|
||||
/// If this is Some, and if smart-select is enabled, double-clicking within this range will
|
||||
/// select this range instead of the normal smart-select logic. The purpose of this is to
|
||||
/// allow double-click selection to work on the TerminalView::highlighted_link even when it
|
||||
/// contains spaces. Smart-select never traverses across whitespace.
|
||||
smart_select_override: Option<RangeInclusive<Point>>,
|
||||
|
||||
/// 'Sampler' that samples background color of output grid cells as the alt screen is rendered.
|
||||
///
|
||||
/// In some instances, we color-match the background of other UI elements outside the altscreen
|
||||
/// against the altscreen background.
|
||||
///
|
||||
/// This has interior mutability because it's updated at render time, as we render each cell in
|
||||
/// the output grid.
|
||||
pub bg_color_sampler: Arc<Mutex<ColorSampler>>,
|
||||
|
||||
event_proxy: ChannelEventListener,
|
||||
}
|
||||
|
||||
impl AltScreen {
|
||||
pub fn new(
|
||||
size_info: SizeInfo,
|
||||
max_scroll_limit: usize,
|
||||
event_proxy: ChannelEventListener,
|
||||
obfuscate_secrets: ObfuscateSecrets,
|
||||
) -> Self {
|
||||
let grid_handler = GridHandler::new(
|
||||
size_info,
|
||||
max_scroll_limit,
|
||||
event_proxy.clone(),
|
||||
true,
|
||||
obfuscate_secrets,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
AltScreen {
|
||||
grid_handler,
|
||||
pending_lines_to_scroll: Lines::zero(),
|
||||
selection: None,
|
||||
smart_select_override: None,
|
||||
bg_color_sampler: Arc::new(Mutex::new(ColorSampler::new())),
|
||||
event_proxy,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_smart_select_override(&mut self, smart_select_override: RangeInclusive<Point>) {
|
||||
self.smart_select_override = Some(smart_select_override);
|
||||
}
|
||||
|
||||
pub fn clear_smart_select_override(&mut self) {
|
||||
self.smart_select_override = None;
|
||||
}
|
||||
|
||||
pub(super) fn grid_storage(&self) -> &GridStorage {
|
||||
self.grid_handler.grid_storage()
|
||||
}
|
||||
|
||||
pub fn cursor_style(&self) -> CursorStyle {
|
||||
self.grid_handler.cursor_style()
|
||||
}
|
||||
|
||||
pub(super) fn grid_storage_mut(&mut self) -> &mut GridStorage {
|
||||
self.grid_handler.grid_storage_mut()
|
||||
}
|
||||
|
||||
pub fn grid_handler(&self) -> &GridHandler {
|
||||
&self.grid_handler
|
||||
}
|
||||
|
||||
pub fn grid_handler_mut(&mut self) -> &mut GridHandler {
|
||||
&mut self.grid_handler
|
||||
}
|
||||
|
||||
/// Resize terminal to new dimensions.
|
||||
pub fn resize(&mut self, size_update: &SizeUpdate) {
|
||||
// Clear any selection on screen resize, to prevent stale anchors
|
||||
if size_update.rows_or_columns_changed() {
|
||||
self.clear_selection();
|
||||
}
|
||||
self.grid_handler.resize(size_update.new_size);
|
||||
}
|
||||
|
||||
pub fn reset_pending_lines_to_scroll(&mut self) {
|
||||
self.pending_lines_to_scroll = Lines::zero();
|
||||
}
|
||||
|
||||
/// Accumulates scroll amounts, returning the integral number of whole lines
|
||||
/// which should be scrolled and retaining any remaining fractional
|
||||
/// component.
|
||||
pub fn accumulate_lines_to_scroll(&mut self, delta: Lines) -> i32 {
|
||||
self.pending_lines_to_scroll += delta;
|
||||
let whole_lines = self.pending_lines_to_scroll.trunc();
|
||||
self.pending_lines_to_scroll = self.pending_lines_to_scroll.fract();
|
||||
whole_lines.as_f64() as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn pending_lines_to_scroll(&self) -> Lines {
|
||||
self.pending_lines_to_scroll
|
||||
}
|
||||
|
||||
pub fn selection_range(
|
||||
&self,
|
||||
semantic_selection: &SemanticSelection,
|
||||
) -> Option<ExpandedSelectionRange<Point>> {
|
||||
let selection = self.selection().as_ref()?;
|
||||
let SelectionRange {
|
||||
start,
|
||||
end,
|
||||
is_reversed,
|
||||
} = selection.to_range(&self.grid_handler, semantic_selection)?;
|
||||
|
||||
if start == end {
|
||||
return Some(ExpandedSelectionRange::Regular {
|
||||
start,
|
||||
end,
|
||||
reversed: is_reversed,
|
||||
});
|
||||
}
|
||||
|
||||
Some(match selection.ty {
|
||||
SelectionType::Rect => {
|
||||
let start_col = start.col.min(end.col);
|
||||
let end_col = end.col.max(start.col);
|
||||
|
||||
// Skip rect selections that will have zero-width.
|
||||
if start_col == end_col {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Iterate over each row and create a vec of (start, end) points.
|
||||
let rows = (start.row..=end.row)
|
||||
.map(|row| {
|
||||
(
|
||||
Point {
|
||||
row,
|
||||
col: start_col,
|
||||
},
|
||||
Point { row, col: end_col },
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
ExpandedSelectionRange::Rect {
|
||||
rows: Vec1::try_from_vec(rows).ok()?,
|
||||
}
|
||||
}
|
||||
_ => ExpandedSelectionRange::Regular {
|
||||
start,
|
||||
end,
|
||||
reversed: is_reversed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selection(&self) -> &Option<Selection> {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
pub fn start_selection(&mut self, point: Point, selection_type: SelectionType, side: Side) {
|
||||
let mut selection = Selection::new(selection_type, point, side);
|
||||
selection.set_smart_select_side(Direction::Left);
|
||||
|
||||
if let Some(smart_select_override) = &self.smart_select_override {
|
||||
// We only want to accept this override if it actually wraps around the cursor, so we
|
||||
// do this comparison to make sure the point of the cursor is between the ends of the
|
||||
// range of the potential override text.
|
||||
if smart_select_override.contains(&point) {
|
||||
selection.set_smart_select_override(smart_select_override.clone());
|
||||
}
|
||||
}
|
||||
self.set_selection(selection);
|
||||
}
|
||||
|
||||
pub fn update_selection(&mut self, point: Point, side: Side) {
|
||||
let mut selection = match self.selection.take() {
|
||||
None => return,
|
||||
Some(selection) => selection,
|
||||
};
|
||||
|
||||
selection.update(point, side);
|
||||
if selection.is_tail_before_head() {
|
||||
selection.set_smart_select_side(Direction::Right);
|
||||
} else {
|
||||
selection.set_smart_select_side(Direction::Left);
|
||||
}
|
||||
self.set_selection(selection);
|
||||
}
|
||||
|
||||
fn set_selection(&mut self, value: Selection) {
|
||||
self.selection = Some(value);
|
||||
self.event_proxy
|
||||
.send_terminal_event(TerminalEvent::TextSelectionChanged);
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selection = None;
|
||||
self.event_proxy
|
||||
.send_terminal_event(TerminalEvent::TextSelectionChanged);
|
||||
}
|
||||
|
||||
pub fn selection_to_string(&self, semantic_selection: &SemanticSelection) -> Option<String> {
|
||||
let selection_range = self.selection_range(semantic_selection)?;
|
||||
Some(match selection_range {
|
||||
ExpandedSelectionRange::Regular { start, end, .. } => {
|
||||
self.grid_handler.bounds_to_string(
|
||||
start,
|
||||
end,
|
||||
false, /* include_esc_sequences */
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false, /* force_obfuscated_secrets */
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
}
|
||||
ExpandedSelectionRange::Rect { rows } => {
|
||||
rows.into_iter()
|
||||
.map(|(start, end)| {
|
||||
self.grid_handler.bounds_to_string(
|
||||
start,
|
||||
end,
|
||||
false, /* include_esc_sequences */
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false, /* force_obfuscated_secrets */
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn possible_file_paths_at_point(&self, point: Point) -> impl Iterator<Item = PossiblePath> {
|
||||
self.grid_handler
|
||||
.possible_file_paths_at_point(point)
|
||||
.into_iter()
|
||||
}
|
||||
|
||||
pub fn url_at_point(&self, point: &Point) -> Option<Link> {
|
||||
self.grid_handler.url_at_point(*point)
|
||||
}
|
||||
|
||||
pub fn fragment_boundary_at_point(&self, point: &Point) -> FragmentBoundary {
|
||||
self.grid_handler.fragment_boundary_at_point(point)
|
||||
}
|
||||
|
||||
pub fn bounds_to_string(
|
||||
&self,
|
||||
start: Point,
|
||||
end: Point,
|
||||
respect_obfuscated_secrets: RespectObfuscatedSecrets,
|
||||
) -> String {
|
||||
self.grid_handler.bounds_to_string(
|
||||
start,
|
||||
end,
|
||||
false, /* include_esc_sequences */
|
||||
respect_obfuscated_secrets,
|
||||
false, /* force_obfuscated_secrets */
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn output_to_string(&self) -> String {
|
||||
self.grid_handler.bounds_to_string(
|
||||
Point::new(0, 0),
|
||||
Point::new(
|
||||
self.grid_handler.total_rows() - 1,
|
||||
self.grid_handler.columns() - 1,
|
||||
),
|
||||
false, /* include_esc_sequences */
|
||||
RespectObfuscatedSecrets::No,
|
||||
false, /* force_obfuscated_secrets */
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn needs_bracketed_paste(&self) -> bool {
|
||||
self.grid_handler.needs_bracketed_paste()
|
||||
}
|
||||
|
||||
pub fn is_mode_set(&self, mode: TermMode) -> bool {
|
||||
self.grid_handler.is_mode_set(mode)
|
||||
}
|
||||
|
||||
pub fn find(&self, dfas: &RegexDFAs) -> Vec<RangeInclusive<Point>> {
|
||||
self.grid_handler.find(dfas).collect()
|
||||
}
|
||||
|
||||
/// Sets whether any content within a grid that is "secret-like" should be obfuscated.
|
||||
pub(super) fn set_obfuscate_secrets(&mut self, obfuscate_secrets: ObfuscateSecrets) {
|
||||
self.grid_handler.set_obfuscate_secrets(obfuscate_secrets);
|
||||
}
|
||||
|
||||
fn rotate_selection(&mut self, delta: ScrollDelta) {
|
||||
self.selection = self.selection.take().and_then(|s| {
|
||||
s.rotate(
|
||||
self.grid_handler.scroll_region(),
|
||||
delta,
|
||||
self.grid_storage().columns(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn ansi_handler(&mut self) -> &mut impl ansi::Handler {
|
||||
self.grid_handler.ansi_handler()
|
||||
}
|
||||
|
||||
pub(super) fn set_marked_text(&mut self, marked_text: &str, selected_range: &Range<usize>) {
|
||||
self.grid_handler
|
||||
.set_marked_text(marked_text, selected_range);
|
||||
}
|
||||
|
||||
pub(super) fn clear_marked_text(&mut self) {
|
||||
self.grid_handler.clear_marked_text();
|
||||
}
|
||||
|
||||
pub fn inferred_bg_color(&self) -> Option<ColorU> {
|
||||
self.bg_color_sampler
|
||||
.lock()
|
||||
.most_common()
|
||||
.filter(|color| !color.is_fully_transparent())
|
||||
}
|
||||
}
|
||||
|
||||
impl ansi::Handler for AltScreen {
|
||||
fn set_title(&mut self, _: Option<String>) {
|
||||
log::error!("Handler method AltScreen::set_title should never be called. This should be handled by TerminalModel.");
|
||||
}
|
||||
|
||||
fn set_cursor_style(&mut self, style: Option<CursorStyle>) {
|
||||
self.ansi_handler().set_cursor_style(style);
|
||||
}
|
||||
|
||||
fn set_cursor_shape(&mut self, shape: CursorShape) {
|
||||
self.ansi_handler().set_cursor_shape(shape);
|
||||
}
|
||||
|
||||
fn input(&mut self, c: char) {
|
||||
self.ansi_handler().input(c);
|
||||
}
|
||||
|
||||
fn goto(&mut self, row: VisibleRow, col: usize) {
|
||||
self.ansi_handler().goto(row, col);
|
||||
}
|
||||
|
||||
fn goto_line(&mut self, row: VisibleRow) {
|
||||
self.ansi_handler().goto_line(row);
|
||||
}
|
||||
|
||||
fn goto_col(&mut self, col: usize) {
|
||||
self.ansi_handler().goto_col(col);
|
||||
}
|
||||
|
||||
fn insert_blank(&mut self, count: usize) {
|
||||
self.ansi_handler().insert_blank(count);
|
||||
}
|
||||
|
||||
fn move_up(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_up(lines);
|
||||
}
|
||||
|
||||
fn move_down(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_down(lines);
|
||||
}
|
||||
|
||||
fn identify_terminal<W: io::Write>(&mut self, writer: &mut W, intermediate: Option<char>) {
|
||||
self.ansi_handler().identify_terminal(writer, intermediate);
|
||||
}
|
||||
|
||||
fn report_xtversion<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().report_xtversion(writer);
|
||||
}
|
||||
|
||||
fn device_status<W: io::Write>(&mut self, writer: &mut W, arg: usize) {
|
||||
self.ansi_handler().device_status(writer, arg);
|
||||
}
|
||||
|
||||
fn move_forward(&mut self, columns: usize) {
|
||||
self.ansi_handler().move_forward(columns);
|
||||
}
|
||||
|
||||
fn move_backward(&mut self, columns: usize) {
|
||||
self.ansi_handler().move_backward(columns);
|
||||
}
|
||||
|
||||
fn move_down_and_cr(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_down_and_cr(lines);
|
||||
}
|
||||
|
||||
fn move_up_and_cr(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_up_and_cr(lines);
|
||||
}
|
||||
|
||||
fn put_tab(&mut self, count: u16) {
|
||||
self.ansi_handler().put_tab(count);
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
self.ansi_handler().backspace();
|
||||
}
|
||||
|
||||
fn carriage_return(&mut self) {
|
||||
self.ansi_handler().carriage_return();
|
||||
}
|
||||
|
||||
fn linefeed(&mut self) -> ScrollDelta {
|
||||
let lines_scrolled = self.ansi_handler().linefeed();
|
||||
self.rotate_selection(lines_scrolled);
|
||||
lines_scrolled
|
||||
}
|
||||
|
||||
fn bell(&mut self) {
|
||||
self.ansi_handler().bell();
|
||||
}
|
||||
|
||||
fn substitute(&mut self) {
|
||||
self.ansi_handler().substitute();
|
||||
}
|
||||
|
||||
fn newline(&mut self) {
|
||||
self.ansi_handler().newline();
|
||||
}
|
||||
|
||||
fn set_horizontal_tabstop(&mut self) {
|
||||
self.ansi_handler().set_horizontal_tabstop();
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, lines: usize) -> ScrollDelta {
|
||||
let lines_scrolled = self.ansi_handler().scroll_up(lines);
|
||||
self.rotate_selection(lines_scrolled);
|
||||
lines_scrolled
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, lines: usize) -> ScrollDelta {
|
||||
let lines_scrolled = self.ansi_handler().scroll_down(lines);
|
||||
self.rotate_selection(lines_scrolled);
|
||||
lines_scrolled
|
||||
}
|
||||
|
||||
fn insert_blank_lines(&mut self, lines: usize) -> ScrollDelta {
|
||||
let lines_scrolled = self.ansi_handler().insert_blank_lines(lines);
|
||||
self.rotate_selection(lines_scrolled);
|
||||
lines_scrolled
|
||||
}
|
||||
|
||||
fn delete_lines(&mut self, lines: usize) -> ScrollDelta {
|
||||
let lines_scrolled = self.ansi_handler().delete_lines(lines);
|
||||
self.rotate_selection(lines_scrolled);
|
||||
lines_scrolled
|
||||
}
|
||||
|
||||
fn erase_chars(&mut self, count: usize) {
|
||||
self.ansi_handler().erase_chars(count);
|
||||
}
|
||||
|
||||
fn delete_chars(&mut self, count: usize) {
|
||||
self.ansi_handler().delete_chars(count)
|
||||
}
|
||||
|
||||
fn move_backward_tabs(&mut self, count: u16) {
|
||||
self.ansi_handler().move_backward_tabs(count);
|
||||
}
|
||||
|
||||
fn move_forward_tabs(&mut self, count: u16) {
|
||||
self.ansi_handler().move_forward_tabs(count);
|
||||
}
|
||||
|
||||
fn save_cursor_position(&mut self) {
|
||||
self.ansi_handler().save_cursor_position();
|
||||
}
|
||||
|
||||
fn restore_cursor_position(&mut self) {
|
||||
self.ansi_handler().restore_cursor_position();
|
||||
}
|
||||
|
||||
fn clear_line(&mut self, mode: LineClearMode) {
|
||||
self.ansi_handler().clear_line(mode);
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self, mode: ClearMode) {
|
||||
self.ansi_handler().clear_screen(mode);
|
||||
}
|
||||
|
||||
fn clear_tabs(&mut self, mode: TabulationClearMode) {
|
||||
self.ansi_handler().clear_tabs(mode);
|
||||
}
|
||||
|
||||
fn reset_state(&mut self) {
|
||||
self.ansi_handler().reset_state();
|
||||
}
|
||||
|
||||
fn reverse_index(&mut self) -> ScrollDelta {
|
||||
let lines_scrolled = self.ansi_handler().reverse_index();
|
||||
self.rotate_selection(lines_scrolled);
|
||||
lines_scrolled
|
||||
}
|
||||
|
||||
fn terminal_attribute(&mut self, attr: Attr) {
|
||||
self.ansi_handler().terminal_attribute(attr);
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: Mode) {
|
||||
self.ansi_handler().set_mode(mode);
|
||||
}
|
||||
|
||||
fn unset_mode(&mut self, mode: Mode) {
|
||||
self.ansi_handler().unset_mode(mode);
|
||||
}
|
||||
|
||||
fn set_scrolling_region(&mut self, top: usize, bottom: Option<usize>) {
|
||||
self.ansi_handler().set_scrolling_region(top, bottom);
|
||||
}
|
||||
|
||||
fn set_keypad_application_mode(&mut self) {
|
||||
self.ansi_handler().set_keypad_application_mode();
|
||||
}
|
||||
|
||||
fn unset_keypad_application_mode(&mut self) {
|
||||
self.ansi_handler().unset_keypad_application_mode();
|
||||
}
|
||||
|
||||
fn set_active_charset(&mut self, index: CharsetIndex) {
|
||||
self.ansi_handler().set_active_charset(index);
|
||||
}
|
||||
|
||||
fn configure_charset(&mut self, index: CharsetIndex, charset: StandardCharset) {
|
||||
self.ansi_handler().configure_charset(index, charset);
|
||||
}
|
||||
|
||||
fn set_color(&mut self, index: usize, color: ColorU) {
|
||||
self.ansi_handler().set_color(index, color)
|
||||
}
|
||||
|
||||
fn dynamic_color_sequence<W: io::Write>(
|
||||
&mut self,
|
||||
writer: &mut W,
|
||||
code: u8,
|
||||
index: usize,
|
||||
terminator: &str,
|
||||
) {
|
||||
self.ansi_handler()
|
||||
.dynamic_color_sequence(writer, code, index, terminator);
|
||||
}
|
||||
|
||||
fn reset_color(&mut self, index: usize) {
|
||||
self.ansi_handler().reset_color(index);
|
||||
}
|
||||
|
||||
fn clipboard_store(&mut self, clipboard: u8, base64: &[u8]) {
|
||||
self.ansi_handler().clipboard_store(clipboard, base64);
|
||||
}
|
||||
|
||||
fn clipboard_load(&mut self, clipboard: u8, terminator: &str) {
|
||||
self.ansi_handler().clipboard_load(clipboard, terminator);
|
||||
}
|
||||
|
||||
fn decaln(&mut self) {
|
||||
self.ansi_handler().decaln();
|
||||
}
|
||||
|
||||
fn push_title(&mut self) {
|
||||
log::error!("Handler method AltScreen::push_title should never be called. This should be handled by TerminalModel.");
|
||||
}
|
||||
|
||||
fn pop_title(&mut self) {
|
||||
log::error!("Handler method AltScreen::pop_title should never be called. This should be handled by TerminalModel.");
|
||||
}
|
||||
|
||||
fn text_area_size_pixels<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().text_area_size_pixels(writer);
|
||||
}
|
||||
|
||||
fn text_area_size_chars<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().text_area_size_chars(writer);
|
||||
}
|
||||
|
||||
fn command_finished(&mut self, _: CommandFinishedValue) {}
|
||||
|
||||
fn precmd(&mut self, _: PrecmdValue) {}
|
||||
|
||||
fn preexec(&mut self, _: PreexecValue) {}
|
||||
|
||||
fn on_finish_byte_processing(&mut self, input: &ansi::ProcessorInput<'_>) {
|
||||
self.ansi_handler().on_finish_byte_processing(input);
|
||||
}
|
||||
|
||||
fn on_reset_grid(&mut self) {
|
||||
self.ansi_handler().on_reset_grid();
|
||||
}
|
||||
|
||||
fn handle_completed_iterm_image(&mut self, image: ITermImage) {
|
||||
self.ansi_handler().handle_completed_iterm_image(image)
|
||||
}
|
||||
|
||||
fn handle_completed_kitty_action(
|
||||
&mut self,
|
||||
action: KittyAction,
|
||||
metadata: &mut HashMap<u32, StoredImageMetadata>,
|
||||
) -> Option<KittyResponse> {
|
||||
self.ansi_handler()
|
||||
.handle_completed_kitty_action(action, metadata)
|
||||
}
|
||||
|
||||
fn set_keyboard_enhancement_flags(
|
||||
&mut self,
|
||||
mode: KeyboardModes,
|
||||
apply: KeyboardModesApplyBehavior,
|
||||
) {
|
||||
self.ansi_handler()
|
||||
.set_keyboard_enhancement_flags(mode, apply);
|
||||
}
|
||||
|
||||
fn push_keyboard_enhancement_flags(&mut self, mode: KeyboardModes) {
|
||||
self.ansi_handler().push_keyboard_enhancement_flags(mode);
|
||||
}
|
||||
|
||||
fn pop_keyboard_enhancement_flags(&mut self, count: u16) {
|
||||
self.ansi_handler().pop_keyboard_enhancement_flags(count);
|
||||
}
|
||||
|
||||
fn query_keyboard_enhancement_flags<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().query_keyboard_enhancement_flags(writer);
|
||||
}
|
||||
}
|
||||
|
||||
impl Dimensions for AltScreen {
|
||||
#[inline]
|
||||
fn total_rows(&self) -> usize {
|
||||
self.grid_handler.total_rows()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visible_rows(&self) -> usize {
|
||||
self.grid_handler.visible_rows()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn columns(&self) -> usize {
|
||||
self.grid_handler.columns()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "alt_screen_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,101 @@
|
||||
use warpui::units::IntoLines as _;
|
||||
|
||||
use super::*;
|
||||
use crate::terminal::SizeUpdateReason;
|
||||
|
||||
fn default_size() -> SizeInfo {
|
||||
SizeInfo::new_without_font_metrics(10, 7)
|
||||
}
|
||||
|
||||
fn new_alt_screen(size: SizeInfo) -> AltScreen {
|
||||
AltScreen::new(
|
||||
size,
|
||||
1_000, /* max_scroll_limit */
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alt_screen_resize_clears_selection() {
|
||||
let original_size = default_size();
|
||||
let mut screen = new_alt_screen(original_size);
|
||||
|
||||
screen.start_selection(Point::new(2, 6), SelectionType::Simple, Side::Left);
|
||||
screen.update_selection(Point::new(4, 4), Side::Right);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
assert_eq!(
|
||||
screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point::new(2, 6),
|
||||
end: Point::new(4, 4),
|
||||
reversed: false,
|
||||
})
|
||||
);
|
||||
|
||||
// Resizing with same number of rows / cols should not clear selections.
|
||||
let size_update = SizeUpdate {
|
||||
update_reason: SizeUpdateReason::Refresh,
|
||||
last_size: original_size,
|
||||
new_size: original_size,
|
||||
new_gap_height: None,
|
||||
natural_rows: original_size.rows(),
|
||||
natural_cols: original_size.columns(),
|
||||
};
|
||||
screen.resize(&size_update);
|
||||
assert_eq!(
|
||||
screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point::new(2, 6),
|
||||
end: Point::new(4, 4),
|
||||
reversed: false,
|
||||
})
|
||||
);
|
||||
|
||||
// Resizing with diff size should clear selections.
|
||||
let new_size =
|
||||
default_size().with_rows_and_columns(original_size.rows() + 1, original_size.columns() + 1);
|
||||
let size_update = SizeUpdate {
|
||||
update_reason: SizeUpdateReason::Refresh,
|
||||
last_size: original_size,
|
||||
new_size,
|
||||
new_gap_height: None,
|
||||
natural_rows: new_size.rows(),
|
||||
natural_cols: new_size.columns(),
|
||||
};
|
||||
screen.resize(&size_update);
|
||||
assert!(screen.selection_range(&semantic_selection).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alt_screen_single_cell_selection() {
|
||||
let mut screen = new_alt_screen(default_size());
|
||||
|
||||
screen.start_selection(Point::new(2, 6), SelectionType::Simple, Side::Left);
|
||||
screen.update_selection(Point::new(2, 7), Side::Left);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
assert_eq!(
|
||||
screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point::new(2, 6),
|
||||
end: Point::new(2, 6),
|
||||
reversed: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accumulate_lines_to_scroll() {
|
||||
let mut screen = new_alt_screen(default_size());
|
||||
|
||||
assert_eq!(screen.accumulate_lines_to_scroll(-0.9.into_lines()), 0);
|
||||
assert_lines_approx_eq!(screen.pending_lines_to_scroll(), -0.9);
|
||||
|
||||
assert_eq!(screen.accumulate_lines_to_scroll(-3.2.into_lines()), -4);
|
||||
assert_lines_approx_eq!(screen.pending_lines_to_scroll(), -0.1);
|
||||
|
||||
assert_eq!(screen.accumulate_lines_to_scroll(5.0.into_lines()), 4);
|
||||
assert_lines_approx_eq!(screen.pending_lines_to_scroll(), 0.9);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! This module is used to parse shell output that is ANSI-C quoted. This occurs when `printf` is
|
||||
//! used with the argument `%q` and escapes non-printable characters with the POSIX
|
||||
//! `$''` syntax.
|
||||
//! For more information, see
|
||||
//! https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html.
|
||||
|
||||
/// Determines if the given shell output is ANSI-C quoted.
|
||||
pub(super) fn is_ansi_c_quoted(s: &str) -> bool {
|
||||
s.starts_with("$\'") && s.ends_with("\'")
|
||||
}
|
||||
|
||||
/// Unescapes shell output that has is ANSI-C quoted (i.e. of the form `$''`).
|
||||
/// This function guarantees that non-escaped output will be preserved, but ill-formatted escape
|
||||
/// sequences may not be preserved.
|
||||
pub(super) fn parse_ansi_c_quoted_string(quoted_string: String) -> String {
|
||||
// https://superuser.com/questions/301353/escape-non-printing-characters-in-a-function-for-a-bash-prompt
|
||||
|
||||
// In bash, we trigger an empty block to get the shell to repaint the prompt.
|
||||
if quoted_string.trim().is_empty() {
|
||||
return quoted_string;
|
||||
}
|
||||
log::debug!("Attempting to parse the following ANSI C escaped shell output: {quoted_string}");
|
||||
|
||||
let Some(quoted_string_without_prefix) = quoted_string.strip_prefix("$\'") else {
|
||||
log::warn!("Tried to parse ANSI-C quoted string but $\' prefix was not present");
|
||||
return quoted_string;
|
||||
};
|
||||
let Some(quoted_string_trimmed) = quoted_string_without_prefix.strip_suffix("\'") else {
|
||||
log::warn!("Tried to parse ANSI-C quoted string but but \' suffix was not present");
|
||||
return quoted_string;
|
||||
};
|
||||
let mut unescaped_string = String::new();
|
||||
let mut char_indices_iter = quoted_string_trimmed.char_indices().peekable();
|
||||
while let Some((current_char_idx, current_char)) = char_indices_iter.next() {
|
||||
// First, we check for a character that isn't escaped.
|
||||
if current_char != '\\' {
|
||||
unescaped_string.push(current_char);
|
||||
continue;
|
||||
}
|
||||
|
||||
// From here, we assume that the sequence from the current character onwards is an escaped
|
||||
// sequence that needs to be un-escaped.
|
||||
// We also assume that each character is one byte for the sequence.
|
||||
|
||||
// Strip out ranges of non-visible characters.
|
||||
if matches!(
|
||||
quoted_string_trimmed.get(current_char_idx..current_char_idx + 4),
|
||||
Some("\\001") | Some("\\002")
|
||||
) {
|
||||
// Omit these from the result.
|
||||
char_indices_iter.nth(2); // Skips the next 3 chars.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for common two byte characters.
|
||||
let mut found_two_character_sequence = true;
|
||||
let Some((next_character_idx, next_character)) = char_indices_iter.next() else {
|
||||
log::warn!("Was parsing escaped sequence but unexpectedly ran out of characters");
|
||||
break;
|
||||
};
|
||||
// Adapted from https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html.
|
||||
match next_character {
|
||||
// alert (bell)
|
||||
'a' => unescaped_string.push('\x07'),
|
||||
// backspace
|
||||
'b' => unescaped_string.push('\x08'),
|
||||
// an escape character (not ANSI C)
|
||||
'E' | 'e' => unescaped_string.push('\x1b'),
|
||||
// form feed
|
||||
'f' => unescaped_string.push('\x0c'),
|
||||
'n' => unescaped_string.push('\n'),
|
||||
'r' => unescaped_string.push('\r'),
|
||||
't' => unescaped_string.push('\t'),
|
||||
// vertical tab
|
||||
'v' => unescaped_string.push('\x0b'),
|
||||
'\\' => unescaped_string.push('\\'),
|
||||
'\'' => unescaped_string.push('\''),
|
||||
'\"' => unescaped_string.push('\"'),
|
||||
'?' => unescaped_string.push('?'),
|
||||
_ => {
|
||||
found_two_character_sequence = false;
|
||||
}
|
||||
}
|
||||
|
||||
if found_two_character_sequence {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Instead of accumulating characters using the chars iterator, we assume that each
|
||||
// character is one byte. If a multi-byte character is present, then the sequence is
|
||||
// formatted incorrectly and this will fail.
|
||||
match next_character {
|
||||
// \xHH
|
||||
// the eight-bit character whose value is the hexadecimal value HH (one or two hex digits)
|
||||
'x' => {
|
||||
let start_idx = next_character_idx + next_character.len_utf8();
|
||||
let Some(hex_value) = quoted_string_trimmed.get(start_idx..start_idx + 2) else {
|
||||
log::warn!("Could not parse value of the form \\xHH");
|
||||
continue;
|
||||
};
|
||||
match u8::from_str_radix(hex_value, 16).map(|n| n as char) {
|
||||
Ok(c) => {
|
||||
unescaped_string.push(c);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Could not convert \\x{hex_value} into char: {err:#}");
|
||||
}
|
||||
}
|
||||
char_indices_iter.nth(1); // Skips the next 2 chars.
|
||||
}
|
||||
// \uHHHH
|
||||
// the Unicode (ISO/IEC 10646) character whose value is the hexadecimal value HHHH (one to four hex digits)
|
||||
'u' => {
|
||||
let start_idx = next_character_idx + next_character.len_utf8();
|
||||
let Some(hex_value) = quoted_string_trimmed.get(start_idx..start_idx + 4) else {
|
||||
log::warn!("Could not parse value of the form \\uHHHH");
|
||||
continue;
|
||||
};
|
||||
match u32::from_str_radix(hex_value, 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
{
|
||||
Some(c) => {
|
||||
unescaped_string.push(c);
|
||||
}
|
||||
None => {
|
||||
log::warn!("Could not convert \\x{hex_value} into char");
|
||||
}
|
||||
}
|
||||
char_indices_iter.nth(3); // Skips the next 4 chars.
|
||||
}
|
||||
// \UHHHHHHHH
|
||||
// the Unicode (ISO/IEC 10646) character whose value is the hexadecimal value HHHHHHHH (one to eight hex digits)
|
||||
'U' => {
|
||||
let start_idx = next_character_idx + next_character.len_utf8();
|
||||
let Some(hex_value) = quoted_string_trimmed.get(start_idx..start_idx + 8) else {
|
||||
log::warn!("Could not parse value of the form \\uHHHH");
|
||||
continue;
|
||||
};
|
||||
match u32::from_str_radix(hex_value, 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
{
|
||||
Some(c) => {
|
||||
unescaped_string.push(c);
|
||||
}
|
||||
None => {
|
||||
log::warn!("Could not convert \\x{hex_value} into char");
|
||||
}
|
||||
}
|
||||
char_indices_iter.nth(7); // Skips the next 8 chars.
|
||||
}
|
||||
// \cx
|
||||
// a control-x character
|
||||
'c' => {
|
||||
if let Some(c) = char_indices_iter.next().map(|(_, c)| c) {
|
||||
// Control character must be within '@'..='_' or the DELETE character.
|
||||
if matches!(c, '\x40'..='\x5f' | '\x7f') {
|
||||
let control_character = ((c as u8) - 64) as char;
|
||||
unescaped_string.push(control_character);
|
||||
} else {
|
||||
log::warn!("Found invalid control character");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Could not get control character");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// We assume the input is of the form \nnn, which is the eight-bit character whose
|
||||
// value is the octal value nnn (one to three octal digits).
|
||||
if let Some(octal_value) =
|
||||
quoted_string_trimmed.get(next_character_idx..next_character_idx + 3)
|
||||
{
|
||||
match u32::from_str_radix(octal_value, 8)
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
{
|
||||
Some(c) => unescaped_string.push(c),
|
||||
None => {
|
||||
log::warn!("Could not parse value of the form \\nnn (octal value)");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("Could not get octal value");
|
||||
}
|
||||
// Skip the next 2 chars, since we've already consumed the first octal digit.
|
||||
char_indices_iter.nth(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unescaped_string
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ansi_c_decoder_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
use super::parse_ansi_c_quoted_string;
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1() {
|
||||
// This a "real-life" example of a PS1 sent from Git Bash with `printf %q`.
|
||||
let escaped_ps1 = "$'\\001\\E]0;MINGW64:/c/Users/abhis\\a\\002\\r\\n\\001\\E[32m\\002abhis@abhi-linux \\001\\E[35m\\002MINGW64 \\001\\E[33m\\002~\\001\\E[36m\\002\\001\\E[0m\\002\\r\\n$ '";
|
||||
assert_eq!(parse_ansi_c_quoted_string(escaped_ps1.to_string()), "\x1b]0;MINGW64:/c/Users/abhis\x07\r\n\x1b[32mabhis@abhi-linux \x1b[35mMINGW64 \x1b[33m~\x1b[36m\x1b[0m\r\n$ ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_cjk() {
|
||||
let escaped_ps1 = r#"$'\001\E]0;MINGW64:/c/Users/abhis/dev/你\a\002\r\n\001\E[32m\002abhis@abhi-linux \001\E[35m\002MINGW64 \001\E[33m\002~/dev/你\001\E[36m\002\001\E[0m\002\r\n$ '"#;
|
||||
assert_eq!(parse_ansi_c_quoted_string(escaped_ps1.to_string()), "\x1b]0;MINGW64:/c/Users/abhis/dev/你\x07\r\n\x1b[32mabhis@abhi-linux \x1b[35mMINGW64 \x1b[33m~/dev/你\x1b[36m\x1b[0m\r\n$ ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_empty() {
|
||||
let escaped_ps1 = "";
|
||||
assert_eq!(parse_ansi_c_quoted_string(escaped_ps1.to_string()), "");
|
||||
let escaped_ps1 = "$''";
|
||||
assert_eq!(parse_ansi_c_quoted_string(escaped_ps1.to_string()), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_common_characters() {
|
||||
let escaped_ps1 = "$'\\a \\b \\E \\e \\f \\n \\r \\t \\v \\\\ \\\' \\\" \\?'";
|
||||
assert_eq!(
|
||||
parse_ansi_c_quoted_string(escaped_ps1.to_string()),
|
||||
"\x07 \x08 \x1b \x1b \x0c \n \r \t \x0b \\ \' \" ?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_octal_sequences() {
|
||||
let escaped_ps1 = "$'\\101\\177'"; // Octal sequences
|
||||
assert_eq!(parse_ansi_c_quoted_string(escaped_ps1.to_string()), "A\x7f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_hex_sequences() {
|
||||
let escaped_ps1 = "$'\\x1b\\x7f'"; // Hex sequences
|
||||
assert_eq!(
|
||||
parse_ansi_c_quoted_string(escaped_ps1.to_string()),
|
||||
"\x1b\x7f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_unicode_4() {
|
||||
let escaped_ps1 = "$'\\u597D\\u4E0D\\u597D'";
|
||||
assert_eq!(
|
||||
parse_ansi_c_quoted_string(escaped_ps1.to_string()),
|
||||
"好不好"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_unicode_8() {
|
||||
let escaped_ps1 = "$'\\U0000597D'"; // Hex sequences
|
||||
assert_eq!(parse_ansi_c_quoted_string(escaped_ps1.to_string()), "好");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_mixed_content() {
|
||||
let escaped_ps1 = "$'Hello\\nWorld\\x21'"; // Mixed normal text and escapes
|
||||
assert_eq!(
|
||||
parse_ansi_c_quoted_string(escaped_ps1.to_string()),
|
||||
"Hello\nWorld!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_control_sequences() {
|
||||
let escaped_ps1 = "$'\\cA\\cZ'"; // Control characters
|
||||
assert_eq!(
|
||||
parse_ansi_c_quoted_string(escaped_ps1.to_string()),
|
||||
"\x01\x1A"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_ps1_invalid_sequences() {
|
||||
let escaped_ps1 = "$'\\z\\xZZ\\U3D\\'";
|
||||
// Instead of preserving the invalid escape sequences, we want to ensure that the algorithm
|
||||
// doesn't get stuck (i.e. the test finishes).
|
||||
let _ = parse_ansi_c_quoted_string(escaped_ps1.to_string());
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
//! This module defines the schema for DCS hooks sent from the shell to the Rust
|
||||
//! app -- for example, the payloads sent from shell precmd and preexec.
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use ordered_float::OrderedFloat;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use warp_core::command::ExitCode;
|
||||
|
||||
/// Indicates that the following JSON-encoded message is hex-encoded for Warp's lifecycle hooks.
|
||||
/// In DCS, it is used as the final char in the DCS start sequence.
|
||||
/// In OSC, it is used as the first parameter.
|
||||
pub(super) const HEX_ENCODED_JSON_MARKER: char = 'd';
|
||||
|
||||
/// Indicates that the following JSON-encoded message is unencoded for Warp's lifecycle hooks.
|
||||
/// In DCS, it is used as the final char in the DCS start sequence.
|
||||
/// In OSC, it is used as the first parameter.
|
||||
pub(super) const UNENCODED_JSON_MARKER: char = 'f';
|
||||
|
||||
/// Indicates that the following message is a ANSI-C quoted message for receiving Warp's lifecycle
|
||||
/// hooks via key-value pairs.
|
||||
/// In OSC< it is used as the first parameter.
|
||||
pub(super) const UNENCODED_KV_MARKER: char = 'k';
|
||||
|
||||
/// Enum representing all possible JSON payloads for Warp's DCS's.
|
||||
#[derive(Serialize, Debug, Deserialize)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[serde(tag = "hook")]
|
||||
pub(super) enum DProtoHook {
|
||||
CommandFinished {
|
||||
value: CommandFinishedValue,
|
||||
},
|
||||
Precmd {
|
||||
value: PrecmdValue,
|
||||
},
|
||||
Preexec {
|
||||
value: PreexecValue,
|
||||
},
|
||||
Bootstrapped {
|
||||
// This is wrapped in an `Box` to surpress clippy's large-enum-variant warning, not because it
|
||||
// functionally needs to be wrapped in an `Box`.
|
||||
value: Box<BootstrappedValue>,
|
||||
},
|
||||
PreInteractiveSSHSession {
|
||||
value: PreInteractiveSSHSessionValue,
|
||||
},
|
||||
SSH {
|
||||
value: SSHValue,
|
||||
},
|
||||
InitShell {
|
||||
value: InitShellValue,
|
||||
},
|
||||
InputBuffer {
|
||||
value: InputBufferValue,
|
||||
},
|
||||
Clear {
|
||||
value: ClearValue,
|
||||
},
|
||||
InitSubshell {
|
||||
value: InitSubshellValue,
|
||||
},
|
||||
SourcedRcFileForWarp {
|
||||
value: SourcedRcFileForWarpValue,
|
||||
},
|
||||
InitSsh {
|
||||
value: InitSshValue,
|
||||
},
|
||||
FinishUpdate {
|
||||
value: FinishUpdateValue,
|
||||
},
|
||||
RemoteWarpificationIsUnavailable {
|
||||
// If a value is provided, it's suggesting a way to install TMUX on the remote.
|
||||
value: WarpificationUnavailableReason,
|
||||
},
|
||||
SshTmuxInstaller {
|
||||
value: String,
|
||||
},
|
||||
TmuxInstallFailed {
|
||||
value: TmuxInstallFailedInfo,
|
||||
},
|
||||
ExitShell {
|
||||
value: ExitShellValue,
|
||||
},
|
||||
}
|
||||
|
||||
impl DProtoHook {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
DProtoHook::CommandFinished { .. } => "CommandFinished",
|
||||
DProtoHook::Precmd { .. } => "Precmd",
|
||||
DProtoHook::Preexec { .. } => "Preexec",
|
||||
DProtoHook::Bootstrapped { .. } => "Bootstrapped",
|
||||
DProtoHook::PreInteractiveSSHSession { .. } => "PreInteractiveSSHSession",
|
||||
DProtoHook::SSH { .. } => "SSH",
|
||||
DProtoHook::InitShell { .. } => "InitShell",
|
||||
DProtoHook::InputBuffer { .. } => "InputBuffer",
|
||||
DProtoHook::Clear { .. } => "Clear",
|
||||
DProtoHook::InitSubshell { .. } => "InitSubshell",
|
||||
DProtoHook::SourcedRcFileForWarp { .. } => "SourcedRcFileForWarp",
|
||||
DProtoHook::InitSsh { .. } => "InitSsh",
|
||||
DProtoHook::FinishUpdate { .. } => "FinishUpdate",
|
||||
DProtoHook::RemoteWarpificationIsUnavailable { .. } => {
|
||||
"RemoteWarpificationIsUnavailable"
|
||||
}
|
||||
DProtoHook::SshTmuxInstaller { .. } => "SshTmuxInstaller",
|
||||
DProtoHook::TmuxInstallFailed { .. } => "TmuxInstallFailed",
|
||||
DProtoHook::ExitShell { .. } => "ExitShell",
|
||||
}
|
||||
}
|
||||
|
||||
/// This function exists because there doesn't yet exist meaningful defaults for all shell
|
||||
/// hooks.
|
||||
pub fn default_from_name(hook: &str) -> Option<Self> {
|
||||
match hook {
|
||||
"CommandFinished" => Some(DProtoHook::CommandFinished {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"Precmd" => Some(DProtoHook::Precmd {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"Preexec" => Some(DProtoHook::Preexec {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"Bootstrapped" => Some(DProtoHook::Bootstrapped {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"PreInteractiveSSHSession" => Some(DProtoHook::PreInteractiveSSHSession {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"SSH" => Some(DProtoHook::SSH {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"InitShell" => Some(DProtoHook::InitShell {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"InputBuffer" => Some(DProtoHook::InputBuffer {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"Clear" => Some(DProtoHook::Clear {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"InitSubshell" => Some(DProtoHook::InitSubshell {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"SourcedRcFileForWarp" => Some(DProtoHook::SourcedRcFileForWarp {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"InitSsh" => Some(DProtoHook::InitSsh {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"FinishUpdate" => Some(DProtoHook::FinishUpdate {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"SshTmuxInstaller" => Some(DProtoHook::SshTmuxInstaller {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"TmuxInstallFailed" => Some(DProtoHook::TmuxInstallFailed {
|
||||
value: Default::default(),
|
||||
}),
|
||||
"ExitShell" => Some(DProtoHook::ExitShell {
|
||||
value: Default::default(),
|
||||
}),
|
||||
_ => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"We do not yet support receiving the {hook} hook via key-value pairs"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates a field of the hook's `value` with the given key-value pair.
|
||||
pub fn populate_field(&mut self, key: String, v: String) {
|
||||
let map_empty_to_none = |s: String| {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.to_string())
|
||||
}
|
||||
};
|
||||
match self {
|
||||
DProtoHook::CommandFinished { value } => match key.as_ref() {
|
||||
"exit_code" => value.exit_code = v.parse::<i32>().unwrap_or_default().into(),
|
||||
"next_block_id" => {
|
||||
value.next_block_id = v.to_string().into();
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field to CommandFinished");
|
||||
}
|
||||
},
|
||||
DProtoHook::Precmd { value } => match key.as_ref() {
|
||||
"pwd" => {
|
||||
value.pwd = map_empty_to_none(v);
|
||||
}
|
||||
"ps1" => {
|
||||
value.ps1 = map_empty_to_none(v);
|
||||
}
|
||||
"ps1_is_encoded" => value.ps1_is_encoded = v.parse::<bool>().ok(),
|
||||
"honor_ps1" => value.honor_ps1 = v.parse::<bool>().ok(),
|
||||
"rprompt" => {
|
||||
value.rprompt = map_empty_to_none(v);
|
||||
}
|
||||
"git_head" => {
|
||||
value.git_head = map_empty_to_none(v);
|
||||
}
|
||||
"git_branch" => {
|
||||
value.git_branch = map_empty_to_none(v);
|
||||
}
|
||||
"virtual_env" => {
|
||||
value.virtual_env = map_empty_to_none(v);
|
||||
}
|
||||
"conda_env" => {
|
||||
value.conda_env = map_empty_to_none(v);
|
||||
}
|
||||
"kube_config" => {
|
||||
value.kube_config = map_empty_to_none(v);
|
||||
}
|
||||
"session_id" => value.session_id = v.parse::<u64>().ok(),
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to Precmd");
|
||||
}
|
||||
},
|
||||
DProtoHook::InitShell { value } => match key.as_ref() {
|
||||
"session_id" => {
|
||||
value.session_id = v
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|id| id.into())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
"shell" => {
|
||||
value.shell = v;
|
||||
}
|
||||
"is_subshell" => {
|
||||
value.is_subshell = v.parse::<bool>().ok().unwrap_or_default();
|
||||
}
|
||||
"user" => {
|
||||
value.user = trim_null_byte(v);
|
||||
}
|
||||
"hostname" => {
|
||||
value.hostname = trim_null_byte(v);
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to InitShell");
|
||||
}
|
||||
},
|
||||
DProtoHook::Bootstrapped { value } => match key.as_ref() {
|
||||
"histfile" => {
|
||||
value.histfile = map_empty_to_none(v);
|
||||
}
|
||||
"shell" => value.shell = trim_null_byte(v),
|
||||
"home_dir" => value.home_dir = map_empty_to_none(v),
|
||||
"path" => {
|
||||
value.path = map_empty_to_none(v);
|
||||
}
|
||||
"editor" => {
|
||||
value.editor = map_empty_to_none(v);
|
||||
}
|
||||
"aliases" => {
|
||||
value.aliases = map_empty_to_none(v);
|
||||
}
|
||||
"abbreviations" => {
|
||||
value.abbreviations = map_empty_to_none(v);
|
||||
}
|
||||
"function_names" => {
|
||||
value.function_names = map_empty_to_none(v);
|
||||
}
|
||||
"env_var_names" => {
|
||||
value.env_var_names = map_empty_to_none(v);
|
||||
}
|
||||
"builtins" => {
|
||||
value.builtins = map_empty_to_none(v);
|
||||
}
|
||||
"keywords" => {
|
||||
value.keywords = map_empty_to_none(v);
|
||||
}
|
||||
"shell_version" => {
|
||||
value.shell_version = map_empty_to_none(v);
|
||||
}
|
||||
"shell_options" => {
|
||||
value.shell_options = Some(parse_shell_options_list(v));
|
||||
}
|
||||
"rcfiles_start_time" => {
|
||||
value.rcfiles_start_time = parse_float_from_string(v);
|
||||
}
|
||||
"rcfiles_end_time" => {
|
||||
value.rcfiles_end_time = parse_float_from_string(v);
|
||||
}
|
||||
"shell_plugins" => {
|
||||
value.shell_plugins = Some(parse_shell_options_list(v));
|
||||
}
|
||||
"vi_mode_enabled" => {
|
||||
value.vi_mode_enabled = map_empty_to_none(v);
|
||||
}
|
||||
"os_category" => {
|
||||
value.os_category = map_empty_to_none(v);
|
||||
}
|
||||
"linux_distribution" => {
|
||||
value.linux_distribution = map_empty_to_none(v);
|
||||
}
|
||||
"wsl_name" => {
|
||||
value.wsl_name = map_empty_to_none(v);
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to Bootstrapped hook");
|
||||
}
|
||||
},
|
||||
DProtoHook::Preexec { value } => match key.as_ref() {
|
||||
"command" => {
|
||||
value.command = v;
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to Preexec hook");
|
||||
}
|
||||
},
|
||||
DProtoHook::Clear { .. } => {
|
||||
log::warn!("Tried to add unknown field {key} to Clear hook");
|
||||
}
|
||||
DProtoHook::FinishUpdate { value } => match key.as_ref() {
|
||||
"update_id" => {
|
||||
value.update_id = v;
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to FinishUpdate hook");
|
||||
}
|
||||
},
|
||||
DProtoHook::InputBuffer { value } => match key.as_ref() {
|
||||
"buffer" => {
|
||||
value.buffer = v;
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to InputBuffer hook");
|
||||
}
|
||||
},
|
||||
DProtoHook::ExitShell { value } => match key.as_ref() {
|
||||
"session_id" => {
|
||||
value.session_id = v.parse::<u64>().ok().map(Into::into).unwrap_or_default()
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Tried to add unknown field {key} to ExitShell hook");
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"Populating fields of the {} hook is not yet supported via key-value pairs",
|
||||
self.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Details that help us determine which, if any, of our TMUX install scripts
|
||||
/// we should suggest to the user.
|
||||
#[derive(Clone, Debug, Deserialize, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct SystemDetails {
|
||||
#[serde(alias = "os")]
|
||||
pub operating_system: String,
|
||||
#[serde(alias = "pkg")]
|
||||
pub package_manager: String,
|
||||
pub shell: String,
|
||||
/// Is the user's home directory writable? This is None if we haven't gathered that
|
||||
/// information.
|
||||
pub writable_home: Option<bool>,
|
||||
}
|
||||
|
||||
/// The reason that warpification was not available when the user tried
|
||||
/// to warpify.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all(serialize = "snake_case"))]
|
||||
pub enum WarpificationUnavailableReason {
|
||||
TmuxFailed,
|
||||
UnsupportedTmuxVersion {
|
||||
#[serde(flatten)]
|
||||
system_details: SystemDetails,
|
||||
},
|
||||
TmuxNotInstalled {
|
||||
#[serde(flatten)]
|
||||
system_details: SystemDetails,
|
||||
root_access: String,
|
||||
},
|
||||
UnsupportedShell {
|
||||
shell_name: String,
|
||||
},
|
||||
Timeout {
|
||||
is_tmux_install: bool,
|
||||
is_shell_detection: bool,
|
||||
#[serde(flatten)]
|
||||
system_details: Option<SystemDetails>,
|
||||
},
|
||||
TmuxInstallFailed {
|
||||
#[serde(flatten)]
|
||||
system_details: Option<SystemDetails>,
|
||||
line: Option<String>,
|
||||
command: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct TmuxInstallFailedInfo {
|
||||
pub line: String,
|
||||
pub command: String,
|
||||
}
|
||||
/// Received from the pty when a command has finished executing.
|
||||
#[derive(Debug, Deserialize, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct CommandFinishedValue {
|
||||
pub exit_code: ExitCode,
|
||||
pub next_block_id: BlockId,
|
||||
}
|
||||
|
||||
/// Received from the pty at precmd.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrecmdValue {
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub pwd: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub ps1: Option<String>,
|
||||
|
||||
pub ps1_is_encoded: Option<bool>,
|
||||
|
||||
pub honor_ps1: Option<bool>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub rprompt: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub git_head: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub git_branch: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub virtual_env: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub conda_env: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub node_version: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub kube_config: Option<String>,
|
||||
|
||||
pub session_id: Option<u64>,
|
||||
|
||||
/// Whether this PrecmdValue was emitted after the completion of an in-band command.
|
||||
#[serde(default)]
|
||||
pub is_after_in_band_command: bool,
|
||||
}
|
||||
|
||||
impl PrecmdValue {
|
||||
/// Returns `true` if this PrecmdValue was emitted after the completion of an in-band command.
|
||||
///
|
||||
/// This relies on the assumption that the warp_precmd shell function (responsible for writing
|
||||
/// this to the PTY from the shell) does not populate `pwd` or `ps1` when the previous command
|
||||
/// was an in-band command; for all other cases these fields should always be populated.
|
||||
pub fn was_sent_after_in_band_command(&self) -> bool {
|
||||
self.is_after_in_band_command || (self.pwd.is_none() && self.ps1.is_none())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PrecmdValue {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pwd: Default::default(),
|
||||
ps1: Default::default(),
|
||||
// By default, we assume that we are using hex-encoding.
|
||||
ps1_is_encoded: Some(true),
|
||||
honor_ps1: Default::default(),
|
||||
rprompt: Default::default(),
|
||||
git_head: Default::default(),
|
||||
git_branch: Default::default(),
|
||||
virtual_env: Default::default(),
|
||||
conda_env: Default::default(),
|
||||
node_version: Default::default(),
|
||||
kube_config: Default::default(),
|
||||
session_id: Default::default(),
|
||||
is_after_in_band_command: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Received from the pty at preexec.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct PreexecValue {
|
||||
/// The command for which this preexec hook is emitted.
|
||||
///
|
||||
/// For Bash specifically, this command may not be the entire command string (it will only
|
||||
/// include up to the first job control indicator, e.g. '|', '&&'). This is due to a
|
||||
/// shortcoming of the bash_preexec library we use to simulate preexec hooks in bash.
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// Received from the pty after the shell has finished executing Warp's
|
||||
/// bootstrap script.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct BootstrappedValue {
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub histfile: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "trim_null_byte_deserializer", default)]
|
||||
pub shell: String,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub home_dir: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub path: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub editor: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub aliases: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub abbreviations: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub function_names: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub env_var_names: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub builtins: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub keywords: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none")]
|
||||
pub shell_version: Option<String>,
|
||||
|
||||
/// A list of options enabled for the shell by the end of bootstrap. Will
|
||||
/// be None if the shell doesn't support listing options via builtin.
|
||||
#[serde(deserialize_with = "parse_shell_options_list_deserializer", default)]
|
||||
pub shell_options: Option<HashSet<String>>,
|
||||
|
||||
/// The time at which we started sourcing the user rcfiles, measured in
|
||||
/// seconds since epoch.
|
||||
#[serde(deserialize_with = "parse_float_from_string_deserializer", default)]
|
||||
pub rcfiles_start_time: Option<OrderedFloat<f64>>,
|
||||
|
||||
/// The time at which we finished sourcing the user rcfiles, measured in
|
||||
/// seconds since epoch.
|
||||
#[serde(deserialize_with = "parse_float_from_string_deserializer", default)]
|
||||
pub rcfiles_end_time: Option<OrderedFloat<f64>>,
|
||||
|
||||
/// Tags for known shell configurations/plugins, especially ones that are
|
||||
/// incompatible with Warp.
|
||||
#[serde(deserialize_with = "parse_shell_options_list_deserializer", default)]
|
||||
pub shell_plugins: Option<HashSet<String>>,
|
||||
|
||||
/// Whether the shell's native vi mode implementation is on.
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub vi_mode_enabled: Option<String>,
|
||||
|
||||
/// The operating system category (e.g. MacOS, Linux, Windows).
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub os_category: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub linux_distribution: Option<String>,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub wsl_name: Option<String>,
|
||||
|
||||
/// The full path to the running shell binary (e.g. "/usr/bin/zsh").
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub shell_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Custom serde deserializer that parses a float from a string.
|
||||
fn parse_float_from_string_deserializer<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<OrderedFloat<f64>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(
|
||||
s.parse::<f64>().map_err(serde::de::Error::custom)?.into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_float_from_string(s: String) -> Option<OrderedFloat<f64>> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.parse::<f64>().map(|f| f.into()).ok()
|
||||
}
|
||||
|
||||
/// Received from the pty when Warp's SSH wrapper is executed, prior to
|
||||
/// bootstrapping the SSH session.
|
||||
#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize, Clone)]
|
||||
pub struct PreInteractiveSSHSessionValue {}
|
||||
|
||||
/// Received from the pty after establishing an SSH connection, prior to
|
||||
/// bootstrapping the session.
|
||||
#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize, Clone)]
|
||||
pub struct SSHValue {
|
||||
pub socket_path: PathBuf,
|
||||
pub remote_shell: String,
|
||||
}
|
||||
|
||||
/// Received from the pty after the shell session has been initialized, marking
|
||||
/// the shell ready to execute the bootstrap script.
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct InitShellValue {
|
||||
pub session_id: SessionId,
|
||||
|
||||
pub shell: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub is_subshell: bool,
|
||||
|
||||
#[serde(deserialize_with = "trim_null_byte_deserializer", default)]
|
||||
pub user: String,
|
||||
|
||||
#[serde(deserialize_with = "trim_null_byte_deserializer", default)]
|
||||
pub hostname: String,
|
||||
|
||||
#[serde(deserialize_with = "empty_string_is_none", default)]
|
||||
pub wsl_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Emitted as part of the new ssh session bootstrapping process.
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct InitSshValue {
|
||||
pub shell: String,
|
||||
pub uname: Option<String>,
|
||||
}
|
||||
|
||||
/// Emitted as part of the tmux bootstrapping process.
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct InitSubshellValue {
|
||||
pub shell: String,
|
||||
pub uname: Option<String>,
|
||||
}
|
||||
|
||||
/// Emitted by a snippet included in the user's RC file, which signals a new session is being
|
||||
/// created; if the session is for a subshell, this triggers Warp's bootstrap process.
|
||||
/// Otherwise, it's ignored.
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct SourcedRcFileForWarpValue {
|
||||
pub shell: String,
|
||||
pub uname: Option<String>,
|
||||
pub tmux: Option<bool>,
|
||||
}
|
||||
|
||||
/// Received from the pty via a shell line editor hook, whether readline (bash),
|
||||
/// ZLE, or the fish [command line editor](https://fishshell.com/docs/current/interactive.html#command-line-editor).
|
||||
/// The binding is triggered when Warp writes the `ESC-i` escape sequence to the pty.
|
||||
/// Warp usually does this after a block completes, to collect any typeahead
|
||||
/// that the user entered while the block was running (see
|
||||
/// [`TerminalView::request_input_buffer`]).
|
||||
#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
|
||||
pub struct InputBufferValue {
|
||||
pub buffer: String,
|
||||
}
|
||||
|
||||
/// Received from the pty when the terminal screen should be cleared (e.g. via
|
||||
/// the `clear` command or ctrl-l).
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ClearValue {}
|
||||
|
||||
/// Received from the pty when warp_finish_update is called at the end of an
|
||||
/// assisted auto-update.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct FinishUpdateValue {
|
||||
pub update_id: String,
|
||||
}
|
||||
|
||||
/// Received from the pty right before the remote shell exits (via `exit`,
|
||||
/// `logout`, Ctrl-D on an empty prompt, etc.). Lets the Warp client drop
|
||||
/// per-session resources — in particular the `ssh … remote-server-proxy`
|
||||
/// child process that holds a multiplexed channel on the foreground ssh
|
||||
/// ControlMaster — before the user's outer ssh tunnel tries to close, so
|
||||
/// the master can exit cleanly instead of hanging on orphaned slaves.
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ExitShellValue {
|
||||
pub session_id: SessionId,
|
||||
}
|
||||
|
||||
/// Custom serde deserializer that trims trailing null bytes.
|
||||
fn trim_null_byte_deserializer<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let trimmed = trim_null_byte(s);
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn trim_null_byte(s: String) -> String {
|
||||
s.trim().trim_end_matches(char::from(0)).to_owned()
|
||||
}
|
||||
|
||||
/// Custom serde deserializer that maps empty strings to none.
|
||||
fn empty_string_is_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let trimmed = trim_null_byte(s);
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_shell_options_list_deserializer<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<HashSet<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let options: HashSet<String> = parse_shell_options_list(s);
|
||||
Ok(Some(options))
|
||||
}
|
||||
|
||||
fn parse_shell_options_list(s: String) -> HashSet<String> {
|
||||
s.split_whitespace()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Represents a shell hook that will be constructed over time by receiving key-value pairs.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PendingHook {
|
||||
hook: DProtoHook,
|
||||
}
|
||||
|
||||
impl PendingHook {
|
||||
pub fn create(hook_name: &str) -> Option<Self> {
|
||||
DProtoHook::default_from_name(hook_name).map(|hook| Self { hook })
|
||||
}
|
||||
|
||||
/// Updates the field on the hook according to the given key-value pair.
|
||||
pub fn update(&mut self, key: String, mut value: String) {
|
||||
if super::is_ansi_c_quoted(&value) {
|
||||
value = super::parse_ansi_c_quoted_string(value);
|
||||
}
|
||||
self.hook.populate_field(key, value);
|
||||
}
|
||||
|
||||
pub(super) fn finish(self) -> DProtoHook {
|
||||
self.hook
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
|
||||
use warp_terminal::model::ansi::control_sequence_parameters::*;
|
||||
use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior};
|
||||
use warpui::color::ColorU;
|
||||
|
||||
use super::dcs_hooks::*;
|
||||
use super::ProcessorInput;
|
||||
use crate::terminal::model::completions::{ShellCompletion, ShellCompletionUpdate};
|
||||
use crate::terminal::model::image_map::StoredImageMetadata;
|
||||
use crate::terminal::model::iterm_image::{ITermImage, ITermImageMetadata};
|
||||
use crate::terminal::model::kitty::{KittyAction, KittyChunk, KittyResponse};
|
||||
use crate::terminal::model::terminal_model::TmuxInstallationState;
|
||||
use crate::terminal::model::{
|
||||
completions::ShellData as CompletionsShellData, index::VisibleRow, selection::ScrollDelta,
|
||||
tmux::ControlModeEvent,
|
||||
};
|
||||
|
||||
/// Trait to be implemented by model objects that handle pty output. The
|
||||
/// ansi::Performer (our pty output parser) delegates handling of specific
|
||||
/// actions (e.g. `set_title()`, `input()`) to a struct that implements this
|
||||
/// trait.
|
||||
///
|
||||
/// Default implementations are provided for some methods to reduce the amount
|
||||
/// of necessary boilerplate required for a testing-only implementation.
|
||||
pub trait Handler {
|
||||
/// OSC to set window title.
|
||||
fn set_title(&mut self, _: Option<String>);
|
||||
|
||||
/// Set the cursor style.
|
||||
fn set_cursor_style(&mut self, _: Option<CursorStyle>);
|
||||
|
||||
/// Set the cursor shape.
|
||||
fn set_cursor_shape(&mut self, _shape: CursorShape);
|
||||
|
||||
/// A character to be displayed.
|
||||
fn input(&mut self, _c: char);
|
||||
|
||||
/// Set cursor to position.
|
||||
fn goto(&mut self, _: VisibleRow, _: usize);
|
||||
|
||||
/// Set cursor to specific row.
|
||||
fn goto_line(&mut self, _: VisibleRow);
|
||||
|
||||
/// Set cursor to specific column.
|
||||
fn goto_col(&mut self, _: usize);
|
||||
|
||||
/// Insert blank characters in current line starting from cursor.
|
||||
fn insert_blank(&mut self, _: usize);
|
||||
|
||||
/// Move cursor up `rows`.
|
||||
fn move_up(&mut self, _: usize);
|
||||
|
||||
/// Move cursor down `rows`.
|
||||
fn move_down(&mut self, _: usize);
|
||||
|
||||
/// Identify the terminal (should write back to the pty stream).
|
||||
///
|
||||
/// TODO this should probably return an io::Result
|
||||
fn identify_terminal<W: io::Write>(&mut self, _: &mut W, _intermediate: Option<char>);
|
||||
|
||||
/// Report XTVERSION to uniquely identify terminal name and version (should write back to the pty stream).
|
||||
fn report_xtversion<W: io::Write>(&mut self, _: &mut W);
|
||||
|
||||
/// Report device status.
|
||||
fn device_status<W: io::Write>(&mut self, _: &mut W, _: usize);
|
||||
|
||||
/// Move cursor forward `cols`.
|
||||
fn move_forward(&mut self, _: usize);
|
||||
|
||||
/// Move cursor backward `cols`.
|
||||
fn move_backward(&mut self, _: usize);
|
||||
|
||||
/// Move cursor down `rows` and set to column 1.
|
||||
fn move_down_and_cr(&mut self, _: usize);
|
||||
|
||||
/// Move cursor up `rows` and set to column 1.
|
||||
fn move_up_and_cr(&mut self, _: usize);
|
||||
|
||||
/// Put `count` tabs.
|
||||
fn put_tab(&mut self, _count: u16);
|
||||
|
||||
/// Backspace `count` characters.
|
||||
fn backspace(&mut self);
|
||||
|
||||
/// Carriage return.
|
||||
fn carriage_return(&mut self);
|
||||
|
||||
/// Line feed.
|
||||
fn linefeed(&mut self) -> ScrollDelta;
|
||||
|
||||
/// Ring the bell.
|
||||
///
|
||||
/// Hopefully this is never implemented.
|
||||
fn bell(&mut self);
|
||||
|
||||
/// Substitute char under cursor.
|
||||
fn substitute(&mut self);
|
||||
|
||||
/// Newline.
|
||||
fn newline(&mut self);
|
||||
|
||||
/// Set current position as a tabstop.
|
||||
fn set_horizontal_tabstop(&mut self);
|
||||
|
||||
/// Scroll up `rows` rows.
|
||||
fn scroll_up(&mut self, _: usize) -> ScrollDelta;
|
||||
|
||||
/// Scroll down `rows` rows.
|
||||
fn scroll_down(&mut self, _: usize) -> ScrollDelta;
|
||||
|
||||
/// Insert `count` blank lines.
|
||||
fn insert_blank_lines(&mut self, _: usize) -> ScrollDelta;
|
||||
|
||||
/// Delete `count` lines.
|
||||
fn delete_lines(&mut self, _: usize) -> ScrollDelta;
|
||||
|
||||
/// Erase `count` chars in current line following cursor.
|
||||
///
|
||||
/// Erase means resetting to the default state (default colors, no content,
|
||||
/// no mode flags).
|
||||
fn erase_chars(&mut self, _: usize);
|
||||
|
||||
/// Delete `count` chars.
|
||||
///
|
||||
/// Deleting a character is like the delete key on the keyboard - everything
|
||||
/// to the right of the deleted things is shifted left.
|
||||
fn delete_chars(&mut self, _: usize);
|
||||
|
||||
/// Move backward `count` tabs.
|
||||
fn move_backward_tabs(&mut self, _count: u16);
|
||||
|
||||
/// Move forward `count` tabs.
|
||||
fn move_forward_tabs(&mut self, _count: u16);
|
||||
|
||||
/// Save current cursor position.
|
||||
fn save_cursor_position(&mut self);
|
||||
|
||||
/// Restore cursor position.
|
||||
fn restore_cursor_position(&mut self);
|
||||
|
||||
/// Clear current line.
|
||||
fn clear_line(&mut self, _mode: LineClearMode);
|
||||
|
||||
/// Clear screen.
|
||||
fn clear_screen(&mut self, _mode: ClearMode);
|
||||
|
||||
/// Clear tab stops.
|
||||
fn clear_tabs(&mut self, _mode: TabulationClearMode);
|
||||
|
||||
/// Reset terminal state.
|
||||
fn reset_state(&mut self);
|
||||
|
||||
/// Reverse Index.
|
||||
///
|
||||
/// Move the active position to the same horizontal position on the
|
||||
/// preceding line. If the active position is at the top margin, a scroll
|
||||
/// down is performed.
|
||||
fn reverse_index(&mut self) -> ScrollDelta;
|
||||
|
||||
/// Set a terminal attribute.
|
||||
fn terminal_attribute(&mut self, _attr: Attr);
|
||||
|
||||
/// Set mode.
|
||||
fn set_mode(&mut self, _mode: Mode);
|
||||
|
||||
/// Unset mode.
|
||||
fn unset_mode(&mut self, _: Mode);
|
||||
|
||||
/// Set keyboard enhancement flags (Kitty keyboard protocol: CSI = flags u).
|
||||
fn set_keyboard_enhancement_flags(
|
||||
&mut self,
|
||||
mode: KeyboardModes,
|
||||
apply: KeyboardModesApplyBehavior,
|
||||
);
|
||||
|
||||
/// Push keyboard enhancement flags (Kitty keyboard protocol: CSI > flags u).
|
||||
fn push_keyboard_enhancement_flags(&mut self, mode: KeyboardModes);
|
||||
|
||||
/// Pop keyboard enhancement flags (Kitty keyboard protocol: CSI < flags u).
|
||||
fn pop_keyboard_enhancement_flags(&mut self, count: u16);
|
||||
|
||||
/// Query keyboard enhancement flags (Kitty keyboard protocol: CSI ? u).
|
||||
/// Should respond with CSI ? flags u where flags is the currently enabled flags.
|
||||
fn query_keyboard_enhancement_flags<W: io::Write>(&mut self, writer: &mut W);
|
||||
|
||||
/// DECSTBM - Set the terminal scrolling region.
|
||||
fn set_scrolling_region(&mut self, _top: usize, _bottom: Option<usize>);
|
||||
|
||||
/// DECKPAM - Set keypad to applications mode (ESCape instead of digits).
|
||||
fn set_keypad_application_mode(&mut self);
|
||||
|
||||
/// DECKPNM - Set keypad to numeric mode (digits instead of ESCape seq).
|
||||
fn unset_keypad_application_mode(&mut self);
|
||||
|
||||
/// Set one of the graphic character sets, G0 to G3, as the active charset.
|
||||
///
|
||||
/// 'Invoke' one of G0 to G3 in the GL area. Also referred to as shift in,
|
||||
/// shift out and locking shift depending on the set being activated.
|
||||
fn set_active_charset(&mut self, _: CharsetIndex);
|
||||
|
||||
/// Assign a graphic character set to G0, G1, G2 or G3.
|
||||
///
|
||||
/// 'Designate' a graphic character set as one of G0 to G3, so that it can
|
||||
/// later be 'invoked' by `set_active_charset`.
|
||||
fn configure_charset(&mut self, _: CharsetIndex, _: StandardCharset);
|
||||
|
||||
/// Set an indexed color value.
|
||||
fn set_color(&mut self, _: usize, _: ColorU);
|
||||
|
||||
/// Write a foreground/background color escape sequence with the current color.
|
||||
fn dynamic_color_sequence<W: io::Write>(&mut self, _: &mut W, _: u8, _: usize, _: &str);
|
||||
|
||||
/// Reset an indexed color to original value.
|
||||
fn reset_color(&mut self, _: usize);
|
||||
|
||||
/// Store data into clipboard.
|
||||
fn clipboard_store(&mut self, _: u8, _: &[u8]);
|
||||
|
||||
/// Load data from clipboard.
|
||||
fn clipboard_load(&mut self, _: u8, _: &str);
|
||||
|
||||
/// Run the decaln routine.
|
||||
fn decaln(&mut self);
|
||||
|
||||
/// Push a title onto the stack.
|
||||
fn push_title(&mut self);
|
||||
|
||||
/// Pop the last title from the stack.
|
||||
fn pop_title(&mut self);
|
||||
|
||||
/// Report text area size in pixels.
|
||||
fn text_area_size_pixels<W: io::Write>(&mut self, _: &mut W);
|
||||
|
||||
/// Report text area size in characters.
|
||||
fn text_area_size_chars<W: io::Write>(&mut self, _: &mut W);
|
||||
|
||||
/// Callback for the Warp CommandFinished hook.
|
||||
fn command_finished(&mut self, _data: CommandFinishedValue) {}
|
||||
|
||||
/// Process a prompt marker control sequence.
|
||||
fn prompt_marker(&mut self, _marker: PromptMarker) {}
|
||||
|
||||
/// Callback for the Warp precmd hook.
|
||||
fn precmd(&mut self, _data: PrecmdValue) {}
|
||||
|
||||
/// Callback for the Warp preexec hook.
|
||||
fn preexec(&mut self, _data: PreexecValue) {}
|
||||
|
||||
/// Callback for the Warp bootstrapped hook - called once when the shell is
|
||||
/// bootstrapped
|
||||
fn bootstrapped(&mut self, _data: BootstrappedValue) {}
|
||||
|
||||
/// Callback for the Warp pre-interactive SSH session hook - called once
|
||||
/// before initiating an interactive SSH session (either with or without the
|
||||
/// SSH wrapper).
|
||||
fn pre_interactive_ssh_session(&mut self, _data: PreInteractiveSSHSessionValue) {}
|
||||
|
||||
/// Callback for the Warp ssh hook - called once after successfully connecting to
|
||||
/// an SSH server
|
||||
fn ssh(&mut self, _data: SSHValue) {}
|
||||
|
||||
/// Callback for the terminal to initialize the shell by writing the bootstrap
|
||||
/// logic into the PTY
|
||||
fn init_shell(&mut self, _data: InitShellValue) {}
|
||||
|
||||
/// Callback for the Warp exit-shell hook — emitted by the remote shell right
|
||||
/// before it exits. Gives the Warp client a chance to drop per-session
|
||||
/// resources (e.g. the `ssh … remote-server-proxy` child) before the outer
|
||||
/// ssh tunnel starts tearing down, so its ControlMaster can exit cleanly
|
||||
/// rather than hanging on orphaned multiplexed channels.
|
||||
fn exit_shell(&mut self, _data: ExitShellValue) {}
|
||||
|
||||
/// Callback for the terminal to when user executes `clear` command.
|
||||
fn clear(&mut self, _data: ClearValue) {}
|
||||
|
||||
/// Callback for the terminal when the shell reports the current line editor
|
||||
/// input buffer (the reporting is itself triggered by Warp).
|
||||
fn input_buffer(&mut self, _data: InputBufferValue) {}
|
||||
|
||||
/// Callback emitted during the initialization process for subshells with where the shell type
|
||||
/// is initiall not known.
|
||||
fn init_subshell(&mut self, _data: InitSubshellValue) {}
|
||||
|
||||
/// Callback emitted when executing the user's RC file, which signals a new session is being
|
||||
/// created. If the session is for a subshell, this should triggers Warp's bootstrap process.
|
||||
/// Otherwise, it's ignored.
|
||||
fn sourced_rc_file(&mut self, _data: SourcedRcFileForWarpValue) {}
|
||||
|
||||
/// Callback emitted during the initialization process for ssh sessions
|
||||
fn init_ssh(&mut self, _data: InitSshValue) {}
|
||||
|
||||
/// Callback emitted to notify the app that we're ready to complete an
|
||||
/// assisted auto-update.
|
||||
fn finish_update(&mut self, _data: FinishUpdateValue) {}
|
||||
|
||||
/// Callback emitted from the warpify_ssh_session script if it's discovered
|
||||
/// that we can't warpify the remote session.
|
||||
fn remote_warpification_is_unavailable(&mut self, _data: WarpificationUnavailableReason) {}
|
||||
|
||||
/// How tmux was installed.
|
||||
fn notify_ssh_tmux_is_installed(&mut self, _tmux_installation: TmuxInstallationState) {}
|
||||
|
||||
fn tmux_install_failed(&mut self, _data: TmuxInstallFailedInfo) {}
|
||||
|
||||
/// Callback to handle an "in-band command output start" OSC.
|
||||
///
|
||||
/// Chars received via `handler::input()` represent the in-band command output itself.
|
||||
/// Subsequent non-printable chars (e.g. control sequences) should be handled normally.
|
||||
fn start_in_band_command_output(&mut self) {}
|
||||
|
||||
/// Callback to handle an "in-band command output end" OSC.
|
||||
///
|
||||
/// Marks the end of the in-band command output payload.
|
||||
fn end_in_band_command_output(&mut self, _from_osc_sequence: bool) {}
|
||||
|
||||
/// Hook that gets called upon processing a chunk of input from the PTY.
|
||||
/// Implementors can use this to perform any extra, one-off, logic with the
|
||||
/// input after it's been parsed.
|
||||
fn on_finish_byte_processing(&mut self, _input: &ProcessorInput<'_>) {}
|
||||
|
||||
/// Hook that gets called upon receiving a "Reset Grid" OSC from ConPTY.
|
||||
fn on_reset_grid(&mut self) {}
|
||||
|
||||
/// tmux control mode event
|
||||
fn tmux_control_mode_event(&mut self, _event: ControlModeEvent) {}
|
||||
|
||||
/// Callback that tells the terminal that the shell is ready to receive
|
||||
/// the string to run completions for.
|
||||
fn send_completions_prompt(&mut self) {}
|
||||
|
||||
/// Callback to handle the OSC for starting completions.
|
||||
///
|
||||
/// Depending on the output format, subsequent data from the PTY will be
|
||||
/// considered as completions output.
|
||||
fn start_completions_output(&mut self, _format: CompletionsShellData) {}
|
||||
|
||||
/// Callback to handle the OSC for finishing completions.
|
||||
///
|
||||
/// Marks the end of the in-band command output payload.
|
||||
fn end_completions_output(&mut self) {}
|
||||
|
||||
/// Callback invoked when we've received a _typed_ native completion result from the shell.
|
||||
/// This is a noop if we are in "raw" completions mode.
|
||||
fn on_completion_result_received(&mut self, _completion_result: ShellCompletion) {}
|
||||
|
||||
/// Update the last completion result with the metadata in [`ShellCompletionUpdate`].
|
||||
/// This is a noop if we are in "raw" completions mode.
|
||||
fn update_last_completion_result(&mut self, _completion_update: ShellCompletionUpdate) {}
|
||||
|
||||
/// Callback to handle the OSC to start receiving an iTerm image.
|
||||
/// This will either be a MultipartFile or File (legacy).
|
||||
/// This will take the metadata given by the first message.
|
||||
fn start_iterm_image_receiving(&mut self, _metadata: ITermImageMetadata) {}
|
||||
|
||||
/// Callback to handle the OSC to finish receiving an iTerm image.
|
||||
fn end_iterm_image_receiving(&mut self) {}
|
||||
|
||||
/// Callback to handle the OSC to receive a chunk of an iTerm image.
|
||||
fn on_iterm_image_data_received(&mut self, _image_data: &[u8]) {}
|
||||
|
||||
/// Callback to handle the fully transmitted iTerm image on a grid handler level.
|
||||
/// Returns whether or not the image was saved to memory.
|
||||
fn handle_completed_iterm_image(&mut self, _image: ITermImage) {}
|
||||
|
||||
/// Callback that tells the terminal to prepare for receiving the a shell hook via
|
||||
/// key-value pairs.
|
||||
fn start_receiving_hook(&mut self, _hook_name: String) {}
|
||||
|
||||
/// Callback that tells the terminal that the pending shell hook is done receiving key-value
|
||||
/// pairs.
|
||||
///
|
||||
/// Returns the pending shell hook.
|
||||
fn finish_receiving_hook(&mut self) -> Option<PendingHook> {
|
||||
None
|
||||
}
|
||||
|
||||
// Callback that tells the terminal to update the pending shell hook with a new key-value pair.
|
||||
fn update_hook(&mut self, _key: String, _value: String) {}
|
||||
|
||||
/// Callback to handle the APC to finish receiving a kitty action.
|
||||
fn end_kitty_action_receiving<W: io::Write>(&mut self, _writer: &mut W) {}
|
||||
|
||||
/// Callback to handle the APC to receive a chunk of a kitty action.
|
||||
fn on_kitty_image_chunk_received(&mut self, _chunk: KittyChunk) {}
|
||||
|
||||
/// Callback to handle the fully transmitted Kitty action on a grid handler level.
|
||||
fn handle_completed_kitty_action(
|
||||
&mut self,
|
||||
_action: KittyAction,
|
||||
_metadata: &mut HashMap<u32, StoredImageMetadata>,
|
||||
) -> Option<KittyResponse> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Callback for pluggable notifications triggered via OSC 9 or OSC 777 escape sequences.
|
||||
/// These allow external programs to trigger notifications in Warp.
|
||||
/// - OSC 9: Simple notification with just a body (iTerm2/Windows Terminal style)
|
||||
/// - OSC 777: Notification with title and body (urxvt style)
|
||||
fn pluggable_notification(&mut self, _title: Option<String>, _body: String) {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,887 @@
|
||||
use hex;
|
||||
use warp_core::command::ExitCode;
|
||||
use warpui::color::ColorU;
|
||||
|
||||
use super::*;
|
||||
use crate::terminal::model::index::VisibleRow;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::terminal::model::{ansi::InputBufferValue, selection::ScrollDelta};
|
||||
use std::{collections::HashSet, io, io::Write, path::PathBuf};
|
||||
|
||||
const HEX_ENCODED_JSON_DCS_START: &[u8] = &[0x1b, 0x50, 0x24, 0x64];
|
||||
const UNENCODED_JSON_DCS_START: &[u8] = &[0x1b, 0x50, 0x24, 0x66];
|
||||
const DCS_END: &[u8] = &[0x9c];
|
||||
|
||||
struct MockHandler {
|
||||
index: CharsetIndex,
|
||||
charset: StandardCharset,
|
||||
attr: Option<Attr>,
|
||||
identity_reported: bool,
|
||||
d_proto_hooks: Vec<DProtoHook>,
|
||||
pluggable_notifications: Vec<(Option<String>, String)>,
|
||||
}
|
||||
|
||||
impl Handler for MockHandler {
|
||||
fn terminal_attribute(&mut self, attr: Attr) {
|
||||
self.attr = Some(attr);
|
||||
}
|
||||
|
||||
fn configure_charset(&mut self, index: CharsetIndex, charset: StandardCharset) {
|
||||
self.index = index;
|
||||
self.charset = charset;
|
||||
}
|
||||
|
||||
fn set_active_charset(&mut self, index: CharsetIndex) {
|
||||
self.index = index;
|
||||
}
|
||||
|
||||
fn identify_terminal<W: io::Write>(&mut self, _: &mut W, _intermediate: Option<char>) {
|
||||
self.identity_reported = true;
|
||||
}
|
||||
|
||||
fn report_xtversion<W: io::Write>(&mut self, _: &mut W) {}
|
||||
|
||||
fn reset_state(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn set_title(&mut self, _: Option<String>) {}
|
||||
|
||||
fn set_cursor_style(&mut self, _: Option<super::CursorStyle>) {}
|
||||
|
||||
fn set_cursor_shape(&mut self, _shape: super::CursorShape) {}
|
||||
|
||||
fn input(&mut self, _c: char) {}
|
||||
|
||||
fn goto(&mut self, _: VisibleRow, _: usize) {}
|
||||
|
||||
fn goto_line(&mut self, _: VisibleRow) {}
|
||||
|
||||
fn goto_col(&mut self, _: usize) {}
|
||||
|
||||
fn insert_blank(&mut self, _: usize) {}
|
||||
|
||||
fn move_up(&mut self, _: usize) {}
|
||||
|
||||
fn move_down(&mut self, _: usize) {}
|
||||
|
||||
fn device_status<W: io::Write>(&mut self, _: &mut W, _: usize) {}
|
||||
|
||||
fn move_forward(&mut self, _: usize) {}
|
||||
|
||||
fn move_backward(&mut self, _: usize) {}
|
||||
|
||||
fn move_down_and_cr(&mut self, _: usize) {}
|
||||
|
||||
fn move_up_and_cr(&mut self, _: usize) {}
|
||||
|
||||
fn put_tab(&mut self, _count: u16) {}
|
||||
|
||||
fn backspace(&mut self) {}
|
||||
|
||||
fn carriage_return(&mut self) {}
|
||||
|
||||
fn linefeed(&mut self) -> ScrollDelta {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
|
||||
fn bell(&mut self) {}
|
||||
|
||||
fn substitute(&mut self) {}
|
||||
|
||||
fn newline(&mut self) {}
|
||||
|
||||
fn set_horizontal_tabstop(&mut self) {}
|
||||
|
||||
fn scroll_up(&mut self, _: usize) -> ScrollDelta {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, _: usize) -> ScrollDelta {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
|
||||
fn insert_blank_lines(&mut self, _: usize) -> ScrollDelta {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
|
||||
fn delete_lines(&mut self, _: usize) -> ScrollDelta {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
|
||||
fn erase_chars(&mut self, _: usize) {}
|
||||
|
||||
fn delete_chars(&mut self, _: usize) {}
|
||||
|
||||
fn move_backward_tabs(&mut self, _count: u16) {}
|
||||
|
||||
fn move_forward_tabs(&mut self, _count: u16) {}
|
||||
|
||||
fn save_cursor_position(&mut self) {}
|
||||
|
||||
fn restore_cursor_position(&mut self) {}
|
||||
|
||||
fn clear_line(&mut self, _mode: super::LineClearMode) {}
|
||||
|
||||
fn clear_screen(&mut self, _mode: super::ClearMode) {}
|
||||
|
||||
fn clear_tabs(&mut self, _mode: super::TabulationClearMode) {}
|
||||
|
||||
fn reverse_index(&mut self) -> ScrollDelta {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, _mode: super::Mode) {}
|
||||
|
||||
fn unset_mode(&mut self, _: super::Mode) {}
|
||||
|
||||
fn set_scrolling_region(&mut self, _top: usize, _bottom: Option<usize>) {}
|
||||
|
||||
fn set_keypad_application_mode(&mut self) {}
|
||||
|
||||
fn unset_keypad_application_mode(&mut self) {}
|
||||
|
||||
fn set_color(&mut self, _: usize, _: ColorU) {}
|
||||
|
||||
fn dynamic_color_sequence<W: io::Write>(&mut self, _: &mut W, _: u8, _: usize, _: &str) {}
|
||||
|
||||
fn reset_color(&mut self, _: usize) {}
|
||||
|
||||
fn clipboard_store(&mut self, _: u8, _: &[u8]) {}
|
||||
|
||||
fn clipboard_load(&mut self, _: u8, _: &str) {}
|
||||
|
||||
fn decaln(&mut self) {}
|
||||
|
||||
fn push_title(&mut self) {}
|
||||
|
||||
fn pop_title(&mut self) {}
|
||||
|
||||
fn text_area_size_pixels<W: io::Write>(&mut self, _: &mut W) {}
|
||||
|
||||
fn text_area_size_chars<W: io::Write>(&mut self, _: &mut W) {}
|
||||
|
||||
fn command_finished(&mut self, data: CommandFinishedValue) {
|
||||
self.d_proto_hooks
|
||||
.push(DProtoHook::CommandFinished { value: data });
|
||||
}
|
||||
|
||||
fn precmd(&mut self, data: PrecmdValue) {
|
||||
self.d_proto_hooks.push(DProtoHook::Precmd { value: data });
|
||||
}
|
||||
|
||||
fn preexec(&mut self, data: PreexecValue) {
|
||||
self.d_proto_hooks.push(DProtoHook::Preexec { value: data });
|
||||
}
|
||||
|
||||
fn bootstrapped(&mut self, data: BootstrappedValue) {
|
||||
self.d_proto_hooks.push(DProtoHook::Bootstrapped {
|
||||
value: Box::new(data),
|
||||
});
|
||||
}
|
||||
|
||||
fn pre_interactive_ssh_session(&mut self, data: PreInteractiveSSHSessionValue) {
|
||||
self.d_proto_hooks
|
||||
.push(DProtoHook::PreInteractiveSSHSession { value: data })
|
||||
}
|
||||
|
||||
fn ssh(&mut self, data: SSHValue) {
|
||||
self.d_proto_hooks.push(DProtoHook::SSH { value: data });
|
||||
}
|
||||
|
||||
fn init_shell(&mut self, data: InitShellValue) {
|
||||
self.d_proto_hooks
|
||||
.push(DProtoHook::InitShell { value: data });
|
||||
}
|
||||
|
||||
fn clear(&mut self, data: ClearValue) {
|
||||
self.d_proto_hooks.push(DProtoHook::Clear { value: data });
|
||||
}
|
||||
|
||||
fn input_buffer(&mut self, data: super::InputBufferValue) {
|
||||
self.d_proto_hooks
|
||||
.push(DProtoHook::InputBuffer { value: data })
|
||||
}
|
||||
|
||||
fn init_subshell(&mut self, data: InitSubshellValue) {
|
||||
self.d_proto_hooks
|
||||
.push(DProtoHook::InitSubshell { value: data })
|
||||
}
|
||||
|
||||
fn init_ssh(&mut self, data: InitSshValue) {
|
||||
self.d_proto_hooks.push(DProtoHook::InitSsh { value: data })
|
||||
}
|
||||
|
||||
fn sourced_rc_file(&mut self, data: SourcedRcFileForWarpValue) {
|
||||
self.d_proto_hooks
|
||||
.push(DProtoHook::SourcedRcFileForWarp { value: data })
|
||||
}
|
||||
|
||||
fn pluggable_notification(&mut self, title: Option<String>, body: String) {
|
||||
self.pluggable_notifications.push((title, body));
|
||||
}
|
||||
|
||||
fn set_keyboard_enhancement_flags(
|
||||
&mut self,
|
||||
_mode: KeyboardModes,
|
||||
_apply: KeyboardModesApplyBehavior,
|
||||
) {
|
||||
}
|
||||
|
||||
fn push_keyboard_enhancement_flags(&mut self, _mode: KeyboardModes) {}
|
||||
|
||||
fn pop_keyboard_enhancement_flags(&mut self, _count: u16) {}
|
||||
|
||||
fn query_keyboard_enhancement_flags<W: io::Write>(&mut self, _: &mut W) {}
|
||||
}
|
||||
|
||||
impl Default for MockHandler {
|
||||
fn default() -> MockHandler {
|
||||
MockHandler {
|
||||
index: CharsetIndex::G0,
|
||||
charset: StandardCharset::Ascii,
|
||||
attr: None,
|
||||
identity_reported: false,
|
||||
d_proto_hooks: Vec::new(),
|
||||
pluggable_notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_encoded_dcs_string(dcs_payload: &str) -> Vec<u8> {
|
||||
let encoded_dcs_string = hex::encode(dcs_payload).into_bytes();
|
||||
[HEX_ENCODED_JSON_DCS_START, &encoded_dcs_string, DCS_END].concat()
|
||||
}
|
||||
|
||||
fn parse_bytes(bytes: &[u8]) -> (Processor, MockHandler) {
|
||||
let mut parser = Processor::new();
|
||||
let mut handler = MockHandler::default();
|
||||
|
||||
parser.parse_bytes(&mut handler, bytes, &mut io::sink());
|
||||
|
||||
(parser, handler)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_control_attribute() {
|
||||
static BYTES: &[u8] = &[0x1b, b'[', b'1', b'm'];
|
||||
let (_, handler) = parse_bytes(BYTES);
|
||||
|
||||
assert_eq!(handler.attr, Some(Attr::Bold));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_identity_csi() {
|
||||
let bytes: &[u8] = &[0x1b, b'[', b'1', b'c'];
|
||||
|
||||
let (mut parser, mut handler) = parse_bytes(bytes);
|
||||
|
||||
assert!(!handler.identity_reported);
|
||||
handler.reset_state();
|
||||
|
||||
let bytes: &[u8] = &[0x1b, b'[', b'c'];
|
||||
|
||||
parser.parse_bytes(&mut handler, bytes, &mut io::sink());
|
||||
|
||||
assert!(handler.identity_reported);
|
||||
handler.reset_state();
|
||||
|
||||
let bytes: &[u8] = &[0x1b, b'[', b'0', b'c'];
|
||||
|
||||
parser.parse_bytes(&mut handler, bytes, &mut io::sink());
|
||||
|
||||
assert!(handler.identity_reported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_identity_esc() {
|
||||
let bytes: &[u8] = &[0x1b, b'Z'];
|
||||
|
||||
let (mut parser, mut handler) = parse_bytes(bytes);
|
||||
|
||||
assert!(handler.identity_reported);
|
||||
handler.reset_state();
|
||||
|
||||
let bytes: &[u8] = &[0x1b, b'#', b'Z'];
|
||||
|
||||
parser.parse_bytes(&mut handler, bytes, &mut io::sink());
|
||||
|
||||
assert!(!handler.identity_reported);
|
||||
handler.reset_state();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truecolor_attr() {
|
||||
static BYTES: &[u8] = &[
|
||||
0x1b, b'[', b'3', b'8', b';', b'2', b';', b'1', b'2', b'8', b';', b'6', b'6', b';', b'2',
|
||||
b'5', b'5', b'm',
|
||||
];
|
||||
|
||||
let (_, handler) = parse_bytes(BYTES);
|
||||
|
||||
let spec = ColorU::new(128, 66, 255, 0xff);
|
||||
|
||||
assert_eq!(handler.attr, Some(Attr::Foreground(Color::Spec(spec))));
|
||||
}
|
||||
|
||||
/// No exactly a test; useful for debugging.
|
||||
#[test]
|
||||
fn parse_zsh_startup() {
|
||||
static BYTES: &[u8] = &[
|
||||
0x1b, b'[', b'1', b'm', 0x1b, b'[', b'7', b'm', b'%', 0x1b, b'[', b'2', b'7', b'm', 0x1b,
|
||||
b'[', b'1', b'm', 0x1b, b'[', b'0', b'm', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ',
|
||||
b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ',
|
||||
b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ',
|
||||
b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ',
|
||||
b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ',
|
||||
b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b' ', b'\r', b' ', b'\r',
|
||||
b'\r', 0x1b, b'[', b'0', b'm', 0x1b, b'[', b'2', b'7', b'm', 0x1b, b'[', b'2', b'4', b'm',
|
||||
0x1b, b'[', b'J', b'j', b'w', b'i', b'l', b'm', b'@', b'j', b'w', b'i', b'l', b'm', b'-',
|
||||
b'd', b'e', b's', b'k', b' ', 0x1b, b'[', b'0', b'1', b';', b'3', b'2', b'm', 0xe2, 0x9e,
|
||||
0x9c, b' ', 0x1b, b'[', b'0', b'1', b';', b'3', b'2', b'm', b' ', 0x1b, b'[', b'3', b'6',
|
||||
b'm', b'~', b'/', b'c', b'o', b'd', b'e',
|
||||
];
|
||||
|
||||
parse_bytes(BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_designate_g0_as_line_drawing() {
|
||||
static BYTES: &[u8] = &[0x1b, b'(', b'0'];
|
||||
let (_, handler) = parse_bytes(BYTES);
|
||||
|
||||
assert_eq!(handler.index, CharsetIndex::G0);
|
||||
assert_eq!(
|
||||
handler.charset,
|
||||
StandardCharset::SpecialCharacterAndusizeDrawing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_designate_g1_as_line_drawing_and_invoke() {
|
||||
static BYTES: &[u8] = &[0x1b, b')', b'0', 0x0e];
|
||||
let (mut parser, handler) = parse_bytes(BYTES);
|
||||
|
||||
assert_eq!(handler.index, CharsetIndex::G1);
|
||||
assert_eq!(
|
||||
handler.charset,
|
||||
StandardCharset::SpecialCharacterAndusizeDrawing
|
||||
);
|
||||
|
||||
let mut handler = MockHandler::default();
|
||||
parser.parse_bytes(&mut handler, &[BYTES[3]], &mut io::sink());
|
||||
|
||||
assert_eq!(handler.index, CharsetIndex::G1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_rgb_colors() {
|
||||
assert_eq!(
|
||||
xparse_color(b"rgb:f/e/d"),
|
||||
Some(ColorU::new(0xff, 0xee, 0xdd, 0xff))
|
||||
);
|
||||
assert_eq!(
|
||||
xparse_color(b"rgb:11/aa/ff"),
|
||||
Some(ColorU::new(0x11, 0xaa, 0xff, 0xff))
|
||||
);
|
||||
assert_eq!(
|
||||
xparse_color(b"rgb:f/ed1/cb23"),
|
||||
Some(ColorU::new(0xff, 0xec, 0xca, 0xff))
|
||||
);
|
||||
assert_eq!(
|
||||
xparse_color(b"rgb:ffff/0/0"),
|
||||
Some(ColorU::new(0xff, 0x0, 0x0, 0xff))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_legacy_rgb_colors() {
|
||||
assert_eq!(
|
||||
xparse_color(b"#1af"),
|
||||
Some(ColorU::new(0x10, 0xa0, 0xf0, 0xff))
|
||||
);
|
||||
assert_eq!(
|
||||
xparse_color(b"#11aaff"),
|
||||
Some(ColorU::new(0x11, 0xaa, 0xff, 0xff))
|
||||
);
|
||||
assert_eq!(
|
||||
xparse_color(b"#110aa0ff0"),
|
||||
Some(ColorU::new(0x11, 0xaa, 0xff, 0xff))
|
||||
);
|
||||
assert_eq!(
|
||||
xparse_color(b"#1100aa00ff00"),
|
||||
Some(ColorU::new(0x11, 0xaa, 0xff, 0xff))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_rgb_colors() {
|
||||
assert_eq!(xparse_color(b"rgb:0//"), None);
|
||||
assert_eq!(xparse_color(b"rgb://///"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_legacy_rgb_colors() {
|
||||
assert_eq!(xparse_color(b"#"), None);
|
||||
assert_eq!(xparse_color(b"#f"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_number() {
|
||||
assert_eq!(parse_number(b"1abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_number() {
|
||||
assert_eq!(parse_number(b"123"), Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_number_too_large() {
|
||||
assert_eq!(parse_number(b"321"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_color_to_ansi_escape_valid() {
|
||||
assert!(matches!(NamedColor::Red.to_ansi_fg_escape_code(), Ok(31)));
|
||||
assert!(matches!(NamedColor::Red.to_ansi_bg_escape_code(), Ok(41)));
|
||||
assert!(matches!(
|
||||
NamedColor::BrightGreen.to_ansi_fg_escape_code(),
|
||||
Ok(92)
|
||||
));
|
||||
assert!(matches!(
|
||||
NamedColor::BrightBlue.to_ansi_bg_escape_code(),
|
||||
Ok(104)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_color_to_ansi_escape_invalid() {
|
||||
assert!(NamedColor::Background.to_ansi_fg_escape_code().is_err());
|
||||
assert!(NamedColor::Foreground.to_ansi_bg_escape_code().is_err());
|
||||
assert!(NamedColor::Cursor.to_ansi_bg_escape_code().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dcs_ssh() {
|
||||
let bytes = hex_encoded_dcs_string(
|
||||
r#"{
|
||||
"hook": "SSH",
|
||||
"value": {
|
||||
"socket_path": "~/.ssh/9001",
|
||||
"remote_shell": "zsh"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::SSH { value } => assert_eq!(
|
||||
*value,
|
||||
SSHValue {
|
||||
socket_path: PathBuf::from("~/.ssh/9001"),
|
||||
remote_shell: "zsh".to_string(),
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dcs_precmd() {
|
||||
let bytes = hex_encoded_dcs_string(
|
||||
r#"{
|
||||
"hook": "Precmd",
|
||||
"value": {
|
||||
"pwd": "/Users",
|
||||
"ps1": "$>",
|
||||
"honor_ps1": true,
|
||||
"git_head": "",
|
||||
"git_branch": "",
|
||||
"virtual_env": "",
|
||||
"conda_env": "numpy",
|
||||
"exit_code": 0,
|
||||
"session_id": 167303092612201
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::Precmd { value } => assert_eq!(
|
||||
*value,
|
||||
PrecmdValue {
|
||||
pwd: Some("/Users".to_string()),
|
||||
ps1: Some("$>".to_string()),
|
||||
honor_ps1: Some(true),
|
||||
rprompt: None,
|
||||
git_head: None,
|
||||
git_branch: None,
|
||||
virtual_env: None,
|
||||
conda_env: Some("numpy".to_string()),
|
||||
node_version: None,
|
||||
kube_config: None,
|
||||
session_id: Some(167303092612201),
|
||||
ps1_is_encoded: None,
|
||||
is_after_in_band_command: false,
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dcs_command_finished() {
|
||||
let bytes = hex_encoded_dcs_string(
|
||||
r#"{
|
||||
"hook": "CommandFinished",
|
||||
"value": {
|
||||
"exit_code": 127,
|
||||
"next_block_id": "block_id"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::CommandFinished { value } => {
|
||||
assert_eq!(
|
||||
*value,
|
||||
CommandFinishedValue {
|
||||
exit_code: ExitCode::from(127),
|
||||
next_block_id: "block_id".to_owned().into()
|
||||
}
|
||||
)
|
||||
}
|
||||
_ => panic!("incorrect dcs value"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dcs_bootstrapped() {
|
||||
let bytes = hex_encoded_dcs_string(
|
||||
r#"{
|
||||
"hook": "Bootstrapped",
|
||||
"value": {
|
||||
"histfile": "/Users/andy/.zsh_history",
|
||||
"session_id": 167303092612201,
|
||||
"shell": "bash",
|
||||
"home_dir": "/Users/andy",
|
||||
"user": "andy",
|
||||
"host": "ubuntu-test",
|
||||
"path": "/usr/sbin:/usr/bin",
|
||||
"editor": "vim",
|
||||
"aliases": "vi=nvim\nvim=nvim",
|
||||
"abbreviations": "abbr -a -- vi nvim\nabbr -a -- gc 'git checkout'",
|
||||
"env_var_names": "LOGNAME CARGO_HOME",
|
||||
"function_names": "cd\nextract",
|
||||
"builtins": "alias\nhistory",
|
||||
"keywords": "for\nif",
|
||||
"shell_version": "5.8.0",
|
||||
"shell_options": "alwaystoend\nautocd",
|
||||
"rcfiles_start_time": "1675789245.4744160175",
|
||||
"rcfiles_end_time": "1675789246.9067308903",
|
||||
"shell_plugins": "powerlevel10k pure",
|
||||
"shell_path": "/usr/local/bin/bash"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::Bootstrapped { value } => assert_eq!(
|
||||
**value,
|
||||
BootstrappedValue {
|
||||
histfile: Some("/Users/andy/.zsh_history".to_string()),
|
||||
shell: "bash".to_string(),
|
||||
home_dir: Some("/Users/andy".to_string()),
|
||||
path: Some("/usr/sbin:/usr/bin".to_string()),
|
||||
editor: Some("vim".to_string()),
|
||||
aliases: Some("vi=nvim\nvim=nvim".to_string()),
|
||||
abbreviations: Some("abbr -a -- vi nvim\nabbr -a -- gc 'git checkout'".to_string()),
|
||||
env_var_names: Some("LOGNAME CARGO_HOME".to_string()),
|
||||
function_names: Some("cd\nextract".to_string()),
|
||||
builtins: Some("alias\nhistory".to_string()),
|
||||
keywords: Some("for\nif".to_string()),
|
||||
shell_version: Some("5.8.0".to_string()),
|
||||
shell_options: Some(HashSet::from([
|
||||
"alwaystoend".to_string(),
|
||||
"autocd".to_string()
|
||||
])),
|
||||
shell_plugins: Some(HashSet::from([
|
||||
"powerlevel10k".to_string(),
|
||||
"pure".to_string()
|
||||
])),
|
||||
rcfiles_start_time: Some(1675789245.474416.into()),
|
||||
rcfiles_end_time: Some(1675789246.906731.into()),
|
||||
vi_mode_enabled: None,
|
||||
os_category: None,
|
||||
linux_distribution: None,
|
||||
wsl_name: None,
|
||||
shell_path: Some("/usr/local/bin/bash".to_string())
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dcs_init_shell() {
|
||||
let bytes = hex_encoded_dcs_string(
|
||||
r#"{
|
||||
"hook": "InitShell",
|
||||
"value": {
|
||||
"session_id": 167303092612201,
|
||||
"user": "andy",
|
||||
"hostname": "ubuntu-test",
|
||||
"shell": "zsh"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::InitShell { value } => assert_eq!(
|
||||
*value,
|
||||
InitShellValue {
|
||||
session_id: SessionId::from(167303092612201),
|
||||
user: "andy".to_owned(),
|
||||
hostname: "ubuntu-test".to_owned(),
|
||||
shell: "zsh".to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dcs_input_buffer() {
|
||||
let bytes = hex_encoded_dcs_string(
|
||||
r#"{
|
||||
"hook": "InputBuffer",
|
||||
"value": {
|
||||
"buffer": "ls -al dir"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::InputBuffer { value } => assert_eq!(
|
||||
*value,
|
||||
InputBufferValue {
|
||||
buffer: "ls -al dir".to_string()
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sourced_rc_file_hook() {
|
||||
let rc_file_hook = r#"{"hook": "SourcedRcFileForWarp", "value": { "shell": "zsh" }}"#;
|
||||
let bytes = [
|
||||
UNENCODED_JSON_DCS_START,
|
||||
&Vec::from(rc_file_hook.as_bytes()),
|
||||
DCS_END,
|
||||
]
|
||||
.concat();
|
||||
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::SourcedRcFileForWarp { value } => assert_eq!(
|
||||
*value,
|
||||
SourcedRcFileForWarpValue {
|
||||
shell: "zsh".to_owned(),
|
||||
uname: None,
|
||||
tmux: None,
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sourced_rc_file_hook_with_uname() {
|
||||
let rc_file_hook =
|
||||
r#"{"hook": "SourcedRcFileForWarp", "value": { "shell": "zsh", "uname": "Darwin" }}"#;
|
||||
let bytes = [
|
||||
UNENCODED_JSON_DCS_START,
|
||||
&Vec::from(rc_file_hook.as_bytes()),
|
||||
DCS_END,
|
||||
]
|
||||
.concat();
|
||||
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
|
||||
assert_eq!(handler.d_proto_hooks.len(), 1);
|
||||
match handler.d_proto_hooks.first().unwrap() {
|
||||
DProtoHook::SourcedRcFileForWarp { value } => assert_eq!(
|
||||
*value,
|
||||
SourcedRcFileForWarpValue {
|
||||
shell: "zsh".to_owned(),
|
||||
uname: Some("Darwin".to_owned()),
|
||||
tmux: None,
|
||||
}
|
||||
),
|
||||
_ => panic!("incorrect dcs value"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc9_notification() {
|
||||
let bytes: &[u8] = b"\x1b]9;Hello from OSC 9\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 1);
|
||||
let (title, body) = &handler.pluggable_notifications[0];
|
||||
assert_eq!(*title, None);
|
||||
assert_eq!(body, "Hello from OSC 9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc9_notification_with_st_terminator() {
|
||||
let bytes: &[u8] = b"\x1b]9;Message with ST terminator\x1b\\";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 1);
|
||||
let (title, body) = &handler.pluggable_notifications[0];
|
||||
assert_eq!(*title, None);
|
||||
assert_eq!(body, "Message with ST terminator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc9_empty_body() {
|
||||
let bytes: &[u8] = b"\x1b]9;\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc9_windows_terminal_cwd_ignored() {
|
||||
// OSC 9;9 is Windows Terminal's CWD notification (ESC ] 9 ; 9 ; "<cwd>" ST).
|
||||
// It should be silently ignored and not trigger a pluggable notification.
|
||||
// Reference: https://github.com/microsoft/terminal/issues/8166
|
||||
let bytes: &[u8] = b"\x1b]9;9;\"C:\\Users\\scottha\"\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc9_numeric_subcommand_ignored() {
|
||||
// Any OSC 9 sequence with a purely numeric params[1] is a ConEmu-style subcommand
|
||||
// and should be silently ignored, not treated as a notification.
|
||||
// This covers known ones (9;4 progress, 9;9 CWD) and any unknown future ones.
|
||||
for subcommand in [b"1" as &[u8], b"2", b"3", b"4", b"5", b"6", b"7", b"8"] {
|
||||
let bytes = [b"\x1b]9;", subcommand, b";data\x07"].concat();
|
||||
let (_, handler) = parse_bytes(&bytes);
|
||||
assert_eq!(
|
||||
handler.pluggable_notifications.len(),
|
||||
0,
|
||||
"OSC 9;{} should be ignored",
|
||||
String::from_utf8_lossy(subcommand)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc777_notification() {
|
||||
let bytes: &[u8] = b"\x1b]777;notify;Build Complete;Your build has finished\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 1);
|
||||
let (title, body) = &handler.pluggable_notifications[0];
|
||||
assert_eq!(title.as_deref(), Some("Build Complete"));
|
||||
assert_eq!(body, "Your build has finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc777_notification_empty_title() {
|
||||
let bytes: &[u8] = b"\x1b]777;notify;;Just the body\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 1);
|
||||
let (title, body) = &handler.pluggable_notifications[0];
|
||||
assert_eq!(*title, None);
|
||||
assert_eq!(body, "Just the body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc777_notification_with_semicolons_in_body() {
|
||||
let bytes: &[u8] = b"\x1b]777;notify;Title;Body with; semicolons; here\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 1);
|
||||
let (title, body) = &handler.pluggable_notifications[0];
|
||||
assert_eq!(title.as_deref(), Some("Title"));
|
||||
assert_eq!(body, "Body with; semicolons; here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc777_non_notify_subcommand_ignored() {
|
||||
let bytes: &[u8] = b"\x1b]777;other;title;body\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_osc777_missing_parts_ignored() {
|
||||
let bytes: &[u8] = b"\x1b]777;notify;only_title\x07";
|
||||
let (_, handler) = parse_bytes(bytes);
|
||||
|
||||
assert_eq!(handler.pluggable_notifications.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_pane_writer_formats_bytes_as_send_keys() {
|
||||
// Test that TmuxPaneWriter correctly converts writes to tmux send-keys format
|
||||
let mut output = Vec::new();
|
||||
{
|
||||
let mut writer = super::TmuxPaneWriter::new(&mut output, 123);
|
||||
// Write a cursor position response (ESC[1;1R)
|
||||
writer.write_all(b"\x1b[1;1R").unwrap();
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8(output).unwrap();
|
||||
// The output should be a send-keys command with hex bytes
|
||||
// Format: send-keys -Ht %{pane_id} {hex} {hex}...\n
|
||||
assert!(output_str.starts_with("send-keys -Ht %123"));
|
||||
assert!(output_str.contains("1B")); // ESC = 0x1B
|
||||
assert!(output_str.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_pane_writer_empty_write_returns_zero() {
|
||||
let mut output = Vec::new();
|
||||
let mut writer = super::TmuxPaneWriter::new(&mut output, 42);
|
||||
let result = writer.write(&[]).unwrap();
|
||||
|
||||
assert_eq!(result, 0);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_pane_writer_returns_original_byte_count() {
|
||||
let mut output = Vec::new();
|
||||
let mut writer = super::TmuxPaneWriter::new(&mut output, 42);
|
||||
let input = b"test";
|
||||
let result = writer.write(input).unwrap();
|
||||
|
||||
assert_eq!(result, 4);
|
||||
let output_str = String::from_utf8(output).unwrap();
|
||||
assert!(output_str.starts_with("send-keys -Ht %42"));
|
||||
assert!(output_str.ends_with('\n'));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
use anyhow::anyhow;
|
||||
use warp_terminal::model::{grid::Dimensions, Point};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{conversation::AIConversationId, task::TaskId, AIAgentActionId},
|
||||
blocklist::block::cli_controller::{LongRunningCommandControlState, UserTakeOverReason},
|
||||
},
|
||||
terminal::{
|
||||
event::Event,
|
||||
model::{
|
||||
grid::{grid_handler::GridHandler, RespectDisplayedOutput},
|
||||
RespectObfuscatedSecrets,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{Block, SerializedAIMetadata};
|
||||
|
||||
impl Block {
|
||||
/// `true` if the command is executing and the user has opened the agent mode input.
|
||||
///
|
||||
/// "tagged in" means that the agent mode input should be shown, but control has yet
|
||||
/// to be passed to the agent.
|
||||
pub fn is_agent_tagged_in(&self) -> bool {
|
||||
if !self.is_active_and_long_running() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match &self.interaction_mode {
|
||||
InteractionMode::User(user_mode) => user_mode.did_user_tag_in_agent,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if the command is eligible for tagging in the agent (e.g. showing the agent mode
|
||||
/// input and sending a query to trigger the CLI subagent).
|
||||
///
|
||||
/// Notably, this is NOT true if the subagent had already taken control of the command, and
|
||||
/// the user took control back from the subagent.
|
||||
///
|
||||
/// See doc comment on `InteractionMode` for explanation on the semantics of 'tagged in',
|
||||
/// 'agent in control', 'user take control, and 'agent handoff'.
|
||||
pub fn is_eligible_to_tag_in_agent(&self) -> bool {
|
||||
if !self.is_active_and_long_running()
|
||||
|| self.is_in_band_command_block()
|
||||
|| !self.bootstrap_stage.is_bootstrapped()
|
||||
|| self.env_var_metadata().is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
match &self.interaction_mode {
|
||||
InteractionMode::User(user_mode) => !user_mode.did_user_tag_in_agent,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_is_agent_tagged_in(&mut self, value: bool) {
|
||||
if let InteractionMode::User(UserMode {
|
||||
ref mut did_user_tag_in_agent,
|
||||
}) = &mut self.interaction_mode
|
||||
{
|
||||
if *did_user_tag_in_agent != value {
|
||||
*did_user_tag_in_agent = value;
|
||||
self.event_proxy
|
||||
.send_terminal_event(Event::AgentTaggedInChanged {
|
||||
is_tagged_in: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if an agent is monitoring/interacting with this command.
|
||||
pub fn is_agent_monitoring(&self) -> bool {
|
||||
self.is_active_and_long_running() && self.long_running_control_state().is_some()
|
||||
}
|
||||
|
||||
/// Returns `true` if the agent is either in control or has been tagged in by the user.
|
||||
pub fn is_agent_in_control_or_tagged_in(&self) -> bool {
|
||||
self.is_agent_in_control() || self.is_agent_tagged_in()
|
||||
}
|
||||
|
||||
pub fn cli_subagent_task_id(&self) -> Option<&TaskId> {
|
||||
self.agent_interaction_metadata()
|
||||
.and_then(|metadata| metadata.subagent_task_id())
|
||||
}
|
||||
|
||||
pub fn upgrade_cli_subagent_task_id(&mut self, new_task_id: TaskId) -> anyhow::Result<()> {
|
||||
if let InteractionMode::Agent(AgentInteractionMetadata {
|
||||
subagent_task_id: Some(ref mut task_id),
|
||||
..
|
||||
}) = &mut self.interaction_mode
|
||||
{
|
||||
*task_id = new_task_id;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Tried to upgrade CLI subagent task ID for block with no prior CLI subagent task ID."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this command is active and the agent is in control.
|
||||
pub fn is_agent_in_control(&self) -> bool {
|
||||
self.is_active_and_long_running()
|
||||
&& self
|
||||
.long_running_control_state()
|
||||
.is_some_and(LongRunningCommandControlState::is_agent_in_control)
|
||||
}
|
||||
|
||||
/// Returns `true` if the agent is actively driving this command.
|
||||
///
|
||||
/// This is broader than `is_agent_in_control`: it also covers the window between
|
||||
/// when the agent writes an agent-requested command to the PTY (synchronous) and
|
||||
/// when the CLI subagent is later spawned and `long_running_control_state` is set
|
||||
/// (asynchronous, via `BlocklistAIHistoryEvent::CreatedSubtask`). Returns `false`
|
||||
/// once the user takes over, even for agent-initiated commands.
|
||||
pub fn is_agent_driving_command(&self) -> bool {
|
||||
if self.is_agent_in_control() {
|
||||
return true;
|
||||
}
|
||||
// Agent-initiated command where the CLI subagent hasn't formally taken control yet.
|
||||
self.interaction_mode
|
||||
.agent_interaction_metadata()
|
||||
.is_some_and(|metadata| {
|
||||
metadata.requested_command_action_id().is_some()
|
||||
&& metadata.long_running_control_state().is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` if the agent's interaction with this command is currently blocked by user
|
||||
/// approval.
|
||||
pub fn is_agent_blocked(&self) -> bool {
|
||||
self.is_active_and_long_running()
|
||||
&& self
|
||||
.long_running_control_state()
|
||||
.is_some_and(LongRunningCommandControlState::is_agent_blocked)
|
||||
}
|
||||
|
||||
/// Returns `true` if the command is eligible to be handed off to an agent.
|
||||
pub fn is_eligible_for_agent_handoff(&self) -> bool {
|
||||
self.is_active_and_long_running()
|
||||
&& self
|
||||
.long_running_control_state()
|
||||
.is_some_and(LongRunningCommandControlState::is_user_in_control)
|
||||
}
|
||||
|
||||
pub fn update_is_agent_blocked(&mut self, new_value: bool) {
|
||||
if let InteractionMode::Agent(AgentInteractionMetadata {
|
||||
long_running_control_state:
|
||||
Some(LongRunningCommandControlState::Agent {
|
||||
ref mut is_blocked, ..
|
||||
}),
|
||||
..
|
||||
}) = self.interaction_mode
|
||||
{
|
||||
*is_blocked = new_value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets control to user with Stop reason if a long-running control state exists.
|
||||
pub fn set_user_control_with_stop_reason(&mut self) {
|
||||
if let InteractionMode::Agent(AgentInteractionMetadata {
|
||||
long_running_control_state: Some(ref mut state),
|
||||
..
|
||||
}) = self.interaction_mode
|
||||
{
|
||||
*state = LongRunningCommandControlState::User {
|
||||
reason: UserTakeOverReason::Stop,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if agent responses should be hidden in the UI.
|
||||
pub fn should_hide_responses(&self) -> bool {
|
||||
self.is_active_and_long_running()
|
||||
&& self
|
||||
.long_running_control_state()
|
||||
.is_some_and(LongRunningCommandControlState::should_hide_responses)
|
||||
}
|
||||
|
||||
/// Returns the `agent_interaction_metadata` associated with this block, if any.
|
||||
pub fn agent_interaction_metadata(&self) -> Option<&AgentInteractionMetadata> {
|
||||
self.interaction_mode.agent_interaction_metadata()
|
||||
}
|
||||
|
||||
pub fn ai_conversation_id(&self) -> Option<AIConversationId> {
|
||||
match &self.interaction_mode {
|
||||
InteractionMode::Agent(metadata) => Some(metadata.conversation_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requested_command_action_id(&self) -> Option<&AIAgentActionId> {
|
||||
match &self.interaction_mode {
|
||||
InteractionMode::Agent(metadata) => metadata.requested_command_action_id(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `long_running_control_state` associated with this block, if any.
|
||||
pub fn long_running_control_state(&self) -> Option<&LongRunningCommandControlState> {
|
||||
self.interaction_mode.long_running_control_state()
|
||||
}
|
||||
|
||||
pub fn has_agent_written_to_block(&self) -> bool {
|
||||
self.interaction_mode
|
||||
.agent_interaction_metadata()
|
||||
.is_some_and(|metadata| metadata.has_agent_written_to_block())
|
||||
}
|
||||
|
||||
pub fn mark_agent_written_to_block(&mut self) {
|
||||
if let InteractionMode::Agent(metadata) = &mut self.interaction_mode {
|
||||
metadata.has_agent_written_to_block = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_should_hide(&mut self, value: bool) {
|
||||
self.interaction_mode.set_should_hide_block(value);
|
||||
}
|
||||
|
||||
pub fn set_agent_interaction_mode_for_requested_command(
|
||||
&mut self,
|
||||
requested_command_action_id: AIAgentActionId,
|
||||
subagent_task_id: Option<TaskId>,
|
||||
conversation_id: AIConversationId,
|
||||
) {
|
||||
self.interaction_mode = InteractionMode::Agent(AgentInteractionMetadata {
|
||||
requested_command_action_id: Some(requested_command_action_id),
|
||||
conversation_id,
|
||||
subagent_task_id,
|
||||
long_running_control_state: None,
|
||||
has_agent_written_to_block: false,
|
||||
should_hide_block: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_agent_interaction_mode_for_agent_monitored_command(
|
||||
&mut self,
|
||||
task_id: &TaskId,
|
||||
conversation_id: AIConversationId,
|
||||
) -> Result<(), UpdateInteractionModeError> {
|
||||
let new_mode = self
|
||||
.interaction_mode
|
||||
.to_agent_monitored(task_id, conversation_id)?;
|
||||
self.interaction_mode = new_mode;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_agent_interaction_mode(
|
||||
&mut self,
|
||||
agent_interaction_metadata: AgentInteractionMetadata,
|
||||
) {
|
||||
self.interaction_mode = InteractionMode::new_agent(agent_interaction_metadata);
|
||||
}
|
||||
|
||||
pub fn set_interaction_mode_from_serialized_ai_metadata(
|
||||
&mut self,
|
||||
serialized_metadata: SerializedAIMetadata,
|
||||
) {
|
||||
self.interaction_mode = InteractionMode::from_serialized_ai_metadata(serialized_metadata);
|
||||
}
|
||||
|
||||
pub fn take_over_control_for_user(
|
||||
&mut self,
|
||||
reason: UserTakeOverReason,
|
||||
) -> Result<(), UpdateInteractionModeError> {
|
||||
self.interaction_mode.take_over_for_user(reason)
|
||||
}
|
||||
|
||||
pub fn handoff_control_to_agent(&mut self) -> Result<(), UpdateInteractionModeError> {
|
||||
self.interaction_mode.handoff_to_agent()
|
||||
}
|
||||
|
||||
/// Returns true if the interaction mode is agent-monitored and subagent response visibility was actually toggled.
|
||||
pub fn toggle_subagent_response_visibility(&mut self) -> bool {
|
||||
match &mut self.interaction_mode {
|
||||
InteractionMode::Agent(AgentInteractionMetadata {
|
||||
long_running_control_state:
|
||||
Some(LongRunningCommandControlState::Agent {
|
||||
ref mut should_hide_responses,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) => {
|
||||
*should_hide_responses = !*should_hide_responses;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum UpdateInteractionModeError {
|
||||
#[error("Attempted to update interaction mode from agent with requested command to agent-monitored for mismatched conversation IDs.")]
|
||||
UnexpectedConversationId,
|
||||
#[error("Attempted to take over control for user when block was not already agent controlled")]
|
||||
InvalidTakeOver,
|
||||
#[error("Attempted to handoff control to agent when block was not already user controlled")]
|
||||
InvalidHandOff,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserMode {
|
||||
// `true` if the user executed the command themself and the agent mode input should be shown.
|
||||
//
|
||||
// This does _not_ mean an agent is in control of the command. This merely means the user has
|
||||
// opted to show the agent input, indicated intent to send a query to give control.
|
||||
//
|
||||
// If the user executes a command, shows the input, then hides the input, this reverts to
|
||||
// `false`.
|
||||
did_user_tag_in_agent: bool,
|
||||
}
|
||||
|
||||
/// Represents the 'interaction mode' for a command block with respect to the agent.
|
||||
///
|
||||
/// There are 4 user-perceived states:
|
||||
///
|
||||
/// 1) The command was executed by the user; if long-running, they are in control and the input is hidden.
|
||||
/// 2) The command was executed by the user, but is long-running and the user has toggled on the
|
||||
/// agent input and may send a query to trigger the Agent (the CLI subagent). We refer to this
|
||||
/// state, where the user executed the command and deliberately opened the agent input, as the
|
||||
/// agent being 'tagged in'. Note that this is distinct from the agent actually having control.
|
||||
/// "Tagged in" merely means the command is running and the agent mode input is visible
|
||||
/// 3) The command was executed by the agent (is a requested command) and is not long running. No CLI subagent was triggered.
|
||||
/// 4) The command was executed by the agent and is long running, and thus the CLI subagent was triggered.
|
||||
/// a) The agent is in control of the command (actively reading the command's output or writing input to the command)
|
||||
/// b) The agent was in the control of the command, but the user took over.
|
||||
///
|
||||
/// The `User` variant represents modes where the user executed the original command and the agent has yet to take control.
|
||||
/// The `Agent` variant represents modes where the agent either ran the command itself, or the user tagged in the agent and
|
||||
/// passed control to the CLI subagent by sending a query during its execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InteractionMode {
|
||||
User(UserMode),
|
||||
Agent(AgentInteractionMetadata),
|
||||
}
|
||||
|
||||
impl InteractionMode {
|
||||
fn to_agent_monitored(
|
||||
&self,
|
||||
task_id: &TaskId,
|
||||
conversation_id: AIConversationId,
|
||||
) -> Result<Self, UpdateInteractionModeError> {
|
||||
let requested_command_action_id = match self {
|
||||
InteractionMode::User(_) => None,
|
||||
InteractionMode::Agent(metadata) => {
|
||||
if metadata.conversation_id != conversation_id {
|
||||
return Err(UpdateInteractionModeError::UnexpectedConversationId);
|
||||
}
|
||||
metadata.requested_command_action_id.clone()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self::Agent(AgentInteractionMetadata {
|
||||
requested_command_action_id,
|
||||
conversation_id,
|
||||
subagent_task_id: Some(task_id.clone()),
|
||||
long_running_control_state: Some(LongRunningCommandControlState::Agent {
|
||||
is_blocked: false,
|
||||
should_hide_responses: false,
|
||||
}),
|
||||
has_agent_written_to_block: false,
|
||||
should_hide_block: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn new_agent(metadata: AgentInteractionMetadata) -> Self {
|
||||
Self::Agent(metadata)
|
||||
}
|
||||
|
||||
fn from_serialized_ai_metadata(serialized_metadata: SerializedAIMetadata) -> Self {
|
||||
Self::Agent(serialized_metadata.into())
|
||||
}
|
||||
|
||||
fn agent_interaction_metadata(&self) -> Option<&AgentInteractionMetadata> {
|
||||
match self {
|
||||
Self::Agent(agent_interaction_metadata) => Some(agent_interaction_metadata),
|
||||
Self::User(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_hide_block(&self) -> bool {
|
||||
match self {
|
||||
Self::Agent(metadata) => metadata.should_hide_block,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn long_running_control_state(&self) -> Option<&LongRunningCommandControlState> {
|
||||
match self {
|
||||
Self::Agent(metadata) => metadata.long_running_control_state.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_agent_tagged_in(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::User(UserMode {
|
||||
did_user_tag_in_agent: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn set_should_hide_block(&mut self, value: bool) {
|
||||
if let Self::Agent(metadata) = self {
|
||||
metadata.should_hide_block = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn take_over_for_user(
|
||||
&mut self,
|
||||
reason: UserTakeOverReason,
|
||||
) -> Result<(), UpdateInteractionModeError> {
|
||||
let Self::Agent(AgentInteractionMetadata {
|
||||
ref mut long_running_control_state,
|
||||
..
|
||||
}) = self
|
||||
else {
|
||||
return Err(UpdateInteractionModeError::InvalidTakeOver);
|
||||
};
|
||||
|
||||
if !long_running_control_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.is_agent_in_control())
|
||||
{
|
||||
return Err(UpdateInteractionModeError::InvalidTakeOver);
|
||||
}
|
||||
|
||||
*long_running_control_state = Some(LongRunningCommandControlState::User { reason });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handoff_to_agent(&mut self) -> Result<(), UpdateInteractionModeError> {
|
||||
let Self::Agent(AgentInteractionMetadata {
|
||||
ref mut long_running_control_state,
|
||||
..
|
||||
}) = self
|
||||
else {
|
||||
return Err(UpdateInteractionModeError::InvalidHandOff);
|
||||
};
|
||||
|
||||
if !long_running_control_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.is_user_in_control())
|
||||
{
|
||||
return Err(UpdateInteractionModeError::InvalidHandOff);
|
||||
}
|
||||
|
||||
*long_running_control_state = Some(LongRunningCommandControlState::Agent {
|
||||
is_blocked: false,
|
||||
should_hide_responses: false,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InteractionMode {
|
||||
fn default() -> Self {
|
||||
Self::User(UserMode {
|
||||
did_user_tag_in_agent: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocklist AI metadata associated with this block.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentInteractionMetadata {
|
||||
/// The ID of the `AIAgentAction` associated with this block's requested command execution.
|
||||
/// This is optional because not all AI-related blocks are associated with a requested command.
|
||||
requested_command_action_id: Option<AIAgentActionId>,
|
||||
|
||||
/// The ID of the conversation to which this action belongs.
|
||||
conversation_id: AIConversationId,
|
||||
|
||||
/// The task ID for the CLI subagent interaction with this block if any.
|
||||
subagent_task_id: Option<TaskId>,
|
||||
|
||||
/// State governing user/agent interaction with the command in this block.
|
||||
long_running_control_state: Option<LongRunningCommandControlState>,
|
||||
|
||||
/// `true` if the agent has previously written to this block.
|
||||
has_agent_written_to_block: bool,
|
||||
|
||||
/// `true` if this block should be hidden from the user (as is the case with AI-requested
|
||||
/// commands, for example).
|
||||
should_hide_block: bool,
|
||||
}
|
||||
|
||||
impl AgentInteractionMetadata {
|
||||
/// Creates a new metadata instance with fully specified fields.
|
||||
pub fn new(
|
||||
requested_command_action_id: Option<AIAgentActionId>,
|
||||
conversation_id: AIConversationId,
|
||||
subagent_task_id: Option<TaskId>,
|
||||
long_running_control_state: Option<LongRunningCommandControlState>,
|
||||
has_agent_written_to_block: bool,
|
||||
should_hide_block: bool,
|
||||
) -> Self {
|
||||
AgentInteractionMetadata {
|
||||
requested_command_action_id,
|
||||
conversation_id,
|
||||
subagent_task_id,
|
||||
long_running_control_state,
|
||||
has_agent_written_to_block,
|
||||
should_hide_block,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor for the common "hidden by default" case used for requested commands.
|
||||
pub fn new_hidden(
|
||||
requested_command_action_id: AIAgentActionId,
|
||||
conversation_id: AIConversationId,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
Some(requested_command_action_id),
|
||||
conversation_id,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn requested_command_action_id(&self) -> Option<&AIAgentActionId> {
|
||||
self.requested_command_action_id.as_ref()
|
||||
}
|
||||
|
||||
pub fn conversation_id(&self) -> &AIConversationId {
|
||||
&self.conversation_id
|
||||
}
|
||||
|
||||
pub fn subagent_task_id(&self) -> Option<&TaskId> {
|
||||
self.subagent_task_id.as_ref()
|
||||
}
|
||||
|
||||
pub fn is_agent_in_control(&self) -> bool {
|
||||
self.long_running_control_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.is_agent_in_control())
|
||||
}
|
||||
|
||||
pub fn long_running_control_state(&self) -> Option<&LongRunningCommandControlState> {
|
||||
self.long_running_control_state.as_ref()
|
||||
}
|
||||
|
||||
pub fn has_agent_written_to_block(&self) -> bool {
|
||||
self.has_agent_written_to_block
|
||||
}
|
||||
|
||||
pub fn should_hide_block(&self) -> bool {
|
||||
self.should_hide_block
|
||||
}
|
||||
}
|
||||
|
||||
/// String representation of the cursor to interpolate in the terminal contents string.
|
||||
pub const CURSOR_MARKER: &str = "<|cursor|>";
|
||||
|
||||
/// Returns a string representation of the terminal contents (represented by the `grid_handler`),
|
||||
/// limited to `max_row_count` rows in the grid.
|
||||
///
|
||||
/// This function returns a string representation of the terminal contents, with a cursor "marker" substring
|
||||
/// interpolated at the same position in the string as it appears in the grid.
|
||||
pub fn formatted_terminal_contents_for_input(
|
||||
grid_handler: &GridHandler,
|
||||
max_row_count: Option<usize>,
|
||||
cursor_pattern: &'static str,
|
||||
) -> String {
|
||||
let cursor_point = grid_handler.cursor_point();
|
||||
|
||||
let max_column_index = grid_handler.columns().saturating_sub(1);
|
||||
let (context_start_point, context_end_point) = match max_row_count {
|
||||
Some(max_count) => {
|
||||
// Return start and end points such that the range is of size max_count, bounded to the
|
||||
// max row value of the grid.
|
||||
let end_point = Point::new(grid_handler.max_content_row(), max_column_index).min(
|
||||
Point::new(cursor_point.row + max_count / 2, max_column_index),
|
||||
);
|
||||
let start_point = Point::new(end_point.row.saturating_sub(max_count), 0);
|
||||
(start_point, end_point)
|
||||
}
|
||||
None => (
|
||||
Point::new(0, 0),
|
||||
Point::new(
|
||||
grid_handler.total_rows().saturating_sub(1),
|
||||
grid_handler.columns().saturating_sub(1),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}{}{cursor_pattern}{}",
|
||||
grid_handler.bounds_to_string(
|
||||
context_start_point,
|
||||
if cursor_point.col == 0 {
|
||||
Point::new(
|
||||
cursor_point.row.saturating_sub(1),
|
||||
grid_handler.columns().saturating_sub(1),
|
||||
)
|
||||
} else {
|
||||
Point::new(cursor_point.row, cursor_point.col.saturating_sub(1))
|
||||
},
|
||||
false,
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
true,
|
||||
RespectDisplayedOutput::No,
|
||||
),
|
||||
if cursor_point.col == 0 { "\n" } else { "" },
|
||||
grid_handler.bounds_to_string(
|
||||
cursor_point,
|
||||
context_end_point,
|
||||
false,
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
true,
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::AIAgentActionId;
|
||||
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
|
||||
use crate::terminal::model::block::{
|
||||
has_block_failed, AgentViewVisibility, Block, BlockState, PromptInfo,
|
||||
MAX_SERIALIZED_STYLIZED_OUTPUT_LINES,
|
||||
};
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::terminal::model::BlockId;
|
||||
use crate::terminal::ShellHost;
|
||||
use crate::util::extensions::TrimStringExt;
|
||||
use chrono::{DateTime, Local, TimeZone as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_bytes_repr::{ByteFmtDeserializer, ByteFmtSerializer};
|
||||
use warp_core::command::ExitCode;
|
||||
|
||||
use super::AgentInteractionMetadata;
|
||||
|
||||
/// Serialization-stable representation of [`AgentViewVisibility`].
|
||||
///
|
||||
/// This type decouples the persisted format from the in-app format, allowing
|
||||
/// internal changes to `AgentViewVisibility` without breaking serialization.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SerializedAgentViewVisibility {
|
||||
Terminal {
|
||||
#[serde(default)]
|
||||
pending_conversation_ids: HashSet<AIConversationId>,
|
||||
conversation_ids: HashSet<AIConversationId>,
|
||||
},
|
||||
Agent {
|
||||
#[serde(alias = "conversation_id")]
|
||||
origin_conversation_id: AIConversationId,
|
||||
#[serde(default)]
|
||||
pending_other_conversation_ids: HashSet<AIConversationId>,
|
||||
#[serde(default)]
|
||||
other_conversation_ids: HashSet<AIConversationId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<AgentViewVisibility> for SerializedAgentViewVisibility {
|
||||
fn from(value: AgentViewVisibility) -> Self {
|
||||
match value {
|
||||
AgentViewVisibility::Terminal {
|
||||
pending_conversation_ids,
|
||||
conversation_ids,
|
||||
} => SerializedAgentViewVisibility::Terminal {
|
||||
pending_conversation_ids,
|
||||
conversation_ids,
|
||||
},
|
||||
AgentViewVisibility::Agent {
|
||||
origin_conversation_id,
|
||||
pending_other_conversation_ids,
|
||||
other_conversation_ids,
|
||||
} => SerializedAgentViewVisibility::Agent {
|
||||
origin_conversation_id,
|
||||
pending_other_conversation_ids,
|
||||
other_conversation_ids,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SerializedAgentViewVisibility> for AgentViewVisibility {
|
||||
fn from(value: SerializedAgentViewVisibility) -> Self {
|
||||
match value {
|
||||
SerializedAgentViewVisibility::Terminal {
|
||||
pending_conversation_ids,
|
||||
conversation_ids,
|
||||
} => AgentViewVisibility::Terminal {
|
||||
pending_conversation_ids,
|
||||
conversation_ids,
|
||||
},
|
||||
SerializedAgentViewVisibility::Agent {
|
||||
origin_conversation_id,
|
||||
pending_other_conversation_ids,
|
||||
other_conversation_ids,
|
||||
} => AgentViewVisibility::Agent {
|
||||
origin_conversation_id,
|
||||
pending_other_conversation_ids,
|
||||
other_conversation_ids,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_as_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Blocklist AI metadata associated with this block.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SerializedAIMetadata {
|
||||
/// The ID of the `AIAgentAction` associated with this block's requested command execution.
|
||||
/// This is optional because not all AI-related blocks are associated with a requested command.
|
||||
#[serde(alias = "action_id")]
|
||||
requested_command_action_id: Option<AIAgentActionId>,
|
||||
|
||||
/// The ID of the conversation to which this action belongs.
|
||||
conversation_id: AIConversationId,
|
||||
|
||||
subagent_task_id: Option<TaskId>,
|
||||
|
||||
/// State governing user/agent interaction with the command in this block.
|
||||
long_running_control_state: Option<LongRunningCommandControlState>,
|
||||
|
||||
/// `true` if the agent has previously written to this block.
|
||||
has_agent_written_to_block: bool,
|
||||
|
||||
/// `true` if this block should be hidden from the user (as is the case with AI-requested
|
||||
/// commands, for example).
|
||||
#[serde(default = "default_as_true", skip)]
|
||||
should_hide_block: bool,
|
||||
}
|
||||
|
||||
impl From<AgentInteractionMetadata> for SerializedAIMetadata {
|
||||
fn from(value: AgentInteractionMetadata) -> Self {
|
||||
SerializedAIMetadata {
|
||||
requested_command_action_id: value.requested_command_action_id().cloned(),
|
||||
conversation_id: *value.conversation_id(),
|
||||
subagent_task_id: value.subagent_task_id().cloned(),
|
||||
long_running_control_state: value.long_running_control_state().cloned(),
|
||||
has_agent_written_to_block: value.has_agent_written_to_block(),
|
||||
should_hide_block: value.should_hide_block(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SerializedAIMetadata> for AgentInteractionMetadata {
|
||||
fn from(value: SerializedAIMetadata) -> Self {
|
||||
AgentInteractionMetadata::new(
|
||||
value.requested_command_action_id,
|
||||
value.conversation_id,
|
||||
value.subagent_task_id,
|
||||
value.long_running_control_state,
|
||||
value.has_agent_written_to_block,
|
||||
value.should_hide_block,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Default, Deserialize, PartialEq)]
|
||||
pub struct SerializedBlock {
|
||||
pub id: BlockId,
|
||||
/// The input lines with their corresponding escape sequences so it can be rendered outside of
|
||||
/// the terminal.
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub stylized_command: Vec<u8>,
|
||||
|
||||
/// The output lines with their corresponding escape sequences so it can be rendered outside of
|
||||
/// the terminal.
|
||||
/// They are truncated to MAX_SERIALIZED_STYLIZED_OUTPUT_LINES lines.
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub stylized_output: Vec<u8>,
|
||||
|
||||
/// The current working directory of the block.
|
||||
pub pwd: Option<String>,
|
||||
|
||||
#[serde(alias = "git_branch")]
|
||||
pub git_head: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub git_branch_name: Option<String>,
|
||||
|
||||
pub virtual_env: Option<String>,
|
||||
|
||||
pub conda_env: Option<String>,
|
||||
|
||||
pub node_version: Option<String>,
|
||||
|
||||
pub exit_code: ExitCode,
|
||||
|
||||
/// True iff the block _started_ executing (i.e. preexec was received) or it's a static block.
|
||||
pub did_execute: bool,
|
||||
|
||||
pub completed_ts: Option<DateTime<Local>>,
|
||||
|
||||
pub start_ts: Option<DateTime<Local>>,
|
||||
|
||||
pub ps1: Option<String>,
|
||||
|
||||
pub rprompt: Option<String>,
|
||||
|
||||
pub honor_ps1: bool,
|
||||
|
||||
pub is_background: bool,
|
||||
|
||||
pub session_id: Option<SessionId>,
|
||||
|
||||
pub shell_host: Option<ShellHost>,
|
||||
|
||||
/// JSON-serialized representation of the Warp prompt snapshot (Context Chips). Note that this
|
||||
/// is different from PS1 and RPROMPT1
|
||||
pub prompt_snapshot: Option<String>,
|
||||
|
||||
/// JSON-serialized representation of [`SerializedAIMetadata`].
|
||||
pub ai_metadata: Option<String>,
|
||||
|
||||
/// Whether this block was created locally (true) or remotely (false)
|
||||
#[serde(default)]
|
||||
pub is_local: Option<bool>,
|
||||
|
||||
/// Tracks which views (terminal and/or agent conversations) this block should be visible in.
|
||||
#[serde(default)]
|
||||
pub agent_view_visibility: Option<SerializedAgentViewVisibility>,
|
||||
}
|
||||
|
||||
impl SerializedBlock {
|
||||
/// Sets the command & output and `did_execute` to true.
|
||||
/// Everything else is a default value.
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test(stylized_command: Vec<u8>, stylized_output: Vec<u8>) -> SerializedBlock {
|
||||
SerializedBlock {
|
||||
stylized_command,
|
||||
stylized_output,
|
||||
did_execute: true,
|
||||
start_ts: Some(Local::now()),
|
||||
completed_ts: Some(Local::now()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets only the command with no output, and `did_execute` to false.
|
||||
/// Everything else is a default value.
|
||||
#[cfg(test)]
|
||||
pub fn new_active_block_for_test() -> SerializedBlock {
|
||||
SerializedBlock::default()
|
||||
}
|
||||
|
||||
/// Serialize this block to JSON bytes.
|
||||
///
|
||||
/// The command and output contents are base64-encoded. This is *not* the default serde behavior,
|
||||
/// and blocks encoded this way must be deserialized with [`Self::from_json`].
|
||||
pub fn to_json(&self) -> anyhow::Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let mut ser = serde_json::Serializer::new(&mut buf);
|
||||
let base64_config = base64::engine::GeneralPurposeConfig::new();
|
||||
let base64_ser =
|
||||
ByteFmtSerializer::base64(&mut ser, base64::alphabet::STANDARD, base64_config);
|
||||
self.serialize(base64_ser)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to serialize block to JSON: {e}"))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Deserialize a block from JSON bytes produced by [`Self::to_json`]
|
||||
/// or [`serde_json`].
|
||||
pub fn from_json(json: &[u8]) -> anyhow::Result<Self> {
|
||||
let mut de = serde_json::Deserializer::from_slice(json);
|
||||
let base64_config = base64::engine::GeneralPurposeConfig::new();
|
||||
let base64_de =
|
||||
ByteFmtDeserializer::new_base64(&mut de, base64::alphabet::STANDARD, base64_config);
|
||||
Self::deserialize(base64_de)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to deserialize block from JSON: {e}"))
|
||||
}
|
||||
|
||||
pub fn has_failed(&self) -> bool {
|
||||
let block_state = match self.did_execute {
|
||||
true => BlockState::DoneWithExecution,
|
||||
false => BlockState::DoneWithNoExecution,
|
||||
};
|
||||
has_block_failed(self.exit_code, block_state)
|
||||
}
|
||||
}
|
||||
|
||||
/// We should only be serializing a block that has finished.
|
||||
impl From<&Block> for SerializedBlock {
|
||||
fn from(block: &Block) -> Self {
|
||||
let stylized_command = block
|
||||
.command_with_secrets_unobfuscated(true /*include_escape_sequences*/)
|
||||
.into_bytes();
|
||||
let stylized_output = block
|
||||
.output_grid()
|
||||
.contents_to_string_with_secrets_unobfuscated(
|
||||
true, /*include_escape_sequences*/
|
||||
Some(MAX_SERIALIZED_STYLIZED_OUTPUT_LINES),
|
||||
)
|
||||
.into_bytes();
|
||||
let ps1 =
|
||||
(!block.is_prompt_empty()).then(|| hex::encode(block.prompt_contents_to_string(true)));
|
||||
let rprompt = (!block.rprompt_grid().is_empty()).then(|| {
|
||||
let mut grid_content = block.rprompt_grid().contents_to_string(true, None);
|
||||
// We mustn't allow trailing newlines in the rprompt grid. This is b/c a trailing
|
||||
// newline will cause the Grid::max_cursor::column to go to 0. That is a problem b/c we
|
||||
// assume that the Grid::max_cursor::column is the length of the rprompt, and that
|
||||
// value is used to calculate the left-alignment position when painting the block.
|
||||
grid_content.trim_trailing_newline();
|
||||
hex::encode(grid_content)
|
||||
});
|
||||
let prompt_snapshot = block
|
||||
.prompt_snapshot
|
||||
.as_ref()
|
||||
.and_then(|prompt_snapshot| serde_json::to_string(prompt_snapshot).ok());
|
||||
let prompt_info = PromptInfo {
|
||||
pwd: block.pwd().map(String::from),
|
||||
git_branch: block.git_branch.clone(),
|
||||
git_branch_name: block.git_branch_name.clone(),
|
||||
virtual_env: block.virtual_env.clone(),
|
||||
conda_env: block.conda_env.clone(),
|
||||
node_version: block.node_version.clone(),
|
||||
ps1,
|
||||
rprompt,
|
||||
honor_ps1: block.honor_ps1(),
|
||||
prompt_snapshot,
|
||||
};
|
||||
|
||||
let ai_metadata = block
|
||||
.agent_interaction_metadata()
|
||||
.cloned()
|
||||
.map(Into::<SerializedAIMetadata>::into)
|
||||
.and_then(|metadata| serde_json::to_string(&metadata).ok());
|
||||
|
||||
SerializedBlock {
|
||||
id: block.id.clone(),
|
||||
stylized_command,
|
||||
stylized_output,
|
||||
pwd: prompt_info.pwd,
|
||||
git_head: prompt_info.git_branch,
|
||||
git_branch_name: prompt_info.git_branch_name,
|
||||
virtual_env: prompt_info.virtual_env,
|
||||
conda_env: prompt_info.conda_env,
|
||||
node_version: prompt_info.node_version,
|
||||
exit_code: block.exit_code,
|
||||
did_execute: block.state == BlockState::Executing
|
||||
|| block.state == BlockState::DoneWithExecution
|
||||
|| block.state == BlockState::Static,
|
||||
completed_ts: block.completed_ts,
|
||||
start_ts: block.start_ts,
|
||||
is_background: block.is_background(),
|
||||
ps1: prompt_info.ps1,
|
||||
rprompt: prompt_info.rprompt,
|
||||
honor_ps1: prompt_info.honor_ps1,
|
||||
session_id: block.session_id,
|
||||
shell_host: block.shell_host.clone(),
|
||||
prompt_snapshot: prompt_info.prompt_snapshot,
|
||||
ai_metadata,
|
||||
is_local: None,
|
||||
agent_view_visibility: Some(block.agent_view_visibility().clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::persistence::model::Block> for SerializedBlock {
|
||||
fn from(block: crate::persistence::model::Block) -> Self {
|
||||
let exit_code = ExitCode::from(block.exit_code);
|
||||
Self {
|
||||
shell_host: ShellHost::try_from_persisted_block(&block),
|
||||
id: block.block_id.into(),
|
||||
stylized_command: block.stylized_command,
|
||||
stylized_output: block.stylized_output,
|
||||
pwd: block.pwd,
|
||||
git_head: block.git_branch,
|
||||
git_branch_name: block.git_branch_name,
|
||||
virtual_env: block.virtual_env,
|
||||
conda_env: block.conda_env,
|
||||
node_version: None, // Database does not store node_version yet
|
||||
exit_code,
|
||||
did_execute: block.did_execute,
|
||||
completed_ts: block
|
||||
.completed_ts
|
||||
.map(|naive_ts| Local.from_utc_datetime(&naive_ts)),
|
||||
start_ts: block
|
||||
.start_ts
|
||||
.map(|naive_ts| Local.from_utc_datetime(&naive_ts)),
|
||||
ps1: block.ps1,
|
||||
rprompt: block.rprompt,
|
||||
honor_ps1: block.honor_ps1,
|
||||
session_id: None,
|
||||
is_background: block.is_background,
|
||||
prompt_snapshot: block.prompt_snapshot,
|
||||
ai_metadata: block.ai_metadata,
|
||||
is_local: block.is_local,
|
||||
agent_view_visibility: block
|
||||
.agent_view_visibility
|
||||
.and_then(|json| serde_json::from_str(&json).ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "serialized_block_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,63 @@
|
||||
use super::*;
|
||||
|
||||
/// Serialized block JSON with byte fields encoded as base64 strings, as produced by `to_json`.
|
||||
const BASE64_JSON: &str = r#"{
|
||||
"id": "test-block-1",
|
||||
"stylized_command": "ZWNobyBoZWxsbw==",
|
||||
"stylized_output": "aGVsbG8gd29ybGQ=",
|
||||
"pwd": null,
|
||||
"git_head": null,
|
||||
"virtual_env": null,
|
||||
"conda_env": null,
|
||||
"node_version": null,
|
||||
"exit_code": 0,
|
||||
"did_execute": true,
|
||||
"completed_ts": null,
|
||||
"start_ts": null,
|
||||
"ps1": null,
|
||||
"rprompt": null,
|
||||
"honor_ps1": false,
|
||||
"is_background": false,
|
||||
"session_id": null,
|
||||
"shell_host": null,
|
||||
"prompt_snapshot": null,
|
||||
"ai_metadata": null
|
||||
}"#;
|
||||
|
||||
/// Serialized block JSON with byte fields as integer arrays, as produced by plain `serde_json`.
|
||||
const ARRAY_JSON: &str = r#"{
|
||||
"id": "test-block-1",
|
||||
"stylized_command": [101,99,104,111,32,104,101,108,108,111],
|
||||
"stylized_output": [104,101,108,108,111,32,119,111,114,108,100],
|
||||
"pwd": null,
|
||||
"git_head": null,
|
||||
"virtual_env": null,
|
||||
"conda_env": null,
|
||||
"node_version": null,
|
||||
"exit_code": 0,
|
||||
"did_execute": true,
|
||||
"completed_ts": null,
|
||||
"start_ts": null,
|
||||
"ps1": null,
|
||||
"rprompt": null,
|
||||
"honor_ps1": false,
|
||||
"is_background": false,
|
||||
"session_id": null,
|
||||
"shell_host": null,
|
||||
"prompt_snapshot": null,
|
||||
"ai_metadata": null
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn from_json_accepts_base64_encoded_bytes() {
|
||||
let block = SerializedBlock::from_json(BASE64_JSON.as_bytes()).unwrap();
|
||||
assert_eq!(block.stylized_command, b"echo hello");
|
||||
assert_eq!(block.stylized_output, b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_accepts_integer_array_bytes() {
|
||||
let block = SerializedBlock::from_json(ARRAY_JSON.as_bytes()).unwrap();
|
||||
assert_eq!(block.stylized_command, b"echo hello");
|
||||
assert_eq!(block.stylized_output, b"hello world");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,999 @@
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::SizeInfo;
|
||||
|
||||
use crate::terminal::model::ansi::{
|
||||
self, Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, LineClearMode, Mode,
|
||||
PrecmdValue, PreexecValue, StandardCharset, TabulationClearMode,
|
||||
};
|
||||
use crate::terminal::model::grid::grid_handler::{GridHandler, PerformResetGridChecks, RegexIter};
|
||||
use crate::terminal::model::index::{Point, VisibleRow};
|
||||
use crate::terminal::model::iterm_image::ITermImage;
|
||||
|
||||
use crate::terminal::model::grid::Dimensions;
|
||||
use crate::terminal::model::GridStorage;
|
||||
|
||||
use crate::terminal::model::secrets::ObfuscateSecrets;
|
||||
use instant::Instant;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use super::find::RegexDFAs;
|
||||
use super::grid::RespectDisplayedOutput;
|
||||
use super::image_map::StoredImageMetadata;
|
||||
use super::kitty::{KittyAction, KittyResponse};
|
||||
use super::secrets::RespectObfuscatedSecrets;
|
||||
use super::selection::ScrollDelta;
|
||||
use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlockGrid {
|
||||
pub(super) grid_handler: GridHandler,
|
||||
|
||||
started: bool,
|
||||
|
||||
/// Whether the grid is finished receiving input. For the command grid this
|
||||
/// // maps to when `preexec` gets called and for the output grid this maps
|
||||
/// to when the `precmd` gets called. Once finished is set, we consider the
|
||||
/// grid to be immutable, though the block may go through a new
|
||||
/// start/input/finish cycle.
|
||||
finished: bool,
|
||||
|
||||
start_time: Option<Instant>,
|
||||
|
||||
/// [`Grid::rightmost_visible_nonempty_cell`] is inefficient to compute anew, especially if it
|
||||
/// happens every frame, as it loops through the visible cells in the Grid. As the Grid itself
|
||||
/// doesn't know its own finished state, it can't memoize the result. That's why we put this
|
||||
/// state here where FinishedState is known.
|
||||
cached_rightmost_visible_nonempty_cell: OnceLock<Option<usize>>,
|
||||
|
||||
/// Similar to [`Self::cached_rightmost_visible_nonempty_cell`].
|
||||
cached_has_visible_chars: OnceLock<bool>,
|
||||
|
||||
/// Similar to [`Self::cached_rightmost_visible_nonempty_cell`].
|
||||
cached_starts_with_input_buffer_sequence: OnceLock<bool>,
|
||||
|
||||
pub(super) should_scan_for_secrets: ObfuscateSecrets,
|
||||
|
||||
/// When true, `len_displayed()` caps its return value at the bottommost
|
||||
/// visible content row to trim trailing blank rows.
|
||||
trim_trailing_blank_rows: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(in crate::terminal) enum CursorDisplayPoint {
|
||||
Visible(Point),
|
||||
HiddenCache(Point),
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
impl std::fmt::Debug for BlockGrid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{:?}",
|
||||
self.to_string(
|
||||
false, /* include_esc_sequences */
|
||||
None,
|
||||
RespectObfuscatedSecrets::No,
|
||||
false, /* force_secrets_obfuscated */
|
||||
RespectDisplayedOutput::Yes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockGrid {
|
||||
pub fn new(
|
||||
size_info: SizeInfo,
|
||||
max_scroll_limit: usize,
|
||||
event_proxy: ChannelEventListener,
|
||||
should_scan_for_secrets: ObfuscateSecrets,
|
||||
perform_reset_grid_checks: PerformResetGridChecks,
|
||||
) -> Self {
|
||||
let grid_handler = GridHandler::new(
|
||||
size_info,
|
||||
max_scroll_limit,
|
||||
event_proxy,
|
||||
false,
|
||||
should_scan_for_secrets,
|
||||
perform_reset_grid_checks,
|
||||
);
|
||||
|
||||
BlockGrid {
|
||||
grid_handler,
|
||||
started: false,
|
||||
finished: false,
|
||||
start_time: None,
|
||||
cached_rightmost_visible_nonempty_cell: Default::default(),
|
||||
cached_has_visible_chars: Default::default(),
|
||||
should_scan_for_secrets,
|
||||
cached_starts_with_input_buffer_sequence: Default::default(),
|
||||
trim_trailing_blank_rows: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_secrets_obfuscated(&self) -> usize {
|
||||
self.grid_handler().num_secrets_obfuscated()
|
||||
}
|
||||
|
||||
pub fn should_scan_for_secrets(&self) -> ObfuscateSecrets {
|
||||
self.should_scan_for_secrets
|
||||
}
|
||||
|
||||
/// Splits the [`BlockGrid`] into two at the specified Grid row.
|
||||
///
|
||||
/// The top grid contains all rows up to but not including the split row.
|
||||
/// The bottom grid contains all rows including and following the split row.
|
||||
/// If the split row is out of bounds, returns [`None`] for the bottom grid.
|
||||
///
|
||||
/// The row index here is the index into the full grid, not the visible
|
||||
/// region (i.e.: includes any rows in the scrollback buffer).
|
||||
pub fn split(&self, row_to_split_on: NonZeroUsize) -> (BlockGrid, Option<BlockGrid>) {
|
||||
let (top_grid, bottom_grid) = self.grid_handler.split(row_to_split_on);
|
||||
|
||||
(
|
||||
self.new_for_split(top_grid),
|
||||
bottom_grid.map(|b| self.new_for_split(b)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Constructs a new [`BlockGrid`] from a [`GridHandler`] that holds a
|
||||
/// subset of rows in `self`.
|
||||
fn new_for_split(&self, grid_handler: GridHandler) -> BlockGrid {
|
||||
BlockGrid {
|
||||
grid_handler,
|
||||
started: self.started,
|
||||
finished: self.finished,
|
||||
start_time: self.start_time,
|
||||
cached_rightmost_visible_nonempty_cell: Default::default(),
|
||||
cached_has_visible_chars: Default::default(),
|
||||
should_scan_for_secrets: self.should_scan_for_secrets,
|
||||
cached_starts_with_input_buffer_sequence: Default::default(),
|
||||
trim_trailing_blank_rows: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the length of the entire blockgrid, including rows that are not
|
||||
/// displayed.
|
||||
pub fn len(&self) -> usize {
|
||||
if self.finished() {
|
||||
self.finished_len()
|
||||
} else if self.started {
|
||||
self.grid_storage().max_cursor_point.row.0 + self.grid_handler.history_size() + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the length of the blockgrid containing only displayed rows.
|
||||
/// When `trim_trailing_blank_rows` is set, caps at the bottommost visible
|
||||
/// content row to exclude trailing blank rows from the displayed height.
|
||||
pub fn len_displayed(&self) -> usize {
|
||||
let base = if let Some(len_displayed) = self.grid_handler().len_displayed() {
|
||||
len_displayed
|
||||
} else {
|
||||
self.len()
|
||||
};
|
||||
if self.trim_trailing_blank_rows {
|
||||
let content_len = self
|
||||
.grid_handler()
|
||||
.visible_content_len_for_trimming()
|
||||
.unwrap_or(usize::from(self.started));
|
||||
base.min(content_len)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::terminal) fn cursor_display_point(&self) -> Option<CursorDisplayPoint> {
|
||||
let cursor_point = self.grid_handler().cursor_render_point();
|
||||
let cursor_display_point = self
|
||||
.grid_handler()
|
||||
.maybe_translate_point_from_original_to_displayed(cursor_point);
|
||||
let len_displayed = self.len_displayed();
|
||||
|
||||
if !self.trim_trailing_blank_rows || cursor_display_point.row < len_displayed {
|
||||
return Some(CursorDisplayPoint::Visible(cursor_display_point));
|
||||
}
|
||||
|
||||
if len_displayed == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(CursorDisplayPoint::HiddenCache(Point::new(
|
||||
cursor_display_point.row.min(len_displayed - 1),
|
||||
cursor_display_point
|
||||
.col
|
||||
.min(self.grid_handler().columns().saturating_sub(1)),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Determine whether the block is currently empty
|
||||
///
|
||||
/// Note: Depending on the state of the block, it can be _currently_ empty even when it has
|
||||
/// received contents
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Calculate the length of the blockgrid as if it were finished
|
||||
///
|
||||
/// This doesn't look at the current state, but rather represents the state of the block if
|
||||
/// it were already finished
|
||||
fn finished_len(&self) -> usize {
|
||||
// Only include the row with the cursor if the cursor is not on the first column.
|
||||
self.grid_handler.rows_to_cursor() + (self.grid_handler.cursor_point().col != 0) as usize
|
||||
}
|
||||
|
||||
/// Determine whether the grid has received content.
|
||||
/// This is a simple check for whether the grid_cursor has been moved at all by the shell.
|
||||
/// I.e., is the max grid_cursor past position (0,0)? If the cursor position has been modified,
|
||||
/// then it's safe to assume we've received output contents from the shell.
|
||||
///
|
||||
/// An example where has_received_content() == true but should_show_as_empty_when_finished == false
|
||||
/// are full-screen apps like mitmproxy. Here, the program has an output that spans the size
|
||||
/// of the terminal view; however, the grid_cursor is positioned at (0,0), indicating that this
|
||||
/// content should be truncated away once the program is finished -- hence, pre-finish content
|
||||
/// but no post-finish content.
|
||||
pub fn has_received_content(&self) -> bool {
|
||||
!(self.grid_storage().max_cursor_point.row.0 == 0
|
||||
&& self.grid_storage().max_cursor_point.col == 0)
|
||||
}
|
||||
|
||||
/// Determine whether the grid will have content once it's been finished.
|
||||
/// This is a simple check for whether the grid_cursor is currently at coordinates (0,0).
|
||||
/// If it is, this means any possible content in the grid will be truncated away at
|
||||
/// grid-finish time (precmd or preexec), leaving the grid contentless. If the grid_cursor
|
||||
/// is not at (0,0), we can assume the grid contains content that will persist through finish.
|
||||
pub fn should_show_as_empty_when_finished(&self) -> bool {
|
||||
self.finished_len() == 0
|
||||
|| !self.has_visible_chars()
|
||||
|| self.contains_only_input_buffer_sequence()
|
||||
}
|
||||
|
||||
pub(super) fn all_bytes_scanned_for_secrets(&self) -> bool {
|
||||
self.grid_handler().all_bytes_scanned_for_secrets()
|
||||
}
|
||||
|
||||
/// Disables secret obfuscation unconditionally.
|
||||
pub(super) fn disable_secret_obfuscation(&mut self) {
|
||||
self.should_scan_for_secrets = ObfuscateSecrets::No;
|
||||
self.grid_handler
|
||||
.set_obfuscate_secrets(ObfuscateSecrets::No);
|
||||
}
|
||||
|
||||
/// Sets the grid to obfuscate secrets except when both of the following are true:
|
||||
/// (1) The grid is finished.
|
||||
/// (2) All bytes have not been scanned for secrets already.
|
||||
pub(super) fn maybe_enable_secret_obfuscation(&mut self, obfuscate_secrets: ObfuscateSecrets) {
|
||||
if self.finished() && !self.all_bytes_scanned_for_secrets() {
|
||||
// This grid finished with secrets disabled and has not been
|
||||
// scanned for secrets but we are attempting to obfuscate secrets now.
|
||||
return;
|
||||
}
|
||||
|
||||
self.should_scan_for_secrets = obfuscate_secrets;
|
||||
self.grid_handler.set_obfuscate_secrets(obfuscate_secrets);
|
||||
}
|
||||
|
||||
pub(super) fn scan_full_grid_for_secrets(&mut self) {
|
||||
self.grid_handler.scan_full_grid_for_secrets();
|
||||
}
|
||||
|
||||
/// Mark the grid as finished and update the length of the grid accordingly. Once finished, the
|
||||
/// block is considered immutable and new content is no longer fed to it from the PTY. Any
|
||||
/// unused rows are truncated from the grid.
|
||||
pub fn finish(&mut self) {
|
||||
self.started = true;
|
||||
self.finished = true;
|
||||
self.trim_trailing_blank_rows = false;
|
||||
|
||||
self.grid_handler_mut().finish();
|
||||
}
|
||||
|
||||
pub fn set_trim_trailing_blank_rows(&mut self, trim: bool) {
|
||||
self.trim_trailing_blank_rows = trim;
|
||||
self.grid_handler.set_track_content_length(trim);
|
||||
}
|
||||
|
||||
/// Returns a freshly-computed value of rightmost_nonempty_cell if this grid isn't
|
||||
/// finished yet. Otherwise, return a memoized value since the grid won't be mutated anymore.
|
||||
pub fn rightmost_visible_nonempty_cell(&self) -> Option<usize> {
|
||||
if self.finished {
|
||||
self.cached_rightmost_visible_nonempty_cell
|
||||
.get_or_init(|| self.grid_handler().rightmost_nonempty_cell(None))
|
||||
.to_owned()
|
||||
} else {
|
||||
self.grid_handler().rightmost_nonempty_cell(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_visible_chars(&self) -> bool {
|
||||
if self.finished {
|
||||
*self
|
||||
.cached_has_visible_chars
|
||||
.get_or_init(|| self.grid_handler().has_visible_chars())
|
||||
} else {
|
||||
self.grid_handler().has_visible_chars()
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_if_grid_contains_only_input_buffer_sequence(&self) -> bool {
|
||||
if self.len_displayed() > 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let contents = self.to_string(
|
||||
false, /* include_esc_sequences */
|
||||
None, /* max_rows */
|
||||
RespectObfuscatedSecrets::No,
|
||||
false, /* force_secrets_obfuscated */
|
||||
RespectDisplayedOutput::No,
|
||||
);
|
||||
// We ignore any content that only contains line editor bindkeys that Warp sends to the
|
||||
// shell. Specifically `^[i` (requests the shell's input buffer) and `^P` (clears the
|
||||
// shell's input buffer). There are instances where these bindkeys will show up in
|
||||
// background blocks when PTY throughput is reduced.
|
||||
let contents_trimmed = contents.trim();
|
||||
contents_trimmed == "^[i" || contents_trimmed == "^P" || contents_trimmed == "^[i^P"
|
||||
}
|
||||
|
||||
/// The block only starts with a control sequence related to the Input Buffer hook.
|
||||
fn contains_only_input_buffer_sequence(&self) -> bool {
|
||||
if self.finished {
|
||||
*self
|
||||
.cached_starts_with_input_buffer_sequence
|
||||
.get_or_init(|| self.calculate_if_grid_contains_only_input_buffer_sequence())
|
||||
} else {
|
||||
self.calculate_if_grid_contains_only_input_buffer_sequence()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a freshly-computed value of rightmost_nonempty_cell, taking into account `max_rows` if given.
|
||||
pub fn rightmost_visible_nonempty_cell_with_max_row(&self, max_row: usize) -> Option<usize> {
|
||||
self.grid_handler().rightmost_nonempty_cell(Some(max_row))
|
||||
}
|
||||
|
||||
/// Resizes the grid based on the incoming `SizeInfo` that represents the
|
||||
/// size of the entire terminal.
|
||||
pub fn resize(&mut self, size_info: SizeInfo) {
|
||||
self.grid_handler.resize(size_info);
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
self.started = true;
|
||||
self.start_time = Some(Instant::now());
|
||||
}
|
||||
|
||||
pub(in crate::terminal) fn grid_storage(&self) -> &GridStorage {
|
||||
self.grid_handler.grid_storage()
|
||||
}
|
||||
|
||||
pub fn grid_handler(&self) -> &GridHandler {
|
||||
&self.grid_handler
|
||||
}
|
||||
|
||||
// TODO(vorporeal): Fix code location of blockgrid test utils and make this
|
||||
// pub(in crate::terminal).
|
||||
pub fn grid_storage_mut(&mut self) -> &mut GridStorage {
|
||||
self.grid_handler.grid_storage_mut()
|
||||
}
|
||||
|
||||
pub fn grid_handler_mut(&mut self) -> &mut GridHandler {
|
||||
&mut self.grid_handler
|
||||
}
|
||||
|
||||
pub fn find<'a>(&'a self, dfas: &'a RegexDFAs) -> RegexIter<'a> {
|
||||
self.grid_handler.find(dfas)
|
||||
}
|
||||
|
||||
pub(super) fn to_string(
|
||||
&self,
|
||||
include_esc_sequences: bool,
|
||||
max_rows: Option<usize>,
|
||||
respect_obfuscated_secrets: RespectObfuscatedSecrets,
|
||||
force_secrets_obfuscated: bool,
|
||||
respect_displayed_output: RespectDisplayedOutput,
|
||||
) -> String {
|
||||
if self.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let row_start_bound = match max_rows {
|
||||
None => 0,
|
||||
Some(max) => (self.len() - 1).saturating_sub(max),
|
||||
};
|
||||
self.grid_handler.bounds_to_string(
|
||||
Point::new(row_start_bound, 0),
|
||||
self.end_point(),
|
||||
include_esc_sequences,
|
||||
respect_obfuscated_secrets,
|
||||
force_secrets_obfuscated,
|
||||
respect_displayed_output,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the grid into a string.
|
||||
/// If the include_escape_sequences field is true, we include the ansi escape sequences.
|
||||
/// Otherwise, we have a plaintext string.
|
||||
/// max_rows is an optional limit, if it is none, we return everything. Otherwise, we return
|
||||
/// the last max_rows rows.
|
||||
pub fn contents_to_string(
|
||||
&self,
|
||||
include_escape_sequences: bool,
|
||||
max_rows: Option<usize>,
|
||||
) -> String {
|
||||
self.to_string(
|
||||
include_escape_sequences,
|
||||
max_rows,
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false,
|
||||
RespectDisplayedOutput::Yes,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a string containing a summary of the block's contents.
|
||||
///
|
||||
/// The summary is guaranteed to contain the first `start_rows` rows and
|
||||
/// the last `end_rows` rows. If the block is longer than
|
||||
/// `start_rows + end_rows`, a line will be added in the middle to denote
|
||||
/// the truncated section.
|
||||
pub fn content_summary(
|
||||
&self,
|
||||
start_rows: usize,
|
||||
end_rows: usize,
|
||||
force_secrets_obfuscated: bool,
|
||||
) -> String {
|
||||
let include_esc_sequences = false;
|
||||
let respect_obfuscated_secrets = RespectObfuscatedSecrets::Yes;
|
||||
let respect_displayed_output = RespectDisplayedOutput::No;
|
||||
|
||||
// If the block is shorter than the combined number of rows requested, return the full block.
|
||||
let total_rows = self.finished_len();
|
||||
if start_rows + end_rows >= total_rows {
|
||||
let max_rows = None;
|
||||
return self.to_string(
|
||||
include_esc_sequences,
|
||||
max_rows,
|
||||
respect_obfuscated_secrets,
|
||||
force_secrets_obfuscated,
|
||||
respect_displayed_output,
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, get the first `start_rows` rows and the last `end_rows` rows, and join them
|
||||
// with some text in the middle to denote the truncated section.
|
||||
let cols = self.grid_handler().columns();
|
||||
let mut summary = self.grid_handler.bounds_to_string(
|
||||
self.start_point(),
|
||||
Point::new(start_rows - 1, cols - 1),
|
||||
include_esc_sequences,
|
||||
respect_obfuscated_secrets,
|
||||
force_secrets_obfuscated,
|
||||
respect_displayed_output,
|
||||
);
|
||||
const TRUNCATION_MESSAGE: &str = "\n...(truncated)...\n";
|
||||
let summary_suffix = self.grid_handler.bounds_to_string(
|
||||
Point::new(total_rows - end_rows, 0),
|
||||
self.end_point(),
|
||||
include_esc_sequences,
|
||||
respect_obfuscated_secrets,
|
||||
force_secrets_obfuscated,
|
||||
respect_displayed_output,
|
||||
);
|
||||
|
||||
// Now that we know all of the pieces of the final string, we can grow the
|
||||
// initial string by exactly the amount we need, then append the additional
|
||||
// content.
|
||||
summary.reserve_exact(TRUNCATION_MESSAGE.len() + summary_suffix.len());
|
||||
summary.push_str(TRUNCATION_MESSAGE);
|
||||
summary.push_str(&summary_suffix);
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// Converts the grid into a string.
|
||||
/// If the include_escape_sequences field is true, we include the ansi escape sequences.
|
||||
/// Otherwise, we have a plaintext string.
|
||||
/// max_rows is an optional limit, if it is none, we return everything. Otherwise, we return
|
||||
/// the last max_rows rows.
|
||||
///
|
||||
/// NOTE: This function does not respect displayed/filtered content and returns all
|
||||
/// contents in the original grid.
|
||||
pub fn contents_to_string_force_full_grid_contents(
|
||||
&self,
|
||||
include_escape_sequences: bool,
|
||||
max_rows: Option<usize>,
|
||||
) -> String {
|
||||
self.to_string(
|
||||
include_escape_sequences,
|
||||
max_rows,
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false,
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts the grid into a string.
|
||||
/// If the include_escape_sequences field is true, we include the ansi escape sequences.
|
||||
/// Otherwise, we have a plaintext string.
|
||||
/// max_rows is an optional limit, if it is none, we return everything. Otherwise, we return
|
||||
/// the last max_rows rows.
|
||||
///
|
||||
/// NOTE: This function does not obfuscate secrets.
|
||||
pub fn contents_to_string_with_secrets_unobfuscated(
|
||||
&self,
|
||||
include_escape_sequences: bool,
|
||||
max_rows: Option<usize>,
|
||||
) -> String {
|
||||
self.to_string(
|
||||
include_escape_sequences,
|
||||
max_rows,
|
||||
RespectObfuscatedSecrets::No,
|
||||
false,
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts the grid into a string.
|
||||
/// If the include_escape_sequences field is true, we include the ansi escape sequences.
|
||||
/// Otherwise, we have a plaintext string.
|
||||
/// max_rows is an optional limit, if it is none, we return everything. Otherwise, we return
|
||||
/// the last max_rows rows.
|
||||
///
|
||||
/// NOTE: This function forces all secrets in grids to be obfuscated. We need this
|
||||
/// to respect the value when serializing a block for sharing.
|
||||
pub fn contents_to_string_force_secrets_obfuscated(
|
||||
&self,
|
||||
include_escape_sequences: bool,
|
||||
max_rows: Option<usize>,
|
||||
) -> String {
|
||||
self.to_string(
|
||||
include_escape_sequences,
|
||||
max_rows,
|
||||
RespectObfuscatedSecrets::No,
|
||||
true,
|
||||
RespectDisplayedOutput::No,
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts all the points from (0.0) to `point` to a string.
|
||||
pub fn start_to_point_as_string(&self, point: Point) -> String {
|
||||
if self.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.grid_handler.bounds_to_string(
|
||||
self.start_point(),
|
||||
point,
|
||||
false, /* include_esc_sequences */
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false, /* force_obfuscated_secrets */
|
||||
RespectDisplayedOutput::Yes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts all the points from `point` to the end to a string.
|
||||
pub fn point_to_end_as_string(&self, point: Point) -> String {
|
||||
if self.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.grid_handler.bounds_to_string(
|
||||
point,
|
||||
self.end_point(),
|
||||
false, /* include_esc_sequences */
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false, /* force_obfuscated_secrets */
|
||||
RespectDisplayedOutput::Yes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Point position of the last character/element in the grid.
|
||||
pub fn end_point(&self) -> Point {
|
||||
Point::new(
|
||||
self.len().saturating_sub(1),
|
||||
self.grid_handler.columns().saturating_sub(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the Point position of the first character/element in the grid.
|
||||
pub fn start_point(&self) -> Point {
|
||||
Point::new(0, 0)
|
||||
}
|
||||
|
||||
/// Returns the total number of rows printed to the grid, including any lines that were
|
||||
/// truncated due maximum block line limits.
|
||||
pub fn total_row_count(&self) -> u64 {
|
||||
(self.end_point().row as u64) + self.grid_handler().num_lines_truncated() + 1
|
||||
}
|
||||
|
||||
pub fn started(&self) -> bool {
|
||||
self.started
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
|
||||
pub fn cursor_style(&self) -> CursorStyle {
|
||||
self.grid_handler.cursor_style()
|
||||
}
|
||||
|
||||
pub fn start_time(&self) -> Option<Instant> {
|
||||
self.start_time
|
||||
}
|
||||
|
||||
pub fn needs_bracketed_paste(&self) -> bool {
|
||||
self.grid_handler.needs_bracketed_paste()
|
||||
}
|
||||
|
||||
pub fn filter_matches(&self) -> Option<&[RangeInclusive<Point>]> {
|
||||
self.grid_handler().filter_matches()
|
||||
}
|
||||
|
||||
/// Apply a filter to this blockgrid. The logical lines containing the
|
||||
/// matches will be shown in the blockgrid's visible output, while
|
||||
/// non-matching lines will be hidden.
|
||||
pub fn filter_lines(
|
||||
&mut self,
|
||||
dfas: Arc<RegexDFAs>,
|
||||
num_context_lines: usize,
|
||||
invert_filter: bool,
|
||||
) {
|
||||
self.grid_handler_mut()
|
||||
.filter_lines(dfas, num_context_lines, invert_filter);
|
||||
}
|
||||
|
||||
/// Re-applies the filter to the blockgrid if it has truncated rows.
|
||||
pub fn maybe_refilter_lines(&mut self) {
|
||||
if self.grid_handler().num_lines_truncated() > 0 {
|
||||
self.grid_handler_mut().refilter_lines();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the applied filter. Will reset the displayed output so all rows in
|
||||
/// the blockgrid will be visible.
|
||||
pub fn clear_filter(&mut self) {
|
||||
self.grid_handler_mut().clear_filter();
|
||||
}
|
||||
|
||||
pub fn estimated_heap_usage_bytes(&self) -> usize {
|
||||
self.grid_storage().estimated_heap_usage_bytes()
|
||||
}
|
||||
|
||||
pub fn estimated_memory_usage_bytes(&self) -> usize {
|
||||
std::mem::size_of::<Self>() + self.grid_storage().estimated_heap_usage_bytes()
|
||||
}
|
||||
|
||||
pub fn flat_storage_lines(&self) -> usize {
|
||||
self.grid_handler().flat_storage.total_rows()
|
||||
}
|
||||
|
||||
pub fn flat_storage_bytes(&self) -> usize {
|
||||
self.grid_handler()
|
||||
.flat_storage
|
||||
.estimated_memory_usage_bytes()
|
||||
}
|
||||
|
||||
pub(super) fn disable_reset_grid_checks(&mut self) {
|
||||
self.grid_handler.disable_reset_grid_checks();
|
||||
}
|
||||
|
||||
pub(super) fn reset_received_osc(&mut self) {
|
||||
self.grid_handler.reset_received_osc();
|
||||
}
|
||||
|
||||
fn ansi_handler(&mut self) -> &mut impl ansi::Handler {
|
||||
self.grid_handler.ansi_handler()
|
||||
}
|
||||
|
||||
pub(super) fn set_marked_text(&mut self, marked_text: &str, selected_range: &Range<usize>) {
|
||||
self.grid_handler
|
||||
.set_marked_text(marked_text, selected_range);
|
||||
}
|
||||
|
||||
pub(super) fn clear_marked_text(&mut self) {
|
||||
self.grid_handler.clear_marked_text();
|
||||
}
|
||||
}
|
||||
|
||||
impl ansi::Handler for BlockGrid {
|
||||
fn set_title(&mut self, _: Option<String>) {
|
||||
// Ignore: This should be handled at model layer. However, the PS1 may set the title to
|
||||
// trigger this, so we want to gracefully... ignore their request.
|
||||
}
|
||||
|
||||
fn set_cursor_style(&mut self, style: Option<CursorStyle>) {
|
||||
self.ansi_handler().set_cursor_style(style);
|
||||
}
|
||||
|
||||
fn set_cursor_shape(&mut self, shape: CursorShape) {
|
||||
self.ansi_handler().set_cursor_shape(shape);
|
||||
}
|
||||
|
||||
fn input(&mut self, c: char) {
|
||||
self.ansi_handler().input(c);
|
||||
}
|
||||
|
||||
fn goto(&mut self, row: VisibleRow, col: usize) {
|
||||
self.ansi_handler().goto(row, col);
|
||||
}
|
||||
|
||||
fn goto_line(&mut self, row: VisibleRow) {
|
||||
self.ansi_handler().goto_line(row);
|
||||
}
|
||||
|
||||
fn goto_col(&mut self, col: usize) {
|
||||
self.ansi_handler().goto_col(col);
|
||||
}
|
||||
|
||||
fn insert_blank(&mut self, count: usize) {
|
||||
self.ansi_handler().insert_blank(count);
|
||||
}
|
||||
|
||||
fn move_up(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_up(lines);
|
||||
}
|
||||
|
||||
fn move_down(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_down(lines);
|
||||
}
|
||||
|
||||
fn identify_terminal<W: io::Write>(&mut self, writer: &mut W, intermediate: Option<char>) {
|
||||
self.ansi_handler().identify_terminal(writer, intermediate);
|
||||
}
|
||||
|
||||
fn report_xtversion<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().report_xtversion(writer);
|
||||
}
|
||||
|
||||
fn device_status<W: io::Write>(&mut self, writer: &mut W, arg: usize) {
|
||||
self.ansi_handler().device_status(writer, arg);
|
||||
}
|
||||
|
||||
fn move_forward(&mut self, columns: usize) {
|
||||
self.ansi_handler().move_forward(columns);
|
||||
}
|
||||
|
||||
fn move_backward(&mut self, columns: usize) {
|
||||
self.ansi_handler().move_backward(columns);
|
||||
}
|
||||
|
||||
fn move_down_and_cr(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_down_and_cr(lines);
|
||||
}
|
||||
|
||||
fn move_up_and_cr(&mut self, lines: usize) {
|
||||
self.ansi_handler().move_up_and_cr(lines);
|
||||
}
|
||||
|
||||
fn put_tab(&mut self, count: u16) {
|
||||
self.ansi_handler().put_tab(count);
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
self.ansi_handler().backspace();
|
||||
}
|
||||
|
||||
fn carriage_return(&mut self) {
|
||||
self.ansi_handler().carriage_return();
|
||||
}
|
||||
|
||||
fn linefeed(&mut self) -> ScrollDelta {
|
||||
self.ansi_handler().linefeed()
|
||||
}
|
||||
|
||||
fn bell(&mut self) {
|
||||
self.ansi_handler().bell();
|
||||
}
|
||||
|
||||
fn substitute(&mut self) {
|
||||
self.ansi_handler().substitute();
|
||||
}
|
||||
|
||||
fn newline(&mut self) {
|
||||
self.ansi_handler().newline();
|
||||
}
|
||||
|
||||
fn set_horizontal_tabstop(&mut self) {
|
||||
self.ansi_handler().set_horizontal_tabstop();
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, lines: usize) -> ScrollDelta {
|
||||
self.ansi_handler().scroll_up(lines)
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, lines: usize) -> ScrollDelta {
|
||||
self.ansi_handler().scroll_down(lines)
|
||||
}
|
||||
|
||||
fn insert_blank_lines(&mut self, lines: usize) -> ScrollDelta {
|
||||
self.ansi_handler().insert_blank_lines(lines)
|
||||
}
|
||||
|
||||
fn delete_lines(&mut self, lines: usize) -> ScrollDelta {
|
||||
self.ansi_handler().delete_lines(lines)
|
||||
}
|
||||
|
||||
fn erase_chars(&mut self, count: usize) {
|
||||
self.ansi_handler().erase_chars(count);
|
||||
}
|
||||
|
||||
fn delete_chars(&mut self, count: usize) {
|
||||
self.ansi_handler().delete_chars(count)
|
||||
}
|
||||
|
||||
fn move_backward_tabs(&mut self, count: u16) {
|
||||
self.ansi_handler().move_backward_tabs(count);
|
||||
}
|
||||
|
||||
fn move_forward_tabs(&mut self, count: u16) {
|
||||
self.ansi_handler().move_forward_tabs(count);
|
||||
}
|
||||
|
||||
fn save_cursor_position(&mut self) {
|
||||
self.ansi_handler().save_cursor_position();
|
||||
}
|
||||
|
||||
fn restore_cursor_position(&mut self) {
|
||||
self.ansi_handler().restore_cursor_position();
|
||||
}
|
||||
|
||||
fn clear_line(&mut self, mode: LineClearMode) {
|
||||
self.ansi_handler().clear_line(mode);
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self, mode: ClearMode) {
|
||||
self.ansi_handler().clear_screen(mode);
|
||||
}
|
||||
|
||||
fn clear_tabs(&mut self, mode: TabulationClearMode) {
|
||||
self.ansi_handler().clear_tabs(mode);
|
||||
}
|
||||
|
||||
fn reset_state(&mut self) {
|
||||
self.ansi_handler().reset_state();
|
||||
}
|
||||
|
||||
fn reverse_index(&mut self) -> ScrollDelta {
|
||||
self.ansi_handler().reverse_index()
|
||||
}
|
||||
|
||||
fn terminal_attribute(&mut self, attr: Attr) {
|
||||
self.ansi_handler().terminal_attribute(attr);
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: Mode) {
|
||||
self.ansi_handler().set_mode(mode);
|
||||
}
|
||||
|
||||
fn unset_mode(&mut self, mode: Mode) {
|
||||
self.ansi_handler().unset_mode(mode);
|
||||
}
|
||||
|
||||
fn set_scrolling_region(&mut self, top: usize, bottom: Option<usize>) {
|
||||
self.ansi_handler().set_scrolling_region(top, bottom);
|
||||
}
|
||||
|
||||
fn set_keypad_application_mode(&mut self) {
|
||||
self.ansi_handler().set_keypad_application_mode();
|
||||
}
|
||||
|
||||
fn unset_keypad_application_mode(&mut self) {
|
||||
self.ansi_handler().unset_keypad_application_mode();
|
||||
}
|
||||
|
||||
fn set_active_charset(&mut self, index: CharsetIndex) {
|
||||
self.ansi_handler().set_active_charset(index);
|
||||
}
|
||||
|
||||
fn configure_charset(&mut self, index: CharsetIndex, charset: StandardCharset) {
|
||||
self.ansi_handler().configure_charset(index, charset);
|
||||
}
|
||||
|
||||
fn set_color(&mut self, _index: usize, _color: ColorU) {
|
||||
// Ignore. This needs to be handled by the TerminalModel. Users should emit this escape
|
||||
// code in their RC files if they want to override colors
|
||||
}
|
||||
|
||||
fn dynamic_color_sequence<W: io::Write>(
|
||||
&mut self,
|
||||
writer: &mut W,
|
||||
code: u8,
|
||||
index: usize,
|
||||
terminator: &str,
|
||||
) {
|
||||
self.ansi_handler()
|
||||
.dynamic_color_sequence(writer, code, index, terminator);
|
||||
}
|
||||
|
||||
fn reset_color(&mut self, _index: usize) {
|
||||
// Ignore. See the Self::set_color method for explanation
|
||||
}
|
||||
|
||||
fn clipboard_store(&mut self, clipboard: u8, base64: &[u8]) {
|
||||
self.ansi_handler().clipboard_store(clipboard, base64);
|
||||
}
|
||||
|
||||
fn clipboard_load(&mut self, clipboard: u8, terminator: &str) {
|
||||
self.ansi_handler().clipboard_load(clipboard, terminator);
|
||||
}
|
||||
|
||||
fn decaln(&mut self) {
|
||||
self.ansi_handler().decaln();
|
||||
}
|
||||
|
||||
fn push_title(&mut self) {
|
||||
// Ignore: see Self::set_title for explanation
|
||||
}
|
||||
|
||||
fn pop_title(&mut self) {
|
||||
// Ignore: see Self::set_title for explanation
|
||||
}
|
||||
|
||||
fn text_area_size_pixels<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().text_area_size_pixels(writer);
|
||||
}
|
||||
|
||||
fn text_area_size_chars<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().text_area_size_chars(writer);
|
||||
}
|
||||
|
||||
fn precmd(&mut self, _: PrecmdValue) {
|
||||
unreachable!("Handled at block layer");
|
||||
}
|
||||
|
||||
fn preexec(&mut self, _: PreexecValue) {
|
||||
unreachable!("Handled at block layer");
|
||||
}
|
||||
|
||||
fn on_finish_byte_processing(&mut self, input: &ansi::ProcessorInput<'_>) {
|
||||
self.ansi_handler().on_finish_byte_processing(input);
|
||||
}
|
||||
|
||||
fn on_reset_grid(&mut self) {
|
||||
self.ansi_handler().on_reset_grid();
|
||||
}
|
||||
|
||||
fn handle_completed_iterm_image(&mut self, image: ITermImage) {
|
||||
self.ansi_handler().handle_completed_iterm_image(image)
|
||||
}
|
||||
|
||||
fn handle_completed_kitty_action(
|
||||
&mut self,
|
||||
action: KittyAction,
|
||||
metadata: &mut HashMap<u32, StoredImageMetadata>,
|
||||
) -> Option<KittyResponse> {
|
||||
self.ansi_handler()
|
||||
.handle_completed_kitty_action(action, metadata)
|
||||
}
|
||||
|
||||
fn set_keyboard_enhancement_flags(
|
||||
&mut self,
|
||||
mode: KeyboardModes,
|
||||
apply: KeyboardModesApplyBehavior,
|
||||
) {
|
||||
self.ansi_handler()
|
||||
.set_keyboard_enhancement_flags(mode, apply);
|
||||
}
|
||||
|
||||
fn push_keyboard_enhancement_flags(&mut self, mode: KeyboardModes) {
|
||||
self.ansi_handler().push_keyboard_enhancement_flags(mode);
|
||||
}
|
||||
|
||||
fn pop_keyboard_enhancement_flags(&mut self, count: u16) {
|
||||
self.ansi_handler().pop_keyboard_enhancement_flags(count);
|
||||
}
|
||||
|
||||
fn query_keyboard_enhancement_flags<W: io::Write>(&mut self, writer: &mut W) {
|
||||
self.ansi_handler().query_keyboard_enhancement_flags(writer);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "blockgrid_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,246 @@
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::ansi::{self, Handler};
|
||||
use crate::terminal::model::blockgrid::{BlockGrid, CursorDisplayPoint};
|
||||
use crate::terminal::model::grid::grid_handler::PerformResetGridChecks;
|
||||
use crate::terminal::model::grid::Dimensions;
|
||||
use crate::terminal::model::index::{Point, VisibleRow};
|
||||
use crate::terminal::model::secrets::ObfuscateSecrets;
|
||||
use crate::terminal::SizeInfo;
|
||||
use crate::test_util::mock_blockgrid;
|
||||
|
||||
#[test]
|
||||
pub fn test_finish_truncates_grid_basic() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 7);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000, /* max_scroll_limit */
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
for c in "hello".chars() {
|
||||
block_grid.input(c);
|
||||
}
|
||||
block_grid.linefeed();
|
||||
block_grid.finish();
|
||||
|
||||
assert_eq!(block_grid.len(), 2);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 2);
|
||||
assert_eq!(block_grid.grid_handler().columns(), size.columns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_finish_truncates_grid_cursor_at_bottom() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 7);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000, /* max_scroll_limit */
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
for _ in 0..300 {
|
||||
block_grid.input('a');
|
||||
block_grid.linefeed();
|
||||
block_grid.carriage_return();
|
||||
}
|
||||
|
||||
block_grid.finish();
|
||||
|
||||
assert_eq!(block_grid.len(), 300);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 301);
|
||||
assert_eq!(block_grid.grid_handler().columns(), size.columns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_resize_finished_block() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 7);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000, /* max_scroll_limit */
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
for _ in 0..5 {
|
||||
for c in "hello".chars() {
|
||||
block_grid.input(c);
|
||||
}
|
||||
|
||||
block_grid.linefeed();
|
||||
block_grid.carriage_return();
|
||||
}
|
||||
|
||||
block_grid.finish();
|
||||
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 6);
|
||||
|
||||
block_grid.resize(SizeInfo::new_without_font_metrics(10, 10));
|
||||
|
||||
// The len and total rows of the grid should be unchanged even after resize.
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 6);
|
||||
|
||||
// Resize so that the contents of can no longer fit on one line. The length of the grid
|
||||
// should increase because each row is now soft-wrapped.
|
||||
block_grid.resize(SizeInfo::new_without_font_metrics(10, 3));
|
||||
assert_eq!(block_grid.len(), 10);
|
||||
// Each of the 5 "hello" rows should be split into "hel" and "lo" (for a total
|
||||
// of 10 rows), plus one final empty row at the end.
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 11);
|
||||
|
||||
// Resizing the height in either direction should have no effect on the total grid height.
|
||||
block_grid.resize(SizeInfo::new_without_font_metrics(3, 7));
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 6);
|
||||
|
||||
block_grid.resize(SizeInfo::new_without_font_metrics(300, 7));
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_resize_finished_softwrapped_block() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 3);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000, /* max_scroll_limit */
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
for _ in 0..5 {
|
||||
for c in "hello".chars() {
|
||||
block_grid.input(c);
|
||||
}
|
||||
|
||||
block_grid.linefeed();
|
||||
block_grid.carriage_return();
|
||||
}
|
||||
|
||||
block_grid.finish();
|
||||
|
||||
assert_eq!(block_grid.len(), 10);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 11);
|
||||
|
||||
// Resize so each item can only fit on a given line.
|
||||
block_grid.resize(SizeInfo::new_without_font_metrics(10, 10));
|
||||
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.grid_handler().total_rows(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_content_summary() {
|
||||
let blockgrid = mock_blockgrid("1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9\r\n10\r\n");
|
||||
|
||||
// Test a summary that omits the middle of the grid.
|
||||
let summary = blockgrid.content_summary(1, 2, false);
|
||||
assert_eq!(summary, "1\n...(truncated)...\n9\n10\n");
|
||||
|
||||
// Test a summary that perfectly covers the entire grid.
|
||||
let summary = blockgrid.content_summary(7, 3, false);
|
||||
assert_eq!(summary, "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
|
||||
|
||||
// Test a summary where the total range exceeds the grid size.
|
||||
let summary = blockgrid.content_summary(7, 6, false);
|
||||
assert_eq!(summary, "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_trim_trailing_blank_rows_uses_active_floor_for_blank_started_grid() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 10);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000,
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
block_grid.start();
|
||||
block_grid.goto(VisibleRow(4), 0);
|
||||
block_grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
block_grid.set_trim_trailing_blank_rows(true);
|
||||
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.len_displayed(), 1);
|
||||
assert_eq!(
|
||||
block_grid.cursor_display_point(),
|
||||
Some(CursorDisplayPoint::HiddenCache(Point::new(0, 0)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_cursor_display_point_hidden_when_cursor_below_trimmed_content() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 10);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000,
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
block_grid.start();
|
||||
block_grid.input('a');
|
||||
block_grid.input('b');
|
||||
block_grid.input('c');
|
||||
block_grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
block_grid.set_trim_trailing_blank_rows(true);
|
||||
|
||||
assert_eq!(block_grid.len_displayed(), 1);
|
||||
assert_eq!(
|
||||
block_grid.cursor_display_point(),
|
||||
Some(CursorDisplayPoint::Visible(Point::new(0, 3)))
|
||||
);
|
||||
|
||||
block_grid.goto(VisibleRow(4), 2);
|
||||
block_grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
assert_eq!(block_grid.len(), 5);
|
||||
assert_eq!(block_grid.len_displayed(), 1);
|
||||
assert_eq!(
|
||||
block_grid.cursor_display_point(),
|
||||
Some(CursorDisplayPoint::HiddenCache(Point::new(0, 2)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_cursor_display_point_not_clipped_when_trimming_disabled() {
|
||||
let size = SizeInfo::new_without_font_metrics(10, 10);
|
||||
let mut block_grid = BlockGrid::new(
|
||||
size,
|
||||
1000,
|
||||
ChannelEventListener::new_for_test(),
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::default(),
|
||||
);
|
||||
|
||||
block_grid.start();
|
||||
block_grid.input('a');
|
||||
block_grid.input('b');
|
||||
block_grid.input('c');
|
||||
block_grid
|
||||
.grid_handler_mut()
|
||||
.set_marked_text("12345678", &(0..0));
|
||||
|
||||
assert_eq!(block_grid.len_displayed(), 1);
|
||||
assert_eq!(
|
||||
block_grid.cursor_display_point(),
|
||||
Some(CursorDisplayPoint::Visible(Point::new(1, 1)))
|
||||
);
|
||||
|
||||
block_grid.set_trim_trailing_blank_rows(true);
|
||||
|
||||
assert_eq!(block_grid.len_displayed(), 1);
|
||||
assert_eq!(
|
||||
block_grid.cursor_display_point(),
|
||||
Some(CursorDisplayPoint::HiddenCache(Point::new(0, 1)))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
/// Stages during the course of bootstrapping the shell.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BootstrapStage {
|
||||
/// Warp is re-parsing historical blocks for this session. We haven't yet started
|
||||
/// bootstrapping.
|
||||
RestoreBlocks,
|
||||
/// Warp is writing the bootstrap script into the running shell.
|
||||
WarpInput,
|
||||
/// Execution of any shell startup scripts such as .rc or .profile files.
|
||||
ScriptExecution,
|
||||
/// Model is fully bootstrapped (i.e the `Bootstrap` message was successfully received by Warp).
|
||||
Bootstrapped,
|
||||
/// Model is fully bootstrapped and we've received the precmd that results from bootstrapping itself
|
||||
PostBootstrapPrecmd,
|
||||
}
|
||||
|
||||
impl BootstrapStage {
|
||||
pub fn next_stage(&self) -> Self {
|
||||
match self {
|
||||
BootstrapStage::RestoreBlocks => BootstrapStage::WarpInput,
|
||||
BootstrapStage::WarpInput => BootstrapStage::ScriptExecution,
|
||||
BootstrapStage::ScriptExecution => {
|
||||
log::error!("calling next_stage on a block that should be bootstrapped");
|
||||
BootstrapStage::ScriptExecution
|
||||
}
|
||||
BootstrapStage::Bootstrapped => BootstrapStage::PostBootstrapPrecmd,
|
||||
BootstrapStage::PostBootstrapPrecmd => {
|
||||
log::error!(
|
||||
"calling next_stage on an already bootstrapped block that has seen precmd"
|
||||
);
|
||||
BootstrapStage::PostBootstrapPrecmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_bootstrapped(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
BootstrapStage::Bootstrapped | BootstrapStage::PostBootstrapPrecmd
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_done(&self) -> bool {
|
||||
matches!(self, BootstrapStage::PostBootstrapPrecmd)
|
||||
}
|
||||
|
||||
/// WarpInput is the one block that is hidden by default (unless debug mode is on).
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
matches!(self, BootstrapStage::WarpInput)
|
||||
}
|
||||
|
||||
/// We only can have an empty block that's shown if it's a block a user created by hitting enter, or if it's
|
||||
/// a restored block that was created by the user hitting enter.
|
||||
pub fn is_empty_block_allowed(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
BootstrapStage::RestoreBlocks | BootstrapStage::PostBootstrapPrecmd
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use warp_completer::completer::{Match, MatchedSuggestion, Suggestion, SuggestionType};
|
||||
|
||||
/// The completions data coming from the shell.
|
||||
///
|
||||
/// For now, we only support sourcing shell completion data for zsh,
|
||||
/// but eventually parsing will require knowing which shell the data came from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ShellData {
|
||||
/// The completions will be emitted as raw shell completion data, where
|
||||
/// each individual result is delimited by whitespace.
|
||||
/// For now, we assume that the results are sorted.
|
||||
Raw { output: String },
|
||||
/// The completions will be emitted as typed completion results, one-by-one, directly from the shell.
|
||||
/// After a completion is emitted, it can still be updated; see [`ShellCompletion::update`].
|
||||
IncrementallyTyped { output: Vec<ShellCompletion> },
|
||||
}
|
||||
|
||||
/// A completion result that was produced natively by the shell.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShellCompletion {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
suggestion_type: SuggestionType,
|
||||
}
|
||||
|
||||
/// Enum indicating which field of a [`ShellCompletion`] should be updated.
|
||||
pub enum ShellCompletionUpdate {
|
||||
Description { value: String },
|
||||
}
|
||||
|
||||
impl ShellCompletion {
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name: name.trim().to_string(),
|
||||
description: None,
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update(&mut self, completion_update: ShellCompletionUpdate) {
|
||||
match completion_update {
|
||||
ShellCompletionUpdate::Description { value } => {
|
||||
if !value.is_empty() {
|
||||
self.description = Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellData {
|
||||
/// Returns the corresponding `ShellData` given a format type.
|
||||
pub fn from_format_type(format: &str) -> Option<ShellData> {
|
||||
match format {
|
||||
"raw" => Some(ShellData::Raw {
|
||||
output: Default::default(),
|
||||
}),
|
||||
"incrementally_typed" => Some(ShellData::IncrementallyTyped {
|
||||
output: Default::default(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShellData> for Vec<ShellCompletion> {
|
||||
fn from(shell_data: ShellData) -> Self {
|
||||
match shell_data {
|
||||
// TODO(suraj): Determine the correct parsing strategy for raw shell completion data.
|
||||
ShellData::Raw { output } => output
|
||||
.split_whitespace()
|
||||
.map(|name| ShellCompletion::new(name.into()))
|
||||
.collect(),
|
||||
ShellData::IncrementallyTyped { mut output } => {
|
||||
// TODO: we need to get metadata from the shell about how the results
|
||||
// should be sorted (intra- and inter-groups).
|
||||
output.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShellCompletion> for MatchedSuggestion {
|
||||
fn from(value: ShellCompletion) -> Self {
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
value.name,
|
||||
value.description,
|
||||
value.suggestion_type,
|
||||
Default::default(),
|
||||
);
|
||||
MatchedSuggestion::new(
|
||||
suggestion,
|
||||
Match::Prefix {
|
||||
is_case_sensitive: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::mem;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use crate::safe_debug;
|
||||
use crate::terminal::view::CONTROL_MASTER_ERROR_REGEX;
|
||||
use crate::terminal::{event::Event as TerminalEvent, event_listener::ChannelEventListener};
|
||||
|
||||
use super::ansi;
|
||||
use super::block::Block;
|
||||
use super::blocks::BlockList;
|
||||
use super::selection::ScrollDelta;
|
||||
use super::session::SessionInfo;
|
||||
use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "early_output_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// The approach we're using to detect user typeahead.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum TypeaheadMode {
|
||||
/// The shell reports its input buffer to Warp, and we use that for typeahead.
|
||||
ShellReported,
|
||||
/// Warp matches user input against characters echoed to the PTY to estimate typeahead.
|
||||
/// This is only used on bash 3.2 and should be removed if we stop supporting
|
||||
/// such old bash versions.
|
||||
InputMatching,
|
||||
}
|
||||
|
||||
/// Model for "early" terminal output. Early output is output that Warp receives
|
||||
/// from the PTY while no block is running. In concrete terms, it's output received
|
||||
/// after a `BlockFinished` hook but before Warp has written the next command from
|
||||
/// the input editor to the PTY.
|
||||
///
|
||||
/// This output belongs to one of two categories:
|
||||
/// 1. Typeahead - if a user types while a command is running, but the command
|
||||
/// doesn't read that input, it's echoed as the basis for the next command. This
|
||||
/// lets users queue up commands if their connection is slow or a command
|
||||
/// takes longer than expected.
|
||||
/// 2. Background output - if a background job is running, it can print output
|
||||
/// outside the context of a running block. Additionally, the shell might
|
||||
/// print messages about job completion.
|
||||
pub struct EarlyOutput {
|
||||
mode: TypeaheadMode,
|
||||
|
||||
/// The currently-accumulated typeahead.
|
||||
typeahead: String,
|
||||
|
||||
/// Counter for the number of typeahead characters inserted into the current
|
||||
/// input buffer. We can receive multiple typeahead events, so this counter
|
||||
/// tells us how many characters to replace with new typeahead.
|
||||
typeahead_chars_inserted: CharOffset,
|
||||
|
||||
/// User input that may be typeahead, which is matched against echoed text.
|
||||
unmatched_input: VecDeque<char>,
|
||||
/// Whether the last potential typeahead character received on the PTY was a
|
||||
/// carriage return. We can't rely on the last character of `typeahead` for
|
||||
/// this, because it only stores _matched_ typeahead.
|
||||
just_matched_carriage_return: bool,
|
||||
|
||||
/// The event proxy sends terminal events (in this case, typeahead), to the
|
||||
/// terminal view.
|
||||
event_proxy: ChannelEventListener,
|
||||
pending_background_block: Option<Block>,
|
||||
}
|
||||
|
||||
impl EarlyOutput {
|
||||
/// Creates a new `EarlyOutput` model. The event proxy is used to notify
|
||||
/// the terminal view about new typeahead.
|
||||
pub fn new(event_proxy: ChannelEventListener) -> Self {
|
||||
Self {
|
||||
// Default to InputMatching as a baseline for all shells.
|
||||
mode: TypeaheadMode::InputMatching,
|
||||
typeahead: String::new(),
|
||||
typeahead_chars_inserted: 0.into(),
|
||||
unmatched_input: VecDeque::new(),
|
||||
just_matched_carriage_return: false,
|
||||
event_proxy,
|
||||
pending_background_block: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the typeahead mode to use given the features that the current
|
||||
/// shell session supports.
|
||||
pub fn init_session(&mut self, session_info: &SessionInfo) {
|
||||
let supports_input_reporting = session_info.shell.input_reporting_sequence().is_some();
|
||||
self.mode = if supports_input_reporting {
|
||||
TypeaheadMode::ShellReported
|
||||
} else {
|
||||
TypeaheadMode::InputMatching
|
||||
};
|
||||
log::info!("Configured typeahead mode as {:?}", self.mode);
|
||||
}
|
||||
|
||||
/// Returns a reference to the current typeahead.
|
||||
pub fn typeahead(&self) -> &str {
|
||||
&self.typeahead
|
||||
}
|
||||
|
||||
/// Record input from the user as potential typeahead.
|
||||
pub fn push_user_input(&mut self, input: &str) {
|
||||
if self.mode == TypeaheadMode::InputMatching {
|
||||
self.unmatched_input.extend(input.chars().filter(|ch| {
|
||||
// Only keep control characters that we expect to match in the echoed typeahead.
|
||||
!ch.is_ascii_control() || *ch == '\r'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the unmatched user input. This is called between blocks so that
|
||||
/// unmatched potential typeahead from one command doesn't throw off input
|
||||
/// matching for the rest of the session.
|
||||
pub fn reset_user_input(&mut self) {
|
||||
self.unmatched_input.clear();
|
||||
}
|
||||
|
||||
/// Returns whether the next user input character matches `ch`. If it does
|
||||
/// match, the character is consumed.
|
||||
fn consume_user_input(&mut self, ch: char) -> bool {
|
||||
let is_match = self.unmatched_input.front() == Some(&ch);
|
||||
if is_match {
|
||||
self.unmatched_input.pop_front();
|
||||
}
|
||||
is_match
|
||||
}
|
||||
|
||||
/// Check a character received on the PTY, which may be typeahead or
|
||||
/// background output.
|
||||
fn handle_potential_typeahead(&mut self, ch: char) -> bool {
|
||||
let is_typeahead = match self.mode {
|
||||
TypeaheadMode::InputMatching => {
|
||||
// By default, the ONLCR TTY option is set, so carriage returns (from
|
||||
// the enter key) are echoed as `\r\n`. If we match a carriage return
|
||||
// as typeahead, we want to match the newline as well.
|
||||
self.consume_user_input(ch) || (self.just_matched_carriage_return && ch == '\n')
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.just_matched_carriage_return = is_typeahead && ch == '\r';
|
||||
|
||||
if is_typeahead {
|
||||
self.typeahead.push(ch);
|
||||
safe_debug!(
|
||||
safe: ("Matched PTY output as typeahead"),
|
||||
full: ("Matched {ch:?} as typeahead")
|
||||
);
|
||||
|
||||
if warp_core::channel::ChannelState::channel()
|
||||
== warp_core::channel::Channel::Integration
|
||||
{
|
||||
log::info!(
|
||||
"Sending input-matched typeahead event for {:?}",
|
||||
self.typeahead
|
||||
);
|
||||
}
|
||||
|
||||
self.event_proxy
|
||||
.send_terminal_event(TerminalEvent::Typeahead);
|
||||
}
|
||||
is_typeahead
|
||||
}
|
||||
|
||||
/// Fetch and advance the current typeahead state. This returns the accumulated
|
||||
/// typeahead along with the count of previous typeahead to overwrite. The
|
||||
/// internal count is then updated to match the new typeahead length.
|
||||
pub fn advance_typeahead(&mut self) -> Option<(&str, CharOffset)> {
|
||||
if self.typeahead.is_empty() {
|
||||
if warp_core::channel::ChannelState::channel()
|
||||
== warp_core::channel::Channel::Integration
|
||||
{
|
||||
log::warn!("Tried to advance typeahead, but it was empty");
|
||||
}
|
||||
|
||||
None
|
||||
} else {
|
||||
let prev_inserted = self.typeahead_chars_inserted;
|
||||
self.typeahead_chars_inserted = self.typeahead.chars().count().into();
|
||||
Some((&self.typeahead, prev_inserted))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update typeahead state before the next command. This is called from the
|
||||
/// blocklist's precmd hook, but doesn't implement the [`ansi::Handler`]
|
||||
/// interface because it doesn't need precmd data.
|
||||
pub fn precmd(&mut self) {
|
||||
// On precmd, clear accumulated typeahead for the previous command.
|
||||
safe_debug!(
|
||||
safe: ("Clearing accumulated typeahead"),
|
||||
full: ("Clearing accumulated typeahead: {:?}", self.typeahead)
|
||||
);
|
||||
self.typeahead.clear();
|
||||
self.typeahead_chars_inserted = 0.into();
|
||||
}
|
||||
|
||||
/// Update early output state once the next command has started running. After
|
||||
/// this point, output we receive is no longer "early". This is called from
|
||||
/// the blocklist's preexec hook, but doesn't implement the [`ansi::Handler`]
|
||||
/// interface because it doesn't need preexec data.
|
||||
pub fn preexec(block_list: &mut BlockList) {
|
||||
if block_list.early_output().mode == TypeaheadMode::ShellReported {
|
||||
// We use this to fill in the command grid for commands that are submitted as typeahead (the
|
||||
// user types in a command and hits Enter before the previous command) finishes.
|
||||
// When commands are queued up like this, the shell runs them back-to-back.
|
||||
// For most user-entered commands, we know when to switch from background
|
||||
// output to the active block's command grid because the input editor
|
||||
// marks the block as started right before it sends the command to the pty.
|
||||
// When the command doesn't come from Warp, however, the active block isn't
|
||||
// started until we receive the preexec hook. At this point, the shell has
|
||||
// already written the command to the pty, resulting in Warp treating it as
|
||||
// background output.
|
||||
// We can't correctly identify the command in advance when this happens, so
|
||||
// instead we fix the block list afterwards.
|
||||
if !block_list.active_block().started() {
|
||||
if let Some(background_block) = block_list.remove_background_block() {
|
||||
log::debug!("Repairing command from background block");
|
||||
block_list
|
||||
.active_block_mut()
|
||||
.copy_command_grid(background_block.output_grid());
|
||||
block_list.update_active_block_height();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an [`ansi::Handler`] adapter for early output.
|
||||
pub(super) fn handler(block_list: &mut BlockList) -> impl ansi::Handler + '_ {
|
||||
EarlyOutputHandler { block_list }
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the pending background block, if one
|
||||
/// exists.
|
||||
pub(super) fn pending_background_block_mut(&mut self) -> Option<&mut Block> {
|
||||
self.pending_background_block.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ansi::Handler`] adapter for [`EarlyOutput`]. To handle early output, we
|
||||
/// need a reference to the [`BlockList`], for creating background output blocks.
|
||||
/// Since `BlockList` owns `EarlyOutput`, `EarlyOutput` can't hold a reference to
|
||||
/// its parent. Instead, this adapter temporarily references the `BlockList` and,
|
||||
/// by extension, the `EarlyOutput`.
|
||||
struct EarlyOutputHandler<'a> {
|
||||
block_list: &'a mut BlockList,
|
||||
}
|
||||
|
||||
impl EarlyOutputHandler<'_> {
|
||||
fn inner(&mut self) -> &mut EarlyOutput {
|
||||
self.block_list.early_output_mut()
|
||||
}
|
||||
|
||||
/// Runs `f` against the current background output block, creating a new one
|
||||
/// if needed.
|
||||
///
|
||||
/// If the block was already live, this updates the block heights SumTree
|
||||
/// if needed. If the block starts as a result of `f`, it's added to the
|
||||
/// block list.
|
||||
fn with_background_output<T>(&mut self, f: impl FnOnce(&mut Block) -> T) -> T {
|
||||
fn store_pending_block(block_list: &mut BlockList, block: Block) {
|
||||
if block.started() {
|
||||
block_list.insert_background_block(block);
|
||||
} else {
|
||||
block_list.early_output_mut().pending_background_block = Some(block);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mut block) = self.inner().pending_background_block.take() {
|
||||
debug_assert!(
|
||||
!block.started(),
|
||||
"Started background blocks should be in the block list"
|
||||
);
|
||||
let retval = f(&mut block);
|
||||
store_pending_block(self.block_list, block);
|
||||
retval
|
||||
} else if let Some(block) = self.block_list.background_block_mut() {
|
||||
f(block)
|
||||
} else {
|
||||
let mut block = self.block_list.create_pending_background_block();
|
||||
let retval = f(&mut block);
|
||||
store_pending_block(self.block_list, block);
|
||||
retval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate for `EarlyOutput` that will eventually delegate the method to the
|
||||
/// background block/grid
|
||||
macro_rules! delegate {
|
||||
($self:ident.$method:ident( $( $arg:expr ),* )) => {
|
||||
$self.with_background_output(|block| {
|
||||
block.$method($( $arg ),*)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
impl ansi::Handler for EarlyOutputHandler<'_> {
|
||||
fn input(&mut self, c: char) {
|
||||
let session_id = self.block_list.active_block().session_id();
|
||||
if !self.inner().handle_potential_typeahead(c) {
|
||||
self.with_background_output(|block| {
|
||||
// We don't start background blocks until they have content because
|
||||
// the shell often prints control characters in between commands
|
||||
// to reset terminal state. If we eagerly added background blocks,
|
||||
// there would be an empty one before almost every command.
|
||||
if !block.started() {
|
||||
block.start_background(session_id);
|
||||
}
|
||||
block.input(c);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the current typeahead. We use this when we have complete typeahead
|
||||
/// information, such as when the shell reports its input buffer.
|
||||
fn input_buffer(&mut self, data: ansi::InputBufferValue) {
|
||||
if data.buffer.is_empty() {
|
||||
if warp_core::channel::ChannelState::channel()
|
||||
== warp_core::channel::Channel::Integration
|
||||
{
|
||||
log::info!("Ignoring empty input buffer");
|
||||
}
|
||||
// avoids a race condition when the user enters multiple lines of
|
||||
// typeahead. Suppose the user enters the following typeahead:
|
||||
// > cd foo <ENTER>
|
||||
// > pwd
|
||||
// When the running command finishes, we'll fetch `pwd` as typeahead
|
||||
// from the shell and then clear its input buffer. The shell will
|
||||
// immediately execute `cd foo`, which will start a new block. We'll
|
||||
// ask the shell for its input buffer again, but at this point, we've
|
||||
// already cleared it. If we overwrite our stored typeahead before
|
||||
// the terminal view has added it to the input buffer, it will be
|
||||
// lost.
|
||||
return;
|
||||
}
|
||||
|
||||
let me = self.inner();
|
||||
if me.mode == TypeaheadMode::ShellReported {
|
||||
me.typeahead = data.buffer;
|
||||
if warp_core::channel::ChannelState::channel()
|
||||
== warp_core::channel::Channel::Integration
|
||||
{
|
||||
log::info!(
|
||||
"Sending shell-reported typeahead event for {:?}",
|
||||
me.typeahead
|
||||
);
|
||||
}
|
||||
me.event_proxy.send_terminal_event(TerminalEvent::Typeahead);
|
||||
safe_debug!(
|
||||
safe: ("Received shell input buffer for typeahead"),
|
||||
full: ("Received shell input buffer for typeahead: {:?}", me.typeahead)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn carriage_return(&mut self) {
|
||||
if !self.inner().handle_potential_typeahead('\r') {
|
||||
delegate!(self.carriage_return());
|
||||
}
|
||||
}
|
||||
|
||||
fn linefeed(&mut self) -> ScrollDelta {
|
||||
if self.inner().handle_potential_typeahead('\n') {
|
||||
// If we match a newline as typeahead, this means the shell will
|
||||
// execute the accumulated typeahead as a new command. In that case,
|
||||
// the shell doesn't re-echo the command, so we fill in the command
|
||||
// grid here.
|
||||
if self.inner().mode == TypeaheadMode::InputMatching {
|
||||
let command = mem::take(&mut self.inner().typeahead);
|
||||
safe_debug!(
|
||||
safe: ("Initializing command grid from matched typeahead"),
|
||||
full: ("Initializing command grid from matched typeahead: {command:?}")
|
||||
);
|
||||
self.block_list.active_block_mut().init_command(command);
|
||||
self.block_list.update_active_block_height();
|
||||
}
|
||||
|
||||
ScrollDelta::zero()
|
||||
} else {
|
||||
let lines_scrolled = delegate!(self.linefeed());
|
||||
|
||||
// SSH ControlMaster errors _should_ be categorized as background output.
|
||||
// To avoid checking on every character of background input, we only
|
||||
// match the most recent line after it is completed.
|
||||
if let Some(block) = self.block_list.background_block_mut() {
|
||||
let last_line = block
|
||||
.output_grid()
|
||||
.contents_to_string(false /* include_escape_sequences */, Some(1));
|
||||
if CONTROL_MASTER_ERROR_REGEX.is_match(&last_line) {
|
||||
self.inner()
|
||||
.event_proxy
|
||||
.send_terminal_event(TerminalEvent::SSHControlMasterError);
|
||||
}
|
||||
}
|
||||
|
||||
lines_scrolled
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Handler methods which should not be reached.
|
||||
*/
|
||||
|
||||
fn set_title(&mut self, _: Option<String>) {
|
||||
log::warn!(
|
||||
"Handler method EarlyOutput::set_title should never be called. This should be handled by TerminalModel"
|
||||
);
|
||||
}
|
||||
|
||||
fn push_title(&mut self) {
|
||||
log::warn!(
|
||||
"Handler method EarlyOutput::push_title should never be called. This should be handled by TerminalModel"
|
||||
);
|
||||
}
|
||||
|
||||
fn pop_title(&mut self) {
|
||||
log::warn!(
|
||||
"Handler method EarlyOutput::pop_title should never be called. This should be handled by TerminalModel"
|
||||
);
|
||||
}
|
||||
|
||||
fn precmd(&mut self, _data: ansi::PrecmdValue) {
|
||||
panic!("Called EarlyOutput::precmd handler method instead of Block::precmd");
|
||||
}
|
||||
|
||||
/*
|
||||
* Handler methods only relevant to background output.
|
||||
*/
|
||||
|
||||
fn set_cursor_style(&mut self, style: Option<ansi::CursorStyle>) {
|
||||
delegate!(self.set_cursor_style(style));
|
||||
}
|
||||
|
||||
fn set_cursor_shape(&mut self, shape: ansi::CursorShape) {
|
||||
delegate!(self.set_cursor_shape(shape));
|
||||
}
|
||||
|
||||
fn goto(&mut self, row: super::index::VisibleRow, col: usize) {
|
||||
delegate!(self.goto(row, col));
|
||||
}
|
||||
|
||||
fn goto_line(&mut self, row: super::index::VisibleRow) {
|
||||
delegate!(self.goto_line(row));
|
||||
}
|
||||
|
||||
fn goto_col(&mut self, col: usize) {
|
||||
delegate!(self.goto_col(col));
|
||||
}
|
||||
|
||||
fn insert_blank(&mut self, count: usize) {
|
||||
delegate!(self.insert_blank(count));
|
||||
}
|
||||
|
||||
fn move_up(&mut self, rows: usize) {
|
||||
delegate!(self.move_up(rows));
|
||||
}
|
||||
|
||||
fn move_down(&mut self, rows: usize) {
|
||||
delegate!(self.move_down(rows));
|
||||
}
|
||||
|
||||
fn identify_terminal<W: std::io::Write>(&mut self, writer: &mut W, intermediate: Option<char>) {
|
||||
delegate!(self.identify_terminal(writer, intermediate));
|
||||
}
|
||||
|
||||
fn report_xtversion<W: std::io::Write>(&mut self, writer: &mut W) {
|
||||
delegate!(self.report_xtversion(writer));
|
||||
}
|
||||
|
||||
fn device_status<W: std::io::Write>(&mut self, writer: &mut W, arg: usize) {
|
||||
delegate!(self.device_status(writer, arg));
|
||||
}
|
||||
|
||||
fn move_forward(&mut self, columns: usize) {
|
||||
delegate!(self.move_forward(columns));
|
||||
}
|
||||
|
||||
fn move_backward(&mut self, columns: usize) {
|
||||
delegate!(self.move_backward(columns));
|
||||
}
|
||||
|
||||
fn move_down_and_cr(&mut self, rows: usize) {
|
||||
delegate!(self.move_down_and_cr(rows));
|
||||
}
|
||||
|
||||
fn move_up_and_cr(&mut self, rows: usize) {
|
||||
delegate!(self.move_up_and_cr(rows));
|
||||
}
|
||||
|
||||
fn put_tab(&mut self, count: u16) {
|
||||
delegate!(self.put_tab(count));
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
delegate!(self.backspace());
|
||||
}
|
||||
|
||||
fn bell(&mut self) {
|
||||
delegate!(self.bell());
|
||||
}
|
||||
|
||||
fn substitute(&mut self) {
|
||||
delegate!(self.substitute());
|
||||
}
|
||||
|
||||
fn newline(&mut self) {
|
||||
delegate!(self.newline());
|
||||
}
|
||||
|
||||
fn set_horizontal_tabstop(&mut self) {
|
||||
delegate!(self.set_horizontal_tabstop());
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, rows: usize) -> ScrollDelta {
|
||||
delegate!(self.scroll_up(rows))
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, rows: usize) -> ScrollDelta {
|
||||
delegate!(self.scroll_down(rows))
|
||||
}
|
||||
|
||||
fn insert_blank_lines(&mut self, rows: usize) -> ScrollDelta {
|
||||
delegate!(self.insert_blank_lines(rows))
|
||||
}
|
||||
|
||||
fn delete_lines(&mut self, rows: usize) -> ScrollDelta {
|
||||
delegate!(self.delete_lines(rows))
|
||||
}
|
||||
|
||||
fn erase_chars(&mut self, count: usize) {
|
||||
delegate!(self.erase_chars(count));
|
||||
}
|
||||
|
||||
fn delete_chars(&mut self, count: usize) {
|
||||
delegate!(self.delete_chars(count));
|
||||
}
|
||||
|
||||
fn move_backward_tabs(&mut self, count: u16) {
|
||||
delegate!(self.move_backward_tabs(count));
|
||||
}
|
||||
|
||||
fn move_forward_tabs(&mut self, count: u16) {
|
||||
delegate!(self.move_forward_tabs(count));
|
||||
}
|
||||
|
||||
fn save_cursor_position(&mut self) {
|
||||
delegate!(self.save_cursor_position());
|
||||
}
|
||||
|
||||
fn restore_cursor_position(&mut self) {
|
||||
delegate!(self.restore_cursor_position());
|
||||
}
|
||||
|
||||
fn clear_line(&mut self, mode: ansi::LineClearMode) {
|
||||
delegate!(self.clear_line(mode));
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self, mode: ansi::ClearMode) {
|
||||
delegate!(self.clear_screen(mode));
|
||||
}
|
||||
|
||||
fn clear_tabs(&mut self, mode: ansi::TabulationClearMode) {
|
||||
delegate!(self.clear_tabs(mode));
|
||||
}
|
||||
|
||||
fn reset_state(&mut self) {
|
||||
delegate!(self.reset_state());
|
||||
}
|
||||
|
||||
fn reverse_index(&mut self) -> ScrollDelta {
|
||||
delegate!(self.reverse_index())
|
||||
}
|
||||
|
||||
fn terminal_attribute(&mut self, attribute: ansi::Attr) {
|
||||
delegate!(self.terminal_attribute(attribute));
|
||||
}
|
||||
|
||||
fn set_mode(&mut self, mode: ansi::Mode) {
|
||||
delegate!(self.set_mode(mode));
|
||||
}
|
||||
|
||||
fn unset_mode(&mut self, mode: ansi::Mode) {
|
||||
delegate!(self.unset_mode(mode));
|
||||
}
|
||||
|
||||
fn set_scrolling_region(&mut self, top: usize, bottom: Option<usize>) {
|
||||
delegate!(self.set_scrolling_region(top, bottom));
|
||||
}
|
||||
|
||||
fn set_keypad_application_mode(&mut self) {
|
||||
delegate!(self.set_keypad_application_mode());
|
||||
}
|
||||
|
||||
fn unset_keypad_application_mode(&mut self) {
|
||||
delegate!(self.unset_keypad_application_mode());
|
||||
}
|
||||
|
||||
fn set_active_charset(&mut self, index: ansi::CharsetIndex) {
|
||||
delegate!(self.set_active_charset(index));
|
||||
}
|
||||
|
||||
fn configure_charset(&mut self, index: ansi::CharsetIndex, charset: ansi::StandardCharset) {
|
||||
delegate!(self.configure_charset(index, charset));
|
||||
}
|
||||
|
||||
fn set_color(&mut self, index: usize, color: ColorU) {
|
||||
delegate!(self.set_color(index, color));
|
||||
}
|
||||
|
||||
fn dynamic_color_sequence<W: std::io::Write>(
|
||||
&mut self,
|
||||
writer: &mut W,
|
||||
code: u8,
|
||||
index: usize,
|
||||
terminator: &str,
|
||||
) {
|
||||
delegate!(self.dynamic_color_sequence(writer, code, index, terminator));
|
||||
}
|
||||
|
||||
fn reset_color(&mut self, index: usize) {
|
||||
delegate!(self.reset_color(index));
|
||||
}
|
||||
|
||||
fn clipboard_store(&mut self, clipboard: u8, data: &[u8]) {
|
||||
delegate!(self.clipboard_store(clipboard, data));
|
||||
}
|
||||
|
||||
fn clipboard_load(&mut self, clipboard: u8, terminator: &str) {
|
||||
delegate!(self.clipboard_load(clipboard, terminator));
|
||||
}
|
||||
|
||||
fn decaln(&mut self) {
|
||||
delegate!(self.decaln());
|
||||
}
|
||||
|
||||
fn text_area_size_pixels<W: std::io::Write>(&mut self, writer: &mut W) {
|
||||
delegate!(self.text_area_size_pixels(writer));
|
||||
}
|
||||
|
||||
fn text_area_size_chars<W: std::io::Write>(&mut self, writer: &mut W) {
|
||||
delegate!(self.text_area_size_chars(writer));
|
||||
}
|
||||
|
||||
fn on_finish_byte_processing(&mut self, input: &ansi::ProcessorInput<'_>) {
|
||||
delegate!(self.on_finish_byte_processing(input));
|
||||
}
|
||||
|
||||
fn prompt_marker(&mut self, _marker: ansi::PromptMarker) {
|
||||
log::error!(
|
||||
"Received prompt_marker in EarlyOutput, but it should be sent to the active block by the blocklist"
|
||||
);
|
||||
}
|
||||
|
||||
fn set_keyboard_enhancement_flags(
|
||||
&mut self,
|
||||
mode: KeyboardModes,
|
||||
apply: KeyboardModesApplyBehavior,
|
||||
) {
|
||||
delegate!(self.set_keyboard_enhancement_flags(mode, apply));
|
||||
}
|
||||
|
||||
fn push_keyboard_enhancement_flags(&mut self, mode: KeyboardModes) {
|
||||
delegate!(self.push_keyboard_enhancement_flags(mode));
|
||||
}
|
||||
|
||||
fn pop_keyboard_enhancement_flags(&mut self, count: u16) {
|
||||
delegate!(self.pop_keyboard_enhancement_flags(count));
|
||||
}
|
||||
|
||||
fn query_keyboard_enhancement_flags<W: std::io::Write>(&mut self, writer: &mut W) {
|
||||
delegate!(self.query_keyboard_enhancement_flags(writer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use crate::ai::blocklist::agent_view::AgentViewState;
|
||||
use crate::terminal::{
|
||||
event_listener::ChannelEventListener,
|
||||
model::{
|
||||
ansi::{self, Handler},
|
||||
blocks::BlockList,
|
||||
session::SessionInfo,
|
||||
test_utils::TestBlockListBuilder,
|
||||
},
|
||||
shell::ShellType,
|
||||
};
|
||||
|
||||
use super::TypeaheadMode;
|
||||
|
||||
/// Create a new bootstrapped block list that will use the set typeahead mode.
|
||||
fn new_block_list(event_proxy: ChannelEventListener, mode: TypeaheadMode) -> BlockList {
|
||||
let mut block_list = TestBlockListBuilder::new()
|
||||
.with_channel_event_proxy(event_proxy)
|
||||
.build();
|
||||
|
||||
let (shell, shell_version) = match mode {
|
||||
TypeaheadMode::ShellReported => ("zsh", "5.0"),
|
||||
TypeaheadMode::InputMatching => ("bash", "3.2"),
|
||||
};
|
||||
let init_shell_value = ansi::InitShellValue {
|
||||
shell: shell.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bootstrapped_value = ansi::BootstrappedValue {
|
||||
shell: shell.into(),
|
||||
shell_version: Some(shell_version.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let session_info = SessionInfo::create_pending(
|
||||
ShellType::from_name(shell).unwrap(),
|
||||
init_shell_value,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.merge_from_bootstrapped_value(bootstrapped_value.clone(), false);
|
||||
|
||||
block_list.bootstrapped(bootstrapped_value);
|
||||
block_list.early_output_mut().init_session(&session_info);
|
||||
assert_eq!(block_list.early_output_mut().mode, mode);
|
||||
|
||||
block_list.command_finished(Default::default());
|
||||
block_list.precmd(Default::default());
|
||||
assert!(block_list.is_bootstrapping_precmd_done());
|
||||
block_list
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lazy_background_insertion() {
|
||||
let mut block_list = new_block_list(
|
||||
ChannelEventListener::new_for_test(),
|
||||
TypeaheadMode::ShellReported,
|
||||
);
|
||||
|
||||
// Mimic the shell resetting terminal styles between commands.
|
||||
block_list.carriage_return();
|
||||
block_list.clear_line(ansi::LineClearMode::Right);
|
||||
block_list.terminal_attribute(ansi::Attr::Reset);
|
||||
|
||||
// At this point, the background block should not have been inserted.
|
||||
assert!(block_list.background_block_mut().is_none());
|
||||
assert!(block_list.is_empty());
|
||||
|
||||
// Write actual background output.
|
||||
block_list.input('h');
|
||||
block_list.input('i');
|
||||
block_list.linefeed();
|
||||
block_list.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
assert!(!block_list.is_empty());
|
||||
let background = block_list
|
||||
.background_block_mut()
|
||||
.expect("Background block should exist");
|
||||
assert_eq!(background.output_to_string(), "hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_triggers_wakeup() {
|
||||
let (wakeups_tx, wakeups_rx) = async_channel::unbounded();
|
||||
let mut block_list = new_block_list(
|
||||
ChannelEventListener::builder_for_test()
|
||||
.with_wakeups_tx(wakeups_tx)
|
||||
.build(),
|
||||
TypeaheadMode::ShellReported,
|
||||
);
|
||||
while !wakeups_rx.is_empty() {
|
||||
let _ = wakeups_rx.try_recv();
|
||||
}
|
||||
|
||||
// Write background output.
|
||||
block_list.input('b');
|
||||
|
||||
// There should now be a background block and a wakeup call.
|
||||
assert!(block_list.background_block_mut().is_some());
|
||||
assert!(wakeups_rx.recv_blocking().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queued_typeahead_input_matching() {
|
||||
let mut block_list = new_block_list(
|
||||
ChannelEventListener::new_for_test(),
|
||||
TypeaheadMode::InputMatching,
|
||||
);
|
||||
|
||||
// Provide two lines of typeahead.
|
||||
block_list
|
||||
.early_output_mut()
|
||||
.push_user_input("first\rsecond");
|
||||
|
||||
// Mimic the shell echoing and executing the first command.
|
||||
block_list.input('f');
|
||||
block_list.input('i');
|
||||
block_list.input('r');
|
||||
block_list.input('s');
|
||||
block_list.input('t');
|
||||
block_list.carriage_return();
|
||||
block_list.linefeed();
|
||||
assert!(block_list.active_block().is_command_empty());
|
||||
// With input matching, typeahead is never written to the background block.
|
||||
assert!(block_list.background_block_mut().is_none());
|
||||
// On preexec, the block list detects that the command is missing and restores
|
||||
// it from typeahead.
|
||||
block_list.preexec(ansi::PreexecValue {
|
||||
command: "first".into(),
|
||||
});
|
||||
assert_eq!(block_list.active_block().command_to_string(), "first");
|
||||
block_list.command_finished(Default::default());
|
||||
block_list.precmd(Default::default());
|
||||
|
||||
// Once the second line of typeahead is echoed, it should be recognized as typeahead.
|
||||
block_list.input('s');
|
||||
block_list.input('e');
|
||||
block_list.input('c');
|
||||
block_list.input('o');
|
||||
block_list.input('n');
|
||||
block_list.input('d');
|
||||
assert_eq!(block_list.early_output().typeahead(), "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queued_typeahead_shell_reported() {
|
||||
let mut block_list = new_block_list(
|
||||
ChannelEventListener::new_for_test(),
|
||||
TypeaheadMode::ShellReported,
|
||||
);
|
||||
|
||||
// Mimic the shell echoing and executing the first command.
|
||||
block_list.input('f');
|
||||
block_list.input('i');
|
||||
block_list.input('r');
|
||||
block_list.input('s');
|
||||
block_list.input('t');
|
||||
block_list.carriage_return();
|
||||
block_list.linefeed();
|
||||
block_list.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert!(block_list.active_block().is_command_empty());
|
||||
assert_eq!(
|
||||
block_list
|
||||
.background_block_mut()
|
||||
.expect("Background block should exist")
|
||||
.output_to_string(),
|
||||
"first\n"
|
||||
);
|
||||
// On preexec, the block list detects that the command is missing and restores
|
||||
// it from background output, removing the background block in the process.
|
||||
block_list.preexec(ansi::PreexecValue {
|
||||
command: "first".into(),
|
||||
});
|
||||
assert_eq!(block_list.active_block().command_to_string(), "first");
|
||||
assert!(block_list.background_block_mut().is_none());
|
||||
|
||||
block_list.command_finished(Default::default());
|
||||
block_list.precmd(Default::default());
|
||||
|
||||
// Now, when the second line is echoed, it should be recognized as typeahead.
|
||||
block_list.input('s');
|
||||
block_list.input('e');
|
||||
block_list.input('c');
|
||||
block_list.input('o');
|
||||
block_list.input('n');
|
||||
block_list.input('d');
|
||||
// Mimic the ESC-i keybinding, which clears the input buffer.
|
||||
block_list.input_buffer(ansi::InputBufferValue {
|
||||
buffer: "second".into(),
|
||||
});
|
||||
// zsh appears to use `\r\e[J` (carriage return and clear from cursor to end of screen)
|
||||
// to clear the line. There are lots of ways of doing this, and it doesn't
|
||||
// matter exactly which one the shell uses as long as the effect on the grid is the same.
|
||||
block_list.carriage_return();
|
||||
block_list.clear_screen(ansi::ClearMode::Below);
|
||||
block_list.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
assert_eq!(block_list.early_output().typeahead(), "second");
|
||||
// Unlike regular blocks, if the output grid of a background block is cleared
|
||||
// then it becomes hidden.
|
||||
assert!(block_list
|
||||
.background_block_mut()
|
||||
.expect("Block should exist")
|
||||
.is_empty(&AgentViewState::Inactive));
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use regex::escape;
|
||||
use regex_automata::hybrid::dfa::{Cache, DFA};
|
||||
use regex_automata::hybrid::BuildError;
|
||||
use regex_automata::nfa::thompson;
|
||||
use regex_automata::util::pool::Pool;
|
||||
use regex_automata::util::syntax::Config;
|
||||
use regex_automata::{Anchored, Input};
|
||||
use warp_terminal::model::grid::CellType;
|
||||
|
||||
use crate::terminal::model::index::Direction;
|
||||
use crate::terminal::model::index::Point;
|
||||
|
||||
use super::grid::grapheme_cursor;
|
||||
use super::grid::grid_handler::GridHandler;
|
||||
|
||||
pub type Match = RangeInclusive<Point>;
|
||||
|
||||
/// Describes the state of the find bar configuration options
|
||||
///
|
||||
/// Used to configure DFA in the correct way
|
||||
pub struct FindConfig {
|
||||
pub is_regex_enabled: bool,
|
||||
pub is_case_sensitive: bool,
|
||||
}
|
||||
|
||||
impl Default for FindConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_regex_enabled: true,
|
||||
is_case_sensitive: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the closure we use to create new caches.
|
||||
type CachePoolFn = Box<dyn Fn() -> Cache + Send + Sync>;
|
||||
|
||||
/// Struct that provides APIs to search through a [`Grid`] using a regular expression pattern.
|
||||
#[derive(Debug)]
|
||||
pub struct RegexDFAs {
|
||||
/// DFA used to search the grid from left to right.
|
||||
forward_dfa: DFA,
|
||||
/// DFA used to search the grid from right to left.
|
||||
reverse_dfa: DFA,
|
||||
/// Thread safe pool cache for the forward-DFA. Since we use "lazy" DFAs (which are built
|
||||
/// incrementally during search) we need to cache the DFA's transitional table. This is
|
||||
/// continuously updated when moving through states within the DFA.
|
||||
forward_pool: Pool<Cache, CachePoolFn>,
|
||||
/// Thread safe pool cache for the reverse-DFA. Since we use "lazy" DFAs (which are built
|
||||
/// incrementally during search) we need to cache the DFA's transitional table. This is
|
||||
/// continuously updated when moving through states within the DFA.
|
||||
reverse_pool: Pool<Cache, CachePoolFn>,
|
||||
}
|
||||
|
||||
impl RegexDFAs {
|
||||
// Create case-insensitive Regex DFAs for all find directions.
|
||||
pub fn new(find: &str) -> Result<RegexDFAs, Box<BuildError>> {
|
||||
Self::new_with_config(find, FindConfig::default())
|
||||
}
|
||||
|
||||
/// Constructs a [`RegexDFAs`] that matches any of the patterns provided.
|
||||
pub fn new_many(
|
||||
patterns: &[&str],
|
||||
enable_unicode_word_boundary: bool,
|
||||
case_sensitive: bool,
|
||||
) -> Result<RegexDFAs, Box<BuildError>> {
|
||||
let mut builder = DFA::builder();
|
||||
builder.configure(
|
||||
DFA::config()
|
||||
.unicode_word_boundary(enable_unicode_word_boundary)
|
||||
// Increase the default maximum cache capacity by 4x. The default is
|
||||
// 2MB, which isn't quite enough to efficiently handle large regexes.
|
||||
.cache_capacity(DFA::config().get_cache_capacity() << 2)
|
||||
// Just in case our increased cache capacity is somehow too small to
|
||||
// run the regex at all, we tell the builder to increase the cache
|
||||
// capacity even further if required to meet the minimum.
|
||||
.skip_cache_capacity_check(true),
|
||||
);
|
||||
if !case_sensitive {
|
||||
builder.syntax(Config::new().case_insensitive(true));
|
||||
}
|
||||
Self::new_internal(patterns, builder)
|
||||
}
|
||||
|
||||
// Based on FindConfig, create DFAs for all directions
|
||||
pub fn new_with_config(
|
||||
find: &str,
|
||||
find_config: FindConfig,
|
||||
) -> Result<RegexDFAs, Box<BuildError>> {
|
||||
let mut builder = DFA::builder();
|
||||
if !find_config.is_case_sensitive {
|
||||
builder.syntax(Config::new().case_insensitive(true));
|
||||
}
|
||||
if find_config.is_regex_enabled {
|
||||
let patched_find = replace_unicode_word_boundaries(find);
|
||||
Self::new_internal(&[&patched_find], builder)
|
||||
} else {
|
||||
Self::new_internal(&[&escape(find)], builder)
|
||||
}
|
||||
}
|
||||
|
||||
fn new_internal(
|
||||
patterns: &[&str],
|
||||
mut builder: regex_automata::hybrid::dfa::Builder,
|
||||
) -> Result<RegexDFAs, Box<BuildError>> {
|
||||
// Build a forward and reverse DFA to allow us to find matches either left-to-right or
|
||||
// right-to-left.
|
||||
// We don't use the hybrid Regex (https://docs.rs/regex-automata/latest/regex_automata/hybrid/regex/struct.Regex.html)
|
||||
// struct directly since it would require us to create two different instances of a `Regex`,
|
||||
// which internally would create 4 different DFAs when we really only need 2 to support the
|
||||
// functionality of searching through a grid from either direction.
|
||||
let forward_dfa = builder.clone().build_many(patterns)?;
|
||||
let reverse_dfa = builder
|
||||
.thompson(thompson::Config::new().reverse(true))
|
||||
.build_many(patterns)?;
|
||||
|
||||
let forward_cache = forward_dfa.create_cache();
|
||||
let reverse_cache = reverse_dfa.create_cache();
|
||||
|
||||
let forward_pool = {
|
||||
let create: CachePoolFn = Box::new(move || forward_cache.clone());
|
||||
Pool::new(create)
|
||||
};
|
||||
|
||||
let reverse_pool = {
|
||||
let create: CachePoolFn = Box::new(move || reverse_cache.clone());
|
||||
Pool::new(create)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
forward_dfa,
|
||||
reverse_dfa,
|
||||
forward_pool,
|
||||
reverse_pool,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the next regex match to the right of the origin point by beginning at the `left` Point
|
||||
/// and searching until the `right` Point is reached, inclusive of both points.
|
||||
///
|
||||
/// The origin is always included in the regex.
|
||||
pub fn regex_search_rightwards(
|
||||
&self,
|
||||
left: Point,
|
||||
right: Point,
|
||||
grid: &GridHandler,
|
||||
) -> Option<Match> {
|
||||
// Scan from the left -> right to find the end (rightmost) point of the match.
|
||||
let match_right_point = self.search(left, right, Direction::Right, grid, Anchored::No)?;
|
||||
|
||||
// Scan leftwards from the match end to the left most point to find the beginning (leftmost) point of the match.
|
||||
let match_left_point = self.search(
|
||||
match_right_point,
|
||||
left,
|
||||
Direction::Left,
|
||||
grid,
|
||||
Anchored::Yes,
|
||||
)?;
|
||||
|
||||
Some(match_left_point..=match_right_point)
|
||||
}
|
||||
|
||||
/// Find the next regex match to the left of the `right` Point by searching leftwards from
|
||||
/// `right` until the `left` Point is reached.
|
||||
///
|
||||
/// The origin is always included in the regex.
|
||||
pub fn regex_search_leftwards(
|
||||
&self,
|
||||
right: Point,
|
||||
left: Point,
|
||||
grid: &GridHandler,
|
||||
) -> Option<Match> {
|
||||
// Scan leftwards to find the starting (leftmost) point of the match.
|
||||
let match_left_point = self.search(right, left, Direction::Left, grid, Anchored::No)?;
|
||||
// Scan rightwards from the match start to the rightmost point to find the end (rightmost) point of the match.
|
||||
let match_right_point = self.search(
|
||||
match_left_point,
|
||||
right,
|
||||
Direction::Right,
|
||||
grid,
|
||||
Anchored::Yes,
|
||||
)?;
|
||||
|
||||
Some(match_left_point..=match_right_point)
|
||||
}
|
||||
|
||||
/// Find the next regex match, given a direction.
|
||||
///
|
||||
/// This will always return the side of the first match which is farthest from the start point.
|
||||
fn search(
|
||||
&self,
|
||||
start: Point,
|
||||
end: Point,
|
||||
direction: Direction,
|
||||
grid: &GridHandler,
|
||||
anchored: Anchored,
|
||||
) -> Option<Point> {
|
||||
let (dfa, mut cache) = match direction {
|
||||
Direction::Left => (&self.reverse_dfa, self.reverse_pool.get()),
|
||||
Direction::Right => (&self.forward_dfa, self.forward_pool.get()),
|
||||
};
|
||||
|
||||
let mut cursor = grid.grapheme_cursor_from(start, grapheme_cursor::Wrap::All);
|
||||
|
||||
// Initialize the match state. DFAs can have multiple start states, but only when there are
|
||||
// look-around assertions. When there aren't any look-around assertions, as in this case,
|
||||
// we can ask for a start state without providing any of the haystack. See
|
||||
// https://blog.burntsushi.net/regex-internals.
|
||||
let mut state = dfa
|
||||
.start_state_forward(&mut cache, &Input::new("").anchored(anchored))
|
||||
.ok()?;
|
||||
|
||||
let mut regex_match = None;
|
||||
|
||||
// The state of a DFA is always delayed by one byte in order to support look-around
|
||||
// operators. Store the _previous_ point as we iterate through the grid to ensure that we
|
||||
// don't eagerly report the current point if the additional byte from the current point
|
||||
// triggers a match for the last point.
|
||||
let mut last_point = None;
|
||||
|
||||
'outer: loop {
|
||||
let Some(cursor_item) = cursor.current_item() else {
|
||||
break;
|
||||
};
|
||||
let c = cursor_item.content_char();
|
||||
let current_point = cursor_item.point();
|
||||
|
||||
// Convert char to array of bytes.
|
||||
let mut buf = [0; 4];
|
||||
let utf8_len = c.encode_utf8(&mut buf).len();
|
||||
|
||||
// Pass char to DFA as individual bytes.
|
||||
for i in 0..utf8_len {
|
||||
// Inverse byte order when going left.
|
||||
let byte = match direction {
|
||||
Direction::Right => buf[i],
|
||||
Direction::Left => buf[utf8_len - i - 1],
|
||||
};
|
||||
|
||||
state = dfa.next_state(&mut cache, state, byte).ok()?;
|
||||
if state.is_match() {
|
||||
regex_match = last_point;
|
||||
} else if state.is_dead() {
|
||||
// If regex is in a dead state, it will never reach a match state.
|
||||
// Break out of the loop here.
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
last_point = Some(current_point);
|
||||
|
||||
// Stop once we've reached the target point.
|
||||
if current_point == end {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle linebreaks.
|
||||
let at_line_break = match direction {
|
||||
Direction::Left => cursor.is_at_start_of_line(),
|
||||
Direction::Right => cursor.is_at_end_of_line(),
|
||||
};
|
||||
if at_line_break {
|
||||
match regex_match {
|
||||
// If we are at the line break and there is already a match, break out of the loop.
|
||||
Some(_) => break,
|
||||
// If we are at a line break and there is no match, reset the match state.
|
||||
None => {
|
||||
// Before resetting the match state, walk the special "EOI" transition to
|
||||
// check if the DFA now has a match. Since the match state is always delayed
|
||||
// by a byte, this can happen if the the last cell on a line would end up
|
||||
// triggering a match.
|
||||
state = dfa.next_eoi_state(&mut cache, state).ok()?;
|
||||
if state.is_match() {
|
||||
regex_match = last_point;
|
||||
break;
|
||||
}
|
||||
|
||||
state = dfa
|
||||
.start_state_forward(&mut cache, &Input::new("").anchored(anchored))
|
||||
.ok()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance grid cell iterator.
|
||||
match direction {
|
||||
Direction::Right => {
|
||||
cursor.move_forward();
|
||||
}
|
||||
Direction::Left => {
|
||||
cursor.move_backward();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
state = dfa.next_eoi_state(&mut cache, state).ok()?;
|
||||
if state.is_match() {
|
||||
regex_match = last_point;
|
||||
}
|
||||
|
||||
// Make sure the match point is at the "far" end of any wide character.
|
||||
if let Some(match_point) = &mut regex_match {
|
||||
if direction == Direction::Right
|
||||
&& matches!(grid.cell_type(*match_point), Some(CellType::WideChar))
|
||||
{
|
||||
match_point.col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
regex_match
|
||||
}
|
||||
}
|
||||
|
||||
/// By default, \b doesn't work in `regex-automata`. See this section in their docs:
|
||||
/// https://docs.rs/regex/latest/regex/index.html#unicode-can-impact-memory-usage-and-search-speed
|
||||
///
|
||||
/// "This crate has first class support for Unicode and it is enabled by default... However, some
|
||||
/// of the faster internal regex engines cannot handle a Unicode aware word boundary assertion. So
|
||||
/// if you don’t need Unicode-aware word boundary assertions, you might consider using (?-u:\b)
|
||||
/// instead of \b, where the former uses an ASCII-only definition of a word character."
|
||||
///
|
||||
/// Including a \b in a regex causes compilation of the regex to fail with a haystack containing
|
||||
/// unicode. Therefore, we replace it with the ASCII-only version as the docs suggest.
|
||||
///
|
||||
/// Note: One alternative could be use enable this option:
|
||||
/// https://docs.rs/regex-automata/0.4.6/regex_automata/hybrid/dfa/struct.Config.html#method.unicode_word_boundary
|
||||
/// However, "this only works when the search input is ASCII only." This assumption is
|
||||
/// often false in the terminal context, which often contains emojis, box-drawing chars,
|
||||
/// international text, etc.
|
||||
fn replace_unicode_word_boundaries(pattern: &str) -> String {
|
||||
pattern.replace("\\b", "(?-u:\\b)")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
use std::ops::{Index, IndexMut};
|
||||
|
||||
use crate::terminal::model::index::IndexRange;
|
||||
|
||||
/// Default tab interval, corresponding to terminfo `it` value.
|
||||
const INITIAL_TABSTOPS: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TabStops {
|
||||
tabs: Vec<bool>,
|
||||
}
|
||||
|
||||
impl TabStops {
|
||||
#[inline]
|
||||
pub fn new(num_cols: usize) -> TabStops {
|
||||
TabStops {
|
||||
tabs: IndexRange::from(0..num_cols)
|
||||
.map(|i| i % INITIAL_TABSTOPS == 0)
|
||||
.collect::<Vec<bool>>(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all tabstops.
|
||||
#[inline]
|
||||
pub fn clear_all(&mut self) {
|
||||
unsafe {
|
||||
std::ptr::write_bytes(self.tabs.as_mut_ptr(), 0, self.tabs.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Increase tabstop capacity.
|
||||
#[inline]
|
||||
pub fn resize(&mut self, num_cols: usize) {
|
||||
let mut index = self.tabs.len();
|
||||
self.tabs.resize_with(num_cols, || {
|
||||
let is_tabstop = index.is_multiple_of(INITIAL_TABSTOPS);
|
||||
index += 1;
|
||||
is_tabstop
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for TabStops {
|
||||
type Output = bool;
|
||||
|
||||
fn index(&self, index: usize) -> &bool {
|
||||
&self.tabs[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for TabStops {
|
||||
fn index_mut(&mut self, index: usize) -> &mut bool {
|
||||
self.tabs.index_mut(index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
|
||||
use bimap::BiMap;
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::terminal::model::index::Point;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplaySource {
|
||||
CursorLine,
|
||||
FilterMatch,
|
||||
FilterContext,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DisplayedRows {
|
||||
pub range: RangeInclusive<usize>,
|
||||
pub source: DisplaySource,
|
||||
}
|
||||
|
||||
impl DisplayedRows {
|
||||
#[cfg(test)]
|
||||
pub fn new(range: RangeInclusive<usize>, source: DisplaySource) -> Self {
|
||||
Self { range, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not to respect the displayed output/filter when
|
||||
/// retrieving the grid contents.
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum RespectDisplayedOutput {
|
||||
/// Points are assumed to represent the displayed location in the grid.
|
||||
Yes,
|
||||
/// Points are assumed to represent the original location in the grid.
|
||||
No,
|
||||
}
|
||||
|
||||
/// Structure to represent a subset of rows from the grid that we want to
|
||||
/// make visible when rendering.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct DisplayedOutput {
|
||||
/// The rows that we want to display in the grid, represented by their
|
||||
/// row indices in the grid. The ranges must be non-overlapping and in
|
||||
/// sorted order, from lowest to highest.
|
||||
displayed_rows: Vec<DisplayedRows>,
|
||||
/// Height of the displayed rows in the grid. This is equivalent to the
|
||||
/// number of displayed rows.
|
||||
height: usize,
|
||||
row_translation_map: RowTranslationMap,
|
||||
}
|
||||
|
||||
/// A mapping to translate locations in the grid between the original row and the offset row after filtering.
|
||||
/// NOTE: `Left` = original row and `Right` = offset/translated row
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RowTranslationMap {
|
||||
pub(crate) inner: BiMap<usize, usize>,
|
||||
}
|
||||
|
||||
impl From<BiMap<usize, usize>> for RowTranslationMap {
|
||||
fn from(value: BiMap<usize, usize>) -> Self {
|
||||
Self { inner: value }
|
||||
}
|
||||
}
|
||||
|
||||
impl RowTranslationMap {
|
||||
/// Translates a point from its original location to the displayed point's location (the offset with the filter applied).
|
||||
///
|
||||
/// NOTE: If the original location is not found in the row translation map,
|
||||
/// then it will return the given point.
|
||||
pub fn maybe_translate_point_from_original_to_displayed(&self, original_point: Point) -> Point {
|
||||
self.inner
|
||||
.get_by_left(&original_point.row)
|
||||
.map(|translated_row| Point {
|
||||
row: *translated_row,
|
||||
col: original_point.col,
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("Could not translate point {original_point:?} to its displayed location, returning given point instead");
|
||||
original_point
|
||||
})
|
||||
}
|
||||
|
||||
/// Translates a point from its displayed location (the offset with the filter applied) to its original location.
|
||||
///
|
||||
/// NOTE: If the displayed location is not found in the row translation map,
|
||||
/// then it will return the given point.
|
||||
pub fn maybe_translate_point_from_displayed_to_original(
|
||||
&self,
|
||||
displayed_point: Point,
|
||||
) -> Point {
|
||||
self.inner
|
||||
.get_by_right(&displayed_point.row)
|
||||
.map(|original_row| Point {
|
||||
row: *original_row,
|
||||
col: displayed_point.col,
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("Could not translate point {displayed_point:?} to its original location, returning given point instead");
|
||||
displayed_point
|
||||
})
|
||||
}
|
||||
|
||||
/// Translates a row from its displayed location (the offset with the filter applied) to its original location.
|
||||
///
|
||||
/// NOTE: If the displayed location is not found in the row translation map,
|
||||
/// then it will return the given row.
|
||||
pub fn maybe_translate_row_from_displayed_to_original(&self, displayed_row: usize) -> usize {
|
||||
*self.inner
|
||||
.get_by_right(&displayed_row)
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("Could not translate row {displayed_row:?} to its original location, returning given row instead");
|
||||
&displayed_row
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true iff the `row` is a displayed row.
|
||||
pub fn is_displayed_row(&self, row: usize) -> bool {
|
||||
self.inner.contains_left(&row)
|
||||
}
|
||||
}
|
||||
|
||||
impl DisplayedOutput {
|
||||
/// Constructs a new instance of DisplayedOutput from the given displayed
|
||||
/// rows.
|
||||
///
|
||||
/// This is faster than creating a new instance via DisplayedOutput::default()
|
||||
/// and calling extend_displayed_lines.
|
||||
///
|
||||
/// Caller must ensure that the row ranges in displayed_rows are:
|
||||
/// - In ascending order
|
||||
/// - Representing one or more whole logical lines. The row ranges cannot
|
||||
/// only have part of a logical line.
|
||||
pub fn new_from_displayed_lines(displayed_lines: Vec<DisplayedRows>) -> Self {
|
||||
// Assert that the displayed rows are in ascending order.
|
||||
debug_assert!(displayed_lines
|
||||
.windows(2)
|
||||
.all(|w| w[0].range.end() < w[1].range.start()));
|
||||
|
||||
let mut height = 0;
|
||||
for rows in displayed_lines.iter() {
|
||||
height += (rows.range.end() - rows.range.start()) + 1;
|
||||
}
|
||||
|
||||
let mut original_to_offset_rows = BiMap::with_capacity(height);
|
||||
for (offset, row) in displayed_lines
|
||||
.iter()
|
||||
.map(|rows| &rows.range)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
original_to_offset_rows.insert(row, offset);
|
||||
}
|
||||
|
||||
Self {
|
||||
displayed_rows: displayed_lines,
|
||||
height,
|
||||
row_translation_map: RowTranslationMap {
|
||||
inner: original_to_offset_rows,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the height of the displayed rows.
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn displayed_rows(&self) -> &[DisplayedRows] {
|
||||
&self.displayed_rows
|
||||
}
|
||||
|
||||
/// Returns an iterator over the indices in the grid of the displayed rows.
|
||||
pub fn rows(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||
self.displayed_rows
|
||||
.iter()
|
||||
.map(|rows| &rows.range)
|
||||
.cloned()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Marks a set of row ranges representing logical lines in the grid as
|
||||
/// visible at the end of the currently displayed lines.
|
||||
///
|
||||
/// Caller must ensure that the given lines are in ascending order and
|
||||
/// greater than all existing lines.
|
||||
pub fn extend_displayed_lines(&mut self, displayed_lines: Vec<DisplayedRows>) {
|
||||
for rows in displayed_lines.iter() {
|
||||
self.height += (rows.range.end() - rows.range.start()) + 1;
|
||||
}
|
||||
self.append_to_row_translation(displayed_lines.iter());
|
||||
self.displayed_rows.extend(displayed_lines);
|
||||
|
||||
// Assert that displayed_rows is still in ascending order.
|
||||
debug_assert!(self
|
||||
.displayed_rows
|
||||
.windows(2)
|
||||
.all(|w| w[0].range.end() < w[1].range.start()));
|
||||
}
|
||||
|
||||
/// Marks a set of row ranges representing logical lines in the grid as
|
||||
/// visible at the beginning of the currently displayed lines.
|
||||
///
|
||||
/// Caller must ensure that the given lines are in ascending order and
|
||||
/// less than all existing lines.
|
||||
pub fn prepend_displayed_lines(&mut self, displayed_lines: Vec<DisplayedRows>) {
|
||||
for rows in displayed_lines.iter() {
|
||||
self.height += (rows.range.end() - rows.range.start()) + 1;
|
||||
}
|
||||
self.displayed_rows.splice(0..0, displayed_lines);
|
||||
self.prepend_to_row_translation();
|
||||
|
||||
// Assert that displayed_rows is still in ascending order.
|
||||
debug_assert!(self
|
||||
.displayed_rows
|
||||
.windows(2)
|
||||
.all(|w| w[0].range.end() < w[1].range.start()));
|
||||
}
|
||||
|
||||
/// Replaces the specified range in the existing displayed lines with new
|
||||
/// displayed lines. The new displayed lines need not be the same length
|
||||
/// as the specified range.
|
||||
///
|
||||
/// Caller must ensure that the specified range is valid and the new lines
|
||||
/// are inserted in ascending order.
|
||||
pub fn splice_displayed_lines(
|
||||
&mut self,
|
||||
replace_range: Range<usize>,
|
||||
displayed_lines: Vec<DisplayedRows>,
|
||||
) -> Vec<DisplayedRows> {
|
||||
for rows in displayed_lines.iter() {
|
||||
self.height += (rows.range.end() - rows.range.start()) + 1;
|
||||
}
|
||||
|
||||
let removed = self
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), displayed_lines)
|
||||
.collect_vec();
|
||||
self.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
for rows in removed.iter() {
|
||||
self.height = self
|
||||
.height
|
||||
.saturating_sub((rows.range.end() - rows.range.start()) + 1);
|
||||
}
|
||||
|
||||
// Assert that displayed_rows is still in ascending order.
|
||||
debug_assert!(self
|
||||
.displayed_rows
|
||||
.windows(2)
|
||||
.all(|w| w[0].range.end() < w[1].range.start()));
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Truncates the displayed rows so only rows up to and including the provided
|
||||
/// row are kept.
|
||||
///
|
||||
/// e.g. If we are displaying rows [2..=4, 6..=10, 16..=17], truncating to
|
||||
/// to row 8 would yield [2..=4, 6..=8].
|
||||
///
|
||||
/// Mainly used when finishing a block, as the grid gets truncated to the
|
||||
/// current cursor position.
|
||||
pub fn truncate_to_row(&mut self, row: usize) {
|
||||
if let Some(first) = self.displayed_rows.first() {
|
||||
// If we are truncating before the first rows, just clear it all.
|
||||
if row < *first.range.start() {
|
||||
self.displayed_rows.clear();
|
||||
self.height = 0;
|
||||
return;
|
||||
}
|
||||
} else if let Some(last) = self.displayed_rows.last() {
|
||||
// If we are truncating after the last rows, do nothing.
|
||||
if *last.range.end() < row {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the index of the range that we want to truncate from. Searches
|
||||
// from right-to-left assuming that most of the time, the row to
|
||||
// truncate to will be near the end of displayed_rows.
|
||||
let mut truncate_idx = None;
|
||||
for (i, rows) in self.displayed_rows.iter().enumerate().rev() {
|
||||
if row > *rows.range.end() {
|
||||
break;
|
||||
}
|
||||
truncate_idx = Some(i);
|
||||
}
|
||||
|
||||
let Some(truncate_idx) = truncate_idx else {
|
||||
return;
|
||||
};
|
||||
let mut truncated = self.displayed_rows.drain(truncate_idx..);
|
||||
|
||||
let mut partial_rows = None;
|
||||
// We need to handle the first truncated range specially. We may be
|
||||
// truncating in the middle of the range, in which case we want to
|
||||
// keep the partial row range before the truncate point.
|
||||
if let Some(first_truncated) = truncated.next() {
|
||||
self.height = self
|
||||
.height
|
||||
.saturating_sub((first_truncated.range.end() - first_truncated.range.start()) + 1);
|
||||
|
||||
if first_truncated.range.contains(&row) {
|
||||
partial_rows = Some(DisplayedRows {
|
||||
range: *first_truncated.range.start()..=row,
|
||||
source: first_truncated.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract the remaining truncated rows from the height.
|
||||
for rows in truncated {
|
||||
self.height = self
|
||||
.height
|
||||
.saturating_sub((rows.range.end() - rows.range.start()) + 1);
|
||||
}
|
||||
|
||||
if let Some(partial_rows) = partial_rows {
|
||||
self.height += (partial_rows.range.end() - partial_rows.range.start()) + 1;
|
||||
self.displayed_rows.push(partial_rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the row translation map assuming that we only appended displayed rows.
|
||||
/// NOTE: Must be called before updating `self.displayed_rows`.
|
||||
fn append_to_row_translation<'a, I>(&mut self, new_line_ranges: I)
|
||||
where
|
||||
I: IntoIterator<Item = &'a DisplayedRows>,
|
||||
{
|
||||
let last_displayed_row = self
|
||||
.displayed_rows
|
||||
.last()
|
||||
.map(|last_displayed_row| *last_displayed_row.range.end())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut offset_row = if let Some(last_row_offset) = self
|
||||
.row_translation_map
|
||||
.inner
|
||||
.get_by_left(&last_displayed_row)
|
||||
{
|
||||
last_row_offset + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
for rows in new_line_ranges {
|
||||
for row in rows.range.clone() {
|
||||
self.row_translation_map.inner.insert(row, offset_row);
|
||||
offset_row += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the row translation map assuming that we only prepended displayed rows.
|
||||
/// NOTE: must be called after updating `self.displayed_rows`.
|
||||
fn prepend_to_row_translation(&mut self) {
|
||||
// None of the current entries are valid.
|
||||
self.row_translation_map.inner.clear();
|
||||
|
||||
for (offset, row) in self
|
||||
.displayed_rows
|
||||
.iter()
|
||||
.map(|row_range| &row_range.range)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
self.row_translation_map.inner.insert(row, offset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the row translation map assuming that we replaced some of the displayed rows.
|
||||
/// `replace_range` represents the range of indices in displayed rows that has been updated.
|
||||
/// NOTE: Must be called after updating `self.displayed_rows`.
|
||||
fn replace_rows_from_row_translation(&mut self, replace_range: Range<usize>) {
|
||||
let first_row_before_replace_range = if replace_range.start == 0 {
|
||||
// We are replacing the beginning row(s).
|
||||
None
|
||||
} else {
|
||||
self.displayed_rows
|
||||
.get(replace_range.start - 1)
|
||||
.map(|first_replaced| first_replaced.range.end())
|
||||
};
|
||||
|
||||
let mut offset_row =
|
||||
first_row_before_replace_range.map_or(0, |first_row_before_replace_range| {
|
||||
if let Some(row) = self
|
||||
.row_translation_map
|
||||
.inner
|
||||
.get_by_left(first_row_before_replace_range)
|
||||
{
|
||||
row + 1
|
||||
} else {
|
||||
// There is no offset which means we are at the beginning.
|
||||
0
|
||||
}
|
||||
});
|
||||
|
||||
// Clear all rows from our start offset row to the last offset row.
|
||||
let last_offset_row = self.row_translation_map.inner.len();
|
||||
for row in offset_row..=last_offset_row {
|
||||
self.row_translation_map.inner.remove_by_right(&row);
|
||||
}
|
||||
// Insert entries for all entries in self.displayed_rows from the start of the replace range.
|
||||
for rows in &self.displayed_rows[replace_range.start..] {
|
||||
for row in rows.range.clone() {
|
||||
self.row_translation_map.inner.insert(row, offset_row);
|
||||
offset_row += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a point from its original location to the displayed point's location (the offset with the filter applied).
|
||||
///
|
||||
/// NOTE: If the original location is not found in the row translation map,
|
||||
/// then it will return the given point.
|
||||
pub fn maybe_translate_point_from_original_to_displayed(&self, original_point: Point) -> Point {
|
||||
self.row_translation_map
|
||||
.maybe_translate_point_from_original_to_displayed(original_point)
|
||||
}
|
||||
|
||||
/// Translates a point from its displayed location (the offset with the filter applied) to its original location.
|
||||
///
|
||||
/// NOTE: If the displayed location is not found in the row translation map,
|
||||
/// then it will return the given point.
|
||||
pub fn maybe_translate_point_from_displayed_to_original(
|
||||
&self,
|
||||
displayed_point: Point,
|
||||
) -> Point {
|
||||
self.row_translation_map
|
||||
.maybe_translate_point_from_displayed_to_original(displayed_point)
|
||||
}
|
||||
|
||||
/// Translates a row from its displayed location (the offset with the filter applied) to its original location.
|
||||
///
|
||||
/// NOTE: If the displayed location is not found in the row translation map,
|
||||
/// then it will return the given row.
|
||||
pub fn maybe_translate_row_from_displayed_to_original(&self, displayed_row: usize) -> usize {
|
||||
self.row_translation_map
|
||||
.maybe_translate_row_from_displayed_to_original(displayed_row)
|
||||
}
|
||||
|
||||
/// Returns true if `row` is a displayed row.
|
||||
pub fn is_displayed_row(&self, row: usize) -> bool {
|
||||
self.row_translation_map.is_displayed_row(row)
|
||||
}
|
||||
|
||||
/// If the given original row is being displayed, returns the row it is
|
||||
/// displayed at. Otherwise, searches for the next closest original row that
|
||||
/// is greater than the given original row and is being displayed, and
|
||||
/// returns the row that is displayed at. If there is no next closest row,
|
||||
/// returns None.
|
||||
///
|
||||
/// e.g. If our displayed output structure looked like this:
|
||||
/// original row | displayed row
|
||||
/// 2 | 0
|
||||
/// 3 | 1
|
||||
/// 7 | 2
|
||||
/// get_exact_or_next_displayed_row(2) == 0
|
||||
/// get_exact_or_next_displayed_row(4) == 2
|
||||
/// get_exact_or_next_displayed_row(8) == None
|
||||
pub fn get_exact_or_next_displayed_row(&self, target_original_row: usize) -> Option<usize> {
|
||||
if self.row_translation_map.inner.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(displayed_row) = self
|
||||
.row_translation_map
|
||||
.inner
|
||||
.get_by_left(&target_original_row)
|
||||
{
|
||||
return Some(*displayed_row);
|
||||
}
|
||||
|
||||
// Perform binary search for displayed row of next closest original row.
|
||||
let mut low = 0;
|
||||
let mut high = self.row_translation_map.inner.len() - 1;
|
||||
let mut candidate = None;
|
||||
|
||||
while low <= high {
|
||||
let displayed_row = (high + low) / 2;
|
||||
let original_row = self
|
||||
.row_translation_map
|
||||
.inner
|
||||
.get_by_right(&displayed_row)?;
|
||||
|
||||
match original_row.cmp(&target_original_row) {
|
||||
Ordering::Greater => {
|
||||
candidate = Some(displayed_row);
|
||||
if displayed_row == 0 {
|
||||
// Break early to avoid underflowing.
|
||||
break;
|
||||
}
|
||||
high = displayed_row - 1;
|
||||
}
|
||||
Ordering::Less => {
|
||||
low = displayed_row + 1;
|
||||
}
|
||||
Ordering::Equal => {
|
||||
// We should never reach this case because of the early exit
|
||||
// condition at the beginning of the method, but it is included
|
||||
// here for completeness.
|
||||
return Some(displayed_row);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Finds the first displayed rows that are fully or partially greater than
|
||||
/// or equal to the given row index.
|
||||
///
|
||||
/// Returns the index and a reference to the DisplayedRows object.
|
||||
///
|
||||
/// This is mainly used for updating the dirty lines when filtering an active
|
||||
/// block. The common case has the dirty lines at the end of the block (i.e.
|
||||
/// adding new lines to the output), so this method performs a reverse linear
|
||||
/// search to optimize for this case.
|
||||
pub fn first_rows_greater_than_or_contained_in(
|
||||
&self,
|
||||
row: usize,
|
||||
) -> Option<(usize, &DisplayedRows)> {
|
||||
let mut start_idx = None;
|
||||
let mut start_rows = None;
|
||||
for (idx, rows) in self.displayed_rows.iter().enumerate().rev() {
|
||||
if row > *rows.range.end() {
|
||||
break;
|
||||
}
|
||||
start_idx = Some(idx);
|
||||
start_rows = Some(rows);
|
||||
}
|
||||
start_idx.zip(start_rows)
|
||||
}
|
||||
|
||||
/// Finds the last displayed rows that are fully or partially less than or
|
||||
/// equal to the given row index.
|
||||
///
|
||||
/// Returns the index and a reference to the DisplayedRows object.
|
||||
pub fn last_rows_less_than_or_contained_in(
|
||||
&self,
|
||||
row: usize,
|
||||
) -> Option<(usize, &DisplayedRows)> {
|
||||
self.displayed_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, rows)| *rows.range.start() <= row)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test(displayed_rows: Vec<RangeInclusive<usize>>) -> Self {
|
||||
DisplayedOutput::new_from_displayed_lines(
|
||||
displayed_rows
|
||||
.into_iter()
|
||||
.map(|rows| DisplayedRows {
|
||||
range: rows,
|
||||
source: DisplaySource::FilterMatch,
|
||||
})
|
||||
.collect_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.displayed_rows.clear();
|
||||
self.height = 0;
|
||||
self.row_translation_map.inner.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "displayed_output_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,717 @@
|
||||
use super::*;
|
||||
use bimap::BiMap;
|
||||
use itertools::Itertools;
|
||||
|
||||
/// Converts a vector of ranges into DisplayedRows objects with source FilterMatch.
|
||||
fn make_displayed_rows_from_ranges(ranges: Vec<RangeInclusive<usize>>) -> Vec<DisplayedRows> {
|
||||
ranges
|
||||
.into_iter()
|
||||
.map(|range| DisplayedRows {
|
||||
range,
|
||||
source: DisplaySource::FilterMatch,
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_displayed_output_rows_iterator() {
|
||||
let displayed_output = DisplayedOutput::new_for_test(vec![0..=2, 4..=4, 7..=10]);
|
||||
let iterator = displayed_output.rows();
|
||||
|
||||
let res = iterator.collect_vec();
|
||||
assert_eq!(res, vec![0, 1, 2, 4, 7, 8, 9, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_displayed_output_rows_iterator_no_rows() {
|
||||
let displayed_output = DisplayedOutput::default();
|
||||
let mut iterator = displayed_output.rows();
|
||||
|
||||
assert!(iterator.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_truncate_to_row() {
|
||||
let mut displayed_output =
|
||||
DisplayedOutput::new_for_test(vec![6..=11, 15..=17, 20..=25, 27..=30]);
|
||||
|
||||
displayed_output.truncate_to_row(19);
|
||||
assert_eq!(
|
||||
displayed_output.displayed_rows,
|
||||
make_displayed_rows_from_ranges(vec![6..=11, 15..=17])
|
||||
);
|
||||
assert_eq!(displayed_output.height, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_truncate_to_row_truncate_before_start() {
|
||||
let mut displayed_output = DisplayedOutput::new_for_test(vec![6..=11, 12..=14]);
|
||||
|
||||
displayed_output.truncate_to_row(5);
|
||||
assert_eq!(displayed_output.displayed_rows, vec![]);
|
||||
assert_eq!(displayed_output.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_truncate_to_row_truncate_after_end() {
|
||||
let mut displayed_output = DisplayedOutput::new_for_test(vec![6..=11, 12..=14]);
|
||||
|
||||
displayed_output.truncate_to_row(15);
|
||||
assert_eq!(
|
||||
displayed_output.displayed_rows,
|
||||
make_displayed_rows_from_ranges(vec![6..=11, 12..=14])
|
||||
);
|
||||
assert_eq!(displayed_output.height, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_truncate_to_row_in_range() {
|
||||
let mut displayed_output =
|
||||
DisplayedOutput::new_for_test(vec![6..=11, 15..=17, 20..=25, 27..=30]);
|
||||
|
||||
displayed_output.truncate_to_row(16);
|
||||
assert_eq!(
|
||||
displayed_output.displayed_rows,
|
||||
make_displayed_rows_from_ranges(vec![6..=11, 15..=16])
|
||||
);
|
||||
assert_eq!(displayed_output.height, 8);
|
||||
|
||||
let mut displayed_output =
|
||||
DisplayedOutput::new_for_test(vec![6..=11, 15..=17, 20..=25, 27..=30]);
|
||||
|
||||
displayed_output.truncate_to_row(15);
|
||||
assert_eq!(
|
||||
displayed_output.displayed_rows,
|
||||
make_displayed_rows_from_ranges(vec![6..=11, 15..=15])
|
||||
);
|
||||
assert_eq!(displayed_output.height, 7);
|
||||
|
||||
let mut displayed_output =
|
||||
DisplayedOutput::new_for_test(vec![6..=11, 15..=17, 20..=25, 27..=30]);
|
||||
|
||||
displayed_output.truncate_to_row(17);
|
||||
assert_eq!(
|
||||
displayed_output.displayed_rows,
|
||||
make_displayed_rows_from_ranges(vec![6..=11, 15..=17])
|
||||
);
|
||||
assert_eq!(displayed_output.height, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_truncate_to_row_in_last_range() {
|
||||
let mut displayed_output =
|
||||
DisplayedOutput::new_for_test(vec![6..=11, 15..=17, 20..=25, 27..=30]);
|
||||
|
||||
displayed_output.truncate_to_row(28);
|
||||
assert_eq!(
|
||||
displayed_output.displayed_rows,
|
||||
make_displayed_rows_from_ranges(vec![6..=11, 15..=17, 20..=25, 27..=28])
|
||||
);
|
||||
assert_eq!(displayed_output.height, 17);
|
||||
}
|
||||
|
||||
fn create_bimap_with_insertions(insertion_list: &[(usize, usize)]) -> BiMap<usize, usize> {
|
||||
let mut bimap = BiMap::new();
|
||||
for (row, offset_row) in insertion_list {
|
||||
bimap.insert(*row, *offset_row);
|
||||
}
|
||||
bimap
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_append_to_row_translation_empty_displayed_rows() {
|
||||
// Start with empty displayed rows.
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: vec![],
|
||||
height: 5,
|
||||
row_translation_map: Default::default(),
|
||||
};
|
||||
|
||||
// Append displayed rows 0..=1.
|
||||
displayed_output.append_to_row_translation(make_displayed_rows_from_ranges(vec![0..=1]).iter());
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[(0, 0), (1, 1)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_append_to_row_translation() {
|
||||
// Start with displayed rows 0..=1, 4..=5.
|
||||
let mut bimap = create_bimap_with_insertions(&[(0, 0), (1, 1), (4, 2), (5, 3)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=1, 4..=5]),
|
||||
height: 5,
|
||||
row_translation_map: bimap.clone().into(),
|
||||
};
|
||||
|
||||
// Append displayed rows 6..7.
|
||||
displayed_output.append_to_row_translation(make_displayed_rows_from_ranges(vec![6..=7]).iter());
|
||||
|
||||
bimap.insert(6, 4);
|
||||
bimap.insert(7, 5);
|
||||
assert_eq!(displayed_output.row_translation_map, bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_prepend_to_row_translation_empty_displayed_rows() {
|
||||
// Start with empty displayed rows.
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: vec![],
|
||||
height: 5,
|
||||
row_translation_map: Default::default(),
|
||||
};
|
||||
|
||||
// Prepend displayed rows 0..=1.
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(0..0, new_line_ranges);
|
||||
displayed_output.prepend_to_row_translation();
|
||||
|
||||
let mut expected_bimap = BiMap::new();
|
||||
expected_bimap.insert(0, 0);
|
||||
expected_bimap.insert(1, 1);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_prepend_to_row_translation() {
|
||||
// Start with displayed rows 2..=3, 4..=5.
|
||||
let bimap = create_bimap_with_insertions(&[(2, 0), (3, 1), (4, 2), (5, 3)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![2..=3, 4..=5]),
|
||||
height: 5,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Prepend displayed rows 0..=1.
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(0..0, new_line_ranges);
|
||||
displayed_output.prepend_to_row_translation();
|
||||
|
||||
let expected_bimap =
|
||||
create_bimap_with_insertions(&[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_replace_rows_from_row_translation() {
|
||||
// Start with displayed rows 0, 2, 4.
|
||||
let bimap = create_bimap_with_insertions(&[(0, 0), (2, 1), (4, 2)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=0, 2..=2, 4..=4]),
|
||||
height: 3,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Replace displayed row 2 with displayed row 3.
|
||||
let replace_range = 1..2;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=3]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[(0, 0), (3, 1), (4, 2)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_replace_rows_from_row_translation_from_middle_to_end() {
|
||||
// Start with displayed rows 0, 2, 4.
|
||||
let bimap = create_bimap_with_insertions(&[(0, 0), (2, 1), (4, 2)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=0, 2..=2, 4..=4]),
|
||||
height: 3,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Replace displayed rows 2, 4 with displayed row 3..=5.
|
||||
let replace_range = 1..3;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=5]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[(0, 0), (3, 1), (4, 2), (5, 3)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_replace_rows_from_row_translation_from_beginning_to_middle() {
|
||||
// Start with displayed rows 0, 2, 4, 6.
|
||||
let bimap = create_bimap_with_insertions(&[(0, 0), (2, 1), (4, 2), (6, 3)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=0, 2..=2, 4..=4, 6..=6]),
|
||||
height: 3,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Replace displayed rows 0, 2 with displayed row 0..=1.
|
||||
let replace_range = 0..2;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[(0, 0), (1, 1), (4, 2), (6, 3)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_replace_rows_from_row_translation_replace_beginning_row() {
|
||||
// Start with displayed rows 0.
|
||||
let bimap = create_bimap_with_insertions(&[(0, 0)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=0]),
|
||||
height: 1,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Replace displayed rows 0 with displayed row 0.
|
||||
// This is intended to simulate what happens if we process dirty bytes multiple times.
|
||||
let replace_range = 0..1;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=0]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[(0, 0)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_replace_rows_from_row_translation_break_range() {
|
||||
// Start with displayed_rows 0..=8.
|
||||
let bimap = create_bimap_with_insertions((0..=8).map(|x| (x, x)).collect_vec().as_slice());
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=1, 2..=3, 4..=8]),
|
||||
height: 3,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Replace displayed rows 2..=3 with displayed row 2.
|
||||
let replace_range = 1..2;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![2..=2]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(4, 3),
|
||||
(5, 4),
|
||||
(6, 5),
|
||||
(7, 6),
|
||||
(8, 7),
|
||||
]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_rows_from_row_translation_no_removed_entries() {
|
||||
// Start with displayed_rows 0..=1, 6..=7.
|
||||
let bimap = create_bimap_with_insertions(&[(0, 0), (1, 1), (6, 2), (7, 3)]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![0..=1, 6..=7]),
|
||||
height: 3,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Insert displayed rows 3..=4.
|
||||
let replace_range = 1..1;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=4]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap =
|
||||
create_bimap_with_insertions(&[(0, 0), (1, 1), (3, 2), (4, 3), (6, 4), (7, 5)]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_rows_from_row_translation_dirty_range_overlaps_existing_ranges() {
|
||||
// Start with displayed_rows: [2..=4, 6..=11, 12..=14, 17..=20].
|
||||
let bimap = create_bimap_with_insertions(&[
|
||||
(2, 0),
|
||||
(3, 1),
|
||||
(4, 2),
|
||||
(6, 3),
|
||||
(7, 4),
|
||||
(8, 5),
|
||||
(9, 6),
|
||||
(10, 7),
|
||||
(11, 8),
|
||||
(12, 9),
|
||||
(13, 10),
|
||||
(14, 11),
|
||||
(17, 12),
|
||||
(18, 13),
|
||||
(19, 14),
|
||||
(20, 15),
|
||||
]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![2..=4, 6..=11, 12..=14, 17..=20]),
|
||||
height: 0,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Pretend that the dirty byte range is 8..=13.
|
||||
// The new displayed rows for the dirty range are 8..=10, 12..=12.
|
||||
let replace_range = 1..3;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![8..=10, 12..=12]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(2, 0),
|
||||
(3, 1),
|
||||
(4, 2),
|
||||
(8, 3),
|
||||
(9, 4),
|
||||
(10, 5),
|
||||
(12, 6),
|
||||
(17, 7),
|
||||
(18, 8),
|
||||
(19, 9),
|
||||
(20, 10),
|
||||
]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_rows_from_row_translation_dirty_range_lies_within_existing_range() {
|
||||
// Start with displayed_rows [2..=4, 6..=15, 17..=20].
|
||||
let bimap = create_bimap_with_insertions(&[
|
||||
(2, 0),
|
||||
(3, 1),
|
||||
(4, 2),
|
||||
(6, 3),
|
||||
(7, 4),
|
||||
(8, 5),
|
||||
(9, 6),
|
||||
(10, 7),
|
||||
(11, 8),
|
||||
(12, 9),
|
||||
(13, 10),
|
||||
(14, 11),
|
||||
(15, 12),
|
||||
(17, 13),
|
||||
(18, 14),
|
||||
(19, 15),
|
||||
(20, 16),
|
||||
]);
|
||||
let mut displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![2..=4, 6..=15, 17..=20]),
|
||||
height: 0,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
// Pretend that the dirty byte range is 8..=13.
|
||||
// The new displayed rows for the dirty range are 8..=10, 12..=12.
|
||||
let replace_range = 1..2;
|
||||
let new_line_ranges = make_displayed_rows_from_ranges(vec![6..=10, 12..=12, 13..=15]);
|
||||
displayed_output
|
||||
.displayed_rows
|
||||
.splice(replace_range.clone(), new_line_ranges);
|
||||
displayed_output.replace_rows_from_row_translation(replace_range);
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(2, 0),
|
||||
(3, 1),
|
||||
(4, 2),
|
||||
(6, 3),
|
||||
(7, 4),
|
||||
(8, 5),
|
||||
(9, 6),
|
||||
(10, 7),
|
||||
(12, 8),
|
||||
(13, 9),
|
||||
(14, 10),
|
||||
(15, 11),
|
||||
(17, 12),
|
||||
(18, 13),
|
||||
(19, 14),
|
||||
(20, 15),
|
||||
]);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_exact_or_next_displayed_row() {
|
||||
// Start with displayed_rows [2..=3, 6..=8, 14..=15].
|
||||
let bimap =
|
||||
create_bimap_with_insertions(&[(2, 0), (3, 1), (6, 2), (7, 3), (8, 4), (14, 5), (15, 6)]);
|
||||
let displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![2..=3, 6..=8, 14..=15]),
|
||||
height: 0,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
let expected_input_output_pairs = vec![
|
||||
(0, Some(0)),
|
||||
(1, Some(0)),
|
||||
(2, Some(0)),
|
||||
(3, Some(1)),
|
||||
(4, Some(2)),
|
||||
(5, Some(2)),
|
||||
(6, Some(2)),
|
||||
(7, Some(3)),
|
||||
(8, Some(4)),
|
||||
(9, Some(5)),
|
||||
(10, Some(5)),
|
||||
(11, Some(5)),
|
||||
(12, Some(5)),
|
||||
(13, Some(5)),
|
||||
(14, Some(5)),
|
||||
(15, Some(6)),
|
||||
];
|
||||
for (input, expected_output) in expected_input_output_pairs {
|
||||
assert_eq!(
|
||||
displayed_output.get_exact_or_next_displayed_row(input),
|
||||
expected_output
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_exact_or_next_displayed_row_no_next_closest() {
|
||||
// Start with displayed_rows [2..=3, 6..=8, 14..=15].
|
||||
let bimap =
|
||||
create_bimap_with_insertions(&[(2, 0), (3, 1), (6, 2), (7, 3), (8, 4), (14, 5), (15, 6)]);
|
||||
let displayed_output = DisplayedOutput {
|
||||
displayed_rows: make_displayed_rows_from_ranges(vec![2..=3, 6..=8, 14..=15]),
|
||||
height: 0,
|
||||
row_translation_map: bimap.into(),
|
||||
};
|
||||
|
||||
assert_eq!(displayed_output.get_exact_or_next_displayed_row(16), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_exact_or_next_displayed_row_empty() {
|
||||
let displayed_output = DisplayedOutput {
|
||||
displayed_rows: Vec::new(),
|
||||
height: 0,
|
||||
row_translation_map: BiMap::new().into(),
|
||||
};
|
||||
|
||||
assert!(displayed_output
|
||||
.get_exact_or_next_displayed_row(5)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_from_displayed_lines() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![1..=3, 7..=7, 14..=18]);
|
||||
let displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(1, 0),
|
||||
(2, 1),
|
||||
(3, 2),
|
||||
(7, 3),
|
||||
(14, 4),
|
||||
(15, 5),
|
||||
(16, 6),
|
||||
(17, 7),
|
||||
(18, 8),
|
||||
]);
|
||||
assert_eq!(displayed_output.displayed_rows, displayed_rows);
|
||||
assert_eq!(displayed_output.height, 9);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extend_displayed_lines() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![1..=3]);
|
||||
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
displayed_output.extend_displayed_lines(make_displayed_rows_from_ranges(vec![7..=7, 14..=18]));
|
||||
|
||||
let expected_displayed_rows = make_displayed_rows_from_ranges(vec![1..=3, 7..=7, 14..=18]);
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(1, 0),
|
||||
(2, 1),
|
||||
(3, 2),
|
||||
(7, 3),
|
||||
(14, 4),
|
||||
(15, 5),
|
||||
(16, 6),
|
||||
(17, 7),
|
||||
(18, 8),
|
||||
]);
|
||||
assert_eq!(displayed_output.displayed_rows, expected_displayed_rows);
|
||||
assert_eq!(displayed_output.height, 9);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepend_displayed_lines() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![14..=18]);
|
||||
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
displayed_output.prepend_displayed_lines(make_displayed_rows_from_ranges(vec![1..=3, 7..=7]));
|
||||
|
||||
let expected_displayed_rows = make_displayed_rows_from_ranges(vec![1..=3, 7..=7, 14..=18]);
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(1, 0),
|
||||
(2, 1),
|
||||
(3, 2),
|
||||
(7, 3),
|
||||
(14, 4),
|
||||
(15, 5),
|
||||
(16, 6),
|
||||
(17, 7),
|
||||
(18, 8),
|
||||
]);
|
||||
assert_eq!(displayed_output.displayed_rows, expected_displayed_rows);
|
||||
assert_eq!(displayed_output.height, 9);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_displayed_lines() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![0..=1, 4..=5, 14..=16, 19..=19]);
|
||||
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let new_displayed_rows = make_displayed_rows_from_ranges(vec![6..=6, 8..=9]);
|
||||
displayed_output.splice_displayed_lines(1..3, new_displayed_rows);
|
||||
|
||||
let expected_displayed_rows =
|
||||
make_displayed_rows_from_ranges(vec![0..=1, 6..=6, 8..=9, 19..=19]);
|
||||
let expected_bimap =
|
||||
create_bimap_with_insertions(&[(0, 0), (1, 1), (6, 2), (8, 3), (9, 4), (19, 5)]);
|
||||
assert_eq!(displayed_output.displayed_rows, expected_displayed_rows);
|
||||
assert_eq!(displayed_output.height, 6);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
|
||||
let new_displayed_rows = make_displayed_rows_from_ranges(vec![10..=12, 14..=15]);
|
||||
displayed_output.splice_displayed_lines(3..4, new_displayed_rows);
|
||||
|
||||
let expected_displayed_rows =
|
||||
make_displayed_rows_from_ranges(vec![0..=1, 6..=6, 8..=9, 10..=12, 14..=15]);
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(6, 2),
|
||||
(8, 3),
|
||||
(9, 4),
|
||||
(10, 5),
|
||||
(11, 6),
|
||||
(12, 7),
|
||||
(14, 8),
|
||||
(15, 9),
|
||||
]);
|
||||
assert_eq!(displayed_output.displayed_rows, expected_displayed_rows);
|
||||
assert_eq!(displayed_output.height, 10);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_displayed_lines_nothing_replaced() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![0..=1, 4..=5, 14..=16, 19..=19]);
|
||||
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let new_displayed_rows = make_displayed_rows_from_ranges(vec![8..=9]);
|
||||
displayed_output.splice_displayed_lines(2..2, new_displayed_rows);
|
||||
|
||||
let expected_displayed_rows =
|
||||
make_displayed_rows_from_ranges(vec![0..=1, 4..=5, 8..=9, 14..=16, 19..=19]);
|
||||
let expected_bimap = create_bimap_with_insertions(&[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(4, 2),
|
||||
(5, 3),
|
||||
(8, 4),
|
||||
(9, 5),
|
||||
(14, 6),
|
||||
(15, 7),
|
||||
(16, 8),
|
||||
(19, 9),
|
||||
]);
|
||||
assert_eq!(displayed_output.displayed_rows, expected_displayed_rows);
|
||||
assert_eq!(displayed_output.height, 10);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splice_displayed_lines_no_new_lines() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![0..=1, 4..=5, 14..=16, 19..=19]);
|
||||
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
displayed_output.splice_displayed_lines(1..2, Vec::new());
|
||||
|
||||
let expected_displayed_rows = make_displayed_rows_from_ranges(vec![0..=1, 14..=16, 19..=19]);
|
||||
let expected_bimap =
|
||||
create_bimap_with_insertions(&[(0, 0), (1, 1), (14, 2), (15, 3), (16, 4), (19, 5)]);
|
||||
assert_eq!(displayed_output.displayed_rows, expected_displayed_rows);
|
||||
assert_eq!(displayed_output.height, 6);
|
||||
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_rows_greater_than_or_contained_in() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![2..=5, 7..=9, 14..=16, 19..=19]);
|
||||
let displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let res = displayed_output.first_rows_greater_than_or_contained_in(19);
|
||||
assert_eq!(res.unwrap().0, 3);
|
||||
assert_eq!(res.unwrap().1.range.clone(), 19..=19);
|
||||
|
||||
let res = displayed_output.first_rows_greater_than_or_contained_in(15);
|
||||
assert_eq!(res.unwrap().0, 2);
|
||||
assert_eq!(res.unwrap().1.range.clone(), 14..=16);
|
||||
|
||||
let res = displayed_output.first_rows_greater_than_or_contained_in(1);
|
||||
assert_eq!(res.unwrap().0, 0);
|
||||
assert_eq!(res.unwrap().1.range.clone(), 2..=5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_rows_greater_than_or_contained_in_no_result() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![2..=5, 7..=9, 14..=16, 19..=19]);
|
||||
let displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let res = displayed_output.first_rows_greater_than_or_contained_in(20);
|
||||
assert_eq!(res, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_last_rows_less_than_or_contained_in() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![2..=5, 7..=9, 14..=16, 19..=19]);
|
||||
let displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let res = displayed_output.last_rows_less_than_or_contained_in(2);
|
||||
assert_eq!(res.unwrap().0, 0);
|
||||
assert_eq!(res.unwrap().1.range.clone(), 2..=5);
|
||||
|
||||
let res = displayed_output.last_rows_less_than_or_contained_in(15);
|
||||
assert_eq!(res.unwrap().0, 2);
|
||||
assert_eq!(res.unwrap().1.range.clone(), 14..=16);
|
||||
|
||||
let res = displayed_output.last_rows_less_than_or_contained_in(20);
|
||||
assert_eq!(res.unwrap().0, 3);
|
||||
assert_eq!(res.unwrap().1.range.clone(), 19..=19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_last_rows_less_than_or_contained_in_no_result() {
|
||||
let displayed_rows = make_displayed_rows_from_ranges(vec![2..=5, 7..=9, 14..=16, 19..=19]);
|
||||
let displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
|
||||
|
||||
let res = displayed_output.last_rows_less_than_or_contained_in(1);
|
||||
assert_eq!(res, None);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use warp_terminal::model::grid::{
|
||||
cell::{self, Cell},
|
||||
row::Row,
|
||||
CellType,
|
||||
};
|
||||
|
||||
use crate::terminal::model::index::Point;
|
||||
|
||||
use super::{grid_handler::GridHandler, CursorDirection, CursorState, Dimensions as _};
|
||||
|
||||
/// The set of possible grapheme cursor wrapping behaviors.
|
||||
#[derive(PartialEq)]
|
||||
pub enum Wrap {
|
||||
/// Does not wrap at all (stops at the start and end of a row).
|
||||
None,
|
||||
/// Wraps at the start/end of a row, but stops upon reaching a newline.
|
||||
Soft,
|
||||
/// Wraps at the start and end of each row, ignoring newlines.
|
||||
All,
|
||||
}
|
||||
|
||||
/// A cursor for iterating forward or backward over graphemes in a terminal
|
||||
/// grid.
|
||||
///
|
||||
/// If constructed on a wide char spacer cell, the cursor will be moved one
|
||||
/// cell to the left, snapping it to the cell which contains the content for
|
||||
/// the grapheme under the cursor. If constructed on a leading wide char
|
||||
/// spacer cell, the cursor will be moved one cell to the right (for the same
|
||||
/// reason).
|
||||
pub struct GraphemeCursor<'g> {
|
||||
grid: &'g GridHandler,
|
||||
cur: Point,
|
||||
cursor_state: CursorState,
|
||||
wrap: Wrap,
|
||||
|
||||
/// The index of the row that is stored in `cached_row`.
|
||||
cached_row_idx: usize,
|
||||
/// A cached shared reference to the row with index `cached_row_idx`.
|
||||
///
|
||||
/// This is a `Cow` because grid storage internally holds `Row`s, and can
|
||||
/// return a reference to an existing one, whereas flat storage needs to
|
||||
/// construct one on-demand from its internal representation.
|
||||
cached_row: Option<Cow<'g, Row>>,
|
||||
}
|
||||
|
||||
impl<'g> GraphemeCursor<'g> {
|
||||
/// Returns a new grapheme cursor that starts at the given point and
|
||||
/// adheres to the provided wrapping behavior.
|
||||
pub fn new(point: Point, grid: &'g GridHandler, wrap: Wrap) -> Self {
|
||||
if let Some(row) = grid.row(point.row) {
|
||||
if let Some(cell) = row.get(point.col) {
|
||||
let flags = cell.flags;
|
||||
|
||||
let mut cursor = Self {
|
||||
grid,
|
||||
cur: point,
|
||||
cursor_state: CursorState::Valid,
|
||||
wrap,
|
||||
|
||||
cached_row_idx: point.row,
|
||||
cached_row: Some(row),
|
||||
};
|
||||
|
||||
// The cursor should never start on a spacer cell. If we're on
|
||||
// the second cell in a wide char, move to the first cell. If
|
||||
// we're on the cell where a wide char _should_ have started,
|
||||
// move to its actual start cell.
|
||||
if flags.intersects(cell::Flags::WIDE_CHAR_SPACER) {
|
||||
cursor.move_backward();
|
||||
} else if flags.intersects(cell::Flags::LEADING_WIDE_CHAR_SPACER) {
|
||||
cursor.move_forward();
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
grid,
|
||||
cur: point,
|
||||
cursor_state: CursorState::Invalid,
|
||||
wrap,
|
||||
|
||||
cached_row_idx: point.row,
|
||||
cached_row: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a struct that provides access to information about the grapheme
|
||||
/// under the cursor, or [`None`] if the cursor is invalid or exhausted.
|
||||
pub fn current_item(&self) -> Option<GraphemeCursorItem<'_>> {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid => {
|
||||
let cell = self
|
||||
.cached_row
|
||||
.as_ref()
|
||||
.expect("row should be valid")
|
||||
.get(self.cur.col)
|
||||
.expect("col should be valid");
|
||||
|
||||
Some(GraphemeCursorItem {
|
||||
cell,
|
||||
point: self.cur,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the position the cursor had when it was last valid.
|
||||
///
|
||||
/// This is useful for knowing the "final" cursor position after it has
|
||||
/// been exhausted (e.g.: hitting the start/end of the grid or a wrapping
|
||||
/// boundary condition).
|
||||
pub fn last_valid_position(&self) -> Point {
|
||||
self.cur
|
||||
}
|
||||
|
||||
fn current_point_valid(&self) -> bool {
|
||||
self.cur.row < self.grid.total_rows() && self.cur.col < self.grid.columns()
|
||||
}
|
||||
|
||||
fn has_next(&self) -> bool {
|
||||
if !self.current_point_valid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let at_end_of_row = self.is_at_end_of_row();
|
||||
if self.wrap == Wrap::None && at_end_of_row
|
||||
|| self.wrap == Wrap::Soft && self.is_at_end_of_line()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
!at_end_of_row || self.cur.row < self.grid.total_rows() - 1
|
||||
}
|
||||
|
||||
fn has_prev(&self) -> bool {
|
||||
if !self.current_point_valid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let at_start_of_row = self.is_at_start_of_row();
|
||||
if self.wrap == Wrap::None && at_start_of_row
|
||||
|| self.wrap == Wrap::Soft && self.is_at_start_of_line()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
!at_start_of_row || self.cur.row > 0
|
||||
}
|
||||
|
||||
fn update_cached_row(&mut self) {
|
||||
self.cached_row_idx = self.cur.row;
|
||||
self.cached_row = self.grid.row(self.cached_row_idx);
|
||||
}
|
||||
|
||||
/// Moves the cursor forward by a single grapheme.
|
||||
pub fn move_forward(&mut self) {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid if self.has_next() => {
|
||||
let start_row = self.cur.row;
|
||||
|
||||
self.cur = self.cur.wrapping_add(self.grid.columns(), 1);
|
||||
// Skip over any spacer cells.
|
||||
if matches!(
|
||||
self.grid.cell_type(self.cur),
|
||||
Some(CellType::WideCharSpacer | CellType::LeadingWideCharSpacer)
|
||||
) {
|
||||
self.move_forward();
|
||||
}
|
||||
|
||||
if self.cur.row != start_row {
|
||||
self.update_cached_row();
|
||||
}
|
||||
}
|
||||
CursorState::Valid => {
|
||||
self.cursor_state = CursorState::Exhausted(CursorDirection::Right);
|
||||
}
|
||||
CursorState::Exhausted(CursorDirection::Left) => {
|
||||
self.cursor_state = CursorState::Valid;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor backward by a single grapheme.
|
||||
pub fn move_backward(&mut self) {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid if self.has_prev() => {
|
||||
let start_row = self.cur.row;
|
||||
|
||||
self.cur = self.cur.wrapping_sub(self.grid.columns(), 1);
|
||||
// Skip over any spacer cells.
|
||||
if matches!(
|
||||
self.grid.cell_type(self.cur),
|
||||
Some(CellType::WideCharSpacer | CellType::LeadingWideCharSpacer)
|
||||
) {
|
||||
self.move_backward();
|
||||
}
|
||||
|
||||
if self.cur.row != start_row {
|
||||
self.update_cached_row();
|
||||
}
|
||||
}
|
||||
CursorState::Valid => {
|
||||
self.cursor_state = CursorState::Exhausted(CursorDirection::Left);
|
||||
}
|
||||
CursorState::Exhausted(CursorDirection::Right) => {
|
||||
self.cursor_state = CursorState::Valid;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_at_start_of_row(&self) -> bool {
|
||||
self.cur.col == 0
|
||||
}
|
||||
|
||||
fn is_at_end_of_row(&self) -> bool {
|
||||
self.cur.col == self.grid.columns() - 1
|
||||
}
|
||||
|
||||
/// Returns whether or not the cursor is at the start of a line.
|
||||
///
|
||||
/// In other words, this returns true if moving the cursor backwards would
|
||||
/// transition across a newline/hard wrap.
|
||||
pub fn is_at_start_of_line(&self) -> bool {
|
||||
self.is_at_start_of_row() && (self.cur.row == 0 || !self.grid.row_wraps(self.cur.row - 1))
|
||||
}
|
||||
|
||||
/// Returns whether or not the cursor is at the end of a line.
|
||||
///
|
||||
/// In other words, this returns true if moving the cursor forwards would
|
||||
/// transition across a newline/hard wrap.
|
||||
pub fn is_at_end_of_line(&self) -> bool {
|
||||
self.is_at_end_of_row()
|
||||
&& (self.cur.row == self.grid.total_rows() - 1 || !self.grid.row_wraps(self.cur.row))
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper struct for providing information about the grapheme at which the
|
||||
/// cursor is currently located.
|
||||
pub struct GraphemeCursorItem<'g> {
|
||||
cell: &'g Cell,
|
||||
point: Point,
|
||||
}
|
||||
|
||||
impl GraphemeCursorItem<'_> {
|
||||
/// Returns a reference to the cell that holds the content for the grapheme
|
||||
/// under the cursor.
|
||||
///
|
||||
/// TODO(CORE-2955): Fix the fact that many callers look at `cell().c` to
|
||||
/// get the cell content, which is incorrect for cells
|
||||
/// which have additional content in `CellExtra`.
|
||||
pub fn cell(&self) -> &Cell {
|
||||
self.cell
|
||||
}
|
||||
|
||||
/// Returns the character in the cell under the grapheme cursor.
|
||||
///
|
||||
/// TODO(CORE-2955): Fix the fact that many callers look at `cell().c` to
|
||||
/// get the cell content, which is incorrect for cells
|
||||
/// which have additional content in `CellExtra`.
|
||||
pub fn content_char(&self) -> char {
|
||||
if self.cell.c == cell::DEFAULT_CHAR {
|
||||
' '
|
||||
} else {
|
||||
self.cell.c
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the item's position in the grid.
|
||||
pub fn point(&self) -> Point {
|
||||
self.point
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "grapheme_cursor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
|
||||
fn cell(c: char) -> Cell {
|
||||
let mut cell = Cell::default();
|
||||
cell.c = c;
|
||||
cell
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor() {
|
||||
macro_rules! assert_cursor_contents_eq {
|
||||
($c:expr, $cursor:ident) => {
|
||||
let item = $cursor
|
||||
.current_item()
|
||||
.expect("cursor location should be valid");
|
||||
assert_eq!(&cell($c), item.cell());
|
||||
};
|
||||
}
|
||||
|
||||
let mut grid = GridHandler::new_for_test(5, 5);
|
||||
for i in 0u8..5u8 {
|
||||
for j in 0u8..5u8 {
|
||||
grid.grid_storage_mut()[usize::from(i)][usize::from(j)] = cell((i * 5 + j) as char);
|
||||
}
|
||||
}
|
||||
|
||||
let mut cursor = grid.grapheme_cursor_from(Point { row: 0, col: 0 }, Wrap::All);
|
||||
|
||||
cursor.move_backward();
|
||||
assert!(cursor.current_item().is_none());
|
||||
cursor.move_forward();
|
||||
assert_cursor_contents_eq!(0u8 as char, cursor);
|
||||
|
||||
cursor.move_forward();
|
||||
assert_cursor_contents_eq!(1u8 as char, cursor);
|
||||
assert_eq!(Some(1), cursor.current_item().map(|item| item.point().col));
|
||||
assert_eq!(Some(0), cursor.current_item().map(|item| item.point().row));
|
||||
|
||||
cursor.move_forward();
|
||||
assert_cursor_contents_eq!(2u8 as char, cursor);
|
||||
|
||||
cursor.move_forward();
|
||||
assert_cursor_contents_eq!(3u8 as char, cursor);
|
||||
|
||||
cursor.move_forward();
|
||||
assert_cursor_contents_eq!(4u8 as char, cursor);
|
||||
|
||||
// Test line-wrapping.
|
||||
cursor.move_forward();
|
||||
assert_cursor_contents_eq!(5u8 as char, cursor);
|
||||
assert_eq!(Some(0), cursor.current_item().map(|item| item.point().col));
|
||||
assert_eq!(Some(1), cursor.current_item().map(|item| item.point().row));
|
||||
|
||||
cursor.move_backward();
|
||||
assert_cursor_contents_eq!(4u8 as char, cursor);
|
||||
assert_eq!(Some(4), cursor.current_item().map(|item| item.point().col));
|
||||
assert_eq!(Some(0), cursor.current_item().map(|item| item.point().row));
|
||||
|
||||
// Make sure iter.cell() returns the current iterator position.
|
||||
assert_cursor_contents_eq!(4u8 as char, cursor);
|
||||
|
||||
// Test that iter ends at end of grid.
|
||||
let mut final_cursor = grid.grapheme_cursor_from(Point { row: 4, col: 4 }, Wrap::All);
|
||||
final_cursor.move_forward();
|
||||
assert!(final_cursor.current_item().is_none());
|
||||
|
||||
final_cursor.move_backward();
|
||||
assert_cursor_contents_eq!(24u8 as char, final_cursor);
|
||||
final_cursor.move_backward();
|
||||
assert_cursor_contents_eq!(23u8 as char, final_cursor);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
//! A specialized 2D grid implementation optimized for use in a terminal.
|
||||
|
||||
mod resize;
|
||||
|
||||
use std::cmp::min;
|
||||
use std::ops::{Index, IndexMut, Range};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use warp_terminal::model::grid::Dimensions;
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::ansi::{CharsetIndex, StandardCharset};
|
||||
use crate::terminal::model::cell::{Cell, Flags};
|
||||
use crate::terminal::model::grid::row::Row;
|
||||
use crate::terminal::model::grid::storage::Storage;
|
||||
use crate::terminal::model::index::{IndexRange, Point, VisiblePoint, VisibleRow};
|
||||
use crate::terminal::model::secrets::ObfuscateSecrets;
|
||||
|
||||
impl ::std::cmp::PartialEq for GridStorage {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Compare struct fields and check result of grid comparison.
|
||||
self.raw.eq(&other.raw) && self.columns.eq(&other.columns) && self.rows.eq(&other.rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Cursor {
|
||||
/// The location of this cursor.
|
||||
pub point: VisiblePoint,
|
||||
|
||||
/// Template cell when using this cursor.
|
||||
pub template: Cell,
|
||||
|
||||
/// Currently configured graphic character sets.
|
||||
pub charsets: Charsets,
|
||||
|
||||
/// Tracks if the next call to input will need to first handle wrapping.
|
||||
///
|
||||
/// This is true after the last column is set with the input function. Any function that
|
||||
/// implicitly sets the line or column needs to set this to false to avoid wrapping twice.
|
||||
///
|
||||
/// Tracking `input_needs_wrap` makes it possible to not store a cursor position that exceeds
|
||||
/// the number of columns, which would lead to index out of bounds when interacting with arrays
|
||||
/// without sanitization.
|
||||
pub input_needs_wrap: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
|
||||
pub struct Charsets([StandardCharset; 4]);
|
||||
|
||||
impl Index<CharsetIndex> for Charsets {
|
||||
type Output = StandardCharset;
|
||||
|
||||
fn index(&self, index: CharsetIndex) -> &StandardCharset {
|
||||
&self.0[index as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<CharsetIndex> for Charsets {
|
||||
fn index_mut(&mut self, index: CharsetIndex) -> &mut StandardCharset {
|
||||
&mut self.0[index as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid based terminal content storage.
|
||||
///
|
||||
/// The grid is a 0-based buffer that goes from index 0 to however large the grid needs to be.
|
||||
///
|
||||
/// ┌────────────────────────────────┐
|
||||
/// 0:│cat best_shakespeare_plays.txt │ <-- command grid
|
||||
/// ├────────────────────────────────┤
|
||||
/// 0:│Hamlet │
|
||||
/// 1:│Othello │
|
||||
/// 2:│Macbeth │ <-- output grid
|
||||
/// 3:│The Tempest │
|
||||
/// 4:│Pericles │
|
||||
/// └────────────────────────────────┘
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct GridStorage {
|
||||
/// Current cursor for writing data.
|
||||
#[serde(skip)]
|
||||
pub(super) cursor: Cursor,
|
||||
|
||||
/// The maximum line and column the cursor has been on.
|
||||
#[serde(skip)]
|
||||
pub max_cursor_point: VisiblePoint,
|
||||
|
||||
/// Last saved cursor.
|
||||
#[serde(skip)]
|
||||
pub saved_cursor: Cursor,
|
||||
|
||||
/// VisibleRows in the grid. Each row holds a list of cells corresponding to the
|
||||
/// columns in that row.
|
||||
pub(super) raw: Storage,
|
||||
|
||||
/// Number of columns.
|
||||
pub(crate) columns: usize,
|
||||
|
||||
/// Number of visible rows.
|
||||
pub(crate) rows: usize,
|
||||
|
||||
/// Maximum number of lines in history.
|
||||
pub(crate) max_scroll_limit: usize,
|
||||
|
||||
#[serde(skip)]
|
||||
/// The number of lines that have been truncated due
|
||||
/// to the [`Grid::max_scroll_limit`].
|
||||
///
|
||||
/// This is specifically of type [`u64`] to ensure
|
||||
/// that we don't overflow for blocks with a lot of truncation.
|
||||
/// We might want to consider using a smalluint if this becomes a problem.
|
||||
pub(super) num_lines_truncated: u64,
|
||||
|
||||
/// Whether the resize fix feature flag is enabled (Alacritty cursor reflow bug during resize). Gating
|
||||
/// fix to gain confidence in the fix before enabling for all users.
|
||||
#[serde(skip)]
|
||||
pub(crate) resize_fix_ff_enabled: bool,
|
||||
}
|
||||
|
||||
impl GridStorage {
|
||||
pub fn new(
|
||||
rows: usize,
|
||||
columns: usize,
|
||||
max_scroll_limit: usize,
|
||||
// TODO(vorporeal): remove this argument entirely
|
||||
_secret_obfuscation_mode: ObfuscateSecrets,
|
||||
) -> GridStorage {
|
||||
GridStorage {
|
||||
raw: Storage::with_capacity(rows, columns, FeatureFlag::SequentialStorage.is_enabled()),
|
||||
max_scroll_limit,
|
||||
saved_cursor: Cursor::default(),
|
||||
cursor: Cursor::default(),
|
||||
max_cursor_point: Default::default(),
|
||||
rows,
|
||||
columns,
|
||||
resize_fix_ff_enabled: FeatureFlag::ResizeFix.is_enabled(),
|
||||
num_lines_truncated: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new [`GridStorage`] with a subset of rows from `self`.
|
||||
///
|
||||
/// `num_preceding_rows` is the number of rows in `self` that come before
|
||||
/// the rows in `rows`.
|
||||
///
|
||||
/// `initial_history_size` is the number of rows in the parent
|
||||
/// [`GridHandler`] that are in scrollback. This is needed to properly
|
||||
/// convert [`VisiblePoint`]s to [`Point`]s.
|
||||
pub(in crate::terminal::model) fn new_for_split(
|
||||
&self,
|
||||
mut rows: Vec<Row>,
|
||||
num_preceding_rows: usize,
|
||||
initial_history_size: usize,
|
||||
) -> GridStorage {
|
||||
let visible_lines = rows.len();
|
||||
|
||||
// If we're not using sequential storage, we store the rows in reverse
|
||||
// order, so reverse them here.
|
||||
if !self.raw.is_sequential() {
|
||||
rows.reverse();
|
||||
}
|
||||
|
||||
let mut grid = GridStorage {
|
||||
cursor: self.cursor.clone(),
|
||||
max_cursor_point: self.max_cursor_point,
|
||||
saved_cursor: self.saved_cursor.clone(),
|
||||
raw: Storage::with_rows(rows, self.raw.is_sequential(), visible_lines),
|
||||
columns: self.columns,
|
||||
rows: visible_lines,
|
||||
max_scroll_limit: self.max_scroll_limit,
|
||||
resize_fix_ff_enabled: self.resize_fix_ff_enabled,
|
||||
num_lines_truncated: 0,
|
||||
};
|
||||
|
||||
let new_history_size = grid.history_size();
|
||||
let new_visible_rows = grid.visible_rows();
|
||||
let columns = grid.columns();
|
||||
|
||||
let adjust_cursor_point = |cursor_point: &mut VisiblePoint| {
|
||||
// Convert the cursor row to absolute coordinates, using the
|
||||
// pre-split history size from the `GridHandler` level (so that we
|
||||
// account for rows in flat storage).
|
||||
let row = cursor_point.row.0 + initial_history_size;
|
||||
|
||||
// Check if the cursor is within this grid or before it.
|
||||
if let Some(row) = row.checked_sub(num_preceding_rows) {
|
||||
let visible_row = row.saturating_sub(new_history_size);
|
||||
if visible_row < new_visible_rows {
|
||||
// If the cursor is within this grid, update the row
|
||||
// accordingly.
|
||||
cursor_point.row = VisibleRow(visible_row);
|
||||
} else {
|
||||
// If it is past the end of the grid, put it at the end.
|
||||
cursor_point.row = VisibleRow(new_visible_rows - 1);
|
||||
cursor_point.col = columns - 1;
|
||||
}
|
||||
} else {
|
||||
// The cursor is before this grid, so put it at the start of
|
||||
// the grid.
|
||||
cursor_point.row = VisibleRow(0);
|
||||
cursor_point.col = 0;
|
||||
}
|
||||
};
|
||||
|
||||
adjust_cursor_point(&mut grid.cursor.point);
|
||||
adjust_cursor_point(&mut grid.saved_cursor.point);
|
||||
adjust_cursor_point(&mut grid.max_cursor_point);
|
||||
|
||||
grid
|
||||
}
|
||||
|
||||
pub(super) fn set_stored_rows(
|
||||
&mut self,
|
||||
mut rows: Vec<Row>,
|
||||
visible_rows: usize,
|
||||
columns: usize,
|
||||
) {
|
||||
// Ensure there are a full set of rows.
|
||||
rows.resize_with(visible_rows, || Row::new(columns));
|
||||
debug_assert_eq!(rows.len(), visible_rows);
|
||||
|
||||
self.rows = visible_rows;
|
||||
self.columns = columns;
|
||||
|
||||
let is_sequential = self.raw.is_sequential();
|
||||
if !is_sequential {
|
||||
rows.reverse();
|
||||
}
|
||||
|
||||
self.raw = Storage::with_rows(rows, is_sequential, visible_rows);
|
||||
}
|
||||
|
||||
/// Update the size of the scrollback history.
|
||||
pub fn update_history(&mut self, history_size: usize) {
|
||||
let current_history_size = self.history_size();
|
||||
if current_history_size > history_size {
|
||||
self.raw.shrink_lines(current_history_size - history_size);
|
||||
}
|
||||
self.max_scroll_limit = history_size;
|
||||
}
|
||||
|
||||
fn increase_scroll_limit(&mut self, count: usize) {
|
||||
let count = min(count, self.max_scroll_limit - self.history_size());
|
||||
if count != 0 {
|
||||
self.raw.initialize(count, self.columns);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decrease_scroll_limit(&mut self, count: usize) {
|
||||
let count = min(count, self.history_size());
|
||||
if count != 0 {
|
||||
self.raw.shrink_lines(min(count, self.history_size()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_max_cursor(&mut self) {
|
||||
let point = self.cursor.point;
|
||||
if point.row.0 >= self.rows {
|
||||
return;
|
||||
}
|
||||
if point.row > self.max_cursor_point.row
|
||||
|| (point.row == self.max_cursor_point.row && point.col > self.max_cursor_point.col)
|
||||
{
|
||||
self.max_cursor_point = point;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an immutable reference to the active [`Cursor`]. See [`Self::update_cursor`] as an
|
||||
/// alternative that provides mutable access to the `Cursor`.
|
||||
pub fn cursor(&self) -> &Cursor {
|
||||
&self.cursor
|
||||
}
|
||||
|
||||
pub(super) fn row_wraps(&self, row_idx: usize) -> bool {
|
||||
let Some(cell) = self.get(row_idx).and_then(|row| row.last()) else {
|
||||
return false;
|
||||
};
|
||||
cell.flags().contains(Flags::WRAPLINE)
|
||||
}
|
||||
|
||||
/// Moves everything in the visible screen down by "positions" amount of rows.
|
||||
/// Importantly, this is the concept of grid scrolling, not blocklist scrolling. Examples
|
||||
/// of operations that use this method are any alt-screen command, git log, or git diff.
|
||||
///
|
||||
/// The "region" parameter is a subset of the VisibleScreen, and it tells us about any fixed lines
|
||||
/// at the top or bottom of the screen that should be excluded from the scroll action. For example,
|
||||
/// the VisibleScreen might be (0..40) and the region might be (1..40), indicating that there's
|
||||
/// one fixed line at the top of the grid.
|
||||
///
|
||||
/// Say the grid has 10 rows (0..9) and visible_rows = grid.len() (i.e., no scrollback).
|
||||
/// If there's one fixed line at the bottom of the grid and we scroll down by one line...
|
||||
/// (1) Swap the fixed line at the bottom with the line above it. (ix 9 <--> ix 8)
|
||||
/// (2) Inform the storage layer of the change to the bottommost row. ("shift up by one")
|
||||
/// (3) Clear the topmost row (ix 0).
|
||||
///
|
||||
/// If there is a scrollback, this process requires piecewise swapping of the visible rows.
|
||||
/// For a more detailed specification, look at test_grid_scroll_down() and test_grid_git_diff_or_log()
|
||||
#[inline]
|
||||
pub fn scroll_down(&mut self, region: &Range<VisibleRow>, scroll_distance: usize) {
|
||||
let visible_rows = self.visible_rows();
|
||||
|
||||
// When rotating the entire region, just reset everything.
|
||||
if scroll_distance >= region.end - region.start {
|
||||
for line in IndexRange(region.start..region.end) {
|
||||
self.raw[line].reset(&self.cursor.template);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Which implementation we can use depends on the existence of a scrollback history.
|
||||
//
|
||||
// Since a scrollback history prevents us from rotating the entire buffer downwards, we
|
||||
// instead have to rely on a slower, swap-based implementation.
|
||||
if self.max_scroll_limit == 0 {
|
||||
// Swap the lines fixed at the bottom to their target positions after rotation.
|
||||
//
|
||||
// Since we've made sure that the rotation will never rotate away the entire region, we
|
||||
// know that the position of the fixed lines before the rotation must already be
|
||||
// visible.
|
||||
//
|
||||
// We need to start from the top, to make sure the fixed lines aren't swapped with each
|
||||
// other.
|
||||
let fixed_lines = visible_rows - region.end.0;
|
||||
for i in (0..fixed_lines).rev() {
|
||||
let index = visible_rows - i - 1;
|
||||
self.raw.swap(index, index - scroll_distance);
|
||||
}
|
||||
|
||||
// Reduce the bottommost row of the grid. In other words, retract the concept of the bottom_row
|
||||
// by some integer value.
|
||||
self.raw.retract(scroll_distance as isize);
|
||||
|
||||
// The new lines appear at the top of the screen. Reset them.
|
||||
for i in 0..scroll_distance {
|
||||
self.raw[i].reset(&self.cursor.template);
|
||||
}
|
||||
|
||||
// Swap the fixed lines at the top back into position by swapping them upwards.
|
||||
for i in 0..region.start.0 {
|
||||
self.raw.swap(i, i + scroll_distance);
|
||||
}
|
||||
} else {
|
||||
// Subregion rotation is a two-step process of bubbling the bottom rows to the top
|
||||
// and then clearing them. Like so:
|
||||
// 0: a 0: e 0:
|
||||
// 1: b swap 1: a clear 1: a
|
||||
// 2: c ----> 2: b ----> 2: b
|
||||
// 3: d 3: c 3: c
|
||||
// 4: e 4: d 4: d
|
||||
|
||||
// Starting with the bottommost visible row, swap everything up by one, resulting in
|
||||
// the bottommost row ending up as the topmost row and everything else shifted down by one.
|
||||
for line in IndexRange((region.start + scroll_distance)..region.end).rev() {
|
||||
self.raw.swap_lines(line, line - scroll_distance);
|
||||
}
|
||||
|
||||
// Clear rows at the top, including the rows we just swapped there.
|
||||
for line in IndexRange(region.start..(region.start + scroll_distance)) {
|
||||
self.raw[line].reset(&self.cursor.template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves everything in the visible screen up by `scroll_distance` number of rows.
|
||||
/// Importantly, this is the concept of grid scrolling, not blocklist scrolling. Examples
|
||||
/// of operations that use this method are any alt-screen command, git log, or git diff.
|
||||
///
|
||||
/// This is the performance-sensitive part of scrolling!
|
||||
///
|
||||
/// For a more detailed specification, look at test_grid_scroll_up() and test_grid_git_diff_or_log()
|
||||
pub fn scroll_up(&mut self, region: &Range<VisibleRow>, scroll_distance: usize) {
|
||||
let visible_rows = self.visible_rows();
|
||||
|
||||
// When rotating the entire region with fixed lines at the top, just reset everything.
|
||||
if scroll_distance >= region.end - region.start && region.start != VisibleRow(0) {
|
||||
for line in IndexRange(region.start..region.end) {
|
||||
self.raw[line].reset(&self.cursor.template);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Before extending the scrollback by the scroll amount, calculate
|
||||
// how many lines are going to be truncated, if any.
|
||||
let num_lines_to_truncate =
|
||||
(self.history_size() + scroll_distance).saturating_sub(self.max_scroll_limit);
|
||||
|
||||
// Create scrollback for the new lines.
|
||||
self.increase_scroll_limit(scroll_distance);
|
||||
|
||||
// Swap the lines fixed at the top to their target positions after rotation.
|
||||
//
|
||||
// Since we've made sure that the rotation will never rotate away the entire region, we
|
||||
// know that the position of the fixed lines before the rotation must already be
|
||||
// visible.
|
||||
//
|
||||
// We need to start from the bottom, to make sure the fixed lines aren't swapped with each
|
||||
// other.
|
||||
for i in (0..region.start.0).rev() {
|
||||
self.raw.swap(i, i + scroll_distance);
|
||||
}
|
||||
|
||||
// Mark that the bottommost row is now extended.
|
||||
self.raw.extend(scroll_distance as isize);
|
||||
|
||||
// The new lines appear at the bottom of the screen. Reset them.
|
||||
for i in self.raw.len() - scroll_distance..self.raw.len() {
|
||||
self.raw[i].reset(&self.cursor.template);
|
||||
}
|
||||
|
||||
// Swap the fixed lines at the bottom back into position.
|
||||
let fixed_lines = VisibleRow(visible_rows) - region.end;
|
||||
for i in 0..fixed_lines {
|
||||
let index = visible_rows - i - 1;
|
||||
self.raw.swap(index, index - scroll_distance);
|
||||
}
|
||||
|
||||
// After modifying the scrollback buffer, check if we're truncating
|
||||
// rows and keep track of how many rows we've truncated.
|
||||
if self.max_scroll_limit == self.history_size() {
|
||||
self.num_lines_truncated += num_lines_to_truncate as u64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the grid, leaving the cursor's line at the top and preserving the cursor location
|
||||
/// within the line.
|
||||
pub(super) fn clear_and_reset_saving_cursor_line(&mut self) {
|
||||
self.clear_history();
|
||||
|
||||
self.raw.swap_lines(self.cursor.point.row, VisibleRow(0));
|
||||
self.cursor.point.row = VisibleRow(0);
|
||||
|
||||
// Reset all visible lines except the top most row.
|
||||
for row in 1..self.raw.len() {
|
||||
self.raw[row].reset(&Cell::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Completely reset the grid state.
|
||||
pub fn reset(&mut self) {
|
||||
self.clear_history();
|
||||
|
||||
self.saved_cursor = Cursor::default();
|
||||
self.cursor = Cursor::default();
|
||||
self.max_cursor_point = Default::default();
|
||||
|
||||
// Reset all visible lines.
|
||||
for row in 0..self.raw.len() {
|
||||
self.raw[row].reset(&self.cursor.template);
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate grid from 2D char array - particularly useful for testing purposes.
|
||||
#[cfg(test)]
|
||||
pub fn populate_from_array(&mut self, arr: &[&[char]]) {
|
||||
for (i, row) in arr.iter().enumerate() {
|
||||
for (j, &cell) in row.iter().enumerate() {
|
||||
if i < self.rows && j < self.columns {
|
||||
self[i][j].c = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimated_memory_usage_bytes(&self) -> usize {
|
||||
std::mem::size_of_val(self) + self.estimated_heap_usage_bytes()
|
||||
}
|
||||
|
||||
pub fn estimated_heap_usage_bytes(&self) -> usize {
|
||||
// For right now, we're only factoring in the heap size of
|
||||
// the grid storage. Some other fields (e.g.: `secrets` and
|
||||
// `secrets_in_plaintext`) contain heap-allocated data, but we
|
||||
// aren't as worried about them for now.
|
||||
self.raw.estimated_heap_usage_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
impl GridStorage {
|
||||
#[inline]
|
||||
pub fn clear_history(&mut self) {
|
||||
// Explicitly purge all lines from history.
|
||||
self.raw.shrink_lines(self.history_size());
|
||||
}
|
||||
|
||||
/// The number of rows in the grid up until the cursor.
|
||||
fn rows_to_cursor(&self) -> usize {
|
||||
self.cursor.point.row.0 + self.history_size()
|
||||
}
|
||||
|
||||
/// The number of visible rows in the grid up until the cursor.
|
||||
pub fn visible_rows_to_cursor(&self) -> usize {
|
||||
self.cursor.point.row.0
|
||||
}
|
||||
|
||||
/// The number of columns in the grid up until the cursor.
|
||||
pub fn cols_to_cursor(&self) -> usize {
|
||||
self.cursor.point.col
|
||||
}
|
||||
|
||||
/// Truncate all rows after the cursor's row from the Grid.
|
||||
#[inline]
|
||||
pub(super) fn truncate_to_cursor_rows(&mut self) {
|
||||
let cursor_absolute_row = self.rows_to_cursor();
|
||||
|
||||
// We want to include the line _with_ the cursor, so add one here.
|
||||
let rows_to_include = cursor_absolute_row + 1;
|
||||
self.raw.truncate_to(rows_to_include);
|
||||
|
||||
self.rows = self.rows.min(rows_to_include);
|
||||
|
||||
// Reposition the cursor on the visible screen. If the cursor was not at the bottom of the
|
||||
// grid _and_ we had scrollback available, then truncating the lines below the cursor will
|
||||
// effectively move the contents down the grid, so we need to update the cursor to match.
|
||||
// Ultimately, the _absolute_ position of the cursor in the grid contents should not change
|
||||
// by truncating the content after the cursor.
|
||||
let cursor_position_shift = cursor_absolute_row.saturating_sub(self.rows_to_cursor());
|
||||
self.cursor.point.row += cursor_position_shift;
|
||||
|
||||
// Reset the max cursor to be the value of the cursor, since the the max cursor isn't
|
||||
// guaranteed to be in the grid anymore.
|
||||
self.max_cursor_point = self.cursor.point;
|
||||
}
|
||||
|
||||
/// Truncate columns in Grid and Storage to specified target # of columns.
|
||||
fn truncate_columns(&mut self, col_to_truncate_to: usize) {
|
||||
self.raw.truncate_columns(col_to_truncate_to);
|
||||
self.columns = col_to_truncate_to;
|
||||
}
|
||||
|
||||
/// Truncate all columns to the right of the cursor (including the column of the cursor, if the cursor is
|
||||
/// not at the end of a line that needs to wrap for the next character).
|
||||
pub(super) fn truncate_to_cursor_cols(&mut self) {
|
||||
// If the cursor indicates that the input needs to wrap for the next character AND the cursor is at the
|
||||
// end of the line, then, by definition, the entire line that the cursor is on, INCLUDING the column that
|
||||
// the cursor is on, should be preserved. Hence, we don't need to truncate any columns.
|
||||
// Note the cursor columns are zero-indexed hence the +1 below!
|
||||
if !(self.cols_to_cursor() + 1 == self.columns() && self.cursor.input_needs_wrap) {
|
||||
// If the cursor is NOT at the end of the line, then the cursor should be positioned at the next
|
||||
// available cell to print output to. Hence, we truncate all columns from the cursor's column
|
||||
// onwards (INCLUSIVE).
|
||||
self.truncate_columns(self.cols_to_cursor());
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn cursor_cell(&mut self) -> &mut Cell {
|
||||
let mut point = self.cursor_point();
|
||||
|
||||
if point.row >= self.total_rows() || point.col >= self.columns() {
|
||||
log::error!(
|
||||
"Error retrieving cursor cell, cursor point was outside the bounds of the grid: {point:?}"
|
||||
);
|
||||
point = Point {
|
||||
row: self.total_rows().saturating_sub(1),
|
||||
col: self.columns().saturating_sub(1),
|
||||
};
|
||||
}
|
||||
|
||||
&mut self[&point]
|
||||
}
|
||||
|
||||
pub(super) fn cursor_point(&self) -> Point {
|
||||
Point::new(
|
||||
self.history_size() + self.cursor.point.row.0,
|
||||
self.cursor.point.col,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&Row> {
|
||||
if index < self.total_rows() {
|
||||
Some(&self.raw[index])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dimensions for GridStorage {
|
||||
#[inline]
|
||||
fn total_rows(&self) -> usize {
|
||||
self.raw.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visible_rows(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn columns(&self) -> usize {
|
||||
self.columns
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "grid_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Grid resize and reflow.
|
||||
|
||||
use std::cmp::{min, Ordering};
|
||||
use std::mem;
|
||||
|
||||
use crate::terminal::model::cell::{Cell, Flags};
|
||||
use crate::terminal::model::grid::grid_storage::{Dimensions, GridStorage};
|
||||
use crate::terminal::model::grid::row::Row;
|
||||
use crate::terminal::model::index::{VisiblePoint, VisibleRow};
|
||||
|
||||
impl GridStorage {
|
||||
/// Resize the grid's width and/or height.
|
||||
pub fn resize(&mut self, reflow: bool, lines: usize, cols: usize, finished: bool) {
|
||||
if lines == 0 {
|
||||
return;
|
||||
}
|
||||
// Use empty template cell for resetting cells due to resize.
|
||||
let template = mem::take(&mut self.cursor.template);
|
||||
|
||||
match self.columns.cmp(&cols) {
|
||||
Ordering::Less => self.grow_cols(reflow, cols),
|
||||
Ordering::Greater => self.shrink_cols(reflow, cols),
|
||||
Ordering::Equal => (),
|
||||
}
|
||||
|
||||
// If the grid is finished, don't let the number of visible rows exceed
|
||||
// the number of rows to the cursor (including the cursor row).
|
||||
let lines = if finished {
|
||||
lines.min(self.cursor.point.row.0 + 1)
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
|
||||
match self.rows.cmp(&lines) {
|
||||
Ordering::Less => self.grow_lines(lines),
|
||||
Ordering::Greater => self.shrink_lines(lines),
|
||||
Ordering::Equal => (),
|
||||
}
|
||||
|
||||
// Restore template cell.
|
||||
self.cursor.template = template;
|
||||
}
|
||||
|
||||
/// Add lines to the visible area.
|
||||
///
|
||||
/// Alacritty keeps the cursor at the bottom of the terminal as long as there
|
||||
/// is scrollback available. Once scrollback is exhausted, new lines are
|
||||
/// simply added to the bottom of the screen.
|
||||
fn grow_lines(&mut self, new_line_count: usize) {
|
||||
let lines_added = new_line_count - self.rows;
|
||||
|
||||
// Need to resize before updating buffer.
|
||||
self.raw.grow_visible_lines(new_line_count);
|
||||
self.rows = new_line_count;
|
||||
|
||||
let history_size = self.history_size();
|
||||
let from_history = min(history_size, lines_added);
|
||||
|
||||
// Move existing lines up for every line that couldn't be pulled from history.
|
||||
if from_history != lines_added {
|
||||
let delta = lines_added - from_history;
|
||||
self.scroll_up(&(VisibleRow(0)..VisibleRow(new_line_count)), delta);
|
||||
}
|
||||
|
||||
// Move cursor down for every line pulled from history.
|
||||
self.saved_cursor.point.row += from_history;
|
||||
self.cursor.point.row += from_history;
|
||||
self.max_cursor_point.row += from_history;
|
||||
|
||||
self.decrease_scroll_limit(lines_added);
|
||||
}
|
||||
|
||||
/// Remove lines from the visible area.
|
||||
///
|
||||
/// The behavior in Terminal.app and iTerm.app is to keep the cursor at the
|
||||
/// bottom of the screen. This is achieved by pushing history "out the top"
|
||||
/// of the terminal window.
|
||||
///
|
||||
/// Alacritty takes the same approach.
|
||||
pub(crate) fn shrink_lines(&mut self, target: usize) {
|
||||
// Scroll up to keep content inside the window.
|
||||
let required_scrolling = (self.cursor.point.row + 1).saturating_sub(target).0;
|
||||
let last_row = VisibleRow(target - 1);
|
||||
if required_scrolling > 0 {
|
||||
self.scroll_up(&(VisibleRow(0)..VisibleRow(self.rows)), required_scrolling);
|
||||
|
||||
// Clamp cursors to the new viewport size.
|
||||
self.cursor.point.row = min(self.cursor.point.row, last_row);
|
||||
}
|
||||
|
||||
// Clamp saved cursor, since only primary cursor is scrolled into viewport.
|
||||
self.saved_cursor.point.row = min(self.saved_cursor.point.row, last_row);
|
||||
|
||||
if self.max_cursor_point.row > last_row {
|
||||
self.max_cursor_point.row = last_row;
|
||||
}
|
||||
|
||||
self.raw.rotate((self.rows - target) as isize);
|
||||
self.raw.shrink_visible_lines(target);
|
||||
self.rows = target;
|
||||
}
|
||||
|
||||
/// Grow number of columns in each row, reflowing if necessary.
|
||||
fn grow_cols(&mut self, reflow: bool, columns: usize) {
|
||||
// Check if a row needs to be wrapped.
|
||||
let should_reflow = |row: &Row| -> bool {
|
||||
let len = row.len();
|
||||
reflow && len > 0 && len < columns && row[len - 1].flags().contains(Flags::WRAPLINE)
|
||||
};
|
||||
|
||||
self.columns = columns;
|
||||
|
||||
let mut reversed: Vec<Row> = Vec::with_capacity(self.raw.len());
|
||||
let mut cursor_line_delta = 0_usize;
|
||||
|
||||
// Remove the linewrap special case, by moving the cursor outside of the grid.
|
||||
if self.cursor.input_needs_wrap && reflow {
|
||||
self.cursor.input_needs_wrap = false;
|
||||
self.cursor.point.col += 1;
|
||||
}
|
||||
|
||||
let mut rows = self.raw.take_all();
|
||||
let raw_total_rows_len = rows.len();
|
||||
|
||||
for (i, mut row) in rows.drain(..).enumerate().rev() {
|
||||
// Check if reflowing should be performed.
|
||||
let last_row = match reversed.last_mut() {
|
||||
Some(last_row) if should_reflow(last_row) => last_row,
|
||||
_ => {
|
||||
reversed.push(row);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm that the last row is a wrapped row.
|
||||
debug_assert!(
|
||||
last_row[last_row.len() - 1]
|
||||
.flags()
|
||||
.contains(Flags::WRAPLINE),
|
||||
"Trying to reflow a non-wrapped row"
|
||||
);
|
||||
|
||||
// Remove wrap flag before appending additional cells.
|
||||
if let Some(cell) = last_row.last_mut() {
|
||||
cell.flags_mut().remove(Flags::WRAPLINE);
|
||||
}
|
||||
|
||||
// Remove leading spacers when reflowing wide char to the previous line.
|
||||
let mut last_len = last_row.len();
|
||||
if last_len >= 1
|
||||
&& last_row[last_len - 1]
|
||||
.flags()
|
||||
.contains(Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
{
|
||||
last_row.shrink(last_len - 1);
|
||||
last_len -= 1;
|
||||
}
|
||||
|
||||
// Don't try to pull more cells from the next line than available.
|
||||
let mut num_wrapped = columns - last_len;
|
||||
let len = min(row.len(), num_wrapped);
|
||||
|
||||
// Insert leading spacer when there's not enough room for reflowing wide char.
|
||||
let mut cells = if row[len - 1].flags().contains(Flags::WIDE_CHAR) {
|
||||
num_wrapped -= 1;
|
||||
|
||||
let mut cells = row.front_split_off(len - 1);
|
||||
|
||||
let mut spacer = Cell::default();
|
||||
spacer.flags_mut().insert(Flags::LEADING_WIDE_CHAR_SPACER);
|
||||
cells.push(spacer);
|
||||
|
||||
cells
|
||||
} else {
|
||||
row.front_split_off(len)
|
||||
};
|
||||
|
||||
// Add removed cells to previous row and reflow content.
|
||||
last_row.append(&mut cells);
|
||||
|
||||
// We do not want to consider rows containing the end of prompt to be "clear", even if they are "empty".
|
||||
let row_is_clear = row.is_clear() && row.has_no_end_of_prompt_marker();
|
||||
|
||||
// First, reflow the max_cursor position, if necessary
|
||||
let max_cursor_line = self.rows - self.max_cursor_point.row.0 - 1;
|
||||
if i == max_cursor_line && reflow {
|
||||
let target = self.max_cursor_point.wrapping_sub(columns, num_wrapped);
|
||||
self.max_cursor_point.col = target.col;
|
||||
} else if row_is_clear && i < max_cursor_line {
|
||||
self.max_cursor_point.row += 1;
|
||||
}
|
||||
|
||||
// Next, reflow the actual cursor position - This has an impact on how much history is
|
||||
// pulled in, so it includes control flow not needed for max_cursor.
|
||||
let cursor_buffer_line = self.rows - self.cursor.point.row.0 - 1;
|
||||
if i == cursor_buffer_line && reflow {
|
||||
let visible_point_origin = VisiblePoint {
|
||||
row: VisibleRow(0),
|
||||
col: 0,
|
||||
};
|
||||
let mut adjust_cursor = false;
|
||||
|
||||
// We want to adjust the cursor properly IF we need to pull a row from scrollback history.
|
||||
// This applies when:
|
||||
// 1. The cursor adjustment would incorrectly saturate at (0, 0) if no adjustment was made.
|
||||
// 2. We have at least 1 row in scrollback (raw_total_rows_len - self.rows >= 1)
|
||||
if self.resize_fix_ff_enabled
|
||||
&& row_is_clear
|
||||
&& self.cursor.point.wrapping_sub(columns, num_wrapped) == visible_point_origin
|
||||
&& self.cursor.point.wrapping_sub(columns, num_wrapped + 1)
|
||||
== visible_point_origin
|
||||
&& raw_total_rows_len - self.rows >= 1
|
||||
{
|
||||
// Fixes the bug of incorrect cursor position reflow in situations where we are
|
||||
// re-growing the terminal window after shrinking it. Specifically, this results in
|
||||
// scrollback history lines, which we want to pull back into the "visible lines".
|
||||
// We rotate the cursor down to ensure that the wrapping subtraction logic below with
|
||||
// `num_wrapped` can be completed correctly, without saturating at (0, 0) (which previously
|
||||
// occurred, if the cursor isn't adjusted). Ultimately, the user-facing impact resulted in an
|
||||
// incorrect cursor position comparison leading to incorrect block heights, since Warp
|
||||
// erronenously believed a command to be "empty" (when comparing the "end of prompt" cursor
|
||||
// to the "end of the command").
|
||||
// Note that last_row is a wrapped line in this case (see `should_reflow` and `debug_assert` above)!
|
||||
self.cursor.point.row += 1;
|
||||
adjust_cursor = true;
|
||||
}
|
||||
// Resize cursor's line and reflow the cursor if necessary.
|
||||
let mut target = self.cursor.point.wrapping_sub(columns, num_wrapped);
|
||||
|
||||
// Clamp to the last column, if no content was reflown with the cursor.
|
||||
if target.col == 0 && row_is_clear {
|
||||
self.cursor.input_needs_wrap = true;
|
||||
target = target.wrapping_sub(columns, 1);
|
||||
}
|
||||
self.cursor.point.col = target.col;
|
||||
|
||||
// Get required cursor line changes. Since `num_wrapped` is smaller than `cols`
|
||||
// this will always be either `0` or `1`.
|
||||
let line_delta = self.cursor.point.row - target.row;
|
||||
|
||||
if line_delta != 0 && row_is_clear {
|
||||
if self.resize_fix_ff_enabled && adjust_cursor {
|
||||
// We move the cursor up a line, if the current row is being entirely reflowed and removed.
|
||||
self.cursor.point.row = self.cursor.point.row - line_delta;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
cursor_line_delta += line_delta;
|
||||
} else if row_is_clear {
|
||||
// Rotate cursor down if content below them was pulled from history.
|
||||
if i < cursor_buffer_line {
|
||||
self.cursor.point.row += 1;
|
||||
}
|
||||
|
||||
// Don't push line into the new buffer.
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(cell) = last_row.last_mut() {
|
||||
// Set wrap flag if next line still has cells.
|
||||
cell.flags_mut().insert(Flags::WRAPLINE);
|
||||
}
|
||||
|
||||
reversed.push(row);
|
||||
}
|
||||
|
||||
// Make sure we have at least the viewport filled.
|
||||
if reversed.len() < self.rows {
|
||||
let delta = self.rows - reversed.len();
|
||||
self.cursor.point.row = self.cursor.point.row.saturating_sub(delta);
|
||||
self.max_cursor_point.row = self.max_cursor_point.row.saturating_sub(delta);
|
||||
reversed.resize_with(self.rows, || Row::new(columns));
|
||||
}
|
||||
|
||||
// Pull content down to put cursor in correct position, or move cursor up if there's no
|
||||
// more lines to delete below the cursor.
|
||||
if cursor_line_delta != 0 {
|
||||
let cursor_buffer_line = self.rows - self.cursor.point.row.0 - 1;
|
||||
let available = min(cursor_buffer_line, reversed.len() - self.rows);
|
||||
let overflow = cursor_line_delta.saturating_sub(available);
|
||||
reversed.truncate(reversed.len() + overflow - cursor_line_delta);
|
||||
self.cursor.point.row = self.cursor.point.row.saturating_sub(overflow);
|
||||
self.max_cursor_point.row = self.max_cursor_point.row.saturating_sub(overflow);
|
||||
}
|
||||
|
||||
// Reverse iterator and fill all rows that are still too short.
|
||||
let mut new_raw = Vec::with_capacity(reversed.len());
|
||||
for mut row in reversed.drain(..).rev() {
|
||||
if row.len() < columns {
|
||||
row.grow(columns);
|
||||
}
|
||||
new_raw.push(row);
|
||||
}
|
||||
|
||||
self.raw.replace_inner(new_raw);
|
||||
|
||||
// Confirm we haven't adjusted the cursor incorrectly into an invalid state (beyond max number of rows).
|
||||
debug_assert!(
|
||||
self.cursor.point.row <= VisibleRow(self.rows - 1),
|
||||
"Cursor in invalid state (beyond max number of rows)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Shrink number of columns in each row, reflowing if necessary.
|
||||
fn shrink_cols(&mut self, reflow: bool, columns: usize) {
|
||||
self.columns = columns;
|
||||
|
||||
// Remove the linewrap special case, by moving the cursor outside of the grid.
|
||||
if self.cursor.input_needs_wrap && reflow {
|
||||
self.cursor.input_needs_wrap = false;
|
||||
self.cursor.point.col += 1;
|
||||
}
|
||||
|
||||
let mut new_raw = Vec::with_capacity(self.raw.len());
|
||||
let mut buffered: Option<Vec<Cell>> = None;
|
||||
|
||||
let mut rows = self.raw.take_all();
|
||||
for (i, mut row) in rows.drain(..).enumerate().rev() {
|
||||
// Append lines left over from the previous row.
|
||||
if let Some(buffered) = buffered.take() {
|
||||
// Add a column for every cell added before the cursor, if it goes beyond the new
|
||||
// width it is then later reflown.
|
||||
let cursor_buffer_line = self.rows - self.cursor.point.row.0 - 1;
|
||||
if i == cursor_buffer_line {
|
||||
self.cursor.point.col += buffered.len();
|
||||
}
|
||||
|
||||
// Also add to the max_cursor position in the same manner
|
||||
let max_cursor_line = self.rows - self.max_cursor_point.row.0 - 1;
|
||||
if i == max_cursor_line {
|
||||
self.max_cursor_point.col += buffered.len();
|
||||
}
|
||||
|
||||
row.append_front(buffered);
|
||||
}
|
||||
|
||||
loop {
|
||||
// Remove all cells which require reflowing.
|
||||
let mut wrapped = match row.shrink(columns) {
|
||||
Some(wrapped) if reflow => wrapped,
|
||||
_ => {
|
||||
let cursor_buffer_line =
|
||||
self.rows.saturating_sub(self.cursor.point.row.0 + 1);
|
||||
let max_cursor_line =
|
||||
self.rows.saturating_sub(self.max_cursor_point.row.0 + 1);
|
||||
if reflow
|
||||
&& ((i == cursor_buffer_line && self.cursor.point.col >= columns)
|
||||
|| (i == max_cursor_line && self.max_cursor_point.col >= columns))
|
||||
{
|
||||
// If there are empty cells before the cursor or max_cursor, we assume
|
||||
// it is explicit whitespace and need to wrap it like normal content.
|
||||
Vec::new()
|
||||
} else {
|
||||
// Since it fits, just push the existing line without any reflow.
|
||||
new_raw.push(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Insert spacer if a wide char would be wrapped into the last column.
|
||||
if row.len() >= columns && row[columns - 1].flags().contains(Flags::WIDE_CHAR) {
|
||||
let mut spacer = Cell::default();
|
||||
spacer.flags_mut().insert(Flags::LEADING_WIDE_CHAR_SPACER);
|
||||
|
||||
let wide_char = mem::replace(&mut row[columns - 1], spacer);
|
||||
wrapped.insert(0, wide_char);
|
||||
}
|
||||
|
||||
// Remove wide char spacer before shrinking.
|
||||
let len = wrapped.len();
|
||||
if len > 0
|
||||
&& wrapped[len - 1]
|
||||
.flags()
|
||||
.contains(Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
{
|
||||
if len == 1 {
|
||||
row[columns - 1].flags_mut().insert(Flags::WRAPLINE);
|
||||
new_raw.push(row);
|
||||
break;
|
||||
} else {
|
||||
// Remove the leading spacer from the end of the wrapped row.
|
||||
wrapped[len - 2].flags_mut().insert(Flags::WRAPLINE);
|
||||
wrapped.truncate(len - 1);
|
||||
}
|
||||
}
|
||||
|
||||
new_raw.push(row);
|
||||
|
||||
// Set line as wrapped if cells got removed.
|
||||
if let Some(cell) = new_raw.last_mut().and_then(|r| r.last_mut()) {
|
||||
cell.flags_mut().insert(Flags::WRAPLINE);
|
||||
}
|
||||
|
||||
if wrapped
|
||||
.last()
|
||||
.map(|c| c.flags().contains(Flags::WRAPLINE) && i >= 1)
|
||||
.unwrap_or(false)
|
||||
&& wrapped.len() < columns
|
||||
{
|
||||
// Make sure previous wrap flag doesn't linger around.
|
||||
if let Some(cell) = wrapped.last_mut() {
|
||||
cell.flags_mut().remove(Flags::WRAPLINE);
|
||||
}
|
||||
|
||||
// Add removed cells to start of next row.
|
||||
buffered = Some(wrapped);
|
||||
break;
|
||||
} else {
|
||||
// Reflow cursor if a line below it is deleted.
|
||||
let cursor_buffer_line = self.rows - self.cursor.point.row.0 - 1;
|
||||
if (i == cursor_buffer_line && self.cursor.point.col < columns)
|
||||
|| i < cursor_buffer_line
|
||||
{
|
||||
self.cursor.point.row = self.cursor.point.row.saturating_sub(1);
|
||||
}
|
||||
|
||||
// Reflow the cursor if it is on this line beyond the width.
|
||||
if i == cursor_buffer_line && self.cursor.point.col >= columns {
|
||||
// Since only a single new line is created, we subtract only `cols`
|
||||
// from the cursor instead of reflowing it completely.
|
||||
self.cursor.point.col -= columns;
|
||||
}
|
||||
|
||||
// Reflow max_cursor if a line below it is deleted
|
||||
let max_cursor_line = self.rows - self.max_cursor_point.row.0 - 1;
|
||||
if (i == max_cursor_line && self.max_cursor_point.col < columns)
|
||||
|| i < max_cursor_line
|
||||
{
|
||||
self.max_cursor_point.row = self.max_cursor_point.row.saturating_sub(1);
|
||||
}
|
||||
|
||||
// Reflow the max cursor if it is on this line beyond the width.
|
||||
if i == max_cursor_line && self.max_cursor_point.col >= columns {
|
||||
self.max_cursor_point.col -= columns;
|
||||
}
|
||||
|
||||
// Make sure new row is at least as long as new width.
|
||||
let occ = wrapped.len();
|
||||
if occ < columns {
|
||||
wrapped.resize_with(columns, Cell::default);
|
||||
}
|
||||
row = Row::from_vec(wrapped, occ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse iterator and use it as the new grid storage.
|
||||
let mut reversed: Vec<Row> = new_raw.drain(..).rev().collect();
|
||||
reversed.truncate(self.max_scroll_limit + self.rows);
|
||||
self.raw.replace_inner(reversed);
|
||||
|
||||
// Reflow the cursor and max_cursor positions, clamping if reflow is disabled
|
||||
if !reflow {
|
||||
self.cursor.point.col = min(self.cursor.point.col, columns - 1);
|
||||
self.max_cursor_point.col = min(self.max_cursor_point.col, columns - 1);
|
||||
} else {
|
||||
if self.cursor.point.col == columns
|
||||
&& !self[self.cursor_point().row][columns - 1]
|
||||
.flags
|
||||
.contains(Flags::WRAPLINE)
|
||||
{
|
||||
self.cursor.input_needs_wrap = true;
|
||||
self.cursor.point.col -= 1;
|
||||
} else {
|
||||
self.cursor.point = self.cursor.point.wrap(columns);
|
||||
}
|
||||
self.max_cursor_point = self.max_cursor_point.wrap(columns);
|
||||
}
|
||||
|
||||
// Clamp the saved cursor to the grid.
|
||||
self.saved_cursor.point.col = min(self.saved_cursor.point.col, columns - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use crate::terminal::model::ansi::{self, Handler as _};
|
||||
use crate::terminal::model::grid::grid_handler::GridHandler;
|
||||
use crate::terminal::model::index::Point;
|
||||
use crate::terminal::model::secrets::ObfuscateSecrets;
|
||||
use crate::terminal::model::{
|
||||
grid::Dimensions,
|
||||
index::{VisiblePoint, VisibleRow},
|
||||
};
|
||||
use crate::terminal::SizeInfo;
|
||||
|
||||
use super::GridStorage;
|
||||
|
||||
macro_rules! assert_cell_char_eq {
|
||||
($grid:ident[$row:literal][$col:literal], $expected:literal) => {
|
||||
let row = $grid.row($row).expect("row should exist");
|
||||
assert_eq!(row[$col].c, $expected);
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_scroll_down() {
|
||||
// this simulates the alt-screen with 5 rows
|
||||
let mut grid = GridStorage::new(5, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
grid.scroll_down(&(VisibleRow(0)..VisibleRow(5)), 1);
|
||||
assert_eq!(grid[0][0].c, '\0');
|
||||
assert_eq!(grid[1][0].c, 'a');
|
||||
assert_eq!(grid[2][0].c, 'b');
|
||||
assert_eq!(grid[3][0].c, 'c');
|
||||
assert_eq!(grid[4][0].c, 'd');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_scroll_down_with_fixed_line() {
|
||||
// this simulates the alt-screen with a fixed row at the top and bottom
|
||||
let mut grid = GridStorage::new(5, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
grid.scroll_down(&(VisibleRow(1)..VisibleRow(4)), 1);
|
||||
assert_eq!(grid[0][0].c, 'a');
|
||||
assert_eq!(grid[1][0].c, '\0');
|
||||
assert_eq!(grid[2][0].c, 'b');
|
||||
assert_eq!(grid[3][0].c, 'c');
|
||||
assert_eq!(grid[4][0].c, 'e');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_scroll_up() {
|
||||
// this simulates the alt-screen with 5 rows
|
||||
let mut grid = GridStorage::new(5, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
grid.scroll_up(&(VisibleRow(0)..VisibleRow(5)), 1);
|
||||
assert_eq!(grid[0][0].c, 'b');
|
||||
assert_eq!(grid[1][0].c, 'c');
|
||||
assert_eq!(grid[2][0].c, 'd');
|
||||
assert_eq!(grid[3][0].c, 'e');
|
||||
assert_eq!(grid[4][0].c, '\0');
|
||||
assert_eq!(grid.num_lines_truncated, 1);
|
||||
|
||||
// this simulates the alt-screen with a fixed row at the top and bottom
|
||||
let mut grid = GridStorage::new(5, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
grid.scroll_up(&(VisibleRow(1)..VisibleRow(4)), 1);
|
||||
assert_eq!(grid[0][0].c, 'a');
|
||||
assert_eq!(grid[1][0].c, 'c');
|
||||
assert_eq!(grid[2][0].c, 'd');
|
||||
assert_eq!(grid[3][0].c, '\0');
|
||||
assert_eq!(grid[4][0].c, 'e');
|
||||
assert_eq!(grid.num_lines_truncated, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_scroll_up_by_multiple_lines() {
|
||||
let mut grid = GridStorage::new(5, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
|
||||
grid.scroll_up(&(VisibleRow(0)..VisibleRow(5)), 2);
|
||||
|
||||
assert_eq!(grid[0][0].c, 'c');
|
||||
assert_eq!(grid[1][0].c, 'd');
|
||||
assert_eq!(grid[2][0].c, 'e');
|
||||
assert_eq!(grid[3][0].c, '\0');
|
||||
assert_eq!(grid[4][0].c, '\0');
|
||||
|
||||
// Every line scrolled immediately becomes truncated since there's no scrollback buffer.
|
||||
assert_eq!(grid.num_lines_truncated, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_scroll_up_only_some_lines_truncated() {
|
||||
let mut grid = GridStorage::new(5, 1, 1, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
|
||||
grid.scroll_up(&(VisibleRow(0)..VisibleRow(5)), 2);
|
||||
|
||||
assert_eq!(grid[0][0].c, 'b');
|
||||
assert_eq!(grid[1][0].c, 'c');
|
||||
assert_eq!(grid[2][0].c, 'd');
|
||||
assert_eq!(grid[3][0].c, 'e');
|
||||
assert_eq!(grid[4][0].c, '\0');
|
||||
|
||||
// Only one line ('b') should be truncated even though we scrolled up by 2
|
||||
// since the scrollback buffer has space for one row.
|
||||
assert_eq!(grid.history_size(), 1);
|
||||
assert_eq!(grid.num_lines_truncated, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_scroll_up_with_fixed_lines() {
|
||||
// this simulates the alt-screen with a fixed row at the top and bottom
|
||||
let mut grid = GridStorage::new(5, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
grid.scroll_up(&(VisibleRow(1)..VisibleRow(4)), 1);
|
||||
assert_eq!(grid[0][0].c, 'a');
|
||||
assert_eq!(grid[1][0].c, 'c');
|
||||
assert_eq!(grid[2][0].c, 'd');
|
||||
assert_eq!(grid[3][0].c, '\0');
|
||||
assert_eq!(grid[4][0].c, 'e');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_git_diff_or_log() {
|
||||
// Imagine this is a git log.
|
||||
|
||||
// First, simulate the shell populating the visible screen.
|
||||
let mut grid = GridStorage::new(5, 1, 2, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
assert_eq!(grid.total_rows(), 5);
|
||||
|
||||
// Second, simulate the user scrolling for more rows twice.
|
||||
grid.scroll_up(&(VisibleRow(0)..VisibleRow(5)), 2); // use the scrollback
|
||||
assert_eq!(grid.total_rows(), 7);
|
||||
|
||||
// Now, the shell populating these two new rows.
|
||||
grid[5][0].c = 'f';
|
||||
grid[6][0].c = 'g';
|
||||
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c'); // grid index 2
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, 'f');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, 'g'); // grid index 6
|
||||
|
||||
// Third, simulate the user scrolling back towards the top
|
||||
grid.scroll_down(&(VisibleRow(0)..VisibleRow(5)), 1);
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, '\0'); // the shell is responsible for populating this
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, 'f');
|
||||
|
||||
// The off-screen region should still be intact
|
||||
assert_eq!(grid[0][0].c, 'a');
|
||||
assert_eq!(grid[1][0].c, 'b');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_cursor_bottom_scrollback() {
|
||||
// Create a filled grid with 2 lines of scrollback buffer
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(5, 1, 2);
|
||||
{
|
||||
let grid = grid.grid_storage_mut();
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
}
|
||||
|
||||
// Push two rows into scrollback
|
||||
grid.scroll_up(2);
|
||||
assert_eq!(grid.history_size(), 2);
|
||||
|
||||
// Verify the grid
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Set the cursor to the bottom of the grid
|
||||
grid.set_cursor_point(4, 0);
|
||||
|
||||
// Truncate everything after the cursor
|
||||
grid.truncate_to_cursor_rows();
|
||||
|
||||
// Verify that the grid is unchanged
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Verify that the cursor is unchanged
|
||||
assert_eq!(grid.grid_storage().cursor.point.row, VisibleRow(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_cursor_bottom_no_scrollback() {
|
||||
// Create a filled grid with no scrollback buffer
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(5, 1, 0);
|
||||
{
|
||||
let grid = grid.grid_storage_mut();
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
}
|
||||
|
||||
// Push two rows off the screen
|
||||
grid.scroll_up(2);
|
||||
assert_eq!(grid.history_size(), 0);
|
||||
|
||||
// Verify the grid
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Set the cursor to the bottom of the grid
|
||||
grid.set_cursor_point(4, 0);
|
||||
|
||||
// Truncate everything after the cursor
|
||||
grid.truncate_to_cursor_rows();
|
||||
|
||||
// Verify that the grid is unchanged
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Verify that the cursor is unchanged
|
||||
assert_eq!(grid.grid_storage().cursor.point.row, VisibleRow(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_cursor_middle_scrollback() {
|
||||
// Create a filled grid with 2 lines of scrollback buffer
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(5, 1, 2);
|
||||
{
|
||||
let grid = grid.grid_storage_mut();
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
}
|
||||
|
||||
// Push two rows into scrollback
|
||||
grid.scroll_up(2);
|
||||
|
||||
// Verify the grid
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Set the cursor to the middle of the grid
|
||||
grid.set_cursor_point(2, 0);
|
||||
|
||||
// Truncate everything after the cursor
|
||||
grid.truncate_to_cursor_rows();
|
||||
|
||||
// Verify that the grid has pulled content from scrollback
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'a');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'b');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, 'e');
|
||||
}
|
||||
|
||||
// Verify that the cursor is now at the bottom
|
||||
assert_eq!(grid.grid_storage().cursor.point.row, VisibleRow(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_cursor_middle_partial_scrollback() {
|
||||
// Create a filled grid with one line of scrollback buffer
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(5, 1, 1);
|
||||
{
|
||||
let grid = grid.grid_storage_mut();
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
}
|
||||
|
||||
// Push two rows off the screen; one will stay in scrollback because that is the limit
|
||||
grid.scroll_up(2);
|
||||
assert_eq!(grid.history_size(), 1);
|
||||
|
||||
// Verify the grid
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Set the cursor to the middle of the grid
|
||||
grid.set_cursor_point(2, 0);
|
||||
|
||||
// Truncate everything after the cursor
|
||||
grid.truncate_to_cursor_rows();
|
||||
|
||||
// Expected behavior is that two rows below the cursor get truncated, and
|
||||
// one row gets pulled into the grid from scrollback. This leaves a total
|
||||
// of 4 visible rows, and none in scrollback.
|
||||
assert_eq!(grid.visible_rows(), 4);
|
||||
assert_eq!(grid.history_size(), 0);
|
||||
|
||||
// Verify that the grid has pulled content from scrollback
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'b');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, 'e');
|
||||
}
|
||||
|
||||
// Verify that the cursor has shifted
|
||||
assert_eq!(grid.grid_storage().cursor.point.row, VisibleRow(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_cursor_middle_no_scrollback() {
|
||||
// Create a filled grid with no scrollback buffer
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(5, 1, 0);
|
||||
{
|
||||
let grid = grid.grid_storage_mut();
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid[3][0].c = 'd';
|
||||
grid[4][0].c = 'e';
|
||||
}
|
||||
|
||||
// Push two rows off the screen
|
||||
grid.scroll_up(2);
|
||||
|
||||
// Verify the grid
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
assert_eq!(grid[VisibleRow(3)][0].c, '\0');
|
||||
assert_eq!(grid[VisibleRow(4)][0].c, '\0');
|
||||
}
|
||||
|
||||
// Set the cursor to the middle of the grid
|
||||
grid.set_cursor_point(2, 0);
|
||||
|
||||
// Truncate everything after the cursor
|
||||
grid.truncate_to_cursor_rows();
|
||||
assert_eq!(grid.visible_rows(), 3);
|
||||
assert_eq!(grid.history_size(), 0);
|
||||
|
||||
// Verify that the grid is truncated
|
||||
{
|
||||
let grid = grid.grid_storage();
|
||||
assert_eq!(grid[VisibleRow(0)][0].c, 'c');
|
||||
assert_eq!(grid[VisibleRow(1)][0].c, 'd');
|
||||
assert_eq!(grid[VisibleRow(2)][0].c, 'e');
|
||||
}
|
||||
|
||||
// Verify that the cursor is unchanged
|
||||
assert_eq!(grid.grid_storage().cursor.point.row, VisibleRow(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_prompt_point() {
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(5, 6, 1);
|
||||
assert_eq!(grid.prompt_end_point(), None);
|
||||
|
||||
grid.grid_storage_mut()[2][1].mark_end_of_prompt(false);
|
||||
|
||||
assert_eq!(grid.prompt_end_point(), Some(Point::new(2, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_truncate_to_cursor_cols() {
|
||||
let mut grid = GridStorage::new(3, 6, 1, ObfuscateSecrets::No);
|
||||
// Before:
|
||||
// 012345
|
||||
// 0 ab
|
||||
// 1 c
|
||||
// 2
|
||||
grid[0][0].c = 'a';
|
||||
grid[0][1].c = 'b';
|
||||
grid[0][2].c = ' ';
|
||||
grid[1][2].c = 'c';
|
||||
grid[1][3].c = ' ';
|
||||
|
||||
// Simulate that the cursor just finished printing the characters above.
|
||||
grid.cursor.point = VisiblePoint {
|
||||
row: VisibleRow(1),
|
||||
// Cursor goes one character beyond the last printed character
|
||||
// i.e. the next cell we can print.
|
||||
col: 4,
|
||||
};
|
||||
|
||||
assert_eq!(grid.columns, 6);
|
||||
|
||||
// We expect this truncate the grid to 4 columns.
|
||||
grid.truncate_to_cursor_cols();
|
||||
|
||||
// After:
|
||||
// 0123
|
||||
// 0 ab
|
||||
// 1 c
|
||||
// 2
|
||||
|
||||
// Verify we've truncated the columns and cell content still exists.
|
||||
assert_eq!(grid.columns, 4);
|
||||
assert_eq!(grid[1][2].c, 'c');
|
||||
assert_eq!(grid[1][3].c, ' ');
|
||||
}
|
||||
|
||||
/// Test to ensure we DO NOT truncate "true content" in the case where the cursor is at the end of the line
|
||||
/// and indicates the input needs to be wrapped for the next character.
|
||||
#[test]
|
||||
fn test_grid_truncate_to_cursor_cols_full_wrapped_line() {
|
||||
let mut grid = GridStorage::new(3, 6, 1, ObfuscateSecrets::No);
|
||||
// Before:
|
||||
// 012345
|
||||
// 0 ab
|
||||
// 1 abcdef <- cursor at (1, 5) with input_needs_wrap = true
|
||||
// 2
|
||||
grid[0][0].c = 'a';
|
||||
grid[0][1].c = 'b';
|
||||
grid[0][2].c = ' ';
|
||||
|
||||
grid[1][0].c = 'a';
|
||||
grid[1][1].c = 'b';
|
||||
grid[1][2].c = 'c';
|
||||
grid[1][3].c = 'd';
|
||||
grid[1][4].c = 'e';
|
||||
grid[1][5].c = 'f';
|
||||
|
||||
// Cursor at end of line, with input_needs_wrap = true, indicating the next
|
||||
// character should be wrapped!
|
||||
grid.cursor.point = VisiblePoint {
|
||||
row: VisibleRow(1),
|
||||
col: 5,
|
||||
};
|
||||
grid.cursor.input_needs_wrap = true;
|
||||
|
||||
assert_eq!(grid.columns, 6);
|
||||
|
||||
// We expect this DOES NOT truncate the grid any further, due to the cursor state above.
|
||||
grid.truncate_to_cursor_cols();
|
||||
|
||||
// Verify we haven't truncated columns and cell content still exists.
|
||||
assert_eq!(grid.columns, 6);
|
||||
assert_eq!(grid[0][1].c, 'b');
|
||||
assert_eq!(grid[1][5].c, 'f');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_grid_cursor_last_position() {
|
||||
// 1. Setup: Create and initialize the grid.
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(4, 6, 4);
|
||||
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
&['a', 'a', '\0', '\0', '\0', '\0'],
|
||||
&['b', 'b', '\0', '\0', '\0', '\0'],
|
||||
&['c', 'c', 'c', 'c', '\0', '\0'],
|
||||
&['d', 'd', 'd', 'd', 'd', '\0'],
|
||||
]);
|
||||
|
||||
assert_eq!(grid.dirty_cells_range(), None);
|
||||
|
||||
// We update the cursor, to create a dirty_cells_range
|
||||
grid.update_cursor(|cursor| {
|
||||
cursor.point.row = VisibleRow(3);
|
||||
cursor.point.col = 5;
|
||||
});
|
||||
|
||||
// The cursor marks the cell _after_ the last modified cell, so the range
|
||||
// should extend to the cell before the cursor.
|
||||
assert_eq!(
|
||||
grid.dirty_cells_range(),
|
||||
Some(Point::new(0, 0)..=Point::new(3, 4))
|
||||
);
|
||||
|
||||
// Grid splitting only ever occurs from the main thread, so pretend to
|
||||
// finish byte processing before we start the split. This should clear
|
||||
// out the dirty cells range.
|
||||
grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert!(grid.dirty_cells_range().is_none());
|
||||
|
||||
// 2. Preconditions: Check initial state.
|
||||
assert_eq!(grid.columns(), 6);
|
||||
assert_eq!(grid.grid_storage()[0][0].c, 'a'); // Should be reversed due to bottom_row = 0
|
||||
|
||||
// 3. Action: Split the grid.
|
||||
match grid.split(NonZeroUsize::new(1).expect("should not be zero")) {
|
||||
(grid, Some(bottom_grid)) => {
|
||||
// 4. Postconditions: Verify split results.
|
||||
// Check row and visible counts
|
||||
assert_eq!(grid.total_rows(), 1);
|
||||
assert_eq!(grid.visible_rows(), 1);
|
||||
assert_eq!(grid.grid_storage().raw.len(), 1);
|
||||
|
||||
assert_eq!(bottom_grid.total_rows(), 3);
|
||||
assert_eq!(bottom_grid.visible_rows(), 3);
|
||||
assert_eq!(bottom_grid.grid_storage().raw.len(), 3);
|
||||
|
||||
// Verify character content for grid1 and grid2
|
||||
assert_eq!(grid.grid_storage()[0][0].c, 'a');
|
||||
assert_eq!(grid.grid_storage()[0][1].c, 'a');
|
||||
|
||||
assert_eq!(bottom_grid.grid_storage()[0][0].c, 'b');
|
||||
assert_eq!(bottom_grid.grid_storage()[0][1].c, 'b');
|
||||
assert_eq!(bottom_grid.grid_storage()[1][0].c, 'c');
|
||||
assert_eq!(bottom_grid.grid_storage()[2][0].c, 'd');
|
||||
|
||||
// Check cursor position
|
||||
assert_eq!(
|
||||
grid.grid_storage().cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(0),
|
||||
col: 5
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
bottom_grid.grid_storage().cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(2),
|
||||
col: 5
|
||||
}
|
||||
);
|
||||
|
||||
// We shouldn't ever split a grid while we're in the middle of
|
||||
// processing data, so the dirty_cells_range should always be
|
||||
// empty (i.e.: None).
|
||||
assert!(grid.dirty_cells_range().is_none());
|
||||
assert!(bottom_grid.dirty_cells_range().is_none());
|
||||
}
|
||||
_ => {
|
||||
panic!("Received None from split (split row exceeded Grid!)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_grid_cursor_in_grid1() {
|
||||
// 1. Setup: Create and initialize the grid.
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(4, 6, 4);
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
&['a', 'a', '\0', '\0', '\0', '\0'],
|
||||
&['b', 'b', '\0', '\0', '\0', '\0'],
|
||||
&['c', 'c', 'c', 'c', '\0', '\0'],
|
||||
&['d', 'd', 'd', 'd', 'd', '\0'],
|
||||
]);
|
||||
|
||||
// Place the cursor somewhere in the first half of the grid.
|
||||
grid.set_cursor_point(1, 1); // Cursor at 'b'
|
||||
grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// 2. Preconditions: Check initial state.
|
||||
assert_eq!(
|
||||
grid.grid_storage().cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(1),
|
||||
col: 1
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Action: Split the grid at the third row (index 2).
|
||||
match grid.split(NonZeroUsize::new(2).expect("should not be zero")) {
|
||||
(grid, Some(bottom_grid)) => {
|
||||
let grid = grid.grid_storage();
|
||||
let bottom_grid = bottom_grid.grid_storage();
|
||||
|
||||
// 4. Postconditions: Verify split results.
|
||||
|
||||
// Check that cursor's position in grid1 is as expected.
|
||||
assert_eq!(
|
||||
grid.cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(1),
|
||||
col: 1
|
||||
}
|
||||
);
|
||||
|
||||
// Ensure grid2's cursor is at the default (0, 0) position.
|
||||
assert_eq!(
|
||||
bottom_grid.cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(0),
|
||||
col: 0
|
||||
}
|
||||
);
|
||||
|
||||
// Additionally, verify the contents to ensure the split was successful.
|
||||
assert_eq!(grid[0][0].c, 'a');
|
||||
assert_eq!(grid[1][0].c, 'b');
|
||||
|
||||
assert_eq!(bottom_grid[0][0].c, 'c');
|
||||
assert_eq!(bottom_grid[1][0].c, 'd');
|
||||
}
|
||||
_ => {
|
||||
panic!("Received None from split (split row exceeded Grid!)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_already_split_grid() {
|
||||
// 1. Setup: Create and initialize the grid.
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(4, 6, 4);
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
&['a', 'a', '\0', '\0', '\0', '\0'],
|
||||
&['b', 'b', '\0', '\0', '\0', '\0'],
|
||||
&['c', 'c', 'c', 'c', '\0', '\0'],
|
||||
&['d', 'd', 'd', 'd', 'd', '\0'],
|
||||
]);
|
||||
|
||||
match grid.split(NonZeroUsize::new(2).expect("should not be zero")) {
|
||||
(grid, Some(bottom_grid)) => {
|
||||
// Preconditions: Check initial state of split grid.
|
||||
assert_eq!(grid.columns(), 6);
|
||||
assert_eq!(grid.total_rows(), 2);
|
||||
|
||||
assert_eq!(bottom_grid.columns(), 6);
|
||||
assert_eq!(bottom_grid.total_rows(), 2);
|
||||
|
||||
// Action: Split the grid again.
|
||||
match bottom_grid.split(NonZeroUsize::new(1).expect("should not be zero")) {
|
||||
(bottom_grid, Some(bottom_grid_2)) => {
|
||||
let bottom_grid = bottom_grid.grid_storage();
|
||||
let bottom_grid_2 = bottom_grid_2.grid_storage();
|
||||
|
||||
// Postconditions: Verify split results for second split.
|
||||
assert_eq!(bottom_grid.total_rows(), 1);
|
||||
assert_eq!(bottom_grid.raw.len(), 1);
|
||||
|
||||
assert_eq!(bottom_grid_2.total_rows(), 1);
|
||||
assert_eq!(bottom_grid_2.raw.len(), 1);
|
||||
|
||||
// Verify character content for grid2a and grid2b
|
||||
assert_eq!(bottom_grid[0][0].c, 'c');
|
||||
|
||||
assert_eq!(bottom_grid_2[0][0].c, 'd');
|
||||
}
|
||||
_ => {
|
||||
panic!("Received None from split (split row exceeded Grid!)");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
panic!("Received None from split (split row exceeded Grid!)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_grid_cursor_at_split_point() {
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(4, 6, 4);
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
&['a', 'a', '\0', '\0', '\0', '\0'],
|
||||
&['b', 'b', '\0', '\0', '\0', '\0'],
|
||||
&['c', 'c', 'c', 'c', '\0', '\0'],
|
||||
&['d', 'd', 'd', 'd', 'd', '\0'],
|
||||
]);
|
||||
|
||||
grid.set_cursor_point(2, 3); // Cursor at 'c'
|
||||
grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
match grid.split(NonZeroUsize::new(2).expect("should not be zero")) {
|
||||
(grid, Some(bottom_grid)) => {
|
||||
let grid = grid.grid_storage();
|
||||
let bottom_grid = bottom_grid.grid_storage();
|
||||
|
||||
// Check cursor position in grid2 since the cursor is at the split point.
|
||||
assert_eq!(
|
||||
bottom_grid.cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(0),
|
||||
col: 3
|
||||
}
|
||||
);
|
||||
|
||||
// Check auxiliary data
|
||||
assert_eq!(grid.total_rows(), 2);
|
||||
assert_eq!(grid.raw.len(), 2);
|
||||
|
||||
assert_eq!(bottom_grid.total_rows(), 2);
|
||||
assert_eq!(bottom_grid.raw.len(), 2);
|
||||
|
||||
// Verify character content for grid2a and grid2b
|
||||
assert_eq!(grid[0][0].c, 'a');
|
||||
assert_eq!(grid[1][0].c, 'b');
|
||||
|
||||
assert_eq!(bottom_grid[0][0].c, 'c');
|
||||
assert_eq!(bottom_grid[1][0].c, 'd');
|
||||
}
|
||||
_ => {
|
||||
panic!("Received None from split (split row exceeded Grid!)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_grid_exceed_rows() {
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(4, 6, 4);
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
&['a', 'a', '\0', '\0', '\0', '\0'],
|
||||
&['b', 'b', '\0', '\0', '\0', '\0'],
|
||||
&['c', 'c', 'c', 'c', '\0', '\0'],
|
||||
&['d', 'd', 'd', 'd', 'd', '\0'],
|
||||
]);
|
||||
|
||||
// Attempt to split beyond len which is illegal.
|
||||
let (_top_grid, bottom_grid) = grid.split(NonZeroUsize::new(5).expect("should not be zero"));
|
||||
assert!(bottom_grid.is_none());
|
||||
}
|
||||
|
||||
/// Regression test for (CORE-1950), checks whether the grid splitting operation correctly
|
||||
/// splits visible rows (appropriately handling scrollback history).
|
||||
#[test]
|
||||
fn test_split_grid_scrollback_visible_rows() {
|
||||
// Setup: Create and initialize the grid.
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(30, 6, 7);
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
// Comments describe rows _after_ resizes below.
|
||||
&['a', '0', '\0', '\0', '\0', '\0'], // history
|
||||
&['a', '1', '\0', '\0', '\0', '\0'], // history
|
||||
&['a', '2', '\0', '\0', '\0', '\0'], // history
|
||||
&['a', '3', '\0', '\0', '\0', '\0'], // visible row 0
|
||||
&['a', '4', '\0', '\0', '\0', '\0'], // visible row 1 <---- split above this row
|
||||
&['a', '5', '\0', '\0', '\0', '\0'], // visible row 2
|
||||
&['a', '6', '\0', '\0', '\0', '\0'], // visible row 3
|
||||
]);
|
||||
grid.set_cursor_point(6, 2);
|
||||
grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// Force rows to go into scrollback with consecutive resize operations.
|
||||
grid.resize(SizeInfo::new_without_font_metrics(18, 4));
|
||||
// We force 4 visible rows (the bottom rows from grid above).
|
||||
grid.resize(SizeInfo::new_without_font_metrics(4, 8));
|
||||
|
||||
// Confirm the grid is in the expected state after resizing.
|
||||
assert_eq!(grid.visible_rows(), 4);
|
||||
assert_eq!(grid.total_rows(), 7);
|
||||
assert_eq!(grid.history_size(), 3);
|
||||
|
||||
// Split the grid at the row containing "a4", so "a4\na5\na6" goes into the
|
||||
// bottom grid and the rest remains in the top grid.
|
||||
match grid.split(NonZeroUsize::new(4).expect("should not be zero")) {
|
||||
(grid, Some(bottom_grid)) => {
|
||||
// Check that visible rows were split correctly.
|
||||
assert_eq!(grid.total_rows(), 4);
|
||||
assert_eq!(grid.visible_rows(), 4);
|
||||
assert_eq!(grid.history_size(), 0);
|
||||
|
||||
assert_eq!(bottom_grid.total_rows(), 3);
|
||||
assert_eq!(bottom_grid.visible_rows(), 3);
|
||||
assert_eq!(bottom_grid.history_size(), 0);
|
||||
|
||||
// Verify character content for both grids.
|
||||
assert_cell_char_eq!(grid[0][1], '0');
|
||||
assert_cell_char_eq!(grid[1][1], '1');
|
||||
assert_cell_char_eq!(grid[2][1], '2');
|
||||
assert_cell_char_eq!(grid[3][1], '3');
|
||||
|
||||
assert_cell_char_eq!(bottom_grid[0][1], '4');
|
||||
assert_cell_char_eq!(bottom_grid[1][1], '5');
|
||||
assert_cell_char_eq!(bottom_grid[2][1], '6');
|
||||
|
||||
// Verify cursor positions.
|
||||
assert_eq!(
|
||||
grid.grid_storage().cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(3),
|
||||
col: 7
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
bottom_grid.grid_storage().cursor.point,
|
||||
VisiblePoint {
|
||||
row: VisibleRow(2),
|
||||
col: 2
|
||||
}
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
panic!("Received None from split (split row exceeded Grid!)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for checking whether row indices which are out of bounds of the visible rows
|
||||
/// (but in bounds for total rows) are handled correctly i.e. splits are not allowed.
|
||||
#[test]
|
||||
fn test_split_grid_scrollback_visible_rows_out_of_bounds() {
|
||||
// Setup: Create and initialize the grid.
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(30, 6, 7);
|
||||
grid.grid_storage_mut().populate_from_array(&[
|
||||
// Comments describe rows _after_ resizes below.
|
||||
&['a', '0', '\0', '\0', '\0', '\0'], // history
|
||||
&['a', '1', '\0', '\0', '\0', '\0'], // history
|
||||
&['a', '2', '\0', '\0', '\0', '\0'], // history
|
||||
&['a', '3', '\0', '\0', '\0', '\0'], // visible row 0
|
||||
&['a', '4', '\0', '\0', '\0', '\0'], // visible row 1 <---- split above this row
|
||||
&['a', '5', '\0', '\0', '\0', '\0'], // visible row 2
|
||||
&['a', '6', '\0', '\0', '\0', '\0'], // visible row 3
|
||||
]);
|
||||
grid.set_cursor_point(6, 2);
|
||||
grid.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// Force rows to go into scrollback with consecutive resize operations.
|
||||
grid.resize(SizeInfo::new_without_font_metrics(18, 4));
|
||||
// We force 4 visible rows (the bottom rows from grid above).
|
||||
grid.resize(SizeInfo::new_without_font_metrics(4, 8));
|
||||
|
||||
// Confirm the grid is in the expected state after resizing.
|
||||
assert_eq!(grid.visible_rows(), 4);
|
||||
assert_eq!(grid.total_rows(), 7);
|
||||
assert_eq!(grid.history_size(), 3);
|
||||
|
||||
// Purposely split at a row which is out of bounds, to confirm behavior.
|
||||
let (_top_grid, bottom_grid) = grid.split(NonZeroUsize::new(7).expect("should not be zero"));
|
||||
assert!(bottom_grid.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrolling_up_updates_dirty_cells_range() {
|
||||
let mut grid = GridHandler::new_for_test_with_scroll_limit(3, 4, 2);
|
||||
grid.input_at_cursor("abcd");
|
||||
|
||||
let pre_scroll_cursor_point = grid.cursor_point();
|
||||
|
||||
assert!(
|
||||
grid.dirty_cells_range().is_none(),
|
||||
"input_at_cursor should clear dirty_cells_range"
|
||||
);
|
||||
assert_eq!(pre_scroll_cursor_point, Point { row: 1, col: 0 });
|
||||
|
||||
grid.scroll_up(1);
|
||||
|
||||
// Increasing the scroll limit by 1 will update the cursor position.
|
||||
assert_eq!(grid.cursor_point(), Point { row: 2, col: 0 });
|
||||
|
||||
// The dirty cells range should have been updated to include everything
|
||||
// up to the new cursor point.
|
||||
let dirty_cells_range = grid
|
||||
.dirty_cells_range()
|
||||
.expect("dirty_cells_range should be non-empty");
|
||||
assert_eq!(dirty_cells_range.start(), &pre_scroll_cursor_point);
|
||||
assert_eq!(
|
||||
grid.cursor_point(),
|
||||
dirty_cells_range.end().wrapping_add(grid.columns(), 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_cursor_cell() {
|
||||
let mut grid = GridStorage::new(3, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
grid.cursor.point = VisiblePoint {
|
||||
row: VisibleRow(2),
|
||||
col: 0,
|
||||
};
|
||||
|
||||
let cell = grid.cursor_cell();
|
||||
assert_eq!(cell.c, 'c');
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_cursor_cell_out_of_bounds() {
|
||||
let mut grid = GridStorage::new(3, 1, 0, ObfuscateSecrets::No);
|
||||
grid[0][0].c = 'a';
|
||||
grid[1][0].c = 'b';
|
||||
grid[2][0].c = 'c';
|
||||
let total_rows = grid.total_rows();
|
||||
let total_cols = grid.columns();
|
||||
|
||||
// Row is out of bounds.
|
||||
grid.cursor.point = VisiblePoint {
|
||||
row: VisibleRow(total_rows + 1),
|
||||
col: 0,
|
||||
};
|
||||
|
||||
let cell = grid.cursor_cell();
|
||||
assert_eq!(cell.c, 'c');
|
||||
|
||||
// Column is out of bounds.
|
||||
grid.cursor.point = VisiblePoint {
|
||||
row: VisibleRow(0),
|
||||
col: total_cols + 1,
|
||||
};
|
||||
|
||||
let cell = grid.cursor_cell();
|
||||
assert_eq!(cell.c, 'c');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::terminal::model::image_map::ImagePlacementData;
|
||||
|
||||
use super::{AbsolutePoint, AbsoluteRectangle, GridHandler};
|
||||
use warp_terminal::model::Point;
|
||||
|
||||
impl GridHandler {
|
||||
pub fn get_image_ids_in_range(
|
||||
&self,
|
||||
displayed_start_row: usize,
|
||||
displayed_end_row: usize,
|
||||
) -> Vec<ImagePlacement> {
|
||||
if self.has_displayed_output() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
if displayed_start_row > displayed_end_row {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
self.images
|
||||
.get_image_ids_by_rectangle(AbsoluteRectangle::from_range(
|
||||
displayed_start_row,
|
||||
displayed_end_row,
|
||||
self,
|
||||
))
|
||||
.into_iter()
|
||||
.filter_map(|absolute_image_placement| {
|
||||
absolute_image_placement
|
||||
.top_left
|
||||
.to_point(self)
|
||||
.map(|top_left| ImagePlacement {
|
||||
image_id: absolute_image_placement.image_id,
|
||||
placement_id: absolute_image_placement.placement_id,
|
||||
z_index: absolute_image_placement.z_index,
|
||||
top_left,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(in crate::terminal::model) fn has_image_in_row(&self, displayed_row: usize) -> bool {
|
||||
if self.has_displayed_output() {
|
||||
return false;
|
||||
}
|
||||
let absolute_row = AbsolutePoint::from_point(Point::new(displayed_row, 0), self).row;
|
||||
self.images.has_image_in_row(absolute_row)
|
||||
}
|
||||
|
||||
pub fn get_image_placement_data(
|
||||
&self,
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
) -> Option<&ImagePlacementData> {
|
||||
self.images.get_image_placement_data(image_id, placement_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImagePlacement {
|
||||
pub image_id: u32,
|
||||
pub placement_id: u32,
|
||||
pub z_index: i32,
|
||||
pub top_left: Point,
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use std::ops::{Index, IndexMut, Range, RangeFrom, RangeFull, RangeTo};
|
||||
|
||||
use warp_terminal::model::grid::cell::Cell;
|
||||
use warp_terminal::model::grid::row::Row;
|
||||
|
||||
use crate::terminal::model::{
|
||||
grid::Dimensions as _,
|
||||
index::{Point, VisiblePoint, VisibleRow},
|
||||
};
|
||||
|
||||
use super::{grid_handler::GridHandler, GridStorage};
|
||||
|
||||
pub(in crate::terminal::model) trait ConvertToAbsolute {
|
||||
type Output;
|
||||
|
||||
/// Transforms a value from the VisibleScreen coordinate space to the grid
|
||||
/// coordinate space.
|
||||
fn convert_to_absolute(&self, grid: &GridHandler) -> Self::Output;
|
||||
}
|
||||
|
||||
impl ConvertToAbsolute for VisibleRow {
|
||||
type Output = usize;
|
||||
|
||||
fn convert_to_absolute(&self, grid: &GridHandler) -> Self::Output {
|
||||
grid.history_size() + self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ConvertToAbsolute for VisiblePoint {
|
||||
type Output = Point;
|
||||
|
||||
fn convert_to_absolute(&self, grid: &GridHandler) -> Self::Output {
|
||||
Point::new(self.row.convert_to_absolute(grid), self.col)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index with buffer offset.
|
||||
impl Index<usize> for GridStorage {
|
||||
type Output = Row;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &Row {
|
||||
&self.raw[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for GridStorage {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: usize) -> &mut Row {
|
||||
&mut self.raw[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<&Point> for GridStorage {
|
||||
type Output = Cell;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, point: &Point) -> &Cell {
|
||||
&self[point.row][point.col]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<&Point> for GridStorage {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, point: &Point) -> &mut Cell {
|
||||
&mut self[point.row][point.col]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<Point> for GridStorage {
|
||||
type Output = Cell;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, point: Point) -> &Cell {
|
||||
&self[point.row][point.col]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<Point> for GridStorage {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, point: Point) -> &mut Cell {
|
||||
&mut self[point.row][point.col]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<VisibleRow> for GridStorage {
|
||||
type Output = Row;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: VisibleRow) -> &Row {
|
||||
&self.raw[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<VisibleRow> for GridStorage {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: VisibleRow) -> &mut Row {
|
||||
&mut self.raw[index]
|
||||
}
|
||||
}
|
||||
|
||||
/// A subset of lines in the grid.
|
||||
///
|
||||
/// May be constructed using Grid::region(..).
|
||||
pub struct Region<'a> {
|
||||
start: VisibleRow,
|
||||
end: VisibleRow,
|
||||
grid: &'a GridStorage,
|
||||
}
|
||||
|
||||
/// A mutable subset of lines in the grid.
|
||||
///
|
||||
/// May be constructed using Grid::region_mut(..).
|
||||
pub struct RegionMut<'a> {
|
||||
start: VisibleRow,
|
||||
end: VisibleRow,
|
||||
grid: &'a mut GridStorage,
|
||||
}
|
||||
|
||||
impl RegionMut<'_> {
|
||||
/// Call the provided function for every item in this region.
|
||||
pub fn each<F: Fn(&mut Cell)>(self, func: F) {
|
||||
for row in self {
|
||||
for item in row {
|
||||
func(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IndexRegion<I> {
|
||||
/// Get an immutable region of Self.
|
||||
fn region(&self, _: I) -> Region<'_>;
|
||||
|
||||
/// Get a mutable region of Self.
|
||||
fn region_mut(&mut self, _: I) -> RegionMut<'_>;
|
||||
}
|
||||
|
||||
impl IndexRegion<Range<VisibleRow>> for GridStorage {
|
||||
fn region(&self, index: Range<VisibleRow>) -> Region<'_> {
|
||||
assert!(index.start < VisibleRow(self.visible_rows()));
|
||||
assert!(index.end <= VisibleRow(self.visible_rows()));
|
||||
assert!(index.start <= index.end);
|
||||
Region {
|
||||
start: index.start,
|
||||
end: index.end,
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
|
||||
fn region_mut(&mut self, index: Range<VisibleRow>) -> RegionMut<'_> {
|
||||
assert!(index.start < VisibleRow(self.visible_rows()));
|
||||
assert!(index.end <= VisibleRow(self.visible_rows()));
|
||||
assert!(index.start <= index.end);
|
||||
RegionMut {
|
||||
start: index.start,
|
||||
end: index.end,
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexRegion<RangeTo<VisibleRow>> for GridStorage {
|
||||
fn region(&self, index: RangeTo<VisibleRow>) -> Region<'_> {
|
||||
assert!(index.end <= VisibleRow(self.visible_rows()));
|
||||
Region {
|
||||
start: VisibleRow(0),
|
||||
end: index.end,
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
|
||||
fn region_mut(&mut self, index: RangeTo<VisibleRow>) -> RegionMut<'_> {
|
||||
assert!(index.end <= VisibleRow(self.visible_rows()));
|
||||
RegionMut {
|
||||
start: VisibleRow(0),
|
||||
end: index.end,
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexRegion<RangeFrom<VisibleRow>> for GridStorage {
|
||||
fn region(&self, index: RangeFrom<VisibleRow>) -> Region<'_> {
|
||||
assert!(index.start < VisibleRow(self.visible_rows()));
|
||||
Region {
|
||||
start: index.start,
|
||||
end: VisibleRow(self.visible_rows()),
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
|
||||
fn region_mut(&mut self, index: RangeFrom<VisibleRow>) -> RegionMut<'_> {
|
||||
assert!(index.start < VisibleRow(self.visible_rows()));
|
||||
RegionMut {
|
||||
start: index.start,
|
||||
end: VisibleRow(self.visible_rows()),
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexRegion<RangeFull> for GridStorage {
|
||||
fn region(&self, _: RangeFull) -> Region<'_> {
|
||||
Region {
|
||||
start: VisibleRow(0),
|
||||
end: VisibleRow(self.visible_rows()),
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
|
||||
fn region_mut(&mut self, _: RangeFull) -> RegionMut<'_> {
|
||||
RegionMut {
|
||||
start: VisibleRow(0),
|
||||
end: VisibleRow(self.visible_rows()),
|
||||
grid: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RegionIter<'a> {
|
||||
end: VisibleRow,
|
||||
cur: VisibleRow,
|
||||
grid: &'a GridStorage,
|
||||
}
|
||||
|
||||
pub struct RegionIterMut<'a> {
|
||||
end: VisibleRow,
|
||||
cur: VisibleRow,
|
||||
grid: &'a mut GridStorage,
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for Region<'a> {
|
||||
type IntoIter = RegionIter<'a>;
|
||||
type Item = &'a Row;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
RegionIter {
|
||||
end: self.end,
|
||||
cur: self.start,
|
||||
grid: self.grid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for RegionMut<'a> {
|
||||
type IntoIter = RegionIterMut<'a>;
|
||||
type Item = &'a mut Row;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
RegionIterMut {
|
||||
end: self.end,
|
||||
cur: self.start,
|
||||
grid: self.grid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for RegionIter<'a> {
|
||||
type Item = &'a Row;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.cur < self.end {
|
||||
let index = self.cur;
|
||||
self.cur += 1;
|
||||
Some(&self.grid[index])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for RegionIterMut<'a> {
|
||||
type Item = &'a mut Row;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.cur < self.end {
|
||||
let index = self.cur;
|
||||
self.cur += 1;
|
||||
unsafe { Some(&mut *(&mut self.grid[index] as *mut _)) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
mod displayed_output;
|
||||
pub mod grid_handler;
|
||||
mod grid_storage;
|
||||
mod indexing;
|
||||
mod selection_cursor;
|
||||
mod storage;
|
||||
|
||||
pub(super) mod grapheme_cursor;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use warp_terminal::model::grid::row;
|
||||
|
||||
pub use displayed_output::RespectDisplayedOutput;
|
||||
pub use grid_storage::*;
|
||||
pub(super) use indexing::ConvertToAbsolute;
|
||||
pub use indexing::IndexRegion;
|
||||
pub use selection_cursor::SelectionCursor;
|
||||
|
||||
enum CursorDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
enum CursorState {
|
||||
Valid,
|
||||
Exhausted(CursorDirection),
|
||||
Invalid,
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use string_offset::ByteOffset;
|
||||
use warp_terminal::model::{
|
||||
grid::{
|
||||
cell::{self, LineLength as _},
|
||||
Dimensions as _,
|
||||
},
|
||||
Point, VisiblePoint, VisibleRow,
|
||||
};
|
||||
|
||||
use crate::terminal::{model::grid::Cursor, SizeInfo};
|
||||
|
||||
use super::GridHandler;
|
||||
|
||||
impl GridHandler {
|
||||
/// Resize terminal to new dimensions.
|
||||
pub fn resize(&mut self, size: SizeInfo) {
|
||||
self.ansi_handler_state.cell_width = size.cell_width_px.as_f32() as usize;
|
||||
self.ansi_handler_state.cell_height = size.cell_height_px.as_f32() as usize;
|
||||
|
||||
let old_cols = self.columns();
|
||||
let old_rows = self.visible_rows();
|
||||
|
||||
let num_cols = size.columns();
|
||||
let num_rows = size.rows();
|
||||
|
||||
if old_cols == num_cols && old_rows == num_rows {
|
||||
log::debug!("Term::resize dimensions unchanged");
|
||||
return;
|
||||
}
|
||||
|
||||
if num_rows == 0 {
|
||||
log::debug!("Ignoring resize down to zero visible lines");
|
||||
return;
|
||||
}
|
||||
|
||||
log::debug!("New num_cols is {num_cols} and num_lines is {num_rows}");
|
||||
|
||||
if old_cols != num_cols {
|
||||
// Recreate tabs list.
|
||||
self.ansi_handler_state.tabs.resize(num_cols);
|
||||
}
|
||||
|
||||
// Resize the internal storage structures.
|
||||
self.resize_storage(num_rows, num_cols);
|
||||
|
||||
// Reset scrolling region.
|
||||
self.ansi_handler_state.scroll_region = VisibleRow(0)..VisibleRow(self.visible_rows());
|
||||
|
||||
// If the current grid has secrets, we now need to rescan the grid to refind any secrets.
|
||||
if !self.secrets.is_empty() {
|
||||
self.scan_for_secrets_after_resize();
|
||||
}
|
||||
|
||||
// Re-apply the grid filter, if one exists.
|
||||
self.refilter_lines();
|
||||
}
|
||||
|
||||
pub(super) fn resize_storage(&mut self, num_rows: usize, num_cols: usize) {
|
||||
use std::cmp::min;
|
||||
|
||||
// If this is the alt screen, we can skip reflowing the grid and simply
|
||||
// adjust the size of rows.
|
||||
if self.ansi_handler_state.is_alt_screen {
|
||||
// We should never finish the alt screen grid.
|
||||
debug_assert!(!self.finished);
|
||||
// We can delegate to the old grid resizing logic, as there's no
|
||||
// flat storage for the alt screen.
|
||||
self.grid.resize(false, num_rows, num_cols, self.finished);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store information about the initial cursor position in the grid.
|
||||
let cursor = InitialCursorState::new(
|
||||
self.grid.cursor.point,
|
||||
self.grid.cursor.input_needs_wrap,
|
||||
self,
|
||||
);
|
||||
let saved_cursor = InitialCursorState::new(
|
||||
self.grid.saved_cursor.point,
|
||||
self.grid.saved_cursor.input_needs_wrap,
|
||||
self,
|
||||
);
|
||||
let max_cursor = InitialCursorState::new(self.grid.max_cursor_point, false, self);
|
||||
|
||||
// Push all rows from grid storage into flat storage. We make sure not
|
||||
// to truncate rows that exceed the maximum scrollback size, as we only
|
||||
// want to apply that limit after we've pulled rows back out into the
|
||||
// grid.
|
||||
for row_idx in 0..self.grid.total_rows() {
|
||||
self.flat_storage
|
||||
.push_rows_without_truncation([&self.grid[VisibleRow(row_idx)]]);
|
||||
}
|
||||
|
||||
// Now that all data is in flat storage, convert the cursor state to
|
||||
// reference a flat storage content offset.
|
||||
let cursor = cursor.into_content_offset(self);
|
||||
let saved_cursor = saved_cursor.into_content_offset(self);
|
||||
let max_cursor = max_cursor.into_content_offset(self);
|
||||
|
||||
// Resize flat storage.
|
||||
self.flat_storage.set_columns(num_cols);
|
||||
|
||||
// If the grid is finished, don't let the number of visible rows exceed
|
||||
// the number of total rows (i.e.: if we can't pop a full num_rows
|
||||
// from flat storage, limit visible_rows to the number of rows we
|
||||
// _could_ pop).
|
||||
let visible_rows = if self.finished {
|
||||
num_rows.min(self.flat_storage.total_rows())
|
||||
} else {
|
||||
num_rows
|
||||
};
|
||||
|
||||
// Convert back from a content offset to an actual cursor position.
|
||||
let cursor = cursor.into_cursor_point(num_cols, self);
|
||||
let saved_cursor = saved_cursor.into_cursor_point(num_cols, self);
|
||||
let max_cursor = max_cursor.into_cursor_point(num_cols, self);
|
||||
|
||||
// If we're reducing the number of visible rows, we want to first drop
|
||||
// rows after the cursor before we start pushing rows into scrollback.
|
||||
//
|
||||
// It's easiest to think about this in the context of a traditional
|
||||
// terminal. If the window has a height of 10 rows but only 5 rows of
|
||||
// content, those 5 rows will be at the top of the window. If the
|
||||
// window is resized to be 8 rows tall, the bottom two rows of the grid
|
||||
// will be truncated.
|
||||
//
|
||||
// Here, we eliminate those final rows by not pushing them back into
|
||||
// grid storage after pulling them out of flat storage.
|
||||
let rows_after_cursor = self
|
||||
.flat_storage
|
||||
.total_rows()
|
||||
.saturating_sub(cursor.row() + 1);
|
||||
let shrink_amount = self.visible_rows().saturating_sub(num_rows);
|
||||
let rows_to_drop = shrink_amount.min(rows_after_cursor);
|
||||
let rows_to_pop = visible_rows + rows_to_drop;
|
||||
|
||||
// Pop the rows from the bottom of flat storage, and drop some of them
|
||||
// if necessary.
|
||||
//
|
||||
// Note: This may produce a Vec with len < num_rows.
|
||||
let mut grid_rows = self.flat_storage.pop_rows(rows_to_pop);
|
||||
grid_rows.truncate(grid_rows.len().saturating_sub(rows_to_drop));
|
||||
|
||||
// Set `GridStorage` contents to the given number of rows.
|
||||
self.grid.set_stored_rows(grid_rows, visible_rows, num_cols);
|
||||
|
||||
// Set the new cursor positions.
|
||||
let history_size = self.history_size();
|
||||
cursor.update_cursor(&mut self.grid.cursor, history_size);
|
||||
saved_cursor.update_cursor(&mut self.grid.saved_cursor, history_size);
|
||||
self.grid.max_cursor_point = max_cursor.into_visible_point(self);
|
||||
|
||||
// Clamp cursors to the new visible region.
|
||||
//
|
||||
// TODO(vorporeal): This can lead to a `max_cursor_point` that has
|
||||
// content after it. We should decide if this is something important
|
||||
// to fix or not. (The behavior is inherited from grid storage resize
|
||||
// logic.)
|
||||
let last_row = VisibleRow(visible_rows - 1);
|
||||
self.grid.cursor.point.row = min(self.grid.cursor.point.row, last_row);
|
||||
self.grid.max_cursor_point.row = min(self.grid.max_cursor_point.row, last_row);
|
||||
self.grid.saved_cursor.point.row = min(self.grid.saved_cursor.point.row, last_row);
|
||||
|
||||
// Finally, make sure we don't have too many rows in scrollback.
|
||||
self.flat_storage.apply_max_rows();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum InitialCursorState {
|
||||
/// Cursor is at some point in the grid.
|
||||
AtPoint(Point),
|
||||
/// Cursor is at the cell after the given point in the grid.
|
||||
///
|
||||
/// We set this in two situations:
|
||||
/// 1. When the cursor has `input_needs_wrap = True`, and
|
||||
/// 2. When the cursor is over an empty cell, we describe it as being after
|
||||
/// the preceding cell. This allows us to set `input_needs_wrap`
|
||||
/// properly when doing the final conversion back to a cursor.
|
||||
AtCellAfterPoint(Point),
|
||||
}
|
||||
|
||||
impl InitialCursorState {
|
||||
fn new(mut cursor_point: VisiblePoint, input_needs_wrap: bool, grid: &mut GridHandler) -> Self {
|
||||
// Start by clamping the cursor to the visible region of the grid, just
|
||||
// in case some bug causes it to end up in an invalid place.
|
||||
if cursor_point.row.0 >= grid.visible_rows() {
|
||||
#[cfg(debug_assertions)]
|
||||
log::error!(
|
||||
"cursor should not be outside the bounds of the grid! \
|
||||
cursor at ({}, {}) but grid has {} rows and {} columns",
|
||||
cursor_point.row,
|
||||
cursor_point.col,
|
||||
grid.total_rows(),
|
||||
grid.columns()
|
||||
);
|
||||
cursor_point.row.0 = grid.visible_rows() - 1;
|
||||
}
|
||||
if cursor_point.col >= grid.columns() {
|
||||
#[cfg(debug_assertions)]
|
||||
log::error!(
|
||||
"cursor should not be outside the bounds of the grid! \
|
||||
cursor at ({}, {}) but grid has {} rows and {} columns",
|
||||
cursor_point.row,
|
||||
cursor_point.col,
|
||||
grid.total_rows(),
|
||||
grid.columns()
|
||||
);
|
||||
cursor_point.col = grid.columns() - 1;
|
||||
}
|
||||
|
||||
let history_size = grid.history_size();
|
||||
let mut point = Point {
|
||||
row: cursor_point.row.0 + history_size,
|
||||
col: cursor_point.col,
|
||||
};
|
||||
let mut cell_after_point = false;
|
||||
|
||||
let row = &grid.grid[cursor_point.row];
|
||||
|
||||
let cell_follows_newline = |point: Point| -> bool {
|
||||
// The cell cannot follow a newline unless it's the first cell in the row.
|
||||
if point.col > 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the cursor is at the first cell in the first row, treat it as if it follows a newline.
|
||||
let Some(prev_row_idx) = point.row.checked_sub(1) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
// The cell follows a newline if the previous row does not wrap.
|
||||
!grid.row_wraps(prev_row_idx)
|
||||
};
|
||||
|
||||
if input_needs_wrap {
|
||||
// If the input needs wrapping, the target cell is the one
|
||||
// after the current cursor point.
|
||||
cell_after_point = true;
|
||||
} else if row[point.col].c == cell::DEFAULT_CHAR
|
||||
&& point.col >= row.line_length()
|
||||
&& !cell_follows_newline(point)
|
||||
{
|
||||
// If the cursor is on an empty cell at the end of a row and could
|
||||
// wrap back to the previous cell, track it relative to the
|
||||
// previous cell. This allows us to set `Cursor.input_needs_wrap`
|
||||
// instead of putting the cursor at the start of an
|
||||
// otherwise-unneeded blank row.
|
||||
cell_after_point = true;
|
||||
point = point.wrapping_sub(grid.columns(), 1);
|
||||
}
|
||||
|
||||
// Mark the location of the cursor within the grid, to ensure that
|
||||
// the cell under the cursor exists post-resize.
|
||||
if let Some(super::StorageRow::GridStorage(row_idx)) = grid.storage_row(point.row) {
|
||||
grid.grid[row_idx][point.col]
|
||||
.flags
|
||||
.insert(cell::Flags::HAS_CURSOR);
|
||||
}
|
||||
|
||||
if cell_after_point {
|
||||
Self::AtCellAfterPoint(point)
|
||||
} else {
|
||||
Self::AtPoint(point)
|
||||
}
|
||||
}
|
||||
|
||||
fn into_content_offset(self, grid: &GridHandler) -> CursorContentOffset {
|
||||
match self {
|
||||
Self::AtPoint(point) => {
|
||||
let content_offset = grid
|
||||
.flat_storage
|
||||
.content_offset_at_point(point)
|
||||
.expect("should have a content offset for point");
|
||||
CursorContentOffset::AtPoint(content_offset)
|
||||
}
|
||||
Self::AtCellAfterPoint(point) => {
|
||||
let content_offset = grid
|
||||
.flat_storage
|
||||
.content_offset_at_point(point)
|
||||
.expect("should have a content offset for point");
|
||||
CursorContentOffset::AtCellAfterPoint(content_offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CursorContentOffset {
|
||||
/// Cursor is at the location with the given byte offset in flat storage.
|
||||
AtPoint(ByteOffset),
|
||||
/// Cursor is at cell _after_ the location with the given byte offset in
|
||||
/// flat storage. This helps ensure we properly handle `input_needs_wrap`
|
||||
/// cases.
|
||||
AtCellAfterPoint(ByteOffset),
|
||||
}
|
||||
|
||||
impl CursorContentOffset {
|
||||
fn into_cursor_point(self, new_cols: usize, grid: &GridHandler) -> FinalCursorState {
|
||||
match self {
|
||||
Self::AtPoint(byte_offset) => FinalCursorState::AtPoint(
|
||||
grid.flat_storage
|
||||
.content_offset_to_point(byte_offset)
|
||||
.expect("content offset should be valid"),
|
||||
),
|
||||
Self::AtCellAfterPoint(byte_offset) => {
|
||||
let mut point = grid
|
||||
.flat_storage
|
||||
.content_offset_to_point(byte_offset)
|
||||
.expect("content offset should be valid");
|
||||
// All data is in flat storage at the moment, so we need to
|
||||
// explicitly ask it about row wrapping.
|
||||
let input_needs_wrap =
|
||||
point.col == new_cols - 1 && !grid.flat_storage.row_wraps(point.row);
|
||||
if !input_needs_wrap {
|
||||
point = point.wrapping_add(new_cols, 1);
|
||||
}
|
||||
FinalCursorState::AtCellAfterPoint {
|
||||
point,
|
||||
input_needs_wrap,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FinalCursorState {
|
||||
AtPoint(Point),
|
||||
AtCellAfterPoint {
|
||||
point: Point,
|
||||
input_needs_wrap: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FinalCursorState {
|
||||
fn update_cursor(self, cursor: &mut Cursor, history_size: usize) {
|
||||
let (point, input_needs_wrap) = match self {
|
||||
FinalCursorState::AtPoint(point) => (point, false),
|
||||
FinalCursorState::AtCellAfterPoint {
|
||||
point,
|
||||
input_needs_wrap,
|
||||
} => (point, input_needs_wrap),
|
||||
};
|
||||
|
||||
cursor.point = point.to_visible_point(history_size);
|
||||
cursor.input_needs_wrap = input_needs_wrap;
|
||||
}
|
||||
|
||||
fn into_visible_point(self, grid: &GridHandler) -> VisiblePoint {
|
||||
let (Self::AtPoint(point) | Self::AtCellAfterPoint { point, .. }) = self;
|
||||
point.to_visible_point(grid.history_size())
|
||||
}
|
||||
|
||||
fn row(&self) -> usize {
|
||||
let (Self::AtPoint(point) | Self::AtCellAfterPoint { point, .. }) = self;
|
||||
point.row
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use std::{collections::HashSet, ops::RangeInclusive};
|
||||
|
||||
use itertools::Itertools as _;
|
||||
|
||||
use crate::ai::blocklist::block::secret_redaction::find_secrets_in_text_with_levels;
|
||||
use crate::terminal::model::grid::{grapheme_cursor, Dimensions as _};
|
||||
use crate::terminal::model::terminal_model::RangeInModel;
|
||||
use crate::terminal::model::{
|
||||
grid::RespectDisplayedOutput,
|
||||
index::{Direction, Point},
|
||||
secrets::{
|
||||
IsObfuscated, ObfuscateSecrets, Secret, SecretAndHandle, SecretHandle, SecretLevel,
|
||||
SECRETS_DFA,
|
||||
},
|
||||
};
|
||||
|
||||
use super::GridHandler;
|
||||
|
||||
impl GridHandler {
|
||||
pub fn num_secrets_obfuscated(&self) -> usize {
|
||||
self.secrets.len()
|
||||
}
|
||||
|
||||
pub fn get_secret_obfuscation(&self) -> ObfuscateSecrets {
|
||||
self.secret_obfuscation_mode
|
||||
}
|
||||
|
||||
/// Returns a tuple of [`Secret`] and [`SecretHandle`] at the given point (assuming the point is
|
||||
/// the displayed location and needs to be translated to the original location) or `None` if none is identified.
|
||||
pub fn secret_at_displayed_point(&self, displayed_point: Point) -> Option<SecretAndHandle<'_>> {
|
||||
self.secrets
|
||||
.get_by_point(displayed_point, self, RespectDisplayedOutput::Yes)
|
||||
}
|
||||
|
||||
/// Returns a tuple of [`Secret`] and [`SecretHandle`] at the given point (assuming
|
||||
/// the point is the original location in the grid) or `None` if none is identified.
|
||||
pub fn secret_at_original_point(&self, original_point: Point) -> Option<SecretAndHandle<'_>> {
|
||||
self.secrets
|
||||
.get_by_point(original_point, self, RespectDisplayedOutput::No)
|
||||
}
|
||||
|
||||
/// Returns a [`Secret`] identified by [`SecretHandle`] or `None` if none is identified.
|
||||
pub fn secret_by_handle(&self, secret_handle: SecretHandle) -> Option<&Secret> {
|
||||
self.secrets.get_by_handle(&secret_handle)
|
||||
}
|
||||
|
||||
/// Finds all secrets with matching plaintext and updates whether or not they are obfuscated.
|
||||
fn mark_matching_secrets(&mut self, secret_handle: &SecretHandle, is_obfuscated: IsObfuscated) {
|
||||
let Some(secret) = self.secrets.get_by_handle(secret_handle) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let secret_plaintext = self.generate_secret_plaintext(secret.range());
|
||||
let Some(matching_secret_handles) =
|
||||
self.secrets_in_plaintext.get(secret_plaintext.as_str())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for secret_handle in matching_secret_handles.iter() {
|
||||
if let Err(e) = self.secrets.set_is_obfuscated(secret_handle, is_obfuscated) {
|
||||
log::warn!("Unable to obfuscate secret: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::terminal::model) fn obfuscate_secrets(
|
||||
&mut self,
|
||||
obfuscate_secrets: ObfuscateSecrets,
|
||||
) {
|
||||
self.secret_obfuscation_mode = obfuscate_secrets;
|
||||
}
|
||||
|
||||
/// Marks the secret identified by [`SecretHandle`] as obfuscated. Returns an `Err` if no secret
|
||||
/// is identified by [`SecretHandle`].
|
||||
pub fn obfuscate_secret(&mut self, secret_handle: SecretHandle) -> anyhow::Result<()> {
|
||||
self.secrets
|
||||
.set_is_obfuscated(&secret_handle, IsObfuscated::Yes)
|
||||
}
|
||||
|
||||
/// Marks the secret identified by [`SecretHandle`] as unobfuscated. Returns an `Err` if no
|
||||
/// secret is identified by [`SecretHandle`].
|
||||
pub fn unobfuscate_secret(&mut self, secret_handle: SecretHandle) -> anyhow::Result<()> {
|
||||
self.secrets
|
||||
.set_is_obfuscated(&secret_handle, IsObfuscated::No)?;
|
||||
|
||||
self.mark_matching_secrets(&secret_handle, IsObfuscated::No);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks the range of points identified by `range` as a secret within the grid.
|
||||
pub(in crate::terminal::model::grid) fn mark_secret_range(
|
||||
&mut self,
|
||||
range: RangeInclusive<Point>,
|
||||
is_obfuscated: IsObfuscated,
|
||||
plaintext: String,
|
||||
secret_level: SecretLevel,
|
||||
) {
|
||||
let handle = SecretHandle::next();
|
||||
|
||||
let range = *range.start()..=*range.end();
|
||||
let secret = Secret::new(is_obfuscated, range.clone(), secret_level);
|
||||
self.secrets.insert(handle, secret, self.columns());
|
||||
|
||||
self.secrets_in_plaintext
|
||||
.entry(plaintext)
|
||||
.or_default()
|
||||
.insert(handle);
|
||||
}
|
||||
|
||||
/// Clears any secrets currently stored in the grid.
|
||||
pub(in crate::terminal::model) fn clear_secrets(&mut self) {
|
||||
self.secrets.clear();
|
||||
self.secrets_in_plaintext.clear();
|
||||
self.set_all_bytes_scanned_for_secrets(true);
|
||||
}
|
||||
|
||||
/// Clears the secrets that are encompassed in the given range. Returns a range of the minimum
|
||||
/// and maximum point of all of the secrets that were removed.
|
||||
fn clear_secrets_in_range(
|
||||
&mut self,
|
||||
range: RangeInclusive<Point>,
|
||||
) -> Option<RangeInclusive<Point>> {
|
||||
// Determine the set of secrets that need to be removed and eagerly collect them into a vec.
|
||||
// We can't mutate the cells or the secrets map directly within the iterator due to lifetime
|
||||
// issues.
|
||||
let secrets_to_remove = self
|
||||
.secrets
|
||||
.iter()
|
||||
.filter_map(|(secret_handle, secret)| {
|
||||
let secret_range = secret.range();
|
||||
// If the secret intersects with the dirty range at all, clear it.
|
||||
let ranges_intersect =
|
||||
range.start() <= secret_range.end() && range.end() >= secret_range.start();
|
||||
ranges_intersect.then_some((*secret_handle, secret_range))
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
let (_, secret_range) = secrets_to_remove.first()?;
|
||||
let mut start_point = *secret_range.start();
|
||||
let mut end_point = *secret_range.end();
|
||||
|
||||
for (secret_handle, secret_range) in secrets_to_remove {
|
||||
start_point = start_point.min(*secret_range.start());
|
||||
end_point = end_point.max(*secret_range.end());
|
||||
|
||||
self.secrets.remove(secret_handle, self.columns());
|
||||
}
|
||||
|
||||
Some(start_point..=end_point)
|
||||
}
|
||||
|
||||
/// Scans the entire grid (not just the dirty cells range) for secrets.
|
||||
pub fn scan_full_grid_for_secrets(&mut self) {
|
||||
let start_point = Point::new(0, 0);
|
||||
let end_point = Point::new(self.total_rows(), self.columns());
|
||||
|
||||
self.scan_range_for_secrets(start_point..=end_point);
|
||||
self.set_all_bytes_scanned_for_secrets(true);
|
||||
}
|
||||
|
||||
/// Scans the grid for any secrets in the cells that are currently marked as "dirty".
|
||||
/// To scan for secrets, we take the beginning and end of the dirty cell range and expand this
|
||||
/// range to its word boundaries. We then invalidate any prior secret that is contained within
|
||||
/// this range, and then scan for secrets using regular expressions.
|
||||
/// Returns the number of secret matches found.
|
||||
fn scan_dirty_cells_for_secrets(&mut self) {
|
||||
let Some((dirty_range_start, dirty_range_end)) =
|
||||
self.dirty_cells_range().map(RangeInclusive::into_inner)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Expand both the start and end points to word boundaries. The range of dirty cells is not
|
||||
// guaranteed to be at a word boundary, in which case we would incorrectly omit secrets that
|
||||
// start before the range or end after the range.
|
||||
let start_point = self
|
||||
.nonblank_word_bound_before_point(dirty_range_start)
|
||||
.unwrap_or(dirty_range_start);
|
||||
|
||||
let end_point = self
|
||||
.nonblank_word_bound_after_point(dirty_range_end)
|
||||
.unwrap_or(dirty_range_end);
|
||||
|
||||
self.scan_range_for_secrets(start_point..=end_point);
|
||||
}
|
||||
|
||||
/// Rescans the entire grid for secrets after a resize.
|
||||
pub(super) fn scan_for_secrets_after_resize(&mut self) {
|
||||
// Clear the entire secret range map since the underlying ranges stored within the map may
|
||||
// be out of date after the resize.
|
||||
self.secrets.clear_ranges_after_resize();
|
||||
self.scan_range_for_secrets(
|
||||
Point::new(0, 0)..=Point::new(self.total_rows() - 1, self.columns() - 1),
|
||||
);
|
||||
}
|
||||
|
||||
/// Scans the given `range` for secrets. Any prior secrets within the range are removed from the
|
||||
/// grid. Any match is visually redacted in the grid.
|
||||
fn scan_range_for_secrets(&mut self, range: RangeInclusive<Point>) {
|
||||
let old_unobfuscated_secrets = &self
|
||||
.secrets
|
||||
.iter()
|
||||
.filter_map(|(_, secret)| {
|
||||
if !secret.is_obfuscated() {
|
||||
Some(self.generate_secret_plaintext(secret.range()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<HashSet<String>>();
|
||||
|
||||
// Clear any secrets that may are encompassed by the range that we are now searching --
|
||||
// they may no longer be valid.
|
||||
let cleared_secrets_range = self.clear_secrets_in_range(range.clone());
|
||||
|
||||
let mut start_point = *range.start();
|
||||
let mut end_point = *range.end();
|
||||
|
||||
// Adjust the start and end points of where we are scanning if we cleared any secrets that
|
||||
// started _before_ the range we are searching or ended _after_ the range we are searching.
|
||||
// If we don't expand the boundaries of where we are searching, we may end up incorrectly
|
||||
// removing a secret without refinding it.
|
||||
if let Some(cleared_secrets_range) = cleared_secrets_range {
|
||||
start_point = start_point.min(*cleared_secrets_range.start());
|
||||
end_point = end_point.max(*cleared_secrets_range.end());
|
||||
}
|
||||
|
||||
let matches = self
|
||||
.regex_iter(
|
||||
start_point,
|
||||
end_point,
|
||||
Direction::Right,
|
||||
&SECRETS_DFA.read(),
|
||||
)
|
||||
.collect_vec();
|
||||
|
||||
for secret_match in matches {
|
||||
// We mark a secret as unobfuscated if there is a new secret in the dirty cells range
|
||||
// that has the same plaintext as a previous secret that was unobfuscated.
|
||||
let plaintext = self.generate_secret_plaintext(secret_match.clone());
|
||||
let old_secret_found = old_unobfuscated_secrets.contains(plaintext.as_str());
|
||||
let is_obfuscated = if old_secret_found {
|
||||
IsObfuscated::No
|
||||
} else {
|
||||
IsObfuscated::Yes
|
||||
};
|
||||
|
||||
// Determine the secret level by re-scanning the plaintext
|
||||
let secret_level = self.determine_secret_level(&plaintext);
|
||||
|
||||
self.mark_secret_range(secret_match, is_obfuscated, plaintext, secret_level);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_secret_plaintext(&self, range: RangeInclusive<Point>) -> String {
|
||||
let mut text = String::new();
|
||||
|
||||
let mut cursor = self.grapheme_cursor_from(*range.start(), grapheme_cursor::Wrap::All);
|
||||
|
||||
while let Some(item) = cursor.current_item() {
|
||||
if !((item.point().row < range.end().row)
|
||||
|| (item.point().row <= range.end().row && item.point().col <= range.end().col))
|
||||
{
|
||||
break;
|
||||
}
|
||||
text.push(item.cell().c);
|
||||
cursor.move_forward();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Determines the secret level by re-scanning the plaintext using the rich content detection
|
||||
/// which includes secret level information
|
||||
fn determine_secret_level(&self, plaintext: &str) -> SecretLevel {
|
||||
let secrets_with_levels = find_secrets_in_text_with_levels(plaintext);
|
||||
|
||||
// Find the first match that corresponds to our plaintext
|
||||
// In case of multiple matches, we return the highest priority level
|
||||
secrets_with_levels
|
||||
.into_iter()
|
||||
.map(|(_, level)| level)
|
||||
.max_by_key(|level| level.priority())
|
||||
.unwrap_or(SecretLevel::User) // Default to User level if no matches found
|
||||
}
|
||||
|
||||
fn set_all_bytes_scanned_for_secrets(&mut self, value: bool) {
|
||||
self.all_bytes_scanned_for_secrets = value;
|
||||
}
|
||||
|
||||
pub(in crate::terminal::model) fn all_bytes_scanned_for_secrets(&self) -> bool {
|
||||
self.all_bytes_scanned_for_secrets
|
||||
}
|
||||
|
||||
pub(super) fn maybe_scan_dirty_cells_for_secrets(&mut self) {
|
||||
if self.secret_obfuscation_mode.should_redact_secret() {
|
||||
self.scan_dirty_cells_for_secrets();
|
||||
self.all_bytes_scanned_for_secrets &= true;
|
||||
} else {
|
||||
self.all_bytes_scanned_for_secrets = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "secrets_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,326 @@
|
||||
use regex::Regex;
|
||||
|
||||
use crate::terminal::{
|
||||
event_listener::ChannelEventListener,
|
||||
model::{
|
||||
ansi::{self, Handler as _},
|
||||
blockgrid::BlockGrid,
|
||||
},
|
||||
SizeInfo,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn secret_ranges(grid_handler: &GridHandler) -> Vec<RangeInclusive<Point>> {
|
||||
grid_handler
|
||||
.secrets
|
||||
.ranges()
|
||||
.map(|(range, _)| range)
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
fn empty_blockgrid(
|
||||
rows: usize,
|
||||
columns: usize,
|
||||
max_scroll_limit: usize,
|
||||
secret_obfuscation_mode: ObfuscateSecrets,
|
||||
) -> BlockGrid {
|
||||
BlockGrid::new(
|
||||
SizeInfo::new_without_font_metrics(rows, columns),
|
||||
max_scroll_limit,
|
||||
ChannelEventListener::new_for_test(),
|
||||
secret_obfuscation_mode,
|
||||
Default::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_redacted_after_byte_processing() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("ABCD").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(5, 10, 1, ObfuscateSecrets::Yes);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
// Nothing has been inserted into the grid, there should be no secrets.
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
|
||||
grid_handler.input_at_cursor("ABCD");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
|
||||
// Delete one character (by moving the cursor back and replacing the cell's content with a
|
||||
// space).
|
||||
grid_handler.update_cursor(|cursor| cursor.point = cursor.point.wrapping_sub(10, 1));
|
||||
grid_handler.grid_storage_mut().cursor_cell().c = ' ';
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should no longer be any secrets ("ABC" does not match the regex).
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
assert!(secret_ranges(grid_handler).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_redacted_after_multiple_byte_processing() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("ABCD").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(5, 10, 1, ObfuscateSecrets::Yes);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
// Nothing has been inserted into the grid, there should be no secrets.
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
|
||||
grid_handler.input_at_cursor("AB");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should no longer be any secrets ("AB" does not match the regex).
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
assert!(secret_ranges(grid_handler).is_empty());
|
||||
|
||||
// Insert "C" into the grid.
|
||||
grid_handler.input_at_cursor("C");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There shouldn't be any secrets ("ABC" does not match the regex).
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
assert!(secret_ranges(grid_handler).is_empty());
|
||||
|
||||
// Insert "D" into the grid.
|
||||
grid_handler.input_at_cursor("D");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_redaction_unobfuscated_secret_remains_after_byte_processing() -> anyhow::Result<()> {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("ABCD").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(5, 10, 1, ObfuscateSecrets::Yes);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
// Nothing has been inserted into the grid, there should be no secrets.
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
|
||||
grid_handler.input_at_cursor("ABCD");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
|
||||
let secret_handle = grid_handler
|
||||
.secrets
|
||||
.iter()
|
||||
.map(|(handle, _)| *handle)
|
||||
.next()
|
||||
.expect("Should be at least one secret in the grid");
|
||||
grid_handler.unobfuscate_secret(secret_handle)?;
|
||||
|
||||
for _ in 0..2 {
|
||||
grid_handler.update_cursor(|cursor| cursor.point = cursor.point.wrapping_sub(10, 1));
|
||||
grid_handler.grid_storage_mut().cursor_cell().c = ' ';
|
||||
}
|
||||
|
||||
grid_handler.input_at_cursor("CD");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret still.
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
|
||||
let secret = grid_handler
|
||||
.secrets
|
||||
.iter()
|
||||
.map(|(_, secret)| secret.clone())
|
||||
.next()
|
||||
.expect("Should be at least one secret in the grid");
|
||||
|
||||
assert!(!secret.is_obfuscated());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_redaction_secret_remains_after_resize() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("ABCD").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(2, 5, 2, ObfuscateSecrets::Yes);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
// Nothing has been inserted into the grid, there should be no secrets.
|
||||
assert!(grid_handler.secrets.is_empty());
|
||||
|
||||
grid_handler.input_at_cursor("ABCDE");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
|
||||
// Resize the grid, to only include 2 columns. This will cause the the last two cells in the
|
||||
// secret to wrap to the next line.
|
||||
let size_info = SizeInfo::new_without_font_metrics(2, 2);
|
||||
grid_handler.resize(size_info);
|
||||
assert_eq!(grid_handler.total_rows(), 4);
|
||||
|
||||
// The range should now be from (0,0)..=(1,1)
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(1, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_processed_for_secrets_after_turning_redaction() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("abcd").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(2, 4, 2, ObfuscateSecrets::Yes);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
assert!(grid_handler.all_bytes_scanned_for_secrets());
|
||||
|
||||
// Turn off secret obfuscation.
|
||||
grid_handler.obfuscate_secrets(ObfuscateSecrets::No);
|
||||
|
||||
// There should still only be one secret and all bytes should not be processed.
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_processed_for_secrets_after_turning_redaction_on() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("abcd").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(2, 4, 2, ObfuscateSecrets::No);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should no secrets.
|
||||
assert_eq!(grid_handler.secrets.len(), 0);
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
|
||||
// Turn secret obfuscation on.
|
||||
grid_handler.obfuscate_secrets(ObfuscateSecrets::Yes);
|
||||
|
||||
// Insert another secret and assert that all bytes are not processed.
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_processed_for_secrets_after_turning_redaction_off_then_on() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("abcd").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(3, 4, 2, ObfuscateSecrets::Yes);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert_eq!(
|
||||
secret_ranges(grid_handler),
|
||||
vec![Point::new(0, 0)..=Point::new(0, 3)]
|
||||
);
|
||||
assert!(grid_handler.all_bytes_scanned_for_secrets());
|
||||
|
||||
// Turn secret obfuscation off.
|
||||
grid_handler.obfuscate_secrets(ObfuscateSecrets::No);
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert_eq!(grid_handler.secrets.len(), 1);
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
|
||||
// Turn secret obfuscation back on, and ensure that all bytes are still not processed.
|
||||
grid_handler.obfuscate_secrets(ObfuscateSecrets::Yes);
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_processed_for_secrets_after_turning_redaction_on_then_off() {
|
||||
crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes(
|
||||
[&Regex::new("abcd").expect("Should be able to construct regex")],
|
||||
std::iter::empty(), // No enterprise secrets
|
||||
);
|
||||
|
||||
let mut blockgrid = empty_blockgrid(3, 4, 2, ObfuscateSecrets::No);
|
||||
let grid_handler = blockgrid.grid_handler_mut();
|
||||
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
|
||||
// There should be one secret in the range (0,0)..(0,3).
|
||||
assert_eq!(grid_handler.secrets.len(), 0);
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
|
||||
// Turn secret obfuscation on.
|
||||
grid_handler.obfuscate_secrets(ObfuscateSecrets::Yes);
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
|
||||
// Turn secret obfuscation back off, and ensure that all bytes are still not processed.
|
||||
grid_handler.obfuscate_secrets(ObfuscateSecrets::No);
|
||||
grid_handler.input_at_cursor("abcd");
|
||||
grid_handler.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
|
||||
assert!(!grid_handler.all_bytes_scanned_for_secrets());
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use warp_terminal::model::grid::CellType;
|
||||
|
||||
use crate::terminal::model::index::Point;
|
||||
|
||||
use super::{grid_handler::GridHandler, CursorDirection, CursorState, Dimensions as _};
|
||||
|
||||
/// A structure to help with movement of the cursor for keyboard-driven
|
||||
/// text selection.
|
||||
pub struct SelectionCursor<'g> {
|
||||
/// Reference to the underlying grid.
|
||||
grid: &'g GridHandler,
|
||||
|
||||
/// Current position of the cursor within the grid.
|
||||
pos: Point,
|
||||
|
||||
/// The state of the cursor.
|
||||
cursor_state: CursorState,
|
||||
}
|
||||
|
||||
impl<'g> SelectionCursor<'g> {
|
||||
pub fn new(grid: &'g GridHandler, pos: Point) -> Self {
|
||||
let mut cursor = Self {
|
||||
grid,
|
||||
pos,
|
||||
cursor_state: CursorState::Invalid,
|
||||
};
|
||||
if cursor.current_point_valid() {
|
||||
cursor.cursor_state = CursorState::Valid;
|
||||
}
|
||||
cursor
|
||||
}
|
||||
|
||||
/// Returns the cursor's current position, if it is valid.
|
||||
pub fn position(&self) -> Option<Point> {
|
||||
matches!(self.cursor_state, CursorState::Valid).then_some(self.pos)
|
||||
}
|
||||
|
||||
/// Moves the cursor forward by a single grapheme.
|
||||
pub fn move_forward(&mut self) {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid if self.has_next() => {
|
||||
self.increment_cursor();
|
||||
// If the cursor is on top of a wide char, move it forward an
|
||||
// extra cell.
|
||||
if self.is_wide_char_or_spacer() {
|
||||
self.increment_cursor();
|
||||
}
|
||||
}
|
||||
CursorState::Valid => {
|
||||
self.cursor_state = CursorState::Exhausted(CursorDirection::Right);
|
||||
}
|
||||
CursorState::Exhausted(CursorDirection::Left) => {
|
||||
self.cursor_state = CursorState::Valid;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor backward by a single grapheme.
|
||||
pub fn move_backward(&mut self) {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid if self.has_prev() => {
|
||||
self.decrement_cursor();
|
||||
// If the cursor is on top of a wide char, move it backward an
|
||||
// extra cell.
|
||||
if self.is_wide_char_or_spacer() {
|
||||
self.decrement_cursor();
|
||||
}
|
||||
}
|
||||
CursorState::Valid => {
|
||||
self.cursor_state = CursorState::Exhausted(CursorDirection::Left);
|
||||
}
|
||||
CursorState::Exhausted(CursorDirection::Right) => {
|
||||
self.cursor_state = CursorState::Valid;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor up a row.
|
||||
///
|
||||
/// Unlike the horizontal movement functions, this is not grapheme-aware -
|
||||
/// the cursor may end up on top of a wide char spacer cell.
|
||||
pub fn move_up(&mut self) {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid if self.pos.row > 0 => {
|
||||
self.pos.row -= 1;
|
||||
}
|
||||
CursorState::Valid => {
|
||||
self.cursor_state = CursorState::Exhausted(CursorDirection::Up);
|
||||
}
|
||||
CursorState::Exhausted(CursorDirection::Down) => {
|
||||
self.cursor_state = CursorState::Valid;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor down a row.
|
||||
///
|
||||
/// Unlike the horizontal movement functions, this is not grapheme-aware -
|
||||
/// the cursor may end up on top of a wide char spacer cell.
|
||||
pub fn move_down(&mut self) {
|
||||
match self.cursor_state {
|
||||
CursorState::Valid if self.pos.row < self.grid.total_rows() - 1 => {
|
||||
self.pos.row += 1;
|
||||
}
|
||||
CursorState::Valid => {
|
||||
self.cursor_state = CursorState::Exhausted(CursorDirection::Down);
|
||||
}
|
||||
CursorState::Exhausted(CursorDirection::Up) => {
|
||||
self.cursor_state = CursorState::Valid;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_cursor(&mut self) {
|
||||
if self.pos.col == self.grid.columns() - 1 {
|
||||
self.pos.row += 1;
|
||||
self.pos.col = 0;
|
||||
} else {
|
||||
self.pos.col += 1
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_cursor(&mut self) {
|
||||
if self.pos.col == 0 {
|
||||
self.pos.row -= 1;
|
||||
self.pos.col = self.grid.columns() - 1;
|
||||
} else {
|
||||
self.pos.col -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the current cursor point is on top of a wide char
|
||||
/// (either the first or second cell).
|
||||
fn is_wide_char_or_spacer(&self) -> bool {
|
||||
matches!(
|
||||
self.grid.cell_type(self.pos),
|
||||
Some(CellType::WideChar) | Some(CellType::WideCharSpacer)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether the current cursor point is valid (i.e.: is within the
|
||||
/// bounds of the grid).
|
||||
fn current_point_valid(&self) -> bool {
|
||||
self.pos.row < self.grid.total_rows() && self.pos.col < self.grid.columns()
|
||||
}
|
||||
|
||||
/// Returns whether the cursor would be valid if it were incremented.
|
||||
fn has_next(&self) -> bool {
|
||||
(self.pos.row != self.grid.total_rows() - 1 || self.pos.col != self.grid.columns() - 1)
|
||||
&& self.current_point_valid()
|
||||
}
|
||||
|
||||
/// Returns whether the cursor would be valid if it were decremented.
|
||||
fn has_prev(&self) -> bool {
|
||||
(self.pos.row != 0 || self.pos.col != 0) && self.current_point_valid()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selection_cursor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cursor() {
|
||||
let grid = GridHandler::new_for_test(5, 5);
|
||||
|
||||
let mut cursor = SelectionCursor::new(&grid, Point::new(0, 0));
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 0)));
|
||||
|
||||
// Test moving the cursor up above the top of the grid and then back down.
|
||||
cursor.move_up();
|
||||
assert_eq!(cursor.position(), None);
|
||||
cursor.move_down();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 0)));
|
||||
|
||||
// Test moving the cursor backward from the first cell, then forward again.
|
||||
cursor.move_backward();
|
||||
assert_eq!(cursor.position(), None);
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 0)));
|
||||
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 1)));
|
||||
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 2)));
|
||||
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 3)));
|
||||
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 4)));
|
||||
|
||||
// Test line-wrapping both forward and backward across a line boundary.
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(1, 0)));
|
||||
cursor.move_backward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(0, 4)));
|
||||
|
||||
cursor = SelectionCursor::new(&grid, Point::new(4, 4));
|
||||
assert_eq!(cursor.position(), Some(Point::new(4, 4)));
|
||||
|
||||
// Test moving the cursor down from the bottom of the grid, then back up.
|
||||
cursor.move_down();
|
||||
assert_eq!(cursor.position(), None);
|
||||
cursor.move_up();
|
||||
assert_eq!(cursor.position(), Some(Point::new(4, 4)));
|
||||
|
||||
// Test moving the cursor forward from the end of the grid, then backward again.
|
||||
cursor.move_forward();
|
||||
assert_eq!(cursor.position(), None);
|
||||
cursor.move_backward();
|
||||
assert_eq!(cursor.position(), Some(Point::new(4, 4)));
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::{max, PartialEq};
|
||||
use std::mem;
|
||||
use std::ops::{Index, IndexMut};
|
||||
|
||||
use crate::terminal::model::grid::row::Row;
|
||||
use crate::terminal::model::index::VisibleRow;
|
||||
|
||||
/// A circular buffer for optimizing indexing and rotation. In other words, a performant implementation of
|
||||
/// a grid API.
|
||||
///
|
||||
/// The data is stored in a vector of rows where top_row tracks the very first row of the terminal (think, top row
|
||||
/// of the visible grid) and bottom_row is the last visible grid row. The direction of the data can either be
|
||||
/// forwards or backwards, depending on the feature flag SequentialStorage.
|
||||
///
|
||||
/// Rezeroing is the process of restoring the topmost line back to raw storage index 0. This allows us then
|
||||
/// to extend or reduce the capacity of the underlying vector. The field "len" tracks the number of active
|
||||
/// rows in the grid, which is different from the currently allocated capacity of the vector (self.inner.len()).
|
||||
/// A more detailed explanation: when the grid grows in length, it does so in chunks of MAX_CACHE_SIZE - that way,
|
||||
/// we don't extend the vector every newline (which constitutes a scroll_up event) but rather every 1000 rows. Because of
|
||||
/// this, the value storage.len (number of active rows) will often be less than the value storage.inner.len()
|
||||
/// (the actual allocated capacity).
|
||||
///
|
||||
/// The [`Storage::rotate`] and [`Storage::rotate_down`] functions are fast modular additions on
|
||||
/// the internal [`bottom_row`] field. As compared with [`slice::rotate_left`] which must rearrange items
|
||||
/// in memory.
|
||||
///
|
||||
/// As a consequence, both [`Index`] and [`IndexMut`] are reimplemented for this type to account
|
||||
/// for the zeroth element not always being at the start of the allocation.
|
||||
///
|
||||
/// Because certain [`Vec`] operations are no longer valid on this type, no [`Deref`]
|
||||
/// implementation is provided. Anything from [`Vec`] that should be exposed must be done so
|
||||
/// manually.
|
||||
///
|
||||
/// [`slice::rotate_left`]: https://doc.rust-lang.org/std/primitive.slice.html#method.rotate_left
|
||||
/// [`Deref`]: std::ops::Deref
|
||||
/// [`bottom_row`]: #structfield.bottom_row
|
||||
///
|
||||
/// --------------------
|
||||
///
|
||||
/// Imagine we want to print Shakespeare plays in alphabetical order. Let's assume sequential storage.
|
||||
///
|
||||
/// 1) The first write takes place at index 0.
|
||||
/// ┌────────────────────────────────┐
|
||||
/// 0: │All's Well That Ends Well │
|
||||
/// 1: │ │
|
||||
/// 2: │ │
|
||||
/// 3: │ │
|
||||
/// 4: │ │
|
||||
/// 5: │ │
|
||||
/// 6: │ │
|
||||
/// 7: │ │
|
||||
/// 8: │ │
|
||||
/// 9: │ │ <-- bottom_row (this is the bottom row because the grid
|
||||
/// └────────────────────────────────┘ was initialized with 10 active rows)
|
||||
/// 2) The grid fills up at 10 rows.
|
||||
/// ┌────────────────────────────────┐
|
||||
/// 0: │All's Well That Ends Well (1602)│
|
||||
/// 1: │Antony and Cleopatra (1606) │
|
||||
/// 2: │As You Like It (1599) │
|
||||
/// 3: │Comedy of Errors (1589) │
|
||||
/// 4: │Coriolanus (1607) │
|
||||
/// 5: │Cymbeline (1609) │
|
||||
/// 6: │Hamlet (1600) │
|
||||
/// 7: │Henry IV, Part I (1597) │
|
||||
/// 8: │Henry IV, Part II (1597) │
|
||||
/// 9: │Henry V (1598) │ <-- bottom_row
|
||||
/// └────────────────────────────────┘
|
||||
///
|
||||
/// 3) Adding 3 more rows means the oldest 3 rows are overwritten.
|
||||
/// ┌────────────────────────────────┐
|
||||
/// 0: │Henry VI, Part I (1591) |
|
||||
/// 1: │Henry VI, Part II (1590) │
|
||||
/// 2: │Henry VI, Part III (1590) │ <-- bottom_row
|
||||
/// 3: │Comedy of Errors (1589) │ <-- self.top_row()
|
||||
/// 4: │Coriolanus (1607) │
|
||||
/// 5: │Cymbeline (1609) │
|
||||
/// 6: │Hamlet (1600) │
|
||||
/// 7: │Henry IV, Part I (1597) │
|
||||
/// 8: │Henry IV, Part II (1597) │
|
||||
/// 9: │Henry V (1598) │
|
||||
/// └────────────────────────────────┘
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub(super) struct Storage {
|
||||
inner: Vec<Row>,
|
||||
|
||||
/// The bottommost row of the visible grid. This is the furthest the grid extends and the maximum grid index.
|
||||
/// In an unrotated buffer, bottom_row = len - 1.
|
||||
bottom_row: usize,
|
||||
|
||||
/// Number of visible lines. This is important because it's the initial grid size.
|
||||
visible_lines: usize,
|
||||
|
||||
/// Total number of lines currently active in the terminal
|
||||
///
|
||||
/// Shrinking this length allows reducing the number of lines in the scrollback buffer without
|
||||
/// having to truncate the raw `inner` buffer.
|
||||
/// As long as `len` is bigger than `inner`, it is also possible to grow the scrollback buffer
|
||||
/// without any additional insertions.
|
||||
len: usize,
|
||||
|
||||
/// Whether or not the storage mechanism is reversed or sequential.
|
||||
#[serde(skip)]
|
||||
is_sequential: bool,
|
||||
|
||||
/// Maximum number of buffered lines outside of the grid for performance optimization.
|
||||
/// Every time we extend the size of the grid, we do so in chunks of this size.
|
||||
#[serde(skip)]
|
||||
max_cache_size: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for Storage {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Both storage buffers need to be truncated and zeroed.
|
||||
assert_eq!(self.bottom_row, 0);
|
||||
assert_eq!(other.bottom_row, 0);
|
||||
|
||||
self.inner == other.inner && self.len == other.len
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
#[inline]
|
||||
pub fn with_capacity(visible_lines: usize, cols: usize, is_sequential: bool) -> Storage {
|
||||
// Initialize visible lines; the scrollback buffer is initialized dynamically.
|
||||
let mut inner = Vec::with_capacity(visible_lines);
|
||||
inner.resize_with(visible_lines, || Row::new(cols));
|
||||
|
||||
Self::with_rows(inner, is_sequential, visible_lines)
|
||||
}
|
||||
|
||||
/// Initialize Storage with given vector of rows.
|
||||
#[inline]
|
||||
pub fn with_rows(rows: Vec<Row>, is_sequential: bool, visible_lines: usize) -> Storage {
|
||||
let len = rows.len();
|
||||
debug_assert!(
|
||||
visible_lines <= len,
|
||||
"Size of the visible grid cannot be bigger than the actual size of the grid."
|
||||
);
|
||||
let inner = rows;
|
||||
|
||||
// We set this to 1 when using flat storage because there is no scrollback buffer
|
||||
// that we'll need to grow.
|
||||
let max_cache_size = 1;
|
||||
|
||||
Storage {
|
||||
inner,
|
||||
bottom_row: 0,
|
||||
visible_lines,
|
||||
len,
|
||||
is_sequential,
|
||||
max_cache_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_sequential(&self) -> bool {
|
||||
self.is_sequential
|
||||
}
|
||||
|
||||
/// Increase the number of lines in the buffer.
|
||||
#[inline]
|
||||
pub fn grow_visible_lines(&mut self, next: usize) {
|
||||
// Number of lines the buffer needs to grow.
|
||||
let growage = next - self.visible_lines;
|
||||
|
||||
let cols = self[0].len();
|
||||
self.initialize(growage, cols);
|
||||
|
||||
// Update visible lines.
|
||||
self.visible_lines = next;
|
||||
}
|
||||
|
||||
/// Decrease the number of lines in the buffer.
|
||||
#[inline]
|
||||
pub fn shrink_visible_lines(&mut self, next: usize) {
|
||||
// Shrink the size without removing any lines.
|
||||
let shrinkage = self.visible_lines - next;
|
||||
self.shrink_lines(shrinkage);
|
||||
|
||||
// Update visible lines.
|
||||
self.visible_lines = next;
|
||||
}
|
||||
|
||||
/// Shrink the number of lines in the buffer.
|
||||
#[inline]
|
||||
pub fn shrink_lines(&mut self, shrinkage: usize) {
|
||||
self.len -= shrinkage;
|
||||
|
||||
// Free memory.
|
||||
if self.inner.len() > self.len + self.max_cache_size {
|
||||
self.truncate_unused_rows();
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate the invisible elements from the raw buffer.
|
||||
#[inline]
|
||||
pub fn truncate_unused_rows(&mut self) {
|
||||
self.rezero();
|
||||
self.inner.truncate(self.len);
|
||||
self.inner.shrink_to_fit();
|
||||
}
|
||||
|
||||
/// Truncate columns in Storage to specified target # of columns.
|
||||
#[inline]
|
||||
pub fn truncate_columns(&mut self, col_to_truncate_to: usize) {
|
||||
for row in &mut self.inner {
|
||||
row.truncate(col_to_truncate_to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate the ring buffer so that it only contains the `target_len` number of rows. Rows are
|
||||
/// filled from bottom to top (bottom_row), so elements are deleted from the beginning of the buffer.
|
||||
#[inline]
|
||||
pub fn truncate_to(&mut self, target_len: usize) {
|
||||
if target_len < self.len() {
|
||||
self.rezero();
|
||||
self.inner.drain(0..self.len - target_len);
|
||||
self.len = target_len;
|
||||
self.visible_lines = self.visible_lines.min(target_len);
|
||||
}
|
||||
|
||||
// Now that we've shortened the vector, remove unused rows, if any.
|
||||
self.truncate_unused_rows();
|
||||
}
|
||||
|
||||
pub fn push_from_scrollback(&mut self, mut rows: Vec<Row>) {
|
||||
let num_rows = rows.len();
|
||||
|
||||
if !self.is_sequential {
|
||||
rows.reverse();
|
||||
}
|
||||
|
||||
// Append the rows to the internal storage.
|
||||
self.rezero();
|
||||
self.inner.splice(self.len.., rows);
|
||||
|
||||
// Increase the length to account for the additional rows we added.
|
||||
self.len += num_rows;
|
||||
}
|
||||
|
||||
/// Dynamically grow the storage buffer at runtime.
|
||||
#[inline]
|
||||
pub fn initialize(&mut self, additional_rows: usize, cols: usize) {
|
||||
if self.len + additional_rows > self.inner.len() {
|
||||
self.rezero();
|
||||
|
||||
let realloc_size = self.inner.len() + max(additional_rows, self.max_cache_size);
|
||||
self.inner.resize_with(realloc_size, || Row::new(cols));
|
||||
}
|
||||
|
||||
self.len += additional_rows;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Swap two visible lines. This function takes visible rows and converts them
|
||||
/// to grid indices and then raw storage indices.
|
||||
pub fn swap_lines(&mut self, a: VisibleRow, b: VisibleRow) {
|
||||
let a = self.from_grid_index(self.to_grid_index(a));
|
||||
let b = self.from_grid_index(self.to_grid_index(b));
|
||||
|
||||
self.inner.swap(a, b);
|
||||
}
|
||||
|
||||
/// Swap implementation for Row.
|
||||
///
|
||||
/// Exploits the known size of Row to produce a slightly more efficient
|
||||
/// swap than going through slice::swap.
|
||||
///
|
||||
/// The default implementation from swap generates 8 movups and 4 movaps
|
||||
/// instructions. This implementation achieves the swap in only 8 movups
|
||||
/// instructions.
|
||||
///
|
||||
/// This function accepts grid indices.
|
||||
pub fn swap(&mut self, a: usize, b: usize) {
|
||||
debug_assert_eq!(mem::size_of::<Row>(), mem::size_of::<usize>() * 4);
|
||||
|
||||
let a = self.from_grid_index(a);
|
||||
let b = self.from_grid_index(b);
|
||||
|
||||
unsafe {
|
||||
// Cast to a qword array to opt out of copy restrictions and avoid
|
||||
// drop hazards. Byte array is no good here since for whatever
|
||||
// reason LLVM won't optimized it.
|
||||
let a_ptr = self.inner.as_mut_ptr().add(a) as *mut usize;
|
||||
let b_ptr = self.inner.as_mut_ptr().add(b) as *mut usize;
|
||||
|
||||
// Copy 1 qword at a time.
|
||||
//
|
||||
// The optimizer unrolls this loop and vectorizes it.
|
||||
let mut tmp: usize;
|
||||
for i in 0..4 {
|
||||
tmp = *a_ptr.offset(i);
|
||||
*a_ptr.offset(i) = *b_ptr.offset(i);
|
||||
*b_ptr.offset(i) = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn rotate(&mut self, count: isize) {
|
||||
debug_assert!(count.unsigned_abs() <= self.inner.len());
|
||||
|
||||
let len = self.inner.len();
|
||||
self.bottom_row = (self.bottom_row as isize + count + len as isize) as usize % len;
|
||||
}
|
||||
|
||||
/// Move the bottommost row to indicate a lesser row value.
|
||||
#[inline]
|
||||
pub fn retract(&mut self, mut count: isize) {
|
||||
debug_assert!(count.unsigned_abs() <= self.inner.len());
|
||||
|
||||
if self.is_sequential {
|
||||
count *= -1;
|
||||
}
|
||||
|
||||
let len = self.inner.len();
|
||||
self.bottom_row = (self.bottom_row as isize + count + len as isize) as usize % len;
|
||||
}
|
||||
|
||||
/// Move the bottommost row to indicate a greater row value.
|
||||
#[inline]
|
||||
pub fn extend(&mut self, mut count: isize) {
|
||||
debug_assert!(count.unsigned_abs() <= self.inner.len());
|
||||
|
||||
if !self.is_sequential {
|
||||
count *= -1;
|
||||
}
|
||||
|
||||
let len = self.inner.len();
|
||||
self.bottom_row = (self.bottom_row as isize + count + len as isize) as usize % len;
|
||||
}
|
||||
|
||||
/// Update the raw storage buffer.
|
||||
#[inline]
|
||||
pub fn replace_inner(&mut self, vec: Vec<Row>) {
|
||||
self.len = vec.len();
|
||||
self.inner = vec;
|
||||
self.bottom_row = 0;
|
||||
}
|
||||
|
||||
/// Remove all rows from storage.
|
||||
#[inline]
|
||||
pub fn take_all(&mut self) -> Vec<Row> {
|
||||
self.truncate_unused_rows();
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
mem::swap(&mut buffer, &mut self.inner);
|
||||
self.len = 0;
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Rotate the ringbuffer to reset `self.bottom_row` back to index `0`.
|
||||
#[inline]
|
||||
fn rezero(&mut self) {
|
||||
if self.bottom_row == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.inner.rotate_left(self.bottom_row);
|
||||
self.bottom_row = 0;
|
||||
}
|
||||
|
||||
/// Convert from grid index to raw storage index
|
||||
#[inline]
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn from_grid_index(&self, mut requested: usize) -> usize {
|
||||
debug_assert!(requested < self.len);
|
||||
|
||||
// If our storage is not sequential... reverse the index
|
||||
if !self.is_sequential {
|
||||
requested = self.len - requested - 1;
|
||||
}
|
||||
|
||||
let zeroed = self.bottom_row + requested;
|
||||
|
||||
// Use if/else instead of remainder here to improve performance.
|
||||
//
|
||||
// Requires `zeroed` to be smaller than `self.inner.len() * 2`,
|
||||
// but both `self.bottom_row` and `requested` are always smaller than `self.inner.len()`.
|
||||
if zeroed >= self.inner.len() {
|
||||
zeroed - self.inner.len()
|
||||
} else {
|
||||
zeroed
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert from visible row to grid index
|
||||
#[inline]
|
||||
pub fn to_grid_index(&self, row: VisibleRow) -> usize {
|
||||
self.len - self.visible_lines + row.0
|
||||
}
|
||||
|
||||
pub fn estimated_heap_usage_bytes(&self) -> usize {
|
||||
// We expect all rows to have the same internal capacity, so we can
|
||||
// avoid summing up the heap usage from every row.
|
||||
self.inner.capacity() * self.inner[0].estimated_memory_usage_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for Storage {
|
||||
type Output = Row;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &Self::Output {
|
||||
&self.inner[self.from_grid_index(index)]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for Storage {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
|
||||
let index = self.from_grid_index(index); // borrowck
|
||||
&mut self.inner[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<VisibleRow> for Storage {
|
||||
type Output = Row;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, row: VisibleRow) -> &Self::Output {
|
||||
&self[self.to_grid_index(row)]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<VisibleRow> for Storage {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, row: VisibleRow) -> &mut Self::Output {
|
||||
let grid_index = self.to_grid_index(row);
|
||||
&mut self[grid_index]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "storage_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,881 @@
|
||||
use crate::terminal::model::cell::Cell;
|
||||
use crate::terminal::model::grid::row::Row;
|
||||
use crate::terminal::model::grid::storage::Storage;
|
||||
|
||||
// Use a large value for `MAX_CACHE_SIZE` for testing.
|
||||
const MAX_CACHE_SIZE: usize = 1_000;
|
||||
|
||||
#[test]
|
||||
fn with_capacity() {
|
||||
let storage = Storage::with_capacity(3, 1, false);
|
||||
|
||||
assert_eq!(storage.inner.len(), 3);
|
||||
assert_eq!(storage.len, 3);
|
||||
assert_eq!(storage.bottom_row, 0);
|
||||
assert_eq!(storage.visible_lines, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testing_grid_to_raw_storage_indexing() {
|
||||
// Macro testing converstion of grid indices to raw-storage indices
|
||||
macro_rules! assert_index_mapping {
|
||||
($storage:ident, $(($grid_idx:literal, $expected_storage_idx:literal)),+) => {
|
||||
$(
|
||||
let actual = $storage.from_grid_index($grid_idx);
|
||||
assert_eq!(actual, $expected_storage_idx, "Expected grid index {} to map to storage index {} but got {actual}", $grid_idx, $expected_storage_idx);
|
||||
)+
|
||||
}
|
||||
}
|
||||
|
||||
let mut storage = Storage::with_capacity(10, 1, false);
|
||||
assert_index_mapping!(
|
||||
storage,
|
||||
(0, 9),
|
||||
(1, 8),
|
||||
(2, 7),
|
||||
(3, 6),
|
||||
(4, 5),
|
||||
(5, 4),
|
||||
(6, 3),
|
||||
(7, 2),
|
||||
(8, 1),
|
||||
(9, 0)
|
||||
);
|
||||
|
||||
// Simulate a shift (going over grid limits)
|
||||
// Changing bottom_row to 8 should mean the oldest row (0) is at raw index 7
|
||||
storage.bottom_row = 8;
|
||||
assert_index_mapping!(
|
||||
storage,
|
||||
(0, 7),
|
||||
(1, 6),
|
||||
(2, 5),
|
||||
(3, 4),
|
||||
(4, 3),
|
||||
(5, 2),
|
||||
(6, 1),
|
||||
(7, 0),
|
||||
(8, 9),
|
||||
(9, 8)
|
||||
);
|
||||
|
||||
// Simulate a size increase.
|
||||
// This causes a rezero-ing and we're back to regular reversed order.
|
||||
storage.initialize(5, 1);
|
||||
assert_index_mapping!(
|
||||
storage,
|
||||
(0, 14),
|
||||
(1, 13),
|
||||
(2, 12),
|
||||
(3, 11),
|
||||
(4, 10),
|
||||
(5, 9),
|
||||
(6, 8),
|
||||
(7, 7),
|
||||
(8, 6),
|
||||
(9, 5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testing_visible_row_to_grid_indexing() {
|
||||
// visible rows are always the bottom (for grids)
|
||||
// Here, we have 10 visible rows and an overall grid of 15 rows.
|
||||
let mut storage = Storage::with_capacity(10, 1, false);
|
||||
storage.initialize(5, 1);
|
||||
assert_eq!(
|
||||
storage.to_grid_index(crate::terminal::model::index::VisibleRow(9)),
|
||||
14
|
||||
);
|
||||
assert_eq!(
|
||||
storage.to_grid_index(crate::terminal::model::index::VisibleRow(0)),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexing() {
|
||||
let mut storage = Storage::with_capacity(3, 1, false);
|
||||
|
||||
storage[0] = filled_row('0');
|
||||
storage[1] = filled_row('1');
|
||||
storage[2] = filled_row('2');
|
||||
|
||||
assert_eq!(storage[0], filled_row('0'));
|
||||
assert_eq!(storage[1], filled_row('1'));
|
||||
assert_eq!(storage[2], filled_row('2'));
|
||||
|
||||
storage.bottom_row = 1;
|
||||
|
||||
// now it's 2, 0, 1
|
||||
// indexing into storage takes a grid index and does its own conversion to a raw index
|
||||
assert_eq!(storage[0], filled_row('2'));
|
||||
assert_eq!(storage[1], filled_row('0'));
|
||||
assert_eq!(storage[2], filled_row('1'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn indexing_above_inner_len() {
|
||||
let storage = Storage::with_capacity(1, 1, false);
|
||||
let _ = &storage[2];
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate() {
|
||||
let mut storage = Storage::with_capacity(3, 1, false);
|
||||
storage.rotate(2);
|
||||
assert_eq!(storage.bottom_row, 2);
|
||||
storage.shrink_lines(2);
|
||||
assert_eq!(storage.len, 1);
|
||||
assert_eq!(storage.inner.len(), 1);
|
||||
assert_eq!(storage.bottom_row, 0);
|
||||
}
|
||||
|
||||
/// Grow the buffer one line at the end of the buffer.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
/// 2: -
|
||||
/// After:
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
/// 2: -
|
||||
/// 3: Cell::default
|
||||
/// ...
|
||||
/// MAX_CACHE_SIZE: 0
|
||||
#[test]
|
||||
fn grow_after_zero() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1'), filled_row('-')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 3,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Grow buffer.
|
||||
storage.grow_visible_lines(4);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let mut expected = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1'), filled_row('-')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 4,
|
||||
len: 4,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
expected
|
||||
.inner
|
||||
.append(&mut vec![filled_row('\0'); MAX_CACHE_SIZE]);
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Grow the buffer one line at the start of the buffer.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: -
|
||||
/// 1: 0 <- Zero
|
||||
/// 2: 1
|
||||
/// After:
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
/// 2: -
|
||||
/// 3: Cell::default
|
||||
/// ...
|
||||
/// MAX_CACHE_SIZE: 0
|
||||
#[test]
|
||||
fn grow_before_zero() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('-'), filled_row('0'), filled_row('1')],
|
||||
bottom_row: 1,
|
||||
visible_lines: 3,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Grow buffer.
|
||||
storage.grow_visible_lines(4);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let mut expected = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1'), filled_row('-')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 4,
|
||||
len: 4,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
expected
|
||||
.inner
|
||||
.append(&mut vec![filled_row('\0'); MAX_CACHE_SIZE]);
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Shrink the buffer one line at the start of the buffer.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 2
|
||||
/// 1: 0 <- Zero
|
||||
/// 2: 1
|
||||
/// After:
|
||||
/// 0: 2 <- Hidden
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
#[test]
|
||||
fn shrink_before_zero() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('2'), filled_row('0'), filled_row('1')],
|
||||
bottom_row: 1,
|
||||
visible_lines: 3,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Shrink buffer.
|
||||
storage.shrink_visible_lines(2);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('2'), filled_row('0'), filled_row('1')],
|
||||
bottom_row: 1,
|
||||
visible_lines: 2,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Shrink the buffer one line at the end of the buffer.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
/// 2: 2
|
||||
/// After:
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
/// 2: 2 <- Hidden
|
||||
#[test]
|
||||
fn shrink_after_zero() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1'), filled_row('2')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 3,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Shrink buffer.
|
||||
storage.shrink_visible_lines(2);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1'), filled_row('2')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 2,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Shrink the buffer at the start and end of the buffer.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 4
|
||||
/// 1: 5
|
||||
/// 2: 0 <- Zero
|
||||
/// 3: 1
|
||||
/// 4: 2
|
||||
/// 5: 3
|
||||
/// After:
|
||||
/// 0: 4 <- Hidden
|
||||
/// 1: 5 <- Hidden
|
||||
/// 2: 0 <- Zero
|
||||
/// 3: 1
|
||||
/// 4: 2 <- Hidden
|
||||
/// 5: 3 <- Hidden
|
||||
#[test]
|
||||
fn shrink_before_and_after_zero() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 6,
|
||||
len: 6,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Shrink buffer.
|
||||
storage.shrink_visible_lines(2);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 2,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Check that truncating columns works as expected.
|
||||
///
|
||||
/// Before (e indicates empty):
|
||||
/// 0: 1 1 1 e e e <- Zero
|
||||
/// 1: 2 2 e e e e
|
||||
/// 2: 3 3 3 3 e e
|
||||
/// 3: 4 e e e e e
|
||||
/// 4: 5 5 e e e e
|
||||
/// After:
|
||||
/// 0: 1 1 1 e <- Zero
|
||||
/// 1: 2 2 e e
|
||||
/// 2: 3 3 3 3
|
||||
/// 3: 4 e e e
|
||||
/// 4: 5 5 e e
|
||||
#[test]
|
||||
fn truncate_columns() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
partially_filled_row('1', 3, 6),
|
||||
partially_filled_row('2', 2, 6),
|
||||
partially_filled_row('3', 4, 6),
|
||||
partially_filled_row('4', 1, 6),
|
||||
partially_filled_row('5', 2, 6),
|
||||
],
|
||||
bottom_row: 0,
|
||||
visible_lines: 5,
|
||||
len: 5,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Truncate buffer (columns).
|
||||
storage.truncate_columns(4);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![
|
||||
partially_filled_row('1', 3, 4),
|
||||
partially_filled_row('2', 2, 4),
|
||||
partially_filled_row('3', 4, 4),
|
||||
partially_filled_row('4', 1, 4),
|
||||
partially_filled_row('5', 2, 4),
|
||||
],
|
||||
bottom_row: 0,
|
||||
visible_lines: 5,
|
||||
len: 5,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Check that truncating columns works as expected.
|
||||
///
|
||||
/// Before (e indicates empty):
|
||||
/// 0: 1 1 1 e e e <- Zero
|
||||
/// 1: 2 2 e e e e
|
||||
/// 2: 3 3 3 3 e e
|
||||
/// 3: 4 e e e e e
|
||||
/// 4: 5 5 5 5 5 e <- Hidden
|
||||
/// After:
|
||||
/// 0: 1 1 1 e <- Zero
|
||||
/// 1: 2 2 e e
|
||||
/// 2: 3 3 3 3
|
||||
/// 3: 4 e e e
|
||||
/// 4: 5 5 5 5 <- Hidden (truncated as well)
|
||||
#[test]
|
||||
fn truncate_columns_ignore_hidden_rows() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
partially_filled_row('1', 3, 6),
|
||||
partially_filled_row('2', 2, 6),
|
||||
partially_filled_row('3', 4, 6),
|
||||
partially_filled_row('4', 1, 6),
|
||||
partially_filled_row('5', 5, 6),
|
||||
],
|
||||
bottom_row: 0,
|
||||
visible_lines: 4,
|
||||
len: 4,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Truncate buffer (columns).
|
||||
storage.truncate_columns(4);
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![
|
||||
partially_filled_row('1', 3, 4),
|
||||
partially_filled_row('2', 2, 4),
|
||||
partially_filled_row('3', 4, 4),
|
||||
partially_filled_row('4', 1, 4),
|
||||
partially_filled_row('5', 4, 4),
|
||||
],
|
||||
bottom_row: 0,
|
||||
visible_lines: 4,
|
||||
len: 4,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Check that when truncating all hidden lines are removed from the raw buffer.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 4 <- Hidden
|
||||
/// 1: 5 <- Hidden
|
||||
/// 2: 0 <- Zero
|
||||
/// 3: 1
|
||||
/// 4: 2 <- Hidden
|
||||
/// 5: 3 <- Hidden
|
||||
/// After:
|
||||
/// 0: 0 <- Zero
|
||||
/// 1: 1
|
||||
#[test]
|
||||
fn truncate_invisible_lines() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 1,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Truncate buffer.
|
||||
storage.truncate_unused_rows();
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 1,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Truncate buffer only at the beginning.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 1
|
||||
/// 1: 2 <- Hidden
|
||||
/// 2: 0 <- Zero
|
||||
/// After:
|
||||
/// 0: 1
|
||||
/// 0: 0 <- Zero
|
||||
#[test]
|
||||
fn truncate_invisible_lines_beginning() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('1'), filled_row('2'), filled_row('0')],
|
||||
bottom_row: 2,
|
||||
visible_lines: 1,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Truncate buffer.
|
||||
storage.truncate_unused_rows();
|
||||
|
||||
// Make sure the result is correct.
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('0'), filled_row('1')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 1,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Before:
|
||||
/// 0: 1 <- Zero
|
||||
/// 1: 2
|
||||
/// 2: 0
|
||||
/// After:
|
||||
/// 0: 0 <- Zero
|
||||
#[test]
|
||||
fn truncate_to_no_invisible_lines_unrotated_buffer() {
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('1'), filled_row('2'), filled_row('0')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 3,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
storage.truncate_to(1);
|
||||
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('0')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 1,
|
||||
len: 1,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Before:
|
||||
/// 0: 1
|
||||
/// 1: 2 <- Zero
|
||||
/// 2: 0
|
||||
/// After:
|
||||
/// 0: 1 <- Zero
|
||||
#[test]
|
||||
fn truncate_to_no_invisible_lines_rotated_buffer() {
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('1'), filled_row('2'), filled_row('0')],
|
||||
bottom_row: 1,
|
||||
visible_lines: 3,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
storage.truncate_to(1);
|
||||
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('1')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 1,
|
||||
len: 1,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// Before:
|
||||
/// 0: 3 <- Hidden
|
||||
/// 1: 4 <- Zero
|
||||
/// 2: 0
|
||||
/// 3: 1
|
||||
/// 4: 2
|
||||
/// After:
|
||||
/// 0: 2 <- Zero
|
||||
/// 1: 3
|
||||
#[test]
|
||||
fn truncate_to_with_invisible_lines_rotated_buffer() {
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
filled_row('3'),
|
||||
filled_row('4'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
],
|
||||
bottom_row: 1,
|
||||
visible_lines: 2,
|
||||
// The grid has 1 hidden line (`inner` has 5 lines).
|
||||
len: 4,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
storage.truncate_to(2);
|
||||
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('1'), filled_row('2')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 2,
|
||||
len: 2,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_to_with_larger_target_len() {
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('1')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 1,
|
||||
len: 1,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
storage.truncate_to(2);
|
||||
|
||||
let expected = Storage {
|
||||
inner: vec![filled_row('1')],
|
||||
bottom_row: 0,
|
||||
visible_lines: 1,
|
||||
len: 1,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
assert_eq!(storage.bottom_row, expected.bottom_row);
|
||||
assert_eq!(storage.len, expected.len);
|
||||
}
|
||||
|
||||
/// First shrink the buffer and then grow it again.
|
||||
///
|
||||
/// Before:
|
||||
/// 0: 4
|
||||
/// 1: 5
|
||||
/// 2: 0 <- Zero
|
||||
/// 3: 1
|
||||
/// 4: 2
|
||||
/// 5: 3
|
||||
/// After Shrinking:
|
||||
/// 0: 4 <- Hidden
|
||||
/// 1: 5 <- Hidden
|
||||
/// 2: 0 <- Zero
|
||||
/// 3: 1
|
||||
/// 4: 2
|
||||
/// 5: 3 <- Hidden
|
||||
/// After Growing:
|
||||
/// 0: 4
|
||||
/// 1: 5
|
||||
/// 2: -
|
||||
/// 3: 0 <- Zero
|
||||
/// 4: 1
|
||||
/// 5: 2
|
||||
/// 6: 3
|
||||
#[test]
|
||||
fn shrink_then_grow() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 0,
|
||||
len: 6,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Shrink buffer.
|
||||
storage.shrink_lines(3);
|
||||
|
||||
// Make sure the result after shrinking is correct.
|
||||
let shrinking_expected = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 0,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
assert_eq!(storage.inner, shrinking_expected.inner);
|
||||
assert_eq!(storage.bottom_row, shrinking_expected.bottom_row);
|
||||
assert_eq!(storage.len, shrinking_expected.len);
|
||||
|
||||
// Grow buffer.
|
||||
storage.initialize(1, 1);
|
||||
|
||||
// Make sure the previously freed elements are reused.
|
||||
let growing_expected = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 0,
|
||||
len: 4,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
assert_eq!(storage.inner, growing_expected.inner);
|
||||
assert_eq!(storage.bottom_row, growing_expected.bottom_row);
|
||||
assert_eq!(storage.len, growing_expected.len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize() {
|
||||
// Setup storage area.
|
||||
let mut storage = Storage {
|
||||
inner: vec![
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
],
|
||||
bottom_row: 2,
|
||||
visible_lines: 0,
|
||||
len: 6,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
// Initialize additional lines.
|
||||
let init_size = 3;
|
||||
storage.initialize(init_size, 1);
|
||||
|
||||
// Generate expected grid.
|
||||
let mut expected_inner = vec![
|
||||
filled_row('0'),
|
||||
filled_row('1'),
|
||||
filled_row('2'),
|
||||
filled_row('3'),
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
];
|
||||
let expected_init_size = std::cmp::max(init_size, MAX_CACHE_SIZE);
|
||||
expected_inner.append(&mut vec![filled_row('\0'); expected_init_size]);
|
||||
let expected_storage = Storage {
|
||||
inner: expected_inner,
|
||||
bottom_row: 0,
|
||||
visible_lines: 0,
|
||||
len: 9,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
assert_eq!(storage.len, expected_storage.len);
|
||||
assert_eq!(storage.bottom_row, expected_storage.bottom_row);
|
||||
assert_eq!(storage.inner, expected_storage.inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_wrap_zero() {
|
||||
let mut storage = Storage {
|
||||
inner: vec![filled_row('-'), filled_row('-'), filled_row('-')],
|
||||
bottom_row: 2,
|
||||
visible_lines: 0,
|
||||
len: 3,
|
||||
is_sequential: false,
|
||||
max_cache_size: MAX_CACHE_SIZE,
|
||||
};
|
||||
|
||||
storage.rotate(2);
|
||||
|
||||
assert!(storage.bottom_row < storage.inner.len());
|
||||
}
|
||||
|
||||
fn filled_row(content: char) -> Row {
|
||||
let mut row = Row::new(1);
|
||||
let mut cell = Cell::default();
|
||||
cell.c = content;
|
||||
row[0] = cell;
|
||||
row
|
||||
}
|
||||
|
||||
fn partially_filled_row(content: char, columns_to_fill: usize, total_columns: usize) -> Row {
|
||||
let mut row = Row::new(total_columns);
|
||||
for col in 0..total_columns {
|
||||
let mut cell = Cell::default();
|
||||
if col <= columns_to_fill {
|
||||
cell.c = content;
|
||||
}
|
||||
row[col] = cell;
|
||||
}
|
||||
row
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::ops::Bound::Included;
|
||||
|
||||
use super::{
|
||||
grid::grid_handler::{AbsolutePoint, AbsoluteRectangle},
|
||||
iterm_image::ITermImageMetadata,
|
||||
kitty::KittyImageMetadata,
|
||||
};
|
||||
|
||||
/// This image cache stores the absolute positions of points, relative to the number of rows
|
||||
/// that haven't been truncated yet. Stored absolute points are nearly always stale as we store
|
||||
/// the number of lines that have been truncated only when we insert a new image. This means that
|
||||
/// upon retrieval, we need to update the key to correctly retrieve the data, and update the response
|
||||
/// to no longer be stale.
|
||||
#[derive(Clone, Default)]
|
||||
pub(in crate::terminal::model) struct ImageMap {
|
||||
/// Map of image ids keyed by absolute position.
|
||||
image_ids_by_point: BTreeMap<AbsolutePoint, HashSet<(u32, u32)>>,
|
||||
/// Map of absolute positions of a given image id.
|
||||
point_by_image_id: BTreeMap<(u32, u32), AbsolutePoint>,
|
||||
/// Image with the largest height in the map.
|
||||
largest_height: usize,
|
||||
image_placement_data: HashMap<(u32, u32), ImagePlacementData>,
|
||||
num_lines_truncated_at_last_update: u64,
|
||||
image_type_by_image_id: HashMap<u32, ImageType>,
|
||||
}
|
||||
|
||||
impl ImageMap {
|
||||
pub fn get_image_placement_data(
|
||||
&self,
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
) -> Option<&ImagePlacementData> {
|
||||
self.image_placement_data.get(&(image_id, placement_id))
|
||||
}
|
||||
|
||||
pub fn add_image_placement_data(
|
||||
&mut self,
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
image_data: ImagePlacementData,
|
||||
) {
|
||||
self.largest_height = self.largest_height.max(image_data.height_cells);
|
||||
self.image_placement_data
|
||||
.insert((image_id, placement_id), image_data);
|
||||
}
|
||||
|
||||
pub fn place(
|
||||
&mut self,
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
primary_point: AbsolutePoint,
|
||||
image_type: ImageType,
|
||||
num_lines_truncated: u64,
|
||||
) {
|
||||
self.image_ids_by_point
|
||||
.entry(primary_point)
|
||||
.or_default()
|
||||
.insert((image_id, placement_id));
|
||||
self.point_by_image_id
|
||||
.insert((image_id, placement_id), primary_point);
|
||||
self.image_type_by_image_id.insert(image_id, image_type);
|
||||
self.evict_truncated_absolute_points(num_lines_truncated);
|
||||
}
|
||||
|
||||
pub fn evict_images_at_point_with_type(
|
||||
&mut self,
|
||||
point: AbsolutePoint,
|
||||
image_types_to_evict: &[ImageType],
|
||||
) {
|
||||
if self.image_ids_by_point.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut to_evict = Vec::new();
|
||||
if let Some(images_to_evict) = self.image_ids_by_point.get(&point) {
|
||||
for (image_id, placement_id) in images_to_evict.iter() {
|
||||
let Some(image_type) = self.image_type_by_image_id.get(image_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !image_types_to_evict.contains(image_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
to_evict.push((*image_id, *placement_id));
|
||||
}
|
||||
}
|
||||
|
||||
for (image_id, placement_id) in to_evict {
|
||||
self.evict_placement(image_id, placement_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_image_ids_by_rectangle(
|
||||
&self,
|
||||
rectangle: AbsoluteRectangle,
|
||||
) -> Vec<AbsoluteImagePlacement> {
|
||||
// We extend our search window upwards by the height of the largest image in the map such that we can
|
||||
// check for images that start above the given search window but still overlap.
|
||||
let range_rectangle = AbsoluteRectangle {
|
||||
start_row: rectangle
|
||||
.start_row
|
||||
.saturating_sub(self.largest_height as u64),
|
||||
end_row: rectangle.end_row,
|
||||
};
|
||||
|
||||
let range_start = range_rectangle.start_row_to_point(usize::MIN);
|
||||
let range_end = range_rectangle.end_row_to_point(usize::MAX);
|
||||
|
||||
// 'end' should always be greater than or equal to 'start', but if is not, the range method on the BTreeMap will panic,
|
||||
// which we want to avoid.
|
||||
if range_start.cmp(&range_end) == Ordering::Greater {
|
||||
log::warn!("get_image_ids_by_rectangle: start > end");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
self.image_ids_by_point
|
||||
.range((Included(&range_start), Included(&range_end)))
|
||||
.map(|(&top_left, image_ids)| {
|
||||
let image_ids = image_ids
|
||||
.iter()
|
||||
.filter(|(image_id, placement_id)| {
|
||||
let height =
|
||||
match self.image_placement_data.get(&(*image_id, *placement_id)) {
|
||||
Some(placement_data) => placement_data.height_cells,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if height == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let rectangle_start = rectangle.start_row_to_point(0);
|
||||
let image_end = top_left.add_rows(height);
|
||||
|
||||
// Since we extended the search window by the height of the largest image, we need to ensure
|
||||
// images actually overlap with our original search window
|
||||
image_end >= rectangle_start
|
||||
})
|
||||
.copied()
|
||||
.collect_vec();
|
||||
(top_left, image_ids)
|
||||
})
|
||||
.flat_map(|(top_left, image_ids)| {
|
||||
image_ids
|
||||
.iter()
|
||||
.map(|&(image_id, placement_id)| {
|
||||
let z_index = match self.image_placement_data.get(&(image_id, placement_id))
|
||||
{
|
||||
Some(placement_data) => placement_data.z_index,
|
||||
None => 0,
|
||||
};
|
||||
(z_index, (image_id, placement_id), top_left)
|
||||
})
|
||||
.collect_vec()
|
||||
})
|
||||
.sorted()
|
||||
.map(
|
||||
|(z_index, (image_id, placement_id), top_left)| AbsoluteImagePlacement {
|
||||
image_id,
|
||||
placement_id,
|
||||
z_index,
|
||||
top_left,
|
||||
},
|
||||
)
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
pub fn has_image_in_row(&self, row: u64) -> bool {
|
||||
// We extend our search window upwards by the height of the largest image in the map such that we can
|
||||
// check for images that start above the given row but still overlap.
|
||||
let range_start = AbsolutePoint {
|
||||
row: row.saturating_sub(self.largest_height as u64),
|
||||
col: usize::MIN,
|
||||
};
|
||||
let range_end = AbsolutePoint {
|
||||
row,
|
||||
col: usize::MAX,
|
||||
};
|
||||
|
||||
self.image_ids_by_point
|
||||
.range((Included(&range_start), Included(&range_end)))
|
||||
.any(|(&top_left, image_ids)| {
|
||||
image_ids.iter().any(|(image_id, placement_id)| {
|
||||
let height = match self.image_placement_data.get(&(*image_id, *placement_id)) {
|
||||
Some(placement_data) => placement_data.height_cells,
|
||||
None => return false,
|
||||
};
|
||||
height != 0 && top_left.add_rows(height).row >= row
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evict_image_ids_between_points_with_type(
|
||||
&mut self,
|
||||
start_point: AbsolutePoint,
|
||||
end_point: AbsolutePoint,
|
||||
image_types_to_evict: Vec<ImageType>,
|
||||
) {
|
||||
// 'end' should always be greater than or equal to 'start', but if is not, the range method on the BTreeMap will panic,
|
||||
// which we want to avoid.
|
||||
if start_point.cmp(&end_point) == Ordering::Greater {
|
||||
log::warn!("evict_image_ids_between_points: start > end");
|
||||
return;
|
||||
}
|
||||
|
||||
let images_to_evict = self
|
||||
.image_ids_by_point
|
||||
.range((Included(&start_point), Included(&end_point)))
|
||||
.flat_map(|(_, image_ids)| image_ids.iter().copied())
|
||||
.collect_vec();
|
||||
|
||||
for (image_id, placement_id) in images_to_evict {
|
||||
let Some(image_type) = self.image_type_by_image_id.get(&image_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !image_types_to_evict.contains(image_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.evict_placement(image_id, placement_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evict_truncated_absolute_points(&mut self, new_num_lines_truncated: u64) {
|
||||
if new_num_lines_truncated == self.num_lines_truncated_at_last_update {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut images_to_evict = vec![];
|
||||
for (top_left, image_ids) in &self.image_ids_by_point {
|
||||
if top_left.is_truncated(new_num_lines_truncated) {
|
||||
images_to_evict.extend(image_ids.iter());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (image_id, placement_id) in images_to_evict {
|
||||
self.evict_placement(image_id, placement_id);
|
||||
}
|
||||
|
||||
self.num_lines_truncated_at_last_update = new_num_lines_truncated;
|
||||
}
|
||||
|
||||
pub fn evict_all_images(&mut self) {
|
||||
self.image_placement_data.clear();
|
||||
self.image_ids_by_point.clear();
|
||||
self.point_by_image_id.clear();
|
||||
self.largest_height = 0;
|
||||
}
|
||||
|
||||
pub fn evict_image(&mut self, image_id_to_evict: u32) {
|
||||
let mut images_to_evict = vec![];
|
||||
for &(image_id, placement_id) in self.point_by_image_id.keys() {
|
||||
if image_id == image_id_to_evict {
|
||||
images_to_evict.push((image_id, placement_id));
|
||||
}
|
||||
}
|
||||
|
||||
for (image_id, placement_id) in images_to_evict {
|
||||
self.evict_placement(image_id, placement_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evict_placement(&mut self, image_id: u32, placement_id: u32) {
|
||||
self.image_placement_data.remove(&(image_id, placement_id));
|
||||
if let Some(point) = self.point_by_image_id.get(&(image_id, placement_id)) {
|
||||
if let Some(image_ids) = self.image_ids_by_point.get_mut(point) {
|
||||
image_ids.remove(&(image_id, placement_id));
|
||||
if image_ids.is_empty() {
|
||||
self.image_ids_by_point.remove(point);
|
||||
}
|
||||
}
|
||||
|
||||
self.point_by_image_id.remove(&(image_id, placement_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AbsoluteImagePlacement {
|
||||
pub image_id: u32,
|
||||
pub placement_id: u32,
|
||||
pub z_index: i32,
|
||||
pub top_left: AbsolutePoint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ImageType {
|
||||
Kitty,
|
||||
ITerm,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StoredImageMetadata {
|
||||
ITerm(ITermImageMetadata),
|
||||
Kitty(KittyImageMetadata),
|
||||
}
|
||||
|
||||
impl StoredImageMetadata {
|
||||
pub fn image_size(&self) -> Vector2F {
|
||||
match self {
|
||||
StoredImageMetadata::ITerm(metadata) => metadata.image_size,
|
||||
StoredImageMetadata::Kitty(metadata) => metadata.image_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preserve_aspect_ratio(&self) -> bool {
|
||||
match self {
|
||||
StoredImageMetadata::ITerm(metadata) => metadata.preserve_aspect_ratio,
|
||||
StoredImageMetadata::Kitty(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImagePlacementData {
|
||||
pub z_index: i32,
|
||||
pub height_cells: usize,
|
||||
pub image_size: Vector2F,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Indexing-related newtypes for strongly typed tty/grid/terminal APIs.
|
||||
|
||||
pub use warp_terminal::model::{Index, IndexRange, Point, VisiblePoint, VisibleRow};
|
||||
|
||||
/// The side of a cell.
|
||||
pub type Side = Direction;
|
||||
|
||||
/// Horizontal direction.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, PartialOrd)]
|
||||
pub enum Direction {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn opposite(self) -> Self {
|
||||
match self {
|
||||
Side::Right => Side::Left,
|
||||
Side::Left => Side::Right,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use rand::Rng;
|
||||
use warpui::util::parse_u32;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ITermImage {
|
||||
pub metadata: ITermImageMetadata,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum ITermImageDimensionUnit {
|
||||
Cell,
|
||||
Pixel,
|
||||
Percent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ITermImageMetadata {
|
||||
pub id: u32,
|
||||
pub desired_width: Option<(u32, ITermImageDimensionUnit)>,
|
||||
pub desired_height: Option<(u32, ITermImageDimensionUnit)>,
|
||||
pub preserve_aspect_ratio: bool,
|
||||
pub name: String,
|
||||
pub inline: bool,
|
||||
pub image_size: Vector2F,
|
||||
}
|
||||
|
||||
impl Default for ITermImageMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: rand::thread_rng().gen(),
|
||||
desired_width: None,
|
||||
desired_height: None,
|
||||
preserve_aspect_ratio: true,
|
||||
name: "Unnamed file".into(),
|
||||
inline: false,
|
||||
image_size: Vector2F::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_iterm_image_dimensions(dimension: &[u8]) -> Option<(u32, ITermImageDimensionUnit)> {
|
||||
if dimension.ends_with(b"px") {
|
||||
let value = &dimension[..dimension.len() - 2];
|
||||
Some((parse_u32(value)?, ITermImageDimensionUnit::Pixel))
|
||||
} else if dimension.ends_with(b"%") {
|
||||
let value = &dimension[..dimension.len() - 1];
|
||||
Some((
|
||||
parse_u32(value)?.clamp(0, 100),
|
||||
ITermImageDimensionUnit::Percent,
|
||||
))
|
||||
} else {
|
||||
Some((parse_u32(dimension)?, ITermImageDimensionUnit::Cell))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_iterm_image_metadata(params: &[&[u8]]) -> ITermImageMetadata {
|
||||
let mut metadata = ITermImageMetadata::default();
|
||||
for param in params {
|
||||
let (mut key, mut value) = match param.iter().position(|&byte| byte == b'=') {
|
||||
Some(position) => (¶m[..position], ¶m[position + 1..]),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Because the format of arguments is (MultipartFile | File) = [optional arguments],
|
||||
// The first optional argument will have "MultipartFile=" or "File=" prefixed to it.
|
||||
// For example, params[1] will be "File=inline=0". So the key-value seperation done before
|
||||
// will yield a value of "inline=0", which we need to further split.
|
||||
if key == b"File" || key == b"MultipartFile" {
|
||||
(key, value) = match value.iter().position(|&byte| byte == b'=') {
|
||||
Some(position) => (&value[..position], &value[position + 1..]),
|
||||
None => continue,
|
||||
};
|
||||
}
|
||||
|
||||
// For unchunked File messages, image data will be contained in a param and will be after
|
||||
// a colon. So we need to remove it here to only look at the metadata.
|
||||
// For example. A param could be "height=100px:image_data".
|
||||
let value = match value.iter().position(|&byte| byte == b':') {
|
||||
Some(position) => &value[..position],
|
||||
None => value,
|
||||
};
|
||||
|
||||
match key {
|
||||
b"width" => {
|
||||
metadata.desired_width = parse_iterm_image_dimensions(value);
|
||||
}
|
||||
b"height" => {
|
||||
metadata.desired_height = parse_iterm_image_dimensions(value);
|
||||
}
|
||||
b"preserveAspectRatio" => {
|
||||
metadata.preserve_aspect_ratio = value == b"1" || value == b"true";
|
||||
}
|
||||
b"name" => {
|
||||
let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(name) = String::from_utf8(decoded_bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = Path::new(&name)
|
||||
.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.map(|file_name| file_name.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
metadata.name = name;
|
||||
}
|
||||
b"inline" => {
|
||||
metadata.inline = value == b"1" || value == b"true";
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "iterm_image_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_iterm_image_dimensions() {
|
||||
assert_eq!(parse_iterm_image_dimensions(b"a123"), None);
|
||||
assert_eq!(parse_iterm_image_dimensions(b"123p"), None);
|
||||
assert_eq!(parse_iterm_image_dimensions(b"123p%"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_iterm_image_dimensions() {
|
||||
assert_eq!(
|
||||
parse_iterm_image_dimensions(b"123"),
|
||||
Some((123, ITermImageDimensionUnit::Cell))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iterm_image_dimensions(b"123px"),
|
||||
Some((123, ITermImageDimensionUnit::Pixel))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iterm_image_dimensions(b"90%"),
|
||||
Some((90, ITermImageDimensionUnit::Percent))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_percentage_image_width() {
|
||||
assert_eq!(
|
||||
parse_iterm_image_dimensions(b"123%"),
|
||||
Some((100, ITermImageDimensionUnit::Percent))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iterm_image_dimensions(b"99%"),
|
||||
Some((99, ITermImageDimensionUnit::Percent))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use flate2::read::ZlibDecoder;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use rand::Rng;
|
||||
use std::cmp::min;
|
||||
use std::io::Read;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::{env, fs, str};
|
||||
use warpui::image_cache::{resize_dimensions, FitType};
|
||||
use warpui::{
|
||||
assets::asset_cache::Asset,
|
||||
image_cache::{CustomHeaderCreationError, CustomImageFormat, CustomImageHeader, ImageType},
|
||||
util::{parse_i32, parse_u32},
|
||||
};
|
||||
|
||||
use super::escape_sequences::C1;
|
||||
|
||||
/// Actions specified by the [Kitty Image Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/)
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KittyAction {
|
||||
StoreOnly(StoreOnly),
|
||||
StoreAndDisplay(StoreAndDisplay),
|
||||
DisplayStoredImage(DisplayStoredImage),
|
||||
QuerySupport(QuerySupport),
|
||||
Delete {
|
||||
delete_placements_only: bool,
|
||||
deletion_type: DeletionType,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct StoreOnly {
|
||||
pub image: KittyImage,
|
||||
pub image_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct StoreAndDisplay {
|
||||
pub image: KittyImage,
|
||||
pub placement_data: KittyPlacementData,
|
||||
pub image_id: u32,
|
||||
pub placement_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DisplayStoredImage {
|
||||
pub placement_data: KittyPlacementData,
|
||||
pub image_id: u32,
|
||||
pub placement_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct QuerySupport {
|
||||
pub image: KittyImage,
|
||||
pub image_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DeletionType {
|
||||
DeleteAll,
|
||||
DeleteById(DeleteById),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeleteById {
|
||||
pub image_id: u32,
|
||||
pub placement_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct KittyImage {
|
||||
pub metadata: KittyImageMetadata,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TryFrom<PendingKittyMessage> for KittyMessage {
|
||||
type Error = InvalidKittyPayload;
|
||||
|
||||
fn try_from(pending: PendingKittyMessage) -> Result<KittyMessage, InvalidKittyPayload> {
|
||||
let mut decoded_payload = vec![];
|
||||
|
||||
for payload in pending.payload {
|
||||
decoded_payload.extend(base64_decode_padding_agnostic(&payload[..])?);
|
||||
}
|
||||
|
||||
Ok(KittyMessage {
|
||||
control_data: pending.control_data,
|
||||
payload: decoded_payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<KittyMessage> for KittyImage {
|
||||
type Error = InvalidKittyPayload;
|
||||
|
||||
fn try_from(message: KittyMessage) -> Result<Self, InvalidKittyPayload> {
|
||||
let decoded_data =
|
||||
decode_kitty_image_data(message.payload, message.control_data.compressed)?;
|
||||
|
||||
let decoded_data = match message.control_data.transmission_medium {
|
||||
KittyTransmissionMedium::Direct => decoded_data,
|
||||
KittyTransmissionMedium::SimpleFile => {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
read_file(decoded_data, false)?
|
||||
} else {
|
||||
return Err(InvalidKittyPayload::FileError(FileError::UnsupportedPlatform));
|
||||
}
|
||||
}
|
||||
}
|
||||
KittyTransmissionMedium::TemporaryFile => {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
read_file(decoded_data, true)?
|
||||
} else {
|
||||
return Err(InvalidKittyPayload::FileError(FileError::UnsupportedPlatform));
|
||||
}
|
||||
}
|
||||
}
|
||||
KittyTransmissionMedium::SharedMemoryObject => {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(all(feature = "local_fs", unix))] {
|
||||
read_shared_memory(message.control_data.clone(), decoded_data)?
|
||||
} else {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::UnsupportedPlatform));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
metadata: KittyImageMetadata::from(message.control_data),
|
||||
data: decoded_data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub type KittyResponse = Result<(), KittyError>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KittyError {
|
||||
InvalidKittyAction(InvalidKittyAction),
|
||||
StorageError(StorageError),
|
||||
KittyFeatureDisabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StorageError {
|
||||
UnknownId { id: u32 },
|
||||
}
|
||||
|
||||
impl From<StorageError> for KittyError {
|
||||
fn from(value: StorageError) -> Self {
|
||||
KittyError::StorageError(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InvalidKittyAction {
|
||||
UnsupportedAction,
|
||||
InvalidKittyPayload(InvalidKittyPayload),
|
||||
InvalidControlData(InvalidControlData),
|
||||
}
|
||||
|
||||
impl From<InvalidKittyAction> for KittyError {
|
||||
fn from(value: InvalidKittyAction) -> Self {
|
||||
KittyError::InvalidKittyAction(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InvalidControlData {
|
||||
IdMissing,
|
||||
UnicodePlaceholderUnsupported,
|
||||
}
|
||||
|
||||
impl From<InvalidControlData> for KittyError {
|
||||
fn from(value: InvalidControlData) -> Self {
|
||||
KittyError::InvalidKittyAction(InvalidKittyAction::InvalidControlData(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InvalidKittyPayload {
|
||||
InvalidTransmissionMedium(KittyTransmissionMedium),
|
||||
KittyDecodeError(KittyDecodeError),
|
||||
InvalidKittyImage(InvalidKittyImage),
|
||||
FileError(FileError),
|
||||
ShmError(ShmError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileError {
|
||||
FileReadError(String),
|
||||
UnsupportedPlatform,
|
||||
}
|
||||
|
||||
impl From<InvalidKittyPayload> for KittyError {
|
||||
fn from(value: InvalidKittyPayload) -> Self {
|
||||
KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ShmError {
|
||||
ObjectOpenError(String),
|
||||
MmapError,
|
||||
EmptyObject,
|
||||
ObjectTooSmall {
|
||||
expected_bytes: usize,
|
||||
actual_bytes: usize,
|
||||
},
|
||||
InvalidObjectSize,
|
||||
FileStatError(String),
|
||||
UnsupportedPlatform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KittyDecodeError {
|
||||
InvalidBase64(String),
|
||||
InvalidCompression(String),
|
||||
InvalidUtf8(String),
|
||||
}
|
||||
|
||||
impl From<KittyDecodeError> for InvalidKittyPayload {
|
||||
fn from(value: KittyDecodeError) -> Self {
|
||||
InvalidKittyPayload::KittyDecodeError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KittyDecodeError> for KittyError {
|
||||
fn from(value: KittyDecodeError) -> Self {
|
||||
KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload(
|
||||
InvalidKittyPayload::KittyDecodeError(value),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InvalidKittyImage {
|
||||
KittyPngError(KittyPngError),
|
||||
KittyRgbError(KittyRgbError),
|
||||
MalformedImage,
|
||||
}
|
||||
|
||||
impl From<InvalidKittyImage> for KittyError {
|
||||
fn from(value: InvalidKittyImage) -> Self {
|
||||
KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload(
|
||||
InvalidKittyPayload::InvalidKittyImage(value),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KittyPngError {
|
||||
InvalidBytes(String),
|
||||
}
|
||||
|
||||
impl From<KittyPngError> for KittyError {
|
||||
fn from(value: KittyPngError) -> Self {
|
||||
KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload(
|
||||
InvalidKittyPayload::InvalidKittyImage(InvalidKittyImage::KittyPngError(value)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KittyRgbError {
|
||||
ExpectedDataSizeMismatch {
|
||||
expected_bytes: usize,
|
||||
actual_bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<KittyRgbError> for KittyError {
|
||||
fn from(value: KittyRgbError) -> Self {
|
||||
KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload(
|
||||
InvalidKittyPayload::InvalidKittyImage(InvalidKittyImage::KittyRgbError(value)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CustomHeaderCreationError> for KittyRgbError {
|
||||
fn from(value: CustomHeaderCreationError) -> Self {
|
||||
match value {
|
||||
CustomHeaderCreationError::ExpectedDataSizeMismatch {
|
||||
expected_bytes,
|
||||
actual_bytes,
|
||||
} => KittyRgbError::ExpectedDataSizeMismatch {
|
||||
expected_bytes,
|
||||
actual_bytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<KittyMessage> for KittyAction {
|
||||
type Error = KittyError;
|
||||
|
||||
fn try_from(message: KittyMessage) -> Result<KittyAction, KittyError> {
|
||||
match message.control_data.placement_action {
|
||||
KittyPlacementAction::StoreOnly => {
|
||||
let mut action = StoreOnly {
|
||||
image_id: message
|
||||
.control_data
|
||||
.image_id
|
||||
.unwrap_or(rand::thread_rng().gen()),
|
||||
image: KittyImage::try_from(message)?,
|
||||
};
|
||||
|
||||
if action.image.metadata.pixel_data_format == KittyPixelDataFormat::Png {
|
||||
action.image = set_kitty_png_size(action.image)?;
|
||||
} else {
|
||||
action.image = set_kitty_rgb_headers(action.image)?;
|
||||
}
|
||||
|
||||
Ok(KittyAction::StoreOnly(action))
|
||||
}
|
||||
KittyPlacementAction::StoreAndDisplay => {
|
||||
if message.control_data.unicode_placeholder {
|
||||
return Err(InvalidControlData::UnicodePlaceholderUnsupported.into());
|
||||
}
|
||||
|
||||
let mut action = StoreAndDisplay {
|
||||
image_id: message
|
||||
.control_data
|
||||
.image_id
|
||||
.unwrap_or(rand::thread_rng().gen()),
|
||||
placement_id: message
|
||||
.control_data
|
||||
.placement_id
|
||||
.unwrap_or(rand::thread_rng().gen()),
|
||||
placement_data: KittyPlacementData {
|
||||
z_index: message.control_data.z_index,
|
||||
cols: message.control_data.cols,
|
||||
rows: message.control_data.rows,
|
||||
cursor_movement_policy: message.control_data.cursor_movement_policy,
|
||||
},
|
||||
image: KittyImage::try_from(message)?,
|
||||
};
|
||||
|
||||
if action.image.metadata.pixel_data_format == KittyPixelDataFormat::Png {
|
||||
action.image = set_kitty_png_size(action.image)?;
|
||||
} else {
|
||||
action.image = set_kitty_rgb_headers(action.image)?;
|
||||
}
|
||||
|
||||
Ok(KittyAction::StoreAndDisplay(action))
|
||||
}
|
||||
KittyPlacementAction::DisplayStoredImage => {
|
||||
if message.control_data.unicode_placeholder {
|
||||
return Err(InvalidControlData::UnicodePlaceholderUnsupported.into());
|
||||
}
|
||||
|
||||
let id = match message.control_data.image_id {
|
||||
Some(id) => id,
|
||||
None => return Err(InvalidControlData::IdMissing.into()),
|
||||
};
|
||||
|
||||
Ok(KittyAction::DisplayStoredImage(DisplayStoredImage {
|
||||
image_id: id,
|
||||
placement_id: message
|
||||
.control_data
|
||||
.placement_id
|
||||
.unwrap_or(rand::thread_rng().gen()),
|
||||
placement_data: KittyPlacementData {
|
||||
z_index: message.control_data.z_index,
|
||||
cols: message.control_data.cols,
|
||||
rows: message.control_data.rows,
|
||||
cursor_movement_policy: message.control_data.cursor_movement_policy,
|
||||
},
|
||||
}))
|
||||
}
|
||||
KittyPlacementAction::QuerySupport => {
|
||||
let mut action = QuerySupport {
|
||||
image_id: message
|
||||
.control_data
|
||||
.image_id
|
||||
.unwrap_or(rand::thread_rng().gen()),
|
||||
image: KittyImage::try_from(message)?,
|
||||
};
|
||||
|
||||
if action.image.metadata.pixel_data_format == KittyPixelDataFormat::Png {
|
||||
action.image = set_kitty_png_size(action.image)?;
|
||||
} else {
|
||||
action.image = set_kitty_rgb_headers(action.image)?;
|
||||
}
|
||||
|
||||
Ok(KittyAction::QuerySupport(action))
|
||||
}
|
||||
KittyPlacementAction::Delete => {
|
||||
let deletion_type = match message.control_data.delete_action {
|
||||
DeleteAction::DeleteAll => DeletionType::DeleteAll,
|
||||
DeleteAction::DeleteById => {
|
||||
let image_id = match message.control_data.image_id {
|
||||
Some(image_id) => image_id,
|
||||
None => return Err(InvalidControlData::IdMissing.into()),
|
||||
};
|
||||
|
||||
DeletionType::DeleteById(DeleteById {
|
||||
image_id,
|
||||
placement_id: message.control_data.placement_id,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(KittyAction::Delete {
|
||||
deletion_type,
|
||||
delete_placements_only: message.control_data.delete_placements_only,
|
||||
})
|
||||
}
|
||||
KittyPlacementAction::Unknown => Err(InvalidKittyAction::UnsupportedAction.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct KittyImageMetadata {
|
||||
pub pixel_data_format: KittyPixelDataFormat,
|
||||
pub transmission_medium: KittyTransmissionMedium,
|
||||
pub image_size: Vector2F,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct KittyPlacementData {
|
||||
pub z_index: i32,
|
||||
pub cols: Option<u32>,
|
||||
pub rows: Option<u32>,
|
||||
pub cursor_movement_policy: CursorMovementPolicy,
|
||||
}
|
||||
|
||||
impl KittyPlacementData {
|
||||
pub fn get_desired_dimensions(
|
||||
&self,
|
||||
image_size: Vector2F,
|
||||
cell_height: usize,
|
||||
cell_width: usize,
|
||||
max_width_px: usize,
|
||||
max_height_px: usize,
|
||||
) -> Vector2F {
|
||||
let aspect_ratio = image_size.x() / image_size.y();
|
||||
|
||||
let (width_px, height_px) = resize_dimensions(
|
||||
image_size.x() as u32,
|
||||
image_size.y() as u32,
|
||||
min(image_size.x() as u32, max_width_px as u32),
|
||||
min(image_size.y() as u32, max_height_px as u32),
|
||||
FitType::Contain,
|
||||
);
|
||||
|
||||
let (desired_width_px, desired_height_px) = match (self.cols, self.rows) {
|
||||
(Some(width), None) => {
|
||||
let desired_width_px = ((cell_width as u32) * width) as f32;
|
||||
(desired_width_px, desired_width_px / aspect_ratio)
|
||||
}
|
||||
(None, Some(height)) => {
|
||||
let desired_height_px = ((cell_height as u32) * height) as f32;
|
||||
(desired_height_px * aspect_ratio, desired_height_px)
|
||||
}
|
||||
(Some(width), Some(height)) => (
|
||||
((cell_width as u32) * width) as f32,
|
||||
((cell_height as u32) * height) as f32,
|
||||
),
|
||||
(None, None) => (width_px as f32, height_px as f32),
|
||||
};
|
||||
|
||||
Vector2F::new(desired_width_px, desired_height_px)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KittyControlData> for KittyImageMetadata {
|
||||
fn from(control_data: KittyControlData) -> Self {
|
||||
Self {
|
||||
pixel_data_format: control_data.pixel_data_format,
|
||||
image_size: Vector2F::new(control_data.width as f32, control_data.height as f32),
|
||||
transmission_medium: control_data.transmission_medium,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KittyChunk {
|
||||
pub control_data: KittyControlData,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingKittyMessage {
|
||||
pub control_data: KittyControlData,
|
||||
pub payload: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KittyMessage {
|
||||
pub control_data: KittyControlData,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub enum KittyPlacementAction {
|
||||
#[default]
|
||||
StoreOnly,
|
||||
StoreAndDisplay,
|
||||
DisplayStoredImage,
|
||||
QuerySupport,
|
||||
Delete,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KittyControlData {
|
||||
pixel_data_format: KittyPixelDataFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pub compressed: bool,
|
||||
transmission_medium: KittyTransmissionMedium,
|
||||
pub further_chunks: bool,
|
||||
pub image_id: Option<u32>,
|
||||
pub placement_id: Option<u32>,
|
||||
pub placement_action: KittyPlacementAction,
|
||||
pub verbosity: KittyResponseVerbosity,
|
||||
pub z_index: i32,
|
||||
pub delete_action: DeleteAction,
|
||||
delete_placements_only: bool,
|
||||
pub rows: Option<u32>,
|
||||
pub cols: Option<u32>,
|
||||
pub cursor_movement_policy: CursorMovementPolicy,
|
||||
pub unicode_placeholder: bool,
|
||||
}
|
||||
|
||||
impl Default for KittyControlData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pixel_data_format: KittyPixelDataFormat::default(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
compressed: false,
|
||||
transmission_medium: KittyTransmissionMedium::default(),
|
||||
further_chunks: false,
|
||||
image_id: None,
|
||||
placement_id: None,
|
||||
placement_action: KittyPlacementAction::default(),
|
||||
verbosity: KittyResponseVerbosity::default(),
|
||||
z_index: 0,
|
||||
delete_action: DeleteAction::default(),
|
||||
delete_placements_only: true,
|
||||
rows: None,
|
||||
cols: None,
|
||||
cursor_movement_policy: CursorMovementPolicy::default(),
|
||||
unicode_placeholder: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Clone, Copy)]
|
||||
pub enum DeleteAction {
|
||||
#[default]
|
||||
DeleteAll,
|
||||
DeleteById,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Clone, Copy)]
|
||||
pub enum KittyPixelDataFormat {
|
||||
Rgb24Bit,
|
||||
#[default]
|
||||
Rgba32Bit,
|
||||
Png,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Clone, Copy)]
|
||||
pub enum KittyTransmissionMedium {
|
||||
#[default]
|
||||
Direct,
|
||||
SimpleFile,
|
||||
TemporaryFile,
|
||||
SharedMemoryObject,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
pub enum KittyResponseVerbosity {
|
||||
#[default]
|
||||
All,
|
||||
ErrorsOnly,
|
||||
None,
|
||||
}
|
||||
|
||||
impl KittyResponseVerbosity {
|
||||
pub fn send_ok(&self) -> bool {
|
||||
matches!(self, KittyResponseVerbosity::All)
|
||||
}
|
||||
|
||||
pub fn send_error(&self) -> bool {
|
||||
matches!(self, KittyResponseVerbosity::All)
|
||||
|| matches!(self, KittyResponseVerbosity::ErrorsOnly)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
pub enum CursorMovementPolicy {
|
||||
#[default]
|
||||
MoveCursor,
|
||||
DoNotMoveCursor,
|
||||
}
|
||||
|
||||
fn parse_kitty_control_data(control_data: &[u8]) -> KittyControlData {
|
||||
let params: Vec<&[u8]> = control_data.split(|&byte| byte == b',').collect();
|
||||
|
||||
let mut parsed_control_data = KittyControlData::default();
|
||||
for param in params {
|
||||
let (key, value) = match param.iter().position(|&byte| byte == b'=') {
|
||||
Some(position) => (¶m[..position], ¶m[position + 1..]),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match key {
|
||||
b"f" => {
|
||||
parsed_control_data.pixel_data_format = match value {
|
||||
b"24" => KittyPixelDataFormat::Rgb24Bit,
|
||||
b"32" => KittyPixelDataFormat::Rgba32Bit,
|
||||
b"100" => KittyPixelDataFormat::Png,
|
||||
_ => KittyPixelDataFormat::default(),
|
||||
};
|
||||
}
|
||||
b"s" => {
|
||||
if let Some(value) = parse_u32(value) {
|
||||
parsed_control_data.width = value;
|
||||
}
|
||||
}
|
||||
b"v" => {
|
||||
if let Some(value) = parse_u32(value) {
|
||||
parsed_control_data.height = value;
|
||||
}
|
||||
}
|
||||
b"o" => {
|
||||
parsed_control_data.compressed = value == b"z";
|
||||
}
|
||||
b"m" => {
|
||||
parsed_control_data.further_chunks = value == b"1";
|
||||
}
|
||||
b"t" => {
|
||||
parsed_control_data.transmission_medium = match value {
|
||||
b"d" => KittyTransmissionMedium::Direct,
|
||||
b"f" => KittyTransmissionMedium::SimpleFile,
|
||||
b"t" => KittyTransmissionMedium::TemporaryFile,
|
||||
b"s" => KittyTransmissionMedium::SharedMemoryObject,
|
||||
_ => KittyTransmissionMedium::default(),
|
||||
}
|
||||
}
|
||||
b"i" => {
|
||||
if let Some(value) = parse_u32(value) {
|
||||
parsed_control_data.image_id = Some(value);
|
||||
}
|
||||
}
|
||||
b"p" => {
|
||||
if let Some(value) = parse_u32(value) {
|
||||
parsed_control_data.placement_id = Some(value);
|
||||
}
|
||||
}
|
||||
b"a" => {
|
||||
parsed_control_data.placement_action = match value {
|
||||
b"t" => KittyPlacementAction::StoreOnly,
|
||||
b"T" => KittyPlacementAction::StoreAndDisplay,
|
||||
b"p" => KittyPlacementAction::DisplayStoredImage,
|
||||
b"q" => KittyPlacementAction::QuerySupport,
|
||||
b"d" => KittyPlacementAction::Delete,
|
||||
_ => KittyPlacementAction::Unknown,
|
||||
}
|
||||
}
|
||||
b"q" => {
|
||||
parsed_control_data.verbosity = match value {
|
||||
b"0" => KittyResponseVerbosity::All,
|
||||
b"1" => KittyResponseVerbosity::ErrorsOnly,
|
||||
b"2" => KittyResponseVerbosity::None,
|
||||
_ => KittyResponseVerbosity::default(),
|
||||
}
|
||||
}
|
||||
b"z" => {
|
||||
if let Some(value) = parse_i32(value) {
|
||||
parsed_control_data.z_index = value;
|
||||
}
|
||||
}
|
||||
b"d" => {
|
||||
if value.iter().all(|x| x.is_ascii_uppercase()) {
|
||||
parsed_control_data.delete_placements_only = false;
|
||||
}
|
||||
|
||||
parsed_control_data.delete_action = match &value.to_ascii_lowercase()[..] {
|
||||
// Note: Kitty Protocol specifies "a" as "Delete all placements on screen",
|
||||
// we're just starting with DeleteAll placements to expedite the launch.
|
||||
b"a" => DeleteAction::DeleteAll,
|
||||
b"i" => DeleteAction::DeleteById,
|
||||
_ => DeleteAction::default(),
|
||||
}
|
||||
}
|
||||
b"c" => {
|
||||
if let Some(value) = parse_u32(value) {
|
||||
parsed_control_data.cols = Some(value);
|
||||
}
|
||||
}
|
||||
b"r" => {
|
||||
if let Some(value) = parse_u32(value) {
|
||||
parsed_control_data.rows = Some(value);
|
||||
}
|
||||
}
|
||||
b"C" => {
|
||||
parsed_control_data.cursor_movement_policy = match value {
|
||||
b"0" => CursorMovementPolicy::MoveCursor,
|
||||
b"1" => CursorMovementPolicy::DoNotMoveCursor,
|
||||
_ => CursorMovementPolicy::default(),
|
||||
}
|
||||
}
|
||||
b"U" => {
|
||||
parsed_control_data.unicode_placeholder = value == b"1";
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
parsed_control_data
|
||||
}
|
||||
|
||||
pub fn parse_kitty_chunk(chunk: Vec<u8>) -> KittyChunk {
|
||||
let (control_data, payload) = match chunk.iter().position(|&byte| byte == b';') {
|
||||
Some(position) => (&chunk[..position], &chunk[position + 1..]),
|
||||
None => (&chunk[..], &vec![][..]),
|
||||
};
|
||||
|
||||
let control_data = parse_kitty_control_data(control_data);
|
||||
|
||||
KittyChunk {
|
||||
control_data,
|
||||
payload: payload.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn read_file(decoded_payload: Vec<u8>, is_temp: bool) -> Result<Vec<u8>, InvalidKittyPayload> {
|
||||
let path = match str::from_utf8(&decoded_payload[..]) {
|
||||
Ok(path) => path,
|
||||
Err(err) => return Err(KittyDecodeError::InvalidUtf8(err.to_string()).into()),
|
||||
};
|
||||
|
||||
let data = match fs::read(path) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
return Err(InvalidKittyPayload::FileError(FileError::FileReadError(
|
||||
err.to_string(),
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
if is_temp {
|
||||
safe_delete_temp_file(path);
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn safe_delete_temp_file(path: &str) {
|
||||
if is_path_in_temp_dir(path) && path.contains("tty-graphics-protocol") {
|
||||
if let Err(err) = fs::remove_file(path) {
|
||||
log::error!("Failed to delete kitty temporary file (path = {path}): {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn is_path_in_temp_dir(path: &str) -> bool {
|
||||
let temp_dirs = vec!["/tmp", "/var/tmp", "/dev/shm"];
|
||||
|
||||
for temp_dir in temp_dirs {
|
||||
if path.starts_with(temp_dir) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(temp_dir) = env::var("TMPDIR") {
|
||||
if path.starts_with(&temp_dir) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "local_fs", unix))]
|
||||
fn read_shared_memory(
|
||||
control_data: KittyControlData,
|
||||
decoded_payload: Vec<u8>,
|
||||
) -> Result<Vec<u8>, InvalidKittyPayload> {
|
||||
use nix::sys::mman::{shm_open, shm_unlink};
|
||||
|
||||
let path = match str::from_utf8(&decoded_payload[..]) {
|
||||
Ok(path) => path,
|
||||
Err(err) => return Err(KittyDecodeError::InvalidUtf8(err.to_string()).into()),
|
||||
};
|
||||
|
||||
let fd = match shm_open(
|
||||
path,
|
||||
nix::fcntl::OFlag::O_RDONLY,
|
||||
nix::sys::stat::Mode::empty(),
|
||||
) {
|
||||
Ok(fd) => fd,
|
||||
Err(err) => {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::ObjectOpenError(
|
||||
err.to_string(),
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let bytes_per_pixel = match control_data.pixel_data_format {
|
||||
KittyPixelDataFormat::Rgb24Bit => Some(3),
|
||||
KittyPixelDataFormat::Rgba32Bit => Some(4),
|
||||
KittyPixelDataFormat::Png => None,
|
||||
};
|
||||
|
||||
let size = bytes_per_pixel.map(|bytes_per_pixel| {
|
||||
(bytes_per_pixel * control_data.width * control_data.height) as usize
|
||||
});
|
||||
|
||||
let data = read_from_shared_memory_fd(fd, size);
|
||||
|
||||
if let Err(err) = shm_unlink(path) {
|
||||
log::warn!("Failed to unlink kitty shm file (path = {path}): {err:?}");
|
||||
};
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "local_fs", unix))]
|
||||
fn read_from_shared_memory_fd(
|
||||
fd: i32,
|
||||
size: Option<usize>,
|
||||
) -> Result<Vec<u8>, InvalidKittyPayload> {
|
||||
use nix::sys::{
|
||||
mman::{mmap, MapFlags, ProtFlags},
|
||||
stat::fstat,
|
||||
};
|
||||
use std::num::NonZero;
|
||||
|
||||
let file_size = match fstat(fd) {
|
||||
Ok(stat) => stat.st_size,
|
||||
Err(err) => {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::FileStatError(
|
||||
err.to_string(),
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(file_size) = usize::try_from(file_size) else {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::InvalidObjectSize));
|
||||
};
|
||||
|
||||
let size = size.unwrap_or(file_size);
|
||||
|
||||
if file_size < size {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::ObjectTooSmall {
|
||||
expected_bytes: size,
|
||||
actual_bytes: file_size,
|
||||
}));
|
||||
}
|
||||
|
||||
let Some(size) = NonZero::new(size) else {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::EmptyObject));
|
||||
};
|
||||
|
||||
let ptr = unsafe {
|
||||
mmap(
|
||||
None,
|
||||
size,
|
||||
ProtFlags::PROT_READ,
|
||||
MapFlags::MAP_SHARED,
|
||||
fd,
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
let Ok(ptr) = ptr else {
|
||||
return Err(InvalidKittyPayload::ShmError(ShmError::MmapError));
|
||||
};
|
||||
|
||||
let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, size.into()) };
|
||||
let data = slice.to_vec();
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn base64_decode_padding_agnostic(data: &[u8]) -> Result<Vec<u8>, KittyDecodeError> {
|
||||
if let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
|
||||
return Ok(decoded_bytes);
|
||||
}
|
||||
|
||||
match base64::engine::general_purpose::STANDARD_NO_PAD.decode(data) {
|
||||
Ok(decoded_bytes) => Ok(decoded_bytes),
|
||||
Err(err) => Err(KittyDecodeError::InvalidBase64(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_kitty_image_data(
|
||||
payload: Vec<u8>,
|
||||
compressed: bool,
|
||||
) -> Result<Vec<u8>, KittyDecodeError> {
|
||||
let mut result = payload;
|
||||
|
||||
if compressed {
|
||||
let mut decoder = ZlibDecoder::new(&result[..]);
|
||||
let mut decompresed_bytes = vec![];
|
||||
match decoder.read_to_end(&mut decompresed_bytes) {
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(KittyDecodeError::InvalidCompression(err.to_string())),
|
||||
};
|
||||
result = decompresed_bytes;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_kitty_png_size(mut image: KittyImage) -> Result<KittyImage, KittyPngError> {
|
||||
let image_type = match ImageType::try_from_bytes(&image.data[..]) {
|
||||
Ok(image_type) => image_type,
|
||||
Err(err) => return Err(KittyPngError::InvalidBytes(err.to_string())),
|
||||
};
|
||||
let image_size = match image_type.image_size() {
|
||||
Some(image_size) => image_size,
|
||||
None => {
|
||||
return Err(KittyPngError::InvalidBytes(
|
||||
"Could not retrieve image size from ImageType for Kitty PNG.".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
image.metadata.image_size = image_size.to_f32();
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
pub fn set_kitty_rgb_headers(mut image: KittyImage) -> Result<KittyImage, KittyRgbError> {
|
||||
let image_format = match image.metadata.pixel_data_format {
|
||||
KittyPixelDataFormat::Rgb24Bit => CustomImageFormat::Rgb,
|
||||
KittyPixelDataFormat::Rgba32Bit => CustomImageFormat::Rgba,
|
||||
KittyPixelDataFormat::Png => return Ok(image),
|
||||
};
|
||||
|
||||
image.data = CustomImageHeader::prepend_custom_header(
|
||||
image.data,
|
||||
image.metadata.image_size.x() as u32,
|
||||
image.metadata.image_size.y() as u32,
|
||||
image_format,
|
||||
)?;
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn create_kitty_reply(image_id: u32, message: String) -> Vec<u8> {
|
||||
[
|
||||
C1::APC,
|
||||
b"G",
|
||||
format!("i={image_id};{message}").as_bytes(),
|
||||
C1::ST,
|
||||
]
|
||||
.concat()
|
||||
}
|
||||
|
||||
pub fn create_kitty_ok_reply(image_id: u32) -> Vec<u8> {
|
||||
create_kitty_reply(image_id, "OK".to_string())
|
||||
}
|
||||
|
||||
pub fn create_kitty_error_reply(image_id: u32, err: KittyError) -> Vec<u8> {
|
||||
create_kitty_reply(image_id, format!("{err:?}"))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
pub use grid::GridStorage;
|
||||
pub use terminal_model::TerminalModel;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_export]
|
||||
macro_rules! assert_lines_approx_eq {
|
||||
($actual:expr, $expected:expr) => {{
|
||||
float_cmp::assert_approx_eq!(
|
||||
warpui::units::Lines,
|
||||
$actual,
|
||||
warpui::units::IntoLines::into_lines($expected)
|
||||
)
|
||||
}};
|
||||
}
|
||||
|
||||
pub mod alt_screen;
|
||||
pub mod ansi;
|
||||
pub mod block;
|
||||
pub mod blockgrid;
|
||||
pub mod blocks;
|
||||
pub mod bootstrap;
|
||||
pub mod completions;
|
||||
pub mod header_grid;
|
||||
pub mod rich_content;
|
||||
pub mod tmux;
|
||||
|
||||
pub mod early_output;
|
||||
pub mod find;
|
||||
pub mod grid;
|
||||
pub mod image_map;
|
||||
pub mod index;
|
||||
pub mod iterm_image;
|
||||
pub mod kitty;
|
||||
pub mod secrets;
|
||||
pub mod selection;
|
||||
pub mod session;
|
||||
pub mod terminal_model;
|
||||
#[cfg(test)]
|
||||
pub mod test_utils;
|
||||
|
||||
pub use secrets::{
|
||||
set_user_and_enterprise_secret_regexes, ObfuscateSecrets, RespectObfuscatedSecrets, Secret,
|
||||
SecretHandle,
|
||||
};
|
||||
pub use warp_terminal::model::{char_or_str, escape_sequences, grid::cell, mouse, BlockId};
|
||||
@@ -0,0 +1,26 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
/// Because it's hard to identify the contents of rich content blocks,
|
||||
/// we register unique identifiers to make it easier to identify them.
|
||||
pub enum RichContentType {
|
||||
AIBlock,
|
||||
EnterAgentView,
|
||||
WarpifySuccessBlock,
|
||||
InlineAgentViewHeader,
|
||||
AgentViewZeroState,
|
||||
TerminalViewZeroState,
|
||||
PluginInstructionsBlock,
|
||||
}
|
||||
|
||||
impl RichContentType {
|
||||
pub fn is_ai_block(&self) -> bool {
|
||||
matches!(self, Self::AIBlock)
|
||||
}
|
||||
|
||||
pub fn is_agent_view_block(&self) -> bool {
|
||||
matches!(self, Self::EnterAgentView)
|
||||
}
|
||||
|
||||
pub fn is_inline_agent_view_header(&self) -> bool {
|
||||
matches!(self, Self::InlineAgentViewHeader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::ai::blocklist::TextLocation;
|
||||
use crate::terminal::model::index::Point;
|
||||
use anyhow::anyhow;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use rangemap::{RangeInclusiveMap, StepLite};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::{Not, RangeInclusive};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use warpui::elements::SecretRange;
|
||||
use warpui::EntityId;
|
||||
|
||||
use super::grid::grid_handler::GridHandler;
|
||||
use super::grid::{Dimensions as _, RespectDisplayedOutput};
|
||||
use super::terminal_model::RangeInModel;
|
||||
use crate::terminal::model::find::RegexDFAs;
|
||||
|
||||
/// Tracks counts to infer which regex patterns correspond to which secret levels
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegexLevelMetadata {
|
||||
/// Number of enterprise regex patterns (they are added first)
|
||||
pub enterprise_count: usize,
|
||||
/// Number of user regex patterns (they are added after enterprise patterns)
|
||||
pub user_count: usize,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Used for secret redaction in the Grid.
|
||||
/// Initially empty - will be populated with user-defined regexes when safe mode is enabled.
|
||||
pub(in crate::terminal::model) static ref SECRETS_DFA: RwLock<RegexDFAs> = RwLock::new(
|
||||
RegexDFAs::new_many(&[], true, true)
|
||||
.expect("should be able to construct empty regex DFA")
|
||||
);
|
||||
/// Used for secret redaction in simple text strings (e.g.: rich content blocks).
|
||||
/// Initially empty - will be populated with user-defined regexes when safe mode is enabled.
|
||||
pub static ref SECRETS_REGEX: RwLock<regex_automata::meta::Regex> = RwLock::new(
|
||||
regex_automata::meta::Regex::new_many(&[] as &[&str])
|
||||
.expect("should be able to construct empty regex")
|
||||
);
|
||||
/// Tracks counts to infer which regex patterns correspond to which secret levels
|
||||
pub static ref REGEX_LEVEL_METADATA: RwLock<RegexLevelMetadata> = RwLock::new(
|
||||
RegexLevelMetadata {
|
||||
enterprise_count: 0,
|
||||
user_count: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd)]
|
||||
/// A handle to a [`Secret`].
|
||||
pub struct SecretHandle(usize);
|
||||
|
||||
impl SecretHandle {
|
||||
pub(super) fn next() -> Self {
|
||||
static SECRET_HANDLE: AtomicUsize = AtomicUsize::new(0);
|
||||
let next = SECRET_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
SecretHandle(next)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> String {
|
||||
format!("{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RichContentSecretTooltipInfo {
|
||||
pub secret: String,
|
||||
pub secret_range: SecretRange,
|
||||
pub location: TextLocation,
|
||||
pub is_obfuscated: bool,
|
||||
pub position_id: String,
|
||||
pub view_id: EntityId,
|
||||
pub secret_level: SecretLevel,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum IsObfuscated {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
/// Represents the level/source of a secret redaction rule
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SecretLevel {
|
||||
/// User-defined custom secret patterns
|
||||
User,
|
||||
/// Enterprise/organization-defined secret patterns
|
||||
Enterprise,
|
||||
}
|
||||
|
||||
impl SecretLevel {
|
||||
/// Returns true if this is an enterprise level secret
|
||||
pub fn is_enterprise(self) -> bool {
|
||||
matches!(self, SecretLevel::Enterprise)
|
||||
}
|
||||
|
||||
/// Returns true if this is a user level secret
|
||||
pub fn is_user(self) -> bool {
|
||||
matches!(self, SecretLevel::User)
|
||||
}
|
||||
|
||||
/// Returns the priority of the secret level. Enterprise has highest priority.
|
||||
pub fn priority(self) -> u8 {
|
||||
match self {
|
||||
SecretLevel::User => 0,
|
||||
SecretLevel::Enterprise => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not to respect obfuscated secrets when retrieving grid contents.
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
pub enum RespectObfuscatedSecrets {
|
||||
No,
|
||||
Yes,
|
||||
}
|
||||
|
||||
/// Whether or not to obfuscate secrets during grid and tooltip rendering, respecting the Safe Mode setting.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub enum ObfuscateSecrets {
|
||||
// Identify and visually obfuscate secrets
|
||||
Yes,
|
||||
/// Do not visually obfuscate secrets, but highlight them with a strikethrough
|
||||
Strikethrough,
|
||||
/// Show secrets with normal styling but still detect them for interaction (no visual treatment)
|
||||
AlwaysShow,
|
||||
#[default]
|
||||
No,
|
||||
}
|
||||
|
||||
impl Not for ObfuscateSecrets {
|
||||
type Output = Self;
|
||||
|
||||
fn not(self) -> Self::Output {
|
||||
match self {
|
||||
ObfuscateSecrets::Yes => ObfuscateSecrets::No,
|
||||
ObfuscateSecrets::No => ObfuscateSecrets::Yes,
|
||||
ObfuscateSecrets::Strikethrough => ObfuscateSecrets::Yes,
|
||||
ObfuscateSecrets::AlwaysShow => ObfuscateSecrets::Yes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObfuscateSecrets {
|
||||
/// Returns the "stronger" obfuscation mode. Priority: Yes > Strikethrough > AlwaysShow > No
|
||||
pub fn and(&self, other: &ObfuscateSecrets) -> ObfuscateSecrets {
|
||||
match (self, other) {
|
||||
(ObfuscateSecrets::Yes, _) | (_, ObfuscateSecrets::Yes) => ObfuscateSecrets::Yes,
|
||||
(ObfuscateSecrets::Strikethrough, _) | (_, ObfuscateSecrets::Strikethrough) => {
|
||||
ObfuscateSecrets::Strikethrough
|
||||
}
|
||||
(ObfuscateSecrets::AlwaysShow, _) | (_, ObfuscateSecrets::AlwaysShow) => {
|
||||
ObfuscateSecrets::AlwaysShow
|
||||
}
|
||||
(ObfuscateSecrets::No, ObfuscateSecrets::No) => ObfuscateSecrets::No,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the secret should be redacted given the current safe mode settings.
|
||||
/// This includes obfuscation, strikethrough, and always show (for interaction purposes).
|
||||
pub fn should_redact_secret(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ObfuscateSecrets::Yes | ObfuscateSecrets::Strikethrough | ObfuscateSecrets::AlwaysShow
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether the current obfuscation mode is `ObfuscateSecrets::Yes`
|
||||
pub fn is_visually_obfuscated(&self) -> bool {
|
||||
matches!(self, ObfuscateSecrets::Yes)
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret (API key, password, etc) contained within the grid.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Secret {
|
||||
/// Whether the secret is currently obfuscated.
|
||||
is_obfuscated: IsObfuscated,
|
||||
range: RangeInclusive<Point>,
|
||||
/// The level/source of this secret's redaction rule
|
||||
secret_level: SecretLevel,
|
||||
}
|
||||
|
||||
impl RangeInModel for &Secret {
|
||||
fn range(&self) -> RangeInclusive<Point> {
|
||||
self.range.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl RangeInModel for &mut Secret {
|
||||
fn range(&self) -> RangeInclusive<Point> {
|
||||
self.range.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SecretAndHandle<'a> = (SecretHandle, &'a Secret);
|
||||
|
||||
impl Secret {
|
||||
pub(super) fn set_is_obfuscated(&mut self, is_obfuscated: IsObfuscated) {
|
||||
self.is_obfuscated = is_obfuscated
|
||||
}
|
||||
|
||||
pub fn is_obfuscated(&self) -> bool {
|
||||
matches!(self.is_obfuscated, IsObfuscated::Yes)
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
is_obfuscated: IsObfuscated,
|
||||
range: RangeInclusive<Point>,
|
||||
secret_level: SecretLevel,
|
||||
) -> Self {
|
||||
Self {
|
||||
is_obfuscated,
|
||||
range,
|
||||
secret_level,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret_level(&self) -> SecretLevel {
|
||||
self.secret_level
|
||||
}
|
||||
}
|
||||
|
||||
/// Map that is responsible for storing secrets indexed by both [`SecretHandle`] and `Range`.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub(in crate::terminal::model) struct SecretMap {
|
||||
/// Mapping of secrets stored within the grid, keyed on the secret's [`SecretHandle`].
|
||||
secrets: HashMap<SecretHandle, Secret>,
|
||||
/// Mapping of secrets keyed on the range of the secret.
|
||||
secret_ranges: RangeInclusiveMap<RangeMapPoint, SecretHandle>,
|
||||
}
|
||||
|
||||
impl SecretMap {
|
||||
/// Insert a [`Secret`] identified by `handle` into the map.
|
||||
pub fn insert(&mut self, handle: SecretHandle, secret: Secret, num_columns: usize) {
|
||||
let secret_range = secret.range.clone();
|
||||
let range_point_range = RangeMapPoint::new(*secret_range.start(), num_columns)
|
||||
..=RangeMapPoint::new(*secret_range.end(), num_columns);
|
||||
self.secret_ranges.insert(range_point_range, handle);
|
||||
self.secrets.insert(handle, secret);
|
||||
}
|
||||
|
||||
/// Removes a [`Secret`] identified by `handle` from the map.
|
||||
pub fn remove(&mut self, handle: SecretHandle, num_columns: usize) {
|
||||
let removed = self.secrets.remove(&handle);
|
||||
if let Some(secret) = removed {
|
||||
let range = RangeMapPoint::new(*secret.range.start(), num_columns)
|
||||
..=RangeMapPoint::new(*secret.range.end(), num_columns);
|
||||
self.secret_ranges.remove(range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`Secret`] identified by [`SecretHandle`] or `None` if no such secret exists.
|
||||
pub fn get_by_handle(&self, handle: &SecretHandle) -> Option<&Secret> {
|
||||
self.secrets.get(handle)
|
||||
}
|
||||
|
||||
/// Returns the [`Secret`] and its corresponding [`SecretHandle`] contained at the current
|
||||
/// [`Point`]. Returns `None` if there is no secret at the given point.
|
||||
pub fn get_by_point(
|
||||
&self,
|
||||
point: Point,
|
||||
grid: &GridHandler,
|
||||
respect_displayed_output: RespectDisplayedOutput,
|
||||
) -> Option<SecretAndHandle<'_>> {
|
||||
let original_point = if grid.has_displayed_output()
|
||||
&& matches!(respect_displayed_output, RespectDisplayedOutput::Yes)
|
||||
{
|
||||
grid.maybe_translate_point_from_displayed_to_original(point)
|
||||
} else {
|
||||
point
|
||||
};
|
||||
let point_with_metadata = RangeMapPoint::new(original_point, grid.columns());
|
||||
let handle = self.secret_ranges.get(&point_with_metadata).copied();
|
||||
|
||||
handle.zip(handle.and_then(|h| self.get_by_handle(&h)))
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&SecretHandle, &Secret)> {
|
||||
self.secrets.iter()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn ranges(&self) -> impl Iterator<Item = (RangeInclusive<Point>, &SecretHandle)> {
|
||||
self.secret_ranges
|
||||
.iter()
|
||||
.map(|(range, handle)| (range.start().as_point()..=range.end().as_point(), handle))
|
||||
}
|
||||
|
||||
/// Clears all secrets within the map.
|
||||
pub fn clear(&mut self) {
|
||||
self.secrets.clear();
|
||||
self.secret_ranges.clear();
|
||||
}
|
||||
|
||||
/// Marks the secret identified by `handle` as obfuscated. Returns an `Err` if no secret is
|
||||
/// identified by the `handle`.
|
||||
pub fn set_is_obfuscated(
|
||||
&mut self,
|
||||
handle: &SecretHandle,
|
||||
is_obfuscated: IsObfuscated,
|
||||
) -> anyhow::Result<()> {
|
||||
let secret = self
|
||||
.secrets
|
||||
.get_mut(handle)
|
||||
.ok_or_else(|| anyhow!("No secret identified by provided SecretHandle"))?;
|
||||
secret.is_obfuscated = is_obfuscated;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.secrets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.secrets.is_empty()
|
||||
}
|
||||
|
||||
/// Clears all of the secret ranges. Should be called after the resize of a grid since ranges
|
||||
/// are not stable across resizes.
|
||||
pub fn clear_ranges_after_resize(&mut self) {
|
||||
self.secret_ranges.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates secret scanning to separate user-defined regexes from enterprise ones.
|
||||
/// Ensures enterprise secrets are handled differently, maintaining separation from user settings.
|
||||
/// If the internal [`RegexDFAs`] can't be constructed from the new regexes for any reason,
|
||||
/// the current DFA is kept unchanged.
|
||||
pub fn set_user_and_enterprise_secret_regexes<'a>(
|
||||
user_secrets: impl IntoIterator<Item = &'a regex::Regex>,
|
||||
enterprise_secrets: impl IntoIterator<Item = &'a regex::Regex>,
|
||||
) {
|
||||
let mut secrets = SECRETS_DFA.write();
|
||||
|
||||
// Collect enterprise and user secrets into vectors to count them
|
||||
let enterprise_secrets_vec: Vec<&'a regex::Regex> = enterprise_secrets.into_iter().collect();
|
||||
let user_secrets_vec: Vec<&'a regex::Regex> = user_secrets.into_iter().collect();
|
||||
|
||||
// Dedup user regex entries against enterprise regexes to improve performance
|
||||
let mut seen_patterns: std::collections::HashSet<&str> =
|
||||
enterprise_secrets_vec.iter().map(|r| r.as_str()).collect();
|
||||
|
||||
let filtered_user_secrets_vec: Vec<&'a regex::Regex> = user_secrets_vec
|
||||
.into_iter()
|
||||
.filter(|r| seen_patterns.insert(r.as_str()))
|
||||
.collect();
|
||||
|
||||
// Combine all secrets additively: enterprise first (highest priority), then filtered user
|
||||
let all_secrets = enterprise_secrets_vec
|
||||
.iter()
|
||||
.map(|regex| regex.as_str())
|
||||
.chain(filtered_user_secrets_vec.iter().map(|regex| regex.as_str()))
|
||||
.collect_vec();
|
||||
|
||||
// Update the metadata counts first to ensure it's ready when the new regexes are set
|
||||
let mut metadata = REGEX_LEVEL_METADATA.write();
|
||||
metadata.enterprise_count = enterprise_secrets_vec.len();
|
||||
metadata.user_count = filtered_user_secrets_vec.len();
|
||||
|
||||
let mut secrets_regex = SECRETS_REGEX.write();
|
||||
match regex_automata::meta::Regex::new_many(&all_secrets) {
|
||||
Ok(regex) => {
|
||||
*secrets_regex = regex;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to construct new Regex with combined secrets: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
match RegexDFAs::new_many(&all_secrets, true, true) {
|
||||
Ok(dfa) => {
|
||||
*secrets = dfa;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to construct new RegexDFA with combined secrets: {err:?}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// A wrapper around a [`Point`] that implements [`StepLite`], allowing us to store it in a
|
||||
/// `RangeMap`. Used for secret redaction so we efficiently map from a given range to an underlying
|
||||
/// secret stored at that range.
|
||||
#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
|
||||
struct RangeMapPoint {
|
||||
point: Point,
|
||||
num_cols: usize,
|
||||
}
|
||||
|
||||
impl RangeMapPoint {
|
||||
fn new(point: Point, num_cols: usize) -> Self {
|
||||
Self { point, num_cols }
|
||||
}
|
||||
|
||||
fn as_point(&self) -> Point {
|
||||
self.point
|
||||
}
|
||||
}
|
||||
|
||||
impl StepLite for RangeMapPoint {
|
||||
fn add_one(&self) -> Self {
|
||||
let mut new_point = self.point;
|
||||
new_point.col += 1;
|
||||
if new_point.col >= self.num_cols {
|
||||
new_point.col = 0;
|
||||
new_point.row += 1;
|
||||
}
|
||||
|
||||
RangeMapPoint {
|
||||
point: new_point,
|
||||
num_cols: self.num_cols,
|
||||
}
|
||||
}
|
||||
|
||||
fn sub_one(&self) -> Self {
|
||||
let mut new_point = self.point;
|
||||
if new_point.col == 0 {
|
||||
if new_point.row == 0 {
|
||||
return *self;
|
||||
}
|
||||
new_point.row -= 1;
|
||||
new_point.col = self.num_cols - 1;
|
||||
} else {
|
||||
new_point.col -= 1;
|
||||
}
|
||||
|
||||
RangeMapPoint {
|
||||
point: new_point,
|
||||
num_cols: self.num_cols,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod regexes {
|
||||
use crate::settings::RegexDisplayInfo;
|
||||
|
||||
/// A default regex pattern with its descriptive name
|
||||
pub struct DefaultRegex {
|
||||
pub pattern: &'static str,
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
impl RegexDisplayInfo for DefaultRegex {
|
||||
fn pattern(&self) -> &str {
|
||||
self.pattern
|
||||
}
|
||||
|
||||
fn name(&self) -> Option<&str> {
|
||||
Some(self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl RegexDisplayInfo for &DefaultRegex {
|
||||
fn pattern(&self) -> &str {
|
||||
self.pattern
|
||||
}
|
||||
|
||||
fn name(&self) -> Option<&str> {
|
||||
Some(self.name)
|
||||
}
|
||||
}
|
||||
/// Identifies an IPv4 address. Source: <https://stackoverflow.com/questions/5284147/validating-ipv4-addresses-with-regexp>.
|
||||
pub const IPV4_ADDRESS: &str = r"\b((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}\b";
|
||||
|
||||
/// Identifies an IPv6 address. Source: <https://regex101.com/library/aL7tV3?orderBy=RELEVANCE&search=ip>
|
||||
pub const IPV6_ADDRESS: &str =
|
||||
r"\b((([0-9A-Fa-f]{1,4}:){1,6}:)|(([0-9A-Fa-f]{1,4}:){7}))([0-9A-Fa-f]{1,4})\b";
|
||||
|
||||
/// Identifies a phone number. Source: <https://stackoverflow.com/questions/16699007/regular-expression-to-match-standard-10-digit-phone-number>.
|
||||
/// NOTE: This does not match 10 digit unformatted numbers (e.g. 1234567890) because it would trigger many false positive matches.
|
||||
pub const PHONE_NUMBER: &str = r"\b(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b";
|
||||
|
||||
/// Identifies a MAC Address. Source: <https://stackoverflow.com/questions/4260467/what-is-a-regular-expression-for-a-mac-address>.
|
||||
pub const MAC_ADDRESS: &str =
|
||||
r"\b((([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))|(([a-zA-z0-9]{2}:){5}([a-zA-z0-9]{2})))\b";
|
||||
|
||||
/// Identifies a Google API Key. Source: <https://github.com/odomojuli/RegExAPI>.
|
||||
pub const GOOGLE_API_KEY: &str = r"\bAIza[0-9A-Za-z-_]{35}\b";
|
||||
|
||||
/// Identifies an OpenAI API Key.
|
||||
/// Source: <https://platform.openai.com/account/api-keys>
|
||||
pub const OPENAI_API_KEY: &str = r"\bsk-[a-zA-Z0-9]{48}\b";
|
||||
|
||||
/// Identifies an Anthropic API Key. Supports current and possible future formats,
|
||||
/// such as sk-ant-api03-... with variable-length body including alphanumerics and hyphens.
|
||||
/// Based on current observed format lengths (~96 chars), but allows 80–120 as buffer.
|
||||
pub const ANTHROPIC_API_KEY: &str = r"\bsk-ant-api\d{0,2}-[a-zA-Z0-9\-]{80,120}\b";
|
||||
|
||||
/// Identifies a general `sk-` style API key (e.g., OpenAI, Anthropic).
|
||||
/// Accepts a wide range of formats with alphanumeric and hyphen characters,
|
||||
/// with a length buffer between 10–100 characters.
|
||||
///
|
||||
/// Used in case providers update their API key format.
|
||||
pub const GENERIC_SK_API_KEY: &str = r"\bsk-[a-zA-Z0-9\-]{10,100}\b";
|
||||
|
||||
/// Identifies a Fireworks API Key. Format: fw_ followed by 24 alphanumeric characters.
|
||||
pub const FIREWORKS_API_KEY: &str = r"\bfw_[a-zA-Z0-9]{24}\b";
|
||||
|
||||
/// Identifies an AWS Access ID.
|
||||
pub const AWS_ACCESS_ID: &str =
|
||||
r"\b(AKIA|A3T|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{12,}\b";
|
||||
|
||||
/// Identifies a Slack app token.
|
||||
pub const SLACK_APP_TOKEN: &str = r"\bxapp-[0-9]+-[A-Za-z0-9_]+-[0-9]+-[a-f0-9]+\b";
|
||||
|
||||
/// The following identify github tokens. Source: <https://github.com/odomojuli/RegExAPI>
|
||||
/// and source of `[A-Za-z0-9_]` character set is <https://github.blog/changelog/2021-03-31-authentication-token-format-updates-are-generally-available/>
|
||||
pub const GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN: &str = r"\bghp_[A-Za-z0-9_]{36}\b";
|
||||
pub const GITHUB_FINE_GRAINED_PERSONAL_ACCESS_TOKEN: &str = r"\bgithub_pat_[A-Za-z0-9_]{82}\b";
|
||||
pub const GITHUB_OAUTH_ACCESS_TOKEN: &str = r"\bgho_[A-Za-z0-9_]{36}\b";
|
||||
pub const GITHUB_USER_TO_SERVER_TOKEN: &str = r"\bghu_[A-Za-z0-9_]{36}\b";
|
||||
pub const GITHUB_SERVER_TO_SERVER_TOKEN: &str = r"\bghs_[A-Za-z0-9_]{36}\b";
|
||||
|
||||
/// Identifies Stripe API Keys. Source: <https://github.com/l4yton/RegHex#stripe-api-key>
|
||||
pub const STRIPE_KEY: &str = r"\b(?:r|s)k_(test|live)_[0-9a-zA-Z]{24}\b";
|
||||
|
||||
/// Identifies a Firebase Auth Domain.
|
||||
pub const FIREBASE_AUTH_DOMAIN: &str = r"\b([a-z0-9-]){1,30}(\.firebaseapp\.com)\b";
|
||||
|
||||
/// Identifies a JSON web token (JWT). Source: <https://en.wikipedia.org/wiki/JSON_Web_Token>
|
||||
/// "ey" is the beginning of the patterns for the header and claims b/c that is:
|
||||
/// echo -n '{"' | base64
|
||||
/// We know those sections are JSON and should begin with '{"'.
|
||||
pub const JWT: &str = r"\b(ey[a-zA-z0-9_\-=]{10,}\.){2}[a-zA-z0-9_\-=]{10,}\b";
|
||||
|
||||
/// Identifies a Warp API Key. Format: wk- followed by a version number and any combination of hex digits, hyphens, or periods.
|
||||
pub const WARP_API_KEY: &str = r"\bwk-[0-9]+\.[A-Fa-f0-9.\-]+\b";
|
||||
|
||||
/// Returns a slice of regex strings that can be used to identify secrets.
|
||||
// NOTE: All regexes added here must also be added server-side in logic/ai/util.go.
|
||||
pub const DEFAULT_REGEXES_WITH_NAMES: &[DefaultRegex] = &[
|
||||
DefaultRegex {
|
||||
pattern: IPV4_ADDRESS,
|
||||
name: "IPv4 Address",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: IPV6_ADDRESS,
|
||||
name: "IPv6 Address",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: PHONE_NUMBER,
|
||||
name: "Phone Number",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: MAC_ADDRESS,
|
||||
name: "MAC Address",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GOOGLE_API_KEY,
|
||||
name: "Google API Key",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: AWS_ACCESS_ID,
|
||||
name: "AWS Access ID",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: SLACK_APP_TOKEN,
|
||||
name: "Slack App Token",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN,
|
||||
name: "GitHub Classic Personal Access Token",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GITHUB_FINE_GRAINED_PERSONAL_ACCESS_TOKEN,
|
||||
name: "GitHub Fine-Grained Personal Access Token",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GITHUB_OAUTH_ACCESS_TOKEN,
|
||||
name: "GitHub OAuth Access Token",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GITHUB_USER_TO_SERVER_TOKEN,
|
||||
name: "GitHub User-to-Server Token",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GITHUB_SERVER_TO_SERVER_TOKEN,
|
||||
name: "GitHub Server-to-Server Token",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: STRIPE_KEY,
|
||||
name: "Stripe Key",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: FIREBASE_AUTH_DOMAIN,
|
||||
name: "Firebase Auth Domain",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: JWT,
|
||||
name: "JWT",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: OPENAI_API_KEY,
|
||||
name: "OpenAI API Key",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: ANTHROPIC_API_KEY,
|
||||
name: "Anthropic API Key",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: GENERIC_SK_API_KEY,
|
||||
name: "Generic SK API Key",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: FIREWORKS_API_KEY,
|
||||
name: "Fireworks API Key",
|
||||
},
|
||||
DefaultRegex {
|
||||
pattern: WARP_API_KEY,
|
||||
name: "Warp API Key",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "secrets_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,156 @@
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_firebase_domain() {
|
||||
let firebase_domain = "warp-server-staging.firebaseapp.com";
|
||||
assert_regex_match_found(regexes::FIREBASE_AUTH_DOMAIN, firebase_domain);
|
||||
|
||||
let bad_firebase_domain = "warp-server-staging_.firebaseapp.com";
|
||||
assert_regex_match_not_found(regexes::FIREBASE_AUTH_DOMAIN, bad_firebase_domain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stripe_api_key() {
|
||||
let stripe_api_key = "sk_live_4eC39HqLyjWDarjtT1zdp7dc";
|
||||
let stripe_restricted_api_key = "rk_live_4eC39HqLyjWDarjtT1zdp7dc";
|
||||
let stripe_api_test_key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc";
|
||||
assert_regex_match_found(regexes::STRIPE_KEY, stripe_api_key);
|
||||
assert_regex_match_found(regexes::STRIPE_KEY, stripe_restricted_api_key);
|
||||
assert_regex_match_found(regexes::STRIPE_KEY, stripe_api_test_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_tokens() {
|
||||
let classic_personal_token = "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";
|
||||
let fine_grained_personal_access_token = "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";
|
||||
let gh_oauth_token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
assert_regex_match_found(
|
||||
regexes::GITHUB_CLASSIC_PERSONAL_ACCESS_TOKEN,
|
||||
classic_personal_token,
|
||||
);
|
||||
assert_regex_match_found(
|
||||
regexes::GITHUB_FINE_GRAINED_PERSONAL_ACCESS_TOKEN,
|
||||
fine_grained_personal_access_token,
|
||||
);
|
||||
assert_regex_match_found(regexes::GITHUB_OAUTH_ACCESS_TOKEN, gh_oauth_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phone_number() {
|
||||
let phone_number_dashes = "123-456-7890";
|
||||
let phone_number_default_format = "(123) 456-7890";
|
||||
let phone_number_spaces = "123 456 7890";
|
||||
let phone_number_dots = "123.456.7890";
|
||||
let international_number = "+91 (123) 456-7890";
|
||||
let phone_number_in_sentence = "Phone number: 123-456-7890.";
|
||||
assert_regex_match_found(regexes::PHONE_NUMBER, phone_number_dashes);
|
||||
assert_regex_match_found(regexes::PHONE_NUMBER, phone_number_default_format);
|
||||
assert_regex_match_found(regexes::PHONE_NUMBER, phone_number_spaces);
|
||||
assert_regex_match_found(regexes::PHONE_NUMBER, phone_number_dots);
|
||||
assert_regex_match_found(regexes::PHONE_NUMBER, international_number);
|
||||
assert_regex_match_found(regexes::PHONE_NUMBER, phone_number_in_sentence);
|
||||
|
||||
let unformatted_phone_number = "1234567890";
|
||||
// We don't want to match on unformatted phone numbers, since obfuscating
|
||||
// every 10 digit number will lead to false positives.
|
||||
assert_regex_match_not_found(regexes::PHONE_NUMBER, unformatted_phone_number);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_google_api_key() {
|
||||
let google_api_key = "AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe";
|
||||
assert_regex_match_found(regexes::GOOGLE_API_KEY, google_api_key);
|
||||
}
|
||||
|
||||
// Note all of these keys are fake and made up for testing purposes.
|
||||
#[test]
|
||||
fn test_openai_api_key() {
|
||||
let openai_key = "sk-5q3faKUz5GMaR8gX2Mf0u1dKgD9u9svG1cECxAaFbYGHZcJG";
|
||||
assert_regex_match_found(regexes::OPENAI_API_KEY, openai_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_api_key() {
|
||||
let anthropic_key = "sk-ant-api03-E0eqRhui1BHGVEkx4aGj8RF6St9vjgGfUhAmwOkOGpDXxTm1qLJfBWT87iWlwapOWeD882FNZfqQLWn-dUnuwQ-hIhDiBBB";
|
||||
assert_regex_match_found(regexes::ANTHROPIC_API_KEY, anthropic_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_sk_api_key() {
|
||||
let generic_sk_key = "sk-prod-super-secret-key-XYZ123abc-987654";
|
||||
assert_regex_match_found(regexes::GENERIC_SK_API_KEY, generic_sk_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fireworks_api_key() {
|
||||
let fireworks_key = "fw_6AM3Dbkj3aiKcTe6fMabW4Zx";
|
||||
assert_regex_match_found(regexes::FIREWORKS_API_KEY, fireworks_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mac_address() {
|
||||
let mac_addr_dashes = "00-B0-D0-63-C2-26";
|
||||
let mac_addr_colons = "c6:2c:99:54:5f:ef";
|
||||
assert_regex_match_found(regexes::MAC_ADDRESS, mac_addr_dashes);
|
||||
assert_regex_match_found(regexes::MAC_ADDRESS, mac_addr_colons);
|
||||
|
||||
let unformatted_mac_addr = "00B0D063C226";
|
||||
assert_regex_match_not_found(regexes::MAC_ADDRESS, unformatted_mac_addr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aws_access_id() {
|
||||
let amazon_credentials = r#"{
|
||||
"Credentials": {
|
||||
"AccessKeyId": "ASIAZRUF5DH7SYC4A3NF",
|
||||
}
|
||||
}"#;
|
||||
assert_regex_match_found(regexes::AWS_ACCESS_ID, amazon_credentials);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_address() {
|
||||
let ipv4_address = "192.0.2.1";
|
||||
let ivp6_address = "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
|
||||
let false_ip_address = "Grid::scan_dirty_cells_for_secrets";
|
||||
let double_colon = "::";
|
||||
let ip_in_sentence = "Could not connect to 143.63.215.9";
|
||||
let warp_version_number = "v0.2023.08.01.08.04.dev_00";
|
||||
assert_regex_match_found(regexes::IPV4_ADDRESS, ipv4_address);
|
||||
assert_regex_match_found(regexes::IPV4_ADDRESS, ip_in_sentence);
|
||||
assert_regex_match_not_found(regexes::IPV4_ADDRESS, ivp6_address);
|
||||
assert_regex_match_not_found(regexes::IPV4_ADDRESS, warp_version_number);
|
||||
|
||||
assert_regex_match_found(regexes::IPV6_ADDRESS, ivp6_address);
|
||||
assert_regex_match_not_found(regexes::IPV6_ADDRESS, ipv4_address);
|
||||
assert_regex_match_not_found(regexes::IPV6_ADDRESS, false_ip_address);
|
||||
assert_regex_match_not_found(regexes::IPV6_ADDRESS, double_colon);
|
||||
}
|
||||
|
||||
fn assert_regex_match_found(given_regex: &str, match_string: &str) {
|
||||
let test_regex = Regex::new(given_regex).expect("Test regex did not compile");
|
||||
assert!(test_regex.is_match(match_string));
|
||||
}
|
||||
|
||||
fn assert_regex_match_not_found(given_regex: &str, match_string: &str) {
|
||||
let test_regex = Regex::new(given_regex).expect("Test regex did not compile");
|
||||
assert!(!test_regex.is_match(match_string));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt() {
|
||||
let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2FycC1zZXJ2ZXItc3RhZ2luZyIsImVtYWlsIjoiYW5keUB3YXJwLmRldiJ9.py4ujtlMjUOs5FpM8c9cpA-bwc8-VQTlhnWDBcjHrmkZwkPtOGN0Sel3EKLjFaC3YlgGMOfgyC80q5f6XG_uuQ8b";
|
||||
let missing_header = "eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2FycC1zZXJ2ZXItc3RhZ2luZyIsImVtYWlsIjoiYW5keUB3YXJwLmRldiJ9.py4ujtlMjUOs5FpM8c9cpA-bwc8-VQTlhnWDBcjHrmkZwkPtOGN0Sel3EKLjFaC3YlgGMOfgyC80q5f6XG_uuQ8b";
|
||||
let missing_claims = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.py4ujtlMjUOs5FpM8c9cpA-bwc8-VQTlhnWDBcjHrmkZwkPtOGN0Sel3EKLjFaC3YlgGMOfgyC80q5f6XG_uuQ8b";
|
||||
let missing_signature = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2FycC1zZXJ2ZXItc3RhZ2luZyIsImVtYWlsIjoiYW5keUB3YXJwLmRldiJ9";
|
||||
let missing_periods = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2FycC1zZXJ2ZXItc3RhZ2luZyIsImVtYWlsIjoiYW5keUB3YXJwLmRldiJ9py4ujtlMjUOs5FpM8c9cpA-bwc8-VQTlhnWDBcjHrmkZwkPtOGN0Sel3EKLjFaC3YlgGMOfgyC80q5f6XG_uuQ8b";
|
||||
let not_a_jwt = "noblenumbat24.jammyjellyfish22.focalfossa20";
|
||||
assert_regex_match_found(regexes::JWT, jwt);
|
||||
assert_regex_match_not_found(regexes::JWT, missing_header);
|
||||
assert_regex_match_not_found(regexes::JWT, missing_claims);
|
||||
assert_regex_match_not_found(regexes::JWT, missing_signature);
|
||||
assert_regex_match_not_found(regexes::JWT, missing_periods);
|
||||
assert_regex_match_not_found(regexes::JWT, not_a_jwt);
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
//! State management for a selection in the grid.
|
||||
//!
|
||||
//! A selection should start when the mouse is clicked, and it should be
|
||||
//! finalized when the button is released. The selection should be cleared
|
||||
//! when text is added/removed/scrolled on the screen. The selection should
|
||||
//! also be cleared if the user clicks off of the selection.
|
||||
use std::fmt::Debug;
|
||||
use vec1::Vec1;
|
||||
use warp_terminal::model::grid::cell;
|
||||
|
||||
use crate::terminal::model::ansi::CursorShape;
|
||||
use crate::terminal::model::cell::Flags;
|
||||
use crate::terminal::model::grid::grid_handler::GridHandler;
|
||||
use crate::terminal::model::grid::Dimensions;
|
||||
use crate::terminal::model::index::{Point, Side};
|
||||
use crate::terminal::model::GridStorage;
|
||||
use crate::terminal::Vector2F;
|
||||
use std::cmp::{max, min};
|
||||
use std::mem;
|
||||
use std::ops::RangeInclusive;
|
||||
pub use std::ops::{Range, RangeBounds};
|
||||
use warp_core::semantic_selection::SemanticSelection;
|
||||
use warpui::text::SelectionType;
|
||||
use warpui::units::Lines;
|
||||
|
||||
use super::index::{Direction, VisibleRow};
|
||||
|
||||
/// A Point and side within that point.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
struct Anchor {
|
||||
point: Point,
|
||||
side: Side,
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
fn new(point: Point, side: Side) -> Anchor {
|
||||
Anchor { point, side }
|
||||
}
|
||||
}
|
||||
|
||||
/// An expanded selection range after processing the active selection mode.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ExpandedSelectionRange<T: Debug> {
|
||||
/// A normal text selection.
|
||||
Regular {
|
||||
/// Start of the renderable selection. This is always before end.
|
||||
start: T,
|
||||
/// End of the renderable selection. This is always after start.
|
||||
end: T,
|
||||
/// If true, start and end were reversed to make start before end, but the user actually started
|
||||
/// the selection from end and dragged toward start.
|
||||
reversed: bool,
|
||||
},
|
||||
/// A text selection in rect mode. Note that we don't keep track of whether the selection is
|
||||
/// reversed or not since extending selection by keyboard is not supported in this mode.
|
||||
Rect {
|
||||
/// Each row of the renderable rect selection, marked by its start and end point.
|
||||
rows: Vec1<(T, T)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<T: Debug> ExpandedSelectionRange<T> {
|
||||
/// Create a new regular renderable selection.
|
||||
pub fn regular(start: T, end: T, reversed: bool) -> Self {
|
||||
ExpandedSelectionRange::Regular {
|
||||
start,
|
||||
end,
|
||||
reversed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new rect renderable selection.
|
||||
pub fn rect(rows: Vec1<(T, T)>) -> Self {
|
||||
ExpandedSelectionRange::Rect { rows }
|
||||
}
|
||||
|
||||
/// The head of the selection (where the user first clicked before dragging).
|
||||
pub fn head(&self) -> &T {
|
||||
match self {
|
||||
Self::Regular {
|
||||
start,
|
||||
end,
|
||||
reversed,
|
||||
} => {
|
||||
if *reversed {
|
||||
end
|
||||
} else {
|
||||
start
|
||||
}
|
||||
}
|
||||
Self::Rect { rows } => &rows.first().0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tail of the selection (where the user most recently dragged to).
|
||||
pub fn tail(&self) -> &T {
|
||||
match self {
|
||||
Self::Regular {
|
||||
start,
|
||||
end,
|
||||
reversed,
|
||||
} => {
|
||||
if *reversed {
|
||||
start
|
||||
} else {
|
||||
end
|
||||
}
|
||||
}
|
||||
Self::Rect { rows } => &rows.last().1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self) -> &T {
|
||||
match self {
|
||||
Self::Regular { start, .. } => start,
|
||||
Self::Rect { rows } => &rows.first().0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the end of the renderable selection where start is always before end.
|
||||
pub fn end(&self) -> &T {
|
||||
match self {
|
||||
Self::Regular { end, .. } => end,
|
||||
Self::Rect { rows } => &rows.last().1,
|
||||
}
|
||||
}
|
||||
|
||||
/// If true, start and end were reversed to make start before end, but the user actually started
|
||||
/// the selection from end and dragged toward start.
|
||||
pub fn is_reversed(&self) -> bool {
|
||||
match self {
|
||||
Self::Regular { reversed, .. } => *reversed,
|
||||
Self::Rect { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a range of selected cells.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SelectionRange {
|
||||
/// Start point, top left of the selection.
|
||||
pub start: Point,
|
||||
/// End point, bottom right of the selection.
|
||||
pub end: Point,
|
||||
/// If true, the user started the selection from the end and dragged backward to the start.
|
||||
pub is_reversed: bool,
|
||||
}
|
||||
|
||||
impl SelectionRange {
|
||||
pub fn new(start: Point, end: Point, is_reversed: bool) -> Self {
|
||||
Self {
|
||||
start,
|
||||
end,
|
||||
is_reversed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a point lies within the selection.
|
||||
pub fn contains(&self, point: Point) -> bool {
|
||||
self.start.row <= point.row
|
||||
&& self.end.row >= point.row
|
||||
&& (self.start.col <= point.col || (self.start.row != point.row))
|
||||
&& (self.end.col >= point.col || (self.end.row != point.row))
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectionRange {
|
||||
/// Check if the cell at a point is part of the selection.
|
||||
pub fn contains_cell(
|
||||
&self,
|
||||
grid: &GridStorage,
|
||||
point: Point,
|
||||
cursor_point: Point,
|
||||
cursor_shape: CursorShape,
|
||||
) -> bool {
|
||||
// Do not invert block cursor at selection boundaries.
|
||||
if cursor_shape == CursorShape::Block
|
||||
&& cursor_point == point
|
||||
&& (self.start == point || self.end == point)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Point itself is selected.
|
||||
if self.contains(point) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let num_cols = grid.columns();
|
||||
|
||||
let cell = &grid[point];
|
||||
|
||||
// Check if wide char's spacers are selected.
|
||||
if cell.flags().contains(Flags::WIDE_CHAR) {
|
||||
let prev = point.wrapping_sub(num_cols, 1);
|
||||
let next = point.wrapping_add(num_cols, 1);
|
||||
|
||||
// Check trailing spacer.
|
||||
self.contains(next)
|
||||
// Check line-wrapping, leading spacer.
|
||||
|| (grid[prev].flags().contains(Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
&& self.contains(prev))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Different directions to move the selection tail.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum SelectionDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Used to share code between different kinds of points
|
||||
/// representing selection ranges.
|
||||
/// The units for row are lines in blocklist space.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SelectionPoint {
|
||||
pub row: Lines,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl PartialOrd for SelectionPoint {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for SelectionPoint {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
(self.row, self.col).cmp(&(other.row, other.col))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SelectAction<T> {
|
||||
Begin {
|
||||
point: T,
|
||||
side: Side,
|
||||
selection_type: SelectionType,
|
||||
position: Vector2F,
|
||||
},
|
||||
Update {
|
||||
point: T,
|
||||
side: Side,
|
||||
delta: Lines,
|
||||
position: Vector2F,
|
||||
},
|
||||
End,
|
||||
}
|
||||
|
||||
/// A scroll position delta, either up or down.
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum ScrollDelta {
|
||||
Up { lines: usize },
|
||||
Down { lines: usize },
|
||||
}
|
||||
|
||||
impl ScrollDelta {
|
||||
/// Technically, `Self::Down { lines: 0 }` and `Self::Up { lines: 0 }` are equivalent.
|
||||
pub fn zero() -> Self {
|
||||
Self::Down { lines: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScrollDelta {
|
||||
fn default() -> Self {
|
||||
ScrollDelta::zero()
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes a region of a 2-dimensional area.
|
||||
///
|
||||
/// Used to track a text selection. There are four supported modes, each with its own constructor:
|
||||
/// [`simple`], [`rect`], [`semantic`], and [`lines`]. The [`simple`] mode precisely tracks which
|
||||
/// cells are selected without any expansion. [`rect`] will select rectangular regions.
|
||||
/// [`semantic`] mode expands the initial selection to the nearest semantic escape char in either
|
||||
/// direction. [`lines`] will always select entire lines.
|
||||
///
|
||||
/// Calls to [`update`] operate different based on the selection kind. The [`simple`] and [`block`]
|
||||
/// mode do nothing special, simply track points and sides. [`semantic`] will continue to expand
|
||||
/// out to semantic boundaries as the selection point changes. Similarly, [`lines`] will always
|
||||
/// expand the new point to encompass entire lines.
|
||||
///
|
||||
/// [`simple`]: enum.Selection.html#method.simple
|
||||
/// [`rect`]: enum.Selection.html#method.block
|
||||
/// [`semantic`]: enum.Selection.html#method.semantic
|
||||
/// [`lines`]: enum.Selection.html#method.rows
|
||||
/// [`update`]: enum.Selection.html#method.update
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Selection {
|
||||
pub ty: SelectionType,
|
||||
region: Range<Anchor>,
|
||||
smart_select_side: Option<Direction>,
|
||||
smart_select_override: Option<RangeInclusive<Point>>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn new(ty: SelectionType, location: Point, side: Side) -> Selection {
|
||||
Self {
|
||||
region: Range {
|
||||
start: Anchor::new(location, side),
|
||||
end: Anchor::new(location, side),
|
||||
},
|
||||
ty,
|
||||
smart_select_side: None,
|
||||
smart_select_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_smart_select_side(&mut self, smart_select_side: Direction) {
|
||||
self.smart_select_side = Some(smart_select_side);
|
||||
}
|
||||
|
||||
pub fn set_smart_select_override(&mut self, smart_select_override: RangeInclusive<Point>) {
|
||||
self.smart_select_override = Some(smart_select_override);
|
||||
}
|
||||
|
||||
/// Update the end of the selection.
|
||||
pub fn update(&mut self, point: Point, side: Side) {
|
||||
self.region.end = Anchor::new(point, side);
|
||||
}
|
||||
|
||||
pub fn is_tail_before_head(&self) -> bool {
|
||||
Self::points_need_swap(self.region.start.point, self.region.end.point)
|
||||
}
|
||||
|
||||
pub fn rotate(
|
||||
mut self,
|
||||
range: &Range<VisibleRow>,
|
||||
delta: ScrollDelta,
|
||||
end_column: usize,
|
||||
) -> Option<Selection> {
|
||||
let range_bottom = range.end.0;
|
||||
let range_top = range.start.0;
|
||||
|
||||
let (mut start, mut end) = (&mut self.region.start, &mut self.region.end);
|
||||
|
||||
if start.point > end.point {
|
||||
mem::swap(&mut start, &mut end);
|
||||
}
|
||||
|
||||
// Rotate start of selection.
|
||||
if (start.point.row >= range_top || range_top == 0) && start.point.row <= range_bottom {
|
||||
match delta {
|
||||
ScrollDelta::Up { lines } => {
|
||||
// When scrolling up and the top selected rows are out of the visible window.
|
||||
if start.point.row < lines + range_top {
|
||||
start.point.col = 0;
|
||||
start.side = Side::Left;
|
||||
start.point.row = range_top;
|
||||
} else {
|
||||
start.point.row -= lines;
|
||||
}
|
||||
}
|
||||
ScrollDelta::Down { lines } => {
|
||||
// If start is below the bottom of the visible window, delete selection.
|
||||
if start.point.row + lines >= range_bottom {
|
||||
return None;
|
||||
}
|
||||
|
||||
start.point.row += lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate end of selection.
|
||||
if (end.point.row >= range_top || range_top == 0) && end.point.row <= range_bottom {
|
||||
match delta {
|
||||
ScrollDelta::Up { lines } => {
|
||||
// If end is out of the top of the visible window, delete selection.
|
||||
if end.point.row < range_top + lines {
|
||||
return None;
|
||||
}
|
||||
|
||||
end.point.row = max(end.point.row - lines, 0);
|
||||
}
|
||||
ScrollDelta::Down { lines } => {
|
||||
// When scrolling down and the bottom selected rows are out of the visible window.
|
||||
if end.point.row + lines >= range_bottom {
|
||||
end.point.col = end_column - 1;
|
||||
end.side = Side::Right;
|
||||
end.point.row = range_bottom - 1;
|
||||
} else {
|
||||
end.point.row += lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(self)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self.ty {
|
||||
SelectionType::Simple | SelectionType::Rect => {
|
||||
let (mut start, mut end) = (self.region.start, self.region.end);
|
||||
if Self::points_need_swap(start.point, end.point) {
|
||||
mem::swap(&mut start, &mut end);
|
||||
}
|
||||
|
||||
// Simple selection is empty when the points are identical
|
||||
// or two adjacent cells have the sides right -> left.
|
||||
start == end
|
||||
|| (start.side == Side::Right
|
||||
&& end.side == Side::Left
|
||||
&& (start.point.row == end.point.row)
|
||||
&& start.point.col + 1 == end.point.col)
|
||||
}
|
||||
SelectionType::Semantic | SelectionType::Lines => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert selection to grid coordinates.
|
||||
pub fn to_range(
|
||||
&self,
|
||||
grid: &GridHandler,
|
||||
selection: &SemanticSelection,
|
||||
) -> Option<SelectionRange> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let num_cols = grid.columns();
|
||||
|
||||
// Order start above the end.
|
||||
let mut start = self.region.start;
|
||||
let mut end = self.region.end;
|
||||
let mut is_reversed = false;
|
||||
|
||||
if Self::points_need_swap(start.point, end.point) {
|
||||
mem::swap(&mut start, &mut end);
|
||||
is_reversed = true;
|
||||
} else if start.point == end.point && matches!(end.side, Side::Left) {
|
||||
is_reversed = true;
|
||||
}
|
||||
|
||||
// Clamp to inside the grid buffer.
|
||||
let (start, end) = Self::grid_clamp(start, end, grid.total_rows()).ok()?;
|
||||
|
||||
Some(match self.ty {
|
||||
SelectionType::Simple | SelectionType::Rect => {
|
||||
self.range_simple(start, end, num_cols, is_reversed)
|
||||
}
|
||||
SelectionType::Semantic => {
|
||||
self.range_semantic(grid, start.point, end.point, selection, is_reversed)
|
||||
}
|
||||
SelectionType::Lines => Self::range_lines(grid, start.point, end.point, is_reversed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bring start and end points in the correct order.
|
||||
fn points_need_swap(start: Point, end: Point) -> bool {
|
||||
start.row > end.row || start.row == end.row && start.col > end.col
|
||||
}
|
||||
|
||||
/// Clamp selection inside grid to prevent OOB.
|
||||
fn grid_clamp(mut start: Anchor, end: Anchor, lines: usize) -> Result<(Anchor, Anchor), ()> {
|
||||
// Clamp selection inside of grid to prevent OOB.
|
||||
if start.point.row >= lines {
|
||||
// Remove selection if it is fully out of the grid.
|
||||
if end.point.row >= lines {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
// Clamp to grid if it is still partially visible.
|
||||
start.side = Side::Left;
|
||||
start.point.col = 0;
|
||||
start.point.row = lines - 1;
|
||||
}
|
||||
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
fn range_semantic(
|
||||
&self,
|
||||
grid_handler: &GridHandler,
|
||||
mut start: Point,
|
||||
mut end: Point,
|
||||
selection: &SemanticSelection,
|
||||
is_reversed: bool,
|
||||
) -> SelectionRange {
|
||||
if start == end {
|
||||
if let Some(matching) = grid_handler.bracket_search(start) {
|
||||
if (matching.row == start.row && matching.col < start.col)
|
||||
|| (matching.row > start.row)
|
||||
{
|
||||
start = matching;
|
||||
} else {
|
||||
end = matching;
|
||||
}
|
||||
|
||||
return SelectionRange {
|
||||
start,
|
||||
end,
|
||||
is_reversed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// first, get the bounds for normal, non-smart-selection
|
||||
let mut range_start =
|
||||
grid_handler.semantic_search_left(start, |c| is_word_boundary_char(selection, c));
|
||||
let mut range_end =
|
||||
grid_handler.semantic_search_right(end, |c| is_word_boundary_char(selection, c));
|
||||
|
||||
if selection.smart_select_enabled() && self.smart_select_override.is_some() {
|
||||
let smart_select_override = self
|
||||
.smart_select_override
|
||||
.as_ref()
|
||||
.expect("already checked this is Some");
|
||||
if smart_select_override.contains(&start) || smart_select_override.contains(&end) {
|
||||
range_start = min(range_start, *smart_select_override.start());
|
||||
range_end = max(range_end, *smart_select_override.end());
|
||||
}
|
||||
} else {
|
||||
// if there is a smart-select match, only override the bounds if it has selected a LARGER
|
||||
// range than normal selection.
|
||||
match self.smart_select_side {
|
||||
Some(Direction::Left) => {
|
||||
if let Some((smart_start, smart_end)) =
|
||||
grid_handler.smart_search(start, selection)
|
||||
{
|
||||
range_start = min(range_start, smart_start);
|
||||
range_end = max(range_end, smart_end);
|
||||
}
|
||||
}
|
||||
Some(Direction::Right) => {
|
||||
if let Some((smart_start, smart_end)) =
|
||||
grid_handler.smart_search(end, selection)
|
||||
{
|
||||
range_start = min(range_start, smart_start);
|
||||
range_end = max(range_end, smart_end);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
SelectionRange {
|
||||
start: range_start,
|
||||
end: range_end,
|
||||
is_reversed,
|
||||
}
|
||||
}
|
||||
|
||||
fn range_lines(
|
||||
grid_handler: &GridHandler,
|
||||
start: Point,
|
||||
end: Point,
|
||||
is_reversed: bool,
|
||||
) -> SelectionRange {
|
||||
let start = grid_handler.maybe_translate_point_from_displayed_to_original(start);
|
||||
let end = grid_handler.maybe_translate_point_from_displayed_to_original(end);
|
||||
|
||||
let start = grid_handler.line_search_left(start);
|
||||
let end = grid_handler.line_search_right(end);
|
||||
|
||||
let start = grid_handler.maybe_translate_point_from_original_to_displayed(start);
|
||||
let end = grid_handler.maybe_translate_point_from_original_to_displayed(end);
|
||||
|
||||
SelectionRange {
|
||||
start,
|
||||
end,
|
||||
is_reversed,
|
||||
}
|
||||
}
|
||||
|
||||
fn range_simple(
|
||||
&self,
|
||||
mut start: Anchor,
|
||||
mut end: Anchor,
|
||||
num_cols: usize,
|
||||
is_reversed: bool,
|
||||
) -> SelectionRange {
|
||||
// Remove last cell if selection ends to the left of a cell.
|
||||
if end.side == Side::Left && start.point != end.point {
|
||||
// Special case when selection ends to left of first cell.
|
||||
if end.point.col == 0 {
|
||||
end.point = Point::new(end.point.row.saturating_sub(1), num_cols - 1);
|
||||
} else {
|
||||
end.point.col -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove first cell if selection starts at the right of a cell.
|
||||
if start.side == Side::Right && start.point != end.point {
|
||||
start.point.col += 1;
|
||||
|
||||
// Wrap to next line when selection starts to the right of last column.
|
||||
if start.point.col == num_cols {
|
||||
start.point = Point::new(start.point.row + 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
SelectionRange {
|
||||
start: start.point,
|
||||
end: end.point,
|
||||
is_reversed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether or not the character should be considered a word boundary
|
||||
/// character, based on the given semantic selection configuration.
|
||||
fn is_word_boundary_char(selection: &SemanticSelection, c: char) -> bool {
|
||||
if c == cell::DEFAULT_CHAR {
|
||||
return true;
|
||||
}
|
||||
selection.is_word_boundary_char(c)
|
||||
}
|
||||
|
||||
/// Tests for selection.
|
||||
///
|
||||
/// There are comments on all of the tests describing the selection. Pictograms
|
||||
/// are used to avoid ambiguity. Grid cells are represented by a [ ]. Only
|
||||
/// cells that are completely covered are counted in a selection. Ends are
|
||||
/// represented by `B` and `E` for begin and end, respectively. A selected cell
|
||||
/// looks like [XX], [BX] (at the start), [XB] (at the end), [XE] (at the end),
|
||||
/// and [EX] (at the start), or [BE] for a single cell. Partially selected cells
|
||||
/// look like [ B] and [E ].
|
||||
#[cfg(test)]
|
||||
#[path = "selection_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,357 @@
|
||||
use super::*;
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::grid::grid_handler::PerformResetGridChecks;
|
||||
use crate::terminal::model::secrets::ObfuscateSecrets;
|
||||
use crate::terminal::SizeInfo;
|
||||
|
||||
fn grid_handler(rows: usize, cols: usize) -> GridHandler {
|
||||
GridHandler::new(
|
||||
SizeInfo::new_without_font_metrics(rows, cols),
|
||||
1000, /* max_scroll_limit */
|
||||
ChannelEventListener::new_for_test(),
|
||||
false,
|
||||
ObfuscateSecrets::No,
|
||||
PerformResetGridChecks::No,
|
||||
)
|
||||
}
|
||||
|
||||
/// Test case of single cell selection.
|
||||
///
|
||||
/// 1. [ ]
|
||||
/// 2. [B ]
|
||||
/// 3. [BE]
|
||||
#[test]
|
||||
fn single_cell_left_to_right() {
|
||||
let location = Point { row: 0, col: 0 };
|
||||
let mut selection = Selection::new(SelectionType::Simple, location, Side::Left);
|
||||
selection.update(location, Side::Right);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(1, 2), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: location,
|
||||
end: location,
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Test case of single cell selection.
|
||||
///
|
||||
/// 1. [ ]
|
||||
/// 2. [ B]
|
||||
/// 3. [EB]
|
||||
#[test]
|
||||
fn single_cell_right_to_left() {
|
||||
let location = Point { row: 0, col: 0 };
|
||||
let mut selection = Selection::new(SelectionType::Simple, location, Side::Right);
|
||||
selection.update(location, Side::Left);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(1, 2), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: location,
|
||||
end: location,
|
||||
is_reversed: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Test selection across adjacent lines.
|
||||
///
|
||||
/// 1. [ ][ ][ ][ ][ ]
|
||||
/// [ ][ ][ ][ ][ ]
|
||||
/// 2. [ ][ B][ ][ ][ ]
|
||||
/// [ ][ ][ ][ ][ ]
|
||||
/// 3. [ ][ B][XX][XX][XX]
|
||||
/// [XX][XE][ ][ ][ ]
|
||||
#[test]
|
||||
fn across_adjacent_lines() {
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(0, 1), Side::Right);
|
||||
selection.update(Point::new(1, 1), Side::Right);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(2, 5), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point::new(0, 2),
|
||||
end: Point::new(1, 1),
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Test selection across adjacent lines.
|
||||
///
|
||||
/// 1. [ ][ ][ ][ ][ ]
|
||||
/// [ ][ ][ ][ ][ ]
|
||||
/// 2. [ ][ ][ ][ ][ ]
|
||||
/// [ ][ B][ ][ ][ ]
|
||||
/// 3. [ ][ E][XX][XX][XX]
|
||||
/// [XX][XB][ ][ ][ ]
|
||||
/// 4. [ E][XX][XX][XX][XX]
|
||||
/// [XX][XB][ ][ ][ ]
|
||||
#[test]
|
||||
fn selection_bigger_then_smaller() {
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(1, 1), Side::Right);
|
||||
selection.update(Point::new(0, 1), Side::Right);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(2, 5), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point::new(0, 2),
|
||||
end: Point::new(1, 1),
|
||||
is_reversed: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_is_empty() {
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(0, 0), Side::Right);
|
||||
assert!(selection.is_empty());
|
||||
selection.update(Point::new(0, 1), Side::Left);
|
||||
assert!(selection.is_empty());
|
||||
selection.update(Point::new(1, 0), Side::Right);
|
||||
assert!(!selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_max_min_column() {
|
||||
// If the selection starts on the Right side of a cell, it should skip that cell
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(0, 0), Side::Right);
|
||||
selection.update(Point::new(0, 2), Side::Right);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(3, 3), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 0, col: 1 },
|
||||
end: Point { row: 0, col: 2 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
// If the selection starts on the Right side of the last column, it should span that column
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(0, 4), Side::Right);
|
||||
selection.update(Point::new(1, 2), Side::Right);
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(5, 5), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 1, col: 0 },
|
||||
end: Point { row: 1, col: 2 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
// If the selection ends on the Left side of a cell, it should remove that cell
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(0, 0), Side::Left);
|
||||
selection.update(Point::new(2, 2), Side::Left);
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(5, 3), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 0, col: 0 },
|
||||
end: Point { row: 2, col: 1 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
// If the selection ends on the Left side of the first column, it should span that column
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(0, 0), Side::Left);
|
||||
selection.update(Point::new(2, 0), Side::Left);
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(5, 3), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 0, col: 0 },
|
||||
end: Point { row: 1, col: 2 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_rotate_up() {
|
||||
let range = VisibleRow(0)..VisibleRow(30);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(10, 3), Side::Left);
|
||||
selection.update(Point::new(20, 56), Side::Right);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Up { lines: 1 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 9, col: 3 },
|
||||
end: Point { row: 19, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Up { lines: 5 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 4, col: 3 },
|
||||
end: Point { row: 14, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
// This causes the selection range to go past the top.
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Up { lines: 5 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 0, col: 0 },
|
||||
end: Point { row: 9, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Down { lines: 1 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 1, col: 0 },
|
||||
end: Point { row: 10, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Up { lines: 6 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 0, col: 0 },
|
||||
end: Point { row: 4, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
// The end of the range passes the top, so the selection should go away.
|
||||
assert_eq!(
|
||||
selection.rotate(&range, ScrollDelta::Up { lines: 6 }, 60),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_rotate_down() {
|
||||
let range = VisibleRow(0)..VisibleRow(30);
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
let mut selection = Selection::new(SelectionType::Simple, Point::new(10, 3), Side::Left);
|
||||
selection.update(Point::new(20, 56), Side::Right);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Down { lines: 1 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 11, col: 3 },
|
||||
end: Point { row: 21, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Down { lines: 5 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 16, col: 3 },
|
||||
end: Point { row: 26, col: 56 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
// This scrolls the end of the selection range passed the bottom.
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Down { lines: 5 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 21, col: 3 },
|
||||
end: Point { row: 29, col: 59 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Up { lines: 1 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 20, col: 3 },
|
||||
end: Point { row: 28, col: 59 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
selection = selection
|
||||
.rotate(&range, ScrollDelta::Down { lines: 6 }, 60)
|
||||
.expect("should still have a selection");
|
||||
assert_eq!(
|
||||
selection
|
||||
.to_range(&grid_handler(30, 60), &semantic_selection)
|
||||
.unwrap(),
|
||||
SelectionRange {
|
||||
start: Point { row: 26, col: 3 },
|
||||
end: Point { row: 29, col: 59 },
|
||||
is_reversed: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
selection.rotate(&range, ScrollDelta::Down { lines: 6 }, 60),
|
||||
None
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
|
||||
|
||||
use crate::{
|
||||
ai_assistant::execution_context::WarpAiExecutionContext,
|
||||
terminal::{
|
||||
model::session::SessionsEvent,
|
||||
model_events::{ModelEvent, ModelEventDispatcher},
|
||||
shell::ShellType,
|
||||
ShellLaunchData,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{Session, SessionType, Sessions};
|
||||
|
||||
pub struct ActiveSession {
|
||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||
sessions: ModelHandle<Sessions>,
|
||||
|
||||
/// The current working directory of the terminal session.
|
||||
current_working_directory: Option<String>,
|
||||
}
|
||||
|
||||
impl ActiveSession {
|
||||
pub fn new(
|
||||
sessions: ModelHandle<Sessions>,
|
||||
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&model_event_dispatcher, move |me, event, ctx| {
|
||||
if let ModelEvent::BlockMetadataReceived(block_metadata_received_event) = event {
|
||||
let new_pwd = block_metadata_received_event
|
||||
.block_metadata
|
||||
.current_working_directory()
|
||||
.map(|cwd| cwd.to_owned());
|
||||
if me.current_working_directory != new_pwd {
|
||||
me.current_working_directory = new_pwd;
|
||||
ctx.emit(ActiveSessionEvent::UpdatedPwd);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&sessions, |me, event, ctx| {
|
||||
if let SessionsEvent::SessionBootstrapped(bootstrap_event) = event {
|
||||
if Some(bootstrap_event.session_id)
|
||||
== me.model_event_dispatcher.as_ref(ctx).active_session_id()
|
||||
{
|
||||
ctx.emit(ActiveSessionEvent::Bootstrapped);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
sessions,
|
||||
model_event_dispatcher,
|
||||
current_working_directory: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self, app: &AppContext) -> Option<Arc<Session>> {
|
||||
self.model_event_dispatcher
|
||||
.as_ref(app)
|
||||
.active_session_id()
|
||||
.and_then(|session_id| self.sessions.as_ref(app).get(session_id))
|
||||
}
|
||||
|
||||
pub fn session_type(&self, app: &AppContext) -> Option<SessionType> {
|
||||
self.session(app).map(|session| session.session_type())
|
||||
}
|
||||
|
||||
pub fn shell_type(&self, app: &AppContext) -> Option<ShellType> {
|
||||
self.session(app)
|
||||
.as_ref()
|
||||
.map(|session| session.shell().shell_type())
|
||||
}
|
||||
|
||||
pub fn shell_launch_data(&self, app: &AppContext) -> Option<ShellLaunchData> {
|
||||
self.session(app)
|
||||
.as_ref()
|
||||
.and_then(|session| session.launch_data().cloned())
|
||||
}
|
||||
|
||||
pub fn current_working_directory(&self) -> Option<&String> {
|
||||
self.current_working_directory.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the `WarpAiExecutionContext` for the active session.
|
||||
pub fn ai_execution_environment(&self, app: &AppContext) -> Option<WarpAiExecutionContext> {
|
||||
self.session(app).as_ref().map(WarpAiExecutionContext::new)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ActiveSessionEvent {
|
||||
/// The active session's working directory changed.
|
||||
UpdatedPwd,
|
||||
/// The active session finished bootstrapping.
|
||||
Bootstrapped,
|
||||
}
|
||||
|
||||
impl Entity for ActiveSession {
|
||||
type Event = ActiveSessionEvent;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
mod in_band_command_executor;
|
||||
#[cfg(feature = "local_tty")]
|
||||
mod local_command_executor;
|
||||
#[cfg(feature = "local_tty")]
|
||||
mod msys2_command_executor;
|
||||
mod tmux_executor;
|
||||
#[cfg(feature = "local_tty")]
|
||||
mod wsl_command_executor;
|
||||
use std::collections::HashMap;
|
||||
mod noop_command_executor;
|
||||
#[cfg(feature = "local_tty")]
|
||||
mod remote_command_executor;
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub(crate) mod remote_server_executor;
|
||||
mod shared;
|
||||
|
||||
use std::{any::Any, fmt::Debug, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_channel::{Receiver, Sender};
|
||||
use async_trait::async_trait;
|
||||
use warp_completer::completer::CommandOutput;
|
||||
use warpui::ModelContext;
|
||||
|
||||
use crate::terminal::{
|
||||
event::ExecutedExecutorCommandEvent, model::session::Sessions, shell::Shell,
|
||||
};
|
||||
|
||||
use super::SessionInfo;
|
||||
|
||||
pub use in_band_command_executor::{
|
||||
is_in_band_command, InBandCommand, InBandCommandCancelledEvent, InBandCommandExecutor,
|
||||
InBandCommandOutputReceiver,
|
||||
};
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub use local_command_executor::LocalCommandExecutor;
|
||||
pub use noop_command_executor::NoOpCommandExecutor;
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub use remote_command_executor::RemoteCommandExecutor;
|
||||
pub use shared::{shell_escape_single_quotes, ExecutorCommandEvent};
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct ExecuteCommandOptions {
|
||||
/// Whether the command must be run in the same shell as the currently running [`Session`].
|
||||
///
|
||||
/// If false, it's an implementation detail which shell the command is run within.
|
||||
/// i.e. For unix shells, the command may be run in an `sh` shell instead of `bash` or `zsh`.
|
||||
/// On Windows, commands may be run through `cmd.exe`.
|
||||
///
|
||||
/// ## Platform Support
|
||||
/// This field is currently only respected on Windows.
|
||||
pub run_command_in_same_shell_as_session: bool,
|
||||
}
|
||||
|
||||
impl Default for ExecuteCommandOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
run_command_in_same_shell_as_session: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to be implemented by structs that execute command in context that emulates or actually is
|
||||
/// identical to the active terminal session's context. `CommandExecutor` is commonly used to
|
||||
/// execute generator commands to power completions, syntax highlighting, and autosuggestions.
|
||||
#[async_trait]
|
||||
pub trait CommandExecutor: Send + Sync + Debug {
|
||||
/// Executes the given command from the given `current_directory_path` with $PATH set to
|
||||
/// `path_env_var` and with the given environment variables.
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput>;
|
||||
|
||||
/// Cancels in-progress commands.
|
||||
///
|
||||
/// Some implementations may not need to explicitly cancel command execution, so a default
|
||||
/// implementation is given.
|
||||
fn cancel_active_commands(&self) {}
|
||||
|
||||
/// Allows us to downcast an executor to check what command execution method is used by a
|
||||
/// session.
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
/// Whether the backing executor for the session supports execution of commands in parallel.
|
||||
fn supports_parallel_command_execution(&self) -> bool;
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub fn new_command_executor_for_session(
|
||||
session_info: &SessionInfo,
|
||||
executor_command_tx: &Sender<ExecutorCommandEvent>,
|
||||
in_band_command_output_rx: Receiver<ExecutedExecutorCommandEvent>,
|
||||
parent_session_info: Option<&SessionInfo>,
|
||||
ctx: &mut ModelContext<Sessions>,
|
||||
) -> Arc<dyn CommandExecutor> {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_tty")] {
|
||||
new_command_executor_for_local_tty_session(session_info, executor_command_tx, in_band_command_output_rx, parent_session_info, ctx)
|
||||
} else if #[cfg(feature = "remote_tty")]{
|
||||
new_command_executor_for_network_backed_pty(executor_command_tx, in_band_command_output_rx, ctx)
|
||||
} else {
|
||||
Arc::new(NoOpCommandExecutor::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new command executor when there is a network-backed PTY (indicated by the
|
||||
/// `remote_tty` feature).
|
||||
#[cfg(feature = "remote_tty")]
|
||||
#[cfg_attr(feature = "remote_tty", allow(dead_code))]
|
||||
fn new_command_executor_for_network_backed_pty(
|
||||
executor_command_tx: &Sender<ExecutorCommandEvent>,
|
||||
in_band_command_output_rx: Receiver<ExecutedExecutorCommandEvent>,
|
||||
ctx: &mut ModelContext<Sessions>,
|
||||
) -> Arc<dyn CommandExecutor> {
|
||||
log::info!("creating an in-band command executor!");
|
||||
let (in_band_command_cancelled_tx, in_band_command_cancelled_rx) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx.clone(),
|
||||
in_band_command_cancelled_tx.clone(),
|
||||
));
|
||||
let executor_clone = executor.clone();
|
||||
ctx.spawn_stream_local(
|
||||
in_band_command_output_rx,
|
||||
move |_, event, _| executor_clone.handle_executed_command_event(event),
|
||||
|_, _| {}, /* on_done */
|
||||
);
|
||||
let executor_clone = executor.clone();
|
||||
ctx.spawn_stream_local(
|
||||
in_band_command_cancelled_rx,
|
||||
move |_, event, _| executor_clone.handle_cancelled_in_band_command_event(event),
|
||||
|_, _| {}, /* on_done */
|
||||
);
|
||||
executor
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
fn new_command_executor_for_local_tty_session(
|
||||
session_info: &SessionInfo,
|
||||
executor_command_tx: &Sender<ExecutorCommandEvent>,
|
||||
in_band_command_output_rx: Receiver<ExecutedExecutorCommandEvent>,
|
||||
parent_session_info: Option<&SessionInfo>,
|
||||
ctx: &mut ModelContext<Sessions>,
|
||||
) -> Arc<dyn CommandExecutor> {
|
||||
use msys2_command_executor::MSYS2CommandExecutor;
|
||||
use remote_server_executor::RemoteServerCommandExecutor;
|
||||
use settings::Setting as _;
|
||||
use tmux_executor::TmuxCommandExecutor;
|
||||
use warpui::SingletonEntity as _;
|
||||
use wsl_command_executor::WslCommandExecutor;
|
||||
|
||||
use crate::{
|
||||
features::FeatureFlag,
|
||||
remote_server::manager::RemoteServerManager,
|
||||
settings::DebugSettings,
|
||||
terminal::{
|
||||
available_shells::AvailableShells,
|
||||
model::session::{BootstrapSessionType, ShellLaunchData},
|
||||
shell::ShellType,
|
||||
},
|
||||
};
|
||||
|
||||
use super::IsLegacySSHSession;
|
||||
|
||||
// When the remote server feature flag is enabled and the session is a
|
||||
// legacy SSH session, use the remote server executor *if* the manager
|
||||
// already has a live `Connected` client for this session.
|
||||
//
|
||||
// By construction this branch is only reached after
|
||||
// `ModelEventDispatcher::complete_bootstrapped_session` has gated on
|
||||
// both `Bootstrapped` and the remote-server setup result
|
||||
// (`RemoteServerReady` / `RemoteServerFailed` / skipped). So
|
||||
// `client_for_session` returning `Some` corresponds to a successful
|
||||
// setup and `None` corresponds to the skip / failure paths, where we
|
||||
// fall through to the existing ControlMaster-based
|
||||
// `RemoteCommandExecutor` below. This preserves the fallback behavior
|
||||
// described in specs/APP-3797.
|
||||
if FeatureFlag::SshRemoteServer.is_enabled() {
|
||||
if let IsLegacySSHSession::Yes { .. } = &session_info.is_legacy_ssh_session {
|
||||
let session_id = session_info.session_id;
|
||||
let maybe_client = RemoteServerManager::handle(ctx)
|
||||
.read(ctx, |mgr, _| mgr.client_for_session(session_id).cloned());
|
||||
if let Some(client) = maybe_client {
|
||||
log::info!("creating a remote server executor for session {session_id:?}");
|
||||
return Arc::new(RemoteServerCommandExecutor::new(session_id, client));
|
||||
}
|
||||
log::info!(
|
||||
"SshRemoteServer flag on but no connected client for session {session_id:?}; \
|
||||
falling back to ControlMaster executor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::SSHTmuxWrapper.is_enabled()
|
||||
&& session_info.tmux_control_mode
|
||||
// We don't allow nested tmux warpification, so if our parent session is already warified using
|
||||
// tmux then we shouldn't.
|
||||
&& !parent_session_info.is_some_and(|s| s.tmux_control_mode)
|
||||
{
|
||||
log::info!("creating a tmux executor!");
|
||||
let executor = Arc::new(TmuxCommandExecutor::new(executor_command_tx.clone()));
|
||||
let executor_clone = executor.clone();
|
||||
ctx.spawn_stream_local(
|
||||
in_band_command_output_rx,
|
||||
move |_, event, _| executor_clone.handle_executed_command_event(event),
|
||||
|_, _| {}, /* on_done */
|
||||
);
|
||||
return executor;
|
||||
}
|
||||
|
||||
let debug_settings = DebugSettings::as_ref(ctx);
|
||||
let are_in_band_generators_for_all_sessions_enabled_debug_setting = debug_settings
|
||||
.are_in_band_generators_for_all_sessions_enabled
|
||||
.value();
|
||||
let should_force_disable_in_band_generators =
|
||||
debug_settings.force_disable_in_band_generators.value();
|
||||
|
||||
let is_legacy_ssh_session = matches!(
|
||||
&session_info.is_legacy_ssh_session,
|
||||
IsLegacySSHSession::Yes { .. }
|
||||
);
|
||||
|
||||
let shell_needs_in_band_executor = session_info.shell.force_in_band_command_executor();
|
||||
// Docker sandbox sessions run commands inside the container; the host-side
|
||||
// LocalCommandExecutor has no way to reach into the sandbox, so generators
|
||||
// must go through the session's in-band executor (which rides on the live
|
||||
// PTY that is already attached to the container).
|
||||
//
|
||||
// TODO(advait): For production this should be a dedicated
|
||||
// `SandboxCommandExecutor` that runs generators via
|
||||
// `sbx exec warp-sandbox-<id> -- sh -c "<cmd>"` (analogous to
|
||||
// how `LocalCommandExecutor` spawns fresh subprocesses for the
|
||||
// host-shell path). That would avoid serializing generators
|
||||
// through the user's live PTY (slower, blocked by long-running
|
||||
// foreground commands, and has to dodge line-editor state) and
|
||||
// instead run them as fresh, parallelizable processes inside the
|
||||
// container — the same semantics host shells get today.
|
||||
let launch_data_needs_in_band_executor = matches!(
|
||||
session_info.launch_data,
|
||||
Some(ShellLaunchData::DockerSandbox { .. })
|
||||
);
|
||||
let force_use_in_band_generators = shell_needs_in_band_executor
|
||||
|| launch_data_needs_in_band_executor
|
||||
|| *are_in_band_generators_for_all_sessions_enabled_debug_setting;
|
||||
|
||||
match &session_info.session_type {
|
||||
BootstrapSessionType::Local if !force_use_in_band_generators => {
|
||||
let shell_type = session_info.shell.shell_type();
|
||||
|
||||
log::info!("creating a local executor!");
|
||||
match &session_info.launch_data {
|
||||
Some(ShellLaunchData::Executable {
|
||||
executable_path, ..
|
||||
}) => Arc::new(LocalCommandExecutor::new(
|
||||
Some(executable_path.to_owned()),
|
||||
shell_type,
|
||||
)),
|
||||
// Docker sandbox sessions should already be routed to the
|
||||
// in-band executor via `launch_data_needs_in_band_executor`
|
||||
// above, so this arm is only reached if that routing drifts
|
||||
// (e.g. a feature flag/debug setting disables it). Rather
|
||||
// than panic in user-facing code, log loudly and fall back
|
||||
// to a no-op executor so the sandbox session still runs;
|
||||
// generators won't work but the PTY stays healthy.
|
||||
Some(ShellLaunchData::DockerSandbox { .. }) => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"Docker sandbox sessions should be routed through the in-band executor"
|
||||
);
|
||||
log::error!(
|
||||
"Docker sandbox session reached the local-executor branch; \
|
||||
falling back to a no-op command executor. \
|
||||
`launch_data_needs_in_band_executor` routing may have drifted."
|
||||
);
|
||||
Arc::new(NoOpCommandExecutor::new())
|
||||
}
|
||||
Some(ShellLaunchData::MSYS2 {
|
||||
executable_path, ..
|
||||
}) => {
|
||||
let windows_native_shell_path =
|
||||
AvailableShells::handle(ctx).read(ctx, |shells, _ctx| {
|
||||
shells
|
||||
.find_known_shell_by_type(ShellType::PowerShell)
|
||||
.and_then(|powershell| powershell.get_valid_shell_path_and_type())
|
||||
.and_then(|shell_launch_data| {
|
||||
if let ShellLaunchData::Executable { executable_path, .. } = shell_launch_data {
|
||||
Some(executable_path)
|
||||
} else {
|
||||
log::warn!("Found available shell for windows-native shell but could not get executable path");
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
Arc::new(MSYS2CommandExecutor::new(
|
||||
windows_native_shell_path,
|
||||
executable_path.to_owned(),
|
||||
))
|
||||
}
|
||||
Some(ShellLaunchData::WSL { distro }) => {
|
||||
Arc::new(WslCommandExecutor::new(distro.to_owned(), shell_type))
|
||||
}
|
||||
None => {
|
||||
if let Some(wsl_name) = session_info.wsl_name() {
|
||||
Arc::new(WslCommandExecutor::new(wsl_name.to_owned(), shell_type))
|
||||
} else {
|
||||
Arc::new(LocalCommandExecutor::new(None, shell_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BootstrapSessionType::WarpifiedRemote
|
||||
if is_legacy_ssh_session
|
||||
&& !FeatureFlag::InBandGeneratorsForSSH.is_enabled()
|
||||
&& !force_use_in_band_generators =>
|
||||
{
|
||||
if let IsLegacySSHSession::Yes { socket_path } = &session_info.is_legacy_ssh_session {
|
||||
let wsl_distro = parent_session_info
|
||||
.and_then(|session| session.wsl_name())
|
||||
.map(ToOwned::to_owned);
|
||||
log::info!("creating a legacy ssh executor!");
|
||||
Arc::new(RemoteCommandExecutor::new(socket_path.clone(), wsl_distro))
|
||||
} else {
|
||||
unreachable!("Unreachable because of match! above. Unfortunately if let guards in rust are still experimental.")
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if *should_force_disable_in_band_generators {
|
||||
// The user has manually disabled in-band generators via command
|
||||
// modifying 'user defaults', so pass a no-op command executor.
|
||||
//
|
||||
// This code path exists as a fail-safe for disabling in-band
|
||||
// generators if some unforeseen severe issue surfaces during or
|
||||
// shortly after subshells launch. The setting that triggers this
|
||||
// codepath is only accessible via a user defaults command that a Warp
|
||||
// engineer would have given to the user via some first-hand
|
||||
// correspondance (e.g. GitHub issues).
|
||||
log::info!("creating a no-op executor!");
|
||||
Arc::new(NoOpCommandExecutor::new())
|
||||
} else {
|
||||
log::info!("creating an in-band command executor!");
|
||||
let (in_band_command_cancelled_tx, in_band_command_cancelled_rx) =
|
||||
async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx.clone(),
|
||||
in_band_command_cancelled_tx.clone(),
|
||||
));
|
||||
let executor_clone = executor.clone();
|
||||
ctx.spawn_stream_local(
|
||||
in_band_command_output_rx,
|
||||
move |_, event, _| executor_clone.handle_executed_command_event(event),
|
||||
|_, _| {}, /* on_done */
|
||||
);
|
||||
let executor_clone = executor.clone();
|
||||
ctx.spawn_stream_local(
|
||||
in_band_command_cancelled_rx,
|
||||
move |_, event, _| executor_clone.handle_cancelled_in_band_command_event(event),
|
||||
|_, _| {}, /* on_done */
|
||||
);
|
||||
executor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod testing {
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use command::r#async::Command;
|
||||
use warp_completer::completer::CommandOutput;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Implementation of `CommandExecutor` for use in tests. This implementation simply executes
|
||||
/// the given command in a bash subprocess.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TestCommandExecutor {}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for TestCommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
let mut command_process = Command::new(match shell.shell_type() {
|
||||
ShellType::PowerShell => "pwsh",
|
||||
_ => "bash",
|
||||
});
|
||||
|
||||
// Set environment variables, including $PATH.
|
||||
if let Some(environment_variables) = environment_variables {
|
||||
command_process.envs(&environment_variables);
|
||||
}
|
||||
|
||||
// Set the current dir, if any.
|
||||
if let Some(current_directory_path) = current_directory_path {
|
||||
command_process.current_dir(current_directory_path);
|
||||
}
|
||||
|
||||
command_process
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.into())
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
use std::any::Any;
|
||||
use std::cmp::min;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::VecDeque, fmt};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_channel::{self, Receiver, Sender};
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use parking_lot::{Mutex, MutexGuard};
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_terminal::model::Point;
|
||||
use warpui::r#async::block_on;
|
||||
|
||||
use crate::safe_info;
|
||||
use crate::server::datetime_ext::DateTimeExt;
|
||||
use crate::terminal::event::ExecutedExecutorCommandEvent;
|
||||
use crate::terminal::shell::{Shell, ShellType};
|
||||
use warp_util::on_cancel::OnCancelFutureExt;
|
||||
|
||||
use crate::terminal::model::session::command_executor::{
|
||||
shared, CommandExecutor, ExecutorCommandEvent,
|
||||
};
|
||||
use crate::terminal::SizeInfo;
|
||||
use warp_completer::completer::{CommandExitStatus, CommandOutput};
|
||||
|
||||
use super::ExecuteCommandOptions;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InBandCommand {
|
||||
pub command: String,
|
||||
pub shell_type: ShellType,
|
||||
pub command_id: String,
|
||||
}
|
||||
|
||||
pub struct InBandCommandCancelledEvent {
|
||||
pub command_id: String,
|
||||
}
|
||||
/// Information about a pending or running command, used to both trigger its execution and handle
|
||||
/// its output.
|
||||
#[derive(Debug, Clone)]
|
||||
struct CommandExecutionInfo {
|
||||
id: String,
|
||||
command: String,
|
||||
shell: Shell,
|
||||
output_tx: Option<Sender<CommandOutput>>,
|
||||
}
|
||||
|
||||
/// A `Session`-scoped executor for "in-band" commands.
|
||||
///
|
||||
/// "In-band" commands are commands that are executed _within_ the active terminal session -- that
|
||||
/// is, the terminal session that corresponds to the `pty_controller` passed to the executor as a
|
||||
/// constructor parameter.
|
||||
///
|
||||
/// Because commands are executed in the active terminal session, commands are executed serially to
|
||||
/// avoid competing command outputs from corrupting one another.
|
||||
///
|
||||
/// This can be used to run arbitrary commands in the user's active session most commonly to query
|
||||
/// the session context (e.g. files in a directory, branches in a git repo) to power features like
|
||||
/// completions and syntax highlighting.
|
||||
///
|
||||
/// For more context, see the "In-band generators" TDD: https://docs.google.com/document/d/15GO1p9WHNnDsV2Nb-O-FW4c38QpWDuIyb7wmnKxmOrE/edit?usp=sharing.
|
||||
pub struct InBandCommandExecutor {
|
||||
executor_command_tx: Sender<ExecutorCommandEvent>,
|
||||
cancel_command_tx: Sender<InBandCommandCancelledEvent>,
|
||||
running_command: Arc<Mutex<Option<CommandExecutionInfo>>>,
|
||||
pending_commands: Arc<Mutex<VecDeque<CommandExecutionInfo>>>,
|
||||
}
|
||||
|
||||
/// Output is written as if it's going to a grid, but we store it as a string. This
|
||||
/// struct manages some cursor state so we can properly handle some cursor mutations
|
||||
/// that might happen during in-band output.
|
||||
pub struct InBandCommandOutputReceiver {
|
||||
output: String,
|
||||
/// Current coordinates of the cursor.
|
||||
point: Point,
|
||||
/// Whether the cursor needs to wrap before receiving more input.
|
||||
/// This is functionally the same as input_needs_wrap on GridStorage::Cursor.
|
||||
needs_wrap: bool,
|
||||
/// The height of the grid.
|
||||
num_rows: usize,
|
||||
/// The width of the grid.
|
||||
num_cols: usize,
|
||||
}
|
||||
|
||||
/// Tracks the cursor during in-band output so we arbitrary cursor mutations
|
||||
/// during an in-band command don't corrupt the output.
|
||||
impl InBandCommandOutputReceiver {
|
||||
pub fn new(starting_location: Point, grid_size: &SizeInfo) -> Self {
|
||||
Self {
|
||||
output: Default::default(),
|
||||
point: starting_location,
|
||||
needs_wrap: false,
|
||||
num_rows: grid_size.rows,
|
||||
num_cols: grid_size.columns,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.output
|
||||
}
|
||||
|
||||
/// Add a character and advance the cursor.
|
||||
pub fn input(&mut self, c: char) {
|
||||
if self.needs_wrap {
|
||||
self.point.row = min(self.num_rows - 1, self.point.row + 1);
|
||||
self.point.col = 0;
|
||||
}
|
||||
self.output.push(c);
|
||||
if self.point.col + 1 >= self.num_cols {
|
||||
self.needs_wrap = true;
|
||||
} else {
|
||||
self.point.col += 1;
|
||||
self.needs_wrap = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor back to the specified coordinate and updates the string.
|
||||
/// Note: this doesn't support forward movements of the cursor!
|
||||
pub fn goto(&mut self, row: usize, column: usize) {
|
||||
let new_point = Point::new(row, column);
|
||||
if new_point <= self.point {
|
||||
let mut distance = self.point.distance(self.num_cols, &new_point);
|
||||
if self.needs_wrap {
|
||||
distance += 1;
|
||||
}
|
||||
self.point = self.point.wrapping_sub(self.num_cols, distance);
|
||||
self.output.truncate(self.output.len() - distance);
|
||||
} else {
|
||||
log::warn!("Moving cursor forward during in-band output, which is unhandled");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor to the beginning of the following row.
|
||||
pub fn carriage_return(&mut self) {
|
||||
self.point.row = min(self.num_rows - 1, self.point.row + 1);
|
||||
self.point.col = 0;
|
||||
self.needs_wrap = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for InBandCommandExecutor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "InBandCommandExecutor {{}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl InBandCommandExecutor {
|
||||
pub fn new(
|
||||
executor_command_tx: Sender<ExecutorCommandEvent>,
|
||||
cancel_command_tx: Sender<InBandCommandCancelledEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
executor_command_tx,
|
||||
cancel_command_tx,
|
||||
running_command: Arc::new(Mutex::new(None)),
|
||||
pending_commands: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a running command.
|
||||
///
|
||||
/// Note: this is different from `Self::cancel_command`, in that
|
||||
/// it represents an *internal* cancellation where something downstack has cancelled
|
||||
/// a command believed to be running. In this case, we remove the command from
|
||||
/// execution, but also resolve the command with a CommandResult.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub(super) fn handle_cancelled_in_band_command_event(
|
||||
&self,
|
||||
InBandCommandCancelledEvent { command_id }: InBandCommandCancelledEvent,
|
||||
) {
|
||||
{
|
||||
// Scope all of this within a block so the `running_command` MutexGuard is not in scope when we call
|
||||
// `execute_command_internal` (which also attempts to lock `running_command`).
|
||||
let mut lock = self.running_command.lock();
|
||||
let Some(cmd) = lock.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if cmd.id != command_id {
|
||||
return;
|
||||
}
|
||||
if let Some(output_tx) = cmd.output_tx.clone() {
|
||||
if !output_tx.is_closed() {
|
||||
// TODO: we should consider turning this into a Result::Err
|
||||
if let Err(error) = output_tx.try_send(CommandOutput {
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
status: CommandExitStatus::Failure,
|
||||
exit_code: None,
|
||||
}) {
|
||||
log::warn!("Error occurred when sending generator command output: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
*lock = None;
|
||||
}
|
||||
self.execute_command_internal();
|
||||
}
|
||||
|
||||
/// Parses `event` (which is parsed DCS output from the PTY) into `CommandOutput` and notifies
|
||||
/// its channel for the completed command (based on its command ID). This effectively
|
||||
/// completes the `Future` returned by `Self::execute_command()` for the command with the
|
||||
/// same command ID.
|
||||
///
|
||||
/// Afterwords, attempts to execute the next pending command, if there is any.
|
||||
pub fn handle_executed_command_event(&self, event: ExecutedExecutorCommandEvent) {
|
||||
{
|
||||
// Scope all of this within a block so the `running_command` MutexGuard is not in scope when we call
|
||||
// `execute_command_internal` (which also attempts to lock `running_command`).
|
||||
let mut current_command = self.running_command.lock();
|
||||
if let Some(cmd) = current_command.take() {
|
||||
if cmd.id == event.command_id {
|
||||
if let Some(output_tx) = cmd.output_tx {
|
||||
if !output_tx.is_closed() {
|
||||
let command_output = if event.exit_code == 0 {
|
||||
CommandOutput {
|
||||
stdout: event.output,
|
||||
stderr: vec![],
|
||||
status: CommandExitStatus::Success,
|
||||
exit_code: Some(ExitCode::from(event.exit_code as i32)),
|
||||
}
|
||||
} else {
|
||||
CommandOutput {
|
||||
stdout: vec![],
|
||||
stderr: event.output,
|
||||
status: CommandExitStatus::Failure,
|
||||
exit_code: Some(ExitCode::from(event.exit_code as i32)),
|
||||
}
|
||||
};
|
||||
if let Err(error) = output_tx.try_send(command_output) {
|
||||
log::error!(
|
||||
"Error occurred when sending generator command output: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("Cached in-band command ID {} does not match ID of executed in-band command output {}", cmd.id, &event.command_id);
|
||||
// If the command event that we received is not for the current running command,
|
||||
// we need to restore the command as the currently running command.
|
||||
*current_command = Some(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.execute_command_internal();
|
||||
}
|
||||
|
||||
fn cancel_command(&self, command_id: &str) {
|
||||
// Notify the `PTYController` that the given command should be cancelled. We use
|
||||
// `send_blocking` here because this function is called in an implementation of `Drop`,
|
||||
// which must be synchronous. The underlying `executor_command_tx` channel is unbounded, so
|
||||
// in practice we'll never actually block if the channel is full.
|
||||
if let Err(e) = block_on(self.executor_command_tx.send(
|
||||
ExecutorCommandEvent::CancelCommand {
|
||||
id: command_id.into(),
|
||||
},
|
||||
)) {
|
||||
log::warn!("Failed to cancel in band command: {e:?}");
|
||||
}
|
||||
|
||||
// Clear the running command if it corresponds to the command we want to cancel.
|
||||
let cmd_is_running = self
|
||||
.running_command
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|item| item.id == command_id);
|
||||
|
||||
if cmd_is_running {
|
||||
self.running_command.lock().take();
|
||||
}
|
||||
|
||||
// Remove the command from the set of pending commands.
|
||||
self.pending_commands
|
||||
.lock()
|
||||
.retain(|item| item.id != command_id);
|
||||
|
||||
// If the current command was removed, we need to restart
|
||||
// the execute_command loop to queue up another command
|
||||
if cmd_is_running {
|
||||
self.execute_command_internal();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `command` with `command_id` to the pending commands queue, and then attempts to
|
||||
/// execute it.
|
||||
fn enqueue_command(
|
||||
&self,
|
||||
command_id: &str,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
) -> Result<Receiver<CommandOutput>> {
|
||||
let (output_channel_tx, output_channel_rx) = async_channel::unbounded::<CommandOutput>();
|
||||
self.pending_commands
|
||||
.lock()
|
||||
.push_back(CommandExecutionInfo {
|
||||
id: command_id.to_owned(),
|
||||
command: command.to_owned(),
|
||||
shell: shell.clone(),
|
||||
output_tx: Some(output_channel_tx),
|
||||
});
|
||||
self.execute_command_internal();
|
||||
Ok(output_channel_rx)
|
||||
}
|
||||
|
||||
/// If no command is currently running, pops a command from the pending_commands queue and
|
||||
/// executes it. If a command is running, does nothing.
|
||||
///
|
||||
/// Prior to writing the command bytes, writes the "kill buffer bytes" for the appropriate
|
||||
/// shell type (which clears the input buffer) to ensure the command is executed as written.
|
||||
fn execute_command_internal(&self) {
|
||||
let mut running_command = self.running_command.lock();
|
||||
if running_command.is_none() {
|
||||
let mut pending_commands = self.pending_commands.lock();
|
||||
*running_command = pending_commands.pop_front();
|
||||
if let Some(CommandExecutionInfo {
|
||||
id, command, shell, ..
|
||||
}) = running_command.as_ref()
|
||||
{
|
||||
safe_info!(
|
||||
safe: ("Running in-band command {id}"),
|
||||
full: ("Running in-band command {id}: {command}")
|
||||
);
|
||||
|
||||
// Because we wrap the command in single quotes in the string sent to the pty,
|
||||
// escape the single quotes in a valid way given the session's shell type.
|
||||
let escaped_command =
|
||||
shared::shell_escape_single_quotes(command, shell.shell_type());
|
||||
|
||||
let in_band_command = match shell.shell_type() {
|
||||
ShellType::PowerShell => {
|
||||
format!("Warp-Run-GeneratorCommand {id} '{escaped_command}' -ErrorAction Ignore")
|
||||
}
|
||||
ShellType::Fish => {
|
||||
// Add a leading space for in-band commands in fish, which omits them from
|
||||
// history. Unlike bash and zsh, fish does not have a mechanism for
|
||||
// specifying command patterns to be omitted from history. Ignoring
|
||||
// commands with a leading space is default, non-configurable behavior in
|
||||
// fish.
|
||||
format!(" warp_run_generator_command {id} '{escaped_command}'")
|
||||
}
|
||||
_ => {
|
||||
format!("warp_run_generator_command {id} '{escaped_command}'")
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
self.executor_command_tx
|
||||
.try_send(ExecutorCommandEvent::ExecuteCommand {
|
||||
command: InBandCommand {
|
||||
command: in_band_command,
|
||||
shell_type: shell.shell_type(),
|
||||
command_id: id.into(),
|
||||
},
|
||||
cancel_tx: self.cancel_command_tx.clone(),
|
||||
})
|
||||
{
|
||||
log::warn!("Failed to send InBandCommand to pty_controller: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the `CommandExecutionInfo` from the given `running_command` and `pending_commands`.
|
||||
///
|
||||
/// This method exists just to unify the cancellation logic in `execute_command_internal()` and
|
||||
/// the public implementation of `cancel_active_commands`. `execute_command_internal()` cannot
|
||||
/// call `self.cancel_active_commands()` directly; this would cause a deadlock on
|
||||
/// `self.running_command`.
|
||||
fn cancel_active_commands(
|
||||
running_command: &mut MutexGuard<Option<CommandExecutionInfo>>,
|
||||
pending_commands: &mut MutexGuard<VecDeque<CommandExecutionInfo>>,
|
||||
) {
|
||||
**running_command = None;
|
||||
pending_commands.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for InBandCommandExecutor {
|
||||
/// Executes `command` as an 'in-band' command in the active terminal session corresponding to
|
||||
/// the `pty_controller` passed to this executor during construction.
|
||||
///
|
||||
/// The given `command` is executed in the active session using the
|
||||
/// `warp_run_generator_command`/`Warp-Run-GeneratorCommand` shell script API that is declared as
|
||||
/// part of Warp's bootstrap script.
|
||||
///
|
||||
/// Internally, `command` is added to a queue of commands to be executed serially (this is to
|
||||
/// avoid output from multiple commands corrupting one another since the pty is a single
|
||||
/// channel). If there are no commands in the queue, `command` is written to the pty immediately.
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
_current_directory_path: Option<&str>,
|
||||
_environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
let command_id = DateTime::now().timestamp_micros().to_string();
|
||||
|
||||
// If the future is aborted (via a call to `AbortHandle#abort`) we need to make sure to
|
||||
// remove the command from the in-band generator pending command queue to ensure that
|
||||
// commands aren't executed when they are eventually popped off the queue. We do this by
|
||||
// attaching a custom `on_cancel` hook to the future that cancels execution of the command
|
||||
// if the future was cancelled before it was ready.
|
||||
let future = async {
|
||||
let output_channel_rx = self.enqueue_command(command_id.as_str(), command, shell)?;
|
||||
output_channel_rx.recv().await.map_err(anyhow::Error::from)
|
||||
}
|
||||
.on_cancel(|| self.cancel_command(command_id.as_str()));
|
||||
|
||||
future.await
|
||||
}
|
||||
|
||||
/// "Cancels" active in-band commands.
|
||||
///
|
||||
/// In reality, this does not cancel command execution (that is, however, actually done on the
|
||||
/// shell side in `warp_preexec`). This merely clears the running and pending command IDs from
|
||||
/// the executor's, such that subsequently calling `handle_executed_command_event` with
|
||||
/// a cleared command ID is a no-op.
|
||||
fn cancel_active_commands(&self) {
|
||||
Self::cancel_active_commands(
|
||||
&mut self.running_command.lock(),
|
||||
&mut self.pending_commands.lock(),
|
||||
);
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `command` is an in-band command string, e.g. a command executed via
|
||||
/// `InBandCommandExecutor`.
|
||||
///
|
||||
/// In-band commands are prefixed with a leading space for Fish, which is done to omit them from
|
||||
/// fish's command history. Thus we strip leading whitespace before matching the `command`.
|
||||
pub fn is_in_band_command(command: &str) -> bool {
|
||||
let trimmed = command.trim_start();
|
||||
trimmed.starts_with("Warp-Run-GeneratorCommand ")
|
||||
|| trimmed.starts_with("warp_run_generator_command ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "in_band_command_executor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,541 @@
|
||||
use futures_util::future::{AbortHandle, Abortable, Aborted};
|
||||
use warpui::App;
|
||||
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl InBandCommandExecutor {
|
||||
/// Returns an `Option` containing the ID of the actively running command.
|
||||
pub fn running_command_id(&self) -> Option<String> {
|
||||
self.running_command
|
||||
.lock()
|
||||
.as_ref()
|
||||
.map(|command_info| command_info.id.clone())
|
||||
}
|
||||
|
||||
/// Returns a `HashSet` containing IDs of all pending commands (i.e. commands that are queued
|
||||
/// for execution but not executing yet).
|
||||
pub fn pending_command_ids(&self) -> Vec<String> {
|
||||
Vec::from_iter(
|
||||
self.pending_commands
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|command_info| &command_info.id)
|
||||
.cloned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a closure that asserts the given `Result<CommandOutput>` is `Ok(..)` and contains
|
||||
/// `CommandOutput` with the given `expected_output` and `expected_success` values.
|
||||
fn assert_command_output_result_fn(
|
||||
expected_output: &'static str,
|
||||
expected_success: bool,
|
||||
) -> impl FnOnce(Result<CommandOutput>) {
|
||||
move |result| {
|
||||
assert!(result.is_ok());
|
||||
|
||||
let output = result.unwrap();
|
||||
assert_eq!(output.success(), expected_success);
|
||||
assert_eq!(output.output(), expected_output.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_test_command<F>(
|
||||
executor: Arc<InBandCommandExecutor>,
|
||||
command: &'static str,
|
||||
assert_result_fn: F,
|
||||
) where
|
||||
F: FnOnce(Result<CommandOutput>) + Send + 'static,
|
||||
{
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
let test_command_result = executor
|
||||
.execute_command(
|
||||
command,
|
||||
&shell,
|
||||
/*current_directory_path=*/ None,
|
||||
/*environment_variables=*/ None,
|
||||
ExecuteCommandOptions::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_result_fn(test_command_result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emits_successful_command_output() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
// TODO(zachbai): Figure out how to make these tests work without
|
||||
// requiring a single-threaded executor (e.g.: running on
|
||||
// app.background_executor()).
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
assert_command_output_result_fn("foo", true),
|
||||
));
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
let pending_command_ids = executor.pending_command_ids();
|
||||
assert!(pending_command_ids.is_empty());
|
||||
|
||||
let test_command_id = executor
|
||||
.running_command_id()
|
||||
.expect("Executor should be running test command.");
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: test_command_id,
|
||||
exit_code: 0,
|
||||
output: "foo".as_bytes().to_vec(),
|
||||
});
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
execute_command_future.await;
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emits_error_command_output() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
// TODO(zachbai): Figure out how to make these tests work without
|
||||
// requiring a single-threaded executor (e.g.: running on
|
||||
// app.background_executor()).
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo bar",
|
||||
assert_command_output_result_fn("failed!", false),
|
||||
));
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
let pending_command_ids = executor.pending_command_ids();
|
||||
assert!(pending_command_ids.is_empty());
|
||||
|
||||
let test_command_id = executor
|
||||
.running_command_id()
|
||||
.expect("Executor should be running test command.");
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: test_command_id,
|
||||
exit_code: 1,
|
||||
output: "failed!".as_bytes().to_vec(),
|
||||
});
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
execute_command_future.await;
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runs_commands_serially() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
// TODO(zachbai): Figure out how to make these tests work without
|
||||
// requiring a single-threaded executor (e.g.: running on
|
||||
// app.background_executor()).
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let first_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
assert_command_output_result_fn("foo", true),
|
||||
));
|
||||
let second_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo bar",
|
||||
assert_command_output_result_fn("bar", true),
|
||||
));
|
||||
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
// "echo bar" is pending.
|
||||
let pending_command_ids = executor.pending_command_ids();
|
||||
assert_eq!(pending_command_ids.len(), 1);
|
||||
|
||||
// "echo foo" is running.
|
||||
let test_command_id = executor
|
||||
.running_command_id()
|
||||
.expect("Executor should be running test command.");
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: test_command_id,
|
||||
exit_code: 0,
|
||||
output: "foo".as_bytes().to_vec(),
|
||||
});
|
||||
|
||||
// "echo bar" is now running.
|
||||
assert_eq!(
|
||||
pending_command_ids[0],
|
||||
executor.running_command_id().unwrap()
|
||||
);
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: pending_command_ids[0].clone(),
|
||||
exit_code: 0,
|
||||
output: "bar".as_bytes().to_vec(),
|
||||
});
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
first_execute_command_future.await;
|
||||
second_execute_command_future.await;
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runs_commands_serially_after_failed_command() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
// TODO(zachbai): Figure out how to make these tests work without
|
||||
// requiring a single-threaded executor (e.g.: running on
|
||||
// app.background_executor()).
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let first_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
assert_command_output_result_fn("foo", true),
|
||||
));
|
||||
let second_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo bar",
|
||||
assert_command_output_result_fn("bar", false),
|
||||
));
|
||||
let third_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo baz",
|
||||
assert_command_output_result_fn("baz", true),
|
||||
));
|
||||
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
// "echo bar" is pending.
|
||||
let pending_command_ids = executor.pending_command_ids();
|
||||
assert_eq!(pending_command_ids.len(), 2);
|
||||
|
||||
// "echo foo" is running.
|
||||
let test_command_id = executor
|
||||
.running_command_id()
|
||||
.expect("Executor should be running test command.");
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: test_command_id,
|
||||
exit_code: 0,
|
||||
output: "foo".as_bytes().to_vec(),
|
||||
});
|
||||
|
||||
// "echo bar" is now running.
|
||||
assert_eq!(
|
||||
pending_command_ids[0],
|
||||
executor.running_command_id().unwrap()
|
||||
);
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: pending_command_ids[0].clone(),
|
||||
exit_code: 1,
|
||||
output: "bar".as_bytes().to_vec(),
|
||||
});
|
||||
|
||||
// "echo baz" is now running.
|
||||
assert_eq!(
|
||||
pending_command_ids[1],
|
||||
executor.running_command_id().unwrap()
|
||||
);
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: pending_command_ids[1].clone(),
|
||||
exit_code: 0,
|
||||
output: "baz".as_bytes().to_vec(),
|
||||
});
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
first_execute_command_future.await;
|
||||
second_execute_command_future.await;
|
||||
third_execute_command_future.await;
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clears_running_and_pending_commands_on_cancellation() {
|
||||
App::test((), |_app| async move {
|
||||
fn assert_failed(result: Result<CommandOutput>) {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
// TODO(zachbai): Figure out how to make these tests work without
|
||||
// requiring a single-threaded executor (e.g.: running on
|
||||
// app.background_executor()).
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let first_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
assert_failed,
|
||||
));
|
||||
let second_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo bar",
|
||||
assert_failed,
|
||||
));
|
||||
let third_execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo baz",
|
||||
assert_failed,
|
||||
));
|
||||
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
executor.cancel_active_commands();
|
||||
|
||||
assert_eq!(executor.running_command_id(), None);
|
||||
assert_eq!(executor.pending_command_ids().len(), 0);
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
first_execute_command_future.await;
|
||||
second_execute_command_future.await;
|
||||
third_execute_command_future.await;
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_are_cleared_if_execute_command_future_is_aborted() {
|
||||
App::test((), |_app| async move {
|
||||
fn assert_failed(result: Result<CommandOutput>) {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
// TODO(zachbai): Figure out how to make these tests work without
|
||||
// requiring a single-threaded executor (e.g.: running on
|
||||
// app.background_executor()).
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let executor_clone = executor.clone();
|
||||
let execute_commands_future = async move {
|
||||
execute_test_command(executor_clone.clone(), "echo foo", assert_failed).await;
|
||||
execute_test_command(executor_clone.clone(), "echo bar", assert_failed).await;
|
||||
execute_test_command(executor_clone.clone(), "echo baz", assert_failed).await;
|
||||
};
|
||||
|
||||
let (handle, registration) = AbortHandle::new_pair();
|
||||
let execute_command_future =
|
||||
task_executor.spawn(Abortable::new(execute_commands_future, registration));
|
||||
// Abort the future where the commands are executing.
|
||||
handle.abort();
|
||||
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
assert_eq!(executor.running_command_id(), None);
|
||||
assert_eq!(executor.pending_command_ids().len(), 0);
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
assert_eq!(execute_command_future.await, Err(Aborted));
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_are_cleared_if_controller_cancels_command() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
|result| {
|
||||
let output = result.expect("Expect result to be ok");
|
||||
assert_eq!(output.status, CommandExitStatus::Failure)
|
||||
},
|
||||
));
|
||||
|
||||
let cancel_command_future = task_executor.spawn(async move {
|
||||
let current_running_id = executor
|
||||
.running_command_id()
|
||||
.expect("There should be a running command");
|
||||
executor.handle_cancelled_in_band_command_event(InBandCommandCancelledEvent {
|
||||
command_id: current_running_id,
|
||||
});
|
||||
assert_eq!(executor.running_command_id(), None);
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
execute_command_future.await;
|
||||
cancel_command_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commands_are_not_cleared_if_controller_cancels_different_command() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
assert_command_output_result_fn("foo", true),
|
||||
));
|
||||
|
||||
let cancel_command_future = task_executor.spawn(async move {
|
||||
let current_running_id = executor
|
||||
.running_command_id()
|
||||
.expect("There should be a running command");
|
||||
|
||||
let a_different_id = format!("{current_running_id}_but_not_really");
|
||||
executor.handle_cancelled_in_band_command_event(InBandCommandCancelledEvent {
|
||||
command_id: a_different_id,
|
||||
});
|
||||
assert!(executor
|
||||
.running_command_id()
|
||||
.is_some_and(|id| id == current_running_id));
|
||||
|
||||
// Resolving the command to unblock the test
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: current_running_id,
|
||||
exit_code: 0,
|
||||
output: "foo".as_bytes().to_vec(),
|
||||
})
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
execute_command_future.await;
|
||||
cancel_command_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancelling_command_queues_up_next_command_command() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let (in_band_command_cancelled_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(InBandCommandExecutor::new(
|
||||
executor_command_tx,
|
||||
in_band_command_cancelled_tx,
|
||||
));
|
||||
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let execute_first_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
|result| {
|
||||
let output = result.expect("Expect result to be ok");
|
||||
assert_eq!(output.status, CommandExitStatus::Failure)
|
||||
},
|
||||
));
|
||||
let execute_second_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo bar",
|
||||
assert_command_output_result_fn("bar", true),
|
||||
));
|
||||
|
||||
let cancel_command_future = task_executor.spawn(async move {
|
||||
let current_running_id = executor
|
||||
.running_command_id()
|
||||
.expect("There should be a running command");
|
||||
|
||||
let pending_command_id = executor
|
||||
.pending_command_ids()
|
||||
.first()
|
||||
.expect("There should be at least one pending command")
|
||||
.to_string();
|
||||
executor.handle_cancelled_in_band_command_event(InBandCommandCancelledEvent {
|
||||
command_id: current_running_id,
|
||||
});
|
||||
assert!(executor
|
||||
.running_command_id()
|
||||
.is_some_and(|id| id == pending_command_id));
|
||||
|
||||
// Resolving the command to unblock the test
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: pending_command_id,
|
||||
exit_code: 0,
|
||||
output: "bar".as_bytes().to_vec(),
|
||||
})
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
execute_first_command_future.await;
|
||||
execute_second_command_future.await;
|
||||
cancel_command_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use super::{CommandExecutor, CommandOutput, ExecuteCommandOptions};
|
||||
use crate::safe_warn;
|
||||
use crate::terminal::shell::{Shell, ShellType};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use command::r#async::Command;
|
||||
use parking_lot::Mutex;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn kill_all_processes_in_process_group(pid: u32) -> Result<(), nix::Error> {
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
// Killing a negative PID kills all processes in this process group
|
||||
kill(Pid::from_raw(-(pid as i32)), Signal::SIGKILL)
|
||||
}
|
||||
|
||||
enum CommandBuilder<'a> {
|
||||
#[cfg(windows)]
|
||||
CmdExe,
|
||||
ShellType {
|
||||
shell_type: ShellType,
|
||||
local_shell_path: Option<&'a Path>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommandBuilder<'_> {
|
||||
fn build(self, command_string: &str, shell_config_flag: &str) -> Command {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
CommandBuilder::CmdExe => {
|
||||
use command::windows::CommandExt as _;
|
||||
let mut command = Command::new_with_process_group("cmd.exe");
|
||||
command.args(["/Q", "/C"]);
|
||||
command.raw_arg(command_string);
|
||||
command
|
||||
}
|
||||
CommandBuilder::ShellType {
|
||||
local_shell_path,
|
||||
shell_type,
|
||||
} => {
|
||||
let program_to_execute = local_shell_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.to_str())
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("local_shell_path was None for a local session");
|
||||
shell_type.name()
|
||||
});
|
||||
let mut command = Command::new_with_process_group(program_to_execute);
|
||||
command.arg(shell_config_flag);
|
||||
command.arg("-c");
|
||||
command.arg(command_string);
|
||||
command
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandExecutor` implementation that executes the given `command` in a forked subshell process
|
||||
/// where the current working directory is set to `current_dir_path` and $PATH is set
|
||||
/// according to environment_variables. This is typically used to run generator commands for local sessions.
|
||||
#[derive(Debug)]
|
||||
pub struct LocalCommandExecutor {
|
||||
local_shell_path: Option<PathBuf>,
|
||||
shell_type: ShellType,
|
||||
|
||||
spawned_children_pids: Arc<Mutex<HashSet<u32>>>,
|
||||
}
|
||||
|
||||
impl LocalCommandExecutor {
|
||||
pub fn new(local_shell_path: Option<PathBuf>, shell_type: ShellType) -> Self {
|
||||
Self {
|
||||
local_shell_path,
|
||||
shell_type,
|
||||
spawned_children_pids: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_local_command(
|
||||
&self,
|
||||
command: &str,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
let shell_config_flag = match self.shell_type {
|
||||
ShellType::Zsh => "-f",
|
||||
ShellType::Bash => "--norc",
|
||||
ShellType::Fish => "--no-config",
|
||||
ShellType::PowerShell => "-NoProfile",
|
||||
};
|
||||
|
||||
self.execute_local_command_internal(
|
||||
command,
|
||||
current_directory_path,
|
||||
environment_variables,
|
||||
shell_config_flag,
|
||||
execute_command_options,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn execute_local_command_in_login_shell(
|
||||
&self,
|
||||
command: &str,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
) -> Result<CommandOutput> {
|
||||
let shell_config_flag = match self.shell_type {
|
||||
ShellType::Bash | ShellType::Zsh | ShellType::Fish => "-l",
|
||||
ShellType::PowerShell => "-Login",
|
||||
};
|
||||
|
||||
self.execute_local_command_internal(
|
||||
command,
|
||||
current_directory_path,
|
||||
environment_variables,
|
||||
shell_config_flag,
|
||||
ExecuteCommandOptions {
|
||||
// We have to run the command in the same shell as the session
|
||||
// because we want to run it in a login shell.
|
||||
run_command_in_same_shell_as_session: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn command_builder(
|
||||
&self,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> CommandBuilder<'_> {
|
||||
CommandBuilder::ShellType {
|
||||
shell_type: self.shell_type,
|
||||
local_shell_path: self.local_shell_path.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command_builder(
|
||||
&self,
|
||||
execute_command_options: ExecuteCommandOptions,
|
||||
) -> CommandBuilder<'_> {
|
||||
let use_cmd_exe = !execute_command_options.run_command_in_same_shell_as_session
|
||||
&& self.shell_type == ShellType::PowerShell;
|
||||
if use_cmd_exe {
|
||||
CommandBuilder::CmdExe
|
||||
} else {
|
||||
CommandBuilder::ShellType {
|
||||
shell_type: self.shell_type,
|
||||
local_shell_path: self.local_shell_path.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_local_command_internal(
|
||||
&self,
|
||||
command: &str,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
// The value of shell_config_flag is appended as an argument
|
||||
// indicating the supplied command should be run under some configuration,
|
||||
// i.e. in a login shell or without sourcing .rc files
|
||||
shell_config_flag: &str,
|
||||
execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
let command_builder = self.command_builder(execute_command_options);
|
||||
|
||||
let mut command_process = command_builder.build(command, shell_config_flag);
|
||||
|
||||
// This sets then enviornment variables, including the PATH var.
|
||||
// We need to run the command with the PATH var set because if the
|
||||
// user opened Warp through a parent process that didn't have the PATH var set
|
||||
// (i.e. outside of a shell, for example opening the app via Finder),
|
||||
// the subshell won't inherit the PATH var, but we need the PATH var
|
||||
// to reference executables we might run as part of generators.
|
||||
// Note: we don't need to quote/escape the PATH and pwd because
|
||||
// they're treated as single words.
|
||||
if let Some(environment_variables) = environment_variables {
|
||||
command_process.envs(&environment_variables);
|
||||
}
|
||||
|
||||
// Set the current dir, if any.
|
||||
if let Some(current_directory_path) = current_directory_path {
|
||||
command_process.current_dir(current_directory_path);
|
||||
}
|
||||
|
||||
// The purpose of the executor is to produce output. If the child
|
||||
// has been dropped, there's no way to get the output anymore,
|
||||
// so there's no need for the process itself to stick around.
|
||||
let child = command_process
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let child_pid = child.id();
|
||||
self.spawned_children_pids.lock().insert(child_pid);
|
||||
|
||||
let output = child
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.into())
|
||||
.map_err(|e| {
|
||||
safe_warn!(
|
||||
safe: ("error executing local command"),
|
||||
full: ("error executing command {:?} with error {:?}", command, e)
|
||||
);
|
||||
anyhow!(e)
|
||||
});
|
||||
|
||||
self.spawned_children_pids.lock().remove(&child_pid);
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for LocalCommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
_shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
self.execute_local_command(
|
||||
command,
|
||||
current_directory_path,
|
||||
environment_variables,
|
||||
execute_command_options,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn cancel_active_commands(&self) {
|
||||
let spawned_children_pids = std::mem::take(&mut *self.spawned_children_pids.lock());
|
||||
for _pid in spawned_children_pids {
|
||||
// TODO(roland): handle for windows
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = kill_all_processes_in_process_group(_pid) {
|
||||
match e {
|
||||
// Ignore errors that occur when the process is no longer running,
|
||||
// or if we cannot kill all processes in the process group. These
|
||||
// are expected to happen occasionally.
|
||||
nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM => {}
|
||||
_ => log::warn!("Failed to kill process {_pid}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use super::{CommandExecutor, ExecuteCommandOptions};
|
||||
use crate::{
|
||||
safe_warn,
|
||||
terminal::shell::{Shell, ShellType},
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use command::r#async::Command;
|
||||
use itertools::Itertools;
|
||||
use std::{any::Any, collections::HashMap, ffi::OsString, path::PathBuf};
|
||||
use typed_path::{TypedPath, WindowsPath};
|
||||
use warp_completer::completer::CommandOutput;
|
||||
use warp_util::path::{convert_msys2_to_windows_native_path, msys2_exe_to_root};
|
||||
|
||||
const BASH_CONFIG_FLAG: &str = "--norc";
|
||||
const POWERSHELL_CONFIG_FLAG: &str = "-NoProfile";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MSYS2CommandExecutor {
|
||||
windows_native_shell_path: Option<PathBuf>,
|
||||
msys2_shell_path: PathBuf,
|
||||
}
|
||||
|
||||
impl MSYS2CommandExecutor {
|
||||
pub fn new(windows_native_shell_path: Option<PathBuf>, msys2_shell_path: PathBuf) -> Self {
|
||||
Self {
|
||||
windows_native_shell_path,
|
||||
msys2_shell_path,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_windows_native_command(
|
||||
&self,
|
||||
command: &str,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
) -> Result<CommandOutput> {
|
||||
// Currently, we use PowerShell for this.
|
||||
let Some(windows_native_shell_path) = self.windows_native_shell_path.as_ref() else {
|
||||
return Err(anyhow!("Windows native shell path not found"));
|
||||
};
|
||||
let mut command_process = Command::new(windows_native_shell_path);
|
||||
command_process.arg(POWERSHELL_CONFIG_FLAG);
|
||||
|
||||
if let Some(mut environment_variables) = environment_variables {
|
||||
// We need to convert the unix-style paths to Windows paths so that PowerShell can
|
||||
// parse it.
|
||||
if let Some(path) = environment_variables.remove("PATH") {
|
||||
let mut new_path = OsString::new();
|
||||
for path in path.split(":").filter_map(|path| {
|
||||
let unix_path = TypedPath::unix(path);
|
||||
convert_msys2_to_windows_native_path(
|
||||
&unix_path,
|
||||
&msys2_exe_to_root(WindowsPath::new(
|
||||
self.msys2_shell_path.as_os_str().as_encoded_bytes(),
|
||||
)),
|
||||
)
|
||||
.ok()
|
||||
}) {
|
||||
new_path.push(path);
|
||||
new_path.push(";");
|
||||
}
|
||||
command_process.env("PATH", new_path);
|
||||
}
|
||||
command_process.envs(&environment_variables);
|
||||
}
|
||||
|
||||
command_process
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
// The purpose of the executor is to produce output. If the child
|
||||
// has been dropped, there's no way to get the output anymore,
|
||||
// so there's no need for the process itself to stick around.
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.into())
|
||||
.map_err(|e| {
|
||||
safe_warn!(
|
||||
safe: ("error executing local command"),
|
||||
full: ("error executing command {:?} with error {:?}", command, e)
|
||||
);
|
||||
anyhow!(e)
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_msys2_shell_command(
|
||||
&self,
|
||||
command: &str,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
) -> Result<CommandOutput> {
|
||||
let mut command_process = Command::new(&self.msys2_shell_path);
|
||||
command_process.arg(BASH_CONFIG_FLAG);
|
||||
|
||||
if let Some(mut environment_variables) = environment_variables {
|
||||
// We exclude anything from the Windows filesystem here because it is very slow.
|
||||
// Compgen can take over a minute to run locally otherwise. We retrieve the
|
||||
// Windows executables by using a Windows-native shell.
|
||||
if command.contains("compgen") {
|
||||
if let Some(path) = environment_variables.get_mut("PATH") {
|
||||
*path = path
|
||||
.split(":")
|
||||
.filter(|path| !path.starts_with("/c/"))
|
||||
.join(":");
|
||||
}
|
||||
}
|
||||
command_process.envs(environment_variables);
|
||||
}
|
||||
|
||||
if let Some(current_directory_path) = current_directory_path {
|
||||
let inner_path = TypedPath::unix(current_directory_path);
|
||||
let current_directory_path = convert_msys2_to_windows_native_path(
|
||||
&inner_path,
|
||||
&msys2_exe_to_root(WindowsPath::new(
|
||||
self.msys2_shell_path.as_os_str().as_encoded_bytes(),
|
||||
)),
|
||||
)?;
|
||||
command_process.current_dir(current_directory_path);
|
||||
}
|
||||
|
||||
command_process
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
// The purpose of the executor is to produce output. If the child
|
||||
// has been dropped, there's no way to get the output anymore,
|
||||
// so there's no need for the process itself to stick around.
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.into())
|
||||
.map_err(|e| {
|
||||
safe_warn!(
|
||||
safe: ("error executing local command"),
|
||||
full: ("error executing command {:?} with error {:?}", command, e)
|
||||
);
|
||||
anyhow!(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for MSYS2CommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
match shell.shell_type() {
|
||||
ShellType::PowerShell => {
|
||||
self.execute_windows_native_command(command, environment_variables)
|
||||
.await
|
||||
}
|
||||
shell_type => {
|
||||
if self.msys2_shell_path.file_stem().is_some_and(|stem| {
|
||||
ShellType::from_name(stem.to_string_lossy().as_ref()) == Some(shell_type)
|
||||
}) {
|
||||
self.execute_msys2_shell_command(
|
||||
command,
|
||||
current_directory_path,
|
||||
environment_variables,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"MSYS2CommandExecutor tried to execute on a shell that isn't supported: {shell_type:?}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::terminal::shell::Shell;
|
||||
|
||||
use super::{CommandExecutor, CommandOutput, ExecuteCommandOptions};
|
||||
|
||||
/// A "no-op" implementation of `CommandExecutor` to be used as a placeholder `CommandExecutor`
|
||||
/// implementation for remote non-SSH subshell `Session`s when the user has disabled in-band
|
||||
/// generators. Users may disable in-band generators by setting a 'hidden' user default (not
|
||||
/// surfaced in Settings), purely as a "killswitch" if there is a particularly bad in-band
|
||||
/// generators bug.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoOpCommandExecutor {}
|
||||
|
||||
impl NoOpCommandExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for NoOpCommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
_command: &str,
|
||||
_shell: &Shell,
|
||||
_current_directory_path: Option<&str>,
|
||||
_environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
Err(anyhow!(
|
||||
"Did not execute command; using NoOpCommandExecutor"
|
||||
))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::collections::HashMap;
|
||||
use std::{any::Any, path::PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use command::r#async::Command;
|
||||
use itertools::Itertools as _;
|
||||
|
||||
use crate::env_vars::{serialize_variables_for_shell, EnvVarValue};
|
||||
use crate::terminal::shell::Shell;
|
||||
|
||||
use super::{CommandExecutor, CommandOutput, ExecuteCommandOptions};
|
||||
|
||||
/// `CommandExecutor` implementation that executes the given `command` in a forked process
|
||||
/// that establishes a one-off SSH session with the same remote host as the active SSH session
|
||||
/// using SSH's ControlMaster/ControlPath feature. This is typically used to run generator
|
||||
/// commands for remote SSH sessions.
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteCommandExecutor {
|
||||
control_socket_path: PathBuf,
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
impl RemoteCommandExecutor {
|
||||
pub fn new(control_socket_path: PathBuf, wsl_distro: Option<String>) -> Self {
|
||||
Self {
|
||||
control_socket_path,
|
||||
wsl_distro,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for RemoteCommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
// We can't use `.env` and `.current_dir` here to set the PATH and current dir respectively
|
||||
// since this is run locally. We just need the subprocess to send the bytes over the
|
||||
// ssh connection. That's why we explicitly set the path and cwd as part of command_str.
|
||||
let mut command_str = String::new();
|
||||
if let Some(environment_variables) = environment_variables {
|
||||
let env_vars = environment_variables
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, EnvVarValue::Constant(value)))
|
||||
.collect_vec();
|
||||
let env_vars_str = serialize_variables_for_shell(
|
||||
env_vars.iter().map(|(key, value)| (key.as_str(), value)),
|
||||
shell.shell_type(),
|
||||
);
|
||||
command_str.push_str(&env_vars_str);
|
||||
command_str.push(';');
|
||||
}
|
||||
if let Some(current_directory_path) = current_directory_path {
|
||||
command_str.push_str(&format!("cd '{current_directory_path}' && "));
|
||||
}
|
||||
command_str.push_str(command);
|
||||
|
||||
let ssh_args = [
|
||||
"-q",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
// Disable X11 forwarding, as none of our background commands
|
||||
// should need it, and it can cause warnings to appear in the
|
||||
// user's session.
|
||||
"-o",
|
||||
"ForwardX11=no",
|
||||
"-o",
|
||||
&format!(
|
||||
"ControlPath={}",
|
||||
self.control_socket_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("socket path must exist"))?
|
||||
),
|
||||
"placeholder@placeholder",
|
||||
command_str.as_str(),
|
||||
];
|
||||
|
||||
// If the SSH session originated from WSL, the ControlPath also exists inside WSL.
|
||||
// Therefore, SSH commands directly from the Windows host will not work. They must be run
|
||||
// inside that same WSL instance
|
||||
let mut command = match &self.wsl_distro {
|
||||
None => Command::new("ssh"),
|
||||
Some(distro_name) => {
|
||||
let mut command = Command::new("wsl");
|
||||
command.args(["-d", distro_name.as_str(), "-e", "ssh"]);
|
||||
command
|
||||
}
|
||||
};
|
||||
|
||||
command.args(ssh_args);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.into())
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
/// The RemoteCommandExecutor can't be run in parallel because a remote box could have a value
|
||||
/// of `MaxSessions` (the max number of open sessions for a single connection) that is less than
|
||||
/// the number of commands we'd be trying to run in in parallel. Functionally, this would result
|
||||
/// in `channel: open failed` messages sent back over the PTY in the running sessions.
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::remote_server::client::RemoteServerClient;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use warp_completer::completer::{CommandExitStatus, CommandOutput};
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_core::SessionId;
|
||||
|
||||
use crate::remote_server::proto::run_command_response;
|
||||
use crate::terminal::model::session::command_executor::{CommandExecutor, ExecuteCommandOptions};
|
||||
use crate::terminal::shell::Shell;
|
||||
|
||||
/// `CommandExecutor` implementation that executes commands via a persistent
|
||||
/// `warp remote-server` process running on the remote host over SSH.
|
||||
///
|
||||
/// The executor is always constructed with a live `RemoteServerClient` that
|
||||
/// was obtained from [`crate::remote_server::manager::RemoteServerManager`]
|
||||
/// after the session reached the `Connected` state. The manager owns the
|
||||
/// authoritative per-session client; this executor holds a cloned `Arc` to
|
||||
/// the same underlying channels and transitively keeps them alive as long
|
||||
/// as the `Session` is alive.
|
||||
///
|
||||
/// If the underlying SSH connection is torn down mid-session,
|
||||
/// [`RemoteServerClient::run_command`] will fail naturally and
|
||||
/// [`execute_command`] surfaces that as an `Err`. We deliberately do *not*
|
||||
/// silently synthesize an empty `Ok(CommandOutput)` for the disconnected
|
||||
/// case, because callers (notably the completions/syntax-highlighting
|
||||
/// pipeline) treat `Ok(empty)` as "there are zero top-level commands" and
|
||||
/// produce incorrect results.
|
||||
pub struct RemoteServerCommandExecutor {
|
||||
session_id: SessionId,
|
||||
client: Arc<RemoteServerClient>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RemoteServerCommandExecutor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RemoteServerCommandExecutor")
|
||||
.field("session_id", &self.session_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteServerCommandExecutor {
|
||||
/// Creates a new executor backed by an already-connected
|
||||
/// [`RemoteServerClient`].
|
||||
pub fn new(session_id: SessionId, client: Arc<RemoteServerClient>) -> Self {
|
||||
Self { session_id, client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for RemoteServerCommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
_shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
let response = self
|
||||
.client
|
||||
.run_command(
|
||||
self.session_id,
|
||||
command.to_owned(),
|
||||
current_directory_path.map(ToOwned::to_owned),
|
||||
environment_variables.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Remote command failed (session={:?}): {e}", self.session_id))?;
|
||||
|
||||
match response.result {
|
||||
Some(run_command_response::Result::Success(success)) => {
|
||||
let status = match success.exit_code {
|
||||
Some(0) => CommandExitStatus::Success,
|
||||
_ => CommandExitStatus::Failure,
|
||||
};
|
||||
Ok(CommandOutput {
|
||||
stdout: success.stdout,
|
||||
stderr: success.stderr,
|
||||
status,
|
||||
exit_code: success.exit_code.map(ExitCode::from),
|
||||
})
|
||||
}
|
||||
Some(run_command_response::Result::Error(err)) => Err(anyhow!(
|
||||
"Remote command error (session={:?}, code={:?}): {}",
|
||||
self.session_id,
|
||||
err.code(),
|
||||
err.message,
|
||||
)),
|
||||
None => Err(anyhow!(
|
||||
"Remote command returned empty response (session={:?})",
|
||||
self.session_id,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
/// The remote server multiplexes commands over a single SSH connection,
|
||||
/// so parallel execution is safe (unlike `RemoteCommandExecutor` which
|
||||
/// opens a new SSH session per command and is limited by `MaxSessions`).
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use async_channel::Sender;
|
||||
|
||||
use crate::terminal::{
|
||||
model::{
|
||||
session::command_executor::{InBandCommand, InBandCommandCancelledEvent},
|
||||
tmux::commands::TmuxCommand,
|
||||
},
|
||||
shell::ShellType,
|
||||
};
|
||||
|
||||
/// Set of events sent by command executors.
|
||||
pub enum ExecutorCommandEvent {
|
||||
/// The command should be executed.
|
||||
ExecuteCommand {
|
||||
command: InBandCommand,
|
||||
/// A Sender that can be used to signal that the command has been cancelled.
|
||||
/// Lets us unblock the command in the executor.
|
||||
cancel_tx: Sender<InBandCommandCancelledEvent>,
|
||||
},
|
||||
ExecuteTmuxCommand(TmuxCommand),
|
||||
/// The command identified by `id` should be cancelled.
|
||||
CancelCommand {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn shell_escape_single_quotes(command: &str, shell_type: ShellType) -> String {
|
||||
match shell_type {
|
||||
ShellType::Fish => {
|
||||
// Backslash-escape single quotes for Fish.
|
||||
command.replace('\'', r"\'")
|
||||
}
|
||||
ShellType::PowerShell => {
|
||||
// In powershell we escape single quotes using two single quotes ''
|
||||
command.replace('\'', "''")
|
||||
}
|
||||
_ => {
|
||||
// For Bash and Zsh, replace each single quote with a '"'"' sequence.
|
||||
// The first single quote completes the single quoted string to the left,
|
||||
// the next three characters: "'" evaluate to a literal single quote in
|
||||
// bash/zsh, and then the final single quote starts a new single-quoted
|
||||
// string to the right. Effectively, this concatenates the left
|
||||
// single-quoted string, a literal single quote char, and the right
|
||||
// single-quoted string.
|
||||
command.replace('\'', r#"'"'"'"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_channel::{self, Receiver, Sender};
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::{ExecuteCommandOptions, ExecutorCommandEvent};
|
||||
use crate::server::datetime_ext::DateTimeExt;
|
||||
use crate::terminal::event::ExecutedExecutorCommandEvent;
|
||||
use crate::terminal::model::tmux::commands::TmuxCommand;
|
||||
use crate::terminal::shell::Shell;
|
||||
|
||||
use super::CommandExecutor;
|
||||
use warp_completer::completer::{CommandExitStatus, CommandOutput};
|
||||
use warp_core::command::ExitCode;
|
||||
|
||||
/// A `Session`-scoped executor for commands via tmux.
|
||||
pub struct TmuxCommandExecutor {
|
||||
executor_command_tx: Sender<ExecutorCommandEvent>,
|
||||
in_flight_commands: Arc<Mutex<HashMap<String, Sender<CommandOutput>>>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TmuxCommandExecutor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "TmuxCommandExecutor {{}}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
impl TmuxCommandExecutor {
|
||||
pub fn new(executor_command_tx: Sender<ExecutorCommandEvent>) -> Self {
|
||||
Self {
|
||||
executor_command_tx,
|
||||
in_flight_commands: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_command_internal(
|
||||
&self,
|
||||
command_id: &str,
|
||||
current_directory_path: Option<&str>,
|
||||
command: &str,
|
||||
_shell: &Shell,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
) -> Result<Receiver<CommandOutput>> {
|
||||
let (output_channel_tx, output_channel_rx) = async_channel::unbounded::<CommandOutput>();
|
||||
|
||||
self.in_flight_commands
|
||||
.lock()
|
||||
.insert(command_id.to_string(), output_channel_tx);
|
||||
|
||||
let tmux_command = TmuxCommand::RunInBackgroundWindow {
|
||||
current_directory_path: current_directory_path.map(|s| s.to_string()),
|
||||
command_id: command_id.to_string(),
|
||||
command: command.to_string(),
|
||||
environment_variables,
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.executor_command_tx
|
||||
.try_send(ExecutorCommandEvent::ExecuteTmuxCommand(tmux_command))
|
||||
{
|
||||
log::warn!("Failed to send TmuxCommand to pty_controller: {e}");
|
||||
}
|
||||
|
||||
Ok(output_channel_rx)
|
||||
}
|
||||
|
||||
pub fn handle_executed_command_event(&self, event: ExecutedExecutorCommandEvent) {
|
||||
if let Some(output_tx) = self.in_flight_commands.lock().get(&event.command_id) {
|
||||
if !output_tx.is_closed() {
|
||||
// We shouldn't be receiving exit codes that aren't 32 bit signed integers.
|
||||
let exit_code = Some(ExitCode::from(event.exit_code as i32));
|
||||
let command_output = if event.exit_code == 0 {
|
||||
CommandOutput {
|
||||
stdout: event.output,
|
||||
stderr: vec![],
|
||||
status: CommandExitStatus::Success,
|
||||
exit_code,
|
||||
}
|
||||
} else {
|
||||
CommandOutput {
|
||||
stdout: vec![],
|
||||
stderr: event.output,
|
||||
status: CommandExitStatus::Failure,
|
||||
exit_code,
|
||||
}
|
||||
};
|
||||
if let Err(error) = output_tx.try_send(command_output) {
|
||||
log::error!("Error occurred when sending generator command output: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for TmuxCommandExecutor {
|
||||
/// Executes `command` while attached to an active tmux control mode session.
|
||||
/// Runs the command in a background tmux window.
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
let command_id = DateTime::now().timestamp_micros().to_string();
|
||||
|
||||
let future = async {
|
||||
let output_channel_rx = self.execute_command_internal(
|
||||
command_id.as_str(),
|
||||
current_directory_path,
|
||||
command,
|
||||
shell,
|
||||
environment_variables,
|
||||
)?;
|
||||
output_channel_rx.recv().await.map_err(anyhow::Error::from)
|
||||
};
|
||||
|
||||
future.await
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tmux_executor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::terminal::{model::session::ExecuteCommandOptions, shell::ShellType};
|
||||
|
||||
use super::*;
|
||||
use warpui::App;
|
||||
|
||||
async fn execute_test_command<F>(
|
||||
executor: Arc<TmuxCommandExecutor>,
|
||||
command: &'static str,
|
||||
assert_result_fn: F,
|
||||
) where
|
||||
F: FnOnce(Result<CommandOutput>) + Send + 'static,
|
||||
{
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
let test_command_result = executor
|
||||
.execute_command(
|
||||
command,
|
||||
&shell,
|
||||
/*current_directory_path=*/ None,
|
||||
/*environment_variables=*/ None,
|
||||
ExecuteCommandOptions::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_result_fn(test_command_result);
|
||||
}
|
||||
|
||||
/// Returns a closure that asserts the given `Result<CommandOutput>` is `Ok(..)` and contains
|
||||
/// `CommandOutput` with the given `expected_output` and `expected_success` values.
|
||||
fn assert_command_output_result_fn(
|
||||
expected_output: &'static str,
|
||||
expected_success: bool,
|
||||
) -> impl FnOnce(Result<CommandOutput>) {
|
||||
move |result| {
|
||||
assert!(result.is_ok());
|
||||
|
||||
let output = result.unwrap();
|
||||
assert_eq!(output.success(), expected_success);
|
||||
assert_eq!(output.output(), expected_output.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emits_successful_command_output() {
|
||||
App::test((), |_app| async move {
|
||||
let (executor_command_tx, _) = async_channel::unbounded();
|
||||
let executor = Arc::new(TmuxCommandExecutor::new(executor_command_tx));
|
||||
|
||||
let task_executor = async_executor::LocalExecutor::new();
|
||||
|
||||
let execute_command_future = task_executor.spawn(execute_test_command(
|
||||
executor.clone(),
|
||||
"echo foo",
|
||||
assert_command_output_result_fn("foo", true),
|
||||
));
|
||||
let handle_command_output_future = task_executor.spawn(async move {
|
||||
let test_command_id = executor
|
||||
.in_flight_commands
|
||||
.lock()
|
||||
.keys()
|
||||
.next()
|
||||
.expect("Executor should be running test command.")
|
||||
.to_string();
|
||||
executor.handle_executed_command_event(ExecutedExecutorCommandEvent {
|
||||
command_id: test_command_id,
|
||||
exit_code: 0,
|
||||
output: "foo".as_bytes().to_vec(),
|
||||
});
|
||||
});
|
||||
|
||||
task_executor
|
||||
.run(async move {
|
||||
execute_command_future.await;
|
||||
handle_command_output_future.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use super::{CommandExecutor, CommandOutput, ExecuteCommandOptions};
|
||||
use crate::env_vars::{serialize_variables_for_shell, EnvVarValue};
|
||||
use crate::safe_warn;
|
||||
use crate::terminal::shell::{Shell, ShellType};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use command::r#async::Command;
|
||||
use itertools::Itertools as _;
|
||||
use std::any::Any;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `CommandExecutor` implementation that executes the given `command` in a WSL instance via the
|
||||
/// `wsl.exe` executable.
|
||||
#[derive(Debug)]
|
||||
pub struct WslCommandExecutor {
|
||||
distro_name: String,
|
||||
shell_type: ShellType,
|
||||
}
|
||||
|
||||
impl WslCommandExecutor {
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
pub fn new(distro_name: String, shell_type: ShellType) -> Self {
|
||||
Self {
|
||||
distro_name,
|
||||
shell_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_local_command(
|
||||
&self,
|
||||
command: &str,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
) -> Result<CommandOutput> {
|
||||
let shell_config_flag = match self.shell_type {
|
||||
ShellType::Zsh => "-f",
|
||||
ShellType::Bash => "--norc",
|
||||
ShellType::Fish => "--no-config",
|
||||
ShellType::PowerShell => "-NoProfile",
|
||||
};
|
||||
|
||||
let mut command_process = Command::new("wsl");
|
||||
|
||||
command_process.arg("--distribution").arg(&self.distro_name);
|
||||
|
||||
if let Some(dir) = current_directory_path {
|
||||
command_process.arg("--cd");
|
||||
command_process.arg(dir);
|
||||
}
|
||||
|
||||
let mut command_with_env = Cow::Borrowed(command);
|
||||
if let Some(mut env_vars) = environment_variables {
|
||||
if let Some(mut path_var) = env_vars.remove("PATH") {
|
||||
// Unfortunately, bash's `compgen` is extremely slow with PATH contains a bunch of
|
||||
// entries pointing to the Windows host. On my system, it takes around 2 minutes to
|
||||
// complete! We special-case this to filter out all paths beginning with `/mnt`.
|
||||
// This means we won't correctly highlight executables on the Windows host. We're
|
||||
// assuming WSL users will mostly be calling the executables inside the WSL guest.
|
||||
if self.shell_type == ShellType::Bash && command.contains("compgen") {
|
||||
path_var = path_var
|
||||
.split(':')
|
||||
.filter(|path| !path.starts_with("/mnt"))
|
||||
.join(":");
|
||||
}
|
||||
|
||||
// Furthermore, we must pass the PATH by serializing it and adding a literal
|
||||
// assignment to the command itself. This is b/c Windows attempts to do a
|
||||
// conversion when propagating PATH from Windows to WSL, which cannot be disabled.
|
||||
// This conversion fails in this case b/c we collected the value of PATH from a
|
||||
// bootstrapped WSL session and it's _already_ converted. Conversion failures
|
||||
// result in truncation.
|
||||
let env_vars_str = serialize_variables_for_shell(
|
||||
[("PATH", &EnvVarValue::Constant(path_var))],
|
||||
self.shell_type,
|
||||
);
|
||||
command_with_env = Cow::Owned(format!(r#"{env_vars_str}; {command}"#));
|
||||
}
|
||||
|
||||
// The rest of the env vars can be passed more "normally", though they need to be
|
||||
// allowlisted by assigning WSLENV.
|
||||
command_process.envs(&env_vars);
|
||||
command_process.env(
|
||||
"WSLENV",
|
||||
env_vars.keys().map(|k| format!("{k}/u")).join(":"),
|
||||
);
|
||||
}
|
||||
|
||||
command_process
|
||||
.arg("--exec")
|
||||
.arg(self.shell_type.name())
|
||||
.arg(shell_config_flag)
|
||||
.arg("-c")
|
||||
.arg(&*command_with_env)
|
||||
// The purpose of the executor is to produce output. If the child
|
||||
// has been dropped, there's no way to get the output anymore,
|
||||
// so there's no need for the process itself to stick around.
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.into())
|
||||
.map_err(|e| {
|
||||
safe_warn!(
|
||||
safe: ("error executing local command"),
|
||||
full: ("error executing command {:?} with error {:?}", command, e)
|
||||
);
|
||||
anyhow!(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandExecutor for WslCommandExecutor {
|
||||
async fn execute_command(
|
||||
&self,
|
||||
command: &str,
|
||||
_shell: &Shell,
|
||||
current_directory_path: Option<&str>,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
_execute_command_options: ExecuteCommandOptions,
|
||||
) -> Result<CommandOutput> {
|
||||
self.execute_local_command(command, current_directory_path, environment_variables)
|
||||
.await
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn supports_parallel_command_execution(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warpui::{
|
||||
elements::Empty, platform::WindowStyle, App, AppContext, Element, Entity, ModelHandle,
|
||||
TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::{SessionId, Sessions, SessionsEvent};
|
||||
|
||||
struct TestView {
|
||||
events: Vec<SessionsEvent>,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = usize;
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
impl TestView {
|
||||
fn new(model: ModelHandle<Sessions>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&model, |me, _, event, _| {
|
||||
me.events.push(event.to_owned());
|
||||
});
|
||||
Self { events: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_env_var_emits_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Sessions::new_for_test());
|
||||
let session_id: SessionId = 0.into();
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestView::new(model_handle.clone(), ctx)
|
||||
});
|
||||
view_handle.read(&app, |view, _ctx| {
|
||||
assert!(view.events.is_empty());
|
||||
});
|
||||
model_handle.update(&mut app, |sessions, ctx| {
|
||||
let new_vars = HashMap::from_iter([("foo".to_string(), "bar".to_string())]);
|
||||
sessions.set_env_vars_for_session(session_id, new_vars, ctx)
|
||||
});
|
||||
|
||||
view_handle.read(&app, |view, _ctx| {
|
||||
assert_eq!(view.events.len(), 1);
|
||||
let expected_session_id = session_id;
|
||||
let event = view.events.first().expect("checked length already");
|
||||
if let SessionsEvent::EnvironmentVariablesUpdated { session_id } = event {
|
||||
assert_eq!(*session_id, expected_session_id);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
event,
|
||||
SessionsEvent::EnvironmentVariablesUpdated { .. }
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_env_var_emits_no_event_when_no_change() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Sessions::new_for_test());
|
||||
let session_id: SessionId = 0.into();
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestView::new(model_handle.clone(), ctx)
|
||||
});
|
||||
view_handle.read(&app, |view, _ctx| {
|
||||
assert!(view.events.is_empty());
|
||||
});
|
||||
model_handle.update(&mut app, |sessions, ctx| {
|
||||
let new_vars = HashMap::from_iter([("foo".to_string(), "bar".to_string())]);
|
||||
sessions.set_env_vars_for_session(session_id, new_vars, ctx)
|
||||
});
|
||||
|
||||
view_handle.read(&app, |view, _ctx| {
|
||||
assert_eq!(view.events.len(), 1);
|
||||
});
|
||||
|
||||
model_handle.update(&mut app, |sessions, ctx| {
|
||||
let new_vars = HashMap::from_iter([("foo".to_string(), "bar".to_string())]);
|
||||
sessions.set_env_vars_for_session(session_id, new_vars, ctx)
|
||||
});
|
||||
|
||||
view_handle.read(&app, |view, _ctx| {
|
||||
assert_eq!(view.events.len(), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,933 @@
|
||||
use super::*;
|
||||
use crate::terminal::model::ansi::{Handler, Processor};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::bootstrap::BootstrapStage;
|
||||
use crate::terminal::model::grid::Dimensions as _;
|
||||
use crate::terminal::model::index::Side;
|
||||
use crate::terminal::model::selection::ExpandedSelectionRange;
|
||||
use chrono::{DateTime, Local};
|
||||
use vec1::vec1;
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_terminal::model::ansi::ClearMode;
|
||||
use warpui::text::str_to_byte_vec;
|
||||
use warpui::text::SelectionType;
|
||||
|
||||
/// Helper function to create a SerializedBlock with default values,
|
||||
/// including the new is_local field.
|
||||
fn create_default_serialized_block() -> SerializedBlock {
|
||||
SerializedBlock {
|
||||
id: BlockId::new(),
|
||||
stylized_command: Default::default(),
|
||||
stylized_output: Default::default(),
|
||||
pwd: None,
|
||||
git_head: None,
|
||||
git_branch_name: None,
|
||||
virtual_env: None,
|
||||
conda_env: None,
|
||||
node_version: None,
|
||||
exit_code: ExitCode::from(0),
|
||||
did_execute: false,
|
||||
start_ts: Some(Local::now()),
|
||||
completed_ts: Some(Local::now()),
|
||||
ps1: None,
|
||||
rprompt: None,
|
||||
honor_ps1: false,
|
||||
session_id: None,
|
||||
shell_host: None,
|
||||
is_background: false,
|
||||
prompt_snapshot: None,
|
||||
ai_metadata: None,
|
||||
is_local: None,
|
||||
agent_view_visibility: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures that an ssh session successfully bootstraps even if the block list is empty.
|
||||
#[test]
|
||||
fn ssh_bootstraps_if_blocklist_empty() {
|
||||
let mut terminal = TerminalModel::mock(None, None);
|
||||
terminal.command_finished(Default::default());
|
||||
terminal.precmd(Default::default());
|
||||
terminal.command_finished(Default::default());
|
||||
terminal.precmd(Default::default());
|
||||
|
||||
let bootstrapped_value = BootstrappedValue {
|
||||
histfile: None,
|
||||
shell: String::from("bash"),
|
||||
home_dir: None,
|
||||
path: None,
|
||||
editor: None,
|
||||
env_var_names: None,
|
||||
aliases: None,
|
||||
abbreviations: None,
|
||||
function_names: None,
|
||||
builtins: None,
|
||||
keywords: None,
|
||||
shell_version: None,
|
||||
shell_options: None,
|
||||
shell_plugins: None,
|
||||
rcfiles_start_time: None,
|
||||
rcfiles_end_time: None,
|
||||
vi_mode_enabled: None,
|
||||
os_category: None,
|
||||
linux_distribution: None,
|
||||
wsl_name: None,
|
||||
shell_path: None,
|
||||
};
|
||||
terminal.bootstrapped(bootstrapped_value.clone());
|
||||
terminal.command_finished(Default::default());
|
||||
terminal.block_list_mut().precmd(Default::default());
|
||||
|
||||
assert!(terminal.is_active_block_bootstrapped());
|
||||
|
||||
// Clear all the blocks in the blocklist.
|
||||
terminal.clear_screen(ClearMode::ResetAndClear);
|
||||
|
||||
terminal.ssh(SSHValue::default());
|
||||
terminal.init_shell(InitShellValue {
|
||||
shell: "bash".into(),
|
||||
user: "zach".to_owned(),
|
||||
hostname: "sf".to_owned(),
|
||||
session_id: 0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// The active block should no longer be considered bootstrapped after the init shell call.
|
||||
assert!(!terminal.is_active_block_bootstrapped());
|
||||
|
||||
terminal.command_finished(Default::default());
|
||||
terminal.precmd(PrecmdValue::default());
|
||||
terminal.command_finished(Default::default());
|
||||
terminal.precmd(Default::default());
|
||||
terminal.bootstrapped(bootstrapped_value);
|
||||
terminal.command_finished(Default::default());
|
||||
terminal.precmd(Default::default());
|
||||
|
||||
assert!(terminal.is_active_block_bootstrapped());
|
||||
}
|
||||
|
||||
#[test]
|
||||
// An empty block that is restored should have a nonzero height and it should not get deleted.
|
||||
pub fn test_restored_empty_command_block() {
|
||||
let restored_blocks = [create_default_serialized_block().into()];
|
||||
let model = TerminalModel::mock(Some(&restored_blocks), None);
|
||||
let restored_block = &model.block_list().blocks()[0];
|
||||
assert_eq!(
|
||||
restored_block.bootstrap_stage(),
|
||||
BootstrapStage::RestoreBlocks
|
||||
);
|
||||
assert!(
|
||||
!restored_block.is_command_empty(),
|
||||
"The empty block should have nonzero length"
|
||||
);
|
||||
// The mocked terminal model comes with a WarpInput block and the active block.
|
||||
assert_eq!(model.block_list().blocks().len(), 3);
|
||||
}
|
||||
|
||||
/// Saved blocks that run on ANY hosts/shells should still get restored to the block list
|
||||
/// during session restoration. This test makes sure blocks from various hosts/shells get
|
||||
/// restored.
|
||||
#[test]
|
||||
fn test_restored_blocks_on_different_host() {
|
||||
let restored_blocks = [
|
||||
SerializedBlock {
|
||||
id: BlockId::new(),
|
||||
stylized_command: str_to_byte_vec("echo $TERM_PROGRAM"),
|
||||
stylized_output: str_to_byte_vec("WarpTerminal"),
|
||||
pwd: Some("/".to_owned()),
|
||||
git_head: None,
|
||||
git_branch_name: None,
|
||||
virtual_env: None,
|
||||
conda_env: None,
|
||||
node_version: None,
|
||||
exit_code: ExitCode::from(0),
|
||||
did_execute: true,
|
||||
completed_ts: Some(
|
||||
DateTime::from_timestamp(1671210994, 100)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
start_ts: Some(
|
||||
DateTime::from_timestamp(1671210994, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
ps1: None,
|
||||
rprompt: None,
|
||||
honor_ps1: false,
|
||||
session_id: None,
|
||||
shell_host: Some(ShellHost {
|
||||
shell_type: ShellType::Zsh,
|
||||
user: "local:user".to_owned(),
|
||||
hostname: "local:host".to_owned(),
|
||||
}),
|
||||
is_background: false,
|
||||
prompt_snapshot: None,
|
||||
ai_metadata: None,
|
||||
is_local: Some(true),
|
||||
agent_view_visibility: None,
|
||||
}
|
||||
.into(),
|
||||
SerializedBlock {
|
||||
id: BlockId::new(),
|
||||
stylized_command: str_to_byte_vec("pwd"),
|
||||
stylized_output: str_to_byte_vec("/"),
|
||||
pwd: Some("/".to_owned()),
|
||||
git_head: None,
|
||||
git_branch_name: None,
|
||||
virtual_env: None,
|
||||
conda_env: None,
|
||||
node_version: None,
|
||||
exit_code: ExitCode::from(0),
|
||||
did_execute: true,
|
||||
completed_ts: Some(
|
||||
DateTime::from_timestamp(1671210995, 100)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
start_ts: Some(
|
||||
DateTime::from_timestamp(1671210995, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
ps1: None,
|
||||
rprompt: None,
|
||||
honor_ps1: false,
|
||||
session_id: None,
|
||||
shell_host: Some(ShellHost {
|
||||
shell_type: ShellType::Bash,
|
||||
user: "local:user".to_owned(),
|
||||
hostname: "local:host".to_owned(),
|
||||
}),
|
||||
is_background: false,
|
||||
prompt_snapshot: None,
|
||||
ai_metadata: None,
|
||||
is_local: Some(true),
|
||||
agent_view_visibility: None,
|
||||
}
|
||||
.into(),
|
||||
SerializedBlock {
|
||||
id: BlockId::new(),
|
||||
stylized_command: str_to_byte_vec("uname"),
|
||||
stylized_output: str_to_byte_vec("Linux"),
|
||||
pwd: Some("/".to_owned()),
|
||||
git_head: None,
|
||||
git_branch_name: None,
|
||||
virtual_env: None,
|
||||
conda_env: None,
|
||||
node_version: None,
|
||||
exit_code: ExitCode::from(0),
|
||||
did_execute: true,
|
||||
completed_ts: Some(
|
||||
DateTime::from_timestamp(1671210996, 100)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
start_ts: Some(
|
||||
DateTime::from_timestamp(1671210996, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
ps1: None,
|
||||
rprompt: None,
|
||||
honor_ps1: false,
|
||||
session_id: None,
|
||||
shell_host: Some(ShellHost {
|
||||
shell_type: ShellType::Zsh,
|
||||
user: "root".to_owned(),
|
||||
hostname: "webserver".to_owned(),
|
||||
}),
|
||||
is_background: false,
|
||||
prompt_snapshot: None,
|
||||
ai_metadata: None,
|
||||
is_local: Some(false),
|
||||
agent_view_visibility: None,
|
||||
}
|
||||
.into(),
|
||||
SerializedBlock {
|
||||
id: BlockId::new(),
|
||||
stylized_command: str_to_byte_vec("mkdir secrets"),
|
||||
stylized_output: str_to_byte_vec("secrets"),
|
||||
pwd: Some("/etc".to_owned()),
|
||||
git_head: None,
|
||||
git_branch_name: None,
|
||||
virtual_env: None,
|
||||
conda_env: None,
|
||||
node_version: None,
|
||||
exit_code: ExitCode::from(0),
|
||||
did_execute: true,
|
||||
completed_ts: Some(
|
||||
DateTime::from_timestamp(1671210997, 100)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
start_ts: Some(
|
||||
DateTime::from_timestamp(1671210997, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&Local),
|
||||
),
|
||||
ps1: None,
|
||||
rprompt: None,
|
||||
honor_ps1: false,
|
||||
session_id: None,
|
||||
shell_host: None,
|
||||
is_background: false,
|
||||
prompt_snapshot: None,
|
||||
ai_metadata: None,
|
||||
is_local: Some(true),
|
||||
agent_view_visibility: None,
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
let model = TerminalModel::mock(Some(&restored_blocks), None);
|
||||
// The mocked terminal model comes with a WarpInput block and the active block.
|
||||
assert_eq!(model.block_list().blocks().len(), restored_blocks.len() + 2);
|
||||
for restored_block in model
|
||||
.block_list()
|
||||
.blocks()
|
||||
.iter()
|
||||
.take(restored_blocks.len())
|
||||
{
|
||||
assert_eq!(
|
||||
restored_block.bootstrap_stage(),
|
||||
BootstrapStage::RestoreBlocks,
|
||||
);
|
||||
}
|
||||
let blocks = model.block_list().blocks();
|
||||
assert_eq!(blocks[0].command_to_string(), "echo $TERM_PROGRAM",);
|
||||
assert_eq!(blocks[0].output_to_string(), "WarpTerminal",);
|
||||
assert_eq!(blocks[1].command_to_string(), "pwd",);
|
||||
assert_eq!(blocks[1].output_to_string(), "/",);
|
||||
assert_eq!(blocks[2].command_to_string(), "uname",);
|
||||
assert_eq!(blocks[2].output_to_string(), "Linux",);
|
||||
assert_eq!(blocks[3].command_to_string(), "mkdir secrets",);
|
||||
assert_eq!(blocks[3].output_to_string(), "secrets",);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_block_range_contains() {
|
||||
let range = SelectedBlockRange {
|
||||
pivot: 10.into(),
|
||||
tail: 2.into(),
|
||||
};
|
||||
assert!(range.contains(4.into()));
|
||||
assert!(!range.contains(1.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_is_selected() {
|
||||
let selected_blocks = SelectedBlocks {
|
||||
ranges: vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 4.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert!(selected_blocks.is_selected(0.into()));
|
||||
assert!(!selected_blocks.is_selected(2.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_tail() {
|
||||
let selected_blocks = SelectedBlocks {
|
||||
ranges: vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 4.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(selected_blocks.tail(), Some(1.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_reset() {
|
||||
let mut selected_blocks = SelectedBlocks {
|
||||
ranges: vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 4.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Reset to nothing.
|
||||
selected_blocks.reset();
|
||||
assert!(selected_blocks.is_empty());
|
||||
assert!(selected_blocks.tail().is_none());
|
||||
|
||||
// Reset to single.
|
||||
selected_blocks.reset_to_single(5.into());
|
||||
assert!(!selected_blocks.is_empty());
|
||||
assert_eq!(selected_blocks.tail(), Some(5.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_range_select() {
|
||||
let mut selected_blocks = SelectedBlocks {
|
||||
ranges: vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 5.into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Range select should reset to a single selection with
|
||||
// same pivot as most recent selection, and new tail.
|
||||
selected_blocks.range_select(6.into());
|
||||
assert_eq!(selected_blocks.ranges().len(), 1);
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().pivot, 3.into());
|
||||
assert_eq!(selected_blocks.tail(), Some(6.into()));
|
||||
|
||||
// Range select reversed should work similarly.
|
||||
selected_blocks.ranges = vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 5.into(),
|
||||
},
|
||||
];
|
||||
selected_blocks.range_select(0.into());
|
||||
assert_eq!(selected_blocks.ranges().len(), 1);
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().pivot, 3.into());
|
||||
assert_eq!(selected_blocks.tail(), Some(0.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_sorted_ranges() {
|
||||
let selected_blocks = SelectedBlocks {
|
||||
ranges: vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 5.into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let sorted_ranges = selected_blocks.sorted_ranges(BlockSortDirection::MostRecentLast);
|
||||
assert_eq!(2, sorted_ranges.len());
|
||||
assert_eq!(sorted_ranges.first().unwrap().start(), 0.into());
|
||||
assert_eq!(sorted_ranges.first().unwrap().end(), 1.into());
|
||||
assert_eq!(sorted_ranges.last().unwrap().start(), 3.into());
|
||||
assert_eq!(sorted_ranges.last().unwrap().end(), 5.into());
|
||||
|
||||
let reverse_sorted = selected_blocks.sorted_ranges(BlockSortDirection::MostRecentFirst);
|
||||
assert_eq!(2, sorted_ranges.len());
|
||||
assert_eq!(
|
||||
reverse_sorted
|
||||
.first()
|
||||
.unwrap()
|
||||
.range(Some(BlockSortDirection::MostRecentFirst))
|
||||
.next()
|
||||
.unwrap(),
|
||||
5.into()
|
||||
);
|
||||
assert_eq!(
|
||||
reverse_sorted
|
||||
.last()
|
||||
.unwrap()
|
||||
.range(Some(BlockSortDirection::MostRecentFirst))
|
||||
.last()
|
||||
.unwrap(),
|
||||
0.into()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_toggle_on() {
|
||||
let mut selected_blocks = SelectedBlocks {
|
||||
ranges: vec![SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 3.into(),
|
||||
}],
|
||||
};
|
||||
|
||||
// Toggle a disjoint selection - should toggle ON
|
||||
selected_blocks.toggle(6.into(), Some(7.into()), Some(5.into()));
|
||||
assert_eq!(selected_blocks.ranges().len(), 2);
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().pivot, 6.into());
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().tail, 6.into());
|
||||
assert!(selected_blocks.is_selected(6.into()));
|
||||
|
||||
// Toggle a selection before another, should merge
|
||||
selected_blocks.toggle(5.into(), Some(6.into()), Some(4.into()));
|
||||
assert_eq!(selected_blocks.ranges().len(), 2);
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().pivot, 6.into());
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().tail, 5.into());
|
||||
assert!(selected_blocks.is_selected(5.into()));
|
||||
|
||||
// Toggle a selection at the end of another, should merge
|
||||
selected_blocks.toggle(7.into(), Some(8.into()), Some(6.into()));
|
||||
assert_eq!(selected_blocks.ranges().len(), 2);
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().pivot, 7.into());
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().tail, 5.into());
|
||||
assert!(selected_blocks.is_selected(7.into()));
|
||||
|
||||
// Toggle a selection between two existing selections, should merge
|
||||
selected_blocks.toggle(4.into(), Some(5.into()), Some(3.into()));
|
||||
assert_eq!(selected_blocks.ranges().len(), 1);
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().pivot, 0.into());
|
||||
assert_eq!(selected_blocks.ranges().last().unwrap().tail, 7.into());
|
||||
assert!(selected_blocks.is_selected(4.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_blocks_toggle_off() {
|
||||
let mut selected_blocks = SelectedBlocks {
|
||||
ranges: vec![
|
||||
SelectedBlockRange {
|
||||
pivot: 2.into(),
|
||||
tail: 2.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 0.into(),
|
||||
tail: 1.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 25.into(),
|
||||
tail: 20.into(),
|
||||
},
|
||||
SelectedBlockRange {
|
||||
pivot: 3.into(),
|
||||
tail: 10.into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Case 1: deselect the entire selection range
|
||||
selected_blocks.toggle(2.into(), Some(3.into()), Some(1.into()));
|
||||
assert_eq!(selected_blocks.ranges().len(), 3);
|
||||
assert!(!selected_blocks.is_selected(2.into()));
|
||||
|
||||
// Case 2: deselect the pivot and tail for non-reversed range
|
||||
selected_blocks.toggle(25.into(), Some(26.into()), Some(24.into())); // deselect pivot
|
||||
selected_blocks.toggle(20.into(), Some(21.into()), Some(19.into())); // deselect tail
|
||||
assert_eq!(selected_blocks.ranges().len(), 3);
|
||||
assert_eq!(selected_blocks.ranges()[1].pivot, 24.into());
|
||||
assert_eq!(selected_blocks.ranges()[1].tail, 21.into());
|
||||
assert!(!selected_blocks.is_selected(25.into()));
|
||||
assert!(!selected_blocks.is_selected(20.into()));
|
||||
|
||||
// Case 2: deselect the pivot and tail for reversed range
|
||||
selected_blocks.toggle(3.into(), Some(4.into()), Some(2.into())); // deselect pivot
|
||||
selected_blocks.toggle(10.into(), Some(11.into()), Some(9.into())); // deselect tail
|
||||
assert_eq!(selected_blocks.ranges().len(), 3);
|
||||
assert_eq!(selected_blocks.ranges()[2].pivot, 4.into());
|
||||
assert_eq!(selected_blocks.ranges()[2].tail, 9.into());
|
||||
assert!(!selected_blocks.is_selected(3.into()));
|
||||
assert!(!selected_blocks.is_selected(10.into()));
|
||||
|
||||
// Case 3: deselect in the middle of range
|
||||
selected_blocks.toggle(6.into(), Some(7.into()), Some(5.into()));
|
||||
assert_eq!(selected_blocks.ranges().len(), 4);
|
||||
assert_eq!(selected_blocks.ranges()[2].pivot, 7.into());
|
||||
assert_eq!(selected_blocks.ranges()[2].tail, 9.into());
|
||||
assert_eq!(selected_blocks.ranges()[3].pivot, 4.into());
|
||||
assert_eq!(selected_blocks.ranges()[3].tail, 5.into());
|
||||
assert!(!selected_blocks.is_selected(6.into()));
|
||||
}
|
||||
|
||||
fn validate_title_event(event: Result<Event, async_channel::TryRecvError>, expected_title: String) {
|
||||
match event {
|
||||
Ok(Event::Title(title)) => assert_eq!(expected_title, title),
|
||||
_ => panic!("Expected Event::Title({expected_title}), got: {event:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_custom_title() {
|
||||
let (event_tx, event_rx) = async_channel::unbounded();
|
||||
let event_proxy = ChannelEventListener::builder_for_test()
|
||||
.with_terminal_events_tx(event_tx)
|
||||
.build();
|
||||
let mut terminal = TerminalModel::mock(None, Some(event_proxy));
|
||||
|
||||
// Empty all the events that could've been sent to this channel prior to us changing the
|
||||
// title for tests.
|
||||
while !event_rx.is_empty() {
|
||||
let _ = event_rx.try_recv();
|
||||
}
|
||||
|
||||
// Validate that we send the title event when the new custom title is set.
|
||||
let custom_title = "Custom title".to_string();
|
||||
terminal.set_custom_title(Some(custom_title.clone()));
|
||||
assert!(terminal.are_any_events_pending());
|
||||
validate_title_event(event_rx.try_recv(), custom_title);
|
||||
|
||||
// Verify that while regular `set_title` will change the self.title, it will NOT emit a new
|
||||
// Title event.
|
||||
let default_title = "Default title".to_string();
|
||||
terminal.set_title(Some(default_title.clone()));
|
||||
assert!(!terminal.are_any_events_pending());
|
||||
|
||||
// Validate that setting a custom title again works
|
||||
let another_custom_title = "Another custom title".to_string();
|
||||
terminal.set_custom_title(Some(another_custom_title.clone()));
|
||||
assert!(terminal.are_any_events_pending());
|
||||
validate_title_event(event_rx.try_recv(), another_custom_title);
|
||||
|
||||
// Validate that setting the custom title to None will emit the default title as the event
|
||||
terminal.set_custom_title(None);
|
||||
assert!(terminal.are_any_events_pending());
|
||||
validate_title_event(event_rx.try_recv(), default_title);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_within_block_points() {
|
||||
let a = WithinBlock::new(Point::new(4, 5), 1.into(), GridType::PromptAndCommand);
|
||||
let b = WithinBlock::new(Point::new(1, 0), 2.into(), GridType::PromptAndCommand);
|
||||
assert!(a < b);
|
||||
|
||||
let c = WithinBlock::new(Point::new(1, 5), 2.into(), GridType::Output);
|
||||
let d = WithinBlock::new(Point::new(4, 0), 2.into(), GridType::PromptAndCommand);
|
||||
assert!(d < c);
|
||||
|
||||
let e = WithinBlock::new(Point::new(1, 5), 2.into(), GridType::PromptAndCommand);
|
||||
let f = WithinBlock::new(Point::new(4, 0), 2.into(), GridType::PromptAndCommand);
|
||||
assert!(e < f);
|
||||
|
||||
let g = WithinBlock::new(Point::new(1, 5), 2.into(), GridType::PromptAndCommand);
|
||||
let h = WithinBlock::new(Point::new(1, 4), 2.into(), GridType::PromptAndCommand);
|
||||
assert!(h < g);
|
||||
|
||||
let i = WithinBlock::new(Point::new(1, 5), 2.into(), GridType::PromptAndCommand);
|
||||
let j = WithinBlock::new(Point::new(1, 5), 2.into(), GridType::PromptAndCommand);
|
||||
assert!(i == j);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alt_screen_toggle() {
|
||||
let mut terminal = TerminalModel::mock(None, None);
|
||||
|
||||
terminal.set_mode(ansi::Mode::SwapScreen {
|
||||
save_cursor_and_clear_screen: true,
|
||||
});
|
||||
assert!(terminal.alt_screen_active);
|
||||
|
||||
// Some programs send the control codes to enter/exit the alternate
|
||||
// screen multiple times (such as `info`). This should still leave the
|
||||
// screen in the expected state (instead of flipping back and forth).
|
||||
terminal.set_mode(ansi::Mode::SwapScreen {
|
||||
save_cursor_and_clear_screen: true,
|
||||
});
|
||||
assert!(terminal.alt_screen_active);
|
||||
|
||||
terminal.unset_mode(ansi::Mode::SwapScreen {
|
||||
save_cursor_and_clear_screen: true,
|
||||
});
|
||||
assert!(!terminal.alt_screen_active);
|
||||
|
||||
terminal.unset_mode(ansi::Mode::SwapScreen {
|
||||
save_cursor_and_clear_screen: true,
|
||||
});
|
||||
assert!(!terminal.alt_screen_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_state() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
|
||||
terminal.set_custom_title(Some("This is a title".to_owned()));
|
||||
terminal.set_title(Some("Other title".to_owned()));
|
||||
|
||||
terminal.reset_state();
|
||||
|
||||
// Make sure that the custom title is not reset.
|
||||
assert_eq!(terminal.title, None);
|
||||
assert_eq!(terminal.custom_title, Some("This is a title".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_alt_screen_on_command_finished() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
|
||||
terminal.enter_alt_screen(true);
|
||||
|
||||
terminal.command_finished(CommandFinishedValue {
|
||||
exit_code: ExitCode::from(0),
|
||||
next_block_id: BlockId::new(),
|
||||
});
|
||||
|
||||
assert!(!terminal.alt_screen_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unset_bracketed_paste_mode_on_command_finished() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
|
||||
terminal.set_mode(Mode::BracketedPaste);
|
||||
|
||||
terminal.command_finished(CommandFinishedValue {
|
||||
exit_code: ExitCode::from(0),
|
||||
next_block_id: BlockId::new(),
|
||||
});
|
||||
|
||||
assert!(!terminal.is_term_mode_set(TermMode::BRACKETED_PASTE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alt_screen_selection_tracks_scroll() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
terminal.enter_alt_screen(true);
|
||||
assert!(terminal.is_alt_screen_active());
|
||||
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
// Select an arbitrary range in the middle of the visible window.
|
||||
terminal
|
||||
.alt_screen
|
||||
.start_selection(Point::new(3, 1), SelectionType::Simple, Side::Left);
|
||||
terminal
|
||||
.alt_screen
|
||||
.update_selection(Point::new(7, 5), Side::Right);
|
||||
assert_eq!(
|
||||
terminal.alt_screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point { row: 3, col: 1 },
|
||||
end: Point { row: 7, col: 5 },
|
||||
reversed: false
|
||||
})
|
||||
);
|
||||
|
||||
// Move the cursor to the last visible row, and then add a line to that, triggering "scroll
|
||||
// down".
|
||||
terminal.alt_screen.goto_line(VisibleRow(9));
|
||||
terminal.alt_screen.linefeed();
|
||||
assert_eq!(
|
||||
terminal.alt_screen().selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point { row: 2, col: 1 },
|
||||
end: Point { row: 6, col: 5 },
|
||||
reversed: false
|
||||
})
|
||||
);
|
||||
|
||||
// Move the cursor to the top, and go up 3 times, scrolling up.
|
||||
terminal.alt_screen.goto_line(VisibleRow(0));
|
||||
terminal.alt_screen.reverse_index();
|
||||
terminal.alt_screen.reverse_index();
|
||||
terminal.alt_screen.reverse_index();
|
||||
assert_eq!(
|
||||
terminal.alt_screen().selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point { row: 5, col: 1 },
|
||||
end: Point { row: 9, col: 5 },
|
||||
reversed: false
|
||||
})
|
||||
);
|
||||
|
||||
// Scroll up some more, pushing the end of the selection past the end of the viewport.
|
||||
terminal.alt_screen.reverse_index();
|
||||
terminal.alt_screen.reverse_index();
|
||||
terminal.alt_screen.reverse_index();
|
||||
|
||||
let grid = terminal.alt_screen().grid_handler();
|
||||
let max_point = Point {
|
||||
row: grid.visible_rows() - 1,
|
||||
col: grid.columns() - 1,
|
||||
};
|
||||
assert_eq!(
|
||||
terminal.alt_screen().selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point { row: 8, col: 1 },
|
||||
end: max_point,
|
||||
reversed: false
|
||||
})
|
||||
);
|
||||
|
||||
// Test an explicit "scroll up".
|
||||
terminal.alt_screen.scroll_up(2);
|
||||
assert_eq!(
|
||||
terminal.alt_screen().selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point { row: 6, col: 1 },
|
||||
end: Point {
|
||||
row: 7,
|
||||
col: max_point.col
|
||||
},
|
||||
reversed: false
|
||||
})
|
||||
);
|
||||
|
||||
terminal.alt_screen.scroll_down(1);
|
||||
assert_eq!(
|
||||
terminal.alt_screen().selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Regular {
|
||||
start: Point { row: 7, col: 1 },
|
||||
end: Point {
|
||||
row: 8,
|
||||
col: max_point.col
|
||||
},
|
||||
reversed: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rect_selection_in_alt_screen() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
terminal.enter_alt_screen(true);
|
||||
assert!(terminal.is_alt_screen_active());
|
||||
|
||||
let semantic_selection = SemanticSelection::mock(false, "");
|
||||
|
||||
// Start a rect selection.
|
||||
terminal
|
||||
.alt_screen
|
||||
.start_selection(Point::new(2, 2), SelectionType::Rect, Side::Left);
|
||||
terminal
|
||||
.alt_screen
|
||||
.update_selection(Point::new(4, 4), Side::Right);
|
||||
assert_eq!(
|
||||
terminal.alt_screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Rect {
|
||||
rows: vec1![
|
||||
(Point { row: 2, col: 2 }, Point { row: 2, col: 4 }),
|
||||
(Point { row: 3, col: 2 }, Point { row: 3, col: 4 }),
|
||||
(Point { row: 4, col: 2 }, Point { row: 4, col: 4 }),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Scroll down and verify the selection adjusts.
|
||||
terminal.alt_screen.scroll_down(1);
|
||||
assert_eq!(
|
||||
terminal.alt_screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Rect {
|
||||
rows: vec1![
|
||||
(Point { row: 3, col: 2 }, Point { row: 3, col: 4 }),
|
||||
(Point { row: 4, col: 2 }, Point { row: 4, col: 4 }),
|
||||
(Point { row: 5, col: 2 }, Point { row: 5, col: 4 }),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Scroll up and verify the selection adjusts back.
|
||||
terminal.alt_screen.scroll_up(1);
|
||||
assert_eq!(
|
||||
terminal.alt_screen.selection_range(&semantic_selection),
|
||||
Some(ExpandedSelectionRange::Rect {
|
||||
rows: vec1![
|
||||
(Point { row: 2, col: 2 }, Point { row: 2, col: 4 }),
|
||||
(Point { row: 3, col: 2 }, Point { row: 3, col: 4 }),
|
||||
(Point { row: 4, col: 2 }, Point { row: 4, col: 4 }),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synchronized_output_sharing_session() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
|
||||
// Configure the terminal model for a shared session.
|
||||
terminal.set_shared_session_status(SharedSessionStatus::ActiveSharer);
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
terminal.set_ordered_terminal_events_for_shared_session_tx(tx);
|
||||
|
||||
// Process bytes including synchronized output markers.
|
||||
terminal.process_bytes(&b"Before\x1b[?2026hsynchronized\x1b[?2026lafter"[..]);
|
||||
|
||||
// Bytes are flushed every time synchronized output toggles, plus the trailing bytes.
|
||||
rx.close();
|
||||
let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
|
||||
assert_eq!(events.len(), 3);
|
||||
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[0] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[0]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"Before\x1b[?2026h");
|
||||
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[1] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[1]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"synchronized\x1b[?2026l");
|
||||
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[2] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[2]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"after");
|
||||
}
|
||||
|
||||
/// Tests the split-batch case where synchronized output markers arrive in separate
|
||||
/// `parse_bytes` calls on a persistent [`Processor`], preserving sync output state across calls.
|
||||
#[test]
|
||||
fn test_synchronized_output_sharing_session_split_batch() {
|
||||
let mut terminal: TerminalModel = TerminalModel::mock(None, None);
|
||||
|
||||
// Configure the terminal model for a shared session.
|
||||
terminal.set_shared_session_status(SharedSessionStatus::ActiveSharer);
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
terminal.set_ordered_terminal_events_for_shared_session_tx(tx);
|
||||
|
||||
// Use a single Processor so that synchronized output state is preserved across calls.
|
||||
let mut processor = Processor::new();
|
||||
|
||||
// First batch: contains the sync output start marker but not the end marker.
|
||||
processor.parse_bytes(
|
||||
&mut terminal,
|
||||
&b"Before\x1b[?2026hsync"[..],
|
||||
&mut std::io::sink(),
|
||||
);
|
||||
|
||||
// Second batch: contains the sync output end marker.
|
||||
processor.parse_bytes(
|
||||
&mut terminal,
|
||||
&b"hronized\x1b[?2026lafter"[..],
|
||||
&mut std::io::sink(),
|
||||
);
|
||||
|
||||
// Bytes are flushed at each toggle point and at the end of each parse_bytes call.
|
||||
rx.close();
|
||||
let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
|
||||
assert_eq!(events.len(), 4);
|
||||
|
||||
// First batch flushes at the sync start toggle, then the remaining bytes.
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[0] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[0]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"Before\x1b[?2026h");
|
||||
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[1] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[1]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"sync");
|
||||
|
||||
// Second batch flushes at the sync end toggle, then the remaining bytes.
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[2] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[2]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"hronized\x1b[?2026l");
|
||||
|
||||
let OrderedTerminalEventType::PtyBytesRead { bytes } = &events[3] else {
|
||||
panic!("Expected PtyBytesRead, got {:?}", events[3]);
|
||||
};
|
||||
assert_eq!(bytes.as_slice(), b"after");
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Utilities to help construct [`TerminalModel`]s and its
|
||||
//! constituents, like [`Block`]s and [`BlockList`]s for use
|
||||
//! in unit tests.
|
||||
//!
|
||||
//! Note that the example code in the documentation of this module
|
||||
//! is marked as no_run only because it's currently not possible
|
||||
//! to reference `#[cfg(test)]` symbols from doctests.
|
||||
|
||||
use std::{io::sink, sync::Arc};
|
||||
|
||||
use warp_core::command::ExitCode;
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
use crate::ai::blocklist::SerializedBlockListItem;
|
||||
use crate::terminal::{
|
||||
color::{self, Colors},
|
||||
event_listener::ChannelEventListener,
|
||||
BlockPadding, SizeInfo,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ansi::{CommandFinishedValue, Handler, PrecmdValue, PreexecValue, Processor},
|
||||
block::{Block, BlockId, BlockSize},
|
||||
blocks::BlockList,
|
||||
bootstrap::BootstrapStage,
|
||||
terminal_model::BlockIndex,
|
||||
ObfuscateSecrets, TerminalModel,
|
||||
};
|
||||
|
||||
pub fn block_size() -> BlockSize {
|
||||
BlockSize {
|
||||
size: SizeInfo::new_without_font_metrics(10, 7),
|
||||
block_padding: block_padding(),
|
||||
max_block_scroll_limit: 1000,
|
||||
warp_prompt_height_lines: 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
fn block_padding() -> BlockPadding {
|
||||
BlockPadding {
|
||||
padding_top: 0.2,
|
||||
command_padding_top: 0.2,
|
||||
middle: 0.5,
|
||||
bottom: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper struct for creating a [`BlockList`] for use in tests.
|
||||
///
|
||||
/// For example, to create a [`BlockList`] that respects the user's custom
|
||||
/// prompt:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use warp::terminal::model::test_utils::TestBlockListBuilder;
|
||||
/// let block_list = TestBlockListBuilder::new()
|
||||
/// .with_honor_ps1(true)
|
||||
/// .build();
|
||||
/// ```
|
||||
/// For tests that want to observe the events produced through interactions
|
||||
/// with the block list, a custom [`ChannelEventListener`] can be registered.
|
||||
///
|
||||
/// This example restores a block, and asserts that an event was sent over
|
||||
/// the channel event proxy:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use warp::terminal::event::{BlockType, Event};
|
||||
/// # use warp::terminal::event_listener::ChannelEventListener;
|
||||
/// # use warp::terminal::model::block::SerializedBlock;
|
||||
/// # use warp::terminal::model::test_utils::TestBlockListBuilder;
|
||||
///
|
||||
/// let (events_tx, events_rx) = async_channel::unbounded();
|
||||
/// let channel_event_proxy = ChannelEventListener::builder_for_test()
|
||||
/// .with_terminal_events_tx(events_tx)
|
||||
/// .build();
|
||||
///
|
||||
/// let block = SerializedBlock::new_for_test("test".into(), "test".into());
|
||||
///
|
||||
/// let block_list = TestBlockListBuilder::new()
|
||||
/// .with_channel_event_proxy(channel_event_proxy)
|
||||
/// .with_restored_blocks(&[block])
|
||||
/// .build();
|
||||
///
|
||||
/// let Ok(Event::BlockCompleted(data)) = events_rx.try_recv() else {
|
||||
/// panic!("Expected a BlockCompleted event to have been generated!");
|
||||
/// };
|
||||
///
|
||||
/// assert!(matches!(data.block_type, BlockType::Restored));
|
||||
/// ```
|
||||
pub struct TestBlockListBuilder<'a> {
|
||||
restored_blocks: Option<&'a [SerializedBlockListItem]>,
|
||||
honor_ps1: bool,
|
||||
block_sizes: BlockSize,
|
||||
channel_event_proxy: ChannelEventListener,
|
||||
}
|
||||
|
||||
impl<'a> TestBlockListBuilder<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
restored_blocks: None,
|
||||
honor_ps1: false,
|
||||
block_sizes: block_size(),
|
||||
channel_event_proxy: ChannelEventListener::new_for_test(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_restored_blocks(mut self, restored_blocks: &'a [SerializedBlockListItem]) -> Self {
|
||||
self.restored_blocks = Some(restored_blocks);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_honor_ps1(mut self, honor_ps1: bool) -> Self {
|
||||
self.honor_ps1 = honor_ps1;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_block_sizes(mut self, block_sizes: BlockSize) -> Self {
|
||||
self.block_sizes = block_sizes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_channel_event_proxy(mut self, channel_event_proxy: ChannelEventListener) -> Self {
|
||||
self.channel_event_proxy = channel_event_proxy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> BlockList {
|
||||
let mut block_list = BlockList::new(
|
||||
self.restored_blocks,
|
||||
self.block_sizes,
|
||||
self.channel_event_proxy,
|
||||
Arc::new(Background::default()),
|
||||
false, /* show_warp_bootstrap_input */
|
||||
false, /* show_warp_bootstrap_input */
|
||||
false, /* show_memory_stats */
|
||||
self.honor_ps1,
|
||||
false, /* is_inverted */
|
||||
ObfuscateSecrets::No,
|
||||
false, /* is_telemetry_enabled */
|
||||
);
|
||||
// This is usually done by the terminal manager after constructing the blocklist,
|
||||
// but we have tests assuming the separator exists.
|
||||
if self.restored_blocks.is_some() {
|
||||
block_list.append_session_restoration_separator_to_block_list(false);
|
||||
}
|
||||
block_list
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TestBlockListBuilder<'_> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper struct for creating a [`Block`] for use in tests.
|
||||
///
|
||||
/// For example, to create a [`Block`] that respects the user's custom prompt:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use warp::terminal::model::test_utils::TestBlockBuilder;
|
||||
/// let block = TestBlockBuilder::new()
|
||||
/// .with_honor_ps1(true)
|
||||
/// .build();
|
||||
/// ```
|
||||
pub struct TestBlockBuilder {
|
||||
block_index: BlockIndex,
|
||||
honor_ps1: bool,
|
||||
event_proxy: ChannelEventListener,
|
||||
size: BlockSize,
|
||||
bootstrap_stage: BootstrapStage,
|
||||
}
|
||||
|
||||
/// A helper struct for creating a [`Block`] for use in tests.
|
||||
impl TestBlockBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
block_index: BlockIndex::zero(),
|
||||
honor_ps1: false,
|
||||
event_proxy: ChannelEventListener::new_for_test(),
|
||||
size: block_size(),
|
||||
bootstrap_stage: BootstrapStage::PostBootstrapPrecmd,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_block_index(mut self, block_index: BlockIndex) -> Self {
|
||||
self.block_index = block_index;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_honor_ps1(mut self, honor_ps1: bool) -> Self {
|
||||
self.honor_ps1 = honor_ps1;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_event_proxy(mut self, event_proxy: ChannelEventListener) -> Self {
|
||||
self.event_proxy = event_proxy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_size_info(mut self, size: SizeInfo) -> Self {
|
||||
self.size.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bootstrap_stage(mut self, bootstrap_stage: BootstrapStage) -> Self {
|
||||
self.bootstrap_stage = bootstrap_stage;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Block {
|
||||
Block::new(
|
||||
BlockId::new(),
|
||||
self.size,
|
||||
self.event_proxy,
|
||||
Arc::new(Background::default()),
|
||||
self.bootstrap_stage,
|
||||
false, /* show_warp_bootstrap_input */
|
||||
false, /* show_in_band_command_blocks */
|
||||
false, /* show_memory_stats */
|
||||
self.block_index,
|
||||
self.honor_ps1,
|
||||
ObfuscateSecrets::No,
|
||||
false, /* is_telemetry_enabled */
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TestBlockBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_test_block_with_grids(
|
||||
block_index: BlockIndex,
|
||||
prompt_and_command_grid: super::blockgrid::BlockGrid,
|
||||
rprompt_grid: super::blockgrid::BlockGrid,
|
||||
output_grid: super::blockgrid::BlockGrid,
|
||||
honor_ps1: bool,
|
||||
) -> super::block::Block {
|
||||
let mut block = TestBlockBuilder::new()
|
||||
.with_block_index(block_index)
|
||||
.with_honor_ps1(honor_ps1)
|
||||
.build();
|
||||
block.set_prompt_and_command_grid(prompt_and_command_grid);
|
||||
block.set_rprompt_grid(rprompt_grid);
|
||||
block.set_output_grid(output_grid);
|
||||
block
|
||||
}
|
||||
|
||||
impl TerminalModel {
|
||||
/// Creates a simple, default [`TerminalModel`] with an optional
|
||||
/// set of restored blocks and an optional [`ChannelEventListener`]
|
||||
/// to subscribe to terminal events.
|
||||
///
|
||||
/// See [`TerminalModel::new_for_test`] for a more configurable
|
||||
/// test constructor.
|
||||
pub fn mock(
|
||||
restored_blocks: Option<&[SerializedBlockListItem]>,
|
||||
event_proxy: Option<ChannelEventListener>,
|
||||
) -> TerminalModel {
|
||||
TerminalModel::new_for_test(
|
||||
block_size(),
|
||||
color::List::from(&Colors::default()),
|
||||
event_proxy.unwrap_or_else(ChannelEventListener::new_for_test),
|
||||
Arc::new(Background::default()),
|
||||
false,
|
||||
restored_blocks,
|
||||
false,
|
||||
false, /* is_inverted */
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Simulates the creation of a block as if the `input` command
|
||||
/// was run and it produced `output` bytes.
|
||||
///
|
||||
/// This includes invoking all of the relevant hooks that
|
||||
/// would be invoked by running the command against a real PTY
|
||||
/// (e.g. pre-exec, pre-cmd).
|
||||
pub fn simulate_block<B: AsBytes>(&mut self, input: B, output: B) {
|
||||
self.simulate_long_running_block(input, output);
|
||||
self.block_list_mut()
|
||||
.active_block_mut()
|
||||
.set_was_long_running(false.into());
|
||||
self.finish_block();
|
||||
}
|
||||
|
||||
/// Simulates the creation of a long-running block as if the `input` command
|
||||
/// was run and it produced `output_so_far` bytes.
|
||||
pub fn simulate_long_running_block<B: AsBytes>(&mut self, input: B, output_so_far: B) {
|
||||
self.block_list_mut().active_block_mut().start();
|
||||
self.simulate_cmd(input);
|
||||
self.process_bytes(output_so_far);
|
||||
self.block_list_mut()
|
||||
.active_block_mut()
|
||||
.set_was_long_running(true.into());
|
||||
}
|
||||
|
||||
/// Simulates a command being run by writing the `input`
|
||||
/// bytes as input and subsequently calling pre-exec.
|
||||
///
|
||||
/// We assume that `input` forms a valid UTF-8 string.
|
||||
pub fn simulate_cmd<B: AsBytes>(&mut self, input: B) {
|
||||
self.process_bytes(input.as_bytes());
|
||||
self.preexec(PreexecValue {
|
||||
command: std::str::from_utf8(input.as_bytes()).unwrap().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulates the completion of a block.
|
||||
/// Assumes that a block was running to begin with.
|
||||
pub fn finish_block(&mut self) {
|
||||
self.command_finished(CommandFinishedValue {
|
||||
exit_code: ExitCode::from(0),
|
||||
next_block_id: BlockId::new(),
|
||||
});
|
||||
self.precmd(PrecmdValue {
|
||||
pwd: None,
|
||||
git_head: None,
|
||||
git_branch: None,
|
||||
virtual_env: None,
|
||||
conda_env: None,
|
||||
node_version: None,
|
||||
session_id: Some(0),
|
||||
kube_config: None,
|
||||
ps1: None,
|
||||
honor_ps1: None,
|
||||
rprompt: None,
|
||||
ps1_is_encoded: Some(true),
|
||||
is_after_in_band_command: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Processes a set of `bytes` and applies them to the model,
|
||||
/// akin to what happens when reading bytes from a real PTY.
|
||||
pub fn process_bytes<B: AsBytes>(&mut self, bytes: B) {
|
||||
let bytes = bytes.as_bytes();
|
||||
let mut processor = Processor::new();
|
||||
processor.parse_bytes(
|
||||
self,
|
||||
bytes,
|
||||
// For unit tests, there's no shell to write back to
|
||||
// so the writes should no-op.
|
||||
&mut sink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper trait to make it more ergonomic
|
||||
/// to use types that can be converted to a byte slice.
|
||||
pub trait AsBytes {
|
||||
fn as_bytes(&self) -> &[u8];
|
||||
}
|
||||
|
||||
impl AsBytes for &str {
|
||||
fn as_bytes(&self) -> &[u8] {
|
||||
str::as_bytes(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsBytes for &[u8] {
|
||||
fn as_bytes(&self) -> &[u8] {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use crate::util::parse_ascii_u32;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::bytes::Regex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// The below strings are used as a tag/prefix at the beginning of a response
|
||||
// from tmux to determine the type of response. These strings must be unique
|
||||
// and should not contain any special characters (alpha-num, dash, underline,
|
||||
// and space are acceptable).
|
||||
// This is because the response is parsed using regex, as well as to avoid
|
||||
// messing up tmux's shell-like parsing.
|
||||
const PRIMARY_WINDOW_PANE_PREFIX: &str = "primary window pane";
|
||||
pub const BACKGROUND_WINDOW_PREFIX: &str = "background window";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TmuxCommand {
|
||||
/// Gets the window id and pane id of the primary window pane.
|
||||
GetPrimaryWindowPane,
|
||||
/// Runs a command in the background in a new temporary window.
|
||||
RunInBackgroundWindow {
|
||||
command_id: String,
|
||||
current_directory_path: Option<String>,
|
||||
command: String,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
},
|
||||
/// Refreshes the client in Control Mode, at a target size of rows and cols.
|
||||
UpdateClientSize { num_rows: usize, num_cols: usize },
|
||||
/// Configures tmux to automatically terminate any sessions that don't have clients attached.
|
||||
SetDestroyUnattached,
|
||||
/// Forces the tmux session to inherit the smallest dimensions of any attached client.
|
||||
SetWindowSizeToSmallest,
|
||||
}
|
||||
|
||||
fn safe_env_var_name(name: &str) -> bool {
|
||||
lazy_static! {
|
||||
static ref SAFE_NAME: Regex = Regex::new(r"^[[:word:]]+$").expect("Invalid regex!");
|
||||
}
|
||||
SAFE_NAME.is_match(name.as_bytes())
|
||||
}
|
||||
|
||||
impl TmuxCommand {
|
||||
pub fn get_command_string(&self) -> String {
|
||||
// All commands must end with `\n`
|
||||
match self {
|
||||
TmuxCommand::GetPrimaryWindowPane => format!(
|
||||
"list-panes -F \"#{{?pane_active,{PRIMARY_WINDOW_PANE_PREFIX}: ,}}#{{window_id}} #{{pane_id}}\"\n"
|
||||
),
|
||||
TmuxCommand::RunInBackgroundWindow {
|
||||
current_directory_path,
|
||||
command,
|
||||
environment_variables,
|
||||
command_id,
|
||||
} => {
|
||||
// We pass the command to tmux wrapped in single quotes. The tmux control mode interface
|
||||
// interprets escapes in a bash style, so bash-escape any single quotes in the command.
|
||||
// Note that we should always use bash-style escapes here regardless of the current shell,
|
||||
// because this command is going to tmux control mode.
|
||||
let escaped_command = escape_single_quotes(command);
|
||||
let has_new_line = escaped_command.contains('\n');
|
||||
debug_assert!(
|
||||
!has_new_line,
|
||||
"Tmux control mode commands must take place on one line: `{escaped_command}`"
|
||||
);
|
||||
if has_new_line {
|
||||
log::error!(
|
||||
"Tmux control mode command contains a newline: `{escaped_command}`"
|
||||
);
|
||||
}
|
||||
// It's highly unreliable to try to strip newlines and make sure commands are still
|
||||
// valid, so we'd rather ensure no commands have newlines in them.
|
||||
let escaped_command = escaped_command.replace('\n', r"\n");
|
||||
|
||||
let set_directory = if let Some(current_directory_path) = current_directory_path {
|
||||
let escaped_path = escape_single_quotes(current_directory_path);
|
||||
format!("-c '{escaped_path}'")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut set_env_vars = String::new();
|
||||
for (name, value) in environment_variables.iter().flatten() {
|
||||
if safe_env_var_name(name) {
|
||||
set_env_vars.push_str("-e ");
|
||||
set_env_vars.push_str(name);
|
||||
set_env_vars.push_str("='");
|
||||
set_env_vars.push_str(&escape_single_quotes(value));
|
||||
set_env_vars.push_str("' ");
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a tmux command string. Tmux control mode will first parse this string with a sh-like
|
||||
// parser. The parsed command will be executed in a new interactive terminal session.
|
||||
// Some things to note:
|
||||
// - This also prints formatted window info with the new window id and pane id with `-PF "background window: #{window_id} #{pane_id}"`.
|
||||
// - This must be kept in sync with command output parsing in TmuxPerformer::tmux_message.
|
||||
// - The output is piped through `cat` to prevent the command from being directly attached
|
||||
// to a pty (and therefore risking it running in an interactive mode).
|
||||
// - This sleeps for 1 second after execution to work around tmux bug which clips largs outputs
|
||||
// when the window exits immediately.
|
||||
// - There's a newline at the end so control mode will start running the command.
|
||||
let newline = "\n";
|
||||
format!(
|
||||
r#"new-window -d {set_directory} {set_env_vars} -PF "{BACKGROUND_WINDOW_PREFIX}: #{{window_id}} #{{pane_id}}" '(builtin echo -n "^^^{command_id}|||"; {escaped_command}; builtin echo "|||$?\$\$\$")|command cat; command sleep 1'{newline}"#
|
||||
)
|
||||
}
|
||||
TmuxCommand::UpdateClientSize { num_rows, num_cols } => {
|
||||
format!("refresh-client -C {num_cols},{num_rows}\n")
|
||||
}
|
||||
TmuxCommand::SetDestroyUnattached => "set destroy-unattached on\n".to_string(),
|
||||
TmuxCommand::SetWindowSizeToSmallest => "set window-size smallest\n".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TmuxCommandResponse {
|
||||
SetPrimaryWindowPane { window_id: u32, pane_id: u32 },
|
||||
BackgroundWindow { window_id: u32, pane_id: u32 },
|
||||
}
|
||||
|
||||
pub fn parse_command(line: Vec<u8>) -> Option<TmuxCommandResponse> {
|
||||
lazy_static! {
|
||||
pub static ref PRIMARY_WINDOW_PANE_REGEX: Regex = {
|
||||
let pattern = format!(
|
||||
r"^{PRIMARY_WINDOW_PANE_PREFIX}: @([[:digit:]]+) %([[:digit:]]+)$"
|
||||
);
|
||||
Regex::new(&pattern).expect("invalid regex")
|
||||
};
|
||||
|
||||
// Must be kept in sync with the tmux command in TmuxExecutor::execute_command_internal.
|
||||
pub static ref BACKGROUND_WINDOW_REGEX: Regex = {
|
||||
let pattern = format!(
|
||||
r"^{BACKGROUND_WINDOW_PREFIX}: @([[:digit:]]+) %([[:digit:]]+)$"
|
||||
);
|
||||
Regex::new(&pattern).expect("invalid regex")
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(captures) = BACKGROUND_WINDOW_REGEX.captures(&line) {
|
||||
let window_id: u32 = parse_ascii_u32(&captures[1])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
let pane_id: u32 = parse_ascii_u32(&captures[2])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
return Some(TmuxCommandResponse::BackgroundWindow { window_id, pane_id });
|
||||
} else if let Some(captures) = PRIMARY_WINDOW_PANE_REGEX.captures(&line) {
|
||||
let window_id: u32 = parse_ascii_u32(&captures[1])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
let pane_id: u32 = parse_ascii_u32(&captures[2])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
return Some(TmuxCommandResponse::SetPrimaryWindowPane { window_id, pane_id });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Tmux control mode uses bash-style escaping, which replaces each single
|
||||
/// quote with a '"'"' sequence. The first single quote completes the
|
||||
/// single quoted string to the left, the next three characters: "'" evaluate
|
||||
/// to a literal single quote in bash/zsh, and then the final single quote
|
||||
/// starts a new single-quoted string to the right. Effectively, this
|
||||
/// concatenates the left single-quoted string, a literal single quote char,
|
||||
/// and the right single-quoted string.
|
||||
fn escape_single_quotes(command: &str) -> String {
|
||||
command.replace('\'', r#"'"'"'"#)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
pub mod commands;
|
||||
pub mod parser;
|
||||
use crate::terminal::event::ExecutedExecutorCommandEvent;
|
||||
use crate::util::parse_ascii_u32;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::bytes::{Regex, RegexBuilder};
|
||||
|
||||
pub enum ControlModeEvent {
|
||||
/// This event is sent when the control mode has started
|
||||
/// we don't have enough info from the tmux output to know
|
||||
/// the primary pane or window yet.
|
||||
Starting,
|
||||
/// Control Mode will inform us of the primary pane and window,
|
||||
/// at which point we can safely direct input to the appropriate
|
||||
/// panel.
|
||||
ControlModeReady {
|
||||
primary_window: u32,
|
||||
primary_pane: u32,
|
||||
},
|
||||
/// This event is sent when Control Mode informs us of pane output
|
||||
/// that is coming from a pane which is not the primary pane.
|
||||
BackgroundPaneOutput { pane: u32, byte: u8 },
|
||||
/// This event is sent when Control Mode has been exited.
|
||||
Exited,
|
||||
}
|
||||
|
||||
pub fn format_input(pane: u32, input: &[u8]) -> String {
|
||||
let mut formatted = String::new();
|
||||
|
||||
for chunk in input.chunks(1000) {
|
||||
formatted.push_str(&format!("send-keys -Ht %{pane}"));
|
||||
for byte in chunk {
|
||||
formatted.push_str(&format!(" {byte:X}"));
|
||||
}
|
||||
formatted.push('\n');
|
||||
}
|
||||
formatted
|
||||
}
|
||||
|
||||
pub fn parse_generator_output(input: &[u8]) -> Option<ExecutedExecutorCommandEvent> {
|
||||
lazy_static! {
|
||||
static ref GENERATOR_OUTPUT_REGEX: Regex =
|
||||
RegexBuilder::new(r"\^\^\^(.+?)\|\|\|(.*?)\|\|\|(\d+)\$\$\$")
|
||||
.dot_matches_new_line(true)
|
||||
.unicode(false)
|
||||
.build()
|
||||
.unwrap();
|
||||
/// tmux adds a carriage return to newlines that it prints, so remove that here.
|
||||
static ref NEWLINE_REGEX: Regex = Regex::new(r"\r\n").expect("Invalid regex");
|
||||
}
|
||||
|
||||
GENERATOR_OUTPUT_REGEX.captures(input).and_then(|caps| {
|
||||
let command_id = std::str::from_utf8(&caps[1]).ok()?.to_string();
|
||||
let output = NEWLINE_REGEX.replace_all(&caps[2], b"\n").to_vec();
|
||||
let exit_code = parse_ascii_u32(&caps[3])? as usize;
|
||||
|
||||
Some(ExecutedExecutorCommandEvent {
|
||||
command_id,
|
||||
output,
|
||||
exit_code,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_generator_output() {
|
||||
let input = b"^^^cmd1|||output|||0$$$";
|
||||
let result = parse_generator_output(input);
|
||||
assert!(result.is_some());
|
||||
let event = result.unwrap();
|
||||
assert_eq!(event.command_id, "cmd1");
|
||||
assert_eq!(event.output, b"output");
|
||||
assert_eq!(event.exit_code, 0);
|
||||
|
||||
let input = b"^^^cmd2|||multi\nline\noutput|||255$$$";
|
||||
let result = parse_generator_output(input);
|
||||
assert!(result.is_some());
|
||||
let event = result.unwrap();
|
||||
assert_eq!(event.command_id, "cmd2");
|
||||
assert_eq!(event.output, b"multi\nline\noutput");
|
||||
assert_eq!(event.exit_code, 255);
|
||||
|
||||
let input = b"invalid input";
|
||||
let result = parse_generator_output(input);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! This module parses output from tmux control mode.
|
||||
//!
|
||||
//! Control mode is a special interface to tmux that allows programs to interact with tmux
|
||||
//! in a structured manner, receiving information about windows, panes, and the state
|
||||
//! of the server in a machine-readable format.
|
||||
//!
|
||||
//! Refer to the tmux control mode protocol documentation for more details:
|
||||
//! https://github.com/tmux/tmux/wiki/Control-Mode
|
||||
|
||||
use crate::util::AsciiDebug;
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum TmuxMessage {
|
||||
/// This is output from a tmux command, like send-keys or new-window. This is different from
|
||||
/// pane output, which is handled separately.
|
||||
CommandOutput {
|
||||
/// Commands can succeed or fail, which is captured by the Result variant, and can have
|
||||
/// multiple lines of output.
|
||||
output_lines: Result<Vec<Vec<u8>>, Vec<Vec<u8>>>,
|
||||
},
|
||||
WindowClose {
|
||||
window_id: u32,
|
||||
},
|
||||
Exit,
|
||||
/// Only used in development.
|
||||
Unknown {
|
||||
tag: Vec<u8>,
|
||||
rest: Vec<u8>,
|
||||
},
|
||||
ParseError {
|
||||
message: &'static str,
|
||||
byte: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// Debug impl which formats the byte vecs as readable unicode strings.
|
||||
impl std::fmt::Debug for TmuxMessage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TmuxMessage::CommandOutput {
|
||||
output_lines: output,
|
||||
} => match output {
|
||||
Ok(v) => {
|
||||
let output: Vec<_> = v.iter().map(|vec| AsciiDebug(vec)).collect();
|
||||
f.debug_struct("CommandOutput")
|
||||
.field("output", &format!("Ok({output:?})"))
|
||||
.finish()
|
||||
}
|
||||
Err(e) => {
|
||||
let errors: Vec<_> = e.iter().map(|vec| AsciiDebug(vec)).collect();
|
||||
f.debug_struct("CommandOutput")
|
||||
.field("output", &format!("Err({errors:?})"))
|
||||
.finish()
|
||||
}
|
||||
},
|
||||
TmuxMessage::Exit => write!(f, "Exit"),
|
||||
TmuxMessage::Unknown { tag, rest } => f
|
||||
.debug_struct("Unknown")
|
||||
.field("tag", &AsciiDebug(tag))
|
||||
.field("rest", &AsciiDebug(rest))
|
||||
.finish(),
|
||||
TmuxMessage::ParseError { message, byte } => f
|
||||
.debug_struct("ParseError")
|
||||
.field("message", message)
|
||||
.field("byte", byte)
|
||||
.finish(),
|
||||
TmuxMessage::WindowClose { window_id } => f
|
||||
.debug_struct("WindowClose")
|
||||
.field("window_id", window_id)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs actions sent by `TmuxControlModeParser`.
|
||||
///
|
||||
/// The functions in this trait are called by the parser immediately upon parsing relevant input.
|
||||
pub trait TmuxControlModeHandler {
|
||||
/// This is called by the parser whenever a byte of output from a particular pane is parsed.
|
||||
fn pane_output(&mut self, pane: u32, byte: u8);
|
||||
|
||||
/// This is called by the parser for all other tmux messages, as soon as they've been parsed.
|
||||
/// See `TmuxMessage` for more details.
|
||||
fn tmux_control_mode_message(&mut self, message: TmuxMessage);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ParserState {
|
||||
BeginningOfLine,
|
||||
ReadingTag {
|
||||
// The longest known tag is 'unlinked-window-renamed', which is 23 characters long.
|
||||
tag: [u8; 23],
|
||||
len: u8,
|
||||
},
|
||||
|
||||
TagExit,
|
||||
TagUnknown {
|
||||
tag: Vec<u8>, // Only set in debug builds
|
||||
args: Vec<u8>, // Only set in debug builds
|
||||
},
|
||||
|
||||
TagBegin,
|
||||
ReadingCommandOutput {
|
||||
current_line: Vec<u8>,
|
||||
lines: Vec<Vec<u8>>,
|
||||
},
|
||||
|
||||
TagOutput {
|
||||
maybe_pane: Option<u32>,
|
||||
},
|
||||
ReadingPaneOutput {
|
||||
pane: u32,
|
||||
maybe_escape_sequence: Option<EscapeSequence>,
|
||||
},
|
||||
|
||||
TagWindowClose {
|
||||
maybe_window: Option<u32>,
|
||||
},
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EscapeSequence {
|
||||
char: u8,
|
||||
remaining_digits: u8,
|
||||
}
|
||||
|
||||
/// This parses output from tmux control mode. It expects the output to be well formed and does a
|
||||
/// best-effort job to identify, report, and recover from errors.
|
||||
/// Specifically:
|
||||
/// - The parser correctly parses all well-formed output.
|
||||
/// - The parser will not crash or infinite loop, whether or not the input is well-formed.
|
||||
/// - Some mal-formed input may result in an incorrect parse, but most will result in a parse error.
|
||||
/// - If the parser continues to get input after a parse error, it will attempt to recover after
|
||||
/// the next newline.
|
||||
///
|
||||
/// N.B. When working with control mode output, it's important to understand the distinction
|
||||
/// between tmux command output (e.g. the output of running the `list-windows` tmux command) with
|
||||
/// tmux pane output (e.g. the output produced by running `ls` in pane 0).
|
||||
///
|
||||
/// For more information on the expected output format, see: https://github.com/tmux/tmux/wiki/Control-Mode
|
||||
#[derive(Debug)]
|
||||
pub struct TmuxControlModeParser {
|
||||
state: ParserState,
|
||||
}
|
||||
|
||||
impl TmuxControlModeParser {
|
||||
pub fn new() -> Self {
|
||||
TmuxControlModeParser {
|
||||
state: ParserState::BeginningOfLine,
|
||||
}
|
||||
}
|
||||
|
||||
/// The primary interface to the parser. Takes a handler and the next byte of output, and
|
||||
/// calls one of the handler methods when it parses a byte of pane output or a complete tmux
|
||||
/// message.
|
||||
pub fn advance(&mut self, handler: &mut impl TmuxControlModeHandler, byte: u8) {
|
||||
if byte == b'\r' {
|
||||
// This should only ever appear directly before a \n and it simplifies parsing
|
||||
// if we just discard carriage returns and only look for newlines.
|
||||
return;
|
||||
}
|
||||
match &mut self.state {
|
||||
ParserState::BeginningOfLine => {
|
||||
// Input prior to this state: ""
|
||||
// Input parsed in this state: "%"
|
||||
if byte != b'%' {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Received non-% character at the beginning of a line",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
return;
|
||||
}
|
||||
|
||||
self.state = ParserState::ReadingTag {
|
||||
tag: Default::default(),
|
||||
len: 0,
|
||||
};
|
||||
}
|
||||
ParserState::ReadingTag { tag, len } => {
|
||||
// Input prior to this state: "%"
|
||||
// Input parsed in this state: "%[tag]"
|
||||
let current_len = *len as usize;
|
||||
if byte == b' ' || byte == b'\n' {
|
||||
// A space or a new line means we've reached the end of the tag (e.g.
|
||||
// "%something").
|
||||
match &tag[..current_len] {
|
||||
b"begin" => {
|
||||
self.state = ParserState::TagBegin;
|
||||
}
|
||||
b"exit" => {
|
||||
self.state = ParserState::TagExit;
|
||||
}
|
||||
b"output" => {
|
||||
self.state = ParserState::TagOutput { maybe_pane: None };
|
||||
}
|
||||
b"window-close" | b"unlinked-window-close" => {
|
||||
self.state = ParserState::TagWindowClose { maybe_window: None };
|
||||
}
|
||||
_ => {
|
||||
self.state = ParserState::TagUnknown {
|
||||
tag: if cfg!(debug_assertions) {
|
||||
// We only care about the contents of unknown tags in debug
|
||||
// builds.
|
||||
tag[..current_len].to_owned()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
args: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
// The parser is now set up to parse the particular message based on the tag we
|
||||
// matched. That message may be parsed differently depending on whether we've
|
||||
// encountered a space or a newline, so we call into this method again to
|
||||
// continue the parse with the same byte.
|
||||
return self.advance(handler, byte);
|
||||
}
|
||||
|
||||
// Ignore any bits that are longer than our max tag length.
|
||||
if current_len < tag.len() {
|
||||
tag[current_len] = byte;
|
||||
*len += 1;
|
||||
}
|
||||
}
|
||||
ParserState::Error => {
|
||||
// Input prior to this state: "[any unexpected input]"
|
||||
// Input parsed in this state: "[any more input]\n"
|
||||
//
|
||||
// Discard input until we see a newline and then try to recover.
|
||||
if byte == b'\n' {
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
}
|
||||
}
|
||||
|
||||
ParserState::TagExit => {
|
||||
// Input prior to this state: "%exit"
|
||||
// Input parsed in this state: "\n"
|
||||
if byte != b'\n' {
|
||||
report_parse_error(handler, "Extraneous byte after %exit", byte);
|
||||
self.state = ParserState::Error;
|
||||
return;
|
||||
}
|
||||
|
||||
handler.tmux_control_mode_message(TmuxMessage::Exit);
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
}
|
||||
|
||||
ParserState::TagUnknown { tag, args } => {
|
||||
// Input prior to this state: "%[unknown tag]"
|
||||
// Input parsed in this state: " [rest of the line]\n"
|
||||
if byte == b'\n' {
|
||||
if cfg!(debug_assertions) {
|
||||
handler.tmux_control_mode_message(TmuxMessage::Unknown {
|
||||
tag: std::mem::take(tag),
|
||||
rest: std::mem::take(args),
|
||||
});
|
||||
}
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
return;
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
// We only care about the contents of unknown tags in debug builds.
|
||||
args.push(byte);
|
||||
}
|
||||
}
|
||||
ParserState::TagBegin => {
|
||||
// Input prior to this state: "%begin"
|
||||
// Input parsed in this state: " [seconds from epoch] [unique command number] [flags]\n"
|
||||
// We don't care about any of the arguments, so discard input until we get a newline.
|
||||
if byte == b'\n' {
|
||||
self.state = ParserState::ReadingCommandOutput {
|
||||
current_line: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
ParserState::ReadingCommandOutput {
|
||||
current_line,
|
||||
lines,
|
||||
} => {
|
||||
// Input prior to this state: "%begin [seconds from epoch] [unique command number] [flags]\n"
|
||||
// Input parsed in this state: some number of "[command output line]\n" followed by "%end\n" or "%error\n"
|
||||
if byte == b'\n' {
|
||||
// Check to see if command output is complete.
|
||||
if current_line.starts_with(b"%end") {
|
||||
handler.tmux_control_mode_message(TmuxMessage::CommandOutput {
|
||||
output_lines: Ok(std::mem::take(lines)),
|
||||
});
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
} else if current_line.starts_with(b"%error") {
|
||||
handler.tmux_control_mode_message(TmuxMessage::CommandOutput {
|
||||
output_lines: Err(std::mem::take(lines)),
|
||||
});
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
} else {
|
||||
// Command output still ongoing -- append to list of lines.
|
||||
lines.push(std::mem::take(current_line));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In the middle of a line.
|
||||
current_line.push(byte);
|
||||
}
|
||||
ParserState::TagOutput { maybe_pane } => {
|
||||
// Input prior to this state: "%output"
|
||||
// Input parsed in this state: " %[pane id] "
|
||||
if let &mut Some(pane) = maybe_pane {
|
||||
// We're parsing the pane number.
|
||||
if byte.is_ascii_digit() {
|
||||
// Got another digit of the pane number.
|
||||
*maybe_pane = Some(pane * 10 + (byte - b'0') as u32);
|
||||
} else if byte == b' ' {
|
||||
// Pane number finished.
|
||||
self.state = ParserState::ReadingPaneOutput {
|
||||
pane,
|
||||
maybe_escape_sequence: None,
|
||||
};
|
||||
} else {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Non-digit character in %output pane number",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
} else {
|
||||
// We haven't started parsing the pane number yet.
|
||||
if byte == b'%' {
|
||||
// Pane number starting.
|
||||
*maybe_pane = Some(0);
|
||||
} else if byte == b' ' {
|
||||
// Ignore spaces.
|
||||
} else {
|
||||
report_parse_error(handler, "Unexpected character after %output", byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParserState::ReadingPaneOutput {
|
||||
pane,
|
||||
maybe_escape_sequence,
|
||||
} => {
|
||||
// Input prior to this state: "%output %[pane id] "
|
||||
// Input parsed in this state: "[escaped command output]\n"
|
||||
//
|
||||
// The escaped output format, according to the docs:
|
||||
// The output has any characters less than ASCII 32 and the \ character replaced
|
||||
// with their octal equivalent, so \ becomes \134. Otherwise, it is exactly what
|
||||
// the application running in the pane sent to tmux. It may not be valid UTF-8 and
|
||||
// may contain escape sequences which will be as expected by tmux (so for
|
||||
// TERM=screen or TERM=tmux).
|
||||
if let Some(escape_sequence) = maybe_escape_sequence {
|
||||
match byte {
|
||||
b'0'..=b'8' => {
|
||||
escape_sequence.remaining_digits -= 1;
|
||||
let octal_digit = byte - b'0';
|
||||
// Put each octal digit in the right spot.
|
||||
// The left shift is equivalent to a multiplication by
|
||||
// (8^remaining_digits).
|
||||
escape_sequence.char |=
|
||||
octal_digit << (escape_sequence.remaining_digits * 3);
|
||||
|
||||
if escape_sequence.remaining_digits == 0 {
|
||||
handler.pane_output(*pane, escape_sequence.char);
|
||||
*maybe_escape_sequence = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Not 0-8 in escape sequence
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Non-octal digit found in output escape sequence",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match byte {
|
||||
b'\n' => {
|
||||
// Pane output over
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
}
|
||||
b'\\' => {
|
||||
// Begin char escape
|
||||
*maybe_escape_sequence = Some(EscapeSequence {
|
||||
char: 0,
|
||||
remaining_digits: 3,
|
||||
})
|
||||
}
|
||||
byte if byte < 32 => {
|
||||
// All bytes < 32 are supposed to be escaped in output mode.
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Unescaped character < ASCII 32 found in output",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
byte => {
|
||||
// Standard input character
|
||||
handler.pane_output(*pane, byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ParserState::TagWindowClose { maybe_window } => {
|
||||
// Input prior to this state: "%window-close" | "%unlinked-window-close"
|
||||
// Input parsed in this state: " @[window id]\n"
|
||||
if let &mut Some(window) = maybe_window {
|
||||
// We're parsing the window number.
|
||||
if byte.is_ascii_digit() {
|
||||
// Got another digit of the pane number.
|
||||
*maybe_window = Some(window * 10 + (byte - b'0') as u32);
|
||||
} else if byte == b'\n' {
|
||||
// Message finished.
|
||||
handler.tmux_control_mode_message(TmuxMessage::WindowClose {
|
||||
window_id: window,
|
||||
});
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
} else {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Non-digit character in %window-close window number",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
} else {
|
||||
// We haven't started parsing the pane number yet.
|
||||
if byte == b'@' {
|
||||
// Window number starting.
|
||||
*maybe_window = Some(0);
|
||||
} else if byte == b' ' {
|
||||
// Ignore spaces.
|
||||
} else {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Unexpected character after %window-close",
|
||||
byte,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TmuxControlModeParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn report_parse_error(handler: &mut impl TmuxControlModeHandler, message: &'static str, byte: u8) {
|
||||
handler.tmux_control_mode_message(TmuxMessage::ParseError { message, byte })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parser_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,326 @@
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct TestHandler {
|
||||
output: HashMap<u32, Vec<u8>>,
|
||||
messages: Vec<TmuxMessage>,
|
||||
}
|
||||
|
||||
impl TestHandler {
|
||||
fn new() -> Self {
|
||||
TestHandler {
|
||||
output: HashMap::new(),
|
||||
messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TmuxControlModeHandler for TestHandler {
|
||||
fn pane_output(&mut self, pane: u32, byte: u8) {
|
||||
self.output.entry(pane).or_default().push(byte);
|
||||
}
|
||||
|
||||
fn tmux_control_mode_message(&mut self, message: TmuxMessage) {
|
||||
self.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_command_output_ok() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%begin 1622462330 1\ndummy output\n%end\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::CommandOutput {
|
||||
output_lines: Ok(vec![b"dummy output".to_vec()])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_command_output_err() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%begin 1622462330 1\ndummy error\n%error\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::CommandOutput {
|
||||
output_lines: Err(vec![b"dummy error".to_vec()])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_message() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%exit\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(&handler.messages[0], &TmuxMessage::Exit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_message() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%unknown something\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"unknown".to_vec(),
|
||||
rest: b" something".to_vec(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_message() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"non-percent\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
match &handler.messages[0] {
|
||||
&TmuxMessage::ParseError { message: _, byte } => {
|
||||
assert_eq!(byte, b'n');
|
||||
}
|
||||
_ => panic!("Expected Error message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 here is some output\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
handler.output.get(&0),
|
||||
Some(&b"here is some output".to_vec())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_escape_sequence() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %1 \\1345\\015\\012\\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.output.get(&1), Some(&b"\\5\r\n".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_multiline() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 first line\\012second line\\012third line\\012\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
handler.output.get(&0),
|
||||
Some(&b"first line\nsecond line\nthird line\n".to_vec())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_split() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 one\n%output %0 two\n%output %0 three\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.output.get(&0), Some(&b"onetwothree".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_incomplete() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 incomplete";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 0);
|
||||
// Output should be written immediately.
|
||||
assert_eq!(handler.output.get(&0), Some(&b"incomplete".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_incomplete_escape() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 incomplete\\01";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 0);
|
||||
// Output should be written as soon as it's ready. Incomplete escape sequences should not be
|
||||
// written.
|
||||
assert_eq!(handler.output.get(&0), Some(&b"incomplete".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_invalid_escape_sequence() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 \\a";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
match &handler.messages[0] {
|
||||
&TmuxMessage::ParseError { message: _, byte } => {
|
||||
assert_eq!(byte, b'a');
|
||||
}
|
||||
_ => panic!("Expected Error message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_invalid_character() {
|
||||
// Characters below ASCII 32 should be escaped in the output. Tab is ASCII 9.
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 \t";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
match &handler.messages[0] {
|
||||
&TmuxMessage::ParseError { message: _, byte } => {
|
||||
assert_eq!(byte, b'\t');
|
||||
}
|
||||
_ => panic!("Expected Error message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_begin_without_end() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%begin 1622462330 1\nsome output\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_example_output() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = br#"%begin 1578920019 258 0
|
||||
%end 1578920019 258 0
|
||||
%window-add @1
|
||||
%sessions-changed
|
||||
%session-changed $1 1
|
||||
%window-renamed @1 tmux
|
||||
%output %1 nicholas@yelena:~$
|
||||
%window-renamed @1 ksh
|
||||
%exit
|
||||
"#;
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 7);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::CommandOutput {
|
||||
output_lines: Ok(vec![])
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[1],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"window-add".to_vec(),
|
||||
rest: b" @1".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[2],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"sessions-changed".to_vec(),
|
||||
rest: b"".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[3],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"session-changed".to_vec(),
|
||||
rest: b" $1 1".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[4],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"window-renamed".to_vec(),
|
||||
rest: b" @1 tmux".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[5],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"window-renamed".to_vec(),
|
||||
rest: b" @1 ksh".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(&handler.messages[6], &TmuxMessage::Exit);
|
||||
assert_eq!(
|
||||
handler.output.get(&1),
|
||||
Some(&b"nicholas@yelena:~$".to_vec())
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user