Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose key helpers on the API for addons #5292

Draft
wants to merge 16 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"src/browser/tsconfig.json",
"src/common/tsconfig.json",
"src/headless/tsconfig.json",
"src/shared/tsconfig.json",
"src/vs/tsconfig.json",
"test/benchmark/tsconfig.json",
"test/playwright/tsconfig.json",
Expand Down
27 changes: 18 additions & 9 deletions addons/addon-progress/src/ProgressAddon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
* @license MIT
*/

import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
import { Terminal, ITerminalAddon, IDisposable, EmitterCtorType, IEmitter, IEvent } from '@xterm/xterm';
import type { ProgressAddon as IProgressApi, IProgressState } from '@xterm/addon-progress';
import type { Emitter, Event } from 'vs/base/common/event';

// to use impl parts:

// in 3rd party addons
// import { EmitterAddon } from '@xterm/xterm';

// in xtermjs repo addons
import { EmitterAddon } from 'shared/shared';


const enum ProgressType {
Expand Down Expand Up @@ -33,13 +40,18 @@ function toInt(s: string): number {
}


export class ProgressAddon implements ITerminalAddon, IProgressApi {
export class ProgressAddon extends EmitterAddon implements ITerminalAddon, IProgressApi {
private _seqHandler: IDisposable | undefined;
private _st: ProgressType = ProgressType.REMOVE;
private _pr = 0;
// HACK: This uses ! to align with the API, this should be fixed when 5283 is resolved
private _onChange!: Emitter<IProgressState>;
public onChange!: Event<IProgressState>;
private _onChange: IEmitter<IProgressState>;
public onChange: IEvent<IProgressState>;

constructor(protected readonly _emitterCtor: EmitterCtorType) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's nicest to just pass in the whole xterm API object to some addons? I think we can do this without error:

import type * as XtermApi from '@xterm/xterm';
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';

That way it would be nicer from the embedder side:

new ProgressAddon(xterm)
new WebglAddon(xterm)
etc.

Looks better than this imo:

new ProgressAddon(AddonDisposable)
new WebglAddon(AddonDisposable)

Copy link
Member Author

@jerch jerch Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thats a good idea. The dispoable + emitter ctors, if both are needed, already make this cumbersome and looking ugly. With the whole exports as one arguments it gets much nicer and easier to comprehend on caller side.
I even wonder if we should make that the first default argument on all addon ctors, this way ppl wont get it wrong on certain addons, just apply it on all (well thats a major API shift).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well the full module export type interface stubbing felt kinda wrong. My next approach looks like that:

on addon impl side:

import { ..., ISharedExports } from '@xterm/xterm';

export class AddonXY implements ... {

  constructor(sharedExports: ISharedExports) {
    // do something with things exposed under sharedExports
  }

on embedding side:

import type { Terminal, sharedExports } from '@xterm/xterm';
import { AddonXY } from '@term/addon-xy';

const term = new Terminal(...);
const addonXY = new AddonXY(sharedExports);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In retrospect it's obvious this would be a breaking change, but that's fine and worth it considering the wins we get in bundle size.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About breaking change - as I wrote above, I wonder whether to make the xterm exports the first ctor argument for all addons, e.g.:

import * as XtermApi from '@xterm/xterm';
import { AddonXY } from '@term/addon-xy';

const term = XtermApi.Terminal(...);
const addonXY = new AddonXY(XtermApi);

Then an addon ctor is free to use the exported ctors there or to ignore that argument:

import type * as XtermApi from '@xterm/xterm';
class AddonXY extends ... {
  contructor(xterm: XtermApi, other_args) {
    // if addon needs an event:
    this._onWhatever = xterm.Emitter<Whatever>();
    ...
    // else: just ignore xterm argument
  }
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if thats too "globalish" looking, we could also aggregate the extra exports under a shared API endpoint:

import { Terminal, shared } from '@xterm/xterm';
import { AddonXY } from '@term/addon-xy';

const term = new Terminal(...);
const addonXY = new AddonXY(shared);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And last but not least - we could also keep Terminal the only exported impl endpoint and put the shared stuff on the terminal class instead:

import { Terminal } from '@xterm/xterm';
import { AddonXY } from '@term/addon-xy';

const term = new Terminal(...);
const addonXY = new AddonXY(Terminal.shared);

Copy link
Member Author

@jerch jerch Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter has a few advantages, like keeping stuff under the Terminal umbrella and automatically gaining access to those symbols even on a terminal instance.

Edit: Tbh the ctor argument idea raises in fact the question, why not to load addons this way in the first place with a terminal instance as first argument. Do you remember, why we have the loadAddon mechanics on the terminal the way it is implemented currently?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not to load addons this way in the first place with a terminal instance as first argument

Terminal has always been the only thing exposed on the API, but it makes a lot of sense to pass in the full API when we start adding new things. This sort of breaking change is more impactful though as we can't just expect all addon ctors to have it as the first arg.

Do you remember, why we have the loadAddon mechanics on the terminal the way it is implemented currently?

Was a long time ago, but one of the big things we get is loadAddon lets the Terminal take ownership of it. So disposing of a terminal means the addon will be destroyed. That's pretty much all AddonManager does.

Copy link
Member Author

@jerch jerch Jan 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it def. smells like quite the big API change would be needed, so idk exactly how to proceed. Maybe we should go back to conceptual structuring before inventing a square wheel here? Gonna try to do a write up of what we have currently vs. what could be done about it in #5283.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted long replies in #5283 (comment)

super(_emitterCtor);
this._onChange = new this._emitterCtor<IProgressState>();
this.onChange = this._onChange.event;
}

public dispose(): void {
this._seqHandler?.dispose();
Expand Down Expand Up @@ -81,9 +93,6 @@ export class ProgressAddon implements ITerminalAddon, IProgressApi {
}
return true;
});
// FIXME: borrow emitter ctor from xterm, to be changed once #5283 is resolved
this._onChange = new (terminal as any)._core._onData.constructor();
this.onChange = this._onChange!.event;
}

public get progress(): IProgressState {
Expand Down
6 changes: 6 additions & 0 deletions addons/addon-progress/src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"vs/*": [
"../../../src/vs/*"
],
"shared/*": [
"../../../src/shared/*"
],
"@xterm/addon-progress": [
"../typings/addon-progress.d.ts"
]
Expand All @@ -37,6 +40,9 @@
},
{
"path": "../../../src/vs"
},
{
"path": "../../../src/shared"
}
]
}
2 changes: 1 addition & 1 deletion addons/addon-progress/test/ProgressAddon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ test.describe('ProgressAddon', () => {
window.progressStack = [];
window.term.reset();
window.progressAddon?.dispose();
window.progressAddon = new ProgressAddon();
window.progressAddon = new ProgressAddon(emitterCtor);
window.term.loadAddon(window.progressAddon);
window.progressAddon.onChange(progress => window.progressStack.push(progress));
`);
Expand Down
7 changes: 4 additions & 3 deletions addons/addon-progress/typings/addon-progress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@
* @license MIT
*/

import { Terminal, ITerminalAddon, IDisposable, IEvent } from '@xterm/xterm';
import { Terminal, ITerminalAddon, IDisposable, IEvent, EmitterCtorType } from '@xterm/xterm';
import { EmitterAddon } from 'shared/shared';

declare module '@xterm/addon-progress' {
/**
* An xterm.js addon that provides an interface for ConEmu's progress
* sequence.
*/
export class ProgressAddon implements ITerminalAddon, IDisposable {
export class ProgressAddon extends EmitterAddon implements ITerminalAddon, IDisposable {

/**
* Creates a new progress addon
*/
constructor();
constructor(_emitterCtor: EmitterCtorType);

/**
* Activates the addon
Expand Down
3 changes: 2 additions & 1 deletion addons/addon-progress/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ module.exports = {
alias: {
common: path.resolve('../../out/common'),
browser: path.resolve('../../out/browser'),
vs: path.resolve('../../out/vs')
vs: path.resolve('../../out/vs'),
shared: path.resolve('../../out/shared')
}
},
output: {
Expand Down
1 change: 1 addition & 0 deletions bin/esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ if (config.addon) {
'src/browser/**/*.ts',
'src/common/**/*.ts',
'src/headless/**/*.ts',
'src/shared/**/*.ts',
'src/vs/base/**/*.ts',
'src/vs/patches/**/*.ts'
],
Expand Down
10 changes: 7 additions & 3 deletions demo/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ if ('WebAssembly' in window) {
ImageAddon = imageAddon.ImageAddon;
}

import { Terminal, ITerminalOptions, type IDisposable, type ITheme } from '@xterm/xterm';
import { Terminal, ITerminalOptions, type IDisposable, type ITheme, emitterCtor } from '@xterm/xterm';
import { AttachAddon } from '@xterm/addon-attach';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { FitAddon } from '@xterm/addon-fit';
Expand Down Expand Up @@ -44,6 +44,8 @@ export interface IWindowWithTerminal extends Window {
Unicode11Addon?: typeof Unicode11Addon; // eslint-disable-line @typescript-eslint/naming-convention
UnicodeGraphemesAddon?: typeof UnicodeGraphemesAddon; // eslint-disable-line @typescript-eslint/naming-convention
LigaturesAddon?: typeof LigaturesAddon; // eslint-disable-line @typescript-eslint/naming-convention

emitterCtor?: typeof emitterCtor;
}
declare let window: IWindowWithTerminal;

Expand Down Expand Up @@ -226,6 +228,8 @@ if (document.location.pathname === '/test') {
window.LigaturesAddon = LigaturesAddon;
window.WebLinksAddon = WebLinksAddon;
window.WebglAddon = WebglAddon;

window.emitterCtor = emitterCtor;
} else {
createTerminal();
document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler);
Expand Down Expand Up @@ -279,7 +283,7 @@ function createTerminal(): void {
addons.serialize.instance = new SerializeAddon();
addons.fit.instance = new FitAddon();
addons.image.instance = new ImageAddon();
addons.progress.instance = new ProgressAddon();
addons.progress.instance = new ProgressAddon(emitterCtor);
addons.unicodeGraphemes.instance = new UnicodeGraphemesAddon();
addons.clipboard.instance = new ClipboardAddon();
try { // try to start with webgl renderer (might throw on older safari/webkit)
Expand Down Expand Up @@ -660,7 +664,7 @@ function initAddons(term: Terminal): void {
}
if (checkbox.checked) {
// HACK: Manually remove addons that cannot be changes
addon.instance = new (addon as IDemoAddon<Exclude<AddonType, 'attach'>>).ctor();
//addon.instance = new (addon as IDemoAddon<Exclude<AddonType, 'attach'>>).ctor();
try {
term.loadAddon(addon.instance);
if (name === 'webgl') {
Expand Down
16 changes: 15 additions & 1 deletion src/browser/public/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { BufferNamespaceApi } from 'common/public/BufferNamespaceApi';
import { ParserApi } from 'common/public/ParserApi';
import { UnicodeApi } from 'common/public/UnicodeApi';
import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from '@xterm/xterm';
import type { Event } from 'vs/base/common/event';
import { type Event } from 'vs/base/common/event';

/**
* The set of options that only have an effect when set in the Terminal constructor.
Expand Down Expand Up @@ -272,3 +272,17 @@ export class Terminal extends Disposable implements ITerminalApi {
}
}
}


/**
* Expose often needed vs/* parts in addons.
* Exposed statically on the xterm package,
* so they can be used on addon ctors already.
*/
export {
DisposableAddon,
EmitterAddon,
DisposableEmitterAddon
} from 'shared/shared';
export { DisposableStore as disposableStoreCtor, toDisposable } from 'vs/base/common/lifecycle';
export { Emitter as emitterCtor } from 'vs/base/common/event';
6 changes: 4 additions & 2 deletions src/browser/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"baseUrl": "..",
"paths": {
"common/*": [ "./common/*" ],
"vs/*": [ "./vs/*" ]
"vs/*": [ "./vs/*" ],
"shared/*": [ "./shared/*" ],
}
},
"include": [
Expand All @@ -22,6 +23,7 @@
],
"references": [
{ "path": "../common" },
{ "path": "../vs" }
{ "path": "../vs" },
{ "path": "../shared" },
]
}
Empty file added src/shared/shared.test.ts
Empty file.
42 changes: 42 additions & 0 deletions src/shared/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { IDisposable, IDisposableStore, DisposableStoreCtorType, EmitterCtorType } from '@xterm/xterm';


export class DisposableAddon implements IDisposable {
protected readonly _store: IDisposableStore;

constructor(protected readonly _storeCtor: DisposableStoreCtorType) {
this._store = new _storeCtor();
}

public dispose(): void {
this._store.dispose();
}
}
Tyriar marked this conversation as resolved.
Show resolved Hide resolved


export class EmitterAddon {
constructor(
protected readonly _emitterCtor: EmitterCtorType
) {}
}


export class DisposableEmitterAddon implements IDisposable {
protected readonly _store: IDisposableStore;

constructor(
protected readonly _storeCtor: DisposableStoreCtorType,
protected readonly _emitterCtor: EmitterCtorType
) {
this._store = new _storeCtor();
}

public dispose(): void {
this._store.dispose();
}
}
24 changes: 24 additions & 0 deletions src/shared/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"extends": "../tsconfig-library-base",
"compilerOptions": {
"lib": [
"es2015",
"es2016.Array.Include"
],
"outDir": "../../out",
"types": [
"../../node_modules/@types/mocha"
],
"baseUrl": "..",
"paths": {
"vs/*": [ "./vs/*" ]
}
},
"include": [
"./**/*",
"../../typings/xterm.d.ts"
],
"references": [
{ "path": "../vs" }
]
}
69 changes: 69 additions & 0 deletions typings/xterm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1956,4 +1956,73 @@ declare module '@xterm/xterm' {
*/
readonly wraparoundMode: boolean;
}


/**
* Get Emitter constructor.
*/
export const emitterCtor: EmitterCtorType;

/**
* Get DisposableStore contructor.
*/
export const disposableStoreCtor: DisposableStoreCtorType;

/**
* Turn a function into a Disposable.
*/
export const toDisposable: (fn: () => void) => IDisposable;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should be able to export class here, similar to export class Terminal. That way we don't eed to deal with this CtorType stuff

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't that pulling types from internal sources into the public API? My idea here was to decouple that with minimal stub types, so linter / type inspection doesn't rely on internal stuff.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't that pulling types from internal sources into the public API?

Everything in the API needs to be standalone, so we'd duplicate it there.

Also, we depend the other way for Terminal here to ensure our implementation matches the API:

export class Terminal extends Disposable implements ITerminalApi {

So we could do the same for DisposableStore/Emitter/etc. by depending on xterm.d.ts from public/Terminal.ts again.



export interface IEmitter<T> {
dispose(): void;
event: IEvent<T>;
fire(event: T): void;
hasListeners(): boolean;
}

interface IDisposableStore extends IDisposable {
/**
* `true` if this object has been disposed of.
*/
isDisposed: boolean;
/**
* Dispose of all registered disposables but do not mark this object
* as disposed.
*/
clear(): void;
/**
* Add a new {@link IDisposable disposable} to the collection.
*/
add<T extends IDisposable>(o: T): T;
/**
* Deletes a disposable from store and disposes of it.
* This will not throw or warn and proceed to dispose the
* disposable even when the disposable is not part in the store.
*/
delete<T extends IDisposable>(o: T): void;
/**
* Deletes the value from the store, but does not dispose it.
*/
deleteAndLeak<T extends IDisposable>(o: T): void;
}

export type EmitterCtorType = new<T>() => IEmitter<T>;
export type DisposableStoreCtorType = new() => IDisposableStore;

export class DisposableAddon implements IDisposable {
protected readonly _store: IDisposableStore;
constructor(_storeCtor: DisposableStoreCtorType);
public dispose(): void;
}
export class EmitterAddon {
protected readonly _emitterCtor: EmitterCtorType;
constructor(_emitterCtor: EmitterCtorType);
}
export class DisposableEmitterAddon implements IDisposable {
protected readonly _store: IDisposableStore;
protected readonly _emitterCtor: EmitterCtorType;
constructor(_storeCtor: DisposableStoreCtorType, _emitterCtor: EmitterCtorType);
public dispose(): void;
}
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
}
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const config = {
common: path.resolve('./out/common'),
browser: path.resolve('./out/browser'),
vs: path.resolve('./out/vs'),
shared: path.resolve('./out/shared')
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
}
},
output: {
Expand Down
Loading