|
1 | 1 | import { basicSetup } from "codemirror"; |
2 | 2 | import { EditorView, keymap } from "@codemirror/view"; |
3 | | -import { EditorState } from "@codemirror/state"; |
| 3 | +import { EditorState, Compartment } from "@codemirror/state"; |
4 | 4 | import { indentWithTab } from "@codemirror/commands" |
5 | 5 | import { python } from "@codemirror/lang-python"; |
| 6 | +import { json } from "@codemirror/lang-json"; |
| 7 | +import { html } from "@codemirror/lang-html"; |
| 8 | +import { css } from "@codemirror/lang-css"; |
| 9 | +import { javascript } from "@codemirror/lang-javascript"; |
| 10 | +import { xml } from "@codemirror/lang-xml"; |
| 11 | +import { markdown } from "@codemirror/lang-markdown"; |
6 | 12 | import { syntaxHighlighting, indentUnit } from "@codemirror/language"; |
7 | 13 | import { classHighlighter } from "@lezer/highlight"; |
8 | 14 | import { circuitpythonHighlight } from "./common/circuitpython_highlight.js"; |
@@ -56,6 +62,87 @@ const settings = new Settings(); |
56 | 62 |
|
57 | 63 | const editorTheme = EditorView.theme({}, {dark: getCssVar('editor-theme-dark').trim() === '1'}); |
58 | 64 |
|
| 65 | +// Map file extensions to a CodeMirror 6 language extension factory. |
| 66 | +// Anything not in this map falls back to plain text (no language plugin). |
| 67 | +// Python is handled separately because it also gets the CircuitPython |
| 68 | +// highlight overlay. |
| 69 | +const LANGUAGE_EXTENSION_MAP = { |
| 70 | + "css": css, |
| 71 | + "htm": html, |
| 72 | + "html": html, |
| 73 | + "js": javascript, |
| 74 | + "json": json, |
| 75 | + "md": markdown, |
| 76 | + "xml": xml, |
| 77 | +}; |
| 78 | + |
| 79 | +function getFileExtensionFromPath(path) { |
| 80 | + if (!path) return null; |
| 81 | + // Use the basename so a dotted directory in the path doesn't fool us. |
| 82 | + const base = path.split("/").pop(); |
| 83 | + if (!base || base.indexOf(".") < 0) return null; |
| 84 | + return base.split(".").pop().toLowerCase(); |
| 85 | +} |
| 86 | + |
| 87 | +// Pick the CodeMirror language extensions to use for a given file path. |
| 88 | +// Returns an array so callers can spread it directly into the editor's |
| 89 | +// extension list. New (untitled) docs default to Python so the editor |
| 90 | +// behaves the same as before for the common "create code.py" case. |
| 91 | +function languageExtensionsForPath(path) { |
| 92 | + if (path === null || path === undefined) { |
| 93 | + return [python(), circuitpythonHighlight]; |
| 94 | + } |
| 95 | + const ext = getFileExtensionFromPath(path); |
| 96 | + if (ext === "py") { |
| 97 | + return [python(), circuitpythonHighlight]; |
| 98 | + } |
| 99 | + if (ext && Object.prototype.hasOwnProperty.call(LANGUAGE_EXTENSION_MAP, ext)) { |
| 100 | + return [LANGUAGE_EXTENSION_MAP[ext]()]; |
| 101 | + } |
| 102 | + return []; |
| 103 | +} |
| 104 | + |
| 105 | +// Compartment used so we can hot-swap the language plugin when the |
| 106 | +// active file's extension changes (e.g. user opens an .html file, or |
| 107 | +// uses Save As to rename code.py to test.html). |
| 108 | +const languageCompartment = new Compartment(); |
| 109 | + |
| 110 | +// Track which path the editor's language plugin is currently configured |
| 111 | +// for, so we can decide whether a reconfigure is actually needed. We |
| 112 | +// can't compare against `workflow.currentFilename` because |
| 113 | +// `workflow.saveFileAs()` mutates that BEFORE the post-save |
| 114 | +// `setFilename` callback runs, so by the time we'd see it the |
| 115 | +// "old" path is already gone. |
| 116 | +let editorLanguagePath = null; |
| 117 | + |
| 118 | +function extensionKey(path) { |
| 119 | + if (path === null || path === undefined) return "__null__"; |
| 120 | + const ext = getFileExtensionFromPath(path); |
| 121 | + return ext || "__noext__"; |
| 122 | +} |
| 123 | + |
| 124 | +// Apply the language plugin matching `path` to the running editor. |
| 125 | +// Safe to call before `editor` exists (the initial state already gets |
| 126 | +// the correct language via languageCompartment.of(...) below). |
| 127 | +function setEditorLanguageForPath(path) { |
| 128 | + if (!editor) { |
| 129 | + editorLanguagePath = path; |
| 130 | + return; |
| 131 | + } |
| 132 | + if (extensionKey(path) === extensionKey(editorLanguagePath)) { |
| 133 | + // Same language plugin would be installed — skip the |
| 134 | + // reconfigure to avoid needlessly resetting language-internal |
| 135 | + // state (folds, parser caches, etc.). |
| 136 | + return; |
| 137 | + } |
| 138 | + editorLanguagePath = path; |
| 139 | + editor.dispatch({ |
| 140 | + effects: languageCompartment.reconfigure( |
| 141 | + languageExtensionsForPath(path), |
| 142 | + ), |
| 143 | + }); |
| 144 | +} |
| 145 | + |
59 | 146 | document.addEventListener('DOMContentLoaded', function() { |
60 | 147 | document.getElementById('mobile-menu-button').addEventListener('click', handleMobileToggle); |
61 | 148 | document.querySelectorAll('#mobile-menu-contents li a').forEach((element) => { |
@@ -279,6 +366,12 @@ async function checkReadOnly() { |
279 | 366 |
|
280 | 367 | /* Update the filename and update the UI */ |
281 | 368 | function setFilename(path) { |
| 369 | + // Refresh the CodeMirror language plugin whenever the active file |
| 370 | + // changes — this is the single chokepoint that all filename |
| 371 | + // changes route through (Open File, New File, Save As, backend |
| 372 | + // load), so it's the right place to keep the language in sync. |
| 373 | + setEditorLanguageForPath(path); |
| 374 | + |
282 | 375 | // Use the extension_map to figure out the file icon |
283 | 376 | let filename = path; |
284 | 377 |
|
@@ -398,29 +491,42 @@ const hotkeyMap = [ |
398 | 491 | { key: "Alt-n", run: newFile }, |
399 | 492 | { key: "Mod-r", run: saveRunFile }, |
400 | 493 | ]; |
401 | | -const editorExtensions = [ |
| 494 | +// Extensions that are always present, regardless of file type. The |
| 495 | +// per-file language extensions live in `languageCompartment` so they |
| 496 | +// can be swapped at runtime (e.g. on Save As to a different |
| 497 | +// extension). |
| 498 | +const baseEditorExtensions = [ |
402 | 499 | basicSetup, |
403 | 500 | keymap.of([indentWithTab]), |
404 | 501 | keymap.of(hotkeyMap), |
405 | 502 | indentUnit.of(" "), |
406 | | - python(), |
407 | 503 | editorTheme, |
408 | 504 | syntaxHighlighting(classHighlighter), |
409 | | - circuitpythonHighlight, |
410 | 505 | EditorView.updateListener.of(onTextChange) |
411 | 506 | ]; |
412 | 507 |
|
| 508 | +function buildEditorExtensions(path) { |
| 509 | + return [ |
| 510 | + ...baseEditorExtensions, |
| 511 | + languageCompartment.of(languageExtensionsForPath(path)), |
| 512 | + ]; |
| 513 | +} |
| 514 | + |
413 | 515 | // Use the editor's function to check if anything has changed |
414 | 516 | function isDirty() { |
415 | 517 | if (unchanged == editor.state.doc.length) return false; |
416 | 518 | return true; |
417 | 519 | } |
418 | 520 |
|
419 | | -function loadEditorContents(content) { |
| 521 | +function loadEditorContents(content, path = null) { |
420 | 522 | editor.setState(EditorState.create({ |
421 | 523 | doc: content, |
422 | | - extensions: editorExtensions |
| 524 | + extensions: buildEditorExtensions(path) |
423 | 525 | })); |
| 526 | + // Keep our tracked language path in sync with the fresh state's |
| 527 | + // compartment contents so the next setEditorLanguageForPath call |
| 528 | + // can correctly skip a no-op reconfigure. |
| 529 | + editorLanguagePath = path; |
424 | 530 | unchanged = editor.state.doc.length; |
425 | 531 | //console.log("doc length", unchanged); |
426 | 532 | } |
@@ -484,7 +590,9 @@ const MAX_SAVE_RETRIES = 3; |
484 | 590 |
|
485 | 591 | // Save the File Contents and update the UI |
486 | 592 | async function saveFileContents(path) { |
487 | | - // If this is a different file, we write everything |
| 593 | + // If this is a different file, we write everything. The language |
| 594 | + // plugin is refreshed by setFilename below (it routes through |
| 595 | + // setEditorLanguageForPath), so no extra dispatch is needed here. |
488 | 596 | if (path !== workflow.currentFilename) { |
489 | 597 | unchanged = 0; |
490 | 598 | } |
@@ -525,7 +633,7 @@ async function saveFileContents(path) { |
525 | 633 | // Load the File Contents and Path into the UI |
526 | 634 | function loadFileContents(path, contents, saved = true) { |
527 | 635 | setFilename(path); |
528 | | - loadEditorContents(contents); |
| 636 | + loadEditorContents(contents, path); |
529 | 637 | if (saved !== null) { |
530 | 638 | setSaved(saved); |
531 | 639 | } |
@@ -573,7 +681,7 @@ function disconnectCallback() { |
573 | 681 | editor = new EditorView({ |
574 | 682 | state: EditorState.create({ |
575 | 683 | doc: "", |
576 | | - extensions: editorExtensions |
| 684 | + extensions: buildEditorExtensions(null) |
577 | 685 | }), |
578 | 686 | parent: document.querySelector('#editor') |
579 | 687 | }); |
|
0 commit comments