import * as React from 'react'; import { injectable, inject, postConstruct } from 'inversify'; import { Widget } from '@phosphor/widgets'; import { Message } from '@phosphor/messaging'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; import 'react-tabs/style/react-tabs.css'; import { Disable } from 'react-disable'; import URI from '@theia/core/lib/common/uri'; import { Emitter } from '@theia/core/lib/common/event'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { deepClone } from '@theia/core/lib/common/objects'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { MaybePromise } from '@theia/core/lib/common/types'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { FileDialogService } from '@theia/filesystem/lib/browser/file-dialog/file-dialog-service'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { AbstractDialog, DialogProps, PreferenceService, PreferenceScope, DialogError, ReactWidget, } from '@theia/core/lib/browser'; import { Index } from '../common/types'; import { CompilerWarnings, CompilerWarningLiterals, ConfigService, FileSystemExt, Network, ProxySettings, } from '../common/protocol'; export interface Settings extends Index { editorFontSize: number; // `editor.fontSize` themeId: string; // `workbench.colorTheme` autoSave: 'on' | 'off'; // `editor.autoSave` quickSuggestions: Record<'other' | 'comments' | 'strings', boolean>; // `editor.quickSuggestions` autoScaleInterface: boolean; // `arduino.window.autoScale` interfaceScale: number; // `arduino.window.zoomLevel` https://github.com/eclipse-theia/theia/issues/8751 checkForUpdates?: boolean; // `arduino.ide.autoUpdate` verboseOnCompile: boolean; // `arduino.compile.verbose` compilerWarnings: CompilerWarnings; // `arduino.compile.warnings` verboseOnUpload: boolean; // `arduino.upload.verbose` verifyAfterUpload: boolean; // `arduino.upload.verify` enableLsLogs: boolean; // `arduino.language.log` sketchbookShowAllFiles: boolean; // `arduino.sketchbook.showAllFiles` sketchbookPath: string; // CLI additionalUrls: string[]; // CLI network: Network; // CLI } export namespace Settings { export function belongsToCli<K extends keyof Settings>(key: K): boolean { return key === 'sketchbookPath' || key === 'additionalUrls'; } } @injectable() export class SettingsService { @inject(FileService) protected readonly fileService: FileService; @inject(FileSystemExt) protected readonly fileSystemExt: FileSystemExt; @inject(ConfigService) protected readonly configService: ConfigService; @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(FrontendApplicationStateService) protected readonly appStateService: FrontendApplicationStateService; protected readonly onDidChangeEmitter = new Emitter<Readonly<Settings>>(); readonly onDidChange = this.onDidChangeEmitter.event; protected ready = new Deferred<void>(); protected _settings: Settings; @postConstruct() protected async init(): Promise<void> { await this.appStateService.reachedState('ready'); // Hack for https://github.com/eclipse-theia/theia/issues/8993 const settings = await this.loadSettings(); this._settings = deepClone(settings); this.ready.resolve(); } protected async loadSettings(): Promise<Settings> { await this.preferenceService.ready; const [ editorFontSize, themeId, autoSave, quickSuggestions, autoScaleInterface, interfaceScale, // checkForUpdates, verboseOnCompile, compilerWarnings, verboseOnUpload, verifyAfterUpload, enableLsLogs, sketchbookShowAllFiles, cliConfig, ] = await Promise.all([ this.preferenceService.get<number>('editor.fontSize', 12), this.preferenceService.get<string>( 'workbench.colorTheme', 'arduino-theme' ), this.preferenceService.get<'on' | 'off'>('editor.autoSave', 'on'), this.preferenceService.get< Record<'other' | 'comments' | 'strings', boolean> >('editor.quickSuggestions', { other: false, comments: false, strings: false, }), this.preferenceService.get<boolean>('arduino.window.autoScale', true), this.preferenceService.get<number>('arduino.window.zoomLevel', 0), // this.preferenceService.get<string>('arduino.ide.autoUpdate', true), this.preferenceService.get<boolean>('arduino.compile.verbose', true), this.preferenceService.get<any>('arduino.compile.warnings', 'None'), this.preferenceService.get<boolean>('arduino.upload.verbose', true), this.preferenceService.get<boolean>('arduino.upload.verify', true), this.preferenceService.get<boolean>('arduino.language.log', true), this.preferenceService.get<boolean>( 'arduino.sketchbook.showAllFiles', false ), this.configService.getConfiguration(), ]); const { additionalUrls, sketchDirUri, network } = cliConfig; const sketchbookPath = await this.fileService.fsPath(new URI(sketchDirUri)); return { editorFontSize, themeId, autoSave, quickSuggestions, autoScaleInterface, interfaceScale, // checkForUpdates, verboseOnCompile, compilerWarnings, verboseOnUpload, verifyAfterUpload, enableLsLogs, sketchbookShowAllFiles, additionalUrls, sketchbookPath, network, }; } async settings(): Promise<Settings> { await this.ready.promise; return this._settings; } async update(settings: Settings, fireDidChange = false): Promise<void> { await this.ready.promise; for (const key of Object.keys(settings)) { this._settings[key] = settings[key]; } if (fireDidChange) { this.onDidChangeEmitter.fire(this._settings); } } async reset(): Promise<void> { const settings = await this.loadSettings(); return this.update(settings, true); } async validate( settings: MaybePromise<Settings> = this.settings() ): Promise<string | true> { try { const { sketchbookPath, editorFontSize, themeId } = await settings; const sketchbookDir = await this.fileSystemExt.getUri(sketchbookPath); if (!(await this.fileService.exists(new URI(sketchbookDir)))) { return `Invalid sketchbook location: ${sketchbookPath}`; } if (editorFontSize <= 0) { return 'Invalid editor font size. It must be a positive integer.'; } if ( !ThemeService.get() .getThemes() .find(({ id }) => id === themeId) ) { return 'Invalid theme.'; } return true; } catch (err) { if (err instanceof Error) { return err.message; } return String(err); } } async save(): Promise<string | true> { await this.ready.promise; const { editorFontSize, themeId, autoSave, quickSuggestions, autoScaleInterface, interfaceScale, // checkForUpdates, verboseOnCompile, compilerWarnings, verboseOnUpload, verifyAfterUpload, enableLsLogs, sketchbookPath, additionalUrls, network, sketchbookShowAllFiles, } = this._settings; const [config, sketchDirUri] = await Promise.all([ this.configService.getConfiguration(), this.fileSystemExt.getUri(sketchbookPath), ]); (config as any).additionalUrls = additionalUrls; (config as any).sketchDirUri = sketchDirUri; (config as any).network = network; await Promise.all([ this.preferenceService.set( 'editor.fontSize', editorFontSize, PreferenceScope.User ), this.preferenceService.set( 'workbench.colorTheme', themeId, PreferenceScope.User ), this.preferenceService.set( 'editor.autoSave', autoSave, PreferenceScope.User ), this.preferenceService.set( 'editor.quickSuggestions', quickSuggestions, PreferenceScope.User ), this.preferenceService.set( 'arduino.window.autoScale', autoScaleInterface, PreferenceScope.User ), this.preferenceService.set( 'arduino.window.zoomLevel', interfaceScale, PreferenceScope.User ), // this.preferenceService.set('arduino.ide.autoUpdate', checkForUpdates, PreferenceScope.User), this.preferenceService.set( 'arduino.compile.verbose', verboseOnCompile, PreferenceScope.User ), this.preferenceService.set( 'arduino.compile.warnings', compilerWarnings, PreferenceScope.User ), this.preferenceService.set( 'arduino.upload.verbose', verboseOnUpload, PreferenceScope.User ), this.preferenceService.set( 'arduino.upload.verify', verifyAfterUpload, PreferenceScope.User ), this.preferenceService.set( 'arduino.language.log', enableLsLogs, PreferenceScope.User ), this.preferenceService.set( 'arduino.sketchbook.showAllFiles', sketchbookShowAllFiles, PreferenceScope.User ), this.configService.setConfiguration(config), ]); this.onDidChangeEmitter.fire(this._settings); return true; } } export class SettingsComponent extends React.Component< SettingsComponent.Props, SettingsComponent.State > { readonly toDispose = new DisposableCollection(); constructor(props: SettingsComponent.Props) { super(props); } componentDidUpdate( _: SettingsComponent.Props, prevState: SettingsComponent.State ): void { if ( this.state && prevState && JSON.stringify(this.state) !== JSON.stringify(prevState) ) { this.props.settingsService.update(this.state, true); } } componentDidMount(): void { this.props.settingsService .settings() .then((settings) => this.setState(settings)); this.toDispose.push( this.props.settingsService.onDidChange((settings) => this.setState(settings) ) ); } componentWillUnmount(): void { this.toDispose.dispose(); } render(): React.ReactNode { if (!this.state) { return <div />; } return ( <Tabs> <TabList> <Tab>Settings</Tab> <Tab>Network</Tab> </TabList> <TabPanel>{this.renderSettings()}</TabPanel> <TabPanel>{this.renderNetwork()}</TabPanel> </Tabs> ); } protected renderSettings(): React.ReactNode { return ( <div className="content noselect"> Sketchbook location: <div className="flex-line"> <input className="theia-input stretch" type="text" value={this.state.sketchbookPath} onChange={this.sketchpathDidChange} /> <button className="theia-button shrink" onClick={this.browseSketchbookDidClick} > Browse </button> </div> <label className="flex-line"> <input type="checkbox" checked={this.state.sketchbookShowAllFiles === true} onChange={this.sketchbookShowAllFilesDidChange} /> Show files inside Sketches </label> <div className="flex-line"> <div className="column"> <div className="flex-line">Editor font size:</div> <div className="flex-line">Interface scale:</div> <div className="flex-line">Theme:</div> <div className="flex-line">Show verbose output during:</div> <div className="flex-line">Compiler warnings:</div> </div> <div className="column"> <div className="flex-line"> <input className="theia-input small" type="number" step={1} pattern="[0-9]+" onKeyDown={this.numbersOnlyKeyDown} value={this.state.editorFontSize} onChange={this.editorFontSizeDidChange} /> </div> <div className="flex-line"> <label className="flex-line"> <input type="checkbox" checked={this.state.autoScaleInterface} onChange={this.autoScaleInterfaceDidChange} /> Automatic </label> <input className="theia-input small with-margin" type="number" step={20} pattern="[0-9]+" onKeyDown={this.noopKeyDown} value={100 + this.state.interfaceScale * 20} onChange={this.interfaceScaleDidChange} /> % </div> <div className="flex-line"> <select className="theia-select" value={ ThemeService.get() .getThemes() .find(({ id }) => id === this.state.themeId)?.label || 'Unknown' } onChange={this.themeDidChange} > {ThemeService.get() .getThemes() .map(({ id, label }) => ( <option key={id} value={label}> {label} </option> ))} </select> </div> <div className="flex-line"> <label className="flex-line"> <input type="checkbox" checked={this.state.verboseOnCompile} onChange={this.verboseOnCompileDidChange} /> compile </label> <label className="flex-line"> <input type="checkbox" checked={this.state.verboseOnUpload} onChange={this.verboseOnUploadDidChange} /> upload </label> </div> <div className="flex-line"> <select className="theia-select" value={this.state.compilerWarnings} onChange={this.compilerWarningsDidChange} > {CompilerWarningLiterals.map((value) => ( <option key={value} value={value}> {value} </option> ))} </select> </div> </div> </div> <label className="flex-line"> <input type="checkbox" checked={this.state.verifyAfterUpload} onChange={this.verifyAfterUploadDidChange} /> Verify code after upload </label> <label className="flex-line"> <input type="checkbox" checked={this.state.checkForUpdates} onChange={this.checkForUpdatesDidChange} disabled={true} /> Check for updates on startup </label> <label className="flex-line"> <input type="checkbox" checked={this.state.autoSave === 'on'} onChange={this.autoSaveDidChange} /> Auto save </label> <label className="flex-line"> <input type="checkbox" checked={this.state.quickSuggestions.other === true} onChange={this.quickSuggestionsOtherDidChange} /> Editor Quick Suggestions </label> <label className="flex-line"> <input type="checkbox" checked={this.state.enableLsLogs} onChange={this.enableLsLogsDidChange} /> Enable language server logging </label> <div className="flex-line"> Additional boards manager URLs: <input className="theia-input stretch with-margin" type="text" value={this.state.additionalUrls.join(',')} onChange={this.additionalUrlsDidChange} /> <i className="fa fa-window-restore theia-button shrink" onClick={this.editAdditionalUrlDidClick} /> </div> </div> ); } protected renderNetwork(): React.ReactNode { return ( <div className="content noselect"> <form> <label className="flex-line"> <input type="radio" checked={this.state.network === 'none'} onChange={this.noProxyDidChange} /> No proxy </label> <label className="flex-line"> <input type="radio" checked={this.state.network !== 'none'} onChange={this.manualProxyDidChange} /> Manual proxy configuration </label> </form> {this.renderProxySettings()} </div> ); } protected renderProxySettings(): React.ReactNode { const disabled = this.state.network === 'none'; return ( <Disable disabled={disabled}> <div className="proxy-settings" aria-disabled={disabled}> <form className="flex-line"> <input type="radio" checked={ this.state.network === 'none' ? true : this.state.network.protocol === 'http' } onChange={this.httpProtocolDidChange} /> HTTP <label className="flex-line"> <input type="radio" checked={ this.state.network === 'none' ? false : this.state.network.protocol !== 'http' } onChange={this.socksProtocolDidChange} /> SOCKS </label> </form> <div className="flex-line proxy-settings"> <div className="column"> <div className="flex-line">Host name:</div> <div className="flex-line">Port number:</div> <div className="flex-line">Username:</div> <div className="flex-line">Password:</div> </div> <div className="column stretch"> <div className="flex-line"> <input className="theia-input stretch with-margin" type="text" value={ this.state.network === 'none' ? '' : this.state.network.hostname } onChange={this.hostnameDidChange} /> </div> <div className="flex-line"> <input className="theia-input small with-margin" type="number" pattern="[0-9]" value={ this.state.network === 'none' ? '' : this.state.network.port } onKeyDown={this.numbersOnlyKeyDown} onChange={this.portDidChange} /> </div> <div className="flex-line"> <input className="theia-input stretch with-margin" type="text" value={ this.state.network === 'none' ? '' : this.state.network.username } onChange={this.usernameDidChange} /> </div> <div className="flex-line"> <input className="theia-input stretch with-margin" type="password" value={ this.state.network === 'none' ? '' : this.state.network.password } onChange={this.passwordDidChange} /> </div> </div> </div> </div> </Disable> ); } private isControlKey(event: React.KeyboardEvent<HTMLInputElement>): boolean { return ( !!event.key && ['tab', 'delete', 'backspace', 'arrowleft', 'arrowright'].some( (key) => event.key.toLocaleLowerCase() === key ) ); } protected noopKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { if (this.isControlKey(event)) { return; } event.nativeEvent.preventDefault(); event.nativeEvent.returnValue = false; }; protected numbersOnlyKeyDown = ( event: React.KeyboardEvent<HTMLInputElement> ) => { if (this.isControlKey(event)) { return; } const key = Number(event.key); if (isNaN(key) || event.key === null || event.key === ' ') { event.nativeEvent.preventDefault(); event.nativeEvent.returnValue = false; return; } }; protected browseSketchbookDidClick = async () => { const uri = await this.props.fileDialogService.showOpenDialog({ title: 'Select new sketchbook location', openLabel: 'Choose', canSelectFiles: false, canSelectMany: false, canSelectFolders: true, }); if (uri) { const sketchbookPath = await this.props.fileService.fsPath(uri); this.setState({ sketchbookPath }); } }; protected editAdditionalUrlDidClick = async () => { const additionalUrls = await new AdditionalUrlsDialog( this.state.additionalUrls, this.props.windowService ).open(); if (additionalUrls) { this.setState({ additionalUrls }); } }; protected editorFontSizeDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { const { value } = event.target; if (value) { this.setState({ editorFontSize: parseInt(value, 10) }); } }; protected additionalUrlsDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ additionalUrls: event.target.value.split(',').map((url) => url.trim()), }); }; protected autoScaleInterfaceDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ autoScaleInterface: event.target.checked }); }; protected enableLsLogsDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ enableLsLogs: event.target.checked }); }; protected interfaceScaleDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { const { value } = event.target; const percentage = parseInt(value, 10); if (isNaN(percentage)) { return; } const interfaceScale = (percentage - 100) / 20; if (!isNaN(interfaceScale)) { this.setState({ interfaceScale }); } }; protected verifyAfterUploadDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ verifyAfterUpload: event.target.checked }); }; protected checkForUpdatesDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ checkForUpdates: event.target.checked }); }; protected sketchbookShowAllFilesDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ sketchbookShowAllFiles: event.target.checked }); }; protected autoSaveDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ autoSave: event.target.checked ? 'on' : 'off' }); }; protected quickSuggestionsOtherDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { // need to persist react events through lifecycle https://reactjs.org/docs/events.html#event-pooling const newVal = event.target.checked ? true : false; this.setState((prevState) => { return { quickSuggestions: { ...prevState.quickSuggestions, other: newVal, }, }; }); }; protected themeDidChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const { selectedIndex } = event.target.options; const theme = ThemeService.get().getThemes()[selectedIndex]; if (theme) { this.setState({ themeId: theme.id }); } }; protected compilerWarningsDidChange = ( event: React.ChangeEvent<HTMLSelectElement> ) => { const { selectedIndex } = event.target.options; const compilerWarnings = CompilerWarningLiterals[selectedIndex]; if (compilerWarnings) { this.setState({ compilerWarnings }); } }; protected verboseOnCompileDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ verboseOnCompile: event.target.checked }); }; protected verboseOnUploadDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { this.setState({ verboseOnUpload: event.target.checked }); }; protected sketchpathDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { const sketchbookPath = event.target.value; if (sketchbookPath) { this.setState({ sketchbookPath }); } }; protected noProxyDidChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (event.target.checked) { this.setState({ network: 'none' }); } else { this.setState({ network: Network.Default() }); } }; protected manualProxyDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { if (event.target.checked) { this.setState({ network: Network.Default() }); } else { this.setState({ network: 'none' }); } }; protected httpProtocolDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { if (this.state.network !== 'none') { const network = this.cloneProxySettings; network.protocol = event.target.checked ? 'http' : 'socks'; this.setState({ network }); } }; protected socksProtocolDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { if (this.state.network !== 'none') { const network = this.cloneProxySettings; network.protocol = event.target.checked ? 'socks' : 'http'; this.setState({ network }); } }; protected hostnameDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { if (this.state.network !== 'none') { const network = this.cloneProxySettings; network.hostname = event.target.value; this.setState({ network }); } }; protected portDidChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (this.state.network !== 'none') { const network = this.cloneProxySettings; network.port = event.target.value; this.setState({ network }); } }; protected usernameDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { if (this.state.network !== 'none') { const network = this.cloneProxySettings; network.username = event.target.value; this.setState({ network }); } }; protected passwordDidChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { if (this.state.network !== 'none') { const network = this.cloneProxySettings; network.password = event.target.value; this.setState({ network }); } }; private get cloneProxySettings(): ProxySettings { const { network } = this.state; if (network === 'none') { throw new Error('Must be called when proxy is enabled.'); } const copyNetwork = deepClone(network); return copyNetwork; } } export namespace SettingsComponent { export interface Props { readonly settingsService: SettingsService; readonly fileService: FileService; readonly fileDialogService: FileDialogService; readonly windowService: WindowService; } export type State = Settings; } @injectable() export class SettingsWidget extends ReactWidget { @inject(SettingsService) protected readonly settingsService: SettingsService; @inject(FileService) protected readonly fileService: FileService; @inject(FileDialogService) protected readonly fileDialogService: FileDialogService; @inject(WindowService) protected readonly windowService: WindowService; protected render(): React.ReactNode { return ( <SettingsComponent settingsService={this.settingsService} fileService={this.fileService} fileDialogService={this.fileDialogService} windowService={this.windowService} /> ); } } @injectable() export class SettingsDialogProps extends DialogProps {} @injectable() export class SettingsDialog extends AbstractDialog<Promise<Settings>> { @inject(SettingsService) protected readonly settingsService: SettingsService; @inject(SettingsWidget) protected readonly widget: SettingsWidget; constructor( @inject(SettingsDialogProps) protected readonly props: SettingsDialogProps ) { super(props); this.contentNode.classList.add('arduino-settings-dialog'); this.appendCloseButton('CANCEL'); this.appendAcceptButton('OK'); } @postConstruct() protected init(): void { this.toDispose.push( this.settingsService.onDidChange(this.validate.bind(this)) ); } protected async isValid(settings: Promise<Settings>): Promise<DialogError> { const result = await this.settingsService.validate(settings); if (typeof result === 'string') { return result; } return ''; } get value(): Promise<Settings> { return this.settingsService.settings(); } protected onAfterAttach(msg: Message): void { if (this.widget.isAttached) { Widget.detach(this.widget); } Widget.attach(this.widget, this.contentNode); this.toDisposeOnDetach.push( this.settingsService.onDidChange(() => this.update()) ); super.onAfterAttach(msg); this.update(); } protected onUpdateRequest(msg: Message) { super.onUpdateRequest(msg); this.widget.update(); } protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); // calling settingsService.reset() in order to reload the settings from the preferenceService // and update the UI including changes triggerd from the command palette this.settingsService.reset(); this.widget.activate(); } } export class AdditionalUrlsDialog extends AbstractDialog<string[]> { protected readonly textArea: HTMLTextAreaElement; constructor(urls: string[], windowService: WindowService) { super({ title: 'Additional Boards Manager URLs' }); this.contentNode.classList.add('additional-urls-dialog'); const description = document.createElement('div'); description.textContent = 'Enter additional URLs, one for each row'; description.style.marginBottom = '5px'; this.contentNode.appendChild(description); this.textArea = document.createElement('textarea'); this.textArea.className = 'theia-input'; this.textArea.setAttribute('style', 'flex: 0;'); this.textArea.value = urls .filter((url) => url.trim()) .filter((url) => !!url) .join('\n'); this.textArea.wrap = 'soft'; this.textArea.cols = 90; this.textArea.rows = 5; this.contentNode.appendChild(this.textArea); const anchor = document.createElement('div'); anchor.classList.add('link'); anchor.textContent = 'Click for a list of unofficial board support URLs'; anchor.style.marginTop = '5px'; anchor.style.cursor = 'pointer'; this.addEventListener(anchor, 'click', () => windowService.openNewWindow( 'https://github.com/arduino/Arduino/wiki/Unofficial-list-of-3rd-party-boards-support-urls', { external: true } ) ); this.contentNode.appendChild(anchor); this.appendAcceptButton('OK'); this.appendCloseButton('Cancel'); } get value(): string[] { return this.textArea.value .split('\n') .map((url) => url.trim()) .filter((url) => !!url); } protected onAfterAttach(message: Message): void { super.onAfterAttach(message); this.addUpdateListener(this.textArea, 'input'); } protected onActivateRequest(message: Message): void { super.onActivateRequest(message); this.textArea.focus(); } protected handleEnter(event: KeyboardEvent): boolean | void { if (event.target instanceof HTMLInputElement) { return super.handleEnter(event); } return false; } }