mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
#451 cleaned two things on copy. The trailing trim is right and every native terminal does it. The shared leading-indent strip is this project's own rule, and it is dropped here rather than shipped. Measured against the shipped transform over 401,445 three-row windows across 1,010 tracked files in this repo, it fired on 73% of them: 92% inside a YAML workflow, 76% over `git log` output, 48% in a TypeScript source. No width threshold separates a margin from content because they are the same widths, a live Claude Code pane's own margins measuring 2 and 5 columns while the most common non-TUI shared run is 4. The failure modes are not symmetric either: a wrong trailing trim costs nothing, while a wrong dedent silently deletes information that was on the screen, with nothing in the clipboard to hint at it, on git log bodies, on indented code read out of cat (semantic in Python), on git diff context rows where the leading space is the marker, and on stack traces. It also could not be made self-consistent cheaply. Whether the first row joined the measurement depended on the mousedown COLUMN, which the user never sees, so one block of three rows produced three different clipboard results; and the flag read getSelectionPosition().start, which is xterm's mousedown anchor and is never normalised, so dragging UP through a block read it off the bottom row. The PR's test stub hardcoded a downward drag, so its suite could not express that case. The transform, the wiring, the tests, the invariants, CLAUDE.md, the wiki page and the changeset all move together. The test block now pins the ABSENCE as a contract, with the git log, Python and git diff cases as its examples, so this is not re-derived later. If it is ever revisited, the one qualification that measured clean is painted trailing padding: zero false positives over all 401,445 windows. Also from the review: the comments and invariant rule justifying the padding-only clear described the pre-change code (the Ctrl+C gate reads the CLEANED selection now, so such a selection falls through to the PTY on its own and the clear is feedback rather than protection), the new 'Nothing to copy' toast gained its zh-CN entry, and the invariants paragraph no longer repeats its own opening sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
306 lines
13 KiB
TypeScript
306 lines
13 KiB
TypeScript
/**
|
|
* What a copy actually puts on the clipboard.
|
|
*
|
|
* xterm returns whole screen rows and trims only the cells that were never
|
|
* written to, so a full-screen TUI's padding spaces reach the clipboard. These
|
|
* tests drive the SHIPPED transform (`CodemanCopySelection.clean` in
|
|
* constants.js), the SHIPPED wiring that decides column mode, and both SHIPPED
|
|
* copy paths, because the interesting failures live in the paths rather than in
|
|
* the string handling: a padding-only selection must not silently keep the
|
|
* user's Ctrl+C, and must not put a bare newline on the clipboard.
|
|
*
|
|
* A shared LEADING indent is deliberately left alone. The block below pins that
|
|
* as a contract rather than an accident, because stripping it was built and
|
|
* dropped before merge: see the rule in docs/architecture-invariants.md.
|
|
*
|
|
* Strategy: constants.js and terminal-ui.js in one vm with a stub CodemanApp,
|
|
* the harness shape test/terminal-auto-copy.test.ts uses. No DOM, no xterm.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
|
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
|
|
|
function loadHarness() {
|
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
|
const windowRef: Record<string, any> = {};
|
|
const context = vm.createContext({
|
|
window: windowRef,
|
|
document: {
|
|
body: { classList: { contains: () => false } },
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
addEventListener: () => {},
|
|
},
|
|
CodemanApp,
|
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
|
_crashDiag: { log: vi.fn() },
|
|
requestAnimationFrame: () => 1,
|
|
setTimeout: () => 1,
|
|
Blob: function Blob() {},
|
|
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
|
|
Worker: function Worker(this: any) {
|
|
this.postMessage = () => {};
|
|
},
|
|
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
|
KeyboardHandler: { keyboardVisible: false },
|
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
|
});
|
|
|
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
|
|
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
|
const toasts: { message: string; type: string }[] = [];
|
|
app.showToast = (message: string, type: string) => toasts.push({ message, type });
|
|
app._copyText = vi.fn(async () => true);
|
|
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true });
|
|
|
|
const setSelection = (selection: string, { startX = 0, columnMode = false } = {}) => {
|
|
app.terminal = {
|
|
hasSelection: () => !!selection,
|
|
getSelection: vi.fn(() => selection),
|
|
getSelectionPosition: () => ({ start: { x: startX, y: 0 }, end: { x: 0, y: 1 } }),
|
|
clearSelection: vi.fn(),
|
|
focus: vi.fn(),
|
|
_core: { _selectionService: { _activeSelectionMode: columnMode ? 3 : 0 } },
|
|
};
|
|
return app.terminal;
|
|
};
|
|
|
|
return { app, windowRef, toasts, setSelection };
|
|
}
|
|
|
|
const clean = (text: unknown) => loadHarness().windowRef.CodemanCopySelection.clean(text);
|
|
|
|
describe('CodemanCopySelection.clean — trailing padding', () => {
|
|
it('drops the padding a full-screen TUI writes across the rest of each row', () => {
|
|
expect(clean('hello \nworld ')).toBe('hello\nworld');
|
|
});
|
|
|
|
it('drops it from a single-row selection too', () => {
|
|
expect(clean('hello ')).toBe('hello');
|
|
});
|
|
|
|
it('keeps the line endings xterm chose, including the Windows \\r\\n', () => {
|
|
expect(clean('hello \r\nworld \r\n')).toBe('hello\r\nworld\r\n');
|
|
});
|
|
|
|
it('drops trailing tabs as well as trailing spaces', () => {
|
|
expect(clean('hello \t \nworld')).toBe('hello\nworld');
|
|
});
|
|
|
|
it('leaves a line that has no padding untouched', () => {
|
|
expect(clean('hello\nworld')).toBe('hello\nworld');
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — a shared leading indent is kept', () => {
|
|
// Measured over 401,445 three-row windows across 1,010 tracked files, stripping
|
|
// the run every row shares fired on 73% of them, and the transform cannot tell
|
|
// a TUI margin from content. These are the cases that settled it: each one is
|
|
// real output a user copies, and each one loses information if this changes.
|
|
it('keeps the indent every selected row shares', () => {
|
|
expect(clean(' first line\n second line')).toBe(' first line\n second line');
|
|
});
|
|
|
|
it('keeps a git log body at its four-space indent', () => {
|
|
expect(clean(' fix(terminal): trim the padding \n xterm hands back whole rows ')).toBe(
|
|
' fix(terminal): trim the padding\n xterm hands back whole rows'
|
|
);
|
|
});
|
|
|
|
it('keeps indented Python, where the indent is semantic', () => {
|
|
expect(clean(' for item in items:\n if item.ready:')).toBe(
|
|
' for item in items:\n if item.ready:'
|
|
);
|
|
});
|
|
|
|
it('keeps the leading space on git diff context rows, where it is the marker', () => {
|
|
expect(clean(' const x = 1;\n }')).toBe(' const x = 1;\n }');
|
|
});
|
|
|
|
it('is a no-op on shell output, which shares no indent anyway', () => {
|
|
expect(clean('$ ls\n indented output\ndone')).toBe('$ ls\n indented output\ndone');
|
|
});
|
|
|
|
it('still drops trailing padding on every one of those rows', () => {
|
|
expect(clean(' first \n \n second ')).toBe(' first\n\n second');
|
|
});
|
|
|
|
it('does not eat a \\r on a blank row', () => {
|
|
expect(clean(' first\r\n\r\n second\r\n')).toBe(' first\r\n\r\n second\r\n');
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — one row is treated like any other', () => {
|
|
it('leaves the indent on a single-row selection', () => {
|
|
expect(clean(' hello world ')).toBe(' hello world');
|
|
});
|
|
|
|
it('leaves it on a single row followed by a blank row', () => {
|
|
expect(clean(' hello world\n ')).toBe(' hello world\n');
|
|
});
|
|
|
|
it('leaves it when a second row carries content, exactly as for one row', () => {
|
|
expect(clean(' hello\n world')).toBe(' hello\n world');
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — nothing to clean', () => {
|
|
it('returns an empty string for an empty selection', () => {
|
|
expect(clean('')).toBe('');
|
|
});
|
|
|
|
it('returns an empty string rather than throwing on a non-string', () => {
|
|
expect(clean(undefined)).toBe('');
|
|
expect(clean(null)).toBe('');
|
|
});
|
|
|
|
it('reduces an all-padding selection to its line breaks alone', () => {
|
|
// The copy paths reject this with trim(); the transform itself only removes
|
|
// whitespace, so the row structure survives here by design.
|
|
expect(clean(' \n \n ')).toBe('\n\n');
|
|
});
|
|
});
|
|
|
|
describe('cleanedTerminalSelection — wiring', () => {
|
|
it('trims each row and leaves the shared indent alone', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection(' first \n second ');
|
|
expect(app.cleanedTerminalSelection()).toBe(' first\n second');
|
|
});
|
|
|
|
it('does not read the selection position at all', () => {
|
|
// The mid-row flag is gone. It read getSelectionPosition().start, which is
|
|
// xterm's mousedown ANCHOR and is never normalised, so an upward drag read
|
|
// it off the bottom row of the selection.
|
|
const { app, setSelection } = loadHarness();
|
|
const terminal = setSelection(' first\n second');
|
|
terminal.getSelectionPosition = vi.fn(() => ({ start: { x: 6, y: 0 }, end: { x: 0, y: 1 } }));
|
|
expect(app.cleanedTerminalSelection()).toBe(' first\n second');
|
|
expect(terminal.getSelectionPosition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the text it is given without reading the selection again', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
// The contract is that `text` IS the live selection, so the stub agrees with
|
|
// it; the assertion that carries weight is that getSelection went unread.
|
|
const terminal = setSelection(' given text \n second row ');
|
|
expect(app.cleanedTerminalSelection(' given text \n second row ')).toBe(' given text\n second row');
|
|
expect(terminal.getSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns an empty string when there is no selection at all', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection('');
|
|
expect(app.cleanedTerminalSelection()).toBe('');
|
|
});
|
|
|
|
it('leaves a column selection completely untouched', () => {
|
|
// Alt+drag makes a rectangle, and its rows lining up is the whole point:
|
|
// both halves of the clean would destroy that alignment.
|
|
const { app, setSelection } = loadHarness();
|
|
const rect = ' alpha \n beta \n gamma ';
|
|
setSelection(rect, { startX: 40, columnMode: true });
|
|
expect(app.cleanedTerminalSelection()).toBe(rect);
|
|
});
|
|
});
|
|
|
|
describe('the xterm internals the column check depends on', () => {
|
|
// The column check reads a private field and compares it to a literal, because
|
|
// xterm publishes the selection mode nowhere. A rename or a renumber would make
|
|
// every rectangular selection get cleaned with both rules and lose the column
|
|
// alignment the rule exists to protect, and the fallback is silent by design.
|
|
// So the assumption is pinned against the real library rather than only against
|
|
// a stub that repeats it. lib/xterm.js is the esbuild input for the shipped
|
|
// vendor bundle, so it is the file that decides what runs in the browser.
|
|
const xtermLib = readFileSync(resolve(import.meta.dirname, '../node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
|
|
|
|
it('still branches on _activeSelectionMode === 3 for a column selection', () => {
|
|
expect(xtermLib).toContain('3===this._activeSelectionMode');
|
|
});
|
|
|
|
it('still reaches that field through _selectionService', () => {
|
|
expect(xtermLib).toContain('_selectionService');
|
|
});
|
|
});
|
|
|
|
describe('copyTerminalSelection — what reaches the clipboard', () => {
|
|
it('copies the cleaned text, never the padded rows', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection(' first line \n second line ');
|
|
return app.copyTerminalSelection().then((ok: boolean) => {
|
|
expect(ok).toBe(true);
|
|
expect(app._copyText).toHaveBeenCalledWith(' first line\n second line');
|
|
});
|
|
});
|
|
|
|
it('cleans a realistic TUI block, padding only', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
const pane = [' That last point is the important one. ', ' Claude Code writes each paragraph. '].join(
|
|
'\n'
|
|
);
|
|
setSelection(pane);
|
|
return app.copyTerminalSelection().then(() => {
|
|
expect(app._copyText).toHaveBeenCalledWith(
|
|
' That last point is the important one.\n Claude Code writes each paragraph.'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('clears a padding-only selection rather than leaving a dead highlight', () => {
|
|
// The clear is feedback, not protection: the Ctrl+C gate tests the CLEANED
|
|
// selection, so a padding-only one falls through to the PTY either way.
|
|
const { app, toasts, setSelection } = loadHarness();
|
|
const terminal = setSelection(' ');
|
|
return app.copyTerminalSelection().then((ok: boolean) => {
|
|
expect(ok).toBe(false);
|
|
expect(app._copyText).not.toHaveBeenCalled();
|
|
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
|
|
expect(toasts).toEqual([{ message: 'Nothing to copy', type: 'warning' }]);
|
|
});
|
|
});
|
|
|
|
it('rejects a multi-row padding selection instead of copying bare newlines', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
const terminal = setSelection(' \n \n ');
|
|
return app.copyTerminalSelection().then((ok: boolean) => {
|
|
expect(ok).toBe(false);
|
|
expect(app._copyText).not.toHaveBeenCalled();
|
|
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('_flushAutoCopySelection — cleaned text is what Auto Copy handles', () => {
|
|
it('copies the cleaned text and remembers it for the dedupe', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection(' first line \n second line ');
|
|
app._autoCopyPending = true;
|
|
return app._flushAutoCopySelection().then(() => {
|
|
expect(app._copyText).toHaveBeenCalledWith(' first line\n second line');
|
|
expect(app._autoCopyLastText).toBe(' first line\n second line');
|
|
});
|
|
});
|
|
|
|
it('reads nothing at all while the toggle is off', () => {
|
|
// Auto Copy defaults to OFF, and a selection can run to the scrollback
|
|
// ceiling, so the flush must not read or clean before it checks.
|
|
const { app, setSelection } = loadHarness();
|
|
const terminal = setSelection(' first line \n second line ');
|
|
terminal.getSelection = vi.fn(() => ' first line ');
|
|
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false });
|
|
app._autoCopyPending = true;
|
|
return app._flushAutoCopySelection().then(() => {
|
|
expect(terminal.getSelection).not.toHaveBeenCalled();
|
|
expect(app._copyText).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|