mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix: stop desktop dead-key doubling and bracket Ctrl+V paste
KeyCode 229 recovery forwarded a composed character that xterm had already delivered, so a desktop dead-key backtick arrived twice. create() now attaches only when the primary pointer is coarse. Ctrl+V text paste went through terminal.paste(), which drops the CSI 200~/201~ markers after a replay clears xterm's DECSET 2004 mirror. Plain text is now a hand-built bracketed frame on the session input path. Fixes #716 Co-authored-by: DDT <1786035110@qq.com>
This commit is contained in:
10 files changed
+274
-22
No files matched your search
@@ -33,11 +33,20 @@ interface FakeTrap {
|
||||
addEventListener: (ev: string, fn: TrapListener) => void;
|
||||
}
|
||||
|
||||
interface SentInput {
|
||||
sessionId: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
|
||||
firePaste: (payload: { text?: string; images?: string[] }) => void;
|
||||
/** Text handed to xterm's terminal.paste(), one entry per call. */
|
||||
pastedText: string[];
|
||||
/** Frames handed to _sendInputAsync(), in order. */
|
||||
sentInputs: SentInput[];
|
||||
/** How many times predictive-echo predictions were cleared. */
|
||||
predictionsCleared: () => number;
|
||||
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
|
||||
uploadedBatches: Array<Array<{ type: string }>>;
|
||||
/** Messages handed to showToast(), one entry per call. */
|
||||
@@ -47,7 +56,14 @@ interface Harness {
|
||||
runTimers: () => void;
|
||||
}
|
||||
|
||||
function loadPasteHarness(): Harness {
|
||||
interface PasteHarnessOptions {
|
||||
/** Session the paste trap is aimed at. Defaults to the active session. */
|
||||
sessionId?: string;
|
||||
/** Unsent overlay text. Enables the local-echo flush path when set. */
|
||||
localEchoPending?: string;
|
||||
}
|
||||
|
||||
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
|
||||
const traps: FakeTrap[] = [];
|
||||
const listeners: TrapListener[] = [];
|
||||
const attached = new Set<FakeTrap>();
|
||||
@@ -99,20 +115,51 @@ function loadPasteHarness(): Harness {
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||
|
||||
const pastedText: string[] = [];
|
||||
const sentInputs: SentInput[] = [];
|
||||
const uploadedBatches: Array<Array<{ type: string }>> = [];
|
||||
let predictionsCleared = 0;
|
||||
const app = new CodemanApp();
|
||||
app.activeSessionId = 'session-1';
|
||||
// Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed,
|
||||
// and must not ask terminal.paste() to consult that mirror (#716).
|
||||
app.terminal = {
|
||||
paste: (text: string) => pastedText.push(text),
|
||||
focus: () => {},
|
||||
modes: { bracketedPasteMode: false },
|
||||
};
|
||||
app._sendInputAsync = (sessionId: string, data: string) => {
|
||||
sentInputs.push({ sessionId, data });
|
||||
};
|
||||
app._predictiveEcho = { clearPredictions: () => { predictionsCleared += 1; } };
|
||||
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
|
||||
uploadedBatches.push(Array.from(files));
|
||||
};
|
||||
if (options.localEchoPending !== undefined) {
|
||||
app._localEchoEnabled = true;
|
||||
app._pendingInput = '';
|
||||
app._localEchoOverlay = {
|
||||
pendingText: options.localEchoPending,
|
||||
clear() {
|
||||
this.pendingText = '';
|
||||
},
|
||||
suppressBufferDetection() {},
|
||||
};
|
||||
app._flushedOffsets = new Map();
|
||||
app._flushedTexts = new Map();
|
||||
app._flushLocalEchoPending = function (this: Record<string, any>) {
|
||||
const pending = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (pending) this._pendingInput += pending;
|
||||
};
|
||||
}
|
||||
const toasts: string[] = [];
|
||||
app.showToast = (message: string) => toasts.push(message);
|
||||
|
||||
(app._handleImagePaste as () => void).call(app);
|
||||
const target = options.sessionId ? { terminal: app.terminal, sessionId: options.sessionId } : undefined;
|
||||
(app._handleImagePaste as (t?: object) => void).call(app, target);
|
||||
|
||||
return {
|
||||
firePaste({ text = '', images = [] }) {
|
||||
@@ -128,6 +175,8 @@ function loadPasteHarness(): Harness {
|
||||
for (const fn of listeners) fn(event);
|
||||
},
|
||||
pastedText,
|
||||
sentInputs,
|
||||
predictionsCleared: () => predictionsCleared,
|
||||
uploadedBatches,
|
||||
toasts,
|
||||
attachedTraps: () => attached.size,
|
||||
@@ -154,12 +203,50 @@ function loadImageInputApp() {
|
||||
}
|
||||
|
||||
describe('Ctrl+V paste trap', () => {
|
||||
it('sends clipboard text to the terminal once for a single paste event', () => {
|
||||
it('sends clipboard text once as a bracketed paste, not through xterm paste()', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ text: 'hello world' });
|
||||
|
||||
expect(h.pastedText).toEqual(['hello world']);
|
||||
// terminal.paste() would drop the markers: the harness mirror is off, which
|
||||
// is what a replay leaves behind.
|
||||
expect(h.pastedText).toEqual([]);
|
||||
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
|
||||
expect(h.predictionsCleared()).toBe(1);
|
||||
});
|
||||
|
||||
it('brackets a multi-line paste and strips nested paste markers', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
|
||||
|
||||
expect(h.pastedText).toEqual([]);
|
||||
expect(h.sentInputs).toEqual([
|
||||
{ sessionId: 'session-1', data: '\x1b[200~line one\rline two\rnested\r\x1b[201~' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('addresses the bracketed paste to the pane that received Ctrl+V', () => {
|
||||
const h = loadPasteHarness({ sessionId: 'pane-b' });
|
||||
|
||||
h.firePaste({ text: 'into pane b' });
|
||||
|
||||
expect(h.sentInputs).toEqual([{ sessionId: 'pane-b', data: '\x1b[200~into pane b\x1b[201~' }]);
|
||||
expect(h.pastedText).toEqual([]);
|
||||
});
|
||||
|
||||
it('flushes unsent local-echo text ahead of the bracketed paste', () => {
|
||||
const h = loadPasteHarness({ localEchoPending: 'hi' });
|
||||
|
||||
h.firePaste({ text: 'world\n' });
|
||||
|
||||
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: 'hi' }]);
|
||||
h.runTimers();
|
||||
expect(h.sentInputs).toEqual([
|
||||
{ sessionId: 'session-1', data: 'hi' },
|
||||
{ sessionId: 'session-1', data: '\x1b[200~world\r\x1b[201~' },
|
||||
]);
|
||||
expect(h.pastedText).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a second paste event for the same Ctrl+V', () => {
|
||||
@@ -170,7 +257,8 @@ describe('Ctrl+V paste trap', () => {
|
||||
h.firePaste({ text: 'hello world' });
|
||||
h.firePaste({ text: 'hello world' });
|
||||
|
||||
expect(h.pastedText).toEqual(['hello world']);
|
||||
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
|
||||
expect(h.pastedText).toEqual([]);
|
||||
});
|
||||
|
||||
it('uploads a pasted image once when the trap sees two paste events', () => {
|
||||
@@ -182,6 +270,7 @@ describe('Ctrl+V paste trap', () => {
|
||||
expect(h.uploadedBatches).toHaveLength(1);
|
||||
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
|
||||
expect(h.pastedText).toEqual([]);
|
||||
expect(h.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
|
||||
@@ -199,7 +288,8 @@ describe('Ctrl+V paste trap', () => {
|
||||
// Copying from a document can carry both: the text is what the user gets.
|
||||
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
|
||||
|
||||
expect(h.pastedText).toEqual(['echo hi']);
|
||||
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~echo hi\x1b[201~' }]);
|
||||
expect(h.pastedText).toEqual([]);
|
||||
expect(h.uploadedBatches).toEqual([]);
|
||||
expect(h.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,29 @@ describe('orphaned terminal input recovery wiring', () => {
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
// Headless Chromium is a fine pointer, and create() then returns null (#716).
|
||||
// The recovery cases below are the soft-keyboard path, so this page reports
|
||||
// a coarse pointer. Other media queries stay native.
|
||||
await page.addInitScript(() => {
|
||||
const native = window.matchMedia.bind(window);
|
||||
window.matchMedia = (query: string) => {
|
||||
if (query === '(pointer: coarse)') {
|
||||
return {
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
return native(query);
|
||||
};
|
||||
});
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 });
|
||||
@@ -50,6 +73,18 @@ describe('orphaned terminal input recovery wiring', () => {
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('does not install recovery when the primary pointer is fine', async () => {
|
||||
const fine = await browser.newPage();
|
||||
try {
|
||||
await fine.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await fine.waitForFunction(() => (window as any).app?.terminal?.textarea, null, { timeout: 30000 });
|
||||
const installed = await fine.evaluate(() => (window as any).app?._keyCode229Recovery ?? null);
|
||||
expect(installed).toBeNull();
|
||||
} finally {
|
||||
await fine.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive one keystroke through the real textarea and report what reached the
|
||||
* PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key
|
||||
|
||||
@@ -46,9 +46,19 @@ function inputEvent(data: string, overrides: Record<string, unknown> = {}) {
|
||||
return { data, inputType: 'insertText', isComposing: false, ...overrides };
|
||||
}
|
||||
|
||||
function harness({ screenReader = false } = {}) {
|
||||
function harness({ screenReader = false, coarse = true, matchMedia = true } = {}) {
|
||||
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||
const exposed: Record<string, any> = {};
|
||||
if (matchMedia) {
|
||||
// create() attaches only for a coarse pointer (#716). The recovery cases
|
||||
// below are the soft-keyboard behaviour, so they opt in. A fine pointer
|
||||
// (or no matchMedia at all) must get null and no listeners.
|
||||
exposed.matchMedia = (query: string) => ({
|
||||
matches: query === '(pointer: coarse)' && coarse,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
}
|
||||
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||
|
||||
const textarea = makeTextarea();
|
||||
@@ -111,6 +121,31 @@ function loadTerminalInput() {
|
||||
}
|
||||
|
||||
describe('orphaned terminal input recovery', () => {
|
||||
it('does not attach on a fine pointer, so a desktop dead key is not forwarded twice', () => {
|
||||
const h = harness({ coarse: false });
|
||||
expect(h.controller).toBeNull();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
expect(h.textarea.registrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not attach when matchMedia is missing', () => {
|
||||
// Fail closed. Treating "unknown" as coarse would put the recovery back on
|
||||
// a desktop whose matchMedia threw, which is the doubled backtick.
|
||||
const h = harness({ matchMedia: false });
|
||||
expect(h.controller).toBeNull();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('still recovers a dropped insertText on a coarse pointer', () => {
|
||||
const h = harness({ coarse: true });
|
||||
h.keydown();
|
||||
h.input('`');
|
||||
expect(h.emitted).toEqual([]);
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['`']);
|
||||
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('forwards the committed text when xterm stayed silent', () => {
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
@@ -432,7 +467,14 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"',
|
||||
*/
|
||||
function editSyncHarness() {
|
||||
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||
const exposed: Record<string, any> = {};
|
||||
const exposed: Record<string, any> = {
|
||||
// Autocorrect recovery is the coarse-pointer (soft keyboard) path.
|
||||
matchMedia: (query: string) => ({
|
||||
matches: query === '(pointer: coarse)',
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}),
|
||||
};
|
||||
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||
|
||||
const textarea = Object.assign(makeTextarea(), { value: '' });
|
||||
|
||||
@@ -48,6 +48,17 @@ function loadContext() {
|
||||
// would run against no controller at all.
|
||||
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||
// The real controller attaches only for a coarse pointer (#716). Tile tests
|
||||
// that exercise soft-keyboard recovery opt in; a test sets this false to
|
||||
// prove a desktop fine pointer installs nothing.
|
||||
__coarsePointer: true,
|
||||
matchMedia(query: string) {
|
||||
return {
|
||||
matches: query === '(pointer: coarse)' && windowStub.__coarsePointer !== false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||
@@ -151,6 +162,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
windowStub.__coarsePointer = true;
|
||||
FakeTerminal.coreFactory = null;
|
||||
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||
FakeSocket.instances = [];
|
||||
@@ -1183,6 +1195,13 @@ function lineOf(frames: Array<{ d?: string }>) {
|
||||
type ControllerTile = Tile & { _keyCode229Recovery: unknown };
|
||||
|
||||
describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => {
|
||||
it('does not install the controller on a fine pointer', async () => {
|
||||
windowStub.__coarsePointer = false;
|
||||
const { tile, term } = await connectTile(makeApp());
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
|
||||
expect(term.textareaListeners).toEqual([]);
|
||||
});
|
||||
|
||||
it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => {
|
||||
const helpers = withCompositionHelpers();
|
||||
const { tile, term } = await connectTile(makeApp());
|
||||
|
||||
Reference in new issue
Block a user