From 93d27ea72aa00509d583cb1f499366b3f9cd76db Mon Sep 17 00:00:00 2001 From: 502E532E <502E532E+github@posteo.org> Date: Wed, 5 Nov 2025 15:56:04 +0000 Subject: [PATCH] feat: add a copy button to serial monitor (#2718) * Add a copy output button to serial monitor If the arduino collects some data that you want to store on your computer, a rather simple way is to write it to the serial monitor and copy it to the clipboard. This commit introduces a button that copies the whole content of the serial monitor to the clipboard to make this rather simple. It is a new component added to the menu, and does not change the behaviour of other compontents. * Test merging lines to str in serial monitor utils Adds a test for merging one or more lines to a single string. It is supposed to just concatenate the content of the lines, without doing anything else. This method is used when copying the serial monitor content to the clipboard. * Add copy output translation key This serves as an addition to the previous commits. It is the result of running `yarn i18n:generate` on the state after adding the copy output button to the serial monitor (see 2df3f465). I hope that this will resolve the current Github action failure. * Improve readability for serial monitor utils Replace return statement in inline method by direct statement, some minor formatting changes. Does not affect the functionality. * Rename linesToMergedStr in monitor-utils Renames the method linesToMergedStr to joinLines in the serial monitor utils. This brings the name more in line with truncateLines. No functionality changes. * Move label and icon registration for copy serial Moves the registration of the label and icon for the copy output button of the serial monitor to the toolbar item registration. Before, it happened at the command registration, but is not necessary at this level, as the icon and label are meant for the toolbar button only. * Do not update widget when copying output No longer updates the serial monitor output after its content is copied. Copying the content does not change anything for the view, so there is no need to update. --- .../browser/serial/monitor/monitor-utils.ts | 4 ++++ .../monitor/monitor-view-contribution.tsx | 18 ++++++++++++++++++ .../browser/serial/monitor/monitor-widget.tsx | 10 ++++++++++ .../monitor/serial-monitor-send-output.tsx | 8 +++++++- .../src/test/browser/monitor-utils.test.ts | 10 ++++++++++ i18n/en.json | 1 + 6 files changed, 50 insertions(+), 1 deletion(-) diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts index 41cb4f45..eca1756f 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts @@ -67,3 +67,7 @@ export function truncateLines( } return [lines, charCount]; } + +export function joinLines(lines: Line[]): string { + return lines.map((line: Line) => line.message).join(''); +} \ No newline at end of file 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 98bf5362..3697363e 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 @@ -52,6 +52,9 @@ export namespace SerialMonitor { }, 'vscode/output.contribution/clearOutput.label' ); + export const COPY_OUTPUT = { + id: 'serial-monitor-copy-output', + }; } } @@ -149,6 +152,12 @@ export class MonitorViewContribution 'Clear Output' ), }); + registry.registerItem({ + id: SerialMonitor.Commands.COPY_OUTPUT.id, + command: SerialMonitor.Commands.COPY_OUTPUT.id, + icon: codicon('copy'), + tooltip: nls.localize('arduino/serial/copyOutput', 'Copy Output'), + }); } override registerCommands(commands: CommandRegistry): void { @@ -161,6 +170,15 @@ export class MonitorViewContribution } }, }); + commands.registerCommand(SerialMonitor.Commands.COPY_OUTPUT, { + isEnabled: (widget) => widget instanceof MonitorWidget, + isVisible: (widget) => widget instanceof MonitorWidget, + execute: (widget) => { + if (widget instanceof MonitorWidget) { + widget.copyOutput(); + } + }, + }); if (this.toggleCommand) { commands.registerCommand(this.toggleCommand, { execute: () => this.toggle(), 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 f5c39460..ee39ec22 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx @@ -28,6 +28,7 @@ import { import { MonitorModel } from '../../monitor-model'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { serialMonitorWidgetLabel } from '../../../common/nls'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; @injectable() export class MonitorWidget extends ReactWidget { @@ -47,6 +48,7 @@ export class MonitorWidget extends ReactWidget { */ protected closing = false; protected readonly clearOutputEmitter = new Emitter(); + protected readonly copyOutputEmitter = new Emitter(); @inject(MonitorModel) private readonly monitorModel: MonitorModel; @@ -56,6 +58,8 @@ export class MonitorWidget extends ReactWidget { private readonly boardsServiceProvider: BoardsServiceProvider; @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; + @inject(ClipboardService) + private readonly clipboardService: ClipboardService; private readonly toDisposeOnReset: DisposableCollection; @@ -102,6 +106,10 @@ export class MonitorWidget extends ReactWidget { this.clearOutputEmitter.fire(undefined); this.update(); } + + copyOutput(): void { + this.copyOutputEmitter.fire(); + } override dispose(): void { this.toDisposeOnReset.dispose(); @@ -247,6 +255,8 @@ export class MonitorWidget extends ReactWidget { monitorModel={this.monitorModel} monitorManagerProxy={this.monitorManagerProxy} clearConsoleEvent={this.clearOutputEmitter.event} + copyOutputEvent={this.copyOutputEmitter.event} + clipboardService={this.clipboardService} height={Math.floor(this.widgetHeight - 50)} /> diff --git a/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-output.tsx b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-output.tsx index ec2327ad..f93b24e5 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-output.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-output.tsx @@ -3,9 +3,10 @@ import { Event } from '@theia/core/lib/common/event'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { areEqual, FixedSizeList as List } from 'react-window'; import dateFormat from 'dateformat'; -import { messagesToLines, truncateLines } from './monitor-utils'; +import { messagesToLines, truncateLines, joinLines } from './monitor-utils'; import { MonitorManagerProxyClient } from '../../../common/protocol'; import { MonitorModel } from '../../monitor-model'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; export type Line = { message: string; timestamp?: Date; lineLen: number }; @@ -74,6 +75,9 @@ export class SerialMonitorOutput extends React.Component< this.props.clearConsoleEvent(() => this.setState({ lines: [], charCount: 0 }) ), + this.props.copyOutputEvent(() => + this.props.clipboardService.writeText(joinLines(this.state.lines)) + ), this.props.monitorModel.onChange(({ property }) => { if (property === 'timestamp') { const { timestamp } = this.props.monitorModel; @@ -130,6 +134,8 @@ export namespace SerialMonitorOutput { readonly monitorModel: MonitorModel; readonly monitorManagerProxy: MonitorManagerProxyClient; readonly clearConsoleEvent: Event; + readonly copyOutputEvent: Event; + readonly clipboardService: ClipboardService; readonly height: number; } diff --git a/arduino-ide-extension/src/test/browser/monitor-utils.test.ts b/arduino-ide-extension/src/test/browser/monitor-utils.test.ts index cf102574..b219ec2e 100644 --- a/arduino-ide-extension/src/test/browser/monitor-utils.test.ts +++ b/arduino-ide-extension/src/test/browser/monitor-utils.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { messagesToLines, truncateLines, + joinLines, } from '../../browser/serial/monitor/monitor-utils'; import { Line } from '../../browser/serial/monitor/serial-monitor-send-output'; import { set, reset } from 'mockdate'; @@ -15,6 +16,7 @@ type TestLine = { charCount: number; maxCharacters?: number; }; + expectedJoined?: string; }; const date = new Date(); @@ -22,6 +24,7 @@ const testLines: TestLine[] = [ { messages: ['Hello'], expected: { lines: [{ message: 'Hello', lineLen: 5 }], charCount: 5 }, + expectedJoined: 'Hello', }, { messages: ['Hello', 'Dog!'], @@ -36,6 +39,7 @@ const testLines: TestLine[] = [ ], charCount: 10, }, + expectedJoined: 'Hello\nDog!' }, { messages: ['Dog!'], @@ -67,6 +71,7 @@ const testLines: TestLine[] = [ { message: "You're a good boy!", lineLen: 8 }, ], }, + expectedJoined: "Hello Dog!\n Who's a good boy?\nYou're a good boy!", }, { messages: ['boy?\n', "You're a good boy!"], @@ -116,6 +121,7 @@ const testLines: TestLine[] = [ { message: 'Yo', lineLen: 2 }, ], }, + expectedJoined: "Hello Dog!\nWho's a good boy?\nYo", }, ]; @@ -165,6 +171,10 @@ describe('Monitor Utils', () => { }); expect(totalCharCount).to.equal(charCount); } + if (testLine.expectedJoined) { + const joined_str = joinLines(testLine.expected.lines); + expect(joined_str).to.equal(testLine.expectedJoined); + } }); }); }); diff --git a/i18n/en.json b/i18n/en.json index e6dbab0c..5e3d19be 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -435,6 +435,7 @@ "autoscroll": "Autoscroll", "carriageReturn": "Carriage Return", "connecting": "Connecting to '{0}' on '{1}'...", + "copyOutput": "Copy Output", "message": "Message (Enter to send message to '{0}' on '{1}')", "newLine": "New Line", "newLineCarriageReturn": "Both NL & CR",