// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. define([ 'jquery', 'base/js/utils', 'base/js/i18n', 'base/js/dialog', 'codemirror/lib/codemirror', 'codemirror/mode/meta', 'codemirror/addon/comment/comment', 'codemirror/addon/dialog/dialog', 'codemirror/addon/edit/closebrackets', 'codemirror/addon/edit/matchbrackets', 'codemirror/addon/search/searchcursor', 'codemirror/addon/search/search', 'codemirror/keymap/emacs', 'codemirror/keymap/sublime', 'codemirror/keymap/vim', ], function( $, utils, i18n, dialog, CodeMirror ) { "use strict"; var Editor = function(selector, options) { var that = this; this.selector = selector; this.clean = false; this.contents = options.contents; this.events = options.events; this.base_url = options.base_url; this.file_path = options.file_path; this.config = options.config; this.file_extension_modes = options.file_extension_modes || {}; this.last_modified = null; this._changed_on_disk_dialog = null; this.codemirror = new CodeMirror($(this.selector)[0]); this.codemirror.on('changes', function(cm, changes){ that._clean_state(); }); this.generation = -1; // It appears we have to set commands on the CodeMirror class, not the // instance. I'd like to be wrong, but since there should only be one CM // instance on the page, this is good enough for now. CodeMirror.commands.save = $.proxy(this.save, this); this.save_enabled = false; this.config.loaded.then(function () { // load codemirror config var cfg = that.config.data.Editor || {}; var cmopts = $.extend(true, {}, // true = recursive copy Editor.default_codemirror_options, cfg.codemirror_options || {} ); that._set_codemirror_options(cmopts); that.events.trigger('config_changed.Editor', {config: that.config}); if (cfg.file_extension_modes) { // check for file extension in user preferences var modename = cfg.file_extension_modes[that._get_file_extension()]; if (modename) { var modeinfo = CodeMirror.findModeByName(modename); if (modeinfo) { that.set_codemirror_mode(modeinfo); } } } that._clean_state(); }); this.clean_sel = $('
'); $('.last_modified').before(this.clean_sel); this.clean_sel.addClass('dirty-indicator-dirty'); }; // default CodeMirror options Editor.default_codemirror_options = { extraKeys: { "Cmd-Right": "goLineRight", "End": "goLineRight", "Cmd-Left": "goLineLeft", "Tab": "indentMore", "Shift-Tab" : "indentLess", "Cmd-/" : "toggleComment", "Ctrl-/" : "toggleComment", }, indentUnit: 4, theme: "ipython", lineNumbers: true, lineWrapping: true }; Editor.prototype.load = function() { /** load the file */ var that = this; var cm = this.codemirror; return this.contents.get(this.file_path, {type: 'file', format: 'text'}) .then(function(model) { cm.setValue(model.content); // Setting the file's initial value creates a history entry, // which we don't want. cm.clearHistory(); that._set_mode_for_model(model); that.save_enabled = true; that.generation = cm.changeGeneration(); that.events.trigger("file_loaded.Editor", model); that._clean_state(); that.last_modified = new Date(model.last_modified); }).catch( function(error) { that.events.trigger("file_load_failed.Editor", error); console.warn('Error loading: ', error); cm.setValue("Error! " + error.message + "\nSaving disabled.\nSee Console for more details."); cm.setOption('readOnly','nocursor'); that.save_enabled = false; } ); }; Editor.prototype._set_mode_for_model = function (model) { /** Set the CodeMirror mode based on the file model */ // Find and load the highlighting mode, // first by mime-type, then by file extension var modeinfo; var ext = this._get_file_extension(); if (ext) { // check if a mode has been remembered for this extension var modename = this.file_extension_modes[ext]; if (modename) { modeinfo = CodeMirror.findModeByName(modename); } } // prioritize CodeMirror's filename identification if (!modeinfo || modeinfo.mode === "null") { modeinfo = CodeMirror.findModeByFileName(model.name); // codemirror's filename identification is case-sensitive. // try once more with lowercase extension if (!modeinfo && ext) { // CodeMirror wants lowercase ext without leading '.' modeinfo = CodeMirror.findModeByExtension(ext.slice(1).toLowerCase()); } } if (model.mimetype && (!modeinfo || modeinfo.mode === "null")) { // mimetype is not set on file rename modeinfo = CodeMirror.findModeByMIME(model.mimetype); } if (modeinfo) { this.set_codemirror_mode(modeinfo); } }; Editor.prototype.set_codemirror_mode = function (modeinfo) { /** set the codemirror mode from a modeinfo struct */ var that = this; utils.requireCodeMirrorMode(modeinfo, function () { that.codemirror.setOption('mode', modeinfo.mime); that.events.trigger("mode_changed.Editor", modeinfo); }, function(err) { console.log('Error getting CodeMirror mode: ' + err); }); }; Editor.prototype.save_codemirror_mode = function (modeinfo) { /** save the selected codemirror mode for the current extension in config */ var update_mode_map = {}; var ext = this._get_file_extension(); // no extension, nothing to save // TODO: allow remembering no-extension things like Makefile? if (!ext) return; update_mode_map[ext] = modeinfo.name; return this.config.update({ Editor: { file_extension_modes: update_mode_map, } }); }; Editor.prototype.get_filename = function () { return utils.url_path_split(this.file_path)[1]; }; Editor.prototype._get_file_extension = function () { /** return file extension *including* . Returns undefined if no extension is found. */ var filename = this.get_filename(); var ext_idx = filename.lastIndexOf('.'); if (ext_idx < 0) { return; } else { return filename.slice(ext_idx); } }; /** * Rename the file. * @param {string} new_name * @return {Promise} promise that resolves when the file is renamed. */ Editor.prototype.rename = function (new_name) { /** rename the file */ var that = this; var parent = utils.url_path_split(this.file_path)[0]; var new_path = utils.url_path_join(parent, new_name); return this.contents.rename(this.file_path, new_path).then( function (model) { that.file_path = model.path; that.events.trigger('file_renamed.Editor', model); that.last_modified = new Date(model.last_modified); that._set_mode_for_model(model); that._clean_state(); } ); }; /** * Save this file on the server. * * @param {boolean} check_last_modified - checks if file has been modified on disk * @return {Promise} - promise that resolves when the notebook is saved. */ Editor.prototype.save = function (check_last_modified) { /** save the file */ if (!this.save_enabled) { console.log("Not saving, save disabled"); return; } // used to check for last modified saves if (check_last_modified === undefined) { check_last_modified = true; } var model = { path: this.file_path, type: 'file', format: 'text', content: this.codemirror.getValue(), }; var that = this; var _save = function () { that.events.trigger("file_saving.Editor"); return that.contents.save(that.file_path, model).then(function(data) { // record change generation for isClean that.generation = that.codemirror.changeGeneration(); that.events.trigger("file_saved.Editor", data); that.last_modified = new Date(data.last_modified); that._clean_state(); }); }; /* * Gets the current working file, and checks if the file has been modified on disk. If so, it * creates & opens a modal that issues the user a warning and prompts them to overwrite the file. * * If it can't get the working file, it builds a new file and saves. */ if (check_last_modified) { return this.contents.get(that.file_path, {content: false}).then( function check_if_modified(data) { var last_modified = new Date(data.last_modified); // We want to check last_modified (disk) > that.last_modified (our last save) // In some cases the filesystem reports an inconsistent time, // so we allow 0.5 seconds difference before complaining. if ((last_modified.getTime() - that.last_modified.getTime()) > 500) { // 500 ms console.warn("Last saving was done on `"+that.last_modified+"`("+that._last_modified+"), "+ "while the current file seem to have been saved on `"+data.last_modified+"`"); if (that._changed_on_disk_dialog !== null) { // since the modal's event bindings are removed when destroyed, we reinstate // save & reload callbacks on the confirmation & reload buttons that._changed_on_disk_dialog.find('.save-confirm-btn').click(_save); that._changed_on_disk_dialog.find('.btn-warning').click(function () {window.location.reload()}); // redisplay existing dialog that._changed_on_disk_dialog.modal('show'); } else { // create new dialog that._changed_on_disk_dialog = dialog.modal({ keyboard_manager: that.keyboard_manager, title: i18n.msg._("File changed"), body: i18n.msg._("The file has changed on disk since the last time we opened or saved it. " + "Do you want to overwrite the file on disk with the version open here, or load " + "the version on disk (reload the page)?"), buttons: { Reload: { class: 'btn-warning', click: function () { window.location.reload(); } }, Cancel: {}, Overwrite: { class: 'btn-danger save-confirm-btn', click: function () { _save(); } }, } }); } } else { return _save(); } }, function (error) { console.log(error); // maybe it has been deleted or renamed? Go ahead and save. return _save(); }) } else { return _save(); } }; Editor.prototype._clean_state = function(){ var clean = this.codemirror.isClean(this.generation); if (clean === this.clean){ return; } else { this.clean = clean; } if(clean){ this.events.trigger("save_status_clean.Editor"); this.clean_sel.attr('class','dirty-indicator-clean').attr('title','No changes to save'); } else { this.events.trigger("save_status_dirty.Editor"); this.clean_sel.attr('class','dirty-indicator-dirty').attr('title','Unsaved changes'); } }; Editor.prototype._set_codemirror_options = function (options) { // update codemirror options from a dict var codemirror = this.codemirror; $.map(options, function (value, opt) { if (value === null) { value = CodeMirror.defaults[opt]; } codemirror.setOption(opt, value); }); var that = this; }; Editor.prototype.update_codemirror_options = function (options) { /** update codemirror options locally and save changes in config */ var that = this; this._set_codemirror_options(options); return this.config.update({ Editor: { codemirror_options: options } }).then( that.events.trigger('config_changed.Editor', {config: that.config}) ); }; return {Editor: Editor}; });