fix: keep short composer pastes as text

This commit is contained in:
2026-09-01 18:23:23 +09:00
parent 2bb661f1cf
commit 47dabd8793
4 changed files with 511 additions and 12 deletions
+220 -12
View File
@@ -15,6 +15,64 @@ use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
pub const MAX_PLAIN_TEXT_PASTE_CHARS: usize = 50;
pub const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PasteMeasurement {
pub chars: usize,
pub logical_lines: usize,
}
impl PasteMeasurement {
pub fn presentation(self) -> PastePresentation {
if self.chars <= MAX_PLAIN_TEXT_PASTE_CHARS
&& self.logical_lines <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
{
PastePresentation::Text
} else {
PastePresentation::Chip
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PastePresentation {
Text,
Chip,
}
pub fn measure_paste(content: &str) -> PasteMeasurement {
PasteMeasurement {
chars: content.chars().count(),
logical_lines: logical_line_count(content),
}
}
/// Empty content has zero logical lines. Otherwise LF, lone CR, and CRLF each
/// advance one line; a CRLF pair is one break rather than two.
pub fn logical_line_count(content: &str) -> usize {
if content.is_empty() {
return 0;
}
let mut lines = 1;
let mut chars = content.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
lines += 1;
}
'\n' => lines += 1,
_ => {}
}
}
lines
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PasteRef { pub struct PasteRef {
pub id: u32, pub id: u32,
@@ -262,16 +320,21 @@ impl InputBuffer {
} }
pub fn insert_paste(&mut self, content: String) { pub fn insert_paste(&mut self, content: String) {
let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text {
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
self.insert_str(&normalized);
return;
}
let id = self.next_paste_id; let id = self.next_paste_id;
self.next_paste_id = self.next_paste_id.wrapping_add(1); self.next_paste_id = self.next_paste_id.wrapping_add(1);
let chars = content.chars().count();
let lines = content.lines().count().max(1);
self.atoms.insert( self.atoms.insert(
self.cursor, self.cursor,
Atom::Paste(PasteRef { Atom::Paste(PasteRef {
id, id,
chars, chars: measurement.chars,
lines, lines: measurement.logical_lines,
content, content,
}), }),
); );
@@ -879,6 +942,136 @@ mod render_viewport_tests {
} }
} }
#[cfg(test)]
mod paste_policy_tests {
use super::*;
use protocol::Segment;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Fixture {
max_plain_text_chars: usize,
max_plain_text_logical_lines: usize,
cases: Vec<FixtureCase>,
}
#[derive(Debug, Deserialize)]
struct FixtureCase {
name: String,
parts: Vec<FixturePart>,
char_count: usize,
logical_line_count: usize,
presentation: FixturePresentation,
}
#[derive(Debug, Deserialize)]
struct FixturePart {
value: String,
repeat: usize,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum FixturePresentation {
Text,
Chip,
}
fn fixture() -> Fixture {
serde_json::from_str(include_str!(
"../../../tests/fixtures/composer-paste-policy.json"
))
.expect("shared composer paste policy fixture must be valid")
}
fn fixture_content(case: &FixtureCase) -> String {
case.parts
.iter()
.map(|part| part.value.repeat(part.repeat))
.collect()
}
#[test]
fn tui_follows_shared_paste_presentation_contract() {
let fixture = fixture();
assert_eq!(fixture.max_plain_text_chars, MAX_PLAIN_TEXT_PASTE_CHARS);
assert_eq!(
fixture.max_plain_text_logical_lines,
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
);
for case in fixture.cases {
let content = fixture_content(&case);
let measurement = measure_paste(&content);
let expected_presentation = match case.presentation {
FixturePresentation::Text => PastePresentation::Text,
FixturePresentation::Chip => PastePresentation::Chip,
};
assert_eq!(measurement.chars, case.char_count, "{} chars", case.name);
assert_eq!(
measurement.logical_lines, case.logical_line_count,
"{} logical lines",
case.name
);
assert_eq!(
measurement.presentation(),
expected_presentation,
"{} presentation",
case.name
);
}
}
#[test]
fn short_paste_is_editable_text_at_the_cursor() {
let mut buffer = InputBuffer::new();
buffer.insert_str("ac");
buffer.move_left();
buffer.insert_paste("b".to_owned());
assert_eq!(buffer.plain_text(), "abc");
assert!(
buffer
.atoms
.iter()
.all(|atom| matches!(atom, Atom::Char(_)))
);
assert_eq!(
buffer.submit_segments(),
vec![Segment::text("abc".to_owned())]
);
}
#[test]
fn short_multiline_paste_normalizes_line_endings_as_text() {
let mut buffer = InputBuffer::new();
buffer.insert_paste("a\r\nb\rc".to_owned());
assert_eq!(buffer.plain_text(), "a\nb\nc");
assert!(
buffer
.atoms
.iter()
.all(|atom| matches!(atom, Atom::Char(_)))
);
assert_eq!(
buffer.submit_segments(),
vec![Segment::text("a\nb\nc".to_owned())]
);
}
#[test]
fn empty_paste_is_a_noop() {
let mut buffer = InputBuffer::new();
buffer.insert_str("unchanged");
let paste_id = buffer.next_paste_id;
buffer.insert_paste(String::new());
assert_eq!(buffer.plain_text(), "unchanged");
assert_eq!(buffer.next_paste_id, paste_id);
}
}
#[cfg(test)] #[cfg(test)]
mod submit_segments_tests { mod submit_segments_tests {
use super::*; use super::*;
@@ -904,7 +1097,8 @@ mod submit_segments_tests {
for c in "see ".chars() { for c in "see ".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
buf.insert_paste("line1\nline2".into()); let pasted = "line1\nline2\nline3\nline4";
buf.insert_paste(pasted.into());
for c in " end".chars() { for c in " end".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
@@ -921,9 +1115,9 @@ mod submit_segments_tests {
content, content,
.. ..
} => { } => {
assert_eq!(content, "line1\nline2"); assert_eq!(content, pasted);
assert_eq!(*chars, "line1\nline2".chars().count() as u32); assert_eq!(*chars, pasted.chars().count() as u32);
assert_eq!(*lines, 2); assert_eq!(*lines, 4);
} }
other => panic!("expected Paste, got {other:?}"), other => panic!("expected Paste, got {other:?}"),
} }
@@ -933,6 +1127,20 @@ mod submit_segments_tests {
} }
} }
#[test]
fn restored_direct_paste_remains_a_typed_segment_without_reclassification() {
let original = Segment::Paste {
id: 7,
chars: 1,
lines: 1,
content: "x".to_owned(),
};
let mut buf = InputBuffer::new();
buf.replace_with_segments(std::slice::from_ref(&original));
assert_eq!(buf.submit_segments(), vec![original]);
}
#[test] #[test]
fn restored_paste_artifact_remains_a_typed_segment() { fn restored_paste_artifact_remains_a_typed_segment() {
let artifact = protocol::PasteArtifactRef { let artifact = protocol::PasteArtifactRef {
@@ -967,7 +1175,7 @@ mod submit_segments_tests {
#[test] #[test]
fn leading_paste_does_not_emit_empty_text() { fn leading_paste_does_not_emit_empty_text() {
let mut buf = InputBuffer::new(); let mut buf = InputBuffer::new();
buf.insert_paste("X".into()); buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
let segs = buf.submit_segments(); let segs = buf.submit_segments();
assert_eq!(segs.len(), 1); assert_eq!(segs.len(), 1);
assert!(matches!(segs[0], Segment::Paste { .. })); assert!(matches!(segs[0], Segment::Paste { .. }));
@@ -1067,7 +1275,7 @@ mod completion_prefix_tests {
#[test] #[test]
fn trigger_after_chip_atom() { fn trigger_after_chip_atom() {
let mut buf = InputBuffer::new(); let mut buf = InputBuffer::new();
buf.insert_paste("X".into()); buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
for c in "@sr".chars() { for c in "@sr".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
@@ -1176,7 +1384,7 @@ mod word_motion_tests {
for c in "foo ".chars() { for c in "foo ".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
buf.insert_paste("anything".into()); buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
for c in " bar".chars() { for c in " bar".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
@@ -1335,7 +1543,7 @@ mod word_motion_tests {
for c in "foo ".chars() { for c in "foo ".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
buf.insert_paste("anything".into()); buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
for c in " bar".chars() { for c in " bar".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
+83
View File
@@ -0,0 +1,83 @@
{
"max_plain_text_chars": 50,
"max_plain_text_logical_lines": 3,
"cases": [
{
"name": "empty",
"parts": [],
"char_count": 0,
"logical_line_count": 0,
"presentation": "text"
},
{
"name": "ascii_50",
"parts": [{ "value": "a", "repeat": 50 }],
"char_count": 50,
"logical_line_count": 1,
"presentation": "text"
},
{
"name": "ascii_51",
"parts": [{ "value": "a", "repeat": 51 }],
"char_count": 51,
"logical_line_count": 1,
"presentation": "chip"
},
{
"name": "unicode_scalar_50",
"parts": [{ "value": "🦀", "repeat": 50 }],
"char_count": 50,
"logical_line_count": 1,
"presentation": "text"
},
{
"name": "unicode_scalar_51",
"parts": [{ "value": "🦀", "repeat": 51 }],
"char_count": 51,
"logical_line_count": 1,
"presentation": "chip"
},
{
"name": "three_lf_lines",
"parts": [{ "value": "alpha\nbeta\ngamma", "repeat": 1 }],
"char_count": 16,
"logical_line_count": 3,
"presentation": "text"
},
{
"name": "four_lf_lines",
"parts": [{ "value": "a\nb\nc\nd", "repeat": 1 }],
"char_count": 7,
"logical_line_count": 4,
"presentation": "chip"
},
{
"name": "three_crlf_lines",
"parts": [{ "value": "a\r\nb\r\nc", "repeat": 1 }],
"char_count": 7,
"logical_line_count": 3,
"presentation": "text"
},
{
"name": "mixed_line_endings",
"parts": [{ "value": "a\r\nb\rc\nd", "repeat": 1 }],
"char_count": 8,
"logical_line_count": 4,
"presentation": "chip"
},
{
"name": "trailing_newline",
"parts": [{ "value": "a\n", "repeat": 1 }],
"char_count": 2,
"logical_line_count": 2,
"presentation": "text"
},
{
"name": "whitespace_rich_50",
"parts": [{ "value": " \t", "repeat": 25 }],
"char_count": 50,
"logical_line_count": 1,
"presentation": "text"
}
]
}
@@ -0,0 +1,87 @@
export const MAX_PLAIN_TEXT_PASTE_CHARS = 50;
export const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES = 3;
export type ComposerPastePresentation = "text" | "chip";
export interface ComposerPasteMeasurement {
charCount: number;
logicalLineCount: number;
presentation: ComposerPastePresentation;
}
export interface ComposerPasteEvent {
clipboardData: { getData(format: string): string } | null;
preventDefault(): void;
}
/**
* Count user-visible Unicode scalar values rather than UTF-16 code units.
* JavaScript string iteration combines a valid surrogate pair into one value.
*/
export function unicodeScalarCount(content: string): number {
let count = 0;
for (const _value of content) count += 1;
return count;
}
/**
* Empty content has zero logical lines. Otherwise each LF, lone CR, or CRLF
* advances one line; CRLF is one break rather than two.
*/
export function logicalLineCount(content: string): number {
if (content.length === 0) return 0;
let count = 1;
for (let index = 0; index < content.length; index += 1) {
const codeUnit = content.charCodeAt(index);
if (codeUnit === 0x0d) {
if (content.charCodeAt(index + 1) === 0x0a) index += 1;
count += 1;
} else if (codeUnit === 0x0a) {
count += 1;
}
}
return count;
}
export function measureComposerPaste(
content: string,
): ComposerPasteMeasurement {
const charCount = unicodeScalarCount(content);
const logicalLineCountValue = logicalLineCount(content);
const presentation = charCount <= MAX_PLAIN_TEXT_PASTE_CHARS &&
logicalLineCountValue <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
? "text"
: "chip";
return {
charCount,
logicalLineCount: logicalLineCountValue,
presentation,
};
}
/**
* Route a Browser paste without disrupting native short-text editing.
*
* Returning false means the caller must leave the event untouched, preserving
* the Browser's cursor, selection replacement, and undo behavior. A chip paste
* is consumed exactly once and handed to the compact-paste implementation.
*/
export function handleComposerPaste(
event: ComposerPasteEvent,
insertCompactPaste: (
content: string,
measurement: ComposerPasteMeasurement,
) => void,
): boolean {
if (!event.clipboardData) return false;
const content = event.clipboardData.getData("text/plain");
const measurement = measureComposerPaste(content);
if (measurement.presentation === "text") return false;
event.preventDefault();
insertCompactPaste(content, measurement);
return true;
}
+121
View File
@@ -0,0 +1,121 @@
import fixtureJson from "../../../tests/fixtures/composer-paste-policy.json" with {
type: "json",
};
import {
type ComposerPasteMeasurement,
handleComposerPaste,
MAX_PLAIN_TEXT_PASTE_CHARS,
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
measureComposerPaste,
} from "../src/lib/workspace/console/composer-paste.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assertEquals(
actual: unknown,
expected: unknown,
message?: string,
): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(
`${
message ? `${message}: ` : ""
}expected ${expectedJson}, got ${actualJson}`,
);
}
}
interface FixturePart {
value: string;
repeat: number;
}
interface FixtureCase {
name: string;
parts: FixturePart[];
char_count: number;
logical_line_count: number;
presentation: "text" | "chip";
}
interface PastePolicyFixture {
max_plain_text_chars: number;
max_plain_text_logical_lines: number;
cases: FixtureCase[];
}
const fixture = fixtureJson as PastePolicyFixture;
function fixtureContent(testCase: FixtureCase): string {
return testCase.parts.map(({ value, repeat }) => value.repeat(repeat)).join(
"",
);
}
Deno.test("Browser composer follows the shared paste presentation contract", () => {
assertEquals(MAX_PLAIN_TEXT_PASTE_CHARS, fixture.max_plain_text_chars);
assertEquals(
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
fixture.max_plain_text_logical_lines,
);
for (const testCase of fixture.cases) {
assertEquals(
measureComposerPaste(fixtureContent(testCase)),
{
charCount: testCase.char_count,
logicalLineCount: testCase.logical_line_count,
presentation: testCase.presentation,
},
testCase.name,
);
}
});
Deno.test("short paste remains a native Browser edit", () => {
let prevented = false;
let inserted = 0;
const handled = handleComposerPaste(
{
clipboardData: { getData: () => "replace the selection" },
preventDefault: () => {
prevented = true;
},
},
() => {
inserted += 1;
},
);
assertEquals(handled, false);
assertEquals(prevented, false);
assertEquals(inserted, 0);
});
Deno.test("chip paste is prevented and routed exactly once", () => {
const content = "🦀".repeat(51);
let prevented = 0;
const inserted: Array<[string, ComposerPasteMeasurement]> = [];
const handled = handleComposerPaste(
{
clipboardData: { getData: () => content },
preventDefault: () => {
prevented += 1;
},
},
(paste, measurement) => inserted.push([paste, measurement]),
);
assertEquals(handled, true);
assertEquals(prevented, 1);
assertEquals(inserted, [[content, {
charCount: 51,
logicalLineCount: 1,
presentation: "chip",
}]]);
});