From 0a843b8f91be589a31487235ec1275cc39a8feba Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:35:34 -0700 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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;