Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions crates/noa-app/src/input/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,13 @@ pub(crate) fn paste_is_unsafe(text: &str, bracketed_paste: bool) -> bool {

fn sanitize_paste_payload(bytes: &[u8]) -> Vec<u8> {
let mut sanitized = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(b"\x1b[200~") {
i += b"\x1b[200~".len();
} else if bytes[i..].starts_with(b"\x1b[201~") {
i += b"\x1b[201~".len();
} else {
sanitized.push(bytes[i]);
i += 1;
for &byte in bytes {
sanitized.push(byte);
// Match the output suffix: removing a nested marker can join its
// surrounding bytes into another marker. The remaining prefix has
// already been sanitized, so one suffix check per byte suffices.
if sanitized.ends_with(b"\x1b[200~") || sanitized.ends_with(b"\x1b[201~") {
sanitized.truncate(sanitized.len() - b"\x1b[201~".len());
}
}
sanitized
Expand Down
33 changes: 33 additions & 0 deletions crates/noa-app/src/input/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,39 @@ fn paste_strips_nested_bracket_markers_from_payload() {
);
}

#[test]
fn paste_strips_bracket_markers_reassembled_by_removal() {
for marker in ["\x1b[200~", "\x1b[201~"] {
for nested in ["\x1b[200~", "\x1b[201~"] {
for split in 1..marker.len() {
let text = format!(
"日本{}{}{}echo harmless\n",
&marker[..split],
nested,
&marker[split..]
);
assert_eq!(
encode_paste(&text, false),
Some("日本echo harmless\n".as_bytes().to_vec()),
"{text:?}"
);
assert_eq!(
encode_paste(&text, true),
Some("\x1b[200~日本echo harmless\n\x1b[201~".as_bytes().to_vec()),
"{text:?}"
);
}
}
}
}

#[test]
fn paste_strips_deeply_nested_bracket_markers() {
let text = format!("{}{}", "\x1b[20".repeat(4096), "1~".repeat(4096));
assert!(encode_paste(&text, true).is_none());
assert!(encode_paste(&text, false).is_none());
}

#[test]
fn paste_with_only_bracket_markers_emits_no_bytes() {
assert_eq!(encode_paste("\x1b[200~\x1b[201~", true), None);
Expand Down
15 changes: 12 additions & 3 deletions crates/noa-grid/src/screen/reflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use super::*;
impl Screen {
/// Resize the grid to `cols`×`rows`, reflowing soft-wrapped logical lines,
/// clamping the cursor, and resetting the scroll region to full-screen. On
/// row-shrink, rows below the cursor are dropped first; if the cursor would
/// fall off the bottom, rows are moved to scrollback and the cursor follows.
/// row-shrink, blank trailing rows below the cursor are dropped first;
/// otherwise top rows move to scrollback and the cursor follows.
pub fn resize(&mut self, cols: u16, rows: u16) {
let cols = cols.max(1);
let rows = rows.max(1);
Expand Down Expand Up @@ -110,19 +110,28 @@ impl Screen {
// Records where each old logical line landed, so placements can be
// re-anchored onto the new row numbering after reflow re-packs history.
let mut line_remaps: Vec<LineRemap> = Vec::new();
let mut content_end = 0;
let (reflowed_len, cursor_position, saved_position) = self.stream_reflow_lines(
cols,
&blank,
cursor_point,
saved_point,
|_, _| {},
|r, row| {
if !row.is_blank() || row.wrapped {
content_end = r + 1;
}
},
|remap| line_remaps.push(remap),
);

let cursor_row = cursor_position.row.min(reflowed_len.saturating_sub(1));
let max_grid_start = reflowed_len.saturating_sub(target_rows);
// A cursor above populated rows must not make the reflow discard
// those rows. Keep the last content row in the window and send any
// displaced top rows to scrollback, just like a row-only shrink.
let grid_start = cursor_row
.saturating_sub(target_rows.saturating_sub(1))
.max(content_end.saturating_sub(target_rows))
.min(max_grid_start);
let grid_end = (grid_start + target_rows).min(reflowed_len);

Expand Down
51 changes: 51 additions & 0 deletions crates/noa-grid/src/tests/text_resize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,57 @@ fn resize_shrink_rows_preserves_nonempty_rows_below_cursor() {
assert_eq!(t.primary.cursor.y, 0);
}

#[test]
fn resize_reflow_preserves_nonempty_rows_below_cursor() {
for cols in [9, 11] {
let mut t = run_size(10, 4, b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[H");

t.resize(GridSize::new(cols, 3));

assert_eq!(t.scrollback_len(), 1);
assert_eq!(t.primary.absolute_row(0).unwrap().cells[0].ch, 'A');
assert_eq!(row_text(&t, 0, 4), "BBBB");
assert_eq!(row_text(&t, 1, 4), "CCCC");
assert_eq!(row_text(&t, 2, 4), "DDDD");
assert_eq!(t.primary.cursor.y, 0);
}
}

#[test]
fn resize_reflow_preserves_wrapped_content_below_cursor() {
let mut t = run_size(8, 3, b"AAAA\r\nBBBBBBBB\r\nCCCCCCCC\x1b[H");

t.resize(GridSize::new(4, 3));

assert_eq!(t.scrollback_len(), 2);
assert_eq!(t.primary.absolute_row(0).unwrap().cells[0].ch, 'A');
let wrapped = t.primary.absolute_row(1).unwrap();
assert_eq!(wrapped.cells[0].ch, 'B');
assert!(wrapped.wrapped);
assert_eq!(row_text(&t, 0, 4), "BBBB");
assert_eq!(row_text(&t, 1, 4), "CCCC");
assert!(t.primary.grid[1].wrapped);
assert_eq!(row_text(&t, 2, 4), "CCCC");
}

#[test]
fn resize_reflow_drops_erased_trailing_rows_before_scrolling() {
let mut t = run_size(
10,
5,
b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\nEEEE\x1b[4;1H\x1b[J\x1b[3;1H\x1b7",
);

t.resize(GridSize::new(11, 3));

assert_eq!(t.scrollback_len(), 0);
assert_eq!(row_text(&t, 0, 4), "AAAA");
assert_eq!(row_text(&t, 1, 4), "BBBB");
assert_eq!(row_text(&t, 2, 4), "CCCC");
assert_eq!(t.primary.cursor.y, 2);
assert_eq!(t.primary.saved_cursor.unwrap().y, 2);
}

#[test]
fn resize_shrink_rows_treats_erased_rows_below_cursor_as_disposable() {
// Rows 4-5 held text and were then cleared with EL 2; they are visually
Expand Down