From 21bf62af951156db92ca2455d80e106c301cd566 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Thu, 23 Apr 2026 09:33:35 -0700 Subject: [PATCH 01/16] Fix BLE disconnect crash and event listener leaks - Guard connectionStep() in updateConnected() behind connectDialog.isOpen() to prevent 'Modal has not been opened yet' crash when BLE disconnects during file write operations (fixes #377) - Store bound event handlers in constructor and reuse them so removeEventListener actually removes the old listener - Fix advertisement listener cleanup in connectToBluetoothDevice() --- js/workflows/ble.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index c2ad323..91622ca 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -34,6 +34,10 @@ class BLEWorkflow extends Workflow { {reconnect: false, request: true}, {reconnect: true, request: true}, ]; + + // Store bound event handlers so they can be properly removed + this._boundOnDisconnected = this.onDisconnected.bind(this); + this._boundOnSerialReceive = this.onSerialReceive.bind(this); } // This is called when a user clicks the main disconnect button @@ -102,8 +106,8 @@ class BLEWorkflow extends Workflow { this.rxCharacteristic = await this.serialService.getCharacteristic(bleNusCharRXUUID); // Remove any existing event listeners to prevent multiple reads - this.txCharacteristic.removeEventListener('characteristicvaluechanged', this.onSerialReceive.bind(this)); - this.txCharacteristic.addEventListener('characteristicvaluechanged', this.onSerialReceive.bind(this)); + this.txCharacteristic.removeEventListener('characteristicvaluechanged', this._boundOnSerialReceive); + this.txCharacteristic.addEventListener('characteristicvaluechanged', this._boundOnSerialReceive); await this.txCharacteristic.startNotifications(); return true; } catch (e) { @@ -144,7 +148,12 @@ class BLEWorkflow extends Workflow { async connectToBluetoothDevice(device) { const abortController = new AbortController(); - async function onAdvertisementReceived(event) { + // Remove previous advertisement listener if one was stored + if (this._boundOnAdvertisementReceived) { + device.removeEventListener('advertisementreceived', this._boundOnAdvertisementReceived); + } + + this._boundOnAdvertisementReceived = (async function onAdvertisementReceived(event) { console.log('> Received advertisement from "' + device.name + '"...'); // Stop watching advertisements to conserve battery life. abortController.abort(); @@ -164,10 +173,9 @@ class BLEWorkflow extends Workflow { } else { console.log('Unable to connect to bluetooth device "' + device.name + '.'); } - } + }).bind(this); - device.removeEventListener('advertisementreceived', onAdvertisementReceived.bind(this)); - device.addEventListener('advertisementreceived', onAdvertisementReceived.bind(this)); + device.addEventListener('advertisementreceived', this._boundOnAdvertisementReceived); this.debugLog("Attempting to connect to " + device.name + "..."); try { @@ -199,8 +207,8 @@ class BLEWorkflow extends Workflow { async switchToDevice(device) { this.bleDevice = device; - this.bleDevice.removeEventListener("gattserverdisconnected", this.onDisconnected.bind(this)); - this.bleDevice.addEventListener("gattserverdisconnected", this.onDisconnected.bind(this)); + this.bleDevice.removeEventListener("gattserverdisconnected", this._boundOnDisconnected); + this.bleDevice.addEventListener("gattserverdisconnected", this._boundOnDisconnected); console.log("connected", this.bleServer); try { @@ -267,7 +275,9 @@ class BLEWorkflow extends Workflow { updateConnected(connectionState) { super.updateConnected(connectionState); - this.connectionStep(2); + if (this.connectDialog && this.connectDialog.isOpen()) { + this.connectionStep(2); + } } async available() { From d3bb94f31a2635368c63ff7b4b3743b0ecf0c12f Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 29 Apr 2026 15:03:39 -0700 Subject: [PATCH 02/16] Add CircuitPython module highlighting overlay CodeMirror 6 dropped the simple extra_keywords mechanism that CM5 had, so instead of forking @codemirror/lang-python we layer a small ViewPlugin on top of the existing Python syntax tree. It walks the visible tree, finds VariableName nodes whose text matches a known CircuitPython core module or common Adafruit library, and tags them with a tok-cp-module class so the theme can color them distinctly. This avoids: - Forking lang-python and tracking upstream changes - Re-tokenizing strings/comments (we only mark identifier nodes) - Adding any per-keystroke parsing cost (decoration set is rebuilt only on doc/viewport/tree changes) Adds a magenta accent (#FF79C6) for .tok-cp-module in the editor theme so CircuitPython modules pop out from regular Python identifiers. Closes #363 --- js/common/circuitpython_highlight.js | 200 +++++++++++++++++++++++++++ js/script.js | 2 + sass/layout/_themes.scss | 7 + 3 files changed, 209 insertions(+) create mode 100644 js/common/circuitpython_highlight.js diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js new file mode 100644 index 0000000..60ca642 --- /dev/null +++ b/js/common/circuitpython_highlight.js @@ -0,0 +1,200 @@ +// CircuitPython syntax highlighting overlay for CodeMirror 6. +// +// CodeMirror 6 dropped the simple `extra_keywords` mechanism that CM5 had, +// so instead of forking @codemirror/lang-python we layer extra decorations +// on top of the existing Python syntax tree. We walk the tree inside the +// viewport, find identifier nodes whose text matches a CircuitPython name, +// and tag them with a CSS class that the theme can style. + +import { ViewPlugin, Decoration } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; + +// CircuitPython core/built-in modules. These are the names that appear in +// `import foo` / `from foo import ...` inside CircuitPython code and are +// the most consistent thing we can match without parsing semantics. +// +// Keep this list focused on modules that ship with CircuitPython (or are +// extremely common Adafruit libraries). Anything we add here will be +// highlighted whenever the identifier appears, so we want low false-positive +// risk against regular Python code. +const CIRCUITPYTHON_MODULES = new Set([ + // Core built-in CircuitPython modules + "adafruit_bus_device", + "aesio", + "alarm", + "analogbufio", + "analogio", + "atexit", + "audiobusio", + "audiocore", + "audioio", + "audiomixer", + "audiomp3", + "audiopwmio", + "bitbangio", + "bitmapfilter", + "bitmaptools", + "bitops", + "board", + "busdisplay", + "busio", + "canio", + "codeop", + "countio", + "digitalio", + "displayio", + "dotclockframebuffer", + "dualbank", + "epaperdisplay", + "espidf", + "espnow", + "espulp", + "floppyio", + "fontio", + "framebufferio", + "frequencyio", + "getpass", + "gifio", + "i2cdisplaybus", + "i2cperipheral", + "i2ctarget", + "imagecapture", + "is31fl3741", + "jpegio", + "keypad", + "keypad_demux", + "lsm6ds", + "max3421e", + "mdns", + "memorymap", + "memorymonitor", + "microcontroller", + "msgpack", + "neopixel_write", + "nvm", + "onewireio", + "paralleldisplay", + "paralleldisplaybus", + "picodvi", + "pulseio", + "pwmio", + "qrio", + "rainbowio", + "rgbmatrix", + "rotaryio", + "rp2pio", + "rtc", + "sdcardio", + "sdioio", + "sharpdisplay", + "socketpool", + "spitarget", + "ssl", + "storage", + "supervisor", + "synthio", + "terminalio", + "tilepalettemapper", + "touchio", + "traceback", + "uheap", + "ulab", + "usb", + "usb_cdc", + "usb_hid", + "usb_host", + "usb_midi", + "usb_video", + "ustack", + "vectorio", + "warnings", + "watchdog", + "wifi", + "zlib", + + // Very common Adafruit/CircuitPython community libraries that users see + // imported all the time. Underscore-prefixed Adafruit names are already + // distinctive enough that false positives are essentially nil. + "adafruit_ble", + "adafruit_connection_manager", + "adafruit_datetime", + "adafruit_display_shapes", + "adafruit_display_text", + "adafruit_displayio_layout", + "adafruit_displayio_sh1106", + "adafruit_displayio_ssd1306", + "adafruit_dotstar", + "adafruit_fakerequests", + "adafruit_framebuf", + "adafruit_hid", + "adafruit_httpserver", + "adafruit_imageload", + "adafruit_io", + "adafruit_logging", + "adafruit_matrixportal", + "adafruit_minimqtt", + "adafruit_motor", + "adafruit_ntp", + "adafruit_pixelbuf", + "adafruit_pixelmap", + "adafruit_portalbase", + "adafruit_register", + "adafruit_requests", + "adafruit_sdcard", + "adafruit_seesaw", + "adafruit_simplemath", + "adafruit_ticks", + "neopixel", + "simpleio", +]); + +const moduleMark = Decoration.mark({ class: "tok-cp-module" }); + +// Build the decoration set for the part of the document currently visible. +// Walking only visible ranges keeps this cheap on big files. +function buildDecorations(view) { + const builder = []; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + // We care about identifier-like leaves only. Lezer Python emits + // `VariableName` for bare identifiers (including module names + // in `import foo` and `from foo import ...`). Module names + // accessed as attributes (e.g. `adafruit_io.MQTT`) come in as + // `VariableName` for the leftmost part, then `PropertyName` + // children — we only mark the root reference. + if (node.name !== "VariableName") return; + const text = view.state.doc.sliceString(node.from, node.to); + if (CIRCUITPYTHON_MODULES.has(text)) { + builder.push(moduleMark.range(node.from, node.to)); + } + }, + }); + } + // Decoration ranges must be sorted by `from`, which they already are + // because we iterate the tree in document order. + return Decoration.set(builder); +} + +// ViewPlugin keeps decorations in sync with viewport / document changes. +export const circuitpythonHighlight = ViewPlugin.fromClass( + class { + constructor(view) { + this.decorations = buildDecorations(view); + } + update(update) { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.decorations = buildDecorations(update.view); + } + } + }, + { + decorations: (v) => v.decorations, + }, +); diff --git a/js/script.js b/js/script.js index db7cc75..7a2d501 100644 --- a/js/script.js +++ b/js/script.js @@ -5,6 +5,7 @@ import { indentWithTab } from "@codemirror/commands" import { python } from "@codemirror/lang-python"; import { syntaxHighlighting, indentUnit } from "@codemirror/language"; import { classHighlighter } from "@lezer/highlight"; +import { circuitpythonHighlight } from "./common/circuitpython_highlight.js"; import { getFileIcon } from "./common/file_dialog.js"; import { Terminal } from '@xterm/xterm'; @@ -405,6 +406,7 @@ const editorExtensions = [ python(), editorTheme, syntaxHighlighting(classHighlighter), + circuitpythonHighlight, EditorView.updateListener.of(onTextChange) ]; diff --git a/sass/layout/_themes.scss b/sass/layout/_themes.scss index 500f1fd..770f991 100644 --- a/sass/layout/_themes.scss +++ b/sass/layout/_themes.scss @@ -174,4 +174,11 @@ .tok-bool { color: #E06C75; } + + // CircuitPython-specific module names (overlay added by + // js/common/circuitpython_highlight.js). Distinct from regular + // variableName so users can spot CircuitPython modules at a glance. + .tok-cp-module { + color: #FF79C6; + } } From e00c925c55ce8d4098a6864709269005c81e4728 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 29 Apr 2026 15:20:28 -0700 Subject: [PATCH 03/16] Match adafruit_ libraries by prefix instead of explicit list Drops the hand-curated list of adafruit_* community libraries in favour of a startsWith("adafruit_") check. New Adafruit CircuitPython libraries will be highlighted automatically as they ship, with no maintenance needed in this file. The core CircuitPython module set still uses an explicit allowlist because those names don't share a distinguishing prefix. --- js/common/circuitpython_highlight.js | 66 ++++++++++------------------ 1 file changed, 23 insertions(+), 43 deletions(-) diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js index 60ca642..812b172 100644 --- a/js/common/circuitpython_highlight.js +++ b/js/common/circuitpython_highlight.js @@ -9,17 +9,15 @@ import { ViewPlugin, Decoration } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; -// CircuitPython core/built-in modules. These are the names that appear in -// `import foo` / `from foo import ...` inside CircuitPython code and are -// the most consistent thing we can match without parsing semantics. +// Core/built-in CircuitPython modules. These are the identifiers that show +// up in `import foo` / `from foo import ...` inside CircuitPython code. // -// Keep this list focused on modules that ship with CircuitPython (or are -// extremely common Adafruit libraries). Anything we add here will be -// highlighted whenever the identifier appears, so we want low false-positive -// risk against regular Python code. -const CIRCUITPYTHON_MODULES = new Set([ - // Core built-in CircuitPython modules - "adafruit_bus_device", +// Anything in this set is highlighted whenever it appears as a bare +// identifier, so it should stay focused on names that ship with +// CircuitPython itself. Third-party Adafruit libraries are matched by +// the `adafruit_` prefix below instead of being listed individually, +// which avoids list maintenance every time a new library lands on PyPI. +const CIRCUITPYTHON_CORE_MODULES = new Set([ "aesio", "alarm", "analogbufio", @@ -112,42 +110,24 @@ const CIRCUITPYTHON_MODULES = new Set([ "wifi", "zlib", - // Very common Adafruit/CircuitPython community libraries that users see - // imported all the time. Underscore-prefixed Adafruit names are already - // distinctive enough that false positives are essentially nil. - "adafruit_ble", - "adafruit_connection_manager", - "adafruit_datetime", - "adafruit_display_shapes", - "adafruit_display_text", - "adafruit_displayio_layout", - "adafruit_displayio_sh1106", - "adafruit_displayio_ssd1306", - "adafruit_dotstar", - "adafruit_fakerequests", - "adafruit_framebuf", - "adafruit_hid", - "adafruit_httpserver", - "adafruit_imageload", - "adafruit_io", - "adafruit_logging", - "adafruit_matrixportal", - "adafruit_minimqtt", - "adafruit_motor", - "adafruit_ntp", - "adafruit_pixelbuf", - "adafruit_pixelmap", - "adafruit_portalbase", - "adafruit_register", - "adafruit_requests", - "adafruit_sdcard", - "adafruit_seesaw", - "adafruit_simplemath", - "adafruit_ticks", + // Bare-named community modules without the `adafruit_` prefix that are + // common enough to be worth recognising explicitly. "neopixel", "simpleio", ]); +// Returns true when `name` is a CircuitPython module worth highlighting. +// Wildcard-matches anything starting with `adafruit_` so new libraries +// (e.g. `adafruit_foo_bar` shipped next month) light up automatically +// without touching this file. +function isCircuitPythonModule(name) { + if (CIRCUITPYTHON_CORE_MODULES.has(name)) return true; + if (name.startsWith("adafruit_") && name.length > "adafruit_".length) { + return true; + } + return false; +} + const moduleMark = Decoration.mark({ class: "tok-cp-module" }); // Build the decoration set for the part of the document currently visible. @@ -167,7 +147,7 @@ function buildDecorations(view) { // children — we only mark the root reference. if (node.name !== "VariableName") return; const text = view.state.doc.sliceString(node.from, node.to); - if (CIRCUITPYTHON_MODULES.has(text)) { + if (isCircuitPythonModule(text)) { builder.push(moduleMark.range(node.from, node.to)); } }, From 53505988dc6c7b386dd78566047d6070926d973d Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 29 Apr 2026 15:30:24 -0700 Subject: [PATCH 04/16] Also wildcard-match circuitpython_ libraries The CircuitPython Community Bundle ships ~10 libraries with a `circuitpython_` prefix (circuitpython_csv, circuitpython_schedule, circuitpython_functools, etc.). Same prefix-match approach as adafruit_*: new community libraries are highlighted automatically. --- js/common/circuitpython_highlight.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js index 812b172..30da5a0 100644 --- a/js/common/circuitpython_highlight.js +++ b/js/common/circuitpython_highlight.js @@ -117,14 +117,22 @@ const CIRCUITPYTHON_CORE_MODULES = new Set([ ]); // Returns true when `name` is a CircuitPython module worth highlighting. -// Wildcard-matches anything starting with `adafruit_` so new libraries -// (e.g. `adafruit_foo_bar` shipped next month) light up automatically -// without touching this file. +// Wildcard-matches anything starting with `adafruit_` (Adafruit-maintained +// libraries) or `circuitpython_` (community bundle libraries) so new +// libraries light up automatically without touching this file. Both +// prefixes are distinctive enough that false positives against ordinary +// Python code are essentially nil. function isCircuitPythonModule(name) { if (CIRCUITPYTHON_CORE_MODULES.has(name)) return true; if (name.startsWith("adafruit_") && name.length > "adafruit_".length) { return true; } + if ( + name.startsWith("circuitpython_") && + name.length > "circuitpython_".length + ) { + return true; + } return false; } From c65d6000447a78b596744c8f485b94f6eda477d8 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 29 Apr 2026 15:34:07 -0700 Subject: [PATCH 05/16] Sync core module list with upstream shared-bindings Refreshed CIRCUITPYTHON_CORE_MODULES against the current contents of adafruit/circuitpython:shared-bindings/. Added 15 modules that have landed since the initial list was written: audiodelays, audiofilters, audiofreeverb, audiospeed, aurora_epaper, camera, fourwire, gnss, i2cioexpander, lvfontio, mcp4822, mipidsi, ps2io, qspibus, rclcpy. Removed the deprecated paralleldisplay alias (paralleldisplaybus is the modern name and is already listed) and the stray lsm6ds entry, which isn't a shared binding and isn't in the community bundle either. Standard-Python modules that CircuitPython also exposes (math, os, time, random, struct, hashlib, ipaddress, locale, __future__) are intentionally left out so we don't recolour those names in plain Python code. Underscore-prefixed internal bindings (_bleio etc.) are also omitted; users reach for the adafruit_ wrappers, which the prefix wildcard already matches. --- js/common/circuitpython_highlight.js | 44 +++++++++++++++++++++------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js index 30da5a0..aa4b722 100644 --- a/js/common/circuitpython_highlight.js +++ b/js/common/circuitpython_highlight.js @@ -12,11 +12,22 @@ import { syntaxTree } from "@codemirror/language"; // Core/built-in CircuitPython modules. These are the identifiers that show // up in `import foo` / `from foo import ...` inside CircuitPython code. // -// Anything in this set is highlighted whenever it appears as a bare -// identifier, so it should stay focused on names that ship with -// CircuitPython itself. Third-party Adafruit libraries are matched by -// the `adafruit_` prefix below instead of being listed individually, -// which avoids list maintenance every time a new library lands on PyPI. +// Sourced from the upstream `shared-bindings/` directory in +// adafruit/circuitpython, plus port-specific bindings that are widely used +// (espidf/espnow/espulp on ESP, picodvi/rp2pio on RP2). Standard-Python +// modules that CircuitPython also exposes (math, os, time, random, struct, +// hashlib, ipaddress, locale, __future__) are intentionally omitted — they +// aren't CircuitPython-specific and highlighting them as such would be +// noisy in regular Python code shown in the editor. +// +// Underscore-prefixed internal bindings (_bleio, _eve, _pew, _pixelmap, +// _stage) are also omitted; users access those via the corresponding +// `adafruit_*` libraries which are matched by the prefix wildcard below. +// +// Third-party Adafruit libraries are matched by the `adafruit_` prefix +// instead of being listed individually, and community-bundle libraries by +// the `circuitpython_` prefix, so this set only needs updating when a new +// shared binding lands upstream. const CIRCUITPYTHON_CORE_MODULES = new Set([ "aesio", "alarm", @@ -25,10 +36,15 @@ const CIRCUITPYTHON_CORE_MODULES = new Set([ "atexit", "audiobusio", "audiocore", + "audiodelays", + "audiofilters", + "audiofreeverb", "audioio", "audiomixer", "audiomp3", "audiopwmio", + "audiospeed", + "aurora_epaper", "bitbangio", "bitmapfilter", "bitmaptools", @@ -36,6 +52,7 @@ const CIRCUITPYTHON_CORE_MODULES = new Set([ "board", "busdisplay", "busio", + "camera", "canio", "codeop", "countio", @@ -49,35 +66,41 @@ const CIRCUITPYTHON_CORE_MODULES = new Set([ "espulp", "floppyio", "fontio", + "fourwire", "framebufferio", "frequencyio", "getpass", "gifio", + "gnss", "i2cdisplaybus", - "i2cperipheral", + "i2cioexpander", "i2ctarget", "imagecapture", "is31fl3741", "jpegio", "keypad", "keypad_demux", - "lsm6ds", + "lvfontio", "max3421e", + "mcp4822", "mdns", "memorymap", "memorymonitor", "microcontroller", + "mipidsi", "msgpack", "neopixel_write", "nvm", "onewireio", - "paralleldisplay", "paralleldisplaybus", "picodvi", + "ps2io", "pulseio", "pwmio", "qrio", + "qspibus", "rainbowio", + "rclcpy", "rgbmatrix", "rotaryio", "rp2pio", @@ -110,8 +133,9 @@ const CIRCUITPYTHON_CORE_MODULES = new Set([ "wifi", "zlib", - // Bare-named community modules without the `adafruit_` prefix that are - // common enough to be worth recognising explicitly. + // Bare-named community modules without the `adafruit_` or + // `circuitpython_` prefix that are common enough to recognise + // explicitly. "neopixel", "simpleio", ]); From b56f5190dc607e8278ee2d24966be3dee39eccd4 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 29 Apr 2026 15:47:51 -0700 Subject: [PATCH 06/16] Clarify neopixel/simpleio comment These are early Adafruit-maintained libraries that predate the adafruit_ naming convention, not community-bundle modules. --- js/common/circuitpython_highlight.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js index aa4b722..18184bf 100644 --- a/js/common/circuitpython_highlight.js +++ b/js/common/circuitpython_highlight.js @@ -133,9 +133,9 @@ const CIRCUITPYTHON_CORE_MODULES = new Set([ "wifi", "zlib", - // Bare-named community modules without the `adafruit_` or - // `circuitpython_` prefix that are common enough to recognise - // explicitly. + // Early Adafruit-maintained libraries that predate the `adafruit_` + // naming convention and shipped without a prefix. Listed explicitly + // because the prefix wildcard below can't catch them. "neopixel", "simpleio", ]); From c57e60cd5594fc46f5ff0929cce26e2dbccb7b77 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 29 Apr 2026 15:56:40 -0700 Subject: [PATCH 07/16] Use Prec.highest so cp-module color wins over variableName When an identifier matches both classHighlighter's VariableName tag and our CircuitPython overlay, CodeMirror renders nested mark spans. The inner span (closer to the text) is what the browser uses for `color`, and it's chosen by decoration precedence \u2014 so without an explicit precedence wrapper, the syntax highlighter's blue stayed on top and `neopixel` rendered the same color as any other variable. Wrapping the ViewPlugin with Prec.highest puts the tok-cp-module span inside the tok-variableName span, letting the pink override the blue without touching the CSS or using !important. --- js/common/circuitpython_highlight.js | 11 ++++++++++- sass/layout/_themes.scss | 5 +++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/js/common/circuitpython_highlight.js b/js/common/circuitpython_highlight.js index 18184bf..8b51313 100644 --- a/js/common/circuitpython_highlight.js +++ b/js/common/circuitpython_highlight.js @@ -8,6 +8,7 @@ import { ViewPlugin, Decoration } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; +import { Prec } from "@codemirror/state"; // Core/built-in CircuitPython modules. These are the identifiers that show // up in `import foo` / `from foo import ...` inside CircuitPython code. @@ -191,7 +192,7 @@ function buildDecorations(view) { } // ViewPlugin keeps decorations in sync with viewport / document changes. -export const circuitpythonHighlight = ViewPlugin.fromClass( +const circuitpythonHighlightPlugin = ViewPlugin.fromClass( class { constructor(view) { this.decorations = buildDecorations(view); @@ -210,3 +211,11 @@ export const circuitpythonHighlight = ViewPlugin.fromClass( decorations: (v) => v.decorations, }, ); + +// Wrap the plugin with Prec.highest so its decoration nests inside the +// classHighlighter span. CodeMirror renders overlapping mark decorations +// as nested spans where higher-precedence decorations end up closer to +// the text. The inner span’s `color` is what the user sees, so making +// `tok-cp-module` the inner class is what lets our pink override the +// underlying `tok-variableName` blue without resorting to !important. +export const circuitpythonHighlight = Prec.highest(circuitpythonHighlightPlugin); diff --git a/sass/layout/_themes.scss b/sass/layout/_themes.scss index 770f991..b4479bf 100644 --- a/sass/layout/_themes.scss +++ b/sass/layout/_themes.scss @@ -176,8 +176,9 @@ } // CircuitPython-specific module names (overlay added by - // js/common/circuitpython_highlight.js). Distinct from regular - // variableName so users can spot CircuitPython modules at a glance. + // js/common/circuitpython_highlight.js). The overlay extension is + // wrapped with Prec.highest so its decoration nests inside the + // classHighlighter span, letting this color win without !important. .tok-cp-module { color: #FF79C6; } From 31e840c77c561309812e044412ff1e0471fdc946 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 5 May 2026 12:23:04 -0700 Subject: [PATCH 08/16] Support Arduino-style labeled multi-value plotter data Match the Arduino IDE Serial Plotter input format so sketches that print 'label:value' pairs separated by tabs (or commas/spaces) are plotted with the labels shown in the legend instead of confusing the parser. Each labeled series is tracked by name across frames so the order can vary, and a per-frame padding pass keeps the x-axis aligned when not every series reports on every line. Existing tuple, list, and plain CSV formats continue to work unchanged. The default color palette is also expanded from 3 colors to 16 so plots with many series remain legible. Fixes #457 --- js/common/plotter.js | 157 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 134 insertions(+), 23 deletions(-) diff --git a/js/common/plotter.js b/js/common/plotter.js index f0e6303..6b944d5 100644 --- a/js/common/plotter.js +++ b/js/common/plotter.js @@ -3,7 +3,27 @@ import Chart from "chart.js/auto"; let textLineBuffer = ""; let textLine; -let defaultColors = ['#8888ff', '#ff8888', '#88ff88']; +// Expanded color palette so multi-series plots aren't limited to 3 colors +// before falling back to black. Roughly follows distinct, easy-to-tell-apart +// hues; reused round-robin if a sketch sends more series than colors. +let defaultColors = [ + '#1f77b4', // blue + '#ff7f0e', // orange + '#2ca02c', // green + '#d62728', // red + '#9467bd', // purple + '#8c564b', // brown + '#e377c2', // pink + '#7f7f7f', // gray + '#bcbd22', // olive + '#17becf', // cyan + '#aec7e8', // light blue + '#ffbb78', // light orange + '#98df8a', // light green + '#ff9896', // light red + '#c5b0d5', // light purple + '#c49c94', // light brown +]; /** * @name LineBreakTransformer @@ -26,6 +46,65 @@ class LineBreakTransformer { let lineTransformer = new LineBreakTransformer() +/** + * Parse an Arduino Serial Plotter style line into an array of + * { label, value } pairs. + * + * The Arduino Serial Plotter accepts values separated by commas, tabs, or + * spaces, and each value may be prefixed with a label using "label:value". + * Labels are optional; positional values without a label fall back to their + * index. Examples that should all parse: + * "1,2,3" + * "1\t2\t3" + * "Temp:23.4,Hum:55.1" + * "405nm_F1:123\t425nm_F2:456\tClear:789" + * + * @param {string} textLine + * @returns {Array<{label: (string|null), value: number}>} + */ +function parseLabeledValues(textLine) { + // Split on commas, tabs, or runs of spaces. Arduino's plotter is lenient + // about which of these the sketch picks. + const tokens = textLine.split(/[,\t]|\s+/).filter(t => t.length > 0); + const parsed = []; + for (const token of tokens) { + const colonIdx = token.indexOf(":"); + let label = null; + let valueText = token; + if (colonIdx > 0) { + label = token.substring(0, colonIdx).trim(); + valueText = token.substring(colonIdx + 1).trim(); + } + const value = parseFloat(valueText); + parsed.push({ label, value }); + } + return parsed; +} + +/** + * Find the dataset index that matches the incoming sample. + * + * If the sample carries a label, prefer matching against an existing dataset + * with the same label so labeled series stay on the same line across frames + * (and across reordering). Without a label, fall back to positional index so + * legacy unlabeled CSV / list / tuple behaviour is unchanged. + * + * @param {object} chartObj + * @param {{label: (string|null), value: number}} sample + * @param {number} positionalIndex + * @returns {number} + */ +function resolveDatasetIndex(chartObj, sample, positionalIndex) { + if (sample.label) { + for (let i = 0; i < chartObj.data.datasets.length; i++) { + if (chartObj.data.datasets[i].label === sample.label) { + return i; + } + } + } + return positionalIndex; +} + export function plotValues(chartObj, serialMessage, bufferSize) { /* Given a string serialMessage, parse it into the plottable value(s) that @@ -44,7 +123,7 @@ export function plotValues(chartObj, serialMessage, bufferSize) { continue; } - let valuesToPlot; + let samples; // handle possible tuple in textLine if (textLine.startsWith("(") && textLine.endsWith(")")) { @@ -54,24 +133,25 @@ export function plotValues(chartObj, serialMessage, bufferSize) { textValues = textValues.substring(0, textValues.length - 1); } textLine = "[" + textValues + "]"; - console.log("after tuple conversion: " + textLine); } // handle possible list in textLine if (textLine.startsWith("[") && textLine.endsWith("]")) { - valuesToPlot = JSON.parse(textLine); - for (let i = 0; i < valuesToPlot.length; i++) { - valuesToPlot[i] = parseFloat(valuesToPlot[i]) - } - - } else { // handle possible CSV in textLine - valuesToPlot = textLine.split(",") - for (let i = 0; i < valuesToPlot.length; i++) { - valuesToPlot[i] = parseFloat(valuesToPlot[i]) + let valuesToPlot; + try { + valuesToPlot = JSON.parse(textLine); + } catch (e) { + // Not a valid JSON list; skip this line. + continue; } + samples = valuesToPlot.map(v => ({ label: null, value: parseFloat(v) })); + } else { + // Handle CSV / tab-separated / labeled values, matching the + // Arduino IDE Serial Plotter format. See parseLabeledValues. + samples = parseLabeledValues(textLine); } - if (valuesToPlot === undefined || valuesToPlot.length === 0) { + if (samples === undefined || samples.length === 0) { continue; } @@ -86,23 +166,38 @@ export function plotValues(chartObj, serialMessage, bufferSize) { } chartObj.data.labels.push(""); - for (let i = 0; i < valuesToPlot.length; i++) { - if (isNaN(valuesToPlot[i])) { + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + if (isNaN(sample.value)) { continue; } - if (i > chartObj.data.datasets.length - 1) { - let curColor = '#000000'; - if (i < defaultColors.length) { - curColor = defaultColors[i]; - } + const datasetIndex = resolveDatasetIndex(chartObj, sample, i); + if (datasetIndex > chartObj.data.datasets.length - 1) { + const colorIdx = chartObj.data.datasets.length % defaultColors.length; + const curColor = defaultColors[colorIdx]; chartObj.data.datasets.push({ - label: i.toString(), + label: sample.label !== null ? sample.label : datasetIndex.toString(), data: [], borderColor: curColor, backgroundColor: curColor }); + } else if (sample.label && chartObj.data.datasets[datasetIndex].label !== sample.label) { + // Upgrade a previously-unlabeled positional dataset to use + // the label the sketch is now sending. This lets a sketch + // that starts unlabeled and switches to labels stay on the + // same series rather than spawning duplicates. + chartObj.data.datasets[datasetIndex].label = sample.label; + } + chartObj.data.datasets[datasetIndex].data.push(sample.value); + } + + // Pad any datasets that didn't receive a sample on this frame so + // x-axis alignment stays consistent across labeled series. + for (let i = 0; i < chartObj.data.datasets.length; i++) { + const ds = chartObj.data.datasets[i]; + while (ds.data.length < chartObj.data.labels.length) { + ds.data.push(null); } - chartObj.data.datasets[i].data.push(valuesToPlot[i]); } updatePlotterScales(chartObj); @@ -121,7 +216,12 @@ function updatePlotterScales(chartObj) { */ let allData = [] for (let i = 0; i < chartObj.data.datasets.length; i++) { - allData = allData.concat(chartObj.data.datasets[i].data) + // Filter out nulls used for x-axis padding so they don't break min/max. + const cleaned = chartObj.data.datasets[i].data.filter(v => v !== null && !isNaN(v)); + allData = allData.concat(cleaned); + } + if (allData.length === 0) { + return; } chartObj.options.scales.y.min = Math.min(...allData) - 10 chartObj.options.scales.y.max = Math.max(...allData) + 10 @@ -142,6 +242,17 @@ export async function setupPlotterChart(workflow) { type: 'line', options: { animation: false, + plugins: { + legend: { + // Show the legend so labeled series are easy to + // identify, matching the Arduino IDE Serial Plotter. + display: true, + position: 'top', + labels: { + color: '#000000' + } + } + }, scales: { y: { min: -1, From 04e13f832e7d684cf66ebbf1cc64423fef60d9a3 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 5 May 2026 12:53:02 -0700 Subject: [PATCH 09/16] Make plotter colors and legend readable on both themes - Drop pale tints, gray, and brown from palette since they wash out on the dark theme's #777 plotter background. - Pull legend label color from --terminal-text-color so it contrasts with the active theme instead of being hardcoded black. --- js/common/plotter.js | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/js/common/plotter.js b/js/common/plotter.js index 6b944d5..3ca92fc 100644 --- a/js/common/plotter.js +++ b/js/common/plotter.js @@ -3,28 +3,39 @@ import Chart from "chart.js/auto"; let textLineBuffer = ""; let textLine; -// Expanded color palette so multi-series plots aren't limited to 3 colors -// before falling back to black. Roughly follows distinct, easy-to-tell-apart -// hues; reused round-robin if a sketch sends more series than colors. +// Plotter color palette. +// Plotter background is #777 on dark theme and #ccc on light theme, so colors +// must be readable against mid-gray. Pale tints, gray, and brown are dropped +// for that reason. Picked from the Okabe-Ito + tab10 mid-saturation set; +// reused round-robin if a sketch sends more series than colors. let defaultColors = [ '#1f77b4', // blue '#ff7f0e', // orange '#2ca02c', // green '#d62728', // red '#9467bd', // purple - '#8c564b', // brown '#e377c2', // pink - '#7f7f7f', // gray - '#bcbd22', // olive '#17becf', // cyan - '#aec7e8', // light blue - '#ffbb78', // light orange - '#98df8a', // light green - '#ff9896', // light red - '#c5b0d5', // light purple - '#c49c94', // light brown + '#bcbd22', // olive + '#e41a1c', // vivid red + '#377eb8', // steel blue + '#4daf4a', // leaf green + '#984ea3', // violet ]; +// Resolve a CSS custom property from :root (or body, for theme overrides) +// at chart-build time. Falls back to the supplied default if the variable +// is unset or empty. +function getCssVar(name, fallback) { + if (typeof window === 'undefined' || !window.getComputedStyle) { + return fallback; + } + const value = window.getComputedStyle(document.body || document.documentElement) + .getPropertyValue(name) + .trim(); + return value || fallback; +} + /** * @name LineBreakTransformer * Helper to parse the incoming string messages into lines. @@ -249,7 +260,10 @@ export async function setupPlotterChart(workflow) { display: true, position: 'top', labels: { - color: '#000000' + // Pick a color that contrasts with the current + // theme's plotter background (set via + // --terminal-text-color in sass/layout/_themes.scss). + color: getCssVar('--terminal-text-color', '#ddd') } } }, From 3bee588c9a0cee415d705bf83bc81f1eeb91aa34 Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 11:48:27 -0700 Subject: [PATCH 10/16] Suggest firmware updates in device info dialogs (refs #357) Adds js/common/firmware-check.js, a small helper that: - Parses CircuitPython version strings (stable + alpha/beta/rc pre-releases, including '-dirty' build suffixes) into a comparable structure. - Fetches the latest stable and the latest dev pre-release of CircuitPython from the adafruit/circuitpython GitHub releases API (cached per page load). - Computes which updates are worth surfacing for the device's current version, following the rules from #357: * If the user is on a development release, suggest a newer stable and/or a newer dev release when available. * If the user is on a stable release, suggest a newer stable and/or any newer dev release. Both DiscoveryModal and DeviceInfoModal now render those suggestions under the device info table with a link to the board's circuitpython.org download page (which only lists firmware that is actually built for that board). The container collapses when there is nothing to show, and any failure of the GitHub API call is non-fatal and silently leaves the dialog unchanged. --- index.html | 2 + js/common/dialogs.js | 9 ++ js/common/firmware-check.js | 200 ++++++++++++++++++++++++++++++++++++ sass/layout/_layout.scss | 37 +++++++ 4 files changed, 248 insertions(+) create mode 100644 js/common/firmware-check.js diff --git a/index.html b/index.html index e937f60..b5b12b7 100644 --- a/index.html +++ b/index.html @@ -391,6 +391,7 @@

Select USB Host Folder

+

More network devices

@@ -432,6 +433,7 @@

More network devices

diff --git a/js/common/dialogs.js b/js/common/dialogs.js index 4c0295c..42e2f7d 100644 --- a/js/common/dialogs.js +++ b/js/common/dialogs.js @@ -1,4 +1,5 @@ import {sleep, isIp, switchDevice} from './utilities.js'; +import {renderFirmwareSuggestions} from './firmware-check.js'; import * as focusTrap from 'focus-trap'; const SELECTOR_CLOSE_BUTTON = ".popup-modal__close"; @@ -357,6 +358,10 @@ class DiscoveryModal extends GenericModal { this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name; this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id; this._currentModal.querySelector("#uid").textContent = deviceInfo.uid; + const updateContainer = this._currentModal.querySelector("#firmware-update"); + if (updateContainer) { + renderFirmwareSuggestions(updateContainer, deviceInfo); + } } async _refreshDevices() { @@ -417,6 +422,10 @@ class DeviceInfoModal extends GenericModal { this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name; this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id; this._currentModal.querySelector("#uid").textContent = deviceInfo.uid; + const updateContainer = this._currentModal.querySelector("#firmware-update"); + if (updateContainer) { + renderFirmwareSuggestions(updateContainer, deviceInfo); + } } async open(workflow, documentState) { diff --git a/js/common/firmware-check.js b/js/common/firmware-check.js new file mode 100644 index 0000000..8b56f40 --- /dev/null +++ b/js/common/firmware-check.js @@ -0,0 +1,200 @@ +// Helpers for comparing CircuitPython firmware versions and surfacing +// "newer firmware available" suggestions in the UI. +// +// CircuitPython release tags follow a SemVer-ish form, e.g.: +// 9.2.8 stable +// 10.0.0-alpha.1 development pre-release +// 10.0.0-beta.0 +// 10.0.0-rc.0 +// +// We parse the version into a tuple we can compare numerically and use the +// GitHub releases API for adafruit/circuitpython to find the latest stable +// and the latest dev (prerelease) versions. Per-board availability is left +// to the linked board page on circuitpython.org, which only lists builds +// that actually exist for that board. +// +// See https://github.com/circuitpython/web-editor/issues/357 + +const RELEASES_API = "https://api.github.com/repos/adafruit/circuitpython/releases"; + +// Cache the API result for the lifetime of the page so opening the device +// info dialog repeatedly doesn't hammer the API. +let _releasesPromise = null; + +// Pre-release identifier ranking. Lower number = earlier in the release cycle. +// Anything not listed (or an empty pre-release section) is treated as a final +// stable release and ranks highest within the same X.Y.Z. +const PRERELEASE_RANK = { + "alpha": 0, + "beta": 1, + "rc": 2, +}; + +// Parse a version string like "9.2.8", "10.0.0-alpha.1", or "10.0.0-rc.0" +// into a comparable structure. Returns null if it can't be parsed. +function parseVersion(versionString) { + if (typeof versionString !== "string") return null; + // Trim a leading "v" and any trailing build metadata after "+" + let raw = versionString.trim().replace(/^v/i, "").split("+", 1)[0]; + // Tolerate "-dirty" suffix on builds compiled from a working tree + raw = raw.replace(/-dirty$/i, ""); + const match = raw.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.]([A-Za-z]+)\.?(\d+)?)?$/); + if (!match) return null; + + const [, maj, min, patch, preLabel, preNum] = match; + let preRank = Number.POSITIVE_INFINITY; // stable releases rank above any pre-release + let preNumber = 0; + let isPrerelease = false; + if (preLabel) { + isPrerelease = true; + const label = preLabel.toLowerCase(); + preRank = label in PRERELEASE_RANK ? PRERELEASE_RANK[label] : -1; + preNumber = preNum ? parseInt(preNum, 10) : 0; + } + return { + raw: versionString, + major: parseInt(maj, 10), + minor: parseInt(min, 10), + patch: parseInt(patch, 10), + prerelease: isPrerelease, + preRank, + preNumber, + }; +} + +// Compare two parsed versions. Returns negative if a < b, positive if a > b, 0 if equal. +function compareVersions(a, b) { + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + if (a.preRank !== b.preRank) return a.preRank - b.preRank; + return a.preNumber - b.preNumber; +} + +// Fetch (and cache) the list of CircuitPython releases from GitHub and pick +// the highest stable + highest dev pre-release. Returns +// { stable: parsedVersion|null, dev: parsedVersion|null }. +async function fetchLatestReleases() { + if (_releasesPromise) return _releasesPromise; + + _releasesPromise = (async () => { + let response; + try { + response = await fetch(`${RELEASES_API}?per_page=30`, { + headers: {"Accept": "application/vnd.github+json"}, + }); + } catch (err) { + console.warn("Firmware check: fetch failed", err); + return {stable: null, dev: null}; + } + if (!response.ok) { + console.warn("Firmware check: GitHub API returned", response.status); + return {stable: null, dev: null}; + } + let releases; + try { + releases = await response.json(); + } catch (err) { + console.warn("Firmware check: bad JSON from GitHub", err); + return {stable: null, dev: null}; + } + + let stable = null; + let dev = null; + for (const release of releases) { + if (release.draft) continue; + const parsed = parseVersion(release.tag_name); + if (!parsed) continue; + if (release.prerelease || parsed.prerelease) { + if (compareVersions(parsed, dev) > 0) dev = parsed; + } else { + if (compareVersions(parsed, stable) > 0) stable = parsed; + } + } + return {stable, dev}; + })(); + + return _releasesPromise; +} + +// Decide which (if any) firmware suggestions to surface for a device that +// is currently running `currentVersionString`. Implements the logic from +// https://github.com/circuitpython/web-editor/issues/357: +// +// - If the user is running a development release: +// - Suggest the latest stable if it is newer. +// - Suggest the latest dev release if it is newer than what they're running. +// - If the user is running a stable release: +// - Suggest a newer stable, if any. +// - Suggest a newer dev release, if any. +// +// Returns { suggestions: [{type: "stable"|"dev", version: "10.0.0"}], current }. +function buildSuggestions(currentVersionString, latestReleases) { + const current = parseVersion(currentVersionString); + const suggestions = []; + if (!current || !latestReleases) { + return {suggestions, current}; + } + const {stable, dev} = latestReleases; + + if (stable && compareVersions(stable, current) > 0) { + suggestions.push({type: "stable", version: stable.raw}); + } + if (dev && compareVersions(dev, current) > 0) { + suggestions.push({type: "dev", version: dev.raw}); + } + return {suggestions, current}; +} + +// Format suggestions as a small HTML snippet suitable for injecting into a +// device info table. `boardId` is used to deep-link to the board's download +// page on circuitpython.org. Returns an empty string when there is nothing +// to suggest. +function renderSuggestionsHtml(suggestions, boardId) { + if (!suggestions || suggestions.length === 0) return ""; + const safeBoard = encodeURIComponent(boardId || ""); + const link = safeBoard + ? `https://circuitpython.org/board/${safeBoard}/` + : "https://circuitpython.org/downloads"; + const items = suggestions.map((s) => { + const label = s.type === "dev" ? "development release" : "stable release"; + return `
  • Newer ${label} available: ${s.version}
  • `; + }).join(""); + return ( + `
    ` + + ` ` + + `Update available` + + `
      ${items}
    ` + + `Download from circuitpython.org` + + `
    ` + ); +} + +// Convenience: fetch latest releases, compute suggestions for the given +// device version + board, and (if any) render them into `containerElement`. +// Failures are non-fatal -- nothing is rendered if the API call fails or the +// version string can't be parsed. +async function renderFirmwareSuggestions(containerElement, deviceInfo) { + if (!containerElement || !deviceInfo) return; + try { + const latest = await fetchLatestReleases(); + const {suggestions} = buildSuggestions(deviceInfo.version, latest); + const html = renderSuggestionsHtml(suggestions, deviceInfo.board_id); + containerElement.innerHTML = html; + } catch (err) { + console.warn("Firmware check failed", err); + containerElement.innerHTML = ""; + } +} + +export { + parseVersion, + compareVersions, + fetchLatestReleases, + buildSuggestions, + renderSuggestionsHtml, + renderFirmwareSuggestions, +}; diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index b9f808e..cd4f108 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -434,6 +434,43 @@ &[data-popup-modal="device-discovery"], &[data-popup-modal="device-info"] { + .firmware-update-suggestion-container { + // Filled in by firmware-check.js when a newer firmware is found. + // Empty by default so the dialog layout doesn't shift when the + // GitHub releases API hasn't responded yet (or fails). + &:empty { + display: none; + } + } + + .firmware-update-suggestion { + margin: 10px 0 5px; + padding: 10px 12px; + border: 1px solid $light-purple; + border-radius: 5px; + background-color: #faf6ff; + font-size: 0.95rem; + + i { + color: $purple; + margin-right: 6px; + } + + .firmware-update-suggestion__title { + font-weight: bold; + } + + ul { + margin: 6px 0; + padding-left: 24px; + } + + a { + color: $purple; + font-weight: bold; + } + } + .device-info { margin-top: 5px; width: 100%; From 67012d6c401d3c21f5685269c6b3e92dee378f2b Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:09:07 -0700 Subject: [PATCH 11/16] Auto-open Device Info dialog after every fresh connect Previously, only the Web workflow opened the Device Info / Discovery dialog automatically after connecting. USB and BLE users had to click the Info button to see the firmware-update suggestion added in this PR. This change moves the post-connect 'show info' trigger into loadEditor() so all three workflows (Web / USB / BLE) behave the same way: connect once, see the dialog, see the firmware-update suggestion if any. To avoid spamming the dialog on silent reconnects, a one-shot flag (shownDeviceInfoForCurrentSession) tracks whether we've already shown it for the current connection; the flag is reset in disconnectCallback so a fresh connect always re-shows the dialog. The dialog is opened fire-and-forget so it doesn't block the rest of the post-connect flow. Removes the now-redundant explicit showInfo() calls in checkConnected() and the URL-backend bootstrap path. --- js/script.js | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/js/script.js b/js/script.js index 7a2d501..19f33e0 100644 --- a/js/script.js +++ b/js/script.js @@ -235,10 +235,9 @@ async function checkConnected() { if (!workflow.connectionStatus()) { // Display the appropriate connection dialog await workflow.showConnect(getDocState()); - } else if (workflow.type === CONNTYPE.Web) { - // We're connected, local, and using Web Workflow - await workflow.showInfo(getDocState()); } + // Note: the Device Info dialog is now opened from loadEditor() so that + // BLE/USB/Web all behave the same way after a fresh connect. } return true; @@ -466,6 +465,12 @@ window.onbeforeunload = () => { } }; +// Tracks whether we've already shown the post-connect Device Info dialog +// for the current workflow. Reset to false in disconnectCallback() so that +// a fresh connect always re-shows it, while silent reconnects (which also +// run loadEditor) do not. +let shownDeviceInfoForCurrentSession = false; + async function loadEditor() { let documentState = loadParameterizedContent(); if (documentState) { @@ -475,6 +480,24 @@ async function loadEditor() { } updateUIConnected(true); + + // Show the Device Info dialog once per fresh connect, regardless of + // workflow (Web / USB / BLE). This is where the firmware-update + // suggestion (issue #357) is surfaced, so the user notices it just + // after connecting without us introducing a new dialog. + // + // Fire-and-forget: we don't await the dialog because it stays open until + // the user dismisses it, and we don't want to block the rest of the + // post-connect flow (busy spinner, parameterized doc loading, etc.). + if (!shownDeviceInfoForCurrentSession + && workflow + && workflow.showInfo + && workflow.connectionStatus && workflow.connectionStatus()) { + shownDeviceInfoForCurrentSession = true; + Promise.resolve() + .then(() => workflow.showInfo(getDocState())) + .catch((err) => console.warn("Could not show device info dialog", err)); + } } var editor; @@ -567,6 +590,7 @@ function disconnectCallback() { currentTimeout = null; } saveRetryCount = 0; + shownDeviceInfoForCurrentSession = false; updateUIConnected(false); } @@ -682,10 +706,10 @@ document.addEventListener('DOMContentLoaded', async (event) => { // If we don't have all the info we need to connect let returnVal = await workflow.parseParams(); if (returnVal === true && await workflowConnect() && workflow.type === CONNTYPE.Web) { - if (await checkReadOnly()) { - // We're connected, local, no errors, and using Web Workflow - await workflow.showInfo(getDocState()); - } + // We're connected, local, no errors, and using Web Workflow. + // The Device Info dialog is opened from loadEditor() now, so we + // just need to verify read-only state here. + await checkReadOnly(); } else { if (returnVal instanceof Error) { await showMessage(returnVal); From ffcc069429efe23f61105ee4071f5b2b2ae5aa9a Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:15:15 -0700 Subject: [PATCH 12/16] Fix USB: flip connected state before calling loadEditor In USBWorkflow.onConnected, loadEditor() was being called BEFORE super.onConnected() set _connected = CONNSTATE.connected. The new post-connect Device Info dialog trigger gates on connectionStatus(), which requires _connected == connected, so the dialog never opened for USB users. Reorder so super.onConnected() (which both flips the state flag and closes the connect dialog) runs first, then loadEditor() runs with connectionStatus() true. Also drop the redundant explicit connectDialog.close() call -- super.onConnected already closes it. --- js/workflows/usb.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/js/workflows/usb.js b/js/workflows/usb.js index 26532cf..6298ec9 100644 --- a/js/workflows/usb.js +++ b/js/workflows/usb.js @@ -45,10 +45,13 @@ class USBWorkflow extends Workflow { } async onConnected(e) { - this.connectDialog.close(); - await this.loadEditor(); + // super.onConnected sets _connected=CONNSTATE.connected and closes + // the connect dialog. Run it first so that loadEditor() (and any + // other code that gates on connectionStatus()) sees us as fully + // connected. + await super.onConnected(e); this.debugLog("connected"); - super.onConnected(e); + await this.loadEditor(); } async onDisconnected(e, reconnect = true) { From 0a843b8f91be589a31487235ec1275cc39a8feba Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:35:34 -0700 Subject: [PATCH 13/16] Add syntax highlighting for additional file types Pick a CodeMirror 6 language extension based on the opened file's extension instead of hard-wiring the editor to Python. Python files keep the existing CircuitPython module highlight overlay; other recognized extensions just get the matching CodeMirror language plugin so users get color coding when editing config and data files commonly stored on CircuitPython devices. Newly handled extensions: .json, .html/.htm, .css, .js, .xml, .md. Anything else (.txt, .toml, .ini, .inf, etc.) falls back to plain text, which is still an improvement over Python parsing being run on a non-Python file. Refs #361 --- js/script.js | 66 +++++++++++++++++--- package-lock.json | 155 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 ++ 3 files changed, 220 insertions(+), 7 deletions(-) diff --git a/js/script.js b/js/script.js index 7a2d501..9b1a3ca 100644 --- a/js/script.js +++ b/js/script.js @@ -3,6 +3,12 @@ import { EditorView, keymap } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; import { indentWithTab } from "@codemirror/commands" import { python } from "@codemirror/lang-python"; +import { json } from "@codemirror/lang-json"; +import { html } from "@codemirror/lang-html"; +import { css } from "@codemirror/lang-css"; +import { javascript } from "@codemirror/lang-javascript"; +import { xml } from "@codemirror/lang-xml"; +import { markdown } from "@codemirror/lang-markdown"; import { syntaxHighlighting, indentUnit } from "@codemirror/language"; import { classHighlighter } from "@lezer/highlight"; import { circuitpythonHighlight } from "./common/circuitpython_highlight.js"; @@ -56,6 +62,46 @@ const settings = new Settings(); const editorTheme = EditorView.theme({}, {dark: getCssVar('editor-theme-dark').trim() === '1'}); +// Map file extensions to a CodeMirror 6 language extension factory. +// Anything not in this map falls back to plain text (no language plugin). +// Python is handled separately because it also gets the CircuitPython +// highlight overlay. +const LANGUAGE_EXTENSION_MAP = { + "css": css, + "htm": html, + "html": html, + "js": javascript, + "json": json, + "md": markdown, + "xml": xml, +}; + +function getFileExtensionFromPath(path) { + if (!path) return null; + // Use the basename so a dotted directory in the path doesn't fool us. + const base = path.split("/").pop(); + if (!base || base.indexOf(".") < 0) return null; + return base.split(".").pop().toLowerCase(); +} + +// Pick the CodeMirror language extensions to use for a given file path. +// Returns an array so callers can spread it directly into the editor's +// extension list. New (untitled) docs default to Python so the editor +// behaves the same as before for the common "create code.py" case. +function languageExtensionsForPath(path) { + if (path === null || path === undefined) { + return [python(), circuitpythonHighlight]; + } + const ext = getFileExtensionFromPath(path); + if (ext === "py") { + return [python(), circuitpythonHighlight]; + } + if (ext && Object.prototype.hasOwnProperty.call(LANGUAGE_EXTENSION_MAP, ext)) { + return [LANGUAGE_EXTENSION_MAP[ext]()]; + } + return []; +} + document.addEventListener('DOMContentLoaded', function() { document.getElementById('mobile-menu-button').addEventListener('click', handleMobileToggle); document.querySelectorAll('#mobile-menu-contents li a').forEach((element) => { @@ -398,28 +444,34 @@ const hotkeyMap = [ { key: "Alt-n", run: newFile }, { key: "Mod-r", run: saveRunFile }, ]; -const editorExtensions = [ +// Extensions that are always present, regardless of file type. The +// per-file language extensions are appended in `buildEditorExtensions` +// so we can swap CodeMirror language plugins when the user opens a +// non-Python file (issue #361). +const baseEditorExtensions = [ basicSetup, keymap.of([indentWithTab]), keymap.of(hotkeyMap), indentUnit.of(" "), - python(), editorTheme, syntaxHighlighting(classHighlighter), - circuitpythonHighlight, EditorView.updateListener.of(onTextChange) ]; +function buildEditorExtensions(path) { + return [...baseEditorExtensions, ...languageExtensionsForPath(path)]; +} + // Use the editor's function to check if anything has changed function isDirty() { if (unchanged == editor.state.doc.length) return false; return true; } -function loadEditorContents(content) { +function loadEditorContents(content, path = null) { editor.setState(EditorState.create({ doc: content, - extensions: editorExtensions + extensions: buildEditorExtensions(path) })); unchanged = editor.state.doc.length; //console.log("doc length", unchanged); @@ -525,7 +577,7 @@ async function saveFileContents(path) { // Load the File Contents and Path into the UI function loadFileContents(path, contents, saved = true) { setFilename(path); - loadEditorContents(contents); + loadEditorContents(contents, path); if (saved !== null) { setSaved(saved); } @@ -573,7 +625,7 @@ function disconnectCallback() { editor = new EditorView({ state: EditorState.create({ doc: "", - extensions: editorExtensions + extensions: buildEditorExtensions(null) }), parent: document.querySelector('#editor') }); diff --git a/package-lock.json b/package-lock.json index 2bdc109..7e02022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,13 @@ "dependencies": { "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5", "@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.3.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-xml": "^6.1.0", "@fortawesome/fontawesome-free": "^7.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -67,6 +73,76 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, "node_modules/@codemirror/lang-python": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", @@ -80,6 +156,20 @@ "@lezer/python": "^1.1.4" } }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", @@ -192,6 +282,17 @@ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", @@ -201,6 +302,39 @@ "@lezer/common": "^1.3.0" } }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", @@ -210,6 +344,16 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@lezer/python": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", @@ -221,6 +365,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", diff --git a/package.json b/package.json index b8d56eb..5998c58 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,13 @@ "dependencies": { "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5", "@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.3.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-xml": "^6.1.0", "@fortawesome/fontawesome-free": "^7.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", From 214b6ec19a2caeb1978aa7610979fb49d68b3a88 Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:45:17 -0700 Subject: [PATCH 14/16] Refresh language plugin on Save As, fix light-theme contrast - Move the per-file language extension into a CodeMirror Compartment so we can reconfigure the language plugin without rebuilding the editor state. Save As to a different extension (code.py -> test.html) now picks up the new language immediately, instead of staying on whatever language was active when the file was first loaded. - Replace hardcoded editor token colors with CSS variables and provide light-theme overrides. The editor previously hardcoded color: #ddd as the default text color and used a palette tuned only for the dark background, so HTML tag names and other tokens that fall back to the default color (or only had pale colors) were nearly invisible on the light theme's #f8f8f8 background. - Style .tok-typeName explicitly so HTML/XML tag names color in both themes instead of falling through to the editor default. Refs #361 --- js/script.js | 35 ++++++++++++++++---- sass/layout/_themes.scss | 71 +++++++++++++++++++++++++++++++--------- 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/js/script.js b/js/script.js index 9b1a3ca..7f7f09b 100644 --- a/js/script.js +++ b/js/script.js @@ -1,6 +1,6 @@ import { basicSetup } from "codemirror"; import { EditorView, keymap } from "@codemirror/view"; -import { EditorState } from "@codemirror/state"; +import { EditorState, Compartment } from "@codemirror/state"; import { indentWithTab } from "@codemirror/commands" import { python } from "@codemirror/lang-python"; import { json } from "@codemirror/lang-json"; @@ -102,6 +102,23 @@ function languageExtensionsForPath(path) { return []; } +// Compartment used so we can hot-swap the language plugin when the +// active file's extension changes (e.g. user opens an .html file, or +// uses Save As to rename code.py to test.html). +const languageCompartment = new Compartment(); + +// Apply the language plugin matching `path` to the running editor. +// Safe to call before `editor` exists (the initial state already gets +// the correct language via languageCompartment.of(...) below). +function setEditorLanguageForPath(path) { + if (!editor) return; + editor.dispatch({ + effects: languageCompartment.reconfigure( + languageExtensionsForPath(path), + ), + }); +} + document.addEventListener('DOMContentLoaded', function() { document.getElementById('mobile-menu-button').addEventListener('click', handleMobileToggle); document.querySelectorAll('#mobile-menu-contents li a').forEach((element) => { @@ -445,9 +462,9 @@ const hotkeyMap = [ { key: "Mod-r", run: saveRunFile }, ]; // Extensions that are always present, regardless of file type. The -// per-file language extensions are appended in `buildEditorExtensions` -// so we can swap CodeMirror language plugins when the user opens a -// non-Python file (issue #361). +// per-file language extensions live in `languageCompartment` so they +// can be swapped at runtime (e.g. on Save As to a different +// extension). const baseEditorExtensions = [ basicSetup, keymap.of([indentWithTab]), @@ -459,7 +476,10 @@ const baseEditorExtensions = [ ]; function buildEditorExtensions(path) { - return [...baseEditorExtensions, ...languageExtensionsForPath(path)]; + return [ + ...baseEditorExtensions, + languageCompartment.of(languageExtensionsForPath(path)), + ]; } // Use the editor's function to check if anything has changed @@ -536,9 +556,12 @@ const MAX_SAVE_RETRIES = 3; // Save the File Contents and update the UI async function saveFileContents(path) { - // If this is a different file, we write everything + // If this is a different file, we write everything and refresh the + // CodeMirror language plugin in case the new path has a different + // extension (e.g. Save As from code.py to test.html). if (path !== workflow.currentFilename) { unchanged = 0; + setEditorLanguageForPath(path); } let doc = editor.state.doc; let offset = 0; diff --git a/sass/layout/_themes.scss b/sass/layout/_themes.scss index b4479bf..c135fb2 100644 --- a/sass/layout/_themes.scss +++ b/sass/layout/_themes.scss @@ -14,6 +14,23 @@ --gutter-text-color: #ddd; --debug-message-color: #fce94f; --unsaved-file-color: #f60; + + // Editor token colors. Defaults are tuned for the dark editor + // background; the light theme below overrides any that would be + // unreadable on a near-white background. + --editor-text-color: #ddd; + --tok-comment-color: #7F848E; + --tok-variable-color: #61AFEF; + --tok-operator-color: #56B6C2; + --tok-string-color: #98C379; + --tok-string2-color: #FFB86C; + --tok-number-color: #E5C07B; + --tok-keyword-color: #C678DD; + --tok-property-color: #D19A66; + --tok-atom-color: #E06C75; + --tok-typename-color: #61AFEF; + --tok-cp-module-color: #FF79C6; + --tok-meta-color: #b084eb; } // Light Theme @@ -28,6 +45,23 @@ --gutter-active-line-color: #ccc; --gutter-text-color: #222; --debug-message-color: #FF9900; + + // Darken token colors enough to stay readable on the + // near-white editor background. Dark theme values above are + // tuned for #333; these are tuned for #f8f8f8. + --editor-text-color: #222; + --tok-comment-color: #6B7280; + --tok-variable-color: #1F6FB8; + --tok-operator-color: #1F8FA8; + --tok-string-color: #2E7D32; + --tok-string2-color: #B85C00; + --tok-number-color: #8A5A00; + --tok-keyword-color: #7C2DB5; + --tok-property-color: #9A5A1A; + --tok-atom-color: #B91C1C; + --tok-typename-color: #1F6FB8; + --tok-cp-module-color: #C71585; + --tok-meta-color: #6E3FBE; } // Styles applied to both themes @@ -75,7 +109,7 @@ } .cm-editor { - color: #ddd; + color: var(--editor-text-color); background-color: var(--background-color); line-height: 1.5; font-family: 'Operator Mono', 'Source Code Pro', Menlo, Monaco, Consolas, Courier New, monospace; @@ -87,7 +121,7 @@ .cm-comment { font-style: italic; - color: #676B79; + color: var(--tok-comment-color); } .cm-operator { @@ -95,19 +129,19 @@ } .cm-string { - color: #19F9D8; + color: var(--tok-string-color); } .cm-string-2 { - color: #FFB86C; + color: var(--tok-string2-color); } .cm-tag { - color: #ff2c6d; + color: var(--tok-typename-color); } .cm-meta { - color: #b084eb; + color: var(--tok-meta-color); } &.cm-focused .cm-cursor { @@ -139,19 +173,19 @@ /* Highlight Tags */ .tok-comment { - color: #7F848E; + color: var(--tok-comment-color); } .tok-variableName { - color: #61AFEF; + color: var(--tok-variable-color); } .tok-operator { - color: #56B6C2; + color: var(--tok-operator-color); } .tok-string { - color: #98C379; + color: var(--tok-string-color); } .tok-punctuation { @@ -159,20 +193,27 @@ } .tok-number { - color: #E5C07B, + color: var(--tok-number-color); } .tok-keyword { - color: #C678DD, + color: var(--tok-keyword-color); } .tok-propertyName { - color: #D19A66; + color: var(--tok-property-color); } .tok-atom, .tok-bool { - color: #E06C75; + color: var(--tok-atom-color); + } + + // HTML/XML tag names, type names. Without an explicit color these + // fall back to the editor default text color, which on the light + // theme renders as nearly invisible #ddd-on-#f8f8f8. + .tok-typeName { + color: var(--tok-typename-color); } // CircuitPython-specific module names (overlay added by @@ -180,6 +221,6 @@ // wrapped with Prec.highest so its decoration nests inside the // classHighlighter span, letting this color win without !important. .tok-cp-module { - color: #FF79C6; + color: var(--tok-cp-module-color); } } From 41793614db5800712e1120ccbc7dd1db4d179f64 Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:54:59 -0700 Subject: [PATCH 15/16] Move language switch into setFilename so Save As actually updates it The previous attempt hooked the language refresh into saveFileContents and compared the new path against workflow.currentFilename. But workflow.saveFileAs() in workflows/workflow.js sets this.currentFilename = path BEFORE delegating to saveFile -> saveFileContents, so by the time we ran our check the two were already equal and the language never got reconfigured. Move the trigger into setFilename, which is the single chokepoint all filename changes route through (Open, New, Save As, backend load), and track the editor's current language path on a module local instead. Skip the reconfigure when the new path resolves to the same language plugin so we don't churn parser state on Save within the same extension. Refs #361 --- js/script.js | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/js/script.js b/js/script.js index 7f7f09b..dc96146 100644 --- a/js/script.js +++ b/js/script.js @@ -107,11 +107,35 @@ function languageExtensionsForPath(path) { // uses Save As to rename code.py to test.html). const languageCompartment = new Compartment(); +// Track which path the editor's language plugin is currently configured +// for, so we can decide whether a reconfigure is actually needed. We +// can't compare against `workflow.currentFilename` because +// `workflow.saveFileAs()` mutates that BEFORE the post-save +// `setFilename` callback runs, so by the time we'd see it the +// "old" path is already gone. +let editorLanguagePath = null; + +function extensionKey(path) { + if (path === null || path === undefined) return "__null__"; + const ext = getFileExtensionFromPath(path); + return ext || "__noext__"; +} + // Apply the language plugin matching `path` to the running editor. // Safe to call before `editor` exists (the initial state already gets // the correct language via languageCompartment.of(...) below). function setEditorLanguageForPath(path) { - if (!editor) return; + if (!editor) { + editorLanguagePath = path; + return; + } + if (extensionKey(path) === extensionKey(editorLanguagePath)) { + // Same language plugin would be installed — skip the + // reconfigure to avoid needlessly resetting language-internal + // state (folds, parser caches, etc.). + return; + } + editorLanguagePath = path; editor.dispatch({ effects: languageCompartment.reconfigure( languageExtensionsForPath(path), @@ -342,6 +366,12 @@ async function checkReadOnly() { /* Update the filename and update the UI */ function setFilename(path) { + // Refresh the CodeMirror language plugin whenever the active file + // changes — this is the single chokepoint that all filename + // changes route through (Open File, New File, Save As, backend + // load), so it's the right place to keep the language in sync. + setEditorLanguageForPath(path); + // Use the extension_map to figure out the file icon let filename = path; @@ -493,6 +523,10 @@ function loadEditorContents(content, path = null) { doc: content, extensions: buildEditorExtensions(path) })); + // Keep our tracked language path in sync with the fresh state's + // compartment contents so the next setEditorLanguageForPath call + // can correctly skip a no-op reconfigure. + editorLanguagePath = path; unchanged = editor.state.doc.length; //console.log("doc length", unchanged); } @@ -556,12 +590,11 @@ const MAX_SAVE_RETRIES = 3; // Save the File Contents and update the UI async function saveFileContents(path) { - // If this is a different file, we write everything and refresh the - // CodeMirror language plugin in case the new path has a different - // extension (e.g. Save As from code.py to test.html). + // If this is a different file, we write everything. The language + // plugin is refreshed by setFilename below (it routes through + // setEditorLanguageForPath), so no extra dispatch is needed here. if (path !== workflow.currentFilename) { unchanged = 0; - setEditorLanguageForPath(path); } let doc = editor.state.doc; let offset = 0; From 21b94e408280a13b4693ba19e125aab45cce2988 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:54:18 +0000 Subject: [PATCH 16/16] Resolve merge conflicts between beta and main branches --- js/script.js | 22 ++++------------------ js/workflows/ble.js | 33 +-------------------------------- 2 files changed, 5 insertions(+), 50 deletions(-) diff --git a/js/script.js b/js/script.js index a2d4828..64906cf 100644 --- a/js/script.js +++ b/js/script.js @@ -694,24 +694,18 @@ function sleep(ms) { // a fire-and-forget setTimeout, which let Save+Run soft-restart the board // before the PUT had succeeded (issue #460). async function saveFileContents(path) { -<<<<<<< HEAD - // If this is a different file, we write everything. The language - // plugin is refreshed by setFilename below (it routes through - // setEditorLanguageForPath), so no extra dispatch is needed here. - if (path !== workflow.currentFilename) { - unchanged = 0; -======= if (saveInFlight) { // Re-entrant save (e.g. user mashing Ctrl-S / Save+Run). The first // call will report success/failure; the second would race the same // bytes onto the wire and confuse partialWrites bookkeeping. console.log("saveFileContents: already in flight, ignoring re-entry"); return false; ->>>>>>> origin/main } saveInFlight = true; try { - // If this is a different file, we write everything + // If this is a different file, we write everything. The language + // plugin is refreshed by setFilename below (it routes through + // setEditorLanguageForPath), so no extra dispatch is needed here. if (path !== workflow.currentFilename) { unchanged = 0; } @@ -842,18 +836,10 @@ async function onTextChange(update) { } function disconnectCallback() { -<<<<<<< HEAD - if (currentTimeout != null) { - clearTimeout(currentTimeout); - currentTimeout = null; - } - saveRetryCount = 0; - shownDeviceInfoForCurrentSession = false; -======= // saveInFlight is intentionally not forced here -- the in-flight // saveFileContents loop checks connectionStatus() between retries and // exits cleanly on its own, then clears the flag in its finally block. ->>>>>>> origin/main + shownDeviceInfoForCurrentSession = false; updateUIConnected(false); } diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 9138789..9cf97d9 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -45,12 +45,6 @@ class BLEWorkflow extends Workflow { {reconnect: false, request: true}, {reconnect: true, request: true}, ]; -<<<<<<< HEAD - - // Store bound event handlers so they can be properly removed - this._boundOnDisconnected = this.onDisconnected.bind(this); - this._boundOnSerialReceive = this.onSerialReceive.bind(this); -======= // Mutating-op disconnects within this window trigger silent reconnect. this._lastMutatingOpAt = 0; this._silentReconnectInFlight = false; @@ -106,7 +100,6 @@ class BLEWorkflow extends Workflow { console.log('awaitPostOpReconnect: silent reconnect rejected:', e); } } ->>>>>>> origin/main } // This is called when a user clicks the main disconnect button @@ -201,15 +194,9 @@ class BLEWorkflow extends Workflow { this.txCharacteristic = await this.serialService.getCharacteristic(bleNusCharTXUUID); this.rxCharacteristic = await this.serialService.getCharacteristic(bleNusCharRXUUID); -<<<<<<< HEAD - // Remove any existing event listeners to prevent multiple reads - this.txCharacteristic.removeEventListener('characteristicvaluechanged', this._boundOnSerialReceive); - this.txCharacteristic.addEventListener('characteristicvaluechanged', this._boundOnSerialReceive); -======= // Use cached bound handler so removeEventListener actually matches. this.txCharacteristic.removeEventListener('characteristicvaluechanged', this._onSerialReceiveBound); this.txCharacteristic.addEventListener('characteristicvaluechanged', this._onSerialReceiveBound); ->>>>>>> origin/main await this.txCharacteristic.startNotifications(); return true; } catch (e) { @@ -252,14 +239,6 @@ class BLEWorkflow extends Workflow { this._pendingAdvAborts.add(abortController); let advHandled = false; -<<<<<<< HEAD - // Remove previous advertisement listener if one was stored - if (this._boundOnAdvertisementReceived) { - device.removeEventListener('advertisementreceived', this._boundOnAdvertisementReceived); - } - - this._boundOnAdvertisementReceived = (async function onAdvertisementReceived(event) { -======= async function onAdvertisementReceived(event) { // Multiple ads can land in the same event-loop tick before // abortController.abort() takes effect on the listener. Guard @@ -268,7 +247,6 @@ class BLEWorkflow extends Workflow { return; } advHandled = true; ->>>>>>> origin/main console.log('> Received advertisement from "' + device.name + '"...'); // This device won. Abort ALL pending watchAdvertisements // (including this one) so other paired devices stop scanning @@ -293,18 +271,14 @@ class BLEWorkflow extends Workflow { } else { console.log('Unable to connect to bluetooth device "' + device.name + '.'); } - }).bind(this); + } -<<<<<<< HEAD - device.addEventListener('advertisementreceived', this._boundOnAdvertisementReceived); -======= // Use the abortController signal so we don't need to manage the // handler reference manually — the listener is auto-removed when // onAdvertisementReceived calls abortController.abort(). device.addEventListener('advertisementreceived', onAdvertisementReceived.bind(this), {signal: abortController.signal}); ->>>>>>> origin/main this.debugLog("Attempting to connect to " + device.name + "..."); try { @@ -336,13 +310,8 @@ class BLEWorkflow extends Workflow { async switchToDevice(device) { this.bleDevice = device; -<<<<<<< HEAD - this.bleDevice.removeEventListener("gattserverdisconnected", this._boundOnDisconnected); - this.bleDevice.addEventListener("gattserverdisconnected", this._boundOnDisconnected); -======= this.bleDevice.removeEventListener("gattserverdisconnected", this._onDisconnectedBound); this.bleDevice.addEventListener("gattserverdisconnected", this._onDisconnectedBound); ->>>>>>> origin/main console.log("connected", this.bleServer); try {