Merge branch 'master' into keydup

This commit is contained in:
Daniel Imms
2025-12-27 06:32:16 -08:00
committed by GitHub
119 changed files with 16461 additions and 8367 deletions
-2
View File
@@ -126,8 +126,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
public readonly onCursorMove = this._onCursorMove.event;
private readonly _onKey = this._register(new Emitter<{ key: string, domEvent: KeyboardEvent }>());
public readonly onKey = this._onKey.event;
private readonly _onRender = this._register(new Emitter<{ start: number, end: number }>());
public readonly onRender = this._onRender.event;
private readonly _onSelectionChange = this._register(new Emitter<void>());
public readonly onSelectionChange = this._onSelectionChange.event;
private readonly _onTitleChange = this._register(new Emitter<string>());
+1 -1
View File
@@ -75,7 +75,7 @@ export class OscLinkProvider implements ILinkProvider {
if (!['http:', 'https:'].includes(parsed.protocol)) {
ignoreLink = true;
}
} catch (e) {
} catch {
// Ignore invalid URLs to prevent unexpected behaviors
ignoreLink = true;
}
-41
View File
@@ -1079,47 +1079,6 @@ describe('Terminal', () => {
assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false);
});
});
describe('Windows Mode', () => {
it('should mark lines as wrapped when the line ends in a non-null character after a LF', async () => {
const data = [
'aaaaaaaaaa\n\r', // cannot wrap as it's the first
'aaaaaaaaa\n\r', // wrapped (windows mode only)
'aaaaaaaaa' // not wrapped
];
const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: false });
await normalTerminal.writeP(data.join(''));
assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false);
assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false);
assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false);
const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: true });
await windowsModeTerminal.writeP(data.join(''));
assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false);
assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character');
assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false);
});
it('should mark lines as wrapped when the line ends in a non-null character after a CUP', async () => {
const data = [
'aaaaaaaaaa\x1b[2;1H', // cannot wrap as it's the first
'aaaaaaaaa\x1b[3;1H', // wrapped (windows mode only)
'aaaaaaaaa' // not wrapped
];
const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: false });
await normalTerminal.writeP(data.join(''));
assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false);
assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false);
assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false);
const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: true });
await windowsModeTerminal.writeP(data.join(''));
assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false);
assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character');
assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false);
});
});
it('convertEol setting', async () => {
// not converting
const termNotConverting = new TestTerminal({ cols: 15, rows: 10 });
+7 -5
View File
@@ -3,7 +3,6 @@
* @license MIT
*/
import * as glob from 'glob';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
@@ -15,7 +14,10 @@ import { IDisposable } from '@xterm/xterm';
const COLS = 80;
const ROWS = 25;
const TESTFILES = glob.sync('**/escape_sequence_files/*.in', { cwd: path.join(__dirname, '../..')});
const escapeSequenceFilesDir = path.join(__dirname, '../../fixtures/escape_sequence_files');
const TESTFILES = fs.readdirSync(escapeSequenceFilesDir)
.filter(f => f.endsWith('.in'))
.map(f => path.join(escapeSequenceFilesDir, f));
const SKIP_FILES = [
't0055-EL.in', // EL/ED handle cursor at cols differently (see #3362)
't0084-CBT.in',
@@ -33,7 +35,7 @@ if (os.platform() === 'darwin') {
);
}
// filter skipFilenames
const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(value.split('/').slice(-1)[0]));
const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(path.basename(value)));
describe('Escape Sequence Files', function(): void {
this.timeout(1000);
@@ -62,7 +64,7 @@ describe('Escape Sequence Files', function(): void {
});
for (const filename of FILES) {
(process.platform === 'win32' ? it.skip : it)(filename.split('/').slice(-1)[0], async () => {
(process.platform === 'win32' ? it.skip : it)(path.basename(filename), async () => {
// reset terminal and handler
if (customHandler) {
customHandler.dispose();
@@ -88,7 +90,7 @@ describe('Escape Sequence Files', function(): void {
});
// compare with expected output (right trimmed)
const expected = fs.readFileSync(filename.split('.')[0] + '.text', 'utf8');
const expected = fs.readFileSync(filename.replace(/\.in$/, '.text'), 'utf8');
const expectedRightTrimmed = expected.split('\n').map(l => l.replace(/\s+$/, '')).join('\n');
if (content !== expectedRightTrimmed) {
throw new Error(formatError(fs.readFileSync(filename, 'utf8'), content, expected));
+2 -2
View File
@@ -45,7 +45,7 @@ export class TimeBasedDebouncer implements IRenderDebouncer {
// Only refresh if the time since last refresh is above a threshold, otherwise wait for
// enough time to pass before refreshing again.
const refreshRequestTime: number = Date.now();
const refreshRequestTime: number = performance.now();
if (refreshRequestTime - this._lastRefreshMs >= this._debounceThresholdMS) {
// Enough time has lapsed since the last refresh; refresh immediately
this._lastRefreshMs = refreshRequestTime;
@@ -57,7 +57,7 @@ export class TimeBasedDebouncer implements IRenderDebouncer {
this._additionalRefreshRequested = true;
this._refreshTimeoutID = window.setTimeout(() => {
this._lastRefreshMs = Date.now();
this._lastRefreshMs = performance.now();
this._innerRefresh();
this._additionalRefreshRequested = false;
this._refreshTimeoutID = undefined; // No longer need to clear the timeout
@@ -9,7 +9,7 @@ import { ColorZoneStore } from 'browser/decorations/ColorZoneStore';
const optionsRedFull = {
overviewRulerOptions: {
color: 'red',
position: 'full' as 'full'
position: 'full' as const
}
};
+1
View File
@@ -123,6 +123,7 @@ export class Terminal extends Disposable implements ITerminalApi {
originMode: m.origin,
reverseWraparoundMode: m.reverseWraparound,
sendFocusMode: m.sendFocus,
synchronizedOutputMode: m.synchronizedOutput,
wraparoundMode: m.wraparound
};
}
+101 -11
View File
@@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { DebouncedIdleTask } from 'common/TaskQueue';
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
import { Emitter } from 'vs/base/common/event';
interface ISelectionState {
@@ -18,6 +18,10 @@ interface ISelectionState {
columnSelectMode: boolean;
}
const enum Constants {
SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000
}
export class RenderService extends Disposable implements IRenderService {
public serviceBrand: undefined;
@@ -32,6 +36,7 @@ export class RenderService extends Disposable implements IRenderService {
private _needsSelectionRefresh: boolean = false;
private _canvasWidth: number = 0;
private _canvasHeight: number = 0;
private _syncOutputHandler: SynchronizedOutputHandler;
private _selectionState: ISelectionState = {
start: undefined,
end: undefined,
@@ -52,23 +57,31 @@ export class RenderService extends Disposable implements IRenderService {
constructor(
private _rowCount: number,
screenElement: HTMLElement,
@IOptionsService optionsService: IOptionsService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICharSizeService private readonly _charSizeService: ICharSizeService,
@ICoreService private readonly _coreService: ICoreService,
@IDecorationService decorationService: IDecorationService,
@IBufferService bufferService: IBufferService,
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
@IThemeService themeService: IThemeService
) {
super();
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), coreBrowserService);
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService);
this._register(this._renderDebouncer);
this._register(coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange()));
this._syncOutputHandler = new SynchronizedOutputHandler(
this._coreBrowserService,
this._coreService,
() => this._fullRefresh()
);
this._register(toDisposable(() => this._syncOutputHandler.dispose()));
this._register(this._coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange()));
this._register(bufferService.onResize(() => this._fullRefresh()));
this._register(bufferService.buffers.onBufferActivate(() => this._renderer.value?.clear()));
this._register(optionsService.onOptionChange(() => this._handleOptionsChanged()));
this._register(this._optionsService.onOptionChange(() => this._handleOptionsChanged()));
this._register(this._charSizeService.onCharSizeChange(() => this.handleCharSizeChanged()));
// Do a full refresh whenever any decoration is added or removed. This may not actually result
@@ -78,8 +91,7 @@ export class RenderService extends Disposable implements IRenderService {
this._register(decorationService.onDecorationRemoved(() => this._fullRefresh()));
// Clear the renderer when the a change that could affect glyphs occurs
this._register(optionsService.onMultipleOptionChange([
'customGlyphs',
this._register(this._optionsService.onMultipleOptionChange([
'drawBoldTextInBrightColors',
'letterSpacing',
'lineHeight',
@@ -96,15 +108,15 @@ export class RenderService extends Disposable implements IRenderService {
}));
// Refresh the cursor line when the cursor changes
this._register(optionsService.onMultipleOptionChange([
this._register(this._optionsService.onMultipleOptionChange([
'cursorBlink',
'cursorStyle'
], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true)));
this._register(themeService.onChangeColors(() => this._fullRefresh()));
this._registerIntersectionObserver(coreBrowserService.window, screenElement);
this._register(coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement)));
this._registerIntersectionObserver(this._coreBrowserService.window, screenElement);
this._register(this._coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement)));
}
private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void {
@@ -137,6 +149,18 @@ export class RenderService extends Disposable implements IRenderService {
this._needsFullRefresh = true;
return;
}
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._syncOutputHandler.bufferRows(start, end);
return;
}
const buffered = this._syncOutputHandler.flush();
if (buffered) {
start = Math.min(start, buffered.start);
end = Math.max(end, buffered.end);
}
if (!isRedrawOnly) {
this._isNextRenderRedrawOnly = false;
}
@@ -148,6 +172,13 @@ export class RenderService extends Disposable implements IRenderService {
return;
}
// Skip rendering if synchronized output mode is enabled. This check must happen here
// (in addition to refreshRows) to handle renders that were queued before the mode was enabled.
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._syncOutputHandler.bufferRows(start, end);
return;
}
// Since this is debounced, a resize event could have happened between the time a refresh was
// requested and when this triggers. Clamp the values of start and end to ensure they're valid
// given the current viewport state.
@@ -283,3 +314,62 @@ export class RenderService extends Disposable implements IRenderService {
this._renderer.value?.clear();
}
}
/**
* Buffers row refresh requests during synchronized output mode (DEC mode 2026).
* When the mode is disabled, the accumulated row range is flushed for rendering.
* A safety timeout ensures rendering occurs even if the end sequence is not received.
*/
class SynchronizedOutputHandler {
private _start: number = 0;
private _end: number = 0;
private _timeout: number | undefined;
private _isBuffering: boolean = false;
constructor(
private readonly _coreBrowserService: ICoreBrowserService,
private readonly _coreService: ICoreService,
private readonly _onTimeout: () => void
) {}
public bufferRows(start: number, end: number): void {
if (!this._isBuffering) {
this._start = start;
this._end = end;
this._isBuffering = true;
} else {
this._start = Math.min(this._start, start);
this._end = Math.max(this._end, end);
}
if (this._timeout === undefined) {
this._timeout = this._coreBrowserService.window.setTimeout(() => {
this._timeout = undefined;
this._coreService.decPrivateModes.synchronizedOutput = false;
this._onTimeout();
}, Constants.SYNCHRONIZED_OUTPUT_TIMEOUT_MS);
}
}
public flush(): { start: number, end: number } | undefined {
if (this._timeout !== undefined) {
this._coreBrowserService.window.clearTimeout(this._timeout);
this._timeout = undefined;
}
if (!this._isBuffering) {
return undefined;
}
const result = { start: this._start, end: this._end };
this._isBuffering = false;
return result;
}
public dispose(): void {
if (this._timeout !== undefined) {
this._coreBrowserService.window.clearTimeout(this._timeout);
this._timeout = undefined;
}
}
}