diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 97325cb845..461f8fdbc3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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-` +- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them ## Addon Development Pattern diff --git a/addons/addon-ligatures/bin/download-fonts.js b/addons/addon-ligatures/bin/download-fonts.js deleted file mode 100644 index 27e75c0987..0000000000 --- a/addons/addon-ligatures/bin/download-fonts.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2018 The xterm.js authors. All rights reserved. - * @license MIT - */ - -const fs = require('fs'); -const path = require('path'); -const util = require('util'); - -const axios = require('axios').default; -const mkdirp = require('mkdirp'); -const yauzl = require('yauzl'); - -const urls = { - fira: 'https://github.com/tonsky/FiraCode/raw/d42e7276fa925e5f82748f3ec9ea429736611b48/distr/otf/FiraCode-Regular.otf', - iosevka: 'https://github.com/be5invis/Iosevka/releases/download/v1.14.3/01-iosevka-1.14.3.zip' -}; - -const writeFile = util.promisify(fs.writeFile); -const fontsFolder = path.join(__dirname, '../fonts'); - -async function download() { - await mkdirp(fontsFolder); - - try { - await downloadFiraCode(); - await downloadIosevka(); - console.log('Loaded all fonts for testing') - } catch (e) { - console.warn('Fonts failed to download, ligature tests will not work', e); - } -} - -async function downloadFiraCode() { - const file = path.join(fontsFolder, 'firaCode.otf'); - if (await util.promisify(fs.exists)(file)) { - console.log('Fira Code already loaded'); - } else { - console.log('Downloading Fira Code...'); - await writeFile( - file, - (await axios.get(urls.fira, { responseType: 'arraybuffer' })).data - ); - } -} - -async function downloadIosevka() { - const file = path.join(fontsFolder, 'iosevka.ttf'); - if (await util.promisify(fs.exists)(file)) { - console.log('Iosevka already loaded'); - } else { - console.log('Downloading Iosevka...'); - const iosevkaContents = (await axios.get(urls.iosevka, { responseType: 'arraybuffer' })).data; - const iosevkaZipfile = await util.promisify(yauzl.fromBuffer)(iosevkaContents); - await new Promise((resolve, reject) => { - iosevkaZipfile.on('entry', entry => { - if (entry.fileName === 'ttf/iosevka-regular.ttf') { - iosevkaZipfile.openReadStream(entry, (err, stream) => { - if (err) { - return reject(err); - } - - const writeStream = fs.createWriteStream(file); - stream.pipe(writeStream); - writeStream.on('close', () => resolve()); - }); - } - }); - }); - } -} - -download(); - -process.on('unhandledRejection', e => { - console.error(e); - process.exit(1); -}); diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index 5150f3838d..3117b7e803 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -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", diff --git a/bin/lint_changes.js b/bin/lint_changes.js new file mode 100644 index 0000000000..a8edb44c1d --- /dev/null +++ b/bin/lint_changes.js @@ -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)); diff --git a/package.json b/package.json index ecb60ef47e..9b54946ce8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 39d666deb3..6abd5fb9ff 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -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; @@ -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) { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index e0e6253c3b..f816e1141c 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -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); @@ -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. diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 48236bc606..ee0374f730 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -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) { diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index acd89f7352..d90b361bcf 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -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 () => { @@ -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(`