lively.ide_debug_console.cp.js

import { ViewModel, Text, component } from 'lively.morphic';
import { obj, string } from 'lively.lang/index.js';
import { pt, Color } from 'lively.graphics';

const defaultConsoleMethods = ['log', 'group', 'groupEnd', 'warn', 'assert', 'error'];

// implements a subset of the string substitution patterns of `console`
// See: https://developer.mozilla.org/en-US/docs/Web/API/Console#using_string_substitutions
// Support `%s` only and simply converts everything into a string
function formatTemplateString (template = '', ...args) {
  let string = template;
  for (let i = 0; i < args.length; i++) {
    let idx = string.indexOf('%s');
    if (idx > -1) string = string.slice(0, idx) + String(args[i]) + string.slice(idx + 2);
    else string = string + ' ' + String(args[i]);
  }
  return string;
}

function installErrorCapture (target, _window = window) {
  if (!target._errorHandler) {
    target._errorHandler = (function errorHandler (errEvent, url, lineNumber, column, errorObj) {
      let err = errEvent.error || errEvent; let msg;
      if (err.stack) msg = String(err.stack);
      else if (url) msg = `${err} ${url}:${lineNumber}`;
      else msg = String(err);
      if (typeof target.error === 'function') target.error(msg);
    });
    _window.addEventListener('error', target._errorHandler);
  }
  if (!target._errorHandlerForPromises) {
    target._errorHandlerForPromises = function unhandledPromiseRejection (evt) {
      if (typeof target.error === 'function') { target.error('unhandled promise rejection:\n' + evt.reason); }
    };
    _window.addEventListener('unhandledrejection', target._errorHandlerForPromises);
  }
}

function removeErrorCapture (target, _window = window) {
  if (target._errorHandler) {
    _window.removeEventListener('error', target._errorHandler);
    delete target._errorHandler;
  }
  if (!target._errorHandlerForPromises) {
    _window.removeEventListener('unhandledrejection', target._errorHandlerForPromises);
    delete target._errorHandlerForPromises;
  }
}

/**
 * @param {console} platformConsole - The `console` object to instrument/prepare
 * @param {string[]} consoleMethods - An array with the names of methods to instrument
 * @param {Window} _window - The window to which `platformConsole` belongs
 */
export function prepareConsole (platformConsole, consoleMethods = defaultConsoleMethods, _window = window) {
  // reset consumer to be notified when one of `consoleMethods` gets executed on `platformConsole`
  // for `simpleConsumer`, wrapped `console` functions (their equivalents on the consumer) get invoked with a single string (returned from @see { formatTemplateString }) as argument
  // for `consumers`, wrapped `console` functions (their equivalents on the consumer) get invoked with the same arguments as the original `console `function
  let consumers = platformConsole.consumers = [];
  let simpleConsumers = platformConsole.simpleConsumers = [];

  function emptyFunc () {}

  function addWrappers () {
    if (platformConsole.wasWrapped) return;
    platformConsole.wasWrapped = true;

    let exceptions = ['removeWrappers', 'addWrappers', 'addConsumer', 'removeConsumer'];
    // find all methods on `platformConsole` which need to be instrumented
    let methods = Object.keys(platformConsole)
      .filter(name => typeof platformConsole[name] === 'function' &&
        // we back up the original function source of x() with $x() below
        !name.startsWith('$') &&
        !platformConsole.hasOwnProperty('$' + name) &&
        !exceptions.includes(name));
    let activationState = {};

    methods.forEach(name => {
      // backup original function source
      platformConsole['$' + name] = platformConsole[name];
      platformConsole[name] = function () {
        if (activationState[name]) return;
        activationState[name] = true;
        try {
          // execute original method of `platformConsole`
          platformConsole['$' + name].apply(platformConsole, arguments);
          // call all consumer which implement the function with the same name as well
          for (let i = 0; i < consumers.length; i++) {
            let consumerFunc = consumers[i][name];
            if (typeof consumerFunc === 'function') { consumerFunc.apply(consumers[i], arguments); }
          }
          for (let i = 0; i < simpleConsumers.length; i++) {
            let consumerFunc = simpleConsumers[i][name];
            if (typeof consumerFunc === 'function') { consumerFunc.call(simpleConsumers[i], formatTemplateString.apply(null, arguments)); }
          }
        } finally { activationState[name] = false; }
      };
    });
  }

  // find backed up methods on `console` (we prefixed with $) and remove them while restoring the original function
  function removeWrappers () {
    for (let name in platformConsole) {
      if (name[0] !== '$') continue;
      let realName = name.substring(1, name.length);
      platformConsole[realName] = platformConsole[name];
      delete platformConsole[name];
    }
    platformConsole.wasWrapped = true;
  }

  if (platformConsole.wasPrepared) return;
  platformConsole.wasPrepared = true;

  // in case one of `consoleMethods` is missing on `console`, stub with empty function
  // this way we cannot run into undefined functions when wrapping above
  for (let i = 0; i < consoleMethods.length; i++) {
    if (!platformConsole[consoleMethods[i]]) {
      platformConsole[consoleMethods[i]] = emptyFunc;
    }
  }

  platformConsole.wasWrapped = false;

  // install wrapping functions on `console`
  platformConsole.removeWrappers = removeWrappers;
  platformConsole.addWrappers = addWrappers;

  // install function to subscribe to method invocations on `console`
  platformConsole.addConsumer = function (c, simple = false) {
    let subscribers = simple ? simpleConsumers : consumers;
    if (!subscribers.includes(c)) {
      subscribers.push(c);
      // in case we are the first consumer, install wrappers on `console`
      // in case the wrappers are already installed, `addWrappers` will return early
      addWrappers();
    }
    // add event listener that takes care of printing errors that are announced in `console`
    // as those are not triggered by an explicit call to `console.XZY()`, we need to handle them separately
    installErrorCapture(c, _window);
  };

  platformConsole.removeConsumer = function (c) {
    let idx = consumers.indexOf(c);
    if (idx >= 0) consumers.splice(idx, 1);
    let idx2 = simpleConsumers.indexOf(c);
    if (idx2 >= 0) simpleConsumers.splice(idx2, 1);
    // remove wrappers in case we are the last consumer
    if (!consumers.length && !simpleConsumers.length) { removeWrappers(); }
    removeErrorCapture(c, _window);
  };
}

class LocalJSConsoleModel extends ViewModel {
  static get properties () {
    return {
      logLimit: { defaultValue: 1000 },
      bindings: {
        get () {
          return [
            { signal: 'keybindings', override: true, handler: 'keybindings' },
            { signal: 'commands', override: true, handler: 'commands' },
            { signal: 'menuItems', override: true, handler: 'menuItems' }
          ];
        }
      },
      expose: {
        get () {
          return ['onWindowClose', 'clear'];
        }
      }
    };
  }

  reset () {
    this.logLimit = 1000;
    this.clear();
  }

  viewDidLoad () {
    console.wasWrapped = false; // eslint-disable-line no-console
    this.install();
  }

  // Make sure that we clean up after ourselves when closing
  onWindowClose () { this.uninstall(); }

  install () {
    if (!console.addConsumer) prepareConsole(console); // eslint-disable-line no-console
    console.addConsumer(this); // eslint-disable-line no-console
  }

  // Removes our patches from `console` if they are present
  uninstall () {
    if (console.removeConsumer) { console.removeConsumer(this); } // eslint-disable-line no-console
  }

  maybeTemplateMessage (arg, ...args) {
    let msg;
    if (typeof arg !== 'string') {
      msg = obj.inspect(arg, { maxDepth: 2 });
    } else if (args.length) {
      msg = string.formatFromArray(Array.from(arguments));
    } else msg = String(arg);
    return msg;
  }

  clear () {
    this.view.textString = '';
  }

  /*
  We mirror the logging methods provided by `console` below.
  Those are called by the `console` methods after they have been instrumented to log into our own console.
  */
  log (/* args */) {
    this.addLog(this.maybeTemplateMessage.apply(this, arguments));
  }

  warn (template, ...args) {
    this.addLog(this.maybeTemplateMessage.apply(this, arguments),
      { fontColor: 'orange' });
  }

  error (template, ...args) {
    this.addLog(this.maybeTemplateMessage.apply(this, arguments),
      { fontColor: 'red', fontWeight: 'bold' });
  }

  group (msg) {
    this.addLog(`=> ${msg}`);
  }

  groupEnd (msg) {
    this.addLog(`<= ${msg}`);
  }

  assert (test, msg) {
    if (!test) this.error('Assert failed: ' + msg);
  }

  keybindings ({ get: viewKeybindings }) {
    return [
      { keys: { mac: 'Meta-Alt-K', win: 'Ctrl-Alt-K' }, command: '[console] clear' },
      ...viewKeybindings()
    ];
  }

  commands ({ get: viewCommands }) {
    return [
      {
        name: '[console] clear',
        exec: () => { this.clear(); return true; }
      },
      ...viewCommands()
    ];
  }

  async menuItems ($super) {
    const viewItems = await $super();
    return [
      { command: '[console] clear', target: this, alias: 'clear' },
      { isDivider: true },
      ...viewItems
    ];
  }

  /**
   * Appends `string` to the end of what is displayed in the console.
   * Formatting will was already taken care of by @see { maybeTemplateMessage }.
   * @param {String} string - The message to log into the console.
   * @param {object} attr - TextAttributes to style `string` with.
   */
  addLog (string, attr) {
    if (!string.endsWith('\n')) string += '\n';
    const t = this.view;
    let end = t.documentEndPosition;
    let scrollToEnd = t.cursorPosition.row === end.row;
    if (end.column > 0) end = { row: end.row + 1, column: 0 };
    t.insertText(attr ? [string, attr] : string, end, false);
    if (t.lineCount() > this.logLimit) {
      t.deleteText({
        start: { row: 0, column: 0 },
        end: { row: t.lineCount() - this.logLimit, column: 0 }
      });
    }
    if (scrollToEnd) {
      t.gotoDocumentEnd();
      t.scrollCursorIntoView();
    }
  }
}

const Console = component({
  type: Text,
  name: 'debug console',
  defaultViewModel: LocalJSConsoleModel,
  lineWrapping: 'by-words',
  needsDocument: true,
  fixedWidth: true,
  fixedHeight: true,
  selectionMode: 'native',
  extent: pt(500, 400),
  fill: Color.black.withA(.9),
  fontColor: Color.white,
  clipMode: 'auto'
});

export { Console };