refactor: move modal paste policy into editor input

This commit is contained in:
Valdemar Lorenzen
2026-10-05 14:30:40 +02:00
parent 73b15f5134
commit 01805bba0e
4 changed files with 110 additions and 23 deletions
+101 -4
View File
@@ -10,13 +10,14 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use unicode_segmentation::UnicodeSegmentation;
const MAX_EFFECT_BYTES: usize = 4 * 1024 * 1024;
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Input {
Char(char),
Ctrl(char),
Paste(String),
Escape,
Enter,
Backspace,
@@ -323,6 +324,7 @@ impl Editor {
let mut bytes = [0; 4];
self.insert_text(ch.encode_utf8(&mut bytes));
}
Input::Paste(text) => self.paste(&text),
Input::Enter => self.insert_text("\n"),
Input::Backspace => self.backspace(),
Input::Delete => self.delete_forward(),
@@ -429,7 +431,7 @@ impl Editor {
total = total
.checked_add(text.len())
.ok_or("Plugin effects exceed 4 MiB")?;
if total > MAX_EFFECT_BYTES {
if total > MAX_EDIT_BYTES {
return Err("Plugin effects exceed 4 MiB".into());
}
validate_text(text)?;
@@ -510,6 +512,27 @@ impl Editor {
}
}
fn paste(&mut self, text: &str) {
let validation = if text.len() > MAX_EDIT_BYTES {
Err("Paste exceeds 4 MiB".into())
} else {
validate_text(text)
};
if let Err(error) = validation {
self.set_message(error);
return;
}
// Preserve the separate typing / paste / typing undo groups.
self.finish_insert();
let before = (!text.is_empty()).then(|| self.snapshot());
self.insert_text(&normalize_newlines(text));
self.clamp_cursor();
if let Some(before) = before {
self.record(before);
}
self.insert_start = Some(self.snapshot());
}
fn undo(&mut self) {
if let Some(before) = self.undo.pop() {
let current = self.snapshot();
@@ -788,6 +811,80 @@ mod tests {
}
}
#[test]
fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() {
let mut editor = editor();
assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.context().text, "");
assert!(!editor.dirty());
assert!(!editor.should_quit());
editor.handle(Input::Char(':'));
type_text(&mut editor, "q");
assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None);
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "q");
assert_eq!(editor.context().text, "");
assert!(!editor.should_quit());
editor.handle(Input::Enter);
assert!(editor.should_quit());
}
#[test]
fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() {
let mut editor = editor();
editor.handle(Input::Char('i'));
type_text(&mut editor, "a");
assert_eq!(
editor.handle(Input::Paste("界e\u{301}\t\r\n:q!\r".into())),
None
);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
type_text(&mut editor, "z");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "a界e\u{301}\t\n:q!\nz");
assert!(!editor.should_quit());
for expected in ["a界e\u{301}\t\n:q!\n", "a", ""] {
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, expected);
}
for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] {
editor.handle(Input::Ctrl('r'));
assert_eq!(editor.context().text, expected);
}
}
#[test]
fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() {
for text in [
"valid prefix\ninvalid\0".to_owned(),
"\u{1b}[31m".to_owned(),
"x".repeat(MAX_EDIT_BYTES + 1),
] {
let mut editor = editor();
editor.handle(Input::Char('i'));
type_text(&mut editor, "before");
let cursor = editor.cursor();
editor.handle(Input::Paste(text));
assert_eq!(editor.context().text, "before");
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.mode(), Mode::Insert);
assert!(
editor.message().contains("Unsupported control character")
|| editor.message().contains("Paste exceeds 4 MiB")
);
type_text(&mut editor, "after");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Ctrl('r'));
assert_eq!(editor.context().text, "beforeafter");
}
}
#[test]
fn insert_sessions_and_open_lines_are_single_undo_actions() {
let mut editor = editor();
@@ -996,7 +1093,7 @@ mod tests {
assert!(
editor
.apply(vec![
Effect::Insert("a".repeat(MAX_EFFECT_BYTES)),
Effect::Insert("a".repeat(MAX_EDIT_BYTES)),
Effect::Insert("x".into())
])
.is_err()
+1 -10
View File
@@ -5,7 +5,7 @@ use std::{
};
use crate::{
editor::{Editor, Effect, Input, Invocation},
editor::{Editor, Input, Invocation},
ruby::{Config, RubyPlugin, load_config},
};
@@ -77,15 +77,6 @@ impl EditorSession {
}
}
/// Applies frontend effects, such as the TUI's existing Insert-mode paste.
/// Plugin results are applied internally by dispatch, with owner retirement
/// on rejection; frontend effects have no plugin owner to retire.
pub fn apply(&mut self, effects: Vec<Effect>) {
if let Err(error) = self.editor.apply(effects) {
self.editor.set_message(error);
}
}
fn dispatch(&mut self, invocation: Invocation) {
let result = match self.plugins.get_mut(&invocation.plugin) {
Some(plugin) => plugin
+2 -5
View File
@@ -21,7 +21,7 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Effect, Input, Mode},
editor::{Editor, Input, Mode},
session::EditorSession,
};
@@ -75,10 +75,7 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
session.handle(input);
}
}
// Bracketed paste must never execute pasted Ex commands.
Event::Paste(text) if session.editor().mode() == Mode::Insert => {
session.apply(vec![Effect::Insert(text)]);
}
Event::Paste(text) => session.handle(Input::Paste(text)),
_ => {}
}
}