fix(paste) incorrect handling of \\\n in pastes (#6532)
This commit is contained in:
parent
ed1fc4ddb3
commit
2143731f6e
|
@ -1211,6 +1211,43 @@ describe('InputPrompt', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('multiline paste', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
description: 'with \n newlines',
|
||||||
|
pastedText: 'This \n is \n a \n multiline \n paste.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'with extra slashes before \n newlines',
|
||||||
|
pastedText: 'This \\\n is \\\n a \\\n multiline \\\n paste.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'with \r\n newlines',
|
||||||
|
pastedText: 'This\r\nis\r\na\r\nmultiline\r\npaste.',
|
||||||
|
},
|
||||||
|
])('should handle multiline paste $description', async ({ pastedText }) => {
|
||||||
|
const { stdin, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Simulate a bracketed paste event from the terminal
|
||||||
|
stdin.write(`\x1b[200~${pastedText}\x1b[201~`);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Verify that the buffer's handleInput was called once with the full text
|
||||||
|
expect(props.buffer.handleInput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.buffer.handleInput).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
paste: true,
|
||||||
|
sequence: pastedText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('enhanced input UX - double ESC clear functionality', () => {
|
describe('enhanced input UX - double ESC clear functionality', () => {
|
||||||
it('should clear buffer on second ESC press', async () => {
|
it('should clear buffer on second ESC press', async () => {
|
||||||
const onEscapePromptChange = vi.fn();
|
const onEscapePromptChange = vi.fn();
|
||||||
|
|
|
@ -239,6 +239,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.paste) {
|
||||||
|
// Ensure we never accidentally interpret paste as regular input.
|
||||||
|
buffer.handleInput(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (vimHandleInput && vimHandleInput(key)) {
|
if (vimHandleInput && vimHandleInput(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1833,6 +1833,13 @@ export function useTextBuffer({
|
||||||
}): void => {
|
}): void => {
|
||||||
const { sequence: input } = key;
|
const { sequence: input } = key;
|
||||||
|
|
||||||
|
if (key.paste) {
|
||||||
|
// Do not do any other processing on pastes so ensure we handle them
|
||||||
|
// before all other cases.
|
||||||
|
insert(input, { paste: key.paste });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
key.name === 'return' ||
|
key.name === 'return' ||
|
||||||
input === '\r' ||
|
input === '\r' ||
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
import { vi, Mock } from 'vitest';
|
import { vi, Mock } from 'vitest';
|
||||||
import {
|
import {
|
||||||
KeypressProvider,
|
KeypressProvider,
|
||||||
|
@ -28,18 +28,6 @@ vi.mock('ink', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the 'readline' module
|
|
||||||
vi.mock('readline', () => {
|
|
||||||
const mockedReadline = {
|
|
||||||
createInterface: vi.fn().mockReturnValue({ close: vi.fn() }),
|
|
||||||
emitKeypressEvents: vi.fn(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...mockedReadline,
|
|
||||||
default: mockedReadline,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
class MockStdin extends EventEmitter {
|
class MockStdin extends EventEmitter {
|
||||||
isTTY = true;
|
isTTY = true;
|
||||||
setRawMode = vi.fn();
|
setRawMode = vi.fn();
|
||||||
|
@ -47,6 +35,7 @@ class MockStdin extends EventEmitter {
|
||||||
override removeListener = super.removeListener;
|
override removeListener = super.removeListener;
|
||||||
write = vi.fn();
|
write = vi.fn();
|
||||||
resume = vi.fn();
|
resume = vi.fn();
|
||||||
|
pause = vi.fn();
|
||||||
|
|
||||||
// Helper to simulate a keypress event
|
// Helper to simulate a keypress event
|
||||||
pressKey(key: Partial<Key>) {
|
pressKey(key: Partial<Key>) {
|
||||||
|
@ -55,32 +44,16 @@ class MockStdin extends EventEmitter {
|
||||||
|
|
||||||
// Helper to simulate a kitty protocol sequence
|
// Helper to simulate a kitty protocol sequence
|
||||||
sendKittySequence(sequence: string) {
|
sendKittySequence(sequence: string) {
|
||||||
// Kitty sequences come in multiple parts
|
this.emit('data', Buffer.from(sequence));
|
||||||
// For example, ESC[13u comes as: ESC[ then 1 then 3 then u
|
}
|
||||||
// ESC[57414;2u comes as: ESC[ then 5 then 7 then 4 then 1 then 4 then ; then 2 then u
|
|
||||||
const escIndex = sequence.indexOf('\x1b[');
|
|
||||||
if (escIndex !== -1) {
|
|
||||||
// Send ESC[
|
|
||||||
this.emit('keypress', null, {
|
|
||||||
name: undefined,
|
|
||||||
sequence: '\x1b[',
|
|
||||||
ctrl: false,
|
|
||||||
meta: false,
|
|
||||||
shift: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the rest character by character
|
// Helper to simulate a paste event
|
||||||
const rest = sequence.substring(escIndex + 2);
|
sendPaste(text: string) {
|
||||||
for (const char of rest) {
|
const PASTE_MODE_PREFIX = `\x1b[200~`;
|
||||||
this.emit('keypress', null, {
|
const PASTE_MODE_SUFFIX = `\x1b[201~`;
|
||||||
name: /[a-zA-Z0-9]/.test(char) ? char : undefined,
|
this.emit('data', Buffer.from(PASTE_MODE_PREFIX));
|
||||||
sequence: char,
|
this.emit('data', Buffer.from(text));
|
||||||
ctrl: false,
|
this.emit('data', Buffer.from(PASTE_MODE_SUFFIX));
|
||||||
meta: false,
|
|
||||||
shift: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -304,4 +277,37 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('paste mode', () => {
|
||||||
|
it('should handle multiline paste as a single event', async () => {
|
||||||
|
const keyHandler = vi.fn();
|
||||||
|
const pastedText = 'This \nis \na \nmultiline \npaste.';
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useKeypressContext(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.subscribe(keyHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate a bracketed paste event
|
||||||
|
act(() => {
|
||||||
|
stdin.sendPaste(pastedText);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Expect the handler to be called exactly once for the entire paste
|
||||||
|
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the single event contains the full pasted text
|
||||||
|
expect(keyHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
paste: true,
|
||||||
|
sequence: pastedText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -172,6 +172,29 @@ export function KeypressProvider({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeypress = (_: unknown, key: Key) => {
|
const handleKeypress = (_: unknown, key: Key) => {
|
||||||
|
if (key.name === 'paste-start') {
|
||||||
|
isPaste = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.name === 'paste-end') {
|
||||||
|
isPaste = false;
|
||||||
|
broadcast({
|
||||||
|
name: '',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: true,
|
||||||
|
sequence: pasteBuffer.toString(),
|
||||||
|
});
|
||||||
|
pasteBuffer = Buffer.alloc(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaste) {
|
||||||
|
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (key.name === 'return' && waitingForEnterAfterBackslash) {
|
if (key.name === 'return' && waitingForEnterAfterBackslash) {
|
||||||
if (backslashTimeout) {
|
if (backslashTimeout) {
|
||||||
clearTimeout(backslashTimeout);
|
clearTimeout(backslashTimeout);
|
||||||
|
@ -278,29 +301,10 @@ export function KeypressProvider({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'paste-start') {
|
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
|
||||||
isPaste = true;
|
key.meta = true;
|
||||||
} else if (key.name === 'paste-end') {
|
|
||||||
isPaste = false;
|
|
||||||
broadcast({
|
|
||||||
name: '',
|
|
||||||
ctrl: false,
|
|
||||||
meta: false,
|
|
||||||
shift: false,
|
|
||||||
paste: true,
|
|
||||||
sequence: pasteBuffer.toString(),
|
|
||||||
});
|
|
||||||
pasteBuffer = Buffer.alloc(0);
|
|
||||||
} else {
|
|
||||||
if (isPaste) {
|
|
||||||
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
|
|
||||||
} else {
|
|
||||||
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
|
|
||||||
key.meta = true;
|
|
||||||
}
|
|
||||||
broadcast({ ...key, paste: isPaste });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
broadcast({ ...key, paste: isPaste });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRawKeypress = (data: Buffer) => {
|
const handleRawKeypress = (data: Buffer) => {
|
||||||
|
|
Loading…
Reference in New Issue