From 732e1ad65d2edb12f61972d87696c59928e1500c Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Fri, 25 Nov 2022 18:48:19 +0100 Subject: [PATCH] feat: use a monaco editor for the monitor output Closes #105 Signed-off-by: Akos Kitta --- .../browser/arduino-ide-frontend-module.ts | 36 +- .../monitor/monitor-context-menu-service.ts | 16 + .../serial/monitor/monitor-editor-factory.ts | 46 ++ .../monitor/monitor-editor-model-factory.ts | 8 + .../monitor/monitor-resource-provider.ts | 32 ++ .../serial/monitor/monitor-resource.ts | 15 + .../src/browser/serial/monitor/monitor-uri.ts | 3 + .../monitor/monitor-view-contribution.tsx | 66 ++- .../browser/serial/monitor/monitor-widget.tsx | 432 ++++++++++++------ .../src/browser/style/monitor.css | 11 + .../theia/monaco/monaco-text-model-service.ts | 47 +- 11 files changed, 554 insertions(+), 158 deletions(-) create mode 100644 arduino-ide-extension/src/browser/serial/monitor/monitor-context-menu-service.ts create mode 100644 arduino-ide-extension/src/browser/serial/monitor/monitor-editor-factory.ts create mode 100644 arduino-ide-extension/src/browser/serial/monitor/monitor-editor-model-factory.ts create mode 100644 arduino-ide-extension/src/browser/serial/monitor/monitor-resource-provider.ts create mode 100644 arduino-ide-extension/src/browser/serial/monitor/monitor-resource.ts create mode 100644 arduino-ide-extension/src/browser/serial/monitor/monitor-uri.ts diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index ba13b1b11..71cbb1435 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -154,7 +154,10 @@ import { MonitorManagerProxyFactory, MonitorManagerProxyPath, } from '../common/protocol'; -import { MonacoTextModelService as TheiaMonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; +import { + MonacoEditorModelFactory, + MonacoTextModelService as TheiaMonacoTextModelService, +} from '@theia/monaco/lib/browser/monaco-text-model-service'; import { MonacoTextModelService } from './theia/monaco/monaco-text-model-service'; import { ResponseServiceImpl } from './response-service-impl'; import { @@ -249,7 +252,7 @@ import { UserFieldsDialog, UserFieldsDialogProps, } from './dialogs/user-fields/user-fields-dialog'; -import { nls } from '@theia/core/lib/common'; +import { nls, ResourceResolver } from '@theia/core/lib/common'; import { IDEUpdaterCommands } from './ide-updater/ide-updater-commands'; import { IDEUpdater, @@ -313,6 +316,7 @@ import { } from './widgets/component-list/filter-renderer'; import { CheckForUpdates } from './contributions/check-for-updates'; import { OutputEditorFactory } from './theia/output/output-editor-factory'; +import { OutputEditorFactory as TheiaOutputEditorFactory } from '@theia/output/lib/browser/output-editor-factory'; import { StartupTaskProvider } from '../electron-common/startup-task'; import { DeleteSketch } from './contributions/delete-sketch'; import { UserFields } from './contributions/user-fields'; @@ -331,6 +335,10 @@ import { TypeHierarchyServiceProvider } from './theia/typehierarchy/type-hierarc import { TypeHierarchyServiceProvider as TheiaTypeHierarchyServiceProvider } from '@theia/typehierarchy/lib/browser/typehierarchy-service'; import { TypeHierarchyContribution } from './theia/typehierarchy/type-hierarchy-contribution'; import { TypeHierarchyContribution as TheiaTypeHierarchyContribution } from '@theia/typehierarchy/lib/browser/typehierarchy-contribution'; +import { MonitorResourceProvider } from './serial/monitor/monitor-resource-provider'; +import { MonitorEditorFactory } from './serial/monitor/monitor-editor-factory'; +import { MonitorEditorModelFactory } from './serial/monitor/monitor-editor-model-factory'; +import { MonitorContextMenuService } from './serial/monitor/monitor-context-menu-service'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Commands and toolbar items @@ -465,17 +473,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MonitorModel).toSelf().inSingletonScope(); bindViewContribution(bind, MonitorViewContribution); bind(TabBarToolbarContribution).toService(MonitorViewContribution); - bind(WidgetFactory).toDynamicValue((context) => ({ + bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: MonitorWidget.ID, - createWidget: () => { - return new MonitorWidget( - context.container.get(MonitorModel), - context.container.get( - MonitorManagerProxyClient - ), - context.container.get(BoardsServiceProvider) - ); - }, + createWidget: () => container.get(MonitorWidget), })); bind(MonitorManagerProxyFactory).toFactory( @@ -499,6 +499,15 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .to(MonitorManagerProxyClientImpl) .inSingletonScope(); + bind(MonitorResourceProvider).toSelf().inSingletonScope(); + bind(ResourceResolver).toService(MonitorResourceProvider); + bind(MonitorEditorFactory).toSelf().inSingletonScope(); + bind(MonacoEditorFactory).toService(MonitorEditorFactory); + bind(MonacoEditorModelFactory) + .to(MonitorEditorModelFactory) + .inSingletonScope(); + bind(MonitorContextMenuService).toSelf().inSingletonScope(); + bind(WorkspaceService).toSelf().inSingletonScope(); rebind(TheiaWorkspaceService).toService(WorkspaceService); bind(WorkspaceVariableContribution).toSelf().inSingletonScope(); @@ -611,8 +620,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // To disable the highlighting of non-unicode characters in the _Output_ view bind(OutputEditorFactory).toSelf().inSingletonScope(); - // Rebind to `TheiaOutputEditorFactory` when https://github.com/eclipse-theia/theia/pull/11615 is available. - rebind(MonacoEditorFactory).toService(OutputEditorFactory); + rebind(TheiaOutputEditorFactory).toService(OutputEditorFactory); bind(ArduinoDaemon) .toDynamicValue((context) => diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-context-menu-service.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-context-menu-service.ts new file mode 100644 index 000000000..387c12fb0 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-context-menu-service.ts @@ -0,0 +1,16 @@ +import type { MenuPath } from '@theia/core/lib/common/menu'; +import { injectable } from '@theia/core/shared/inversify'; +import { MonacoContextMenuService } from '@theia/monaco/lib/browser/monaco-context-menu'; + +export namespace MonitorContextMenu { + export const MENU_PATH: MenuPath = ['monitor_context_menu']; + export const TEXT_EDIT_GROUP = [...MENU_PATH, '0_text_edit_group']; + export const WIDGET_GROUP = [...MENU_PATH, '1_widget_group']; +} + +@injectable() +export class MonitorContextMenuService extends MonacoContextMenuService { + protected override menuPath(): MenuPath { + return MonitorContextMenu.MENU_PATH; + } +} diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-editor-factory.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-editor-factory.ts new file mode 100644 index 000000000..ef055bc00 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-editor-factory.ts @@ -0,0 +1,46 @@ +import { inject, injectable } from '@theia/core/shared/inversify'; +import { IContextMenuService } from '@theia/monaco-editor-core/esm/vs/platform/contextview/browser/contextView'; +import { MonacoContextMenuService } from '@theia/monaco/lib/browser/monaco-context-menu'; +import { + EditorServiceOverrides, + MonacoEditor, +} from '@theia/monaco/lib/browser/monaco-editor'; +import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; +import { OutputEditorFactory } from '../../theia/output/output-editor-factory'; +import { MonitorContextMenuService } from './monitor-context-menu-service'; +import { MonitorUri } from './monitor-uri'; + +@injectable() +export class MonitorEditorFactory extends OutputEditorFactory { + @inject(MonitorContextMenuService) + private readonly monitorContextMenuService: MonacoContextMenuService; + + override readonly scheme: string = MonitorUri.scheme; + + protected override createOptions( + model: MonacoEditorModel, + defaultOptions: MonacoEditor.IOptions + ): MonacoEditor.IOptions { + return { + ...super.createOptions(model, defaultOptions), + // To hide the margin in the editor https://github.com/microsoft/monaco-editor/issues/1960 + lineNumbers: 'off', + glyphMargin: false, + folding: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + }; + } + + protected override *createOverrides( + model: MonacoEditorModel, + defaultOverrides: EditorServiceOverrides + ): EditorServiceOverrides { + yield [IContextMenuService, this.monitorContextMenuService]; + for (const [identifier, provider] of defaultOverrides) { + if (identifier !== IContextMenuService) { + yield [identifier, provider]; + } + } + } +} diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-editor-model-factory.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-editor-model-factory.ts new file mode 100644 index 000000000..0f6ef2ab4 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-editor-model-factory.ts @@ -0,0 +1,8 @@ +import { injectable } from '@theia/core/shared/inversify'; +import { OutputEditorModelFactory } from '@theia/output/lib/browser/output-editor-model-factory'; +import { MonitorUri } from './monitor-uri'; + +@injectable() +export class MonitorEditorModelFactory extends OutputEditorModelFactory { + override readonly scheme: string = MonitorUri.scheme; +} diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-resource-provider.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-resource-provider.ts new file mode 100644 index 000000000..93e856c19 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-resource-provider.ts @@ -0,0 +1,32 @@ +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { Resource, ResourceResolver } from '@theia/core/lib/common/resource'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { IReference } from '@theia/monaco-editor-core/esm/vs/base/common/lifecycle'; +import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; +import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; +import { MonitorResource } from './monitor-resource'; +import { MonitorUri } from './monitor-uri'; + +@injectable() +export class MonitorResourceProvider implements ResourceResolver { + readonly resource: MonitorResource; + + constructor( + @inject(MonacoTextModelService) textModelService: MonacoTextModelService + ) { + const editorModelRef = new Deferred>(); + this.resource = new MonitorResource(MonitorUri, editorModelRef); + textModelService + .createModelReference(MonitorUri) + .then((ref) => editorModelRef.resolve(ref)); + } + + async resolve(uri: URI): Promise { + if (this.resource.uri.toString() === uri.toString()) { + return this.resource; + } + // Note: this is totally normal. This is the way Theia loads a resource. + throw new Error(`Cannot handle URI: ${uri.toString()}`); + } +} diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-resource.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-resource.ts new file mode 100644 index 000000000..c0afb7b99 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-resource.ts @@ -0,0 +1,15 @@ +import type { ResourceReadOptions } from '@theia/core/lib/common/resource'; +import { OutputResource } from '@theia/output/lib/browser/output-resource'; + +export class MonitorResource extends OutputResource { + override async readContents(options?: ResourceReadOptions): Promise { + if (!this._textModel) { + return ''; + } + return super.readContents(options); + } + + async reset(): Promise { + this.textModel?.setValue(''); + } +} diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-uri.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-uri.ts new file mode 100644 index 000000000..83bed2374 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-uri.ts @@ -0,0 +1,3 @@ +import URI from '@theia/core/lib/common/uri'; + +export const MonitorUri = new URI('monitor:/arduino'); diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx index 36f13c3b2..fb3ec9d26 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx @@ -1,6 +1,11 @@ import * as React from '@theia/core/shared/react'; import { injectable, inject } from '@theia/core/shared/inversify'; -import { AbstractViewContribution, codicon } from '@theia/core/lib/browser'; +import { + AbstractViewContribution, + codicon, + CommonCommands, + Widget, +} from '@theia/core/lib/browser'; import { MonitorWidget } from './monitor-widget'; import { MenuModelRegistry, Command, CommandRegistry } from '@theia/core'; import { @@ -12,6 +17,8 @@ import { ArduinoMenus } from '../../menu/arduino-menus'; import { nls } from '@theia/core/lib/common'; import { MonitorModel } from '../../monitor-model'; import { MonitorManagerProxyClient } from '../../../common/protocol'; +import { MonitorContextMenu } from './monitor-context-menu-service'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; export namespace SerialMonitor { export namespace Commands { @@ -36,7 +43,10 @@ export namespace SerialMonitor { iconClass: codicon('clear-all'), }, 'vscode/output.contribution/clearOutput.label' - ); + ) as Command & { label: string }; + export const COPY_ALL: Command = { + id: 'serial-monitor-copy-all', + }; } } @@ -50,6 +60,9 @@ export class MonitorViewContribution MonitorWidget.ID + ':toggle-toolbar'; static readonly RESET_SERIAL_MONITOR = MonitorWidget.ID + ':reset'; + @inject(ClipboardService) + private readonly clipboardService: ClipboardService; + constructor( @inject(MonitorModel) protected readonly model: MonitorModel, @@ -77,6 +90,17 @@ export class MonitorViewContribution order: '5', }); } + menus.registerMenuAction(MonitorContextMenu.TEXT_EDIT_GROUP, { + commandId: CommonCommands.COPY.id, + }); + menus.registerMenuAction(MonitorContextMenu.TEXT_EDIT_GROUP, { + commandId: SerialMonitor.Commands.COPY_ALL.id, + label: nls.localizeByDefault('Copy All'), + }); + menus.registerMenuAction(MonitorContextMenu.WIDGET_GROUP, { + commandId: SerialMonitor.Commands.CLEAR_OUTPUT.id, + label: nls.localizeByDefault('Clear Output'), + }); } registerToolbarItems(registry: TabBarToolbarRegistry): void { @@ -104,12 +128,25 @@ export class MonitorViewContribution override registerCommands(commands: CommandRegistry): void { commands.registerCommand(SerialMonitor.Commands.CLEAR_OUTPUT, { - isEnabled: (widget) => widget instanceof MonitorWidget, - isVisible: (widget) => widget instanceof MonitorWidget, - execute: (widget) => { - if (widget instanceof MonitorWidget) { - widget.clearConsole(); + isEnabled: (arg) => { + if (arg instanceof Widget) { + return arg instanceof MonitorWidget; } + return this.shell.currentWidget instanceof MonitorWidget; + }, + isVisible: (arg) => { + if (arg instanceof Widget) { + return arg instanceof MonitorWidget; + } + return this.shell.currentWidget instanceof MonitorWidget; + }, + execute: () => { + this.widget.then((widget) => { + this.withWidget(widget, (output) => { + output.clearConsole(); + return true; + }); + }); }, }); if (this.toggleCommand) { @@ -129,6 +166,14 @@ export class MonitorViewContribution { id: MonitorViewContribution.RESET_SERIAL_MONITOR }, { execute: () => this.reset() } ); + commands.registerCommand(SerialMonitor.Commands.COPY_ALL, { + execute: () => { + const text = this.tryGetWidget()?.text; + if (text) { + this.clipboardService.writeText(text); + } + }, + }); } protected async toggle(): Promise { @@ -191,4 +236,11 @@ export class MonitorViewContribution protected async doToggleTimestamp(): Promise { this.model.toggleTimestamp(); } + + private withWidget( + widget: Widget | undefined = this.tryGetWidget(), + predicate: (monitorWidget: MonitorWidget) => boolean = () => true + ): boolean | false { + return widget instanceof MonitorWidget ? predicate(widget) : false; + } } diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx index a5a25230c..d003497f9 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx @@ -1,182 +1,219 @@ -import * as React from '@theia/core/shared/react'; -import { injectable, inject } from '@theia/core/shared/inversify'; -import { Emitter } from '@theia/core/lib/common/event'; +import { BaseWidget, Widget } from '@theia/core/lib/browser/widgets/widget'; import { Disposable } from '@theia/core/lib/common/disposable'; +import { nls } from '@theia/core/lib/common/nls'; +import { SelectionService } from '@theia/core/lib/common/selection-service'; +import { toArray } from '@theia/core/shared/@phosphor/algorithm'; +import { Message, MessageLoop } from '@theia/core/shared/@phosphor/messaging'; +import { DockPanel } from '@theia/core/shared/@phosphor/widgets'; import { - ReactWidget, - Message, - Widget, - MessageLoop, -} from '@theia/core/lib/browser/widgets'; + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import * as ReactDOM from '@theia/core/shared/react-dom'; +import { EditorWidget } from '@theia/editor/lib/browser'; +import * as monaco from '@theia/monaco-editor-core'; +import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; +import * as PQueue from 'p-queue'; +import { MonitorManagerProxyClient } from '../../../common/protocol'; +import type { MonitorSettings } from '../../../node/monitor-settings/monitor-settings-provider'; +import { BoardsServiceProvider } from '../../boards/boards-service-provider'; +import { MonitorModel } from '../../monitor-model'; import { ArduinoSelect } from '../../widgets/arduino-select'; +import { MonitorResourceProvider } from './monitor-resource-provider'; import { SerialMonitorSendInput } from './serial-monitor-send-input'; import { SerialMonitorOutput } from './serial-monitor-send-output'; -import { BoardsServiceProvider } from '../../boards/boards-service-provider'; -import { nls } from '@theia/core/lib/common'; -import { MonitorManagerProxyClient } from '../../../common/protocol'; -import { MonitorModel } from '../../monitor-model'; -import { MonitorSettings } from '../../../node/monitor-settings/monitor-settings-provider'; @injectable() -export class MonitorWidget extends ReactWidget { +export class MonitorWidget extends BaseWidget { + static readonly ID = 'serial-monitor'; static readonly LABEL = nls.localize( 'arduino/common/serialMonitor', 'Serial Monitor' ); - static readonly ID = 'serial-monitor'; - - protected settings: MonitorSettings = {}; - - protected widgetHeight: number; + private settings: MonitorSettings = {}; + private widgetHeight: number; /** * Do not touch or use it. It is for setting the focus on the `input` after the widget activation. */ - protected focusNode: HTMLElement | undefined; + private focusNode: HTMLElement | undefined; /** * Guard against re-rendering the view after the close was requested. * See: https://github.com/eclipse-theia/theia/issues/6704 */ - protected closing = false; - protected readonly clearOutputEmitter = new Emitter(); - - constructor( - @inject(MonitorModel) - protected readonly monitorModel: MonitorModel, + private readonly contentNode: HTMLDivElement; + private readonly headerNode: HTMLDivElement; + private readonly editorContainer: DockPanel; + private closing = false; + protected readonly appendContentQueue = new PQueue({ + autoStart: true, + concurrency: 1, + }); - @inject(MonitorManagerProxyClient) - protected readonly monitorManagerProxy: MonitorManagerProxyClient, + @inject(MonitorModel) + private readonly monitorModel: MonitorModel; + @inject(MonitorManagerProxyClient) + private readonly monitorManagerProxy: MonitorManagerProxyClient; + @inject(BoardsServiceProvider) + private readonly boardsServiceProvider: BoardsServiceProvider; + @inject(SelectionService) + private readonly selectionService: SelectionService; + @inject(MonacoEditorProvider) + private readonly editorProvider: MonacoEditorProvider; + @inject(MonitorResourceProvider) + private readonly resourceProvider: MonitorResourceProvider; - @inject(BoardsServiceProvider) - protected readonly boardsServiceProvider: BoardsServiceProvider - ) { + constructor() { super(); this.id = MonitorWidget.ID; this.title.label = MonitorWidget.LABEL; this.title.iconClass = 'monitor-tab-icon'; this.title.closable = true; this.scrollOptions = undefined; - this.toDispose.push(this.clearOutputEmitter); this.toDispose.push( Disposable.create(() => this.monitorManagerProxy.disconnect()) ); - } - protected override onBeforeAttach(msg: Message): void { - this.update(); - this.toDispose.push(this.monitorModel.onChange(() => this.update())); - this.getCurrentSettings().then(this.onMonitorSettingsDidChange.bind(this)); - this.monitorManagerProxy.onMonitorSettingsDidChange( - this.onMonitorSettingsDidChange.bind(this) - ); + this.contentNode = document.createElement('div'); + this.contentNode.classList.add('content'); + this.headerNode = document.createElement('div'); + this.headerNode.classList.add('header'); + this.contentNode.appendChild(this.headerNode); + this.node.appendChild(this.contentNode); - this.monitorManagerProxy.startMonitor(); + this.editorContainer = new NoopDragOverDockPanel({ + spacing: 0, + mode: 'single-document', + }); + this.editorContainer.addClass('editor-container'); + this.editorContainer.node.tabIndex = -1; + + this.toDispose.pushAll([ + Disposable.create(() => this.monitorManagerProxy.disconnect()), + Disposable.create(() => { + this.appendContentQueue.pause(); + this.appendContentQueue.clear(); + }), + ]); } - onMonitorSettingsDidChange(settings: MonitorSettings): void { - this.settings = { - ...this.settings, - pluggableMonitorSettings: { - ...this.settings.pluggableMonitorSettings, - ...settings.pluggableMonitorSettings, - }, - }; - this.update(); + @postConstruct() + protected init(): void { + this.toDispose.pushAll([ + this.monitorModel.onChange(async ({ property }) => { + if (property === 'connected') { + const { connected } = this.monitorModel; + if (!connected) { + await this.clearConsole(); + } + } + this.update(); + }), + this.monitorManagerProxy.onMonitorSettingsDidChange((settings) => + this.updateSettings(settings) + ), + this.monitorManagerProxy.onMessagesReceived(({ messages }) => { + messages.forEach((message) => this.appendContent({ message })); + }), + ]); + this.getCurrentSettings().then((settings) => this.updateSettings(settings)); + this.monitorManagerProxy.startMonitor(); } - clearConsole(): void { - this.clearOutputEmitter.fire(undefined); - this.update(); + async clearConsole(): Promise { + return this.resourceProvider.resource.reset(); } - override dispose(): void { - super.dispose(); + get text(): string | undefined { + return this.editor?.getControl().getModel()?.getValue(); } - protected override onCloseRequest(msg: Message): void { - this.closing = true; - super.onCloseRequest(msg); + protected override onAfterAttach(message: Message): void { + super.onAfterAttach(message); + ReactDOM.render( + {this.renderHeader()}, + this.headerNode + ); + Widget.attach(this.editorContainer, this.contentNode); + this.toDisposeOnDetach.push( + Disposable.create(() => Widget.detach(this.editorContainer)) + ); } - protected override onUpdateRequest(msg: Message): void { + protected override onUpdateRequest(message: Message): void { // TODO: `this.isAttached` // See: https://github.com/eclipse-theia/theia/issues/6704#issuecomment-562574713 if (!this.closing && this.isAttached) { - super.onUpdateRequest(msg); + super.onUpdateRequest(message); } } - protected override onResize(msg: Widget.ResizeMessage): void { - super.onResize(msg); - this.widgetHeight = msg.height; - this.update(); + protected override onActivateRequest(message: Message): void { + super.onActivateRequest(message); + (this.focusNode || this.node).focus(); } - protected override onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - (this.focusNode || this.node).focus(); + protected override onCloseRequest(message: Message): void { + this.closing = true; + super.onCloseRequest(message); } - protected onFocusResolved = (element: HTMLElement | undefined) => { - if (this.closing || !this.isAttached) { - return; - } - this.focusNode = element; - requestAnimationFrame(() => - MessageLoop.sendMessage(this, Widget.Msg.ActivateRequest) + protected override onResize(message: Widget.ResizeMessage): void { + super.onResize(message); + MessageLoop.sendMessage( + this.editorContainer, + Widget.ResizeMessage.UnknownSize ); - }; + for (const widget of toArray(this.editorContainer.widgets())) { + MessageLoop.sendMessage(widget, Widget.ResizeMessage.UnknownSize); + } + this.widgetHeight = message.height; + this.update(); + this.refreshEditorWidget(); + } - protected get lineEndings(): SerialMonitorOutput.SelectOption[] { - return [ - { - label: nls.localize('arduino/serial/noLineEndings', 'No Line Ending'), - value: '', - }, - { - label: nls.localize('arduino/serial/newLine', 'New Line'), - value: '\n', - }, - { - label: nls.localize('arduino/serial/carriageReturn', 'Carriage Return'), - value: '\r', - }, - { - label: nls.localize( - 'arduino/serial/newLineCarriageReturn', - 'Both NL & CR' - ), - value: '\r\n', + protected override onAfterShow(message: Message): void { + super.onAfterShow(message); + this.onResize(Widget.ResizeMessage.UnknownSize); + } + + private updateSettings(settings: MonitorSettings): void { + this.settings = { + ...this.settings, + pluggableMonitorSettings: { + ...this.settings.pluggableMonitorSettings, + ...settings.pluggableMonitorSettings, }, - ]; + }; + this.update(); } - private getCurrentSettings(): Promise { + private async getCurrentSettings(): Promise { const board = this.boardsServiceProvider.boardsConfig.selectedBoard; const port = this.boardsServiceProvider.boardsConfig.selectedPort; if (!board || !port) { - return Promise.resolve(this.settings || {}); + return this.settings ?? {}; } return this.monitorManagerProxy.getCurrentSettings(board, port); } - protected render(): React.ReactNode { + private renderHeader(): React.ReactNode { const baudrate = this.settings?.pluggableMonitorSettings ? this.settings.pluggableMonitorSettings.baudrate : undefined; - - const baudrateOptions = baudrate?.values.map((b) => ({ - label: b + ' baud', - value: b, + const baudrateOptions = baudrate?.values.map((value) => ({ + label: `${value} baud`, + value, })); - const baudrateSelectedOption = baudrateOptions?.find( - (b) => b.value === baudrate?.selectedValue + const selectedBaudrateOption = baudrateOptions?.find( + (baud) => baud.value === baudrate?.selectedValue ); - const lineEnding = - this.lineEndings.find( - (item) => item.value === this.monitorModel.lineEnding - ) || this.lineEndings[1]; // Defaults to `\n`. + lineEndings.find((item) => item.value === this.monitorModel.lineEnding) ?? + defaultLineEnding; return (
@@ -193,58 +230,193 @@ export class MonitorWidget extends ReactWidget {
- {baudrateOptions && baudrateSelectedOption && ( + {baudrateOptions && selectedBaudrateOption && (
)}
-
- -
); } - protected readonly onSend = (value: string) => this.doSend(value); - protected async doSend(value: string): Promise { + private readonly onFocusResolved = ( + element: HTMLElement | undefined + ): void => { + if (this.closing || !this.isAttached) { + return; + } + this.focusNode = element; + requestAnimationFrame(() => + MessageLoop.sendMessage(this, Widget.Msg.ActivateRequest) + ); + }; + + private readonly onSend = (value: string): void => this.monitorManagerProxy.send(value); - } - protected readonly onChangeLineEnding = ( + private readonly onChangeLineEnding = ( option: SerialMonitorOutput.SelectOption ): void => { this.monitorModel.lineEnding = option.value; }; - protected readonly onChangeBaudRate = ({ - value, - }: { - value: string; - }): void => { + private readonly onChangeBaudRate = ({ value }: { value: string }): void => { this.getCurrentSettings().then(({ pluggableMonitorSettings }) => { - if (!pluggableMonitorSettings || !pluggableMonitorSettings['baudrate']) + if (!pluggableMonitorSettings || !pluggableMonitorSettings['baudrate']) { return; + } const baudRateSettings = pluggableMonitorSettings['baudrate']; baudRateSettings.selectedValue = value; this.monitorManagerProxy.changeSettings({ pluggableMonitorSettings }); }); }; + + private async refreshEditorWidget( + { preserveFocus }: { preserveFocus: boolean } = { preserveFocus: false } + ): Promise { + const editorWidget = this.editorWidget; + if (editorWidget) { + if (!preserveFocus) { + this.activate(); + return; + } + } + const widget = await this.createEditorWidget(); + this.editorContainer.addWidget(widget); + this.toDispose.pushAll([ + Disposable.create(() => widget.close()), + this.resourceProvider.resource.onDidChangeContents(() => + this.revealLastLine() + ), + ]); + if (!preserveFocus) { + this.activate(); + } + this.revealLastLine(); + } + + private revealLastLine(): void { + if (this.isLocked) { + return; + } + const editor = this.editor; + if (editor) { + const model = editor.getControl().getModel(); + if (model) { + const lineNumber = model.getLineCount(); + const column = model.getLineMaxColumn(lineNumber); + editor + .getControl() + .revealPosition( + { lineNumber, column }, + monaco.editor.ScrollType.Smooth + ); + } + } + } + + private get isLocked(): boolean { + return !this.monitorModel.autoscroll; + } + + private async createEditorWidget(): Promise { + const editor = await this.editorProvider.get( + this.resourceProvider.resource.uri + ); + return new EditorWidget(editor, this.selectionService); + } + + private get editorWidget(): EditorWidget | undefined { + for (const widget of toArray(this.editorContainer.children())) { + if (widget instanceof EditorWidget) { + return widget; + } + } + return undefined; + } + + private get editor(): MonacoEditor | undefined { + const widget = this.editorWidget; + if (widget instanceof EditorWidget) { + if (widget.editor instanceof MonacoEditor) { + return widget.editor; + } + } + return undefined; + } + + private async appendContent({ message }: { message: string }): Promise { + return this.appendContentQueue.add(async () => { + const textModel = ( + await this.resourceProvider.resource.editorModelRef.promise + ).object.textEditorModel; + const lastLine = textModel.getLineCount(); + const lastLineMaxColumn = textModel.getLineMaxColumn(lastLine); + const position = new monaco.Position(lastLine, lastLineMaxColumn); + const range = new monaco.Range( + position.lineNumber, + position.column, + position.lineNumber, + position.column + ); + const edits = [ + { + range, + text: message, + forceMoveMarkers: true, + }, + ]; + // We do not use `pushEditOperations` as we do not need undo/redo support. VS Code uses `applyEdits` too. + // https://github.com/microsoft/vscode/blob/dc348340fd1a6c583cb63a1e7e6b4fd657e01e01/src/vs/workbench/services/output/common/outputChannelModel.ts#L108-L115 + textModel.applyEdits(edits); + }); + } } + +const defaultLineEnding: SerialMonitorOutput.SelectOption = { + label: nls.localize('arduino/serial/newLine', 'New Line'), + value: '\n', +}; +const lineEndings: SerialMonitorOutput.SelectOption[] = [ + { + label: nls.localize('arduino/serial/noLineEndings', 'No Line Ending'), + value: '', + }, + defaultLineEnding, + { + label: nls.localize('arduino/serial/carriageReturn', 'Carriage Return'), + value: '\r', + }, + { + label: nls.localize('arduino/serial/newLineCarriageReturn', 'Both NL & CR'), + value: '\r\n', + }, +]; + +/** + * Customized `DockPanel` that does not allow dropping widgets into it. + * Intercepts `'p-dragover'` events, and sets the desired drop action to `'none'`. + */ +class NoopDragOverDockPanel extends DockPanel {} +NoopDragOverDockPanel.prototype['_evtDragOver'] = () => { + /* NOOP */ +}; +NoopDragOverDockPanel.prototype['_evtDrop'] = () => { + /* NOOP */ +}; +NoopDragOverDockPanel.prototype['_evtDragLeave'] = () => { + /* NOOP */ +}; diff --git a/arduino-ide-extension/src/browser/style/monitor.css b/arduino-ide-extension/src/browser/style/monitor.css index fdcdfc21c..189f2fc54 100644 --- a/arduino-ide-extension/src/browser/style/monitor.css +++ b/arduino-ide-extension/src/browser/style/monitor.css @@ -68,3 +68,14 @@ .p-TabBar-toolbar .item .clear-all { background: var(--theia-icon-clear) no-repeat; } + +#serial-monitor .content { + display: flex; + flex-direction: column; + height: 100%; +} + +#serial-monitor .editor-container { + height: 100%; + margin: 0px 5px; +} diff --git a/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts b/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts index 7a8ece561..90859bacc 100644 --- a/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts +++ b/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts @@ -1,19 +1,51 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; -import { Resource } from '@theia/core/lib/common/resource'; -import { ILogger, Log, Loggable } from '@theia/core/lib/common/logger'; +import type { ILogger, Log, Loggable } from '@theia/core/lib/common/logger'; +import { OS } from '@theia/core/lib/common/os'; +import type { Resource } from '@theia/core/lib/common/resource'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import { URI as CodeURI } from '@theia/core/shared/vscode-uri'; +import type { EditorPreferences } from '@theia/editor/lib/browser/editor-preferences'; +import { ITextResourcePropertiesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/textResourceConfiguration'; +import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; -import { EditorPreferences } from '@theia/editor/lib/browser/editor-preferences'; -import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter'; -import { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter'; import { MonacoTextModelService as TheiaMonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; +import type { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter'; +import type { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter'; import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; +import { MonitorUri } from '../../serial/monitor/monitor-uri'; @injectable() export class MonacoTextModelService extends TheiaMonacoTextModelService { @inject(SketchesServiceClientImpl) protected readonly sketchesServiceClient: SketchesServiceClientImpl; - protected override async createModel(resource: Resource): Promise { + @postConstruct() + override init(): void { + const resourcePropertiesService = StandaloneServices.get( + ITextResourcePropertiesService + ); + if (resourcePropertiesService) { + resourcePropertiesService.getEOL = (resource: CodeURI) => { + if (MonitorUri.toString() === resource.toString()) { + // The CLI seems to send `\r\n` through the monitor when calling `Serial.println` from `ino` code. + // See: https://github.com/arduino/arduino-ide/issues/391#issuecomment-850622814 + return '\r\n'; + } + const eol = this.editorPreferences['files.eol']; + if (eol && eol !== 'auto') { + return eol; + } + return OS.backend.isWindows ? '\r\n' : '\n'; + }; + } + } + + protected override async createModel( + resource: Resource + ): Promise { const factory = this.factories .getContributions() .find(({ scheme }) => resource.uri.scheme === scheme); @@ -73,6 +105,7 @@ class MaybeReadonlyMonacoEditorModel extends SilentMonacoEditorModel { } this._dirty = dirty; if (dirty === false) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (this as any).updateSavedVersionId(); } this.onDirtyChangedEmitter.fire(undefined);