From 5b878d3e26af88ffeb3d2571c6f2d83841afe69b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 10 Aug 2026 10:05:20 -0400 Subject: [PATCH 1/4] feat(console): render arduino-cli colour and collapse progress redraws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build output is captured from a pipe and pushed to the console verbatim, so the two terminal control sequences arduino-cli uses were both mishandled. **Carriage returns.** A download progress bar redraws by rewriting one line with `\r`. Each redraw arrived as its own chunk and became its own timestamped entry, so one core install produced hundreds of near-identical lines that pushed the real output out of view: [09:12:34]: ...54.94 MiB / 93.67 MiB [=====>-----] 58.65% [09:12:35]: ...54.94 MiB / 93.67 MiB [=====>-----] 58.65% [09:12:35]: ...57.68 MiB / 93.67 MiB [======>----] 61.58% A chunk is now collapsed to the frame a terminal would leave on screen, and the entry is marked `transient` while the line is still open. The next redraw overwrites it; a trailing newline commits it and the following download starts a fresh line. One live-updating line, as in a terminal. **SGR colour.** arduino-cli colours its compile summary table (bright green headers, yellow platform id, grey paths). The editor suppressed this with `output.no_color` in `arduino-cli.yaml` — inherited from the 2022 Python editor, which added `--no-color` because the raw `ESC[92m` bytes were printed literally. The console parses SGR now, so the suppression is gone. Colour is split off once, at the console slice: `message` always holds clean text and `segments` carries the styling only when there was any. Search, level filters and copy-to-clipboard keep working on `message` untouched — no consumer besides the renderer learns that colour exists, and uncoloured logs (the overwhelming majority) allocate nothing extra. Dead code removed rather than left behind: - `output.no_color` is dropped from `ARDUINO_DATA`, and existing configs are migrated. The config was written once with `{ flag: 'wx' }` and skipped on EEXIST forever after, so every install that ever ran an older build would have kept colour off and made the new renderer unreachable. Reconciliation is narrow and non-destructive: add missing board-manager URLs, drop `no_color`, prune the `output` map only if it is left empty, and never touch anything else. Uses the `yaml` Document API so user comments, ordering and custom indexes survive; an unparseable config is left alone. - `ArduinoCliConfigSchema` / `ArduinoCliConfig` deleted — a zod schema that described the config's `no_color` shape and was imported by nothing. Not a terminal emulator: cursor addressing, scroll regions and erase-in-line are stripped rather than interpreted, because build output never uses them. Tests: 29 new across the parser, the CR state machine, the slice's overwrite rule and the config migration (including the upgrade-with-no_color path). Verified against real captured arduino-cli bytes: 24 CR frames collapse, the summary table maps to green/plain/green/plain/grey, and neither an escape nor a carriage return survives into stored text. Full suite 6354 passing. Paired with openplc-web (shared-core parity). Co-Authored-By: Claude Opus 5 (1M context) --- src/backend/editor/compiler/types.ts | 15 +- .../data/__tests__/arduino-cli-config.test.ts | 112 +++++++++++++ .../user-service/data/arduino-cli-config.ts | 80 +++++++++ .../services/user-service/data/types.ts | 2 - .../editor/services/user-service/index.ts | 34 +++- .../components/_organisms/console/index.tsx | 1 + .../components/_organisms/console/log.tsx | 52 ++++-- .../store/__tests__/console-slice.test.ts | 77 +++++++++ src/frontend/store/slices/console/slice.ts | 30 +++- src/frontend/store/slices/console/types.ts | 13 +- .../utils/__tests__/debugger-session.test.ts | 66 ++++++++ .../utils/__tests__/terminal-output.test.ts | 116 +++++++++++++ src/frontend/utils/debugger-session.ts | 64 +++++-- src/frontend/utils/terminal-output.ts | 157 ++++++++++++++++++ src/middleware/shared/ports/types.ts | 24 +++ 15 files changed, 785 insertions(+), 58 deletions(-) create mode 100644 src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts create mode 100644 src/backend/editor/services/user-service/data/arduino-cli-config.ts create mode 100644 src/frontend/utils/__tests__/terminal-output.test.ts create mode 100644 src/frontend/utils/terminal-output.ts diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index 7d2533449..cc5109af6 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -1,16 +1,5 @@ import { z } from 'zod/v4' -const ArduinoCliConfigSchema = z.object({ - board_manager: z.object({ - additional_urls: z.array(z.string()), - }), - output: z.object({ - no_color: z.boolean(), - }), -}) - -type ArduinoCliConfig = z.infer - const ArduinoCoreControlSchema = z.array(z.record(z.string(), z.string())) type ArduinoCoreControl = z.infer @@ -38,6 +27,6 @@ type ToolchainProperties = { export type { BoardInfo, HalsFile } from '../hardware/types' export { BoardInfoSchema, HalsFileSchema } from '../hardware/types' -export { ArduinoCliConfigSchema, ArduinoCoreControlSchema } +export { ArduinoCoreControlSchema } -export type { ArduinoCliConfig, ArduinoCoreControl, ToolchainProperties } +export type { ArduinoCoreControl, ToolchainProperties } diff --git a/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts new file mode 100644 index 000000000..71afbe25e --- /dev/null +++ b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts @@ -0,0 +1,112 @@ +import { parse } from 'yaml' + +import { reconcileArduinoCliConfig } from '../arduino-cli-config' +import { ARDUINO_DATA } from '../types' + +/** + * The config every install created before this change: two board-manager URLs + * and the colour suppression the console no longer needs. + */ +const LEGACY_CONFIG = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://espressif.github.io/arduino-esp32/package_esp32_index.json +output: + no_color: true +` + +function urlsOf(yaml: string): string[] { + return (parse(yaml) as { board_manager?: { additional_urls?: string[] } })?.board_manager?.additional_urls ?? [] +} + +describe('reconcileArduinoCliConfig', () => { + // --------------------------------------------------------------------- + // The upgrade path that matters: an install that already has no_color. + // --------------------------------------------------------------------- + it('drops output.no_color from a legacy config', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + expect(updated).not.toBeNull() + expect(updated).not.toContain('no_color') + // The whole `output` map existed only to hold it. + expect(updated).not.toContain('output:') + }) + + it('backfills the board manager URLs the legacy config never received', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + const result = urlsOf(updated as string) + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('produces a config that still parses as valid YAML', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) as string + expect(() => parse(updated)).not.toThrow() + expect(parse(updated)).toMatchObject({ board_manager: { additional_urls: expect.any(Array) } }) + }) + + it('is idempotent — a second pass reports nothing left to do', () => { + const once = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) as string + expect(reconcileArduinoCliConfig(once, ARDUINO_DATA)).toBeNull() + }) + + // --------------------------------------------------------------------- + // Don't destroy what the user put there. + // --------------------------------------------------------------------- + it('keeps user-added URLs that the editor does not ship', () => { + const withCustom = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://example.com/package_mine_index.json +output: + no_color: true +` + const updated = reconcileArduinoCliConfig(withCustom, ARDUINO_DATA) as string + const result = urlsOf(updated) + expect(result).toContain('https://example.com/package_mine_index.json') + // ...and the shipped ones still get added alongside it. + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('preserves unrelated settings and comments', () => { + const withExtras = `# my notes\nlogging:\n level: debug\n${LEGACY_CONFIG}` + const updated = reconcileArduinoCliConfig(withExtras, ARDUINO_DATA) as string + expect(updated).toContain('# my notes') + expect(parse(updated)).toMatchObject({ logging: { level: 'debug' } }) + }) + + it('keeps an output map that still holds other keys', () => { + const withOtherOutput = `board_manager:\n additional_urls: []\noutput:\n no_color: true\n format: json\n` + const updated = reconcileArduinoCliConfig(withOtherOutput, ARDUINO_DATA) as string + expect(updated).not.toContain('no_color') + expect(parse(updated)).toMatchObject({ output: { format: 'json' } }) + }) + + // --------------------------------------------------------------------- + // No-op and failure cases. + // --------------------------------------------------------------------- + it('returns null when the file already matches what we ship', () => { + expect(reconcileArduinoCliConfig(ARDUINO_DATA, ARDUINO_DATA)).toBeNull() + }) + + it('installs the shipped URL list when the key is missing entirely', () => { + const updated = reconcileArduinoCliConfig('output:\n no_color: true\n', ARDUINO_DATA) as string + expect(urlsOf(updated)).toEqual(urlsOf(ARDUINO_DATA)) + }) + + it('leaves an unparseable config alone rather than clobbering it', () => { + expect(reconcileArduinoCliConfig('board_manager: [oops\n : :\n', ARDUINO_DATA)).toBeNull() + }) +}) + +describe('ARDUINO_DATA', () => { + it('no longer ships the obsolete colour suppression', () => { + // The console renders SGR colour now; forcing it off would make the + // renderer dead code on every fresh install. + expect(ARDUINO_DATA).not.toContain('no_color') + }) + + it('is valid YAML with a non-empty board manager list', () => { + expect(urlsOf(ARDUINO_DATA).length).toBeGreaterThan(0) + }) +}) diff --git a/src/backend/editor/services/user-service/data/arduino-cli-config.ts b/src/backend/editor/services/user-service/data/arduino-cli-config.ts new file mode 100644 index 000000000..9ae3299de --- /dev/null +++ b/src/backend/editor/services/user-service/data/arduino-cli-config.ts @@ -0,0 +1,80 @@ +/** + * Reconcile an existing `arduino-cli.yaml` with the one the editor ships. + * + * The config used to be written once with `{ flag: 'wx' }` and skipped + * forever after, so anything added to `ARDUINO_DATA` later never reached an + * existing install — the only fix was deleting the file by hand. This brings + * a stale file up to date in place. + * + * Two rules, and deliberately only two: + * + * - **Add missing board-manager URLs.** Never remove one: users add their own + * vendor indexes here, and VPP-declared indexes arrive at compile time. + * - **Drop `output.no_color`.** The editor forced it on to stop raw `ESC[92m` + * bytes appearing in the console. The console now renders SGR colour + * itself, so the suppression is obsolete; leaving it behind would silently + * keep colour off on every machine that has ever launched an older build. + * + * Everything else is left exactly as the user left it, comments and ordering + * included — hence the `Document` API for the existing file rather than a + * parse/serialise round-trip through plain objects. + */ + +import { isMap, isSeq, parse, parseDocument } from 'yaml' + +const BOARD_MANAGER_URLS_PATH = ['board_manager', 'additional_urls'] +const NO_COLOR_PATH = ['output', 'no_color'] + +type ArduinoCliConfigShape = { + board_manager?: { additional_urls?: unknown } +} + +/** Board-manager URLs declared by the shipped template (which we author). */ +function shippedBoardManagerUrls(shipped: string): string[] { + const urls = (parse(shipped) as ArduinoCliConfigShape | null)?.board_manager?.additional_urls + return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === 'string') : [] +} + +/** + * Return the updated file contents, or `null` when nothing needed changing. + * + * Also returns `null` when `existing` is unparseable — a broken config is the + * user's to fix, and rewriting it would discard whatever they were editing. + */ +export function reconcileArduinoCliConfig(existing: string, shipped: string): string | null { + const doc = parseDocument(existing) + if (doc.errors.length > 0) return null + + let changed = false + + // 1. Board-manager URLs — union, never subtract. + const shippedUrls = shippedBoardManagerUrls(shipped) + if (shippedUrls.length > 0) { + const current = doc.getIn(BOARD_MANAGER_URLS_PATH) + const present = new Set(isSeq(current) ? current.toJSON().map(String) : []) + const missing = shippedUrls.filter((url) => !present.has(url)) + + if (missing.length > 0) { + if (isSeq(current)) { + for (const url of missing) current.add(url) + } else { + // No `additional_urls` key, or it is not a list — install the shipped + // set wholesale rather than guessing at a merge. + doc.setIn(BOARD_MANAGER_URLS_PATH, shippedUrls) + } + changed = true + } + } + + // 2. Retire the obsolete colour suppression. + if (doc.hasIn(NO_COLOR_PATH)) { + doc.deleteIn(NO_COLOR_PATH) + changed = true + + // Don't leave an empty `output:` behind once its only key is gone. + const output = doc.get('output') + if (isMap(output) && output.items.length === 0) doc.delete('output') + } + + return changed ? String(doc) : null +} diff --git a/src/backend/editor/services/user-service/data/types.ts b/src/backend/editor/services/user-service/data/types.ts index 57575b7dc..5d2c6dd8e 100644 --- a/src/backend/editor/services/user-service/data/types.ts +++ b/src/backend/editor/services/user-service/data/types.ts @@ -11,8 +11,6 @@ board_manager: - https://raw.githubusercontent.com/VEA-SRL/IRUINO_Library/main/package_vea_index.json - https://github.com/CONTROLLINO-PLC/controllino_rp2/releases/download/global/package_controllino_rp2_index.json - https://downloads.arduino.cc/packages/package_zephyr_index.json -output: - no_color: true ` export const HISTORY_DATA = { diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts index 5e6aae3c3..9f470508a 100644 --- a/src/backend/editor/services/user-service/index.ts +++ b/src/backend/editor/services/user-service/index.ts @@ -1,10 +1,11 @@ import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { exec } from 'child_process' import { app } from 'electron' -import { access, constants, mkdir, rename, rm, writeFile } from 'fs/promises' +import { access, constants, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { basename, join } from 'path' import { promisify } from 'util' +import { reconcileArduinoCliConfig } from './data/arduino-cli-config' import { ARDUINO_DATA, HISTORY_DATA, SETTINGS_DATA } from './data/types' import type { ArduinoListOutput } from './types' @@ -144,22 +145,39 @@ class UserService { } /** - * Checks if the Arduino CLI configuration file exists and creates it if it doesn't. + * Create the Arduino CLI configuration file, or bring an existing one up to + * date with what the editor ships. + * + * Previously this wrote with `{ flag: 'wx' }` and swallowed `EEXIST`, so the + * file was effectively write-once. Any install that had launched an older + * build kept a stale config forever — including the now-obsolete + * `output.no_color`, which would keep the console monochrome even though it + * renders SGR colour itself now. See `reconcileArduinoCliConfig` for the + * (deliberately narrow) merge rules. */ async #checkIfArduinoCliConfigExists(): Promise { const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml') + try { await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' }) + return } catch (err) { - // If the error is due to the file already existing, log a warning and continue. - if (err instanceof Error && err.message.includes('EEXIST')) { - console.warn(`File already exists at ${pathToArduinoCliConfig}.\nSkipping creation.`) - } else if (err instanceof Error) { - console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) - } else { + if (!(err instanceof Error && err.message.includes('EEXIST'))) { console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + return } } + + try { + const existing = await readFile(pathToArduinoCliConfig, 'utf-8') + const updated = reconcileArduinoCliConfig(existing, UserService.ARDUINO_FILE_CONTENT) + if (!updated) return + + await writeFile(pathToArduinoCliConfig, updated, 'utf-8') + console.warn(`Updated Arduino CLI config at ${pathToArduinoCliConfig}.`) + } catch (err) { + console.error(`Error updating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + } } async #executeArduinoCliCommand(command: string): Promise<{ stderr: string; stdout: string }> { diff --git a/src/frontend/components/_organisms/console/index.tsx b/src/frontend/components/_organisms/console/index.tsx index a6bcd49e9..76f45ca58 100644 --- a/src/frontend/components/_organisms/console/index.tsx +++ b/src/frontend/components/_organisms/console/index.tsx @@ -136,6 +136,7 @@ const Console = memo(() => { message={log.message} tstamp={formatTimestamp(log.tstamp ?? new Date(), filters.timestampFormat)} searchTerm={filters.searchTerm} + segments={log.segments} compileError={log.compileError} onCompileErrorClick={navigateToCompileError} /> diff --git a/src/frontend/components/_organisms/console/log.tsx b/src/frontend/components/_organisms/console/log.tsx index 66e2872e2..e0912705c 100644 --- a/src/frontend/components/_organisms/console/log.tsx +++ b/src/frontend/components/_organisms/console/log.tsx @@ -1,4 +1,4 @@ -import type { StructuredCompileError } from '@root/middleware/shared/ports/types' +import type { LogSegment, StructuredCompileError } from '@root/middleware/shared/ports/types' import { Copy } from 'lucide-react' import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react' @@ -18,6 +18,9 @@ type LogComponentProps = ComponentPropsWithoutRef<'p'> & { message: string tstamp: string searchTerm?: string + /** Styled runs when the tool emitted SGR colour; `message` is the same + * text with the escapes stripped. */ + segments?: LogSegment[] /** When set, the bracketed POU prefix on the first line becomes a * click-to-open button that calls {@link onCompileErrorClick}. * Multi-line gcc-style snippet renders as plain pre-wrapped text @@ -63,6 +66,35 @@ const HighlightedText = ({ text, searchTerm }: { text: string; searchTerm?: stri return <>{parts} } +/** + * The message body of a log line. + * + * Applies the tool's SGR colour when the line carried any, and otherwise + * renders exactly as before. Search highlighting runs inside each styled run + * so a match spanning a colour change still highlights correctly. + */ +const MessageBody = ({ + message, + segments, + searchTerm, +}: { + message: string + segments?: LogSegment[] + searchTerm?: string +}) => { + if (!segments?.length) return + + return ( + <> + {segments.map((segment, index) => ( + + + + ))} + + ) +} + /** * Render a compile-error log line: the first line (the bracketed POU * prefix the compiler-module emits, e.g. `[Manual_Override / body @@ -118,6 +150,7 @@ const LogComponent = ({ message, tstamp, searchTerm, + segments, compileError, onCompileErrorClick, ...rest @@ -156,22 +189,13 @@ const LogComponent = ({ {message && (

- {level && tstamp ? ( + {level && tstamp && ( <> [ ]:{' '} - {compileError ? ( - - ) : ( - - )} - ) : compileError ? ( + )} + {compileError ? ( ) : ( - + )}