diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 500308bd1..8988d9fc8 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -158,6 +158,8 @@ export class WebglRenderer extends Disposable implements IRenderer { this._isAttached = this._core.screenElement!.isConnected; this._register(toDisposable(() => { + clearTimeout(this._contextRestorationTimeout); + this._contextRestorationTimeout = undefined; for (const l of this._renderLayers) { l.dispose(); } diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 52eb00025..e44b1717e 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -563,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { try { this._onWillOpen.fire(this.element); + } catch (e) { + this._logService.error('onWillOpen handler threw an exception', e); } - catch { /* fails to load addon for some reason */ } if (!this._renderService.hasRenderer()) { this._renderService.setRenderer(this._createRenderer()); } diff --git a/src/browser/Linkifier.test.ts b/src/browser/Linkifier.test.ts index 4294ef105..685c404f4 100644 --- a/src/browser/Linkifier.test.ts +++ b/src/browser/Linkifier.test.ts @@ -43,6 +43,20 @@ describe('Linkifier2', () => { }, activate: () => { } }; + const multiLineLink: ILink = { + text: 'foo', + range: { + start: { + x: 2, + y: 1 + }, + end: { + x: 4, + y: 2 + } + }, + activate: () => { } + }; beforeEach(() => { const dom = new jsdom.JSDOM(); @@ -86,4 +100,30 @@ describe('Linkifier2', () => { linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any); }); + it('onShowLinkUnderline event range is correct for wrapped links', done => { + linkifier.onShowLinkUnderline(e => { + assert.equal(multiLineLink.range.start.x - 1, e.x1); + assert.equal(multiLineLink.range.start.y - 1, e.y1); + assert.equal(multiLineLink.range.end.x, e.x2); + assert.equal(multiLineLink.range.end.y - 1, e.y2); + + done(); + }); + + linkifier.linkHover({ classList: { add: () => { } } } as any, multiLineLink, {} as any); + }); + + it('onHideLinkUnderline event range is correct for wrapped links', done => { + linkifier.onHideLinkUnderline(e => { + assert.equal(multiLineLink.range.start.x - 1, e.x1); + assert.equal(multiLineLink.range.start.y - 1, e.y1); + assert.equal(multiLineLink.range.end.x, e.x2); + assert.equal(multiLineLink.range.end.y - 1, e.y2); + + done(); + }); + + linkifier.linkLeave({ classList: { add: () => { } } } as any, multiLineLink, {} as any); + }); + }); diff --git a/src/browser/OscLinkProvider.test.ts b/src/browser/OscLinkProvider.test.ts new file mode 100644 index 000000000..9e844cd16 --- /dev/null +++ b/src/browser/OscLinkProvider.test.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { OscLinkProvider } from 'browser/OscLinkProvider'; +import { ILink } from 'browser/Types'; +import { createCellData, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { IBufferService, IOscLinkService } from 'common/services/Services'; +import { IBufferLine, IOscLinkData } from 'common/Types'; + +class TestOscLinkService implements IOscLinkService { + public serviceBrand: any; + public registerLink(_linkData: IOscLinkData): number { return 0; } + public addLineToLink(_linkId: number, _y: number): void { } + public getLinkData(linkId: number): IOscLinkData | undefined { + return { uri: `https://example.com/${linkId}` }; + } +} + +function setText(line: IBufferLine | undefined, x: number, text: string): void { + if (!line) { + throw new Error('Missing buffer line'); + } + for (let i = 0; i < text.length; i++) { + line.setCell(x + i, createCellData(0, text[i], 1)); + } +} + +function setUrl(line: IBufferLine | undefined, x: number, text: string, linkId: number): void { + if (!line) { + throw new Error('Missing buffer line'); + } + for (let i = 0; i < text.length; i++) { + const cell = createCellData(0, text[i], 1); + cell.extended.urlId = linkId; + cell.updateExtended(); + line.setCell(x + i, cell); + } +} + +function getLinks(provider: OscLinkProvider, y: number): Promise { + return new Promise(resolve => provider.provideLinks(y, links => resolve(links ?? []))); +} + +describe('OscLinkProvider', () => { + let bufferService: IBufferService; + let provider: OscLinkProvider; + + beforeEach(() => { + const optionsService = new MockOptionsService(); + bufferService = new MockBufferService(5, 5, optionsService); + provider = new OscLinkProvider(bufferService, optionsService, new TestOscLinkService()); + }); + + it('expands a wrapped link range backward to the previous line', async () => { + const line1 = bufferService.buffer.lines.get(0); + const line2 = bufferService.buffer.lines.get(1); + setText(line1, 0, 'aa'); + setUrl(line1, 2, 'bbb', 1); + setUrl(line2, 0, 'cccc', 1); + setText(line2, 4, 'x'); + line2!.isWrapped = true; + + const links = await getLinks(provider, 2); + assert.lengthOf(links, 1); + assert.deepEqual(links[0].range, { + start: { x: 3, y: 1 }, + end: { x: 4, y: 2 } + }); + }); + + it('expands a wrapped link range forward when a link ends at line boundary', async () => { + const line1 = bufferService.buffer.lines.get(0); + const line2 = bufferService.buffer.lines.get(1); + setUrl(line1, 0, 'aaaaa', 1); + setUrl(line2, 0, 'bb', 1); + setText(line2, 2, 'ccc'); + line2!.isWrapped = true; + + const links = await getLinks(provider, 1); + assert.lengthOf(links, 1); + assert.deepEqual(links[0].range, { + start: { x: 1, y: 1 }, + end: { x: 2, y: 2 } + }); + }); + + it('does not merge wrapped links with different url ids', async () => { + const line1 = bufferService.buffer.lines.get(0); + const line2 = bufferService.buffer.lines.get(1); + setUrl(line1, 0, 'aaaaa', 1); + setUrl(line2, 0, 'bbb', 2); + setText(line2, 3, 'cc'); + line2!.isWrapped = true; + + const links = await getLinks(provider, 1); + assert.lengthOf(links, 1); + assert.deepEqual(links[0].range, { + start: { x: 1, y: 1 }, + end: { x: 5, y: 1 } + }); + }); +}); diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts index b6a3cce0a..d01a20b35 100644 --- a/src/browser/OscLinkProvider.ts +++ b/src/browser/OscLinkProvider.ts @@ -6,6 +6,7 @@ import { IBufferRange, ILink } from 'browser/Types'; import { ILinkProvider } from 'browser/services/Services'; import { CellData } from 'common/buffer/CellData'; +import { IBufferLine } from 'common/Types'; import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services'; export class OscLinkProvider implements ILinkProvider { @@ -57,19 +58,8 @@ export class OscLinkProvider implements ILinkProvider { if (finishLink || (currentStart !== -1 && x === lineLength - 1)) { const text = this._oscLinkService.getLinkData(currentLinkId)?.uri; if (text) { - // These ranges are 1-based - const range: IBufferRange = { - start: { - x: currentStart + 1, - y - }, - end: { - // Offset end x if it's a link that ends on the last cell in the line - x: x + (!finishLink && x === lineLength - 1 ? 1 : 0), - y - } - }; - + const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0); + const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId); let ignoreLink = false; if (!linkHandler?.allowNonHttpProtocols) { try { @@ -111,6 +101,82 @@ export class OscLinkProvider implements ILinkProvider { // id callback(result); } + + /** + * Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id. + */ + private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange { + let startY = y; + let finalStartX = startX; + let endY = y; + let finalEndX = endX; + + // Expand upward only when this segment starts at column 0 and the current line is wrapped. + while (finalStartX === 0) { + const currentLine = this._bufferService.buffer.lines.get(startY - 1); + if (!currentLine?.isWrapped) { + break; + } + const previousLine = this._bufferService.buffer.lines.get(startY - 2); + if (!previousLine) { + break; + } + const previousLineLength = previousLine.getTrimmedLength(); + if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) { + break; + } + let previousStartX = previousLineLength - 1; + while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) { + previousStartX--; + } + startY--; + finalStartX = previousStartX; + } + + // Expand downward only when this segment reaches trimmed EOL and the next line is wrapped. + while (true) { + const currentLine = this._bufferService.buffer.lines.get(endY - 1); + if (!currentLine) { + break; + } + const currentLineLength = currentLine.getTrimmedLength(); + if (finalEndX !== currentLineLength) { + break; + } + const nextLine = this._bufferService.buffer.lines.get(endY); + if (!nextLine?.isWrapped) { + break; + } + const nextLineLength = nextLine.getTrimmedLength(); + if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) { + break; + } + let nextEndX = 1; + while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) { + nextEndX++; + } + endY++; + finalEndX = nextEndX; + } + + // IBufferRange uses 1-based coordinates. + return { + start: { + x: finalStartX + 1, + y: startY + }, + end: { + x: finalEndX, + y: endY + } + }; + } + + private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean { + const cell = this._workCell; + line.loadCell(x, cell); + return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId; + } } function defaultActivate(e: MouseEvent, uri: string): void { diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 39692a871..356cd77fe 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -9,7 +9,7 @@ import { moveToCellSequence } from 'browser/input/MoveToCell'; import { SelectionModel } from 'browser/selection/SelectionModel'; import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services'; -import { Disposable, toDisposable } from 'common/Lifecycle'; +import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; import { IBufferLine, ICellData, IDisposable } from 'common/Types'; import { getRangeLength } from 'common/buffer/BufferRange'; @@ -102,7 +102,7 @@ export class SelectionService extends Disposable implements ISelectionService { private _mouseMoveListener: EventListener; private _mouseUpListener: EventListener; - private _trimListener: IDisposable; + private readonly _trimListener = this._register(new MutableDisposable()); private _workCell: CellData = new CellData(); private _mouseDownTimeStamp: number = 0; @@ -140,7 +140,7 @@ export class SelectionService extends Disposable implements ISelectionService { this.clearSelection(); } }); - this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount)); + this._trimListener.value = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount)); this._register(this._bufferService.buffers.onBufferActivate(e => this._handleBufferActivate(e))); this.enable(); @@ -769,8 +769,7 @@ export class SelectionService extends Disposable implements ISelectionService { // reverseIndex) and delete in a splice is only ever used when the same // number of elements was just added. Given this is could actually be // beneficial to leave the selection as is for these cases. - this._trimListener.dispose(); - this._trimListener = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount)); + this._trimListener.value = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount)); } /** diff --git a/src/common/Clone.test.ts b/src/common/Clone.test.ts deleted file mode 100644 index 5fbe4bf9b..000000000 --- a/src/common/Clone.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import { clone } from 'common/Clone'; - -describe('clone', () => { - it('should clone simple objects', () => { - const test = { - a: 1, - b: 2 - }; - - assert.deepEqual(clone(test), { a: 1, b: 2 }); - }); - - it('should clone nested objects', () => { - const test = { - bar: { - a: 1, - b: 2, - c: { - foo: 'bar' - } - } - }; - - assert.deepEqual(clone(test), { - bar: { - a: 1, - b: 2, - c: { - foo: 'bar' - } - } - }); - }); - - it('should clone array values', () => { - const test = { - a: [1, 2, 3], - b: [1, null, 'test', { foo: 'bar' }] - }; - - assert.deepEqual(clone(test), { - a: [1, 2, 3], - b: [1, null, 'test', { foo: 'bar' }] - }); - }); - - it('should stop mutation from occuring on the original object', () => { - const test = { - a: 1, - b: 2, - c: { - foo: 'bar' - } - }; - - const cloned = clone(test); - - test.a = 5; - test.c.foo = 'barbaz'; - - assert.deepEqual(cloned, { - a: 1, - b: 2, - c: { - foo: 'bar' - } - }); - }); - - it('should clone to a maximum depth of 5 by default', () => { - const test = { - a: { - b: { - c: { - d: { - e: { - f: 'foo' - } - } - } - } - } - }; - - const cloned = clone(test); - - test.a.b.c.d.e.f = 'bar'; - - // The values at a greater depth then 5 should not be cloned - assert.equal((cloned as any).a.b.c.d.e.f, 'bar'); - }); - - it('should allow an optional maximum depth to be set', () => { - const test = { - a: { - b: { - c: 'foo' - } - } - }; - - const cloned = clone(test, 2); - - test.a.b.c = 'bar'; - - // The values at a greater depth then 2 should not be cloned - assert.equal((cloned as any).a.b.c, 'bar'); - }); - - it('should not throw when cloning a recursive reference', () => { - const test = { - a: { - b: { - c: {} - } - } - }; - - test.a.b.c = test; - - assert.doesNotThrow(() => clone(test)); - }); -}); diff --git a/src/common/Clone.ts b/src/common/Clone.ts deleted file mode 100644 index 37821fe02..000000000 --- a/src/common/Clone.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. - * @license MIT - */ - -/* - * A simple utility for cloning values - */ -export function clone(val: T, depth: number = 5): T { - if (typeof val !== 'object') { - return val; - } - - // If we're cloning an array, use an array as the base, otherwise use an object - const clonedObject: any = Array.isArray(val) ? [] : {}; - - for (const key in val) { - // Recursively clone eack item unless we're at the maximum depth - clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1)); - } - - return clonedObject as T; -} diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index f9da81a25..5c59b062b 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -14,7 +14,6 @@ import { Params } from 'common/parser/Params'; import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService, extendedAttributes } from 'common/TestUtils.test'; import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; -import { clone } from 'common/Clone'; import { BufferService } from 'common/services/BufferService'; import { CoreService } from 'common/services/CoreService'; import { OscLinkService } from 'common/services/OscLinkService'; @@ -227,32 +226,32 @@ describe('InputHandler', () => { assert.equal(coreService.decPrivateModes.cursorStyle, undefined); assert.equal(coreService.decPrivateModes.cursorBlink, undefined); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([1])); assert.equal(coreService.decPrivateModes.cursorStyle, 'block'); assert.equal(coreService.decPrivateModes.cursorBlink, true); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([2])); assert.equal(coreService.decPrivateModes.cursorStyle, 'block'); assert.equal(coreService.decPrivateModes.cursorBlink, false); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([3])); assert.equal(coreService.decPrivateModes.cursorStyle, 'underline'); assert.equal(coreService.decPrivateModes.cursorBlink, true); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([4])); assert.equal(coreService.decPrivateModes.cursorStyle, 'underline'); assert.equal(coreService.decPrivateModes.cursorBlink, false); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([5])); assert.equal(coreService.decPrivateModes.cursorStyle, 'bar'); assert.equal(coreService.decPrivateModes.cursorBlink, true); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([6])); assert.equal(coreService.decPrivateModes.cursorStyle, 'bar'); assert.equal(coreService.decPrivateModes.cursorBlink, false); diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 5940fa659..69c2d499d 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -5,7 +5,6 @@ import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services'; import { UnicodeService } from 'common/services/UnicodeService'; -import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { BufferSet } from 'common/buffer/BufferSet'; @@ -157,7 +156,7 @@ export class MockLogService implements ILogService { export class MockOptionsService implements IOptionsService { public serviceBrand: any; - public readonly rawOptions: Required = clone(DEFAULT_OPTIONS); + public readonly rawOptions: Required = structuredClone(DEFAULT_OPTIONS); public options: Required = this.rawOptions; public onOptionChange: IEvent = new Emitter().event; constructor(testOptions?: Partial) { diff --git a/src/common/input/WriteBuffer.test.ts b/src/common/input/WriteBuffer.test.ts index f366a6320..cca6bd0be 100644 --- a/src/common/input/WriteBuffer.test.ts +++ b/src/common/input/WriteBuffer.test.ts @@ -125,5 +125,20 @@ describe('WriteBuffer', () => { assert.deepEqual(stack, []); assert.deepEqual(cbStack, []); }); + it('flushSync fires onWriteParsed', () => { + let parsed = 0; + wb.onWriteParsed(() => parsed++); + wb.write('a'); + wb.write('b'); + assert.equal(parsed, 0); + wb.flushSync(); + assert.equal(parsed, 1); + }); + it('flushSync with no pending writes does not fire onWriteParsed', () => { + let parsed = 0; + wb.onWriteParsed(() => parsed++); + wb.flushSync(); + assert.equal(parsed, 0); + }); }); }); diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index b936c9dbf..7c6d5d866 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -71,7 +71,9 @@ export class WriteBuffer extends Disposable { // Process all pending chunks synchronously let chunk: string | Uint8Array | undefined; + let didProcess = false; while (chunk = this._writeBuffer.shift()) { + didProcess = true; this._action(chunk); const cb = this._callbacks.shift(); if (cb) cb(); @@ -84,6 +86,9 @@ export class WriteBuffer extends Disposable { this._callbacks.length = 0; this._isSyncWriting = false; + if (didProcess) { + this._onWriteParsed.fire(); + } } /** diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index d8962ec88..f5094f5a9 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -3,7 +3,6 @@ * @license MIT */ -import { clone } from 'common/Clone'; import { Disposable } from 'common/Lifecycle'; import { IDecPrivateModes, IKittyKeyboardState, IModes } from 'common/Types'; import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; @@ -61,14 +60,14 @@ export class CoreService extends Disposable implements ICoreService { ) { super(); this.isCursorInitialized = _optionsService.rawOptions.showCursorImmediately ?? false; - this.modes = clone(DEFAULT_MODES); - this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + this.modes = structuredClone(DEFAULT_MODES); + this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES); this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE(); } public reset(): void { - this.modes = clone(DEFAULT_MODES); - this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + this.modes = structuredClone(DEFAULT_MODES); + this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES); this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE(); }