Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ npm run build && npm run esbuild # Build all TypeScript and bundle
- Integration tests: `npm run test-integration` (Playwright across Chrome/Firefox/WebKit)
- Integration tests by file: `npm run test-integration -- test/playwright/InputHandler.test.ts`. Never use grep to filter tests, it doesn't work
- Integration tests by addon: `npm run test-integration --suite=addon-search`. Suites always follow the format `addon-<something>`
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them

## Addon Development Pattern

Expand Down
78 changes: 0 additions & 78 deletions addons/addon-ligatures/bin/download-fonts.js

This file was deleted.

1 change: 0 additions & 1 deletion addons/addon-ligatures/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"node": ">8.0.0"
},
"scripts": {
"prepare": "node bin/download-fonts.js",
"build": "tsc -p src",
"watch": "tsc -w -p src",
"prepackage": "npm run build",
Expand Down
49 changes: 49 additions & 0 deletions bin/lint_changes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/

// @ts-check

const { execSync, spawn } = require('child_process');
const path = require('path');

const extensions = ['.ts', '.mts'];
const fix = process.argv.includes('--fix');

// Get uncommitted changed files (staged + unstaged)
function getChangedFiles() {
try {
const output = execSync('git diff --name-only --diff-filter=ACMR HEAD', {
encoding: 'utf-8',
cwd: path.join(__dirname, '..')
});
return output.split('\n').filter(f => f && extensions.some(ext => f.endsWith(ext)));
} catch {
return [];
}
}

const files = getChangedFiles();

if (files.length === 0) {
console.log('No changed TypeScript files to lint.');
process.exit(0);
}

console.log(`Linting ${files.length} changed file(s)...`);

const eslintArgs = ['--max-warnings', '0'];
if (fix) {
eslintArgs.push('--fix');
}
eslintArgs.push(...files);

const eslint = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
const child = spawn(eslint, eslintArgs, {
stdio: 'inherit',
cwd: path.join(__dirname, '..'),
shell: process.platform === 'win32'
});

child.on('close', code => process.exit(code ?? 0));
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"test": "npm run test-unit",
"posttest": "npm run lint",
"lint": "eslint --max-warnings 0 src/ addons/ demo/",
"lint-changes": "node ./bin/lint_changes.js",
"lint-changes-fix": "node ./bin/lint_changes.js --fix",
"lint-fix": "eslint --fix src/ addons/ demo/",
"lint-api": "eslint --config eslint.config.typings.mjs --max-warnings 0 typings/",
"test-unit": "node ./bin/test_unit.js",
Expand Down
8 changes: 8 additions & 0 deletions src/browser/services/SelectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,9 @@ export class SelectionService extends Disposable implements ISelectionService {
* @param event The mouse event.
*/
private _handleSingleClick(event: MouseEvent): void {
// Track if there was a selection before clearing
const hadSelection = this.hasSelection;

this._model.selectionStartLength = 0;
this._model.isSelectAllActive = false;
this._activeSelectionMode = this.shouldColumnSelect(event) ? SelectionMode.COLUMN : SelectionMode.NORMAL;
Expand All @@ -543,6 +546,11 @@ export class SelectionService extends Disposable implements ISelectionService {
}
this._model.selectionEnd = undefined;

// Fire selection change event if a selection was cleared
if (hadSelection) {
this._fireOnSelectionChange(this._model.finalSelectionStart, this._model.finalSelectionEnd, false);
}

// Ensure the line exists
const line = this._bufferService.buffer.lines.get(this._model.selectionStart[1]);
if (!line) {
Expand Down
13 changes: 11 additions & 2 deletions src/common/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,13 @@ export class InputHandler extends Disposable implements IInputHandler {
const wraparoundMode = this._coreService.decPrivateModes.wraparound;
const insertMode = this._coreService.modes.insertMode;
const curAttr = this._curAttrData;
let bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!;
let bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y);

// Defensive check: bufferRow can be undefined if a resize occurred mid-write due to async
// scheduling gaps in WriteBuffer. See https://github.com/xtermjs/xterm.js/issues/5597
if (!bufferRow) {
return;
}

this._dirtyRowTracker.markDirty(this._activeBuffer.y);

Expand Down Expand Up @@ -586,7 +592,10 @@ export class InputHandler extends Disposable implements IInputHandler {
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true;
}
// row changed, get it again
bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!;
bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y);
if (!bufferRow) {
return;
}
if (oldWidth > 0 && bufferRow instanceof BufferLine) {
// Combining character widens 1 column to 2.
// Move old character to next line.
Expand Down
7 changes: 7 additions & 0 deletions src/common/buffer/Buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ export class Buffer implements IBuffer {
this._cols = newCols;
this._rows = newRows;

// Ensure the cursor position invariant: ybase + y must be within buffer bounds
// This can be violated during reflow or when shrinking rows
if (this.lines.length > 0) {
const maxY = Math.max(0, this.lines.length - this.ybase - 1);
this.y = Math.min(this.y, maxY);
}

this._memoryCleanupQueue.clear();
// schedule memory cleanup only, if more than 10% of the lines are affected
if (dirtyMemoryLines > 0.1 * this.lines.length) {
Expand Down
87 changes: 76 additions & 11 deletions test/playwright/Terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,17 +361,72 @@ test.describe('API Integration Tests', () => {
await pollFor(ctx.page, `window.calls`, [1, 2]);
});

test('onSelectionChange', async () => {
await openTerminal(ctx);
await ctx.page.evaluate(`
window.callCount = 0;
window.term.onSelectionChange(() => window.callCount++);
`);
await pollFor(ctx.page, `window.callCount`, 0);
await ctx.page.evaluate(`window.term.selectAll()`);
await pollFor(ctx.page, `window.callCount`, 1);
await ctx.page.evaluate(`window.term.clearSelection()`);
await pollFor(ctx.page, `window.callCount`, 2);
test.describe('onSelectionChange', () => {
let callCount: number;

test.beforeEach(async () => {
await openTerminal(ctx);
callCount = 0;
ctx.proxy.onSelectionChange(() => callCount++);
});

test('should fire for programmatic selection changes', async () => {
strictEqual(callCount, 0);
await ctx.proxy.selectAll();
strictEqual(callCount, 1);
await ctx.proxy.clearSelection();
strictEqual(callCount, 2);
});

test('should fire on mousedown when clearing selection', async () => {
await ctx.proxy.write('foo bar baz');
await ctx.proxy.selectAll();
strictEqual(callCount, 1);

const dims = (await ctx.proxy.dimensions)!;
const termRect: any = await ctx.page.evaluate(`window.term.element.getBoundingClientRect()`);
const x = termRect.left + dims.css.cell.width * 5;
const y = termRect.top + dims.css.cell.height * 0.5;
await ctx.page.mouse.click(x, y);

await pollFor(ctx.page, () => callCount, 2);
});

test('should not fire on mousedown when no prior selection', async () => {
await ctx.proxy.write('foo bar baz');
strictEqual(callCount, 0);

const dims = (await ctx.proxy.dimensions)!;
const termRect: any = await ctx.page.evaluate(`window.term.element.getBoundingClientRect()`);
const x = termRect.left + dims.css.cell.width * 5;
const y = termRect.top + dims.css.cell.height * 0.5;
await ctx.page.mouse.click(x, y);
await timeout(20);

strictEqual(callCount, 0);
});

test('should fire once on mousedown to clear, and again on mouseup after drag', async () => {
await ctx.proxy.write('foo bar baz');
await ctx.proxy.selectAll();
strictEqual(callCount, 1);

const dims = (await ctx.proxy.dimensions)!;
const termRect: any = await ctx.page.evaluate(`window.term.element.getBoundingClientRect()`);
const startX = termRect.left + dims.css.cell.width * 0.5;
const endX = termRect.left + dims.css.cell.width * 5;
const y = termRect.top + dims.css.cell.height * 0.5;

await ctx.page.mouse.move(startX, y);
await ctx.page.mouse.down();
await pollFor(ctx.page, () => callCount, 2);

await ctx.page.mouse.move(endX, y);
strictEqual(callCount, 2);

await ctx.page.mouse.up();
await pollFor(ctx.page, () => callCount, 3);
});
});

test('onRender', async () => {
Expand Down Expand Up @@ -402,6 +457,16 @@ test.describe('API Integration Tests', () => {
await pollFor(ctx.page, `window.calls`, [[10, 5], [20, 15]]);
});

test('resize during write should not throw', async () => {
await openTerminal(ctx, { rows: 50, cols: 80 });
const largeData = 'x'.repeat(10000);
await ctx.proxy.write(largeData);
await ctx.proxy.resize(80, 10);
await ctx.proxy.write(largeData);
await ctx.proxy.resize(80, 5);
await ctx.proxy.write(largeData);
});

test('onTitleChange', async () => {
await openTerminal(ctx);
await ctx.page.evaluate(`
Expand Down
Loading