From 47dabd8793a4a7e7f762cc53d47691dcdf30dd06 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 18:23:23 +0900 Subject: [PATCH] fix: keep short composer pastes as text --- crates/tui/src/input.rs | 232 +++++++++++++++++- tests/fixtures/composer-paste-policy.json | 83 +++++++ .../lib/workspace/console/composer-paste.ts | 87 +++++++ web/workspace/tests/composer-paste.test.ts | 121 +++++++++ 4 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/composer-paste-policy.json create mode 100644 web/workspace/src/lib/workspace/console/composer-paste.ts create mode 100644 web/workspace/tests/composer-paste.test.ts diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index b83b8132..f15d3673 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -15,6 +15,64 @@ use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; 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)] pub struct PasteRef { pub id: u32, @@ -262,16 +320,21 @@ impl InputBuffer { } 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; 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.cursor, Atom::Paste(PasteRef { id, - chars, - lines, + chars: measurement.chars, + lines: measurement.logical_lines, 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, + } + + #[derive(Debug, Deserialize)] + struct FixtureCase { + name: String, + parts: Vec, + 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)] mod submit_segments_tests { use super::*; @@ -904,7 +1097,8 @@ mod submit_segments_tests { for c in "see ".chars() { 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() { buf.insert_char(c); } @@ -921,9 +1115,9 @@ mod submit_segments_tests { content, .. } => { - assert_eq!(content, "line1\nline2"); - assert_eq!(*chars, "line1\nline2".chars().count() as u32); - assert_eq!(*lines, 2); + assert_eq!(content, pasted); + assert_eq!(*chars, pasted.chars().count() as u32); + assert_eq!(*lines, 4); } 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] fn restored_paste_artifact_remains_a_typed_segment() { let artifact = protocol::PasteArtifactRef { @@ -967,7 +1175,7 @@ mod submit_segments_tests { #[test] fn leading_paste_does_not_emit_empty_text() { 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(); assert_eq!(segs.len(), 1); assert!(matches!(segs[0], Segment::Paste { .. })); @@ -1067,7 +1275,7 @@ mod completion_prefix_tests { #[test] fn trigger_after_chip_atom() { 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() { buf.insert_char(c); } @@ -1176,7 +1384,7 @@ mod word_motion_tests { for c in "foo ".chars() { 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() { buf.insert_char(c); } @@ -1335,7 +1543,7 @@ mod word_motion_tests { for c in "foo ".chars() { 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() { buf.insert_char(c); } diff --git a/tests/fixtures/composer-paste-policy.json b/tests/fixtures/composer-paste-policy.json new file mode 100644 index 00000000..04eb55a0 --- /dev/null +++ b/tests/fixtures/composer-paste-policy.json @@ -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" + } + ] +} diff --git a/web/workspace/src/lib/workspace/console/composer-paste.ts b/web/workspace/src/lib/workspace/console/composer-paste.ts new file mode 100644 index 00000000..ab205a87 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-paste.ts @@ -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; +} diff --git a/web/workspace/tests/composer-paste.test.ts b/web/workspace/tests/composer-paste.test.ts new file mode 100644 index 00000000..b59850ce --- /dev/null +++ b/web/workspace/tests/composer-paste.test.ts @@ -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", + }]]); +});