/*MD 
# CodeMirror

Workspace / main source code editing component. 

Keywords: #Tool #Widget #Core #Lively4 #Workspace #Important

Authors: @JensLincke @CodeMirrorProject 



![](lively-code-mirror.png){height=200}

MD*/

import { promisedEvent, through, uuid as generateUUID } from 'utils';
import boundEval from 'src/client/bound-eval.js';
import Morph from "src/components/widgets/lively-morph.js";
import SyntaxChecker from 'src/client/syntax.js';
import { debounce } from "utils";
import Preferences from 'src/client/preferences.js';
import { pt } from 'src/client/graphics.js';
import 'src/client/stablefocus.js';
import Strings from 'src/client/strings.js';
import { letsScript } from 'src/client/vivide/vivide.js';
// import LivelyCodeMirrorWidgetImport from 'src/components/widgets/lively-code-mirror-widget-import.js';
import openMenu from 'src/components/widgets/lively-code-mirror-context-menu.js';
import * as spellCheck from "src/external/codemirror-spellcheck.js";
import { isSet } from 'utils';
import fake from "./lively-code-mirror-fake.js";
import CodeMirror from "src/external/code-mirror/lib/codemirror.js";
self.CodeMirror = CodeMirror; // for modules
self.__codeMirrorLoadingPromise__ = self.__codeMirrorLoadingPromise__ || undefined;
import indentationWidth from 'src/components/widgets/indent.js';
import AEGutter from 'src/client/reactive/components/basic/AEGutter.js';

import LivelyCodeMirrorCodeProvider from 'src/components/widgets/lively-code-mirror-code-provider.js';
import 'src/components/widgets/ast-capabilities.js';
import 'src/components/widgets/lively-code-mirror-modes.js';
import 'src/components/widgets/lively-code-mirror-shadow-text.js';

import _ from 'src/external/lodash/lodash.js';

import preloaWebComponents from 'src/client/preload-components.js'
await preloaWebComponents(['lively-probe'])

export function stripErrorString(s) {
  return s.toString().replace(/Error: workspace(js)?:[^:]*:/, "Error:").replace(/\n {2}Evaluating workspace(js)?:.*/, "").replace(/\n {2}Loading workspace(js)?:.*/, "").replace(/\n {2}Instantiating workspace(js)?:.*/, "");
}

function posEq(a, b) {
  return a.line == b.line && a.ch == b.ch;
}

export default class LivelyCodeMirror extends HTMLElement {

  get livelyUpdateStrategy() {
    return 'inplace';
  }


  livelyUpdate() {
    // let's update inplace I guess?
  }

  fake(...args) {
    fake(this.editor, ...args);
  }

  get mode() {
    return this.getAttribute('mode');
  }
  set mode(val) {
    return this.setAttribute('mode', val);
  }

  static get codeMirrorPath() {
    return lively4url + "/src/external/code-mirror/";
  }

  static async loadModule(path, force) {
    if (!self.CodeMirror) {
      console.warn("CodeMirror is missing, could not initialize " + path);
      return;
    }
    try {
      var code = await fetch(this.codeMirrorPath + path).then(r => {
        if (!r.ok) throw new Error("404 or other error " + r.status)
        return r.text()
      });

      // AdHoc fix broken UMD dependencies in code mirror modules... alternative: make dependency to "../../lib/codemirror" work
      var originalDefine = globalThis.define
      try {
        delete globalThis.define
        eval(code);
      } finally {
        globalThis.define = originalDefine
      }
    } catch (e) {
      console.error("Could not load CodeMirror module " + path, e);
      throw e; // Rethrow so loadModules knows!
    }
    // return lively.loadJavaScriptThroughDOM("codemirror_"+path.replace(/[^A-Za-z]/g,""),
    //   this.codeMirrorPath + path, force)
  }

  static async loadCSS(path) {
    return lively.loadCSSThroughDOM("codemirror_" + path.replace(/[^A-Za-z]/g, ""), this.codeMirrorPath + path);
  }

  static async loadModules(force) {
    // console.log("loadModules", self.__codeMirrorLoadingPromise__);
    if (self.__codeMirrorLoadingPromise__ && !force) return self.__codeMirrorLoadingPromise__;
    self.__codeMirrorLoadingPromise__ = (async () => {

      await this.loadModule("addon/fold/foldcode.js");

      await this.loadModule("mode/javascript/javascript.js");
      await this.loadModule("mode/xml/xml.js");
      await this.loadModule("mode/css/css.js");
      await this.loadModule("mode/diff/diff.js");

      await this.loadModule("mode/meta.js");
      await this.loadModule("mode/markdown/markdown.js");
      await this.loadModule("mode/htmlmixed/htmlmixed.js");
      await this.loadModule("addon/mode/overlay.js");
      await this.loadModule("mode/gfm/gfm.js");
      await this.loadModule("mode/stex/stex.js");
      await this.loadModule("mode/jsx/jsx.js");
      await this.loadModule("mode/python/python.js");
      await this.loadModule("mode/clike/clike.js");
      await this.loadModule("mode/shell/shell.js");

      await this.loadModule("addon/edit/matchbrackets.js");
      await this.loadModule("addon/edit/closetag.js");
      await this.loadModule("addon/edit/closebrackets.js");
      await this.loadModule("addon/edit/continuelist.js");
      await this.loadModule("addon/edit/matchtags.js");
      await this.loadModule("addon/edit/trailingspace.js");
      await this.loadModule("addon/hint/show-hint.js");
      // await this.loadModule("addon/hint/javascript-hint.js");
      await this.loadModule("addon/search/searchcursor.js");
      await this.loadModule("addon/search/search.js");
      await this.loadModule("addon/search/jump-to-line.js");
      await this.loadModule("addon/search/matchesonscrollbar.js");
      await this.loadModule("addon/search/match-highlighter.js");
      await this.loadModule("addon/scroll/annotatescrollbar.js");
      await this.loadModule("addon/comment/comment.js");
      await this.loadModule("addon/dialog/dialog.js");
      await this.loadModule("addon/scroll/simplescrollbars.js");
      await this.loadModule("addon/display/autorefresh.js");

      await this.loadModule("addon/lint/lint.js");
      await this.loadModule("addon/lint/javascript-lint.js");
      // Provide minimal HTMLHint stub to prevent errors
      if (!window.HTMLHint) {
        window.HTMLHint = { verify: () => [] };
      }
      await this.loadModule("addon/lint/html-lint.js");

      await System.import(lively4url + '/src/external/eslint/eslint-lint.js');


      await this.loadModule("addon/merge/merge.js");
      await this.loadModule("addon/selection/mark-selection.js");

      await this.loadModule("keymap/sublime.js");
      await System.import(lively4url + '/src/components/widgets/lively-code-mirror-hint.js');
      await System.import(lively4url + '/src/components/widgets/lively-code-mirror-lint.js');

      this.loadCSS("addon/hint/show-hint.css");
      this.loadCSS("addon/lint/lint.css");
      lively.loadCSSThroughDOM("CodeMirrorCSS", lively4url + "/src/components/widgets/lively-code-mirror.css");
      self.__modulesAreLoaded__ = true
    })();
    return self.__codeMirrorLoadingPromise__;
  }

  get astCapabilities() {
    if (!this.myASTCapabilities || !(this.myASTCapabilities instanceof self.__ASTCapabilities__
    )) {
      const codeProvider = new LivelyCodeMirrorCodeProvider(this, this.editor);
      this.myASTCapabilities = new self.__ASTCapabilities__
        (codeProvider);
    }

    return this.myASTCapabilities;
  }

  get ternWrapper() {
    return System.import('src/components/widgets/tern-wrapper.js').then(m => {
      this.ternLoaded = true;
      return m.TernCodeMirrorWrapper;
    });
  }

  initialize() {
    this._attrObserver = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (mutation.type == "attributes") {
          // console.log("observation", mutation.attributeName,mutation.target.getAttribute(mutation.attributeName));
          this.attributeChangedCallback(mutation.attributeName, mutation.oldValue, mutation.target.getAttribute(mutation.attributeName));
        }
      });
    });
    this._attrObserver.observe(this, { attributes: true });

    this.addEventListener("keydown", evt => this.onKeyDown(evt))
    this.addEventListener("pointerup", evt => this.onPointerUp(evt))
  }

  onKeyDown(evt) {
    if ((evt.ctrlKey || evt.metaKey) && evt.key === "c") {
      this.ensureTextContent() // widgets might have a word here..
    }

    // #KeyboardShortcut CTRL+SHIFT+S toggle sandblocks
    if (evt.ctrlKey && evt.shiftKey && evt.key === "S") {
      evt.preventDefault();
      evt.stopPropagation()
      this.toggleSandblocks();
    }

  }

  onPointerUp(evt) {
    // keyboard is running out.. so go for the mouse
    // just a test if this is usefull... TODO make it customizable
    if (evt.button === 3) {
      this.editor.replaceSelection("“")
    } else if (evt.button === 4) {
      this.editor.replaceSelection("”")
    }
  }

  applyAttribute(attr) {
    var value = this.getAttribute(attr);
    if (value !== undefined) {
      this.setAttribute(attr, value);
    }
  }
  connectedCallback() {
    if (this.isLoading || this.editor) return;
    this.isLoading = true;
    if (self.__modulesAreLoaded__) {
      this._connectedCallback()
    } else {
      LivelyCodeMirror.loadModules().then(() => this._connectedCallback())
    }
  }


  _connectedCallback() {
    this.root = this.shadowRoot; // used in code mirror to find current element

    if (this.textContent) {
      var value = this.decodeHTML(this.textContent);
    } else {
      value = this.value || "";
    }
    this.editView(value);
    this.isLoading = false;
    // console.log("[editor] #dispatch editor-loaded")
    var event = new CustomEvent("editor-loaded");
    // event.stopPropagation();
    this.dispatchEvent(event);
    this["editor-loaded"] = true; // event can sometimes already be fired

    lively.sleep(0).then(() => {
      this.editor.refresh();
      this.updateAExprDependencies();
    })
  }

  async editorLoaded() {
    if (!this["editor-loaded"]) {
      return promisedEvent(this, "editor-loaded");
    }
  }

  editView(value) {
    if (!value) value = this.value || "";
    var container = this.shadowRoot.querySelector("#code-mirror-container");
    container.innerHTML = "";
    this.setEditor(CodeMirror(container, {
      value: value,
      lineNumbers: true,
      gutters: ["git-status", "leftgutter", "CodeMirror-linenumbers", "rightgutter", "CodeMirror-lint-markers"],
      lint: true
    }));

    //load astCapabilities
    this.astCapabilities;
  }

  setEditor(editor) {
    this.editor = editor;
    this.setupEditor();
  }

  setupEditor() {
    var editor = this.editor;
    if (this.mode) {
      editor.setOption("mode", this.mode);
    }
    this.setupEditorOptions(editor
      // edit addons
      // editor.setOption("showTrailingSpace", true)
      // editor.setOption("matchTags", true)

    );



    editor.on("change", (doc, evt) => this.dispatchEvent(new CustomEvent("change", { detail: evt })));
    editor.on("beforeChange", (instance, changeObj) => this.onBeforeChange(instance, changeObj));
    editor.on("change", (() => this.checkSyntax()).debounce(500));
    editor.on("change", (() => this.updateGitStatus()).debounce(500));
    editor.on("change", (() => this.astCapabilities.codeChanged()).debounce(200));

    editor.on("changes", (cm, changes) => this.shadowText.handleContentChange(cm, changes));
    editor.on("focus", (cm, evt) => this.shadowText.handleEditorFocus(cm, evt));
    editor.on("blur", (cm, evt) => this.shadowText.handleEditorBlur(cm, evt));

    editor.on("cursorActivity", (() => this.onCursorActivity()).debounce(500));
    editor.on("cursorActivity", cm => this.shadowText.handleCursorActivity(cm));
    editor.on("cursorActivity", (doc, evt) => {
      this.dispatchEvent(new CustomEvent("cursorActivity", { detail: evt }))
    });

    // apply attributes
    _.map(this.attributes, ea => ea.name).forEach(ea => this.applyAttribute(ea));

    // if(Preferences.get('UseTernInCodeMirror')) {
    //   this.enableTern();
    // }
    editor.on("keydown", (...args) => this.keyEvent(...args));
  }

  keyEvent(cm, evt) {
    this.shadowText.handleKeyEvent(cm, evt);
    return self.__CodeMirrorModes__(this, cm).handleKeyEvent(evt);
  }

  get shadowText() {
    return __CodeMirrorShadowText__(this, this.editor)
  }

  clearHistory() {
    var cm = this.editor;
    cm.getDoc().clearHistory();
  }

  addKeys(keymap) {
    var keys = this.ensureExtraKeys();
    this.extraKeys = Object.assign(keys, keymap);
  }

  ensureExtraKeys() {
    if (!this.extraKeys) {
      const keys = '1234567890qwertyuiopasdf hjk zxcvbnm'.replace(/\s/, '').split('').map(c => c.upperCase());
      //       const defaultASTHandlers = _.fromPairs(keys.flatMap(c => {
      //         return ['Alt-', 'Ctrl-Alt-', 'Shift-Alt-'].map(shortcut => {
      //           const shortcutC = shortcut + c;
      //           const prop = shortcutC.camelCase();

      //           return [shortcutC, cm => {
      //             this.astCapabilities(cm).then(ac => {
      //               if (ac[prop]) {
      //                 ac[prop]();
      //               } else {
      //                 lively.notify(shortcutC, 'No Shortcut defined yet.');
      //               }
      //             });
      //           }];
      //         });
      //       }));
      const defaultASTHandlers = {};

      const enterPsychMode = (cm, which, inclusive) => {
        self.__CodeMirrorModes__(this, cm).pushMode('psych', { command: which, inclusive });
      };

      this.extraKeys = Object.assign(defaultASTHandlers, {

        // #KeyboardShortcut Alt-X shortcut for experimental features
        "Alt-X": cm => this.astCapabilities.braveNewWorld(),


        // #KeyboardShortcut Alt-Enter enter 'command' mode
        "Alt-Enter": cm => self.__CodeMirrorModes__(this, cm).pushMode('command'),
        // #KeyboardShortcut Alt-I Inline variable
        "Alt-I": cm => {
          this.astCapabilities.inlineLocalVariable();
        },

        // #KeyboardShortcut Alt-E Extract Expression into a local variable
        "Alt-E": cm => {
          this.astCapabilities.extractExpressionIntoLocalVariable();
        },
        // #KeyboardShortcut Alt-R Rename this identifier
        "Alt-R": cm => {
          this.astCapabilities.rename();
        },
        // #KeyboardShortcut Alt-T enter 'case' mode
        "Alt-T": cm => self.__CodeMirrorModes__(this, cm).pushMode('case'),

        // #KeyboardShortcut Alt-A Select current list item
        "Alt-A": cm => this.astCapabilities.selectCurrentItem(),

        // #KeyboardShortcut Shift-Alt-S slurp backward
        "Shift-Alt-S": cm => this.astCapabilities.slurp(false),
        // #KeyboardShortcut Alt-S slurp forward
        "Alt-S": cm => this.astCapabilities.slurp(true),

        // #KeyboardShortcut Alt-D psych within (smart): paste group surrounding mouse position enclosed by brackets, braces, or quotes (exclusive)
        "Alt-D": cm => this.astCapabilities.psychInSmart(false),
        // #KeyboardShortcut Shift-Alt-D psych within (smart): paste group surrounding mouse position enclosed by brackets, braces, or quotes (inclusive)
        "Shift-Alt-D": cm => this.astCapabilities.psychInSmart(true),
        // #KeyboardShortcut Alt-F psych within: paste group surrounding mouse position with (exclusive) <character>
        "Alt-F": cm => enterPsychMode(cm, 'psychIn', false),
        // #KeyboardShortcut Shift-Alt-F psych within: paste group surrounding mouse position with (inclusive) <character>
        "Shift-Alt-F": cm => enterPsychMode(cm, 'psychIn', true),

        // #KeyboardShortcut Ctrl-Alt-C enter 'psych' mode
        "Ctrl-Alt-C": cm => self.__CodeMirrorModes__(this, cm).pushMode('psych'),
        // #KeyboardShortcut Alt-C psych: paste word from mouse position
        "Alt-C": cm => this.astCapabilities.psych(),
        // #KeyboardShortcut Shift-Alt-C psych each: paste word part from mouse position
        "Shift-Alt-C": cm => this.astCapabilities.psychEach(),
        // #KeyboardShortcut Alt-V psych to (exclusive): paste from word on mouse position up to (exclusive) <character>
        "Alt-V": cm => enterPsychMode(cm, 'psychTo', false),
        // #KeyboardShortcut Shift-Alt-V psych to (inclusive): paste from word on mouse position up to (inclusive) <character>
        "Shift-Alt-V": cm => enterPsychMode(cm, 'psychTo', true),

        // #KeyboardShortcut Shift-Alt-B barf upward
        "Shift-Alt-B": cm => this.astCapabilities.barf(false),
        // #KeyboardShortcut Alt-B barf downward
        "Alt-B": cm => this.astCapabilities.barf(true),

        // #KeyboardShortcut Alt-N negate an expression
        "Alt-N": cm => this.astCapabilities.negateExpression(),
        // #KeyboardShortcut Alt-U Replace parent node with selection
        "Alt-U": cm => this.astCapabilities.replaceParentWithSelection(),
        // #KeyboardShortcut Alt-O Insert new line below
        "Alt-O": cm => this.astCapabilities.newlineAndIndent(true),
        // #KeyboardShortcut Shift-Alt-O Insert new line above
        "Shift-Alt-O": cm => this.astCapabilities.newlineAndIndent(false),

        // #KeyboardShortcut Alt-/ insert markdown comment
        "Alt-/": cm => this.astCapabilities.insertMarkdownComment(),

        // #KeyboardShortcut Alt-M ast refactoring/autocomplete menu
        "Alt-M": cm => {
          if (this.isJavaScript) {
            this.openContextMenu(cm);
          } else {
            lively.warn("Context Menu doesn't work outside of js files for now!");
          }
        },

        "Insert": cm => {
          // do nothing... the INSERT mode is so often activated by accident 
        },
        "Ctrl-Insert": cm => {
          // INSERT mode is so often activated by accident, require CTRL now 
          cm.toggleOverwrite();
        },
        // #KeyboardShortcut Ctrl-H search and replace
        "Ctrl-H": cm => {
          setTimeout(() => {
            this.editor.execCommand("replace");
            this.shadowRoot.querySelector(".CodeMirror-search-field").focus();
          }, 10);
        },
        // #KeyboardShortcut Ctrl-Space auto complete
        "Ctrl-Space": cm => {
          // lively.notify("fix hints position")
          this.fixHintsPosition();
          cm.execCommand("autocomplete");
        },
        // #KeyboardShortcut Ctrl-Alt-Space auto complete
        "Ctrl-Alt-Space": cm => {
          this.fixHintsPosition();
          cm.execCommand("autocomplete");
        },
        // #KeyboardShortcut Ctrl-P eval and print selection or line
        "Ctrl-P": cm => {
          let text = this.getSelectionOrLine();
          this.tryBoundEval(text, true);
        },
        // #KeyboardShortcut Ctrl-Shift-Alt-P add watch at position
        "Ctrl-Shift-Alt-P": cm => {
          this.addProbeCode(cm)
        },
        // #KeyboardShortcut Ctrl-Alt-U complete code snippet using experimental local SWACopilot 
        "Ctrl-Alt-U": cm => {
          let text = this.getSelectionOrLine();
          this.trySWACopilot(text, true);
        },
        // #KeyboardShortcut Ctrl-I eval and inspect selection or line
        "Ctrl-I": cm => {
          let text = this.getSelectionOrLine();
          this.inspectIt(text);
        },
        // #KeyboardShortcut Ctrl-D eval selection or line (do it)
        "Ctrl-D": (cm, b, c) => {
          let text = this.getSelectionOrLine();
          this.tryBoundEval(text, false);
          return true;
        },
        // #HACK we seem to be incapable of dealling automatically with code snippets that contain "await", so lets make it explicit #FutureWork #Research
        // #KeyboardShortcut Shift-Ctrl-D async eval selection or line (do it)
        "Shift-Ctrl-D": (cm, b, c) => {
          let text = this.getSelectionOrLine();
          this.tryBoundEval(";(async () => { " + text + "})()", false);
          return true;
        },
        // #KeyboardShortcut Ctrl-F search
        "Ctrl-F": cm => {
          // something immediately grabs the "focus" and we close the search dialog..
          // #Hack...
          setTimeout(() => {
            this.editor.execCommand("findPersistent");
            var searchField = this.shadowRoot.querySelector(".CodeMirror-search-field");
            if (searchField) {
              // start with the last search..
              if (!searchField.value && this.lastSearch) {
                var oldSearch = searchField.value;
                searchField.value = this.lastSearch;
              } else {
                this.lastSearch = searchField.value; // we got a new search
              }
              lively.addEventListener("lively4", searchField, "input", () => {
                this.lastSearch = searchField.value;
              });
              searchField.focus();
              searchField.select();
            }
          }, 10
            // editor.execCommand("find")
          );
        },

        // #KeyboardShortcut Ctrl-Alt-Right multiselect next
        "Ctrl-Alt-Right": "selectNextOccurrence",
        // #KeyboardShortcut Ctrl-Alt-Left undo multiselect
        "Ctrl-Alt-Left": "undoSelection",

        // #KeyboardShortcut Ctrl-/ indent selection
        "Ctrl-/": "toggleCommentIndented",
        // #KeyboardShortcut Ctrl-# indent selection
        "Ctrl-#": "toggleCommentIndented",
        // #KeyboardShortcut Tab insert tab or soft indent
        'Tab': cm => {
          if (cm.somethingSelected()) {
            cm.indentSelection("add");
          } else {
            cm.execCommand('insertSoftTab');
          }
        },
        // #KeyboardShortcut Ctrl-S save content
        "Ctrl-S": cm => {
          this.doSave(cm.getValue());
        },

        // #KeyboardShortcut Ctrl-Alt-V eval and open in vivide
        "Ctrl-Alt-V": async cm => {
          let text = this.getSelectionOrLine();
          let result = await this.tryBoundEval(text, false);
          letsScript(result);
        },
        // #KeyboardShortcut Ctrl-Alt-I show type using tern
        "Ctrl-Alt-I": cm => {
          this.ternWrapper.then(tw => tw.showType(cm, this));
        },
        // #KeyboardShortcut Alt-. jump to definition using tern
        "Alt-.": cm => {
          lively.notify("try to JUMP TO DEFINITION");
          this.ternWrapper.then(tw => tw.jumpToDefinition(cm, this));
        },
        // #KeyboardShortcut Alt-, jump back from definition using tern
        "Alt-,": cm => {
          this.ternWrapper.then(tw => tw.jumpBack(cm, this));
        },
        // #KeyboardShortcut Shift-Alt-. show references using tern
        "Shift-Alt-.": cm => {
          this.ternWrapper.then(tw => tw.showReferences(cm, this));
        },

        /*MD #AST-Navigation MD*/

        // #KeyboardShortcut Alt-Up Expand selection in ast-aware manner
        "Alt-Up": cm => {
          this.astCapabilities.expandSelection(cm);
        },
        // #KeyboardShortcut Alt-Down Reduce selection in ast-aware manner
        "Alt-Down": cm => {
          this.astCapabilities.reduceSelection(cm);
        },
        // #KeyboardShortcut Alt-Shift-Up Select previous like this
        "Shift-Alt-Up": cm => {
          this.astCapabilities.selectNextASTNodeLikeThis(true);
        },
        // #KeyboardShortcut Alt-Shift-Down Select next like this
        "Shift-Alt-Down": cm => {
          this.astCapabilities.selectNextASTNodeLikeThis(false);
        },

        // #KeyboardShortcut Alt-Left Select previous element in ast-aware manner
        "Alt-Left": cm => {
          this.astCapabilities.selectNextASTChild(true);
        },
        // #KeyboardShortcut Alt-Right Select next element in ast-aware manner
        "Alt-Right": cm => {
          this.astCapabilities.selectNextASTChild(false);
        },
        // #KeyboardShortcut Alt-Shift-Left Select previous reference
        "Shift-Alt-Left": cm => {
          this.astCapabilities.selectNextReference(true);
        },
        // #KeyboardShortcut Alt-Shift-Right Select next reference
        "Shift-Alt-Right": cm => {
          this.astCapabilities.selectNextReference(false);
        },

        // #KeyboardShortcut Shift-Alt-U Jump to declaration of this identifier
        "Shift-Alt-U": cm => {
          this.astCapabilities.selectDeclaration();
        },

        // #KeyboardShortcut Alt-Backspace Leave Editor and go to Navigation
        "alt-Backspace": async cm => {
          this.singalEditorbackNavigation();
        },
        // #KeyboardShortcut Shift-Alt-Backspace Leave and Close Editor and go to Navigation
        "shift-alt-Backspace": async cm => {
          this.singalEditorbackNavigation(true);
        },
        // #KeyboardShortcut Shift-Alt-A show additional info of this Active Expression
        "Shift-Alt-A": async cm => {
          this.showAExprDependencyTextMarkers();
        },
        // #Async #Workspace #Snippet #Workaround missing global async/await support in JavaScript / our Workspaces
        "Ctrl-Alt-A": cm => {
          var selection = this.editor.getSelection
            // #TODO how can we have custom snippets?
            (); this.editor.replaceSelection(`var value;
(async () => {
  value = ${selection}
})()`);
          this.editor.execCommand(`goWordLeft`);
          this.editor.execCommand(`goCharLeft`);
        },
        // #KeyboardShortcut Ctrl-Shift-A Update Active Expression Dependencies
        "Ctrl-Shift-A": cm => {
          this.updateAExprDependencies();
        },
        "F4": cm => { },
        "F9": cm => { }

      });
    }

    return this.extraKeys;
  }

  openContextMenu(cm) {
    openMenu(this.astCapabilities, cm, this);
  }

  async singalEditorbackNavigation(closeEditor) {
    var container = lively.query(this, "lively-container");
    if (container) {
      if (closeEditor) await container.onCancel();
      await lively.sleep(10
        // it seems not to bubble across shadow root boundaries #Bug ?
        // so we do it manually, but keep it an event
      ); container.dispatchEvent(new CustomEvent("editorbacknavigation", {
        bubbles: true,
        cancelable: true
      }));
    }
  }

  registerExtraKeys(options) {
    if (options) this.addKeys(options)
    var keys = {}
    keys = Object.assign(keys, CodeMirror.keyMap.sublime)
    keys = Object.assign(keys, this.ensureExtraKeys())
    this.editor.setOption("extraKeys", CodeMirror.normalizeKeyMap(keys))
  }

  setupEditorOptions(editor) {
    editor.setOption("matchBrackets", true)
    editor.setOption("styleSelectedText", true)
    editor.setOption("autoCloseBrackets", Preferences.get("CodeMirrorAutoCloseBrackets"))
    editor.setOption("autoCloseTags", true)
    if (CodeMirror.scrollbarModel.simple) {
      editor.setOption("scrollbarStyle", "simple")
    }
    editor.setOption("autoRefresh", { delay: 10 })


    editor.setOption("tabSize", indentationWidth())
    editor.setOption("indentWithTabs", false)
    editor.setOption("indentUnit", indentationWidth())

    editor.setOption("highlightSelectionMatches", {
      showToken: /\w/,
      annotateScrollbar: true
    })

    editor.on("cursorActivity", cm => {
      if (this.ternLoaded) {
        this.ternWrapper.then(tw => tw.updateArgHints(cm, this));
      }
    })

    // http://bl.ocks.org/jasongrout/5378313#fiddle.js
    editor.on("cursorActivity", cm => {
      // this.ternWrapper.then(tw => tw.updateArgHints(cm, this));
      const widgetEnter = cm.widgetEnter
      cm.widgetEnter = undefined
      if (widgetEnter) {
        // check to see if movement is purely navigational, or if it
        // doing something like extending selection
        var cursorHead = cm.getCursor('head')
        var cursorAnchor = cm.getCursor('anchor')
        if (posEq(cursorHead, cursorAnchor)) {
          widgetEnter()
        }
      }
    })

    editor.setOption("hintOptions", {
      container: this.shadowRoot.querySelector("#code-mirror-hints"),
      codemirror: this,
      closeCharacters: /\;/ // we want to keep the hint open when typing spaces and "{" in imports...
    })

    this.registerExtraKeys()
  }

  // Fires when an attribute was added, removed, or updated
  attributeChangedCallback(attr, oldVal, newVal) {
    if (!this.editor) {
      return false;
    }
    switch (attr) {
      // case "theme":
      //     this.editor.setTheme( newVal );
      //     break;
      case "mode":
        this.editor.setOption('mode', newVal);
        break;
      // case "fontsize":
      //     this.editor.setFontSize( newVal );
      //     break;
      // case "softtabs":
      //     this.editor.getSession().setUseSoftTabs( newVal );
      //     break;
      // case "tern":
      //   if (newVal)
      // this.enableTern()
      //   break;

      case "tabsize":
        this.setOption("tabSize", newVal);
        break;
      // case "readonly":
      //     this.editor.setReadOnly( newVal );
      //     break;
      case "wrapmode":
        this.setOption("lineWrapping", newVal);
        break;
      case "sandblocks":
        if (newVal === "true") {
          this.enableSandblocks();
        } else if (newVal === "false" || newVal === null) {
          this.disableSandblocks();
        }
        break;
    }
  }

  setOption(name, value) {
    if (!this.editor) return; // we loose...
    this.editor.setOption(name, value);
  }

  doSave(text) {
    this.tryBoundEval(text // just a default implementation...
    );
  }

  getSelectionOrLine() {
    var text = this.editor.getSelection();
    if (text.length > 0) return text; else return this.editor.getLine(this.editor.getCursor("end").line);
  }

  getDoitContext() {
    return this.doitContext || window.that;
  }

  setDoitContext(context) {
    return this.doitContext = context;
  }

  getTargetModule() {
    // lazily initialize a target module name as fallback
    return this.targetModule || (this.targetModule = lively4url + '/unnamed_module_' + generateUUID().replace(/-/g, '_')); // make it relative to a real path so that relative modules work
  }

  setTargetModule(module) {
    return this.targetModule = module;
  }

  async boundEval(str) {
    // console.log("bound eval " + str)
    var targetModule = this.getTargetModule();

    if (targetModule.match(/.py$/)) {
      return this.boundEvalPython(str);
    }
    // Ensure target module loaded (for .js files only)
    if (targetModule.match(/.js$/)) {
      await System.import(targetModule);
    }
    console.log("EVAL (CM)", targetModule);
    // src, topLevelVariables, thisReference, <- finalStatement
    return boundEval(str, this.getDoitContext(), targetModule);
  }

  async boundEvalPython(str) {
    var result = "";
    var xterm = document.querySelector("lively-xterm.python");
    if (xterm) {
      var term = xterm.term;
      term.__socket.addEventListener('message', function (event) {
        result += event.data;
      });
      // how long do we want to wait?

      term.__sendData(str + "\n");

      while (!result.match(">>>")) {
        // busy wait for the prompt
        await lively.sleep(50);
      }
      // strip input and prompt.... oh what a hack
      return { value: result.replace(str, "").replace(/^[\r\n]+/, "").replace(/>>> $/, "") };
    } else {
      lively.notify("no open python terminal session found");
    }
    return { value: "" };
  }

  printWidget(name) {
    return this.wrapWidget(name, this.editor.getCursor(true), this.editor.getCursor(false));
  }

  wrapWidget(name, from, to, options) {
    var widget = document.createElement("span");
    widget.classList.add("lively-widget");
    widget.style.whiteSpace = "normal";
    var promise = lively.create(name, widget);
    promise.then(comp => {
      Object.assign(comp.style, {
        display: "inline",
        // backgroundColor: "rgb(250,250,250)",
        display: "inline-block",
        minWidth: "20px",
        minHeight: "20px"
      });
    });
    // #TODO, we assume that it will keep the first widget, and further replacements do not work.... and get therefore thrown away
    var marker = this.editor.doc.markText(from, to, Object.assign({
      replacedWith: widget
    }, options));
    promise.then(comp => comp.marker = marker);

    return promise;
  }

  wrapWidgetSync(name, from, to, options) {
    var widget = document.createElement("span");
    widget.classList.add("lively-widget");
    widget.style.whiteSpace = "normal";
    var comp = document.createElement(name);
    widget.appendChild(comp)

    Object.assign(comp.style, {
      display: "inline",
      // backgroundColor: "rgb(250,250,250)",
      display: "inline-block",
      minWidth: "20px",
      minHeight: "20px"
    });

    // #TODO, we assume that it will keep the first widget, and further replacements do not work.... and get therefore thrown away
    var marker = this.editor.doc.markText(from, to, Object.assign({
      replacedWith: widget
    }, options));
    comp.marker = marker;
    marker.component = comp;

    return comp;
  }

  async printResult(result, obj, isPromise) {
    var editor = this.editor;
    var text = result;
    var isAsync = false;
    this.editor.setCursor(this.editor.getCursor("end"
      // don't replace existing selection
    )); this.editor.replaceSelection(result, "around");
    if (obj && obj.__asyncresult__) {
      obj = obj.__asyncresult__; // should be handled in bound-eval.js #TODO
      isAsync = true;
    }
    var promisedWidget;
    var objClass = obj && obj.constructor && obj.constructor.name || typeof obj;
    if (isSet.call(obj)) {
      obj = Array.from(obj);
    }

    if (_.isMap(obj)) {
      var mapObj = {};
      Array.from(obj.keys()).sort().forEach(key => mapObj[key] = obj.get(key));
      obj = mapObj;
    }
    if (Array.isArray(obj) && !obj.every(ea => ea instanceof Node)) {
      if (obj.every(ea => typeof ea == 'object' && !(ea instanceof String))) {
        promisedWidget = this.printWidget("lively-table").then(table => {
          table.setFromJSO(obj);
          table.style.maxHeight = "300px";
          table.style.overflow = "auto";
          return table;
        });
      } else {
        promisedWidget = this.printWidget("lively-table").then(table => {
          table.setFromJSO(obj.map((ea, index) => {
            return {
              index: index,
              value: this.ensuredPrintString(ea)
            };
          }));
          table.style.maxHeight = "300px";
          table.style.overflow = "auto";
          return table;
        });
      }
    } else if (objClass == "Matrix") {
      // obj = obj.toString()
      debugger;
    } else if (typeof obj == 'object' && obj !== null) {
      promisedWidget = this.printWidget("lively-inspector").then(inspector => {
        inspector.inspect(obj);
        inspector.hideWorkspace();
        return inspector;
      });
    }
    if (promisedWidget) {
      var widget = await promisedWidget;
      var span = <span style="border-top:2px solid darkgray;color:darkblue">
        {isPromise ? "PROMISED" : ""} <u>:{objClass}</u> </span>;
      widget.parentElement.insertBefore(span, widget);
      span.appendChild(widget);
      if (isAsync && promisedWidget) {
        if (widget) widget.style.border = "2px dashed blue";
      }
    }
  }

  ensuredPrintString(obj) {
    var s = "";
    try {
      s += obj; // #HACK some objects cannot be printed any more
    } catch (e) {
      s += `UnprintableObject[Error: ${e}]`; // so we print something else
    }
    return s;
  }



  async tryBoundEval(str, printResult) {
    var resp = await this.boundEval(str);
    if (resp.isError) {
      var e = resp.value;
      console.error(e);
      if (printResult) {
        window.LastError = e;
        this.printResult(stripErrorString("" + e));
      } else {
        lively.handleError(e);
      }
      return e;
    }
    var result = resp.value;

    if (printResult) {
      // alaways wait on promises.. when interactively working...
      if (result && result.then) {
        //  && result instanceof Promise
        // we will definitly return a promise on which we can wait here
        result.then(result => {
          this.printResult("RESOLVED: " + this.ensuredPrintString(result), result, true);
        }).catch(error => {
          console.error(error);
          // window.LastError = error;
          this.printResult(stripErrorString("Error in Promise: \n" + error));
        });
      } else {
        this.printResult(" " + this.ensuredPrintString(result), result);
        if (result instanceof HTMLElement) {
          try {
            lively.showElement(result);
          } catch (e) {
            // silent fail... not everything can be shown...
          }
        }
      }
    }
    return result;
  }

  async inspectIt(str) {
    var result = await this.boundEval(str);
    if (!result.isError) {
      result = result.value;
    }
    if (result.then) {
      result = await result; // wait on any promise
    }
    lively.openInspector(result, undefined, str);
  }

  async trySWACopilot(text) {
    var start = Date.now()
    var result = await fetch(`https://lively-kernel.org/swacopilot?prime=True&maxlength=300&temperature=0.8&text=` +
      encodeURIComponent(text)).then(r => r.json())
    if (result.generation) {
      this.editor.setCursor(this.editor.getCursor("end"));
      this.editor.replaceSelection(result.generation, "around");
    }
    lively.notify("SWA Copilot: " + (Date.now() - start) + "ms")
  }

  doSave(text) {
    this.tryBoundEval(text // just a default implementation...
    );
  }

  disconnectedCallback() {
    this.shadowText.handleDetachedCM()
    this._attached = false;
  }

  getWidgets() {
    return this.shadowRoot.querySelectorAll(".inline-embedded-widget")
  }

  ensureTextContent() {
    this.getWidgets().forEach(ea => {
      if (ea.updateRangePreSave) {
        ea.updateRangePreSave()
      }
    })
  }

  // #important
  get value() {
    if (this.editor) {
      this.ensureTextContent()
      return this.editor.getValue();
    } else {
      return this._value;
    }
  }

  set value(text) {
    if (text === undefined) text = "";
    if (this.editor) {
      this.editor.setValue(text);
    } else {
      this._value = text;
    }
    lively.sleep(0).then(() => {
      if (this.editor) this.editor.refresh();
    });
  }

  setCustomStyle(source) {
    this.shadowRoot.querySelector("#customStyle").textContent = source;
  }

  getCustomStyle(source) {
    return this.shadowRoot.querySelector("#customStyle").textContent;
  }

  encodeHTML(s) {
    return s.replace("&", "&amp;").replace("<", "&lt;");
  }

  decodeHTML(s) {
    return s.replace("&lt;", "<").replace("&amp;", "&");
  }

  resize() {
    // #ACE Component compatiblity
  }

  enableAutocompletion() {
    // #ACE Component compatiblity
  }

  get isJavaScript() {
    if (!this.editor) return false;
    let mode = this.editor.getOption("mode");
    return mode === "javascript" || mode === 'text/jsx';
  }

  get isMarkdown() {
    if (!this.editor) return false;
    return this.editor.getOption("mode") == "gfm";
  }

  get isHTML() {
    if (!this.editor) return false;
    return this.editor.getOption("mode") == "text/html";
  }

  async changeModeForFile(filename) {
    if (!this.editor) return;

    var mode = "text";
    // #TODO there must be some kind of automatching?
    if (filename.match(/\.html$/)) {
      mode = "text/html";
    } else if (filename.match(/\.md$/)) {
      mode = "gfm";
    } else if (filename.match(/\.tex$/)) {
      mode = "text/x-stex";
    } else if (filename.match(/\.css$/)) {
      mode = "css";
    } else if (filename.match(/\.xml$/)) {
      mode = "xml";
    } else if (filename.match(/\.java$/)) {
      mode = "text/x-java";
    } else if (filename.match(/\.json$/)) {
      mode = "javascript";
    } else if (filename.match(/\.js$/)) {
      mode = "text/jsx";
    } else if (filename.match(/\.mjs$/)) {
      mode = "text/jsx";
    } else if (filename.match(/\.ts$/)) {
      mode = "text/typescript";
    } else if (filename.match(/\.py$/)) {
      mode = "text/x-python";
    } else if (filename.match(/\.c$/)) {
      mode = "text/x-csrc";
    } else if (filename.match(/\.cpp$/)) {
      mode = "text/x-c++src";
    } else if (filename.match(/\.h$/)) {
      mode = "text/x-c++src";
    } else if (filename.match(/\.sh$/) || filename.match(/Makefile/)) {
      mode = "text/x-sh";
    }

    this.mode = mode;
    this.editor.setOption("mode", mode);
    if (mode == "gfm" || mode == "text/x-stex") {
      // #TODO make language customizable
      var m = this.value.match(/^.*lang\:(.._..)/);
      if (m) {
        var lang = m[1];
        var dict = await spellCheck.loadDictLang(lang);
        if (dict) {
          lively.notify("start spell checking lang: " + lang);
          spellCheck.startSpellCheck(this.editor, dict);
        } else {
          console.log("spellchecking language not found: " + lang);
        }
      } else {
        spellCheck.startSpellCheck(this.editor, (await spellCheck.current()));
      }
    }
    if (mode == "text/x-sh") {
      this.editor.setOption("indentWithTabs", true);
    }
  }

  livelyPrepareSave() {
    if (!this.editor) {
      return;
    }
    this.textContent = this.encodeHTML(this.editor.getValue());
  }

  livelyPreMigrate() {
    if (this.editor) {
      this.lastScrollInfo = this.editor.getScrollInfo(); // #Example #PreserveContext
    }
  }

  focus() {
    if (this.editor) {
      // if (this.editor.options.readOnly == "nocursor") {
      //   // console.warn("[lively-code-mirror] prevent focus")
      //   return
      // }
      this.editor.focus();
    }
  }

  isFocused(doc) {
    doc = doc || document;
    if (doc.activeElement === this) return true;
    // search recursively in shadowDoms
    if (doc.activeElement && doc.activeElement.shadowRoot) {
      return this.isFocused(doc.activeElement.shadowRoot);
    }
    return false;
  }

  async livelyMigrate(other) {
    lively.addEventListener("Migrate", this, "editor-loaded", evt => {
      if (evt.composedPath()[0] !== this) return; // bubbled from another place... that is not me!
      lively.removeEventListener("Migrate", this, "editor-loaded"); // make sure we migrate only once
      this.value = other.value;
      if (other.lastScrollInfo) {
        this.editor.scrollTo(other.lastScrollInfo.left, other.lastScrollInfo.top);
      }
    });
  }

  fixHintsPosition() {
    // #TODO not needed any more, beccause we fix it directly src/external/code-mirror/addon/hint/show-hint.js
  }



  async addTernFile(name, url, text) {
    if (!this.ternServer) return;
    url = url || name;
    text = text || (await fetch(url).then(r => r.text()));
    this.ternServer.server.addFile(name, text);
  }

  mergeView(originalText, originalLeftText) {
    var target = this.shadowRoot.querySelector("#code-mirror-container");
    target.innerHTML = "";
    this._mergeView = CodeMirror.MergeView(target, {
      value: this.value,
      origLeft: originalLeftText,
      orig: originalText,
      lineNumbers: true,
      mode: this.editor.getOption('mode'),
      scrollbarStyle: this.editor.getOption('scrollbarStyle'),
      highlightDifferences: true,
      connect: "align",
      lineWrapping: true,
      collapseIdentical: false
    });
    // if (this._mergeView.right) {
    // this.setEditor(this._mergeView.right.edit)
    // }
    this.setEditor(this._mergeView.editor
      // this.resizeMergeView(this._mergeView)
      ());
  }

  resizeMergeView(mergeView) {
    function editorHeight(editor) {
      if (!editor) return 0;
      return editor.getScrollInfo().height;
    }

    function mergeViewHeight(mergeView) {
      return Math.max(editorHeight(mergeView.leftOriginal()), editorHeight(mergeView.editor()), editorHeight(mergeView.rightOriginal()));
    }
    var height = mergeViewHeight(mergeView);
    for (; ;) {
      if (mergeView.leftOriginal()) mergeView.leftOriginal().setSize(null, height);
      mergeView.editor().setSize(null, height);
      if (mergeView.rightOriginal()) mergeView.rightOriginal().setSize(null, height);

      var newHeight = mergeViewHeight(mergeView);
      if (newHeight >= height) break; else height = newHeight;
    }
    mergeView.wrap.style.height = height + "px";
  }

  async hideDataURLs() {
    var regEx = new RegExp("[\"\'](data:[^\"\']*)[\"\']", "g");
    do {
      var m = regEx.exec(this.value);
      if (m) {
        var from = m.index;
        var to = m.index + m[0].length;
        await this.wrapWidget("span", this.editor.posFromIndex(from), this.editor.posFromIndex(to)).then(div => {
          div.style.backgroundColor = "rgb(240,240,240)";

          if (m[1].match(/^data:image/)) {
            var img = document.createElement("img");
            img.src = m[1];
            img.title = m[1].slice(0, 50) + "...";
            img.style.maxHeight = "100px";

            div.appendChild(document.createTextNode("\""));
            div.appendChild(img);
            div.appendChild(document.createTextNode("\""));
          } else {
            div.innerHTML = "\"" + m[1].slice(0, 50) + "..." + "\"";
          }
        });
      }
    } while (m);
  }

  //    async wrapImageLinks() {
  //     var regEx = new RegExp("\!\[\]\(([A-Za-z0-9_ .]\.((jpg)|(png)))$\)", "g");
  //     do {
  //       var m = regEx.exec(this.value);
  //       if (m) {
  //         var from = m.index
  //         var to = m.index + m[0].length
  //         var url = m[1]
  //         await this.wrapWidget("span", this.editor.posFromIndex(from),
  //                               this.editor.posFromIndex(to)).then( div => {
  //           div.style.backgroundColor = "rgb(240,240,240)"

  //           if (m[1].match(/^data:image/)) {
  //             var img = document.createElement("img")
  //             img.src = m[1]
  //             img.title = m[1].slice(0,50) + "..."
  //             img.style.maxHeight = "100px"

  //             div.appendChild(document.createTextNode("\""))
  //             div.appendChild(img)
  //             div.appendChild(document.createTextNode("\""))
  //           } else {
  //             div.innerHTML = "\""+ m[1].slice(0,50) + "..." + "\""
  //           }
  //         })

  //       }
  //     } while (m);
  //   }


  async wrapImports() {
    // dev mode alternative to #DevLayers, a #S3Pattern: add code the scopes your dev example inline while developing
    if (this.id !== 'spike') {
      // lively.warn('skip because id is not spike')
      return;
    }
    // lively.success('wrap imports in spike')

    const getImportDeclarationRegex = () => {
      const LiteralString = `(["][^"\\n\\r]*["]|['][^'\\n\\r]*['])`;
      const JavaScriptIdentifier = '([a-zA-Z$_][a-zA-Z0-9$_]*)';

      const ImportSpecifierPartSimple = `(${JavaScriptIdentifier})`;
      const ImportSpecifierPartRename = `(${JavaScriptIdentifier}\\s+as\\s+${JavaScriptIdentifier})`;
      const ImportSpecifierPart = `(${ImportSpecifierPartSimple}|${ImportSpecifierPartRename})`;
      // ImportSpecifier: {foo} or {foo as bar}
      const ImportSpecifier = `({\\s*((${ImportSpecifierPart}\\s*\\,\\s*)*${ImportSpecifierPart}\\,?)?\\s*})`;
      // ImportDefaultSpecifier: foo
      const ImportDefaultSpecifier = `(${JavaScriptIdentifier})`;
      // ImportNamespaceSpecifier: * as foo
      const ImportNamespaceSpecifier = `(\\*\\s*as\\s+${JavaScriptIdentifier})`;
      const anySpecifier = `(${ImportSpecifier}|${ImportDefaultSpecifier}|${ImportNamespaceSpecifier})`;
      // ImportDeclaration: import [any] from Literal
      const ImportDeclaration = `import\\s*(${anySpecifier}\\s*\\,\\s*)*${anySpecifier}\\s*from\\s*${LiteralString}(\\s*\\;)?`;

      return ImportDeclaration;
    };

    var regEx = new RegExp(getImportDeclarationRegex(), 'g');

    do {
      var m = regEx.exec(this.value);
      if (m) {
        await LivelyCodeMirrorWidgetImport.importWidgetForRange(this, m);
      }
    } while (m);
  }

  async wrapLinks() {
    // dev mode
    if (this !== window.that) {
      return;
    }
    var regEx = new RegExp("\<([a-zA-Z0-9]+\:\/\/[^ ]+)\>", "g");
    do {
      var m = regEx.exec(this.value);
      if (m) {
        lively.warn("wrap link: " + m[0]);
        var from = m.index;
        var to = m.index + m[0].length;
        var link = m[1];
        // #TODO check for an existing widget and reuse / update it...
        await this.wrapWidget("span", this.editor.posFromIndex(from), this.editor.posFromIndex(to)).then(widget => {
          window.lastWidget = widget;

          widget.style.backgroundColor = "rgb(120,120, 240)";
          var input = <input></input>;
          input.value = m[0];

          lively.warn("new input " + input);

          input.addEventListener("keydown", evt => {
            var range = widget.marker.find();
            if (evt.keyCode == 13) {
              // ENTER
              // #TODO how to replace // update text without replacing widgets
              this.editor.replaceRange(input.value, range.from, range.to // @Stefan, your welcome! ;-)
              ); this.wrapLinks // don't wait and do what you can now
                ();
            }
            if (evt.keyCode == 37) {
              // Left
              if (input.selectionStart == 0) {
                this.editor.setSelection(range.from, range.from);
                this.focus();
              }
            }

            if (evt.keyCode == 39) {
              // Right
              if (input.selectionStart == input.value.length) {
                this.editor.setSelection(range.to, range.to);
                this.focus();
              }
            }
          });

          widget.appendChild(input
            // widget.appendChild(<button click={e => {
            //   lively.openBrowser(link)  // #TODO fix browse and open browser...
            // }}>browse</button>)
          );
        });
      }
    } while (m);
  }

  checkSyntax() {
    const isJavaScript = this.isJavaScript;
    const isMarkdown = this.isMarkdown;
    const isHTML = this.isHTML;

    if (isJavaScript) {
      SyntaxChecker.checkForSyntaxErrors(this.editor);
      // this.wrapImports();
      this.wrapLinks();
    }
    if (isMarkdown || isHTML) {
      this.hideDataURLs();
    }

    if (isJavaScript || isMarkdown || isHTML) {
      this.wrapProbes()
    }
  }

  async isInValidGitRepo() {
    const livelyEditor = lively.query(this, "lively-editor")
    if (!livelyEditor) return false

    const fileURL = livelyEditor.getURL()
    if (!fileURL) return false

    // Get the base URL (parent of lively4url)
    const lively4BaseURL = new URL("..", lively4url).href

    // File must be under the base URL
    if (!fileURL.href.startsWith(lively4BaseURL)) return false

    // Extract the repository path (first path segment after base)
    const relativePath = fileURL.href.replace(lively4BaseURL, "")
    const repoName = relativePath.split("/")[0]

    // Skip known non-git paths
    const excludedPaths = ["OneDrive", "Dropbox"]
    if (excludedPaths.includes(repoName)) return false

    // For now, assume all other paths under the base URL are valid git repos
    // TODO: Re-enable .git directory check when server issues are resolved
    return true
  }

  async updateGitStatus() {
    if (!this.editor) return

    // Check preference (default on)
    if (Preferences.get("ShowGitStatusIndicators") === false) return

    // Check if file is in a valid git repository
    if (!await this.isInValidGitRepo()) return

    // Find parent lively-editor
    const livelyEditor = lively.query(this, "lively-editor")
    if (!livelyEditor || !livelyEditor.getLineChangeStatus) return

    try {
      const changes = await livelyEditor.getLineChangeStatus()
      await this.updateGitStatusIndicators(changes)
    } catch (error) {
      console.log("Git status update failed:", error)
    }
  }


  updateGitScrollbarAnnotations(filteredSets) {
    // filteredSets already contains pre-filtered Sets: { unsaved, uncommitted, unpushed }
    this.updateGitScrollbarAnnotation("unsaved", filteredSets.unsaved)
    this.updateGitScrollbarAnnotation("uncommitted", filteredSets.uncommitted)
    this.updateGitScrollbarAnnotation("unpushed", filteredSets.unpushed)
  }

  updateGitScrollbarAnnotation(kind, lineSet) {
    if (!this.editor) return
    try {
      if (!this._gitAnnMap) this._gitAnnMap = {}

      if (!this._gitAnnMap[kind]) {
        const className = `CodeMirror-scrollbar-change-status-${kind}`
        this._gitAnnMap[kind] = this.editor.annotateScrollbar({ className })
      }

      // Convert Set to ranges efficiently
      const ranges = Array.from(lineSet || []).map(line => ({
        from: CodeMirror.Pos(line, 0),
        to: CodeMirror.Pos(line, 0)
      }))

      this._gitAnnMap[kind].update(ranges)
    } catch (error) {
      console.log(`Git scrollbar annotation error for ${kind}:`, error)
    }
  }

  createGitStatusMarker(color) {
    return <div style={`
      width: 10px;
      height: 100%; 
      background:  ${color};
      color:  ${color};
      display: block
    `}>│</div> // Vertical bar for git changes

    //
  }

  get gitStatusColors() {
    // Read colors from CSS custom properties for single source of truth
    const computedStyle = getComputedStyle(this)
    return {
      unsaved: computedStyle.getPropertyValue('--git-status-unsaved').trim(),
      uncommitted: computedStyle.getPropertyValue('--git-status-uncommitted').trim(),
      unpushed: computedStyle.getPropertyValue('--git-status-unpushed').trim()
    }
  }

  clearGitTextAnnotations() {
    if (this._gitTextMarkers) {
      this._gitTextMarkers.forEach(marker => {
        try {
          marker.clear()
        } catch (error) {
          console.log("Error clearing git text marker:", error)
        }
      })
      this._gitTextMarkers = []
    }
  }

  async updateGitStatusIndicators(changes) {
    if (!this.editor) return

    // Clear existing indicators
    this.editor.clearGutter("git-status")
    this.clearGitTextAnnotations()

    // Create filtered sets with priority logic: unsaved > uncommitted > unpushed
    const unsaved = new Set(changes.unsaved || [])
    const uncommitted = new Set((changes.uncommitted || []).filter(n => !unsaved.has(n)))
    const unpushed = new Set((changes.unpushed || []).filter(n => !unsaved.has(n) && !uncommitted.has(n)))

    const colors = this.gitStatusColors



    // Apply gutter markers - no priority conflicts since sets are pre-filtered
    unpushed.forEach(lineNum => {
      this.editor.setGutterMarker(lineNum, "git-status", this.createGitStatusMarker(colors.unpushed))
    })

    uncommitted.forEach(lineNum => {
      this.editor.setGutterMarker(lineNum, "git-status", this.createGitStatusMarker(colors.uncommitted))
    })

    unsaved.forEach(lineNum => {
      this.editor.setGutterMarker(lineNum, "git-status", this.createGitStatusMarker(colors.unsaved))
    })

    this.updateGitTextAnnotations({ unsaved, uncommitted, unpushed })

    this.updateGitScrollbarAnnotations({ unsaved, uncommitted, unpushed })
  }

  updateGitTextAnnotations(filteredSets) {
    if (!this.editor) return

    try {
      const colors = this.gitStatusColors

      // Mark entire lines for uncommitted changes with subtle background
      if (filteredSets.uncommitted) {
        filteredSets.uncommitted.forEach(lineNum => {
          const line = this.editor.getLine(lineNum)
          if (line !== undefined) {
            const marker = this.editor.markText(
              { line: lineNum, ch: 0 },
              { line: lineNum, ch: line.length },
              {
                className: "git-uncommitted-text",
                css: `background-color: ${colors.uncommitted}15;`, // Very subtle background
                title: `Line ${lineNum + 1}: Saved but not committed`
              }
            )

            // Store marker for cleanup
            if (!this._gitTextMarkers) this._gitTextMarkers = []
            this._gitTextMarkers.push(marker)
          }
        })
      }

      // Mark unsaved changes with a different style
      if (filteredSets.unsaved) {
        filteredSets.unsaved.forEach(lineNum => {
          const line = this.editor.getLine(lineNum)
          if (line !== undefined) {
            const marker = this.editor.markText(
              { line: lineNum, ch: 0 },
              { line: lineNum, ch: line.length },
              {
                className: "git-unsaved-text",
                css: `background-color: ${colors.unsaved}20;`, // Slightly more visible
                title: `Line ${lineNum + 1}: Unsaved changes`
              }
            )

            if (!this._gitTextMarkers) this._gitTextMarkers = []
            this._gitTextMarkers.push(marker)
          }
        })
      }

      // Mark unpushed changes
      if (filteredSets.unpushed) {
        filteredSets.unpushed.forEach(lineNum => {
          const line = this.editor.getLine(lineNum)
          if (line !== undefined) {
            const marker = this.editor.markText(
              { line: lineNum, ch: 0 },
              { line: lineNum, ch: line.length },
              {
                className: "git-unpushed-text",
                css: `background-color: ${colors.unpushed}20;`,
                title: `Line ${lineNum + 1}: Committed but not pushed`
              }
            )

            if (!this._gitTextMarkers) this._gitTextMarkers = []
            this._gitTextMarkers.push(marker)
          }
        })
      }

    } catch (error) {
      console.log("Git text annotation error:", error)
    }
  }

  clearGitStatusAnnotations() {
    if (this._gitAnnMap) {
      Object.values(this._gitAnnMap).forEach(annotation => {
        try {
          annotation.clear()
        } catch (error) {
          console.log("Error clearing git annotation:", error)
        }
      })
      this._gitAnnMap = {}
    }
  }


  /*MD ## Probes MD*/
  get probeRegex() {
    return /__probes__\['(\S*\s\d+\s[a-fA-F0-9]{8})']\s=\s/g
  }

  onBeforeChange(cm, changeObj) {
    if (this.isJavaScript || this.isMarkdown || this.isHTML) {
      this.adaptProbeInjection(cm, changeObj)
    }
  }

  // #important
  adaptProbeInjection(cm, changeObj) {
    if (!changeObj.update) {
      // skip undo, redo
      return
    }

    if (changeObj.origin === "setValue") {
      // skip setting the entire document
      return
    }

    const lines = changeObj.text;
    const modifiedText = lines.map((line, i) => {
      return line.replace(this.probeRegex, (match, first, second) => {
        const fileName = do {
          const container = lively.findParent(this, e => e.localName === 'lively-container', { deep: true })
          if (container) {
            container.getURL().pathname
              .replace(/.*\//, "") // strip path
              .replace(/^lively-/, "") // too common prefix
              .replace(/\..*/, "") // file ending irrelevant
              .replace(/\s/, "-") // leave no space for easy recognition via regex
          } else {
            'workspace'
          }
        };
        const srcLine = changeObj.from.line + i + 1;
        const id = generateUUID().split('-').first
        return `__probes__['${fileName} ${srcLine} ${id}'] = `
      })
    });

    changeObj.update(undefined, undefined, modifiedText);
  }

  // #important
  wrapProbes() {
    var regEx = this.probeRegex;
    do {
      var m = regEx.exec(this.value);
      if (m) {
        const from = this.editor.posFromIndex(m.index);
        const to = this.editor.posFromIndex(m.index + m[0].length);
        const id = m[1]

        const existingMarks = this.editor.findMarks(from, to)
        const hasWidget = existingMarks.some(mark => mark.component?.localName === 'lively-probe');

        if (!hasWidget) {
          const probe = this.wrapWidgetSync("lively-probe", from, to)
          Object.assign(probe.style, {
            backgroundColor: "rgba(113, 194, 229, 0.4)",
            borderRadius: '3px',
            minWidth: "",
            minHeight: ""
          });
          probe.innerText = `probe for '${id}' = `
          probe.setAttribute('data-probe-id', id)
        }
      }
    } while (m);

  }

  addProbeCode(cm) {
    // collapse selection to leftmost
    function leftmostPos(anchor, head) {
      return CodeMirror.cmpPos(anchor, head) <= 0 ? anchor : head;
    }
    const prevSelections = __probes__['code-mirror 1514 a77221c6'] = cm.listSelections();
    const ranges = cm.listSelections().map(range => ({ anchor: leftmostPos(range.anchor, range.head) }))
    cm.setSelections(ranges, undefined, { origin: '+move' })

    // insert probes
    const probeCodes = Array(prevSelections.length).fill("__probes__" + "['file-name 1 11111111'] = ");
    cm.replaceSelections(probeCodes)

    // immediately widget-ify them
    this.wrapProbes()
  }

  /*MD ## Other MD*/
  find(str) {
    // #TODO this is horrible... Why is there not a standard method for this?
    if (!this.editor) return;
    var found = false;
    this.value.split("\n").forEach((ea, index) => {
      var startPos = ea.indexOf(str);
      if (!found && startPos != -1) {
        this.editor.setCursor(index + 20, 10000); // line end ;-)
        this.editor.focus();
        this.editor.setSelection({ line: index, ch: startPos }, { line: index, ch: startPos + str.length });
        found = ea;
      }
    });
  }

  scrollToLine(line) {
    this.editor.scrollTo(null, this.editor.heightAtLine(line - 1, "local"));
  }


  // custom 
  posFromIndex(off, text = this.value) {
    var line = 0
    var total = 0
    for (let s of text.split("\n")) {
      var length = s.length
      if (total + length > off) {
        break;
      }
      total += length + 1
      line++
    }
    var ch = off - total
    return { ch: ch, line: line }
  }

  scrollToCodeElement(data, optionalText) {
    var cm = this.editor //  ok, to many levels of editor involved here...

    // #BUG when dealing with babylonian source code, the source code we are editing might not be the same we 
    // are storing... though the lines should be ok
    // 
    // var start = cm.posFromIndex(data.start)
    // var end = cm.posFromIndex(data.end)

    var start = this.posFromIndex(data.start, optionalText)
    var end = this.posFromIndex(data.end, optionalText)



    cm.setSelection(start, end)

    // scroll only if necessary
    var rect = cm.getWrapperElement().getBoundingClientRect();
    var topVisibleLine = cm.lineAtHeight(rect.top, "window");
    var bottomVisibleLine = cm.lineAtHeight(rect.bottom, "window");

    var topMarginLines = 5
    var bottomMarginLines = 20
    if (start.line - topMarginLines < topVisibleLine) {
      this.scrollToLine(start.line - topMarginLines)
    }
    if (end.line + bottomMarginLines > bottomVisibleLine) {
      var visibleLines = (bottomVisibleLine - topVisibleLine)
      this.scrollToLine(end.line - visibleLines + bottomMarginLines)
    }
  }

  unsavedChanges() {
    if (this.editor && this.editor.getValue() === "") return false;
    return true; // workspaces should be treated carefully
  }

  onCursorActivity() {
    var container = lively.query(this, "lively-container");
    if (!container) return;
    var navbar = lively.query(this, "lively-container-navbar");
    if (!navbar) return;
    navbar.onDetailsContentCursorActivity(this.editor, this.editor.getCursor("start"), this.editor.getCursor("end"));
  }

  livelyMinimizedTitle() {
    return this.value.slice(0, 80);
  }

  /*MD ## Active Expression Support MD*/

  async updateAExprDependencies() {
    if (!this.isJavaScript || !lively.query(this, "lively-container")) return;
    if (!Preferences.get("EnableAEDebugging")) return;
    var url = this.fileURL()
    new AEGutter(await this.editor, url, this.valid.bind(this));
  }

  fileURL() {
    return lively.query(this, "lively-container").getURL().toString();
  }

  valid() {
    return lively.allParents(this, [], true).includes(document.body);
  }


  /*MD # Sandblocks Support MD*/

  async loadSandblocksText() {
    return await System.import("src/client/sandblocks-text.js")
  }

  async enableSandblocks() {
    // Check if already enabled by checking for vitrail instance
    if (this._vitrailInstance) {
      return;
    }

    try {
      var { enableSandblocksText } = await this.loadSandblocksText()
      // Store the vitrail instance for later cleanup
      this._vitrailInstance = await enableSandblocksText(this)

      // Set attribute without triggering observer (we're already in the process)
      if (this.getAttribute("sandblocks") !== "true") {
        this.setAttribute("sandblocks", "true");
      }

      lively.notify("sandblocks enabled!");
    } catch (e) {
      lively.error("Failed to enable sandblocks: " + e.message);
      console.error("Sandblocks error:", e);
    }
  }

  async disableSandblocks() {
    // Check if already disabled by checking for vitrail instance
    if (!this._vitrailInstance) {
      return;
    }

    try {
      var { disableSandblocksText } = await this.loadSandblocksText()

      // Call the centralized disable function
      disableSandblocksText(this, this._vitrailInstance);

      this._vitrailInstance = null;

      // Set attribute without triggering observer (we're already in the process)
      if (this.getAttribute("sandblocks") !== "false") {
        this.setAttribute("sandblocks", "false");
      }

      lively.notify("sandblocks disabled!");
    } catch (e) {
      lively.error("Failed to disable sandblocks: " + e.message);
      console.error("Sandblocks disable error:", e);
    }
  }

  toggleSandblocks() {
    if (this.getAttribute("sandblocks") === "true") {
      this.disableSandblocks();
    } else {
      this.enableSandblocks();
    }
  }



}