diff --git a/js/console.js b/js/console.js index 7eb3d335..eb393726 100644 --- a/js/console.js +++ b/js/console.js @@ -17,7 +17,8 @@ export class ESP32ToolConsole { this.allowInput = allowInput; this.console = null; this.cancelConnection = null; - // Command history buffer + // Command history buffer — keep in sync with src/console.ts + // (history logic is duplicated there; update both files together) this.commandHistory = []; this.historyIndex = -1; this.currentInput = ""; diff --git a/js/util/console-color.js b/js/util/console-color.js index 198b0dd7..b38a3451 100644 --- a/js/util/console-color.js +++ b/js/util/console-color.js @@ -1,177 +1,235 @@ export class ColoredConsole { - constructor(targetElement) { - this.targetElement = targetElement; - this.state = { - bold: false, - italic: false, - underline: false, - strikethrough: false, - foregroundColor: null, - backgroundColor: null, - carriageReturn: false, - lines: [], - secret: false, - blink: false, - rapidBlink: false, - }; - } - - logs() { - if (this.state.lines.length > 0) { - this.processLines(); + constructor(targetElement) { + this.targetElement = targetElement; + this.state = { + bold: false, + italic: false, + underline: false, + strikethrough: false, + foregroundColor: null, + backgroundColor: null, + carriageReturn: false, + lines: [], + secret: false, + blink: false, + rapidBlink: false, + }; } - return this.targetElement.innerText; - } - - processLine(line) { - // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences - const re = /(?:\x1B|\\x1B)(?:\[(.*?)[@-~]|\].*?(?:\x07|\x1B\\))/g; - let i = 0; - - const lineSpan = document.createElement("span"); - lineSpan.classList.add("line"); - - const addSpan = (content) => { - if (content === "") return; - - const span = document.createElement("span"); - if (this.state.bold) span.classList.add("log-bold"); - if (this.state.italic) span.classList.add("log-italic"); - if (this.state.underline) span.classList.add("log-underline"); - if (this.state.strikethrough) span.classList.add("log-strikethrough"); - if (this.state.secret) span.classList.add("log-secret"); - if (this.state.blink) span.classList.add("log-blink"); - if (this.state.rapidBlink) span.classList.add("log-rapid-blink"); - if (this.state.foregroundColor !== null) - span.classList.add(`log-fg-${this.state.foregroundColor}`); - if (this.state.backgroundColor !== null) - span.classList.add(`log-bg-${this.state.backgroundColor}`); - span.appendChild(document.createTextNode(content)); - lineSpan.appendChild(span); - - if (this.state.secret) { - const redacted = document.createElement("span"); - redacted.classList.add("log-secret-redacted"); - redacted.appendChild(document.createTextNode("[redacted]")); - lineSpan.appendChild(redacted); - } - }; - - while (true) { - const match = re.exec(line); - if (match === null) break; - - const j = match.index; - addSpan(line.substring(i, j)); - i = j + match[0].length; - - if (match[1] === undefined) continue; - - for (const colorCode of match[1].split(";")) { - switch (parseInt(colorCode)) { - case 0: - this.state.bold = false; - this.state.italic = false; - this.state.underline = false; - this.state.strikethrough = false; - this.state.foregroundColor = null; - this.state.backgroundColor = null; - this.state.secret = false; - this.state.blink = false; - this.state.rapidBlink = false; - break; - case 1: this.state.bold = true; break; - case 3: this.state.italic = true; break; - case 4: this.state.underline = true; break; - case 5: this.state.blink = true; this.state.rapidBlink = false; break; - case 6: this.state.rapidBlink = true; this.state.blink = false; break; - case 8: this.state.secret = true; break; - case 9: this.state.strikethrough = true; break; - case 22: this.state.bold = false; break; - case 23: this.state.italic = false; break; - case 24: this.state.underline = false; break; - case 25: this.state.blink = false; this.state.rapidBlink = false; break; - case 28: this.state.secret = false; break; - case 29: this.state.strikethrough = false; break; - case 30: this.state.foregroundColor = "black"; break; - case 31: this.state.foregroundColor = "red"; break; - case 32: this.state.foregroundColor = "green"; break; - case 33: this.state.foregroundColor = "yellow"; break; - case 34: this.state.foregroundColor = "blue"; break; - case 35: this.state.foregroundColor = "magenta"; break; - case 36: this.state.foregroundColor = "cyan"; break; - case 37: this.state.foregroundColor = "white"; break; - case 39: this.state.foregroundColor = null; break; - case 40: this.state.backgroundColor = "black"; break; - case 41: this.state.backgroundColor = "red"; break; - case 42: this.state.backgroundColor = "green"; break; - case 43: this.state.backgroundColor = "yellow"; break; - case 44: this.state.backgroundColor = "blue"; break; - case 45: this.state.backgroundColor = "magenta"; break; - case 46: this.state.backgroundColor = "cyan"; break; - case 47: this.state.backgroundColor = "white"; break; - case 49: this.state.backgroundColor = null; break; + logs() { + if (this.state.lines.length > 0) { + this.processLines(); } - } + return this.targetElement.innerText; } - addSpan(line.substring(i)); - return lineSpan; - } - - processLines() { - const atBottom = - this.targetElement.scrollTop > - this.targetElement.scrollHeight - this.targetElement.offsetHeight - 50; - const prevCarriageReturn = this.state.carriageReturn; - const fragment = document.createDocumentFragment(); - - if (this.state.lines.length === 0) { - return; - } - - for (const line of this.state.lines) { - // A lone \r is a pure carriage-return signal — update state but don't - // create a DOM node for it (it has no renderable content). - if (line === "\r") { - this.state.carriageReturn = true; - continue; - } - if (this.state.carriageReturn && line !== "\n") { - if (fragment.childElementCount) { - fragment.removeChild(fragment.lastChild); + processLine(line) { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences + // eslint-disable-next-line no-control-regex + const re = /(?:\x1B|\\x1B)(?:\[(.*?)[@-~]|\].*?(?:\x07|\x1B\\))/g; + let i = 0; + const lineSpan = document.createElement("span"); + lineSpan.classList.add("line"); + const addSpan = (content) => { + if (content === "") + return; + const span = document.createElement("span"); + if (this.state.bold) + span.classList.add("log-bold"); + if (this.state.italic) + span.classList.add("log-italic"); + if (this.state.underline) + span.classList.add("log-underline"); + if (this.state.strikethrough) + span.classList.add("log-strikethrough"); + if (this.state.secret) + span.classList.add("log-secret"); + if (this.state.blink) + span.classList.add("log-blink"); + if (this.state.rapidBlink) + span.classList.add("log-rapid-blink"); + if (this.state.foregroundColor !== null) + span.classList.add(`log-fg-${this.state.foregroundColor}`); + if (this.state.backgroundColor !== null) + span.classList.add(`log-bg-${this.state.backgroundColor}`); + span.appendChild(document.createTextNode(content)); + lineSpan.appendChild(span); + if (this.state.secret) { + const redacted = document.createElement("span"); + redacted.classList.add("log-secret-redacted"); + redacted.appendChild(document.createTextNode("[redacted]")); + lineSpan.appendChild(redacted); + } + }; + while (true) { + const match = re.exec(line); + if (match === null) + break; + const j = match.index; + addSpan(line.substring(i, j)); + i = j + match[0].length; + if (match[1] === undefined) + continue; + for (const colorCode of match[1].split(";")) { + switch (parseInt(colorCode)) { + case 0: + // reset + this.state.bold = false; + this.state.italic = false; + this.state.underline = false; + this.state.strikethrough = false; + this.state.foregroundColor = null; + this.state.backgroundColor = null; + this.state.secret = false; + this.state.blink = false; + this.state.rapidBlink = false; + break; + case 1: + this.state.bold = true; + break; + case 3: + this.state.italic = true; + break; + case 4: + this.state.underline = true; + break; + case 5: + this.state.blink = true; + this.state.rapidBlink = false; + break; + case 6: + this.state.rapidBlink = true; + this.state.blink = false; + break; + case 8: + this.state.secret = true; + break; + case 9: + this.state.strikethrough = true; + break; + case 22: + this.state.bold = false; + break; + case 23: + this.state.italic = false; + break; + case 24: + this.state.underline = false; + break; + case 25: + this.state.blink = false; + this.state.rapidBlink = false; + break; + case 28: + this.state.secret = false; + break; + case 29: + this.state.strikethrough = false; + break; + case 30: + this.state.foregroundColor = "black"; + break; + case 31: + this.state.foregroundColor = "red"; + break; + case 32: + this.state.foregroundColor = "green"; + break; + case 33: + this.state.foregroundColor = "yellow"; + break; + case 34: + this.state.foregroundColor = "blue"; + break; + case 35: + this.state.foregroundColor = "magenta"; + break; + case 36: + this.state.foregroundColor = "cyan"; + break; + case 37: + this.state.foregroundColor = "white"; + break; + case 39: + this.state.foregroundColor = null; + break; + case 40: + this.state.backgroundColor = "black"; + break; + case 41: + this.state.backgroundColor = "red"; + break; + case 42: + this.state.backgroundColor = "green"; + break; + case 43: + this.state.backgroundColor = "yellow"; + break; + case 44: + this.state.backgroundColor = "blue"; + break; + case 45: + this.state.backgroundColor = "magenta"; + break; + case 46: + this.state.backgroundColor = "cyan"; + break; + case 47: + this.state.backgroundColor = "white"; + break; + case 49: + this.state.backgroundColor = null; + break; + } + } } - } - const hadCarriageReturn = line.endsWith("\r"); - fragment.appendChild(this.processLine(line.replace(/\r/g, ""))); - this.state.carriageReturn = hadCarriageReturn; - } - - if ( - prevCarriageReturn && - fragment.childElementCount > 0 && - this.targetElement.lastChild - ) { - this.targetElement.replaceChild(fragment, this.targetElement.lastChild); - } else { - this.targetElement.appendChild(fragment); + addSpan(line.substring(i)); + return lineSpan; } - - this.state.lines = []; - - if (atBottom) { - this.targetElement.scrollTop = this.targetElement.scrollHeight; + processLines() { + const atBottom = this.targetElement.scrollTop > + this.targetElement.scrollHeight - this.targetElement.offsetHeight - 50; + const prevCarriageReturn = this.state.carriageReturn; + const fragment = document.createDocumentFragment(); + if (this.state.lines.length === 0) { + return; + } + for (const line of this.state.lines) { + // A lone \r is a pure carriage-return signal — update state but don't + // create a DOM node for it (it has no renderable content). + if (line === "\r") { + this.state.carriageReturn = true; + continue; + } + if (this.state.carriageReturn && line !== "\n") { + if (fragment.childElementCount) { + fragment.removeChild(fragment.lastChild); + } + } + const hadCarriageReturn = line.endsWith("\r"); + fragment.appendChild(this.processLine(line.replace(/\r/g, ""))); + this.state.carriageReturn = hadCarriageReturn; + } + if (prevCarriageReturn && + fragment.childElementCount > 0 && + this.targetElement.lastChild) { + this.targetElement.replaceChild(fragment, this.targetElement.lastChild); + } + else { + this.targetElement.appendChild(fragment); + } + this.state.lines = []; + // Keep scroll at bottom + if (atBottom) { + this.targetElement.scrollTop = this.targetElement.scrollHeight; + } } - } - - addLine(line) { - // Processing of lines is deferred for performance reasons - if (this.state.lines.length === 0) { - setTimeout(() => this.processLines(), 0); + addLine(line) { + // Processing of lines is deferred for performance reasons + if (this.state.lines.length === 0) { + setTimeout(() => this.processLines(), 0); + } + this.state.lines.push(line); } - this.state.lines.push(line); - } } - export const coloredConsoleStyles = ` .log { flex: 1; @@ -182,42 +240,95 @@ export const coloredConsoleStyles = ` padding: 16px; overflow: auto; line-height: 1.45; - border-radius: 0; + border-radius: 3px; white-space: pre-wrap; overflow-wrap: break-word; color: #ddd; - min-height: 0; } - .log-bold { font-weight: bold; } - .log-italic { font-style: italic; } - .log-underline { text-decoration: underline; } - .log-strikethrough { text-decoration: line-through; } - .log-underline.log-strikethrough { text-decoration: underline line-through; } - .log-blink { animation: blink 1s step-end infinite; } - .log-rapid-blink { animation: blink 0.4s step-end infinite; } - @keyframes blink { 50% { opacity: 0; } } + .log-bold { + font-weight: bold; + } + .log-italic { + font-style: italic; + } + .log-underline { + text-decoration: underline; + } + .log-strikethrough { + text-decoration: line-through; + } + .log-underline.log-strikethrough { + text-decoration: underline line-through; + } + .log-blink { + animation: blink 1s step-end infinite; + } + .log-rapid-blink { + animation: blink 0.4s step-end infinite; + } + @keyframes blink { + 50% { + opacity: 0; + } + } .log-secret { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } - .log-secret-redacted { opacity: 0; width: 1px; font-size: 1px; } - .log-fg-black { color: rgb(128, 128, 128); } - .log-fg-red { color: rgb(255, 0, 0); } - .log-fg-green { color: rgb(0, 255, 0); } - .log-fg-yellow { color: rgb(255, 255, 0); } - .log-fg-blue { color: rgb(0, 0, 255); } - .log-fg-magenta { color: rgb(255, 0, 255); } - .log-fg-cyan { color: rgb(0, 255, 255); } - .log-fg-white { color: rgb(187, 187, 187); } - .log-bg-black { background-color: rgb(0, 0, 0); } - .log-bg-red { background-color: rgb(255, 0, 0); } - .log-bg-green { background-color: rgb(0, 255, 0); } - .log-bg-yellow { background-color: rgb(255, 255, 0); } - .log-bg-blue { background-color: rgb(0, 0, 255); } - .log-bg-magenta { background-color: rgb(255, 0, 255); } - .log-bg-cyan { background-color: rgb(0, 255, 255); } - .log-bg-white { background-color: rgb(255, 255, 255); } + .log-secret-redacted { + opacity: 0; + width: 1px; + font-size: 1px; + } + .log-fg-black { + color: rgb(128, 128, 128); + } + .log-fg-red { + color: rgb(255, 0, 0); + } + .log-fg-green { + color: rgb(0, 255, 0); + } + .log-fg-yellow { + color: rgb(255, 255, 0); + } + .log-fg-blue { + color: rgb(0, 0, 255); + } + .log-fg-magenta { + color: rgb(255, 0, 255); + } + .log-fg-cyan { + color: rgb(0, 255, 255); + } + .log-fg-white { + color: rgb(187, 187, 187); + } + .log-bg-black { + background-color: rgb(0, 0, 0); + } + .log-bg-red { + background-color: rgb(255, 0, 0); + } + .log-bg-green { + background-color: rgb(0, 255, 0); + } + .log-bg-yellow { + background-color: rgb(255, 255, 0); + } + .log-bg-blue { + background-color: rgb(0, 0, 255); + } + .log-bg-magenta { + background-color: rgb(255, 0, 255); + } + .log-bg-cyan { + background-color: rgb(0, 255, 255); + } + .log-bg-white { + background-color: rgb(255, 255, 255); + } `; diff --git a/js/util/line-break-transformer.js b/js/util/line-break-transformer.js index a876f1b0..047a887f 100644 --- a/js/util/line-break-transformer.js +++ b/js/util/line-break-transformer.js @@ -1,33 +1,31 @@ export class LineBreakTransformer { - constructor() { - this.chunks = ""; - } - - transform(chunk, controller) { - // Append new chunks to existing chunks. - this.chunks += chunk; - // Split on \r\n, lone \r, or lone \n — capturing the separator so we can - // distinguish a lone \r (overwrite intent) from a normal newline. - const re = /\r\n|\r|\n/g; - let lastIndex = 0; - let match; - while ((match = re.exec(this.chunks)) !== null) { - // If this is a lone \r at the very end of the buffer, leave it so it can - // be combined with a possible following \n in the next chunk. - if (match[0] === "\r" && match.index === this.chunks.length - 1) { - break; - } - const line = this.chunks.substring(lastIndex, match.index); - // Emit with \r suffix only for lone \r (overwrite), \n for everything else. - const suffix = match[0] === "\r" ? "\r" : "\n"; - controller.enqueue(line + suffix); - lastIndex = re.lastIndex; + constructor() { + this.chunks = ""; + } + transform(chunk, controller) { + // Append new chunks to existing chunks. + this.chunks += chunk; + // Split on \r\n, lone \r, or lone \n — capturing the separator so we can + // distinguish a lone \r (overwrite intent) from a normal newline. + const re = /\r\n|\r|\n/g; + let lastIndex = 0; + let match; + while ((match = re.exec(this.chunks)) !== null) { + // If this is a lone \r at the very end of the buffer, leave it so it can + // be combined with a possible following \n in the next chunk. + if (match[0] === "\r" && match.index === this.chunks.length - 1) { + break; + } + const line = this.chunks.substring(lastIndex, match.index); + // Emit with \r suffix only for lone \r (overwrite), \n for everything else. + const suffix = match[0] === "\r" ? "\r" : "\n"; + controller.enqueue(line + suffix); + lastIndex = re.lastIndex; + } + this.chunks = this.chunks.substring(lastIndex); + } + flush(controller) { + // When the stream is closed, flush any remaining chunks out. + controller.enqueue(this.chunks); } - this.chunks = this.chunks.substring(lastIndex); - } - - flush(controller) { - // When the stream is closed, flush any remaining chunks out. - controller.enqueue(this.chunks); - } } diff --git a/js/util/timestamp-transformer.js b/js/util/timestamp-transformer.js index d4691e39..e9e78cbe 100644 --- a/js/util/timestamp-transformer.js +++ b/js/util/timestamp-transformer.js @@ -1,6 +1,6 @@ // Matches lines that already carry a wall-clock or tick timestamp so we don't -// add a redundant one. Does NOT match bare log-level prefixes like ESPHome's -// [I][tag:line]: — those have no time information. +// add a redundant one. Intentionally does NOT match bare log-level prefixes +// like ESPHome's [I][tag:line]: — those have no time information. // // Covered formats: // (123456) FreeRTOS ms-tick e.g. "(12345) " @@ -8,39 +8,32 @@ // [HH:MM:SS.mmm] wall-clock bracket with millis // I (1234) tag: ESP-IDF log level + tick e.g. "I (1234) wifi: ..." // HH:MM:SS.mmm plain wall-clock -const DEVICE_TIMESTAMP_RE = - /^\s*(?:\(\d+\)\s|\[\d{2}:\d{2}:\d{2}(?:\.\d+)?\]|[DIWEACV] \(\d+\) \w|(?:\d{2}:){2}\d{2}\.\d)/; - +const DEVICE_TIMESTAMP_RE = /^\s*(?:\(\d+\)\s|\[\d{2}:\d{2}:\d{2}(?:\.\d+)?\]|[DIWEACV] \(\d+\) \w|(?:\d{2}:){2}\d{2}\.\d)/; export class TimestampTransformer { - constructor() { - this.deviceHasTimestamps = false; - } - - transform(chunk, controller) { - // Pass through pure newline / empty sentinel unchanged so that - // carriage-return overwrite logic in console-color.js still works. - if (chunk === "" || chunk === "\n" || chunk === "\r") { - controller.enqueue(chunk); - return; + constructor() { + this.deviceHasTimestamps = false; } - - if (!this.deviceHasTimestamps && DEVICE_TIMESTAMP_RE.test(chunk)) { - this.deviceHasTimestamps = true; + transform(chunk, controller) { + // Pass through pure newline / empty sentinel unchanged so that + // carriage-return overwrite logic in console-color.ts still works. + if (chunk === "" || chunk === "\n" || chunk === "\r") { + controller.enqueue(chunk); + return; + } + if (!this.deviceHasTimestamps && DEVICE_TIMESTAMP_RE.test(chunk)) { + this.deviceHasTimestamps = true; + } + if (this.deviceHasTimestamps) { + controller.enqueue(chunk); + return; + } + const date = new Date(); + const h = date.getHours().toString().padStart(2, "0"); + const m = date.getMinutes().toString().padStart(2, "0"); + const s = date.getSeconds().toString().padStart(2, "0"); + controller.enqueue(`[${h}:${m}:${s}] ${chunk}`); } - - if (this.deviceHasTimestamps) { - controller.enqueue(chunk); - return; + reset() { + this.deviceHasTimestamps = false; } - - const date = new Date(); - const h = date.getHours().toString().padStart(2, "0"); - const m = date.getMinutes().toString().padStart(2, "0"); - const s = date.getSeconds().toString().padStart(2, "0"); - controller.enqueue(`[${h}:${m}:${s}] ${chunk}`); - } - - reset() { - this.deviceHasTimestamps = false; - } } diff --git a/package.json b/package.json index a95d0cba..7ff09c5f 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,15 @@ "node": ">=25.4.0" }, "scripts": { - "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('js/modules',{recursive:true,force:true}); fs.mkdirSync('js/modules',{recursive:true});\"", - "build": "npm run prebuild && node update-sw-version.cjs && tsc --skipLibCheck && rollup -c && node -e \"const fs=require('fs'); fs.readdirSync('dist/web').filter(f=>f.endsWith('.js')).forEach(f=>fs.copyFileSync('dist/web/'+f,'js/modules/'+f)); fs.renameSync('js/modules/index.js','js/modules/esptool.js');\" && node fix-cli-imports.cjs", + "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('js/modules',{recursive:true,force:true}); fs.rmSync('js/util',{recursive:true,force:true}); fs.mkdirSync('js/modules',{recursive:true}); fs.mkdirSync('js/util',{recursive:true});\"", + "build": "npm run prebuild && node update-sw-version.cjs && tsc --skipLibCheck && tsc -p tsconfig.util.json && rollup -c && node -e \"const fs=require('fs'); fs.readdirSync('dist/web').filter(f=>f.endsWith('.js')).forEach(f=>fs.copyFileSync('dist/web/'+f,'js/modules/'+f)); fs.renameSync('js/modules/index.js','js/modules/esptool.js');\" && node fix-cli-imports.cjs", "build:binary": "node build-single-binary.cjs", "build:cli-electron": "node build-electron-cli.cjs", "format": "npm exec -- prettier --write src", "dev:clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "dev:tsc-once": "tsc", "dev:tsc": "tsc --watch", + "dev:tsc-util": "tsc -p tsconfig.util.json --watch", "dev:rollup": "rollup -c --watch", "dev:serve": "serve -p 5004", "develop": "npm run dev:clean && npm run dev:tsc-once && npm-run-all --parallel dev:serve dev:tsc dev:rollup", diff --git a/tsconfig.util.json b/tsconfig.util.json new file mode 100644 index 00000000..17172be9 --- /dev/null +++ b/tsconfig.util.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "js/util", + "declaration": false, + "removeComments": false + }, + "include": ["src/util/**/*.ts"] +}