mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
#541 fixed Android autocorrect duplicating the typed line in the primary pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on space (delete a word, insert the corrected one) sent the whole line again. The fix, an edit-based diff that sends one DEL per deleted code point and then the inserted text, lives in terminal-keycode229-recovery.js together with #441's next-keydown drain (a character committed in the same task as Enter goes out ahead of the \r) and the original orphaned-insertText recovery. Only the primary pane created that controller, so a grid tile or the split's Pane B still ran xterm's stock behaviour. Both are gated on width alone (1180 CSS px), which a wide Android tablet clears. TerminalTile now creates its own controller in connect(), after the xterm opens and before the first await, handed this tile's textarea, this tile's CompositionHelper and _onTerminalData as the send path, so recovered bytes go to the tile's own session through the exactly-once queue. As in the primary pane, handleKeyEvent runs first in the custom key handler, above the keyCode-229 early return, and notifyCanonicalData sits in the onData lambda, gated on the same two CodemanTerminalInput predicates, never in _onTerminalData, which the recovered bytes also take. destroy() tears the controller down before disposing the xterm, which restores xterm's own diff and removes the capture listeners. No mode or device gate, matching the primary. The module itself is unchanged apart from its header; terminal-ui.js gains only a comment naming the twin. Tests: test/terminal-tile-input.test.ts now loads the real module into its vm harness (with window timers, without which create() would silently throw and every test would run against no controller) and drives a fake CompositionHelper carrying xterm's own append-only diff. It covers install and restore on the tile's own helper and textarea, autocorrect sent as an edit (with a control reproducing the device-log duplicate), the last character and an autocorrect each followed by Enter in one task, a self-rescued 229 key delivered once, the onData gate ignoring query replies and focus reports, two refused inserts after one keydown both recovered, robustness when the controller throws, per-tile controllers, and a source pin keeping the call above the early return. Removing the create, the handleKeyEvent call, the notify, its gate, or the destroy each turns at least one of them red, as does moving the notify into _onTerminalData. The browser suite gains a TerminalTile block in test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted execCommand input, chunks asserted to address the tile's session, with a destroyed-controller control). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
199 lines
7.1 KiB
TypeScript
199 lines
7.1 KiB
TypeScript
/**
|
|
* @fileoverview Fakes for driving a real TerminalTile (terminal-tile.js) in a
|
|
* `vm` context: its WebSocket, its xterm and the fit addon. A test puts them in
|
|
* the context as `WebSocket`, `Terminal` and `FitAddon.FitAddon`, and runs
|
|
* `connect()` and the socket handlers for real.
|
|
*/
|
|
import { vi } from 'vitest';
|
|
|
|
export type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
|
|
|
|
/** A WebSocket the test opens, feeds and closes by hand; `sent` holds every frame the tile sent. */
|
|
export class FakeSocket {
|
|
static OPEN = 1;
|
|
static instances: FakeSocket[] = [];
|
|
readyState = 0;
|
|
sent: Frame[] = [];
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
onclose: ((ev?: { code: number }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
constructor(public url: string) {
|
|
FakeSocket.instances.push(this);
|
|
}
|
|
send(data: string) {
|
|
this.sent.push(JSON.parse(data) as Frame);
|
|
}
|
|
close = vi.fn(() => {
|
|
this.readyState = 3;
|
|
});
|
|
open() {
|
|
this.readyState = 1;
|
|
this.onopen?.();
|
|
}
|
|
receive(msg: object) {
|
|
this.onmessage?.({ data: JSON.stringify(msg) });
|
|
}
|
|
inputFrames() {
|
|
return this.sent.filter((f) => f.t === 'i');
|
|
}
|
|
/** The connection drops: closed, and the tile hears `code`. */
|
|
drop(code = 1006) {
|
|
this.readyState = 3;
|
|
this.onclose?.({ code });
|
|
}
|
|
}
|
|
|
|
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
|
|
export class FakeFit {
|
|
static proposed = { cols: 80, rows: 24 };
|
|
term: FakeTerminal | null = null;
|
|
fit() {
|
|
const { cols, rows } = FakeFit.proposed;
|
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
this.term?.resize(cols, rows);
|
|
}
|
|
proposeDimensions() {
|
|
return { ...FakeFit.proposed };
|
|
}
|
|
}
|
|
|
|
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
|
|
export class FakeTerminal {
|
|
static last: FakeTerminal | null = null;
|
|
/**
|
|
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
|
|
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
|
|
* beyond the screen, a clear leaves one line, and a resize recomputes it
|
|
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them
|
|
* back). The viewport follows the bottom. Off, `baseY` stays where a test
|
|
* puts it.
|
|
*/
|
|
static emulateScroll = false;
|
|
/**
|
|
* Opt-in, set by a test BEFORE the tile connects (and reset after): extra
|
|
* fields merged into `_core`, e.g. xterm's `_compositionHelper` for the
|
|
* keyCode-229 controller, which reads it once when the tile creates it.
|
|
*/
|
|
static coreFactory: ((term: FakeTerminal) => Record<string, unknown>) | null = null;
|
|
options: Record<string, unknown>;
|
|
cols = 80;
|
|
rows = 24;
|
|
dataCb: ((data: string) => void) | null = null;
|
|
buffer = { active: { type: 'normal', viewportY: 0, baseY: 0, length: 24 } };
|
|
/** xterm's own mouse-tracking mode (DECSET 1000 and friends); 'none' while no app asked for the mouse. */
|
|
modes = { mouseTrackingMode: 'none' };
|
|
/** Lines in the buffer while emulating (the cursor line counts). */
|
|
lineCount = 1;
|
|
emulate = FakeTerminal.emulateScroll;
|
|
/** Called after an emulated resize, so a test can stand in for a reflow. */
|
|
afterResize: ((cols: number, rows: number) => void) | null = null;
|
|
/** Where the screen sits and how big a cell renders, for the click-to-cell math. */
|
|
screenRect = { left: 10, top: 20 };
|
|
element = {
|
|
querySelector: (sel: string) =>
|
|
sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null,
|
|
};
|
|
_core: Record<string, unknown> = {
|
|
_renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } },
|
|
...(FakeTerminal.coreFactory?.(this) ?? {}),
|
|
};
|
|
constructor(options: Record<string, unknown>) {
|
|
this.options = { ...options };
|
|
FakeTerminal.last = this;
|
|
}
|
|
/** Re-derives baseY (and a viewport following the bottom) from the emulated line count. */
|
|
settleRows() {
|
|
const active = this.buffer.active;
|
|
active.baseY = Math.max(0, this.lineCount - this.rows);
|
|
active.viewportY = active.baseY;
|
|
active.length = Math.max(this.lineCount, this.rows);
|
|
}
|
|
loadAddon(addon: FakeFit) {
|
|
addon.term = this;
|
|
}
|
|
open() {}
|
|
onData(cb: (data: string) => void) {
|
|
this.dataCb = cb;
|
|
}
|
|
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
|
focusListeners: Array<() => void> = [];
|
|
/** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */
|
|
textareaListeners: Array<{ type: string; fn: (ev: Record<string, unknown>) => void; capture: unknown }> = [];
|
|
textarea = {
|
|
/** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */
|
|
value: '',
|
|
addEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
|
|
if (type === 'focus') this.focusListeners.push(fn as () => void);
|
|
else this.textareaListeners.push({ type, fn, capture });
|
|
},
|
|
removeEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
|
|
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
|
|
else {
|
|
this.textareaListeners = this.textareaListeners.filter(
|
|
(l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture))
|
|
);
|
|
}
|
|
},
|
|
/** Delivers `ev` to the textarea's listeners of `type`, in registration order. */
|
|
fire: (type: string, ev: Record<string, unknown> = {}) => {
|
|
for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev });
|
|
},
|
|
};
|
|
focusTextarea() {
|
|
for (const fn of this.focusListeners) fn();
|
|
}
|
|
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
|
this.keyHandler = fn;
|
|
}
|
|
registerLinkProvider() {}
|
|
writes: string[] = [];
|
|
/** Set by a test: write callbacks never run, as on a disposed xterm. */
|
|
holdParse = false;
|
|
write(data: string, cb?: () => void) {
|
|
// An empty write puts nothing on screen; the replay queues one only to hear
|
|
// (its callback) that everything before it has been parsed.
|
|
if (data) this.writes.push(data);
|
|
if (data && this.emulate) {
|
|
this.lineCount += data.split('\n').length - 1;
|
|
this.settleRows();
|
|
}
|
|
if (!this.holdParse) cb?.();
|
|
}
|
|
clear() {
|
|
this.writes.push('<CLEAR>');
|
|
if (this.emulate) {
|
|
this.lineCount = 1;
|
|
this.settleRows();
|
|
}
|
|
}
|
|
resizes: Array<[number, number]> = [];
|
|
resize(cols: number, rows: number) {
|
|
this.resizes.push([cols, rows]);
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
if (this.emulate) {
|
|
this.settleRows();
|
|
this.afterResize?.(cols, rows);
|
|
}
|
|
}
|
|
scrollToLine() {}
|
|
scrollToTop() {}
|
|
dispose() {}
|
|
type(data: string) {
|
|
this.dataCb?.(data);
|
|
}
|
|
/** What a drag selected; '' is no selection. */
|
|
selection = '';
|
|
hasSelection() {
|
|
return this.selection !== '';
|
|
}
|
|
getSelection() {
|
|
return this.selection;
|
|
}
|
|
clearSelection = vi.fn(() => {
|
|
this.selection = '';
|
|
});
|
|
focus = vi.fn();
|
|
}
|