Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60aa4bf97 | ||
|
|
22acf01d43 | ||
|
|
fe9ebddd6f | ||
|
|
c6d72b7db6 | ||
|
|
9add7789b2 | ||
|
|
c55a248d52 | ||
|
|
0f0c615748 | ||
|
|
9309ccc480 | ||
|
|
ecd846c027 | ||
|
|
7ddca3a305 | ||
|
|
5ff85db4f3 | ||
|
|
ef84237ef3 | ||
|
|
2da6c9b9b7 | ||
|
|
eb55fd4ba3 | ||
|
|
1f3ebe1b91 | ||
|
|
dc01e4e97b | ||
|
|
9bd72abc59 | ||
|
|
d179d586e9 | ||
|
|
3b56138d0a | ||
|
|
950f1be20d | ||
|
|
41841d0aa8 | ||
|
|
3bebe6c18e | ||
|
|
a344c36940 | ||
|
|
fedf29e870 | ||
|
|
7fbe5a27fa | ||
|
|
d4a8e5f50e | ||
|
|
8405d584d8 | ||
|
|
3ee7df2596 | ||
|
|
0440861b84 | ||
|
|
73c99a8412 | ||
|
|
e5ffbe315a | ||
|
|
510a1b0d75 | ||
|
|
74bec251e6 | ||
|
|
6d08f5938d | ||
|
|
ee322c811f | ||
|
|
811be7b1ac | ||
|
|
17dd1ede33 | ||
|
|
2146e4940d | ||
|
|
d99803293e | ||
|
|
f28bfb7142 | ||
|
|
e4ebd082ce | ||
|
|
a7467a901d | ||
|
|
9902f54c93 | ||
|
|
c7baa96f97 | ||
|
|
11720c9dc3 | ||
|
|
0b1cacd1a5 | ||
|
|
865f835a5e | ||
|
|
bf055dd5fc | ||
|
|
255859606c |
+1
-1
@@ -3,6 +3,7 @@ node_modules/
|
||||
.lock-wscript
|
||||
lib/
|
||||
out/
|
||||
out-benchmark/
|
||||
out-demo/
|
||||
out-test/
|
||||
out-esbuild/
|
||||
@@ -24,7 +25,6 @@ test-results/
|
||||
|
||||
# Tracing/debug files
|
||||
.cursor/nal-trace/
|
||||
.cursor/*.log
|
||||
|
||||
# Keep bundled code out of Git
|
||||
dist/
|
||||
|
||||
@@ -98,15 +98,3 @@ const cell = line?.getCell(0);
|
||||
## Writing unit tests
|
||||
|
||||
- Unit tests live alongside the source code file of the thing it's testing with a .test.ts suffix.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
**Demo server**: Start with `npm start` (port 3000). The demo server uses node-pty to spawn real shell sessions over WebSocket. Integration tests auto-start it via Playwright's `webServer` config, so you don't need to start it manually for `npm run test-integration`.
|
||||
|
||||
**Build before testing**: Always run `npm run build && npm run esbuild` before `npm run test-unit`. Integration tests also need `npm run esbuild-demo-client` and `npm run esbuild-demo-server`. The update script handles this automatically on session start.
|
||||
|
||||
**No external services**: This project has zero external dependencies (no databases, Docker, or APIs). Everything runs locally with Node.js.
|
||||
|
||||
**TypeScript compiler**: The project uses `tsgo` (native TypeScript compiler preview) rather than standard `tsc`. It's installed via the `@typescript/native-preview` package.
|
||||
|
||||
**Lint only changed files**: Prefer `npm run lint-changes` over `npm run lint` when iterating on code changes — it's significantly faster.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
## @xterm/addon-image
|
||||
|
||||
Inline image output in xterm.js. Supports SIXEL, iTerm's inline image protocol (IIP)
|
||||
and partially Kitty's terminal graphics prtocol (TGP).
|
||||
Inline image output in xterm.js. Supports SIXEL and iTerm's inline image protocol (IIP).
|
||||
|
||||
|
||||

|
||||
@@ -44,6 +43,9 @@ terminal.loadAddon(imageAddon);
|
||||
|
||||
### General Notes
|
||||
|
||||
- *IMPORTANT:* The worker approach as done in previous releases got removed.
|
||||
The addon contructor no longer expects a worker path as first argument.
|
||||
|
||||
- By default the addon will activate these `windowOptions` reports on the terminal:
|
||||
- getWinSizePixels (CSI 14 t)
|
||||
- getCellSizePixels (CSI 16 t)
|
||||
@@ -71,7 +73,7 @@ terminal.loadAddon(imageAddon);
|
||||
|
||||
- **Cursor Positioning**
|
||||
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
|
||||
Other cursor positioning modes as used by xterm or mintty are not supported for sixel.
|
||||
Other cursor positioning modes as used by xterm or mintty are not supported.
|
||||
|
||||
- **SIXEL Palette Handling**
|
||||
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
|
||||
@@ -98,24 +100,17 @@ terminal.loadAddon(imageAddon);
|
||||
Set by default, change it with `{iipSupport: true}`.
|
||||
|
||||
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
||||
- Supported formats: PNG, JPEG, GIF and QOI
|
||||
- Supported formats: PNG, JPEG and GIF
|
||||
- No animation support.
|
||||
- Image type hinting is not supported (always deducted from data header).
|
||||
- File download is not supported.
|
||||
- Filename gets parsed but not used.
|
||||
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
|
||||
- Payload size may not exceed CEIL(sizeParameter * 4 / 3).
|
||||
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
|
||||
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
|
||||
- Playload size is restricted by `iipSizeLimit`.
|
||||
- size parameter is not mandatory anymore.
|
||||
- Cursor gets positioned at the next cell of the last image line.
|
||||
- ReportCellSize sequence is supported.
|
||||
- Hi-res display support is currently limited (image gets blurred).
|
||||
|
||||
- **Kitty Graphics Support (TGP)**
|
||||
Set by default, change it with `{kittySupport: true}`.
|
||||
|
||||
Note that the kitty graphics support is still WIP.
|
||||
- Size parameter is restricted by `iipSizeLimit`.
|
||||
- Cursor positioning behaves the same as for sixel (see above).
|
||||
|
||||
|
||||
### Storage and Drawing Settings
|
||||
@@ -132,7 +127,6 @@ The addon exposes two properties to interact with the storage limits at runtime:
|
||||
By default the addon will show a placeholder pattern for evicted images that are still part
|
||||
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
|
||||
|
||||
|
||||
### Image Data Retrieval
|
||||
|
||||
The addon provides the following API endpoints to retrieve raw image data as canvas:
|
||||
@@ -146,7 +140,6 @@ The addon provides the following API endpoints to retrieve raw image data as can
|
||||
The buffer position is the 0-based absolute index (including scrollback at top).
|
||||
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
|
||||
|
||||
|
||||
### Memory Usage
|
||||
|
||||
The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
|
||||
@@ -187,8 +180,8 @@ _How can I adjust the memory usage?_
|
||||
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
|
||||
- `storageLimit`
|
||||
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding.
|
||||
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
|
||||
Constructor settings. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
|
||||
- `sixelSizeLimit`
|
||||
A constructor setting. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken SIXEL sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data.
|
||||
|
||||
|
||||
### Terminal Interaction
|
||||
@@ -218,5 +211,23 @@ _How can I adjust the memory usage?_
|
||||
|
||||
### Status
|
||||
|
||||
- Sixel and IIP support and image handling in xterm.js is considered beta quality.
|
||||
- Kitty support is in alpha stage. Please file a bug for any awkwardities or missing features.
|
||||
- Sixel support and image handling in xterm.js is considered beta quality.
|
||||
- IIP support is in alpha stage. Please file a bug for any awkwardities.
|
||||
|
||||
|
||||
### Changelog
|
||||
|
||||
- 0.5.0 integrate with xtermjs base repo (at v0.4.3)
|
||||
- 0.4.3 defer canvas creation
|
||||
- 0.4.2 fix image canvas resize
|
||||
- 0.4.1 compat release for xterm.js 5.2.0
|
||||
- 0.4.0 IIP support
|
||||
- 0.3.1 compat release for xterm.js 5.1.0
|
||||
- 0.3.0 important change: worker removed from addon
|
||||
- 0.2.0 compat release for xterm.js 5.0.0
|
||||
- 0.1.3 bugfix: avoid striping
|
||||
- 0.1.2 bugfix: reset clear flag
|
||||
- 0.1.1 bugfixes:
|
||||
- clear sticky image tiles on render
|
||||
- create folder from bootstrap.sh
|
||||
- fix peer dependency in package.json
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
|
||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||
@@ -23,7 +23,6 @@ const enum DecoderConst {
|
||||
|
||||
// default IIP header values
|
||||
const DEFAULT_HEADER: IHeaderFields = {
|
||||
type: SequenceType.INVALID,
|
||||
name: 'Unnamed file',
|
||||
size: 0,
|
||||
width: 'auto',
|
||||
@@ -40,8 +39,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private _dec: Base64Decoder;
|
||||
private _qoiDec: QoiDecoder;
|
||||
private _metrics = UNSUPPORTED_TYPE;
|
||||
private _isMultipart = false;
|
||||
private _abortMulti = false;
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
@@ -55,14 +52,11 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._hp.reset();
|
||||
this._dec.release();
|
||||
this._qoiDec.release();
|
||||
}
|
||||
public reset(): void {}
|
||||
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._header = DEFAULT_HEADER;
|
||||
this._metrics = UNSUPPORTED_TYPE;
|
||||
this._hp.reset();
|
||||
}
|
||||
@@ -82,27 +76,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return;
|
||||
}
|
||||
if (dataPos > 0) {
|
||||
const seqType = this._hp.fields.type;
|
||||
if (seqType === SequenceType.FILE) {
|
||||
if (this._isMultipart) {
|
||||
this._isMultipart = false;
|
||||
this._abortMulti = false;
|
||||
this._dec.release();
|
||||
}
|
||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||
if (!this._header.inline) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
this._dec.init();
|
||||
} else if (this._abortMulti) {
|
||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
this._dec.init();
|
||||
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
if (this._isMultipart) this._abortMulti = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,58 +93,22 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
public end(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted) return true;
|
||||
|
||||
if (this._hp.state !== HeaderState.END) {
|
||||
if (this._hp.end()) return true;
|
||||
}
|
||||
const seqType = this._hp.fields.type;
|
||||
|
||||
if (seqType === SequenceType.FILEPART) return true;
|
||||
|
||||
if (seqType === SequenceType.REPORTCELLSIZE) {
|
||||
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
|
||||
let width = CELL_SIZE_DEFAULT.width;
|
||||
let height = CELL_SIZE_DEFAULT.height;
|
||||
if (this._renderer.dimensions) {
|
||||
width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||
height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||
}
|
||||
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
|
||||
const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||
this._coreTerminal.input(report, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (seqType === SequenceType.MULTIPARTFILE) {
|
||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||
this._isMultipart = true;
|
||||
this._abortMulti = false;
|
||||
this._dec.release();
|
||||
this._dec.init();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (seqType === SequenceType.FILEEND) {
|
||||
if (!this._isMultipart) return true;
|
||||
this._isMultipart = false;
|
||||
if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;
|
||||
}
|
||||
|
||||
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
// early exit condition chain
|
||||
let cond: number | boolean;
|
||||
let cond: number | boolean = true;
|
||||
if (cond = success) {
|
||||
if (cond = !this._dec.end()) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
if (cond = this._metrics.mime !== 'unsupported') {
|
||||
w = this._metrics.width;
|
||||
h = this._metrics.height;
|
||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||
[w, h] = this._resize(w, h).map(Math.floor);
|
||||
cond = w && h && w * h < this._opts.pixelLimit;
|
||||
if (cond = this._dec.data8.length === this._header.size) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
if (cond = this._metrics.mime !== 'unsupported') {
|
||||
w = this._metrics.width;
|
||||
h = this._metrics.height;
|
||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||
[w, h] = this._resize(w, h).map(Math.floor);
|
||||
cond = w && h && w * h < this._opts.pixelLimit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,19 @@
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser';
|
||||
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
|
||||
|
||||
|
||||
const CASES: [string, IHeaderFields][] = [
|
||||
['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||
['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||
['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}],
|
||||
['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
|
||||
// utf-8 encoding in name
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}],
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
|
||||
// full header spec
|
||||
[
|
||||
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||
{
|
||||
type: SequenceType.FILE,
|
||||
inline: 1,
|
||||
width: '10px',
|
||||
height: '20%',
|
||||
@@ -29,7 +28,6 @@ const CASES: [string, IHeaderFields][] = [
|
||||
[
|
||||
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||
{
|
||||
type: SequenceType.FILE,
|
||||
inline: 1,
|
||||
width: 'auto',
|
||||
height: '20',
|
||||
@@ -103,7 +101,7 @@ describe('IIPHeaderParser', () => {
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: 'test', size: 0});
|
||||
assert.deepEqual(hp.fields, {name: 'test', size: 0});
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
@@ -117,7 +115,7 @@ describe('IIPHeaderParser', () => {
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: '', size: 123456});
|
||||
assert.deepEqual(hp.fields, {name: '', size: 123456});
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
@@ -137,124 +135,4 @@ describe('IIPHeaderParser', () => {
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||
});
|
||||
it('FilePart sequence', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
|
||||
const res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, 9);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
|
||||
});
|
||||
it('FilePart sequence - bytewise', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
|
||||
let res = -2;
|
||||
let pos = 0;
|
||||
while (res === -2 && pos < inp.length) {
|
||||
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||
}
|
||||
assert.strictEqual(res, 1);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
|
||||
});
|
||||
it('MultipartFile sequence', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, -2);
|
||||
assert.strictEqual(hp.state, HeaderState.VALUE);
|
||||
res = hp.end();
|
||||
assert.strictEqual(res, 0);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(
|
||||
hp.fields,
|
||||
{
|
||||
type: SequenceType.MULTIPARTFILE,
|
||||
inline: 1,
|
||||
width: '10px',
|
||||
height: '20%',
|
||||
preserveAspectRatio: 1,
|
||||
size: 123456,
|
||||
name: 'ümläutß'
|
||||
}
|
||||
);
|
||||
});
|
||||
it('MultipartFile sequence - bytewise', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
|
||||
let res = -2;
|
||||
let pos = 0;
|
||||
while (res === -2 && pos < inp.length) {
|
||||
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||
}
|
||||
assert.strictEqual(res, -2);
|
||||
assert.strictEqual(hp.state, HeaderState.VALUE);
|
||||
res = hp.end();
|
||||
assert.strictEqual(res, 0);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(
|
||||
hp.fields,
|
||||
{
|
||||
type: SequenceType.MULTIPARTFILE,
|
||||
inline: 1,
|
||||
width: '10px',
|
||||
height: '20%',
|
||||
preserveAspectRatio: 1,
|
||||
size: 123456,
|
||||
name: 'ümläutß'
|
||||
}
|
||||
);
|
||||
});
|
||||
it('FileEnd sequence', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('FileEnd');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, -2);
|
||||
assert.strictEqual(hp.state, HeaderState.START);
|
||||
res = hp.end();
|
||||
assert.strictEqual(res, 0);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
|
||||
});
|
||||
it('FileEnd sequence - bytewise', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('FileEnd');
|
||||
let res = -2;
|
||||
let pos = 0;
|
||||
while (res === -2 && pos < inp.length) {
|
||||
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||
}
|
||||
assert.strictEqual(res, -2);
|
||||
assert.strictEqual(hp.state, HeaderState.START);
|
||||
res = hp.end();
|
||||
assert.strictEqual(res, 0);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
|
||||
});
|
||||
it('ReportCellSize sequence', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('ReportCellSize');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, -2);
|
||||
assert.strictEqual(hp.state, HeaderState.START);
|
||||
res = hp.end();
|
||||
assert.strictEqual(res, 0);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
|
||||
});
|
||||
it('ReportCellSize sequence - bytewise', () => {
|
||||
const hp = new HeaderParser();
|
||||
const inp = fromBs('ReportCellSize');
|
||||
let res = -2;
|
||||
let pos = 0;
|
||||
while (res === -2 && pos < inp.length) {
|
||||
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||
}
|
||||
assert.strictEqual(res, -2);
|
||||
assert.strictEqual(hp.state, HeaderState.START);
|
||||
res = hp.end();
|
||||
assert.strictEqual(res, 0);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,27 +6,9 @@
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
export const enum HeaderState {
|
||||
START = 0,
|
||||
ABORT = 1,
|
||||
KEY = 2,
|
||||
VALUE = 3,
|
||||
END = 4
|
||||
}
|
||||
|
||||
export const enum SequenceType {
|
||||
INVALID = 0,
|
||||
FILE = 1,
|
||||
MULTIPARTFILE = 2,
|
||||
FILEPART = 3,
|
||||
FILEEND = 4,
|
||||
REPORTCELLSIZE = 5
|
||||
}
|
||||
|
||||
export interface IHeaderFields {
|
||||
[key: string]: number | string | Uint32Array | null | undefined;
|
||||
// sequence type
|
||||
type: SequenceType;
|
||||
// base-64 encoded filename. Defaults to "Unnamed file".
|
||||
name: string;
|
||||
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
||||
@@ -47,6 +29,14 @@ export interface IHeaderFields {
|
||||
inline?: number;
|
||||
}
|
||||
|
||||
export const enum HeaderState {
|
||||
START = 0,
|
||||
ABORT = 1,
|
||||
KEY = 2,
|
||||
VALUE = 3,
|
||||
END = 4
|
||||
}
|
||||
|
||||
// field value decoders
|
||||
|
||||
// ASCII bytes to string
|
||||
@@ -102,19 +92,7 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
|
||||
};
|
||||
|
||||
|
||||
// sequence type markers
|
||||
// File
|
||||
const FILE_MARKER = [70, 105, 108, 101];
|
||||
// MultipartFile
|
||||
const MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];
|
||||
// FilePart
|
||||
const FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];
|
||||
// FileEnd
|
||||
const FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];
|
||||
// ReportCellSize
|
||||
const REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];
|
||||
|
||||
// max allowed chars for sequence header
|
||||
const MAX_FIELDCHARS = 1024;
|
||||
|
||||
|
||||
@@ -133,43 +111,12 @@ export class HeaderParser {
|
||||
this._key = '';
|
||||
}
|
||||
|
||||
public end(): number {
|
||||
if (this.state === HeaderState.START) {
|
||||
if (this._position === FILEEND_MARKER.length) {
|
||||
for (let k = 0; k < FILEEND_MARKER.length; ++k) {
|
||||
if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();
|
||||
}
|
||||
this.fields['type'] = SequenceType.FILEEND;
|
||||
this.state = HeaderState.END;
|
||||
return 0;
|
||||
}
|
||||
if (this._position === REPORTCELLSIZE_MARKER.length) {
|
||||
for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {
|
||||
if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();
|
||||
}
|
||||
this.fields['type'] = SequenceType.REPORTCELLSIZE;
|
||||
this.state = HeaderState.END;
|
||||
return 0;
|
||||
}
|
||||
return this._a();
|
||||
}
|
||||
if (this.state === HeaderState.END) return 0;
|
||||
if (this.state === HeaderState.VALUE
|
||||
&& this.fields.type === SequenceType.MULTIPARTFILE
|
||||
) {
|
||||
if (!this._storeValue(this._position)) return this._a();
|
||||
this.state = HeaderState.END;
|
||||
return 0;
|
||||
}
|
||||
return this._a();
|
||||
}
|
||||
|
||||
public parse(data: Uint32Array, start: number, end: number): number {
|
||||
let state = this.state;
|
||||
let pos = this._position;
|
||||
const buffer = this._buffer;
|
||||
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
|
||||
if (state === HeaderState.START && pos > 14) return -1;
|
||||
if (state === HeaderState.START && pos > 6) return -1;
|
||||
for (let i = start; i < end; ++i) {
|
||||
const c = data[i];
|
||||
switch (c) {
|
||||
@@ -180,29 +127,8 @@ export class HeaderParser {
|
||||
break;
|
||||
case 61: // =
|
||||
if (state === HeaderState.START) {
|
||||
if (buffer[0] === 70) {
|
||||
// 'File' or 'FilePart'
|
||||
let k = 0;
|
||||
for (; k < FILE_MARKER.length; ++k) {
|
||||
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
||||
}
|
||||
this.fields['type'] = SequenceType.FILE;
|
||||
if (pos === FILEPART_MARKER.length) {
|
||||
for (; k < FILEPART_MARKER.length; ++k) {
|
||||
if (buffer[k] !== FILEPART_MARKER[k]) return this._a();
|
||||
}
|
||||
this.fields['type'] = SequenceType.FILEPART;
|
||||
this.state = HeaderState.END;
|
||||
return i + 1;
|
||||
}
|
||||
} else if (buffer[0] === 77) {
|
||||
// 'MultipartFile'
|
||||
for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {
|
||||
if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();
|
||||
}
|
||||
this.fields['type'] = SequenceType.MULTIPARTFILE;
|
||||
} else {
|
||||
return this._a();
|
||||
for (let k = 0; k < FILE_MARKER.length; ++k) {
|
||||
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
||||
}
|
||||
state = HeaderState.KEY;
|
||||
pos = 0;
|
||||
@@ -232,7 +158,6 @@ export class HeaderParser {
|
||||
}
|
||||
|
||||
private _a(): number {
|
||||
this.fields.type = SequenceType.INVALID;
|
||||
this.state = HeaderState.ABORT;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IAddImageOpts } from 'Types';
|
||||
import { ImageStorage } from './ImageStorage';
|
||||
|
||||
/**
|
||||
@@ -13,7 +12,6 @@ import { ImageStorage } from './ImageStorage';
|
||||
* - Always uses scrolling mode (cursor advances with image)
|
||||
*/
|
||||
export class IIPImageStorage {
|
||||
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
|
||||
constructor(
|
||||
private readonly _storage: ImageStorage
|
||||
) {}
|
||||
@@ -23,6 +21,6 @@ export class IIPImageStorage {
|
||||
* Always uses scrolling mode — cursor advances past the image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
this._storage.addImage(img, this._addImageOpts);
|
||||
this._storage.addImage(img, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ const TEST_IMAGES: [string, IMetrics][] = [
|
||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
|
||||
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
|
||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,11 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
|
||||
// enable size reports
|
||||
if (this._opts.enableSizeReports) {
|
||||
// const windowOptions = terminal.getOption('windowOptions');
|
||||
// windowOptions.getWinSizePixels = true;
|
||||
// windowOptions.getCellSizePixels = true;
|
||||
// windowOptions.getWinSizeChars = true;
|
||||
// terminal.setOption('windowOptions', windowOptions);
|
||||
const windowOps = terminal.options.windowOptions ?? {};
|
||||
windowOps.getWinSizePixels = true;
|
||||
windowOps.getCellSizePixels = true;
|
||||
@@ -255,7 +260,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
}
|
||||
|
||||
private _report(s: string): void {
|
||||
this._terminal?._core.input(s, false);
|
||||
this._terminal?._core.coreService.triggerDataEvent(s);
|
||||
}
|
||||
|
||||
private _decset(params: (number | number[])[]): boolean {
|
||||
|
||||
@@ -146,7 +146,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
public clearLines(start: number, end: number, layer?: ImageLayer): void {
|
||||
const y = start * (this.dimensions?.css.cell.height || 0);
|
||||
const w = this.dimensions?.css.canvas.width || 0;
|
||||
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);
|
||||
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
|
||||
if (!layer || layer === 'top') {
|
||||
this._layers.get('top')?.clearRect(0, y, w, h);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import {
|
||||
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,
|
||||
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,
|
||||
UnderlineStyle, IAddImageOpts
|
||||
} from './Types';
|
||||
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
|
||||
|
||||
|
||||
// fallback default cell size
|
||||
@@ -239,15 +235,13 @@ export class ImageStorage implements IDisposable {
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
* @param img - The image to add (canvas or bitmap).
|
||||
* @param opts - Options for addImage:
|
||||
* - scrolling: When true, cursor advances with the image.
|
||||
* When false, image is placed at ORIGIN and cursor does not move.
|
||||
* - layer: Which canvas layer to render on ('top' or 'bottom').
|
||||
* - zIndex: Z-index for image layering within the same layer.
|
||||
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
|
||||
* @param scrolling - When true, cursor advances with the image (lineFeed per row).
|
||||
* When false, image is placed at (0,0) and cursor is restored (DECSET 80 / sixel origin mode).
|
||||
* @param layer - Which canvas layer to render on ('top' or 'bottom').
|
||||
* @param zIndex - Z-index for image layering within the same layer.
|
||||
* @returns The internal image ID assigned to the stored image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer = 'top', zIndex: number = 0): number {
|
||||
// never allow storage to exceed memory limit
|
||||
this._evictOldest(img.width * img.height);
|
||||
|
||||
@@ -269,7 +263,7 @@ export class ImageStorage implements IDisposable {
|
||||
let offset = originX;
|
||||
let tileCount = 0;
|
||||
|
||||
if (!opts.scrolling) {
|
||||
if (!scrolling) {
|
||||
buffer.x = 0;
|
||||
buffer.y = 0;
|
||||
offset = 0;
|
||||
@@ -283,7 +277,7 @@ export class ImageStorage implements IDisposable {
|
||||
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
||||
tileCount++;
|
||||
}
|
||||
if (opts.scrolling) {
|
||||
if (scrolling) {
|
||||
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
||||
} else {
|
||||
if (++buffer.y >= termRows) break;
|
||||
@@ -293,12 +287,8 @@ export class ImageStorage implements IDisposable {
|
||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||
|
||||
// cursor positioning modes
|
||||
if (opts.scrolling) {
|
||||
if (opts.cursorPos === 'iip') {
|
||||
buffer.x = Math.min(offset + cols, termCols);
|
||||
} else {
|
||||
buffer.x = offset;
|
||||
}
|
||||
if (scrolling) {
|
||||
buffer.x = offset;
|
||||
} else {
|
||||
buffer.x = originX;
|
||||
buffer.y = originY;
|
||||
@@ -341,8 +331,8 @@ export class ImageStorage implements IDisposable {
|
||||
marker: endMarker || undefined,
|
||||
tileCount,
|
||||
bufferType: this._terminal.buffer.active.type,
|
||||
layer: opts.layer,
|
||||
zIndex: opts.zIndex
|
||||
layer,
|
||||
zIndex
|
||||
};
|
||||
|
||||
// finally add the image
|
||||
@@ -465,6 +455,7 @@ export class ImageStorage implements IDisposable {
|
||||
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||
break;
|
||||
}
|
||||
e = nextE;
|
||||
count++;
|
||||
}
|
||||
col--;
|
||||
|
||||
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
const height = this._dec.height;
|
||||
|
||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||
if (!width || !height) {
|
||||
if (!width || ! height) {
|
||||
if (height) {
|
||||
this._storage.advanceCursor(height);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
|
||||
import { IImageAddonOptions, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,6 @@ import { ImageRenderer } from './ImageRenderer';
|
||||
* - advanceCursor for empty sixels carrying only height
|
||||
*/
|
||||
export class SixelImageStorage {
|
||||
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };
|
||||
constructor(
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
@@ -28,8 +27,7 @@ export class SixelImageStorage {
|
||||
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
this._addImageOpts.scrolling = this._opts.sixelScrolling;
|
||||
this._storage.addImage(img, this._addImageOpts);
|
||||
this._storage.addImage(img, this._opts.sixelScrolling);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,14 +100,6 @@ export interface ICellSize {
|
||||
}
|
||||
|
||||
export type ImageLayer = 'top' | 'bottom';
|
||||
export type CursorPos = 'vt340' | 'iip';
|
||||
|
||||
export interface IAddImageOpts {
|
||||
scrolling: boolean;
|
||||
layer: ImageLayer;
|
||||
zIndex: number;
|
||||
cursorPos: CursorPos;
|
||||
}
|
||||
|
||||
export interface IImageSpec {
|
||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageStorage } from '../ImageStorage';
|
||||
import { ImageLayer, IAddImageOpts } from '../Types';
|
||||
import { ImageLayer } from '../Types';
|
||||
import { IKittyImageData } from './KittyGraphicsTypes';
|
||||
|
||||
// Kitty-specific image storage controller.
|
||||
@@ -42,7 +42,6 @@ export class KittyImageStorage implements IDisposable {
|
||||
this._images.delete(kittyId);
|
||||
}
|
||||
};
|
||||
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
|
||||
|
||||
constructor(
|
||||
private readonly _storage: ImageStorage
|
||||
@@ -99,10 +98,7 @@ export class KittyImageStorage implements IDisposable {
|
||||
if (oldStorageId !== undefined) {
|
||||
this._storageIdToKittyId.delete(oldStorageId);
|
||||
}
|
||||
this._addImageOpts.scrolling = scrolling;
|
||||
this._addImageOpts.layer = layer;
|
||||
this._addImageOpts.zIndex = zIndex;
|
||||
const storageId = this._storage.addImage(image, this._addImageOpts);
|
||||
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
|
||||
this._kittyIdToStorageId.set(kittyId, storageId);
|
||||
this._storageIdToKittyId.set(storageId, kittyId);
|
||||
}
|
||||
|
||||
@@ -290,67 +290,6 @@ test.describe('ImageAddon', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP support', () => {
|
||||
test('size params is optional/informative', async () => {
|
||||
// too small size param
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
// no size aparm at all
|
||||
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||
});
|
||||
test.skip('FilePart support - bytewise', async () => {
|
||||
// opener
|
||||
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||
// byte-wise
|
||||
for (let i = 0; i < PALETTE_PNG_BASE64.length; ++i) {
|
||||
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64[i]}\x07`);
|
||||
}
|
||||
// finalizer
|
||||
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
});
|
||||
test('FilePart support - chunks', async () => {
|
||||
// opener
|
||||
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||
// chunks
|
||||
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||
}
|
||||
// finalizer
|
||||
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
});
|
||||
test('FilePart support - edgecases', async () => {
|
||||
// multiple opener do not harm
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||
}
|
||||
// multiple finalizer do not harm
|
||||
for (let i = 0; i < 5; ++i) {
|
||||
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||
}
|
||||
// chunks w'o opener are ignored
|
||||
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||
}
|
||||
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 0);
|
||||
});
|
||||
test('FilePart support - other IIP sequence cancels chunked image', async () => {
|
||||
// opener
|
||||
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||
// chunks
|
||||
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||
}
|
||||
// finalizer skipped
|
||||
// write spinfox.png
|
||||
await ctx.proxy.write(TESTDATA_IIP[1][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP support - testimages', () => {
|
||||
test('palette.png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
||||
|
||||
@@ -539,6 +539,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
|
||||
test.describe('Query support (a=q)', () => {
|
||||
test('responds with OK for capability query without payload', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
@@ -547,11 +548,12 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
|
||||
});
|
||||
|
||||
test('responds with OK for valid PNG query', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
@@ -560,7 +562,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
|
||||
});
|
||||
|
||||
@@ -576,6 +578,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
});
|
||||
|
||||
test('responds with error for invalid base64', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
@@ -584,11 +587,12 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
const response: string = await ctx.page.evaluate('window.kittyResponse');
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
|
||||
});
|
||||
|
||||
test('responds with error for RGB data without dimensions', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
@@ -597,7 +601,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
|
||||
});
|
||||
|
||||
@@ -638,6 +642,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
});
|
||||
|
||||
test('responds with EINVAL when both i and I keys are specified', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
@@ -647,11 +652,12 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Gi=100,I=200,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=100;EINVAL:cannot specify both i and I keys\x1b\\');
|
||||
});
|
||||
|
||||
test('responds with EINVAL for i+I conflict even without payload', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
@@ -661,7 +667,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write('\x1b_Gi=101,I=201,a=d\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=101;EINVAL:cannot specify both i and I keys\x1b\\');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.3.6",
|
||||
"opentype.js": "^2.0.0"
|
||||
"lru-cache": "^6.0.0",
|
||||
"opentype.js": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/opentype.js": "^1.3.9",
|
||||
"font-finder": "^1.1.0"
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/opentype.js": "^0.7.0",
|
||||
"axios": "^1.15.2",
|
||||
"font-finder": "^1.1.0",
|
||||
"mkdirp": "0.5.5",
|
||||
"yauzl": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as opentype from 'opentype.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import LRUCache = require('lru-cache');
|
||||
|
||||
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
|
||||
import mergeTrees from './merge';
|
||||
@@ -23,8 +23,8 @@ class FontImpl implements IFont {
|
||||
|
||||
if (options.cacheSize > 0) {
|
||||
this._cache = new LRUCache({
|
||||
maxSize: options.cacheSize,
|
||||
sizeCalculation: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
||||
max: options.cacheSize,
|
||||
length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function mergeRange(ranges: [number, number][], newRangeStart: nu
|
||||
// current range
|
||||
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
|
||||
ranges.splice(i, 1);
|
||||
inRange = false;
|
||||
return ranges;
|
||||
}
|
||||
// Case 7: New range extends from previous range past the
|
||||
|
||||
@@ -9,18 +9,15 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
|
||||
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
ctx.page.setViewportSize({ width: 1024, height: 768 });
|
||||
await openTerminal(ctx);
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
|
||||
test.describe('ProgressAddon', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
await openTerminal(ctx);
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await ctx?.page?.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async function(): Promise<any> {
|
||||
await ctx.page.evaluate(`
|
||||
window.progressStack = [];
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
lib
|
||||
node_modules
|
||||
node_modules
|
||||
out-benchmark
|
||||
@@ -20,4 +20,13 @@ terminal.loadAddon(searchAddon);
|
||||
searchAddon.findNext('foo');
|
||||
```
|
||||
|
||||
### Search options
|
||||
|
||||
- `decorations` (`ISearchDecorationOptions`): Enables match highlighting and result tracking; pass a decoration options object (colors may be omitted).
|
||||
- `highlightLimit` (constructor option): Caps how many matches are tracked/highlighted (default `1000`).
|
||||
- `incremental`: Expands the current selection when the term still matches.
|
||||
- `wholeWord`, `regex`, `caseSensitive`: Control match semantics.
|
||||
|
||||
`onDidChangeResults` fires after `findNext`/`findPrevious` when `decorations` is set. `clearDecorations()` clears highlights and tracked results but does not emit this event.
|
||||
|
||||
See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-search/typings/addon-search.d.ts) for more advanced usage.
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { SearchAddon } from 'SearchAddon';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
|
||||
const enum Constants {
|
||||
COLS = 120,
|
||||
ROWS = 40,
|
||||
SCROLLBACK = 8000,
|
||||
NAVIGATION_ITERATIONS = 250,
|
||||
COLD_CACHE_ITERATIONS = 75,
|
||||
INCREMENTAL_ITERATIONS = 75,
|
||||
DECORATION_REFRESH_ITERATIONS = 25
|
||||
}
|
||||
|
||||
const DECORATION_OPTIONS: ISearchOptions = {
|
||||
decorations: {
|
||||
activeMatchBackground: '#0000ff',
|
||||
activeMatchColorOverviewRuler: '#ff0000',
|
||||
matchBackground: '#ffff00',
|
||||
matchBorder: '#000000',
|
||||
matchOverviewRuler: '#00ffff'
|
||||
}
|
||||
};
|
||||
|
||||
class TestTerminal extends Terminal {
|
||||
public writeSync(data: string): void {
|
||||
(this as any)._core.writeSync(data);
|
||||
}
|
||||
|
||||
public override select(_column: number, _row: number, _length: number): void {
|
||||
// SearchAddon always selects matches; skip selection internals in headless benchmark runs.
|
||||
}
|
||||
|
||||
public override clearSelection(): void {
|
||||
// SearchAddon clears the previous selection before selecting another match.
|
||||
}
|
||||
|
||||
public override scrollLines(_amount: number): void {
|
||||
// No viewport scrolling is needed for API runtime measurement.
|
||||
}
|
||||
}
|
||||
|
||||
function buildRealWorldBuffer(): string {
|
||||
const fixture = readFileSync(resolve(__dirname, '../../fixtures/issue-2444'), 'utf8')
|
||||
.replace(/\r?\n/g, '\r\n');
|
||||
const lines: string[] = [fixture];
|
||||
for (let i = 0; i < 2500; i++) {
|
||||
const minute = (i % 60).toString().padStart(2, '0');
|
||||
const second = ((i * 7) % 60).toString().padStart(2, '0');
|
||||
lines.push(`2026-05-25T16:${minute}:${second}Z INFO [api-${i % 8}] requestId=req-${i.toString(16).padStart(6, '0')} path=/v1/projects/${i % 120}/search?q=opencv status=200 durationMs=${10 + (i % 90)}`);
|
||||
if (i % 5 === 0) {
|
||||
lines.push(`2026-05-25T16:${minute}:${second}Z WARN [worker-${i % 4}] slow query detected query="SELECT * FROM runs WHERE status='pending'" elapsedMs=${500 + (i % 1200)}`);
|
||||
}
|
||||
if (i % 11 === 0) {
|
||||
lines.push(`2026-05-25T16:${minute}:${second}Z ERROR [worker-${i % 4}] command failed: package.json parse error at column ${10 + (i % 70)}; retrying in ${2 + (i % 7)}s`);
|
||||
}
|
||||
if (i % 17 === 0) {
|
||||
lines.push(`https://example.internal/run/${i}/details?source=terminal-search&state=error`);
|
||||
}
|
||||
}
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
perfContext('SearchAddon API on real-world terminal content', () => {
|
||||
const bufferContent = buildRealWorldBuffer();
|
||||
|
||||
perfContext('findNext/plain navigation', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||
if (search.findNext('opencv')) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findNext/no match full scan', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||
if (search.findNext('zzzznotfoundzzzz')) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== 0) {
|
||||
throw new Error(`Expected 0 matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findNext/case insensitive navigation', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||
if (search.findNext('OPENCV', { caseSensitive: false })) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findNext/regex navigation', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||
if (search.findNext('https://example\\.internal/run/\\d+/details\\?source=terminal-search', { regex: true })) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findPrevious/incremental typing flow', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
const terms = ['e', 'er', 'err', 'erro', 'error'];
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.INCREMENTAL_ITERATIONS; i++) {
|
||||
for (const term of terms) {
|
||||
if (search.findPrevious(term, { incremental: true })) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { payloadSize: Constants.INCREMENTAL_ITERATIONS * terms.length, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findNext/cold cache', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.COLD_CACHE_ITERATIONS; i++) {
|
||||
// Force a fresh logical-line reconstruction and match scan each search.
|
||||
(search as any)._clearMatchCache();
|
||||
if (search.findNext('opencv')) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.COLD_CACHE_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.COLD_CACHE_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.COLD_CACHE_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findNext/wholeWord dense-punctuation', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||
if (search.findNext('error', { wholeWord: true })) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findPrevious/regex wholeWord reverse', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon();
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||
if (search.findPrevious('error', { regex: true, wholeWord: true })) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('findNext/decorations refresh', () => {
|
||||
let terminal: TestTerminal;
|
||||
let search: SearchAddon;
|
||||
before(() => {
|
||||
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||
search = new SearchAddon({ highlightLimit: 1500 });
|
||||
search.activate(terminal);
|
||||
terminal.writeSync(bufferContent);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let foundCount = 0;
|
||||
for (let i = 0; i < Constants.DECORATION_REFRESH_ITERATIONS; i++) {
|
||||
const term = i % 2 === 0 ? 'WARN' : 'ERROR';
|
||||
if (search.findNext(term, DECORATION_OPTIONS)) {
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
if (foundCount !== Constants.DECORATION_REFRESH_ITERATIONS) {
|
||||
throw new Error(`Expected ${Constants.DECORATION_REFRESH_ITERATIONS} matches, got ${foundCount}`);
|
||||
}
|
||||
return { payloadSize: Constants.DECORATION_REFRESH_ITERATIONS, foundCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"APP_PATH": ".benchmark",
|
||||
"evalConfig": {
|
||||
"tolerance": {
|
||||
"*": [0.9, 1.2],
|
||||
"SearchAddon.benchmark.js.*.averageRuntime.mean": [0.93, 1.12]
|
||||
},
|
||||
"skip": [
|
||||
"*.median",
|
||||
"*.runs"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"es2021"
|
||||
],
|
||||
"rootDir": "..",
|
||||
"outDir": "../out-benchmark",
|
||||
"types": ["../../../node_modules/@types/node"],
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"target": "es2021",
|
||||
"module": "commonjs",
|
||||
"paths": {
|
||||
"common/*": ["../../../src/common/*"],
|
||||
"browser/*": ["../../../src/browser/*"],
|
||||
"SearchAddon": ["../src/SearchAddon"],
|
||||
"@xterm/addon-search": [
|
||||
"../typings/addon-search.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["../**/*", "../../../typings/xterm.d.ts"],
|
||||
"exclude": ["../../../**/*test.ts", "../../**/*api.ts"],
|
||||
"references": [
|
||||
{ "path": "../../../src/common" },
|
||||
{ "path": "../../../src/browser" }
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,9 @@
|
||||
"prepackage": "../../node_modules/.bin/tsgo -p .",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
"start": "node ../../demo/start",
|
||||
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
|
||||
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
||||
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --eval -F out-benchmark/benchmark/*benchmark.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a highlight decoration.
|
||||
*/
|
||||
interface IHighlight extends IDisposable {
|
||||
decoration: IDecoration;
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for managing multiple decorations for a single match.
|
||||
*/
|
||||
interface IMultiHighlight extends IDisposable {
|
||||
decorations: IDecoration[];
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages visual decorations for search results including highlighting and active selection
|
||||
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
|
||||
*/
|
||||
export class DecorationManager extends Disposable {
|
||||
private _highlightDecorations: IHighlight[] = [];
|
||||
private _highlightedLines: Set<number> = new Set();
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this.clearHighlightDecorations()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for all provided search results.
|
||||
* @param results The search results to create decorations for.
|
||||
* @param options The decoration options.
|
||||
*/
|
||||
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
|
||||
this.clearHighlightDecorations();
|
||||
|
||||
for (const match of results) {
|
||||
const decorations = this._createResultDecorations(match, options, false);
|
||||
if (decorations) {
|
||||
for (const decoration of decorations) {
|
||||
this._storeDecoration(decoration, match);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for the currently active search result.
|
||||
* @param result The active search result.
|
||||
* @param options The decoration options.
|
||||
* @returns The multi-highlight decoration or undefined if creation failed.
|
||||
*/
|
||||
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
|
||||
const decorations = this._createResultDecorations(result, options, true);
|
||||
if (decorations) {
|
||||
return { decorations, match: result, dispose() { dispose(decorations); } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all highlight decorations.
|
||||
*/
|
||||
public clearHighlightDecorations(): void {
|
||||
dispose(this._highlightDecorations);
|
||||
this._highlightDecorations = [];
|
||||
this._highlightedLines.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a decoration and tracks it for management.
|
||||
* @param decoration The decoration to store.
|
||||
* @param match The search result this decoration represents.
|
||||
*/
|
||||
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
|
||||
this._highlightedLines.add(decoration.marker.line);
|
||||
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies styles to the decoration when it is rendered.
|
||||
* @param element The decoration's element.
|
||||
* @param borderColor The border color to apply.
|
||||
* @param isActiveResult Whether the element is part of the active search result.
|
||||
*/
|
||||
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
||||
if (!element.classList.contains('xterm-find-result-decoration')) {
|
||||
element.classList.add('xterm-find-result-decoration');
|
||||
if (borderColor) {
|
||||
element.style.outline = `1px solid ${borderColor}`;
|
||||
}
|
||||
}
|
||||
if (isActiveResult) {
|
||||
element.classList.add('xterm-find-active-result-decoration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a decoration for the result and applies styles
|
||||
* @param result the search result for which to create the decoration
|
||||
* @param options the options for the decoration
|
||||
* @param isActiveResult whether this is the currently active result
|
||||
* @returns the decorations or undefined if the marker has already been disposed of
|
||||
*/
|
||||
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
|
||||
// Gather decoration ranges for this match as it could wrap
|
||||
const decorationRanges: [number, number, number][] = [];
|
||||
let currentCol = result.col;
|
||||
let remainingSize = result.size;
|
||||
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
|
||||
while (remainingSize > 0) {
|
||||
const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);
|
||||
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
|
||||
currentCol = 0;
|
||||
remainingSize -= amountThisRow;
|
||||
markerOffset++;
|
||||
}
|
||||
|
||||
// Create the decorations
|
||||
const decorations: IDecoration[] = [];
|
||||
for (const range of decorationRanges) {
|
||||
const marker = this._terminal.registerMarker(range[0]);
|
||||
const decoration = this._terminal.registerDecoration({
|
||||
marker,
|
||||
x: range[1],
|
||||
width: range[2],
|
||||
layer: isActiveResult ? 'top' : 'bottom',
|
||||
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
|
||||
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
|
||||
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
|
||||
position: 'center'
|
||||
}
|
||||
});
|
||||
if (decoration) {
|
||||
const disposables: IDisposable[] = [];
|
||||
disposables.push(marker);
|
||||
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
|
||||
disposables.push(decoration.onDispose(() => dispose(disposables)));
|
||||
decorations.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
return decorations.length === 0 ? undefined : decorations;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1204
-205
File diff suppressed because it is too large
Load Diff
@@ -1,708 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchEngine } from './SearchEngine';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import { DisposableStore } from 'common/Lifecycle';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchEngine', () => {
|
||||
let store: DisposableStore;
|
||||
let terminal: Terminal;
|
||||
let lineCache: SearchLineCache;
|
||||
let searchEngine: SearchEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new DisposableStore();
|
||||
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
|
||||
lineCache = store.add(new SearchLineCache(terminal));
|
||||
searchEngine = new SearchEngine(terminal, lineCache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should find basic text in terminal content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text starting from specified position', async () => {
|
||||
await writeP(terminal, 'Hello Hello Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should search across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 7,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when text is not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should throw error for invalid column position', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.throws(() => {
|
||||
searchEngine.find('Hello', 0, 100);
|
||||
}, /Invalid col: 100 to search in terminal of 80 cols/);
|
||||
});
|
||||
|
||||
it('should handle search starting from last column', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
|
||||
});
|
||||
|
||||
it('should handle search from middle of match', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
|
||||
});
|
||||
});
|
||||
|
||||
describe('search options', () => {
|
||||
describe('caseSensitive', () => {
|
||||
it('should find text with case-insensitive search (default)', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text with case-sensitive search when enabled', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find text with case-sensitive search when case differs', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wholeWord', () => {
|
||||
it('should find whole word when enabled', async () => {
|
||||
await writeP(terminal, 'Hello world wonderful');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find partial word when wholeWord is enabled', async () => {
|
||||
await writeP(terminal, 'Hello wonderful');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
|
||||
it('should find word at beginning of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'world is great');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find word at end of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'hello world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle word boundaries with punctuation', async () => {
|
||||
await writeP(terminal, 'hello,world!test');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not match when not whole word', async () => {
|
||||
await writeP(terminal, 'helloworld');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex', () => {
|
||||
it('should find text using simple regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello 123 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
|
||||
term: '123',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-insensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-sensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex regex patterns', async () => {
|
||||
await writeP(terminal, 'Email: test@example.com and another@domain.org');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
|
||||
term: 'test@example.com',
|
||||
col: 7,
|
||||
row: 0,
|
||||
size: 16
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Invalid regex should be handled gracefully
|
||||
assert.throws(() => {
|
||||
searchEngine.find('[invalid', 0, 0, { regex: true });
|
||||
}, /Invalid regular expression/);
|
||||
});
|
||||
|
||||
it('should handle empty regex matches', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined options', () => {
|
||||
it('should handle regex + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'H',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wholeWord + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD wonderful');
|
||||
|
||||
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.deepStrictEqual(result1, {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.strictEqual(result2, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find first occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find next occurrence after current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock the getSelectionPosition to return a selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around to beginning when reaching end', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at the end
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
}); // Should wrap to first occurrence
|
||||
});
|
||||
|
||||
it('should wrap across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
|
||||
|
||||
// Mock selection at first "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
|
||||
term: 'test',
|
||||
col: 7,
|
||||
row: 2,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
it('should return same selection if only one match exists', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Mock selection at "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPreviousWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find last occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find previous occurrence before current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock selection at second "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// It may find the same selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should wrap around to end when reaching beginning', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Due to the expansion attempt, it may find the same Hello first
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should work across multiple rows in reverse', async () => {
|
||||
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
|
||||
|
||||
// Mock selection at last "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('test');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm will find the current selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.row, 'number');
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
it('should handle selection expansion correctly', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm tries expansion first, so it may find the same Hello
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
describe('unicode and special characters', () => {
|
||||
it('should handle unicode characters correctly', async () => {
|
||||
await writeP(terminal, 'Hello 世界 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
|
||||
term: '世界',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, '中文测试');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
|
||||
term: '测试',
|
||||
col: 4,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('wrapped lines', () => {
|
||||
it('should handle search across wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wrapped lines with unicode', async () => {
|
||||
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip wrapped lines correctly in findInLine', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText + '\r\nNext line with target');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 15,
|
||||
row: 3,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer boundaries', () => {
|
||||
|
||||
|
||||
it('should handle empty buffer gracefully', () => {
|
||||
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should handle search beyond buffer size', () => {
|
||||
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should handle undefined search options gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative start positions', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search options with undefined properties', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
const options: ISearchOptions = {
|
||||
caseSensitive: undefined,
|
||||
regex: undefined,
|
||||
wholeWord: undefined
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private method behaviors (tested indirectly)', () => {
|
||||
describe('_isWholeWord behavior', () => {
|
||||
it('should recognize word boundaries with various punctuation', async () => {
|
||||
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
|
||||
|
||||
const tests = [
|
||||
{ term: 'word1', expected: true },
|
||||
{ term: 'word2', expected: true },
|
||||
{ term: 'word3', expected: true },
|
||||
{ term: 'word4', expected: true },
|
||||
{ term: 'word5', expected: true },
|
||||
{ term: 'word6', expected: true },
|
||||
{ term: 'word7', expected: true },
|
||||
{ term: 'word8', expected: true }
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
|
||||
if (test.expected) {
|
||||
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
|
||||
} else {
|
||||
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle word boundaries at line start and end', async () => {
|
||||
await writeP(terminal, 'start middle end');
|
||||
|
||||
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(startResult, {
|
||||
term: 'start',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(endResult, {
|
||||
term: 'end',
|
||||
col: 13,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
|
||||
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(middleResult, {
|
||||
term: 'middle',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer offset calculations', () => {
|
||||
it('should handle wide character offset calculations', async () => {
|
||||
await writeP(terminal, '中文 test 测试');
|
||||
|
||||
const result = searchEngine.find('test', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.term, 'test');
|
||||
// Exact column position depends on wide character handling
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('string to buffer size conversions', () => {
|
||||
it('should correctly calculate size for simple text', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly calculate size for unicode text', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
|
||||
const result = searchEngine.find('世界', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Size should account for wide characters
|
||||
assert.strictEqual(typeof result!.size, 'number');
|
||||
assert.strictEqual(result!.size >= 2, true);
|
||||
});
|
||||
|
||||
it('should handle size calculation across wrapped lines', async () => {
|
||||
const longMatch = 'A'.repeat(100);
|
||||
await writeP(terminal, longMatch);
|
||||
|
||||
const result = searchEngine.find(longMatch, 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.size >= 100, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with SearchLineCache', () => {
|
||||
it('should use cache for line translation', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Initialize cache
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('World', 0, 0);
|
||||
const result2 = searchEngine.find('World', 0, 0);
|
||||
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
assert.notStrictEqual(result2, undefined);
|
||||
assert.deepStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('should handle cache misses gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Don't initialize cache
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should work correctly with cache invalidation', async () => {
|
||||
await writeP(terminal, 'Initial text');
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('Initial', 0, 0);
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
|
||||
// Change terminal content which should invalidate cache
|
||||
await writeP(terminal, '\r\nNew line');
|
||||
|
||||
const result2 = searchEngine.find('New', 0, 0);
|
||||
assert.deepStrictEqual(result2, {
|
||||
term: 'New',
|
||||
col: 0,
|
||||
row: 1,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import type { SearchLineCache } from './SearchLineCache';
|
||||
|
||||
/**
|
||||
* Represents the position to start a search from.
|
||||
*/
|
||||
interface ISearchPosition {
|
||||
startCol: number;
|
||||
startRow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a search result with its position and content.
|
||||
*/
|
||||
export interface ISearchResult {
|
||||
term: string;
|
||||
col: number;
|
||||
row: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for the search engine functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Characters that are considered non-word characters for search boundary detection. These
|
||||
* characters are used to determine word boundaries when performing whole-word searches. Includes
|
||||
* common punctuation, symbols, and whitespace characters.
|
||||
*/
|
||||
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
|
||||
}
|
||||
|
||||
/**
|
||||
* Core search engine that handles finding text within terminal content.
|
||||
* This class is responsible for the actual search algorithms and position calculations.
|
||||
*/
|
||||
export class SearchEngine {
|
||||
constructor(
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _lineCache: SearchLineCache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find the first occurrence of a term starting from a specific position.
|
||||
* @param term The search term.
|
||||
* @param startRow The row to start searching from.
|
||||
* @param startCol The column to start searching from.
|
||||
* @param searchOptions Search options.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
if (startCol > this._terminal.cols) {
|
||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine incremental behavior.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startCol = 0;
|
||||
let startRow = 0;
|
||||
if (prevSelectedPos) {
|
||||
if (cachedSearchTerm === term) {
|
||||
startCol = prevSelectedPos.end.x;
|
||||
startRow = prevSelectedPos.end.y;
|
||||
} else {
|
||||
startCol = prevSelectedPos.start.x;
|
||||
startRow = prevSelectedPos.start.y;
|
||||
}
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the bottom and didn't search from the very top wrap back up
|
||||
if (!result && startRow !== 0) {
|
||||
for (let y = 0; y < startRow; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is only one result, wrap back and return selection if it exists.
|
||||
if (!result && prevSelectedPos) {
|
||||
searchPosition.startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the previous occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||
const startCol = this._terminal.cols;
|
||||
const isReverseSearch = true;
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
let result: ISearchResult | undefined;
|
||||
if (prevSelectedPos) {
|
||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = prevSelectedPos.start.x;
|
||||
if (cachedSearchTerm !== term) {
|
||||
// Try to expand selection to right first.
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
||||
if (!result) {
|
||||
// If selection was not able to be expanded to the right, then try reverse search
|
||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||
searchPosition.startCol = prevSelectedPos.end.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
|
||||
// Search from startRow - 1 to top
|
||||
if (!result) {
|
||||
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
||||
for (let y = startRow - 1; y >= 0; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the top and didn't search from the very bottom wrap back down
|
||||
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
||||
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
||||
* adjacent to it.
|
||||
* @param searchIndex starting index of the potential whole word substring
|
||||
* @param line entire string in which the potential whole word was found
|
||||
* @param term the substring that starts at searchIndex
|
||||
*/
|
||||
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
||||
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
||||
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
||||
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
||||
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
||||
* be properly searched when the terminal line that the text starts on is searched.
|
||||
* @param term The search term.
|
||||
* @param searchPosition The position to start the search.
|
||||
* @param searchOptions Search options.
|
||||
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
||||
* search to the left.
|
||||
* @returns The search result if it was found.
|
||||
*/
|
||||
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
||||
const row = searchPosition.startRow;
|
||||
const col = searchPosition.startCol;
|
||||
|
||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
||||
const firstLine = this._terminal.buffer.active.getLine(row);
|
||||
if (firstLine?.isWrapped) {
|
||||
if (isReverseSearch) {
|
||||
searchPosition.startCol += this._terminal.cols;
|
||||
return;
|
||||
}
|
||||
|
||||
// This will iterate until we find the line start.
|
||||
// When we find it, we will search using the calculated start column.
|
||||
searchPosition.startRow--;
|
||||
searchPosition.startCol += this._terminal.cols;
|
||||
return this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
this._lineCache.setLineInCache(row, cache);
|
||||
}
|
||||
const [stringLine, offsets] = cache;
|
||||
|
||||
const offset = this._bufferColsToStringOffset(row, col);
|
||||
let searchTerm = term;
|
||||
let searchStringLine = stringLine;
|
||||
if (!searchOptions.regex) {
|
||||
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
||||
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (searchOptions.regex) {
|
||||
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
||||
let foundTerm: RegExpExecArray | null;
|
||||
if (isReverseSearch) {
|
||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
||||
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
term = foundTerm[0];
|
||||
searchRegex.lastIndex -= (term.length - 1);
|
||||
}
|
||||
} else {
|
||||
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
||||
if (foundTerm && foundTerm[0].length > 0) {
|
||||
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
||||
term = foundTerm[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isReverseSearch) {
|
||||
if (offset - searchTerm.length >= 0) {
|
||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
||||
}
|
||||
} else {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
||||
startRowOffset++;
|
||||
}
|
||||
let endRowOffset = startRowOffset;
|
||||
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
||||
endRowOffset++;
|
||||
}
|
||||
const startColOffset = resultIndex - offsets[startRowOffset];
|
||||
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
||||
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
||||
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
||||
const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);
|
||||
|
||||
return {
|
||||
term,
|
||||
col: startColIndex,
|
||||
row: row + startRowOffset,
|
||||
size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _stringLengthToBufferSize(row: number, offset: number): number {
|
||||
const line = this._terminal.buffer.active.getLine(row);
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
// Adjust the searchIndex to normalize emoji into single chars
|
||||
const char = cell.getChars();
|
||||
if (char.length > 1) {
|
||||
offset -= char.length - 1;
|
||||
}
|
||||
// Adjust the searchIndex for empty characters following wide unicode
|
||||
// chars (eg. CJK)
|
||||
const nextCell = line.getCell(i + 1);
|
||||
if (nextCell && nextCell.getWidth() === 0) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
||||
let lineIndex = startRow;
|
||||
let offset = 0;
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (cols > 0 && line) {
|
||||
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
if (cell.getWidth()) {
|
||||
// Treat null characters as whitespace to align with the translateToString API
|
||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
||||
}
|
||||
}
|
||||
lineIndex++;
|
||||
line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
if (line && !line.isWrapped) {
|
||||
break;
|
||||
}
|
||||
cols -= this._terminal.cols;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { timeout } from 'common/Async';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchLineCache', () => {
|
||||
let terminal: Terminal;
|
||||
let cache: SearchLineCache;
|
||||
|
||||
beforeEach(() => {
|
||||
terminal = new Terminal({ cols: 80, rows: 24 });
|
||||
cache = new SearchLineCache(terminal);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cache.dispose();
|
||||
terminal.dispose();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a SearchLineCache instance', () => {
|
||||
assert.instanceOf(cache, SearchLineCache);
|
||||
});
|
||||
|
||||
it('should start with no cache initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initLinesCache', () => {
|
||||
it('should initialize the lines cache array', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
|
||||
});
|
||||
|
||||
it('should not reinitialize if cache already exists', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
cache.initLinesCache();
|
||||
|
||||
const entry = cache.getLineFromCache(0);
|
||||
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
|
||||
});
|
||||
|
||||
it('should set up TTL timeout', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLineFromCache', () => {
|
||||
it('should return undefined when cache is not initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(10), undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for unset entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(50), undefined);
|
||||
});
|
||||
|
||||
it('should return cached entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(5, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(5), entry);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLineInCache', () => {
|
||||
it('should not set entries when cache is not initialized', () => {
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should set entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(10, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(10), entry);
|
||||
});
|
||||
|
||||
it('should overwrite existing entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry1: LineCacheEntry = ['first content', [0]];
|
||||
const entry2: LineCacheEntry = ['second content', [0]];
|
||||
|
||||
cache.setLineInCache(0, entry1);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry1);
|
||||
|
||||
cache.setLineInCache(0, entry2);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateBufferLineToStringWithWrap', () => {
|
||||
it('should translate a single line without wrapping', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello World');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle trimRight parameter', async () => {
|
||||
await writeP(terminal, 'Hello World ');
|
||||
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
|
||||
|
||||
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
|
||||
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
|
||||
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
|
||||
});
|
||||
|
||||
it('should handle wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longText);
|
||||
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
|
||||
assert.equal(result[1][0], 0, 'first offset should be 0');
|
||||
});
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello 世界');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle lines beyond buffer', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(1000, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle complex wrapped content', async () => {
|
||||
await writeP(terminal, 'Line 1\r\n');
|
||||
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
|
||||
await writeP(terminal, 'Line 3');
|
||||
|
||||
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
|
||||
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
|
||||
|
||||
assert.equal(result1[0], 'Line 1');
|
||||
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
|
||||
assert.equal(result3[0], 'Line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache on line feed', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.write('test\r\n');
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on cursor move', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
await writeP(terminal, 'some text');
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on resize', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.resize(100, 30);
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposal', () => {
|
||||
it('should clean up resources on dispose', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
|
||||
});
|
||||
|
||||
it('should be safe to dispose multiple times', () => {
|
||||
cache.initLinesCache();
|
||||
cache.dispose();
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LineCacheEntry type', () => {
|
||||
it('should handle complex line offsets', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'A very long line that wraps multiple times across several terminal lines',
|
||||
[0, 20, 40, 60]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0].length, 72);
|
||||
assert.equal(retrieved![1].length, 4);
|
||||
});
|
||||
|
||||
it('should handle unicode characters in cache entries', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'Hello 世界 🌍 测试',
|
||||
[0]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with real terminal content', () => {
|
||||
it('should correctly translate real buffer content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const cached = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
|
||||
|
||||
assert.equal(cached[0], directTranslation);
|
||||
});
|
||||
|
||||
it('should handle real wrapped content correctly', async () => {
|
||||
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
|
||||
await writeP(terminal, longContent);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longContent);
|
||||
assert.isTrue(result[1].length > 1, 'should have wrapped');
|
||||
});
|
||||
|
||||
it('should work with real escape sequences', async () => {
|
||||
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.include(result[0], 'Before');
|
||||
assert.include(result[0], 'Red Text');
|
||||
assert.include(result[0], 'After');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
|
||||
export type LineCacheEntry = [
|
||||
/**
|
||||
* The string representation of a line (as opposed to the buffer cell representation).
|
||||
*/
|
||||
lineAsString: string,
|
||||
/**
|
||||
* The offsets where each line starts when the entry describes a wrapped line.
|
||||
*/
|
||||
lineOffsets: number[]
|
||||
];
|
||||
|
||||
/**
|
||||
* Configuration constants for the search line cache functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Time-to-live for cached search results in milliseconds. After this duration, cached search
|
||||
* results will be invalidated to ensure they remain consistent with terminal content changes.
|
||||
*/
|
||||
LINES_CACHE_TIME_TO_LIVE = 15000
|
||||
}
|
||||
|
||||
export class SearchLineCache extends Disposable {
|
||||
/**
|
||||
* translateBufferLineToStringWithWrap is a fairly expensive call.
|
||||
* We memoize the calls into an array that has a time based ttl.
|
||||
* _linesCache is also invalidated when the terminal cursor moves.
|
||||
*/
|
||||
private _linesCache: LineCacheEntry[] | undefined;
|
||||
private _linesCacheTimeout = this._register(new MutableDisposable());
|
||||
private _linesCacheDisposables = this._register(new MutableDisposable());
|
||||
// Track access to avoid recreating a timeout on every init call which occurs once per search
|
||||
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
|
||||
private _lastAccessTimestamp = 0;
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this._destroyLinesCache()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a line cache with a ttl
|
||||
*/
|
||||
public initLinesCache(): void {
|
||||
if (!this._linesCache) {
|
||||
this._linesCache = new Array(this._terminal.buffer.active.length);
|
||||
this._linesCacheDisposables.value = combinedDisposable(
|
||||
this._terminal.onLineFeed(() => this._destroyLinesCache()),
|
||||
this._terminal.onCursorMove(() => this._destroyLinesCache()),
|
||||
this._terminal.onResize(() => this._destroyLinesCache())
|
||||
);
|
||||
}
|
||||
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (!this._linesCacheTimeout.value) {
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);
|
||||
}
|
||||
}
|
||||
|
||||
private _destroyLinesCache(): void {
|
||||
this._linesCache = undefined;
|
||||
this._lastAccessTimestamp = 0;
|
||||
this._linesCacheDisposables.clear();
|
||||
this._linesCacheTimeout.clear();
|
||||
}
|
||||
|
||||
private _scheduleLinesCacheTimeout(delay: number): void {
|
||||
this._linesCacheTimeout.value = disposableTimeout(() => {
|
||||
if (!this._linesCache) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const elapsed = now - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {
|
||||
this._destroyLinesCache();
|
||||
return;
|
||||
}
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
public getLineFromCache(row: number): LineCacheEntry | undefined {
|
||||
return this._linesCache?.[row];
|
||||
}
|
||||
|
||||
public setLineInCache(row: number, entry: LineCacheEntry): void {
|
||||
if (this._linesCache) {
|
||||
this._linesCache[row] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
||||
* Wide characters will count as two columns in the resulting string. This
|
||||
* function is useful for getting the actual text underneath the raw selection
|
||||
* position.
|
||||
* @param lineIndex The index of the line being translated.
|
||||
* @param trimRight Whether to trim whitespace to the right.
|
||||
*/
|
||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (line) {
|
||||
const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
|
||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
||||
if (lineWrapsToNext && nextLine) {
|
||||
const lastCell = line.getCell(line.length - 1);
|
||||
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
||||
// a wide character wrapped to the next line
|
||||
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
||||
string = string.slice(0, -1);
|
||||
}
|
||||
}
|
||||
strings.push(string);
|
||||
if (lineWrapsToNext) {
|
||||
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
lineIndex++;
|
||||
line = nextLine;
|
||||
}
|
||||
return [strings.join(''), lineOffsets];
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
||||
import type { IDisposable } from '@xterm/xterm';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a currently selected decoration.
|
||||
*/
|
||||
interface ISelectedDecoration extends IDisposable {
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks search results, manages result indexing, and fires events when results change.
|
||||
* This class provides centralized management of search result state and notifications.
|
||||
*/
|
||||
export class SearchResultTracker extends Disposable {
|
||||
private _searchResults: ISearchResult[] = [];
|
||||
private _selectedDecoration: ISelectedDecoration | undefined;
|
||||
|
||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||
|
||||
/**
|
||||
* Gets the current search results.
|
||||
*/
|
||||
public get searchResults(): ReadonlyArray<ISearchResult> {
|
||||
return this._searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected decoration.
|
||||
*/
|
||||
public get selectedDecoration(): ISelectedDecoration | undefined {
|
||||
return this._selectedDecoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the currently selected decoration.
|
||||
*/
|
||||
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
|
||||
this._selectedDecoration = decoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the search results with a new set of results.
|
||||
* @param results The new search results.
|
||||
* @param maxResults The maximum number of results to track.
|
||||
*/
|
||||
public updateResults(results: ISearchResult[], maxResults: number): void {
|
||||
this._searchResults = results.slice(0, maxResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all search results.
|
||||
*/
|
||||
public clearResults(): void {
|
||||
this._searchResults = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected decoration.
|
||||
*/
|
||||
public clearSelectedDecoration(): void {
|
||||
if (this._selectedDecoration) {
|
||||
this._selectedDecoration.dispose();
|
||||
this._selectedDecoration = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a result in the current results array.
|
||||
* @param result The result to find.
|
||||
* @returns The index of the result, or -1 if not found.
|
||||
*/
|
||||
public findResultIndex(result: ISearchResult): number {
|
||||
for (let i = 0; i < this._searchResults.length; i++) {
|
||||
const match = this._searchResults[i];
|
||||
if (match.row === result.row && match.col === result.col && match.size === result.size) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a result change event with the current state.
|
||||
* @param hasDecorations Whether decorations are enabled.
|
||||
*/
|
||||
public fireResultsChanged(hasDecorations: boolean): void {
|
||||
if (!hasDecorations) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (this._selectedDecoration) {
|
||||
resultIndex = this.findResultIndex(this._selectedDecoration.match);
|
||||
}
|
||||
|
||||
this._onDidChangeResults.fire({
|
||||
resultIndex,
|
||||
resultCount: this._searchResults.length
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.clearSelectedDecoration();
|
||||
this.clearResults();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
|
||||
/**
|
||||
* Manages search state including cached search terms, options tracking, and validation.
|
||||
* This class provides a centralized way to handle search state consistency and option changes.
|
||||
*/
|
||||
export class SearchState {
|
||||
private _cachedSearchTerm: string | undefined;
|
||||
private _lastSearchOptions: ISearchOptions | undefined;
|
||||
|
||||
/**
|
||||
* Gets the currently cached search term.
|
||||
*/
|
||||
public get cachedSearchTerm(): string | undefined {
|
||||
return this._cachedSearchTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cached search term.
|
||||
*/
|
||||
public set cachedSearchTerm(term: string | undefined) {
|
||||
this._cachedSearchTerm = term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last search options used.
|
||||
*/
|
||||
public get lastSearchOptions(): ISearchOptions | undefined {
|
||||
return this._lastSearchOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last search options used.
|
||||
*/
|
||||
public set lastSearchOptions(options: ISearchOptions | undefined) {
|
||||
this._lastSearchOptions = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a search term to ensure it's not empty or invalid.
|
||||
* @param term The search term to validate.
|
||||
* @returns true if the term is valid for searching.
|
||||
*/
|
||||
public isValidSearchTerm(term: string): boolean {
|
||||
return !!(term && term.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if search options have changed compared to the last search.
|
||||
* @param newOptions The new search options to compare.
|
||||
* @returns true if the options have changed.
|
||||
*/
|
||||
public didOptionsChange(newOptions?: ISearchOptions): boolean {
|
||||
if (!this._lastSearchOptions) {
|
||||
return true;
|
||||
}
|
||||
if (!newOptions) {
|
||||
return false;
|
||||
}
|
||||
if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.regex !== newOptions.regex) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a new search should trigger highlighting updates.
|
||||
* @param term The search term.
|
||||
* @param options The search options.
|
||||
* @returns true if highlighting should be updated.
|
||||
*/
|
||||
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
|
||||
if (!options?.decorations) {
|
||||
return false;
|
||||
}
|
||||
return this._cachedSearchTerm === undefined ||
|
||||
term !== this._cachedSearchTerm ||
|
||||
this.didOptionsChange(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached search term.
|
||||
*/
|
||||
public clearCachedTerm(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
this._lastSearchOptions = undefined;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,45 @@ test.describe('Search Tests', () => {
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true);
|
||||
deepStrictEqual(await ctx.proxy.getSelection(), '$^1_3{}test$#');
|
||||
});
|
||||
test('Search should not scroll when match is already in viewport', async () => {
|
||||
let dataString = '';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (i === 40) {
|
||||
dataString += `line ${i} needle-in-view`;
|
||||
} else {
|
||||
dataString += `line ${i}`;
|
||||
}
|
||||
dataString += '\n\r';
|
||||
}
|
||||
await ctx.proxy.write(dataString);
|
||||
await ctx.page.evaluate('window.term.scrollToLine(35)');
|
||||
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
|
||||
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-in-view')`), true);
|
||||
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-in-view');
|
||||
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
|
||||
});
|
||||
test('Search should center the match when it is outside the viewport', async () => {
|
||||
let dataString = '';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (i === 60) {
|
||||
dataString += `line ${i} needle-center`;
|
||||
} else {
|
||||
dataString += `line ${i}`;
|
||||
}
|
||||
dataString += '\n\r';
|
||||
}
|
||||
await ctx.proxy.write(dataString);
|
||||
await ctx.page.evaluate('window.term.scrollToLine(0)');
|
||||
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 0);
|
||||
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-center')`), true);
|
||||
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-center');
|
||||
deepStrictEqual(await ctx.page.evaluate('(() => { const viewportY = window.term.buffer.active.viewportY; const rows = window.term.rows; const selection = window.term.getSelectionPosition(); return { viewportY: viewportY, expectedViewportY: selection.start.y - Math.floor(rows / 2) }; })()'), {
|
||||
viewportY: 48,
|
||||
expectedViewportY: 48
|
||||
});
|
||||
});
|
||||
test('Incremental Find Previous', async () => {
|
||||
await ctx.proxy.writeln(`package.jsonc\n`);
|
||||
await ctx.proxy.write('package.json pack package.lock');
|
||||
@@ -277,8 +316,7 @@ test.describe('Search Tests', () => {
|
||||
{ resultCount: 1, resultIndex: 0 },
|
||||
{ resultCount: 2, resultIndex: 1 }
|
||||
]);
|
||||
await timeout(2000);
|
||||
strictEqual(await ctx.page.evaluate(`debugger; window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 },
|
||||
{ resultCount: 2, resultIndex: 1 },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./src" },
|
||||
{ "path": "./test" }
|
||||
{ "path": "./test" },
|
||||
{ "path": "./benchmark" }
|
||||
]
|
||||
}
|
||||
|
||||
+14
-12
@@ -17,7 +17,8 @@ declare module '@xterm/addon-search' {
|
||||
|
||||
/**
|
||||
* Whether to search for a whole word, the result is only valid if it's
|
||||
* surrounded in "non-word" characters such as `_`, `(`, `)` or space.
|
||||
* surrounded by start/end of line or non-word separators such as whitespace
|
||||
* and punctuation (for example `(`, `)`, or space).
|
||||
*/
|
||||
wholeWord?: boolean;
|
||||
|
||||
@@ -28,8 +29,7 @@ declare module '@xterm/addon-search' {
|
||||
|
||||
/**
|
||||
* Whether to do an incremental search, this will expand the selection if it
|
||||
* still matches the term the user typed. Note that this only affects
|
||||
* `findNext`, not `findPrevious`.
|
||||
* still matches the term the user typed.
|
||||
*/
|
||||
incremental?: boolean;
|
||||
|
||||
@@ -43,7 +43,7 @@ declare module '@xterm/addon-search' {
|
||||
/**
|
||||
* Options for showing decorations when searching.
|
||||
*/
|
||||
interface ISearchDecorationOptions {
|
||||
export interface ISearchDecorationOptions {
|
||||
/**
|
||||
* The background color of a match, this must use #RRGGBB format.
|
||||
*/
|
||||
@@ -57,7 +57,7 @@ declare module '@xterm/addon-search' {
|
||||
/**
|
||||
* The overview ruler color of a match.
|
||||
*/
|
||||
matchOverviewRuler: string;
|
||||
matchOverviewRuler?: string;
|
||||
|
||||
/**
|
||||
* The background color for the currently active match, this must use #RRGGBB format.
|
||||
@@ -72,7 +72,7 @@ declare module '@xterm/addon-search' {
|
||||
/**
|
||||
* The overview ruler color of the currently active match.
|
||||
*/
|
||||
activeMatchColorOverviewRuler: string;
|
||||
activeMatchColorOverviewRuler?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,12 +80,13 @@ declare module '@xterm/addon-search' {
|
||||
*/
|
||||
export interface ISearchResultChangeEvent {
|
||||
/**
|
||||
* The index of the currently active result, -1 when the threshold of matches is exceeded.
|
||||
* The index of the currently active result within tracked highlights, or -1 when
|
||||
* the active match is not in the tracked result set.
|
||||
*/
|
||||
resultIndex: number;
|
||||
|
||||
/**
|
||||
* The total number of search results found.
|
||||
* The number of tracked search results (capped by `highlightLimit` when decorations are enabled).
|
||||
*/
|
||||
resultCount: number;
|
||||
}
|
||||
@@ -96,9 +97,9 @@ declare module '@xterm/addon-search' {
|
||||
export interface ISearchAddonOptions {
|
||||
/**
|
||||
* Max number of matches highlighted when decorations are enabled.
|
||||
* Defaults to 1000 highlighted matches
|
||||
* Defaults to 1000. Invalid values fall back to the default.
|
||||
*/
|
||||
highlightLimit: number
|
||||
highlightLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +141,7 @@ declare module '@xterm/addon-search' {
|
||||
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
|
||||
|
||||
/**
|
||||
* Clears the decorations and selection
|
||||
* Clears highlight decorations and tracked results.
|
||||
*/
|
||||
public clearDecorations(): void;
|
||||
|
||||
@@ -162,7 +163,8 @@ declare module '@xterm/addon-search' {
|
||||
readonly onBeforeSearch: IEvent<void>;
|
||||
|
||||
/**
|
||||
* When decorations are enabled, fires when the search results change.
|
||||
* When decorations are enabled, fires after `findNext`/`findPrevious` when tracked
|
||||
* search results change. Does not fire from `clearDecorations()`.
|
||||
*/
|
||||
readonly onDidChangeResults: IEvent<ISearchResultChangeEvent>;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "../out-benchmark",
|
||||
"types": ["../../../node_modules/@types/node"],
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"target": "es2021",
|
||||
"module": "nodenext",
|
||||
"module": "commonjs",
|
||||
"paths": {
|
||||
"common/*": ["../../../src/common/*"],
|
||||
"browser/*": ["../../../src/browser/*"],
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "../out-benchmark",
|
||||
"types": ["../../../node_modules/@types/node"],
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"target": "es2015",
|
||||
"module": "nodenext",
|
||||
"module": "commonjs",
|
||||
"paths": {
|
||||
"common/*": ["../../../src/common/*"],
|
||||
"browser/*": ["../../../src/browser/*"],
|
||||
|
||||
@@ -78,22 +78,7 @@ test.describe('WebFontsAddon', () => {
|
||||
const data = await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
||||
`);
|
||||
let fonts = await getDocumentFonts();
|
||||
// safari may have loaded the font, firefox & chrome never load it
|
||||
if (browser.browserType().name() === 'webkit') {
|
||||
try {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loading' }]);
|
||||
} catch (e) {
|
||||
try {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
} catch (e) {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
}
|
||||
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
|
||||
// broken case: webfont in ctor without addon usage
|
||||
await ctx.page.evaluate(`
|
||||
@@ -101,20 +86,15 @@ test.describe('WebFontsAddon', () => {
|
||||
window.helperTerm.open(term.element);
|
||||
`);
|
||||
|
||||
fonts = await getDocumentFonts();
|
||||
// safari might load the font, firefox & chrome dont
|
||||
// safari loads the font, firefox & chrome dont
|
||||
if (browser.browserType().name() === 'webkit') {
|
||||
try {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loading' }]);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
|
||||
} catch (e) {
|
||||
try {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
} catch (e) {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
}
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
}
|
||||
} else {
|
||||
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
}
|
||||
|
||||
// good case: addon fixes layout for webfont in ctor
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"lib": [
|
||||
"es2021",
|
||||
],
|
||||
// "downlevelIteration": true,
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
|
||||
@@ -113,8 +113,8 @@ export class LinkComputer {
|
||||
let line: IBufferLine | undefined;
|
||||
let topIdx = lineIndex;
|
||||
let bottomIdx = lineIndex;
|
||||
let length;
|
||||
let content;
|
||||
let length = 0;
|
||||
let content = '';
|
||||
const lines: string[] = [];
|
||||
|
||||
if ((line = terminal.buffer.active.getLine(lineIndex))) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"lib": [
|
||||
"es2021",
|
||||
],
|
||||
// "downlevelIteration": true,
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { configEquals } from './CharAtlasUtils';
|
||||
import { ICharAtlasConfig } from './Types';
|
||||
import { NULL_COLOR } from 'common/Color';
|
||||
import { IColor } from 'common/Types';
|
||||
|
||||
function createTestConfig(overrides: Partial<ICharAtlasConfig> = {}): ICharAtlasConfig {
|
||||
const color: IColor = { css: '#ffffff', rgba: 0xffffffff };
|
||||
const contrastCache = {
|
||||
clear: () => {},
|
||||
setCss: () => {},
|
||||
getCss: () => undefined,
|
||||
setColor: () => {},
|
||||
getColor: () => undefined
|
||||
};
|
||||
const colors = {
|
||||
foreground: color,
|
||||
background: color,
|
||||
cursor: NULL_COLOR,
|
||||
cursorAccent: NULL_COLOR,
|
||||
selectionForeground: undefined,
|
||||
selectionBackgroundTransparent: NULL_COLOR,
|
||||
selectionBackgroundOpaque: NULL_COLOR,
|
||||
selectionInactiveBackgroundTransparent: NULL_COLOR,
|
||||
selectionInactiveBackgroundOpaque: NULL_COLOR,
|
||||
overviewRulerBorder: NULL_COLOR,
|
||||
scrollbarSliderBackground: NULL_COLOR,
|
||||
scrollbarSliderHoverBackground: NULL_COLOR,
|
||||
scrollbarSliderActiveBackground: NULL_COLOR,
|
||||
ansi: new Array(256).fill(color),
|
||||
contrastCache,
|
||||
halfContrastCache: contrastCache
|
||||
};
|
||||
return {
|
||||
customGlyphs: true,
|
||||
devicePixelRatio: 1,
|
||||
deviceMaxTextureSize: 4096,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
fontSize: 15,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
deviceCellWidth: 10,
|
||||
deviceCellHeight: 20,
|
||||
deviceCharWidth: 8,
|
||||
deviceCharHeight: 16,
|
||||
allowTransparency: false,
|
||||
drawBoldTextInBrightColors: true,
|
||||
minimumContrastRatio: 1,
|
||||
colors,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CharAtlasUtils', () => {
|
||||
describe('configEquals', () => {
|
||||
it('should return true for identical configs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig();
|
||||
assert.ok(configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceMaxTextureSize differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceMaxTextureSize: 8192 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellWidth differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellWidth: 11 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellHeight differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellHeight: 21 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,6 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
}
|
||||
}
|
||||
return a.devicePixelRatio === b.devicePixelRatio &&
|
||||
a.deviceMaxTextureSize === b.deviceMaxTextureSize &&
|
||||
a.customGlyphs === b.customGlyphs &&
|
||||
a.lineHeight === b.lineHeight &&
|
||||
a.letterSpacing === b.letterSpacing &&
|
||||
@@ -68,8 +67,6 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
a.fontWeight === b.fontWeight &&
|
||||
a.fontWeightBold === b.fontWeightBold &&
|
||||
a.allowTransparency === b.allowTransparency &&
|
||||
a.deviceCellWidth === b.deviceCellWidth &&
|
||||
a.deviceCellHeight === b.deviceCellHeight &&
|
||||
a.deviceCharWidth === b.deviceCharWidth &&
|
||||
a.deviceCharHeight === b.deviceCharHeight &&
|
||||
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
|
||||
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import type { ILogService, IOptionsService } from 'common/services/Services';
|
||||
import type { IOptionsService } from 'common/services/Services';
|
||||
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
interface IVertices {
|
||||
@@ -114,8 +114,7 @@ export class GlyphRenderer extends Disposable {
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _logService: ILogService
|
||||
private readonly _optionsService: IOptionsService
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -128,7 +127,7 @@ export class GlyphRenderer extends Disposable {
|
||||
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
|
||||
}
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages)));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
@@ -137,9 +136,8 @@ export class GlyphRenderer extends Disposable {
|
||||
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { RenderModelConstants } from './RenderModel';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
|
||||
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
const enum VertexAttribLocations {
|
||||
POSITION = 0,
|
||||
@@ -90,23 +89,21 @@ export class RectangleRenderer extends Disposable {
|
||||
private _terminal: Terminal,
|
||||
private _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _logService: ILogService
|
||||
private readonly _themeService: IThemeService
|
||||
) {
|
||||
super();
|
||||
|
||||
const gl = this._gl;
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
|
||||
@@ -109,7 +109,6 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
page.canvas.remove();
|
||||
}
|
||||
this._onAddTextureAtlasCanvas.dispose();
|
||||
this._onRemoveTextureAtlasCanvas.dispose();
|
||||
}
|
||||
|
||||
public warmUp(): void {
|
||||
@@ -530,7 +529,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
let customGlyph = false;
|
||||
if (this._config.customGlyphs !== false) {
|
||||
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, this._logService, backgroundColor.css, variantOffset);
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
|
||||
}
|
||||
|
||||
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface ICursorRenderModel {
|
||||
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
|
||||
vertexAttribDivisor(index: number, divisor: number): void;
|
||||
createVertexArray(): IWebGLVertexArrayObject;
|
||||
deleteVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
bindVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender
|
||||
import { ITerminal } from 'browser/Types';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { getSafariVersion, isSafari } from 'common/Platform';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { IWebGL2RenderingContext } from './Types';
|
||||
import { WebglRenderer } from './WebglRenderer';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
@@ -65,7 +65,6 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
||||
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
||||
const decorationService: IDecorationService = unsafeCore._decorationService;
|
||||
const logService: ILogService = unsafeCore._logService;
|
||||
const themeService: IThemeService = unsafeCore._themeService;
|
||||
|
||||
this._renderer = this._register(new WebglRenderer(
|
||||
@@ -75,7 +74,6 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
coreBrowserService,
|
||||
coreService,
|
||||
decorationService,
|
||||
logService,
|
||||
optionsService,
|
||||
themeService,
|
||||
this._customGlyphs,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { GlyphRenderer } from './GlyphRenderer';
|
||||
import { RectangleRenderer } from './RectangleRenderer';
|
||||
@@ -77,7 +77,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private readonly _coreBrowserService: ICoreBrowserService,
|
||||
private readonly _coreService: ICoreService,
|
||||
private readonly _decorationService: IDecorationService,
|
||||
private readonly _logService: ILogService,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _customGlyphs: boolean = true,
|
||||
@@ -123,19 +122,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
||||
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
|
||||
this._logService.debug('webglcontextlost event received');
|
||||
console.log('webglcontextlost event received');
|
||||
// Prevent the default behavior in order to enable WebGL context restoration.
|
||||
e.preventDefault();
|
||||
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
|
||||
// If not, dispatch the onContextLoss notification to observers.
|
||||
this._contextRestorationTimeout = setTimeout(() => {
|
||||
this._contextRestorationTimeout = undefined;
|
||||
this._logService.warn('webgl context not restored; firing onContextLoss');
|
||||
console.warn('webgl context not restored; firing onContextLoss');
|
||||
this._onContextLoss.fire(e);
|
||||
}, 3000 /* ms */);
|
||||
}));
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
|
||||
this._logService.warn('webglcontextrestored event received');
|
||||
console.warn('webglcontextrestored event received');
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
// The texture atlas and glyph renderer must be fully reinitialized
|
||||
@@ -159,8 +158,6 @@ 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();
|
||||
}
|
||||
@@ -277,8 +274,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
* Initializes members dependent on WebGL context state.
|
||||
*/
|
||||
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService);
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService);
|
||||
|
||||
// Update dimensions and acquire char atlas
|
||||
this.handleCharSizeChanged();
|
||||
@@ -427,8 +424,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
let line: IBufferLine;
|
||||
let joinedRanges: [number, number][];
|
||||
let isJoined: boolean;
|
||||
let skipJoinedCheckUntilX: number;
|
||||
let isValidJoinRange: boolean;
|
||||
let skipJoinedCheckUntilX: number = 0;
|
||||
let isValidJoinRange: boolean = true;
|
||||
let lastCharX: number;
|
||||
let range: [number, number];
|
||||
let isCursorRow: boolean;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
/**
|
||||
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
|
||||
@@ -17,21 +16,21 @@ export const PROJECTION_MATRIX = new Float32Array([
|
||||
-1, 1, 0, 1
|
||||
]);
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined {
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined {
|
||||
const program = throwIfFalsy(gl.createProgram());
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)));
|
||||
gl.linkProgram(program);
|
||||
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||
if (success) {
|
||||
return program;
|
||||
}
|
||||
|
||||
logService.error(gl.getProgramInfoLog(program));
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
}
|
||||
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined {
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
|
||||
const shader = throwIfFalsy(gl.createShader(type));
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
@@ -40,7 +39,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st
|
||||
return shader;
|
||||
}
|
||||
|
||||
logService.error(gl.getShaderInfoLog(shader));
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
||||
|
||||
@@ -23,7 +22,6 @@ export function tryDrawCustomGlyph(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): boolean {
|
||||
@@ -32,7 +30,7 @@ export function tryDrawCustomGlyph(
|
||||
// Normalize to array for uniform handling
|
||||
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
||||
for (const part of parts) {
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset);
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -51,7 +49,6 @@ function drawDefinitionPart(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): void {
|
||||
@@ -82,7 +79,7 @@ function drawDefinitionPart(
|
||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth);
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH:
|
||||
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
@@ -91,7 +88,7 @@ function drawDefinitionPart(
|
||||
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.VECTOR_SHAPE:
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService);
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.BRAILLE:
|
||||
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||
@@ -513,7 +510,6 @@ function drawPathFunctionCharacter(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
strokeWidth?: number
|
||||
): void {
|
||||
ctx.save();
|
||||
@@ -540,7 +536,7 @@ function drawPathFunctionCharacter(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
@@ -602,8 +598,7 @@ function drawVectorShape(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService
|
||||
devicePixelRatio: number
|
||||
): void {
|
||||
// Clip the cell to make sure drawing doesn't occur beyond bounds
|
||||
const clipRegion = new Path2D();
|
||||
@@ -624,7 +619,7 @@ function drawVectorShape(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha"
|
||||
|
||||
@@ -65,8 +65,7 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
const dtIip = document.createElement('dt');
|
||||
dtIip.textContent = 'IIP (iTerm)';
|
||||
dlIip.appendChild(dtIip);
|
||||
this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)');
|
||||
this._addDdWithButton(dlIip, 'image-demo4', 'dice (qoi MultipartFile)');
|
||||
this._addDdWithButton(dlIip, 'image-demo3', 'palette');
|
||||
container.appendChild(dlIip);
|
||||
|
||||
// Kitty demos
|
||||
@@ -142,21 +141,6 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
this._terminal.write(`\x1b]1337;File=inline=1;size=${data.length}:${btoa(sdata)}\x1b\\`);
|
||||
});
|
||||
|
||||
const iipDemoMulti = (url: string) => () => fetch(url)
|
||||
.then(resp => resp.arrayBuffer())
|
||||
.then(buffer => {
|
||||
const data = new Uint8Array(buffer);
|
||||
let sdata = '';
|
||||
for (let i = 0; i < data.length; ++i) sdata += String.fromCharCode(data[i]);
|
||||
const encoded = btoa(sdata);
|
||||
this._terminal.write('\r\n');
|
||||
this._terminal.write(`\x1b]1337;MultipartFile=inline=1\x1b\\`);
|
||||
for (let i = 0; i < encoded.length; i += 100) {
|
||||
this._terminal.write(`\x1b]1337;FilePart=${encoded.slice(i, i + 100)}\x1b\\`);
|
||||
}
|
||||
this._terminal.write(`\x1b]1337;FileEnd\x1b\\`);
|
||||
});
|
||||
|
||||
const kittyDemo = (url: string) => () => fetch(url)
|
||||
.then(resp => resp.arrayBuffer())
|
||||
.then(buffer => {
|
||||
@@ -174,8 +158,6 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
|
||||
document.getElementById('image-demo3')!.addEventListener('click',
|
||||
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||
document.getElementById('image-demo4')!.addEventListener('click',
|
||||
iipDemoMulti('https://raw.githubusercontent.com/jerch/xterm-wasm-parts/master/fixtures/qoi/dice.qoi'));
|
||||
document.getElementById('image-demo-kitty1')!.addEventListener('click',
|
||||
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||
|
||||
@@ -194,7 +176,7 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
// }
|
||||
// }
|
||||
|
||||
const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
|
||||
const pos = (this._terminal as any)._core._mouseService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
|
||||
const x = pos[0] - 1;
|
||||
const y = pos[1] - 1;
|
||||
const canvas = ev.shiftKey
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2021",
|
||||
"types": ["node"],
|
||||
"outDir": "../out-demo/client",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true,
|
||||
|
||||
@@ -118,7 +118,7 @@ function startServer(): void {
|
||||
});
|
||||
|
||||
appWs.ws('/terminals/:pid', function (ws, req) {
|
||||
const term = terminals[parseInt(Array.isArray(req.params.pid) ? req.params.pid[0] : req.params.pid)];
|
||||
const term = terminals[parseInt(req.params.pid)];
|
||||
console.log('Connected to terminal ' + term.pid);
|
||||
temporaryDisposable[term.pid].dispose();
|
||||
delete temporaryDisposable[term.pid];
|
||||
|
||||
+1
-3
@@ -37,13 +37,12 @@ export default tseslint.config(
|
||||
'@stylistic/comma-spacing': ['warn', { before: false, after: true }],
|
||||
'@stylistic/indent': ['warn', 2],
|
||||
'@stylistic/semi': ['warn', 'always'],
|
||||
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: 'always' }],
|
||||
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: true }],
|
||||
'@stylistic/member-delimiter-style': ['warn', {
|
||||
multiline: { delimiter: 'semi', requireLast: true },
|
||||
singleline: { delimiter: 'comma', requireLast: false }
|
||||
}],
|
||||
'@stylistic/type-annotation-spacing': 'warn',
|
||||
'@stylistic/space-unary-ops': 'warn',
|
||||
|
||||
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
@@ -86,7 +85,6 @@ export default tseslint.config(
|
||||
'eqeqeq': ['warn', 'always'],
|
||||
'jsdoc/check-alignment': 'warn',
|
||||
'jsdoc/check-param-names': 'warn',
|
||||
'jsdoc/multiline-blocks': 'warn',
|
||||
'jsdoc/no-multi-asterisks': 'warn',
|
||||
'keyword-spacing': 'warn',
|
||||
'max-len': ['warn', {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default tseslint.config(
|
||||
rules: {
|
||||
'@stylistic/indent': ['warn', 2],
|
||||
'@stylistic/semi': ['warn', 'always'],
|
||||
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: 'always' }],
|
||||
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: true }],
|
||||
'@stylistic/member-delimiter-style': ['warn', {
|
||||
multiline: { delimiter: 'semi', requireLast: true },
|
||||
singleline: { delimiter: 'comma', requireLast: false }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user