diff --git a/src/editor/Editor.js b/src/editor/Editor.js index fd36e7a8cc4..f6231b4a020 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -76,40 +76,37 @@ define(function (require, exports, module) { _ = require("thirdparty/lodash"); /** Editor preferences */ - PreferencesManager.definePreference("useTabChar", "boolean", false); - PreferencesManager.definePreference("tabSize", "number", 4); - PreferencesManager.definePreference("spaceUnits", "number", 4); - PreferencesManager.definePreference("closeBrackets", "boolean", false); - PreferencesManager.definePreference("showLineNumbers", "boolean", true); - PreferencesManager.definePreference("styleActiveLine", "boolean", false); - PreferencesManager.definePreference("wordWrap", "boolean", true); - - var editorSettings = ["useTabChar", "tabSize", "spaceUnits", "closeBrackets", - "showLineNumbers", "styleActiveLine", "wordWrap"]; + var USE_TAB_CHAR = "useTabChar", + TAB_SIZE = "tabSize", + SPACE_UNITS = "spaceUnits", + CLOSE_BRACKETS = "closeBrackets", + SHOW_LINE_NUMBERS = "showLineNumbers", + STYLE_ACTIVE_LINE = "styleActiveLine", + WORD_WRAP = "wordWrap", + cmOptions = {}; + + // Mappings from Brackets preferences to CodeMirror options + cmOptions[USE_TAB_CHAR] = "indentWithTabs"; + cmOptions[TAB_SIZE] = "indentUnit"; + cmOptions[SPACE_UNITS] = "indentUnit"; + cmOptions[CLOSE_BRACKETS] = "autoCloseBrackets"; + cmOptions[SHOW_LINE_NUMBERS] = "lineNumbers"; + cmOptions[STYLE_ACTIVE_LINE] = "styleActiveLine"; + cmOptions[WORD_WRAP] = "lineWrapping"; + + PreferencesManager.definePreference(USE_TAB_CHAR, "boolean", false); + PreferencesManager.definePreference(TAB_SIZE, "number", 4); + PreferencesManager.definePreference(SPACE_UNITS, "number", 4); + PreferencesManager.definePreference(CLOSE_BRACKETS, "boolean", false); + PreferencesManager.definePreference(SHOW_LINE_NUMBERS, "boolean", true); + PreferencesManager.definePreference(STYLE_ACTIVE_LINE, "boolean", false); + PreferencesManager.definePreference(WORD_WRAP, "boolean", true); + + var editorOptions = [USE_TAB_CHAR, TAB_SIZE, SPACE_UNITS, CLOSE_BRACKETS, + SHOW_LINE_NUMBERS, STYLE_ACTIVE_LINE, WORD_WRAP]; /** Editor preferences */ - /** @type {boolean} Global setting: When inserting new text, use tab characters? (instead of spaces) */ - var _useTabChar = PreferencesManager.get("useTabChar"); - - /** @type {number} Global setting: Tab size */ - var _tabSize = PreferencesManager.get("tabSize"); - - /** @type {number} Global setting: Space units (i.e. number of spaces when indenting) */ - var _spaceUnits = PreferencesManager.get("spaceUnits"); - - /** @type {boolean} Global setting: Auto closes (, {, [, " and ' */ - var _closeBrackets = PreferencesManager.get("closeBrackets"); - - /** @type {boolean} Global setting: Show line numbers in the gutter */ - var _showLineNumbers = PreferencesManager.get("showLineNumbers"); - - /** @type {boolean} Global setting: Highlight the background of the line that has the cursor */ - var _styleActiveLine = PreferencesManager.get("styleActiveLine"); - - /** @type {boolean} Global setting: Auto wrap lines */ - var _wordWrap = PreferencesManager.get("wordWrap"); - /** @type {boolean} Guard flag to prevent focus() reentrancy (via blur handlers), even across Editors */ var _duringFocus = false; @@ -271,14 +268,7 @@ define(function (require, exports, module) { * @param {!Event} event */ function _handleCursorActivity(jqEvent, editor, event) { - // If there is a selection in the editor, temporarily hide Active Line Highlight - if (editor.hasSelection()) { - if (editor._codeMirror.getOption("styleActiveLine")) { - editor._codeMirror.setOption("styleActiveLine", false); - } - } else { - editor._codeMirror.setOption("styleActiveLine", _styleActiveLine); - } + editor._updateStyleActiveLine(); } function _handleKeyEvents(jqEvent, editor, event) { @@ -380,22 +370,29 @@ define(function (require, exports, module) { "Cmd-Left": "goLineStartSmart" }; + var currentOptions = this._currentOptions = _.zipObject( + editorOptions, + _.map(editorOptions, function (prefName) { + return self._getOption(prefName); + }) + ); + // Create the CodeMirror instance // (note: CodeMirror doesn't actually require using 'new', but jslint complains without it) this._codeMirror = new CodeMirror(container, { electricChars: false, // we use our own impl of this to avoid CodeMirror bugs; see _checkElectricChars() - indentWithTabs: _useTabChar, - tabSize: _tabSize, - indentUnit: _useTabChar ? _tabSize : _spaceUnits, - lineNumbers: _showLineNumbers, - lineWrapping: _wordWrap, - styleActiveLine: _styleActiveLine, + indentWithTabs: currentOptions[USE_TAB_CHAR], + tabSize: currentOptions[TAB_SIZE], + indentUnit: currentOptions[USE_TAB_CHAR] ? currentOptions[TAB_SIZE] : currentOptions[SPACE_UNITS], + lineNumbers: currentOptions[SHOW_LINE_NUMBERS], + lineWrapping: currentOptions[WORD_WRAP], + styleActiveLine: currentOptions[STYLE_ACTIVE_LINE], coverGutterNextToScrollbar: true, matchBrackets: true, matchTags: {bothTags: true}, dragDrop: false, extraKeys: codeMirrorKeyMap, - autoCloseBrackets: _closeBrackets, + autoCloseBrackets: currentOptions[CLOSE_BRACKETS], autoCloseTags: { whenOpening: true, whenClosing: true, @@ -1530,167 +1527,176 @@ define(function (require, exports, module) { */ Editor.prototype._hideMarks = []; - // Global settings that affect all Editor instances (both currently open Editors as well as those created - // in the future) - /** * @private - * Updates Editor option with the given value. Affects all Editors. - * @param {boolean | number} value - * @param {string} cmOption - CodeMirror option string + * + * Retrieve the value of the named preference for this document. + * + * @param {string} prefName Name of preference to retrieve. + * @return {*} current value of that pref + */ + Editor.prototype._getOption = function (prefName) { + return PreferencesManager.get(prefName, this.document.file.fullPath); + }; + + /** + * @private + * + * Updates the editor to the current value of prefName for the file being edited. + * + * @param {string} prefName Name of the preference to visibly update */ - function _setEditorOption(value, cmOption) { - _instances.forEach(function (editor) { - editor._codeMirror.setOption(cmOption, value); + Editor.prototype._updateOption = function (prefName) { + var oldValue = this._currentOptions[prefName], + newValue = this._getOption(prefName); + + if (oldValue !== newValue) { + this._currentOptions[prefName] = newValue; - // If there is a selection in the editor, temporarily hide Active Line Highlight - if ((cmOption === "styleActiveLine") && (value === true)) { - if (editor.hasSelection()) { - editor._codeMirror.setOption("styleActiveLine", false); + if (prefName === USE_TAB_CHAR) { + this._codeMirror.setOption("indentUnit", newValue === true ? + this._currentOptions[TAB_SIZE] : + this._currentOptions[SPACE_UNITS] + ); + } else if (prefName === STYLE_ACTIVE_LINE) { + this._updateStyleActiveLine(); + } else { + // Set the CodeMirror option as long as it's not a change + // that is in conflict with the useTabChar setting. + var useTabChar = this._currentOptions[USE_TAB_CHAR]; + if ((useTabChar && prefName === SPACE_UNITS) || + (!useTabChar && prefName === TAB_SIZE)) { + return; } + + this._codeMirror.setOption(cmOptions[prefName], newValue); } - $(editor).triggerHandler("optionChange", [cmOption, value]); - }); - } + $(this).triggerHandler("optionChange", [prefName, newValue]); + } + }; /** * @private - * Updates Editor option and the corresponding preference with the given value. Affects all Editors. - * @param {boolean | number} value - * @param {string} cmOption - CodeMirror option string - * @param {string} prefName - preference name string - * @param {boolean} _editorOnly private flag to denote that pref should not be changed - */ - function _setEditorOptionAndPref(value, cmOption, prefName, _editorOnly) { - _setEditorOption(value, cmOption); - if (!_editorOnly) { - PreferencesManager.setValueAndSave(prefName, value); + * + * Used to ensure that "style active line" is turned off when there is a selection. + */ + Editor.prototype._updateStyleActiveLine = function () { + if (this.hasSelection()) { + if (this._codeMirror.getOption("styleActiveLine")) { + this._codeMirror.setOption("styleActiveLine", false); + } + } else { + this._codeMirror.setOption("styleActiveLine", this._currentOptions[STYLE_ACTIVE_LINE]); } - } + }; + // Global settings that affect Editor instances that share the same preference locations + /** - * Sets whether to use tab characters (vs. spaces) when inserting new text. Affects all Editors. + * Sets whether to use tab characters (vs. spaces) when inserting new text. + * Affects any editors that share the same preference location. * @param {boolean} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setUseTabChar = function (value, _editorOnly) { - _useTabChar = value; - _setEditorOptionAndPref(value, "indentWithTabs", "useTabChar", _editorOnly); - _setEditorOption(_useTabChar ? _tabSize : _spaceUnits, "indentUnit"); + Editor.setUseTabChar = function (value) { + PreferencesManager.setValueAndSave(USE_TAB_CHAR, value); }; - /** @type {boolean} Gets whether all Editors use tab characters (vs. spaces) when inserting new text */ + /** @type {boolean} Gets whether the current editor uses tab characters (vs. spaces) when inserting new text */ Editor.getUseTabChar = function () { - return _useTabChar; + return PreferencesManager.get(USE_TAB_CHAR); }; /** - * Sets tab character width. Affects all Editors. + * Sets tab character width. + * Affects any editors that share the same preference location. * @param {number} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setTabSize = function (value, _editorOnly) { - _tabSize = value; - _setEditorOptionAndPref(value, "tabSize", "tabSize", _editorOnly); - _setEditorOption(value, "indentUnit"); + Editor.setTabSize = function (value) { + PreferencesManager.setValueAndSave(TAB_SIZE, value); }; /** @type {number} Get indent unit */ Editor.getTabSize = function () { - return _tabSize; + return PreferencesManager.get(TAB_SIZE); }; /** - * Sets indentation width. Affects all Editors. + * Sets indentation width. + * Affects any editors that share the same preference location. * @param {number} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setSpaceUnits = function (value, _editorOnly) { - _spaceUnits = value; - _setEditorOptionAndPref(value, "indentUnit", "spaceUnits", _editorOnly); + Editor.setSpaceUnits = function (value) { + PreferencesManager.setValueAndSave(SPACE_UNITS, value); }; /** @type {number} Get indentation width */ Editor.getSpaceUnits = function () { - return _spaceUnits; + return PreferencesManager.get(SPACE_UNITS); }; /** - * Sets the auto close brackets. Affects all Editors. + * Sets the auto close brackets. + * Affects any editors that share the same preference location. * @param {boolean} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setCloseBrackets = function (value, _editorOnly) { - _closeBrackets = value; - _setEditorOptionAndPref(value, "autoCloseBrackets", "closeBrackets", _editorOnly); + Editor.setCloseBrackets = function (value) { + PreferencesManager.setValueAndSave(CLOSE_BRACKETS, value); }; - /** @type {boolean} Gets whether all Editors use auto close brackets */ + /** @type {boolean} Gets whether the current editor uses auto close brackets */ Editor.getCloseBrackets = function () { - return _closeBrackets; + return PreferencesManager.get(CLOSE_BRACKETS); }; /** - * Sets show line numbers option and reapply it to all open editors. + * Sets show line numbers option. + * Affects any editors that share the same preference location. * @param {boolean} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setShowLineNumbers = function (value, _editorOnly) { - _showLineNumbers = value; - _setEditorOptionAndPref(value, "lineNumbers", "showLineNumbers", _editorOnly); + Editor.setShowLineNumbers = function (value) { + PreferencesManager.setValueAndSave(SHOW_LINE_NUMBERS, value); }; - /** @type {boolean} Returns true if show line numbers is enabled for all editors */ + /** @type {boolean} Returns true if show line numbers is enabled for the current editor */ Editor.getShowLineNumbers = function () { - return _showLineNumbers; + return PreferencesManager.get(SHOW_LINE_NUMBERS); }; /** - * Sets show active line option and reapply it to all open editors. + * Sets show active line option. + * Affects any editors that share the same preference location. * @param {boolean} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setShowActiveLine = function (value, _editorOnly) { - _styleActiveLine = value; - _setEditorOptionAndPref(value, "styleActiveLine", "styleActiveLine", _editorOnly); + Editor.setShowActiveLine = function (value) { + PreferencesManager.set(STYLE_ACTIVE_LINE, value); }; - /** - * Synonym for setShowActiveLine. This is needed because the preference name - * is styleActiveLine and editorSettings automatically calls this setter. - */ - Editor.setStyleActiveLine = Editor.setShowActiveLine; - - /** @type {boolean} Returns true if show active line is enabled for all editors */ + /** @type {boolean} Returns true if show active line is enabled for the current editor */ Editor.getShowActiveLine = function () { - return _styleActiveLine; + return PreferencesManager.get(STYLE_ACTIVE_LINE); }; /** - * Sets word wrap option and reapply it to all open editors. + * Sets word wrap option. + * Affects any editors that share the same preference location. * @param {boolean} value - * @param {boolean} _editorOnly private flag to denote that pref should not be changed */ - Editor.setWordWrap = function (value, _editorOnly) { - _wordWrap = value; - _setEditorOptionAndPref(value, "lineWrapping", "wordWrap", _editorOnly); + Editor.setWordWrap = function (value) { + PreferencesManager.set(WORD_WRAP, value); }; - /** @type {boolean} Returns true if word wrap is enabled for all editors */ + /** @type {boolean} Returns true if word wrap is enabled for the current editor */ Editor.getWordWrap = function () { - return _wordWrap; + return PreferencesManager.get(WORD_WRAP); }; // Set up listeners for preference changes - editorSettings.forEach(function (setting) { - var setterName = "set" + setting[0].toUpperCase() + setting.substr(1); - PreferencesManager.on("change", setting, function () { - if (Editor[setterName]) { - Editor[setterName](PreferencesManager.get(setting), true); - } else { - console.error("No Editor setter for ", setting); - } + editorOptions.forEach(function (prefName) { + PreferencesManager.on("change", prefName, function () { + _instances.forEach(function (editor) { + editor._updateOption(prefName); + }); }); }); @@ -1701,7 +1707,7 @@ define(function (require, exports, module) { */ function _convertPreferences() { var rules = {}; - editorSettings.forEach(function (setting) { + editorOptions.forEach(function (setting) { rules[setting] = "user"; }); PreferencesManager.convertPreferences(module, rules); diff --git a/src/editor/EditorOptionHandlers.js b/src/editor/EditorOptionHandlers.js index 6ffa0925704..8c2cedc0cea 100644 --- a/src/editor/EditorOptionHandlers.js +++ b/src/editor/EditorOptionHandlers.js @@ -33,19 +33,25 @@ define(function (require, exports, module) { Commands = require("command/Commands"), CommandManager = require("command/CommandManager"), PreferencesManager = require("preferences/PreferencesManager"), - Strings = require("strings"); + Strings = require("strings"), + _ = require("thirdparty/lodash"); + + // Constants for the preferences referred to in this file + var SHOW_LINE_NUMBERS = "showLineNumbers", + STYLE_ACTIVE_LINE = "styleActiveLine", + WORD_WRAP = "wordWrap", + CLOSE_BRACKETS = "closeBrackets"; /** * @private * - * Maps from preference names to the parameters needed to update the checked status. + * Maps from preference names to the command names needed to update the checked status. */ - var _optionMapping = { - showLineNumbers: [Commands.TOGGLE_LINE_NUMBERS, "getShowLineNumbers"], - styleActiveLine: [Commands.TOGGLE_ACTIVE_LINE, "getShowActiveLine"], - wordWrap: [Commands.TOGGLE_WORD_WRAP, "getWordWrap"], - closeBrackets: [Commands.TOGGLE_CLOSE_BRACKETS, "getCloseBrackets"] - }; + var _optionMapping = {}; + _optionMapping[SHOW_LINE_NUMBERS] = Commands.TOGGLE_LINE_NUMBERS; + _optionMapping[STYLE_ACTIVE_LINE] = Commands.TOGGLE_ACTIVE_LINE; + _optionMapping[WORD_WRAP] = Commands.TOGGLE_WORD_WRAP; + _optionMapping[CLOSE_BRACKETS] = Commands.TOGGLE_CLOSE_BRACKETS; /** * @private @@ -59,7 +65,7 @@ define(function (require, exports, module) { if (!mapping) { return; } - CommandManager.get(mapping[0]).setChecked(Editor[mapping[1]]()); + CommandManager.get(mapping).setChecked(PreferencesManager.get(name)); } // Listen to preference changes for the preferences we care about @@ -71,53 +77,26 @@ define(function (require, exports, module) { /** * @private - * Activates/Deactivates showing line numbers option - */ - function _toggleLineNumbers() { - Editor.setShowLineNumbers(!Editor.getShowLineNumbers()); - _updateCheckedState("showLineNumbers"); - } - - - /** - * @private - * Activates/Deactivates showing active line option - */ - function _toggleActiveLine() { - Editor.setShowActiveLine(!Editor.getShowActiveLine()); - _updateCheckedState("styleActiveLine"); - } - - - /** - * @private - * Activates/Deactivates word wrap option - */ - function _toggleWordWrap() { - Editor.setWordWrap(!Editor.getWordWrap()); - _updateCheckedState("wordWrap"); - } - - /** - * @private - * Activates/Deactivates the automatic close brackets option + * Creates a function that will toggle the named preference. + * + * @param {string} prefName Name of preference that should be toggled by the function */ - function _toggleCloseBrackets() { - Editor.setCloseBrackets(!Editor.getCloseBrackets()); - _updateCheckedState("closeBrackets"); + function _getToggler(prefName) { + return function () { + PreferencesManager.set(prefName, !PreferencesManager.get(prefName)); + }; } function _init() { - CommandManager.get(Commands.TOGGLE_LINE_NUMBERS).setChecked(Editor.getShowLineNumbers()); - CommandManager.get(Commands.TOGGLE_ACTIVE_LINE).setChecked(Editor.getShowActiveLine()); - CommandManager.get(Commands.TOGGLE_WORD_WRAP).setChecked(Editor.getWordWrap()); - CommandManager.get(Commands.TOGGLE_CLOSE_BRACKETS).setChecked(Editor.getCloseBrackets()); + _.each(_optionMapping, function (commandName, prefName) { + CommandManager.get(commandName).setChecked(PreferencesManager.get(prefName)); + }); } - CommandManager.register(Strings.CMD_TOGGLE_LINE_NUMBERS, Commands.TOGGLE_LINE_NUMBERS, _toggleLineNumbers); - CommandManager.register(Strings.CMD_TOGGLE_ACTIVE_LINE, Commands.TOGGLE_ACTIVE_LINE, _toggleActiveLine); - CommandManager.register(Strings.CMD_TOGGLE_WORD_WRAP, Commands.TOGGLE_WORD_WRAP, _toggleWordWrap); - CommandManager.register(Strings.CMD_TOGGLE_CLOSE_BRACKETS, Commands.TOGGLE_CLOSE_BRACKETS, _toggleCloseBrackets); + CommandManager.register(Strings.CMD_TOGGLE_LINE_NUMBERS, Commands.TOGGLE_LINE_NUMBERS, _getToggler(SHOW_LINE_NUMBERS)); + CommandManager.register(Strings.CMD_TOGGLE_ACTIVE_LINE, Commands.TOGGLE_ACTIVE_LINE, _getToggler(STYLE_ACTIVE_LINE)); + CommandManager.register(Strings.CMD_TOGGLE_WORD_WRAP, Commands.TOGGLE_WORD_WRAP, _getToggler(WORD_WRAP)); + CommandManager.register(Strings.CMD_TOGGLE_CLOSE_BRACKETS, Commands.TOGGLE_CLOSE_BRACKETS, _getToggler(CLOSE_BRACKETS)); AppInit.htmlReady(_init); }); diff --git a/src/file/FileUtils.js b/src/file/FileUtils.js index dc8e1d649e2..55c418f4fa5 100644 --- a/src/file/FileUtils.js +++ b/src/file/FileUtils.js @@ -308,6 +308,26 @@ define(function (require, exports, module) { return baseName.substr(idx + 1); } + + /** + * Computes filename as relative to the basePath. For example: + * basePath: /foo/bar/, filename: /foo/bar/baz.txt + * returns: baz.txt + * + * The net effect is that the common prefix is stripped away. If basePath is not + * a prefix of filename, then undefined is returned. + * + * @param {string} basePath Path against which we're computing the relative path + * @param {string} filename Full path to the file for which we are computing a relative path + * @return {string} relative path + */ + function getRelativeFilename(basePath, filename) { + if (!filename || filename.substr(0, basePath.length) !== basePath) { + return; + } + + return filename.substr(basePath.length); + } /** @const - hard-coded for now, but may want to make these preferences */ var _staticHtmlFileExts = ["htm", "html"], @@ -404,6 +424,7 @@ define(function (require, exports, module) { exports.isServerHtmlFileExt = isServerHtmlFileExt; exports.getDirectoryPath = getDirectoryPath; exports.getBaseName = getBaseName; + exports.getRelativeFilename = getRelativeFilename; exports.getFileExtension = getFileExtension; exports.compareFilenames = compareFilenames; }); diff --git a/src/preferences/PreferencesBase.js b/src/preferences/PreferencesBase.js index 28ca0845f4b..6957f108c8f 100644 --- a/src/preferences/PreferencesBase.js +++ b/src/preferences/PreferencesBase.js @@ -67,7 +67,8 @@ define(function (require, exports, module) { globmatch = require("thirdparty/globmatch"); // CONSTANTS - var PREFERENCE_CHANGE = "change"; + var PREFERENCE_CHANGE = "change", + SCOPEORDER_CHANGE = "scopeOrderChange"; /* * Storages manage the loading and saving of preference data. @@ -476,51 +477,36 @@ define(function (require, exports, module) { */ fileChanged: function (filename) { this.storage.fileChanged(filename); + }, + + /** + * Determines if there are likely to be any changes based on a change + * to the default filename used in lookups. + * + * @param {string} filename New filename + * @param {string} oldFilename Old filename + * @return {Array.} List of changed IDs + */ + defaultFilenameChanged: function (filename, oldFilename) { + var changes = [], + data = this.data; + + _.each(this._layers, function (layer) { + if (layer.defaultFilenameChanged && data[layer.key]) { + var changesInLayer = layer.defaultFilenameChanged(data[layer.key], + filename, + oldFilename); + if (changesInLayer) { + changes.push(changesInLayer); + } + } + }); + return _.union.apply(null, changes); } }); // Utility functions for the PathLayer - /** - * @private - * - * Finds the directory name of the given path. Ensures that the result always ends with a "/". - * - * @param {string} filename Filename from which to extract the dirname - * @return {string} directory containing the file (ends with "/") - */ - function _getDirName(filename) { - if (!filename) { - return "/"; - } - - var rightMostSlash = filename.lastIndexOf("/"); - return filename.substr(0, rightMostSlash + 1); - } - - /** - * @private - * - * - * Computes filename as relative to the basePath. For example: - * basePath: /foo/bar/, filename: /foo/bar/baz.txt - * returns: baz.txt - * - * The net effect is that the common prefix is returned. If basePath is not - * a prefix of filename, then undefined is returned. - * - * @param {string} basePath Path against which we're computing the relative path - * @param {string} filename Full path to the file for which we are computing a relative path - * @return {string} relative path - */ - function _getRelativeFilename(basePath, filename) { - if (!filename || filename.substr(0, basePath.length) !== basePath) { - return; - } - - return filename.substr(basePath.length); - } - /** * @private * @@ -567,7 +553,7 @@ define(function (require, exports, module) { * @param {string} prefFilePath path to the preference file */ function PathLayer(prefFilePath) { - this.prefFilePath = _getDirName(prefFilePath); + this.setPrefFilePath(prefFilePath); } PathLayer.prototype = { @@ -606,7 +592,7 @@ define(function (require, exports, module) { return; } - var relativeFilename = _getRelativeFilename(this.prefFilePath, context.filename); + var relativeFilename = FileUtils.getRelativeFilename(this.prefFilePath, context.filename); if (!relativeFilename) { return; } @@ -662,7 +648,7 @@ define(function (require, exports, module) { return; } - var relativeFilename = _getRelativeFilename(this.prefFilePath, context.filename); + var relativeFilename = FileUtils.getRelativeFilename(this.prefFilePath, context.filename); if (relativeFilename) { var glob = _findMatchingGlob(data, relativeFilename); @@ -673,46 +659,48 @@ define(function (require, exports, module) { } } return _.union.apply(null, _.map(_.values(data), _.keys)); - } + }, - }; - - /** - * Helper object to add a new path-based Scope to the PreferencesSystem. When a path-based - * Scope will be added, its existence is first checked and *then* this PathScopeAdder will be - * used. - * - * @param {string} filename Filename of the preferences file - * @param {string} scopeName Name of the new Scope to add - * @param {ScopeGenerator} scopeGenerator ScopeGenerator object that knows how to create the - * Scope with the correct kind of Storage object - * @param {string} before Name of the default Scope before which the new Scope should be added - */ - function PathScopeAdder(filename, scopeName, scopeGenerator, before) { - this.filename = filename; - this.scopeName = scopeName; - this.scopeGenerator = scopeGenerator; - this.before = before; - } - - PathScopeAdder.prototype = { /** - * Adds the new Scope to the given PreferencesSystem. + * Changes the preference file path. * - * @param {PreferencesSystem} pm PreferencesSystem to which the Scope will be added - * @param {string} before Name of the Scope before which the new Scope should be added - * @return {Promise} Promise resolved once the Scope is loaded + * @param {string} prefFilePath New path to the preferences file */ - add: function (pm, before) { - var scope = this.scopeGenerator.getScopeForFile(this.filename); - if (scope) { - var pathLayer = new PathLayer(this.filename); - scope.addLayer(pathLayer); - return pm.addScope(this.scopeName, scope, - { - before: before - }); + setPrefFilePath: function (prefFilePath) { + if (!prefFilePath) { + this.prefFilePath = "/"; + } else { + this.prefFilePath = FileUtils.getDirectoryPath(prefFilePath); + } + }, + + /** + * Determines if there are preference IDs that could change as a result of + * a change to the default filename. + * + * @param {Object} data Data in the Scope + * @param {string} filename New filename + * @param {string} oldFilename Old filename + * @return {Array.} list of preference IDs that could have changed + */ + defaultFilenameChanged: function (data, filename, oldFilename) { + var newGlob = _findMatchingGlob(data, + FileUtils.getRelativeFilename(this.prefFilePath, filename)), + oldGlob = _findMatchingGlob(data, + FileUtils.getRelativeFilename(this.prefFilePath, oldFilename)); + + + if (newGlob === oldGlob) { + return; } + if (newGlob === undefined) { + return _.keys(data[oldGlob]); + } + if (oldGlob === undefined) { + return _.keys(data[newGlob]); + } + + return _.union(_.keys(data[oldGlob]), _.keys(data[newGlob])); } }; @@ -933,11 +921,19 @@ define(function (require, exports, module) { }; this._pendingScopes = {}; - this._pendingEvents = {}; this._saveInProgress = false; this._nextSaveDeferred = null; + // The objects that define the different kinds of path-based Scope handlers. + // Examples could include the handler for .brackets.json files or an .editorconfig + // handler. + this._pathScopeDefinitions = {}; + + // Names of the files that contain path scopes + this._pathScopeFilenames = []; + + // Keeps track of cached path scope objects. this._pathScopes = {}; var notifyPrefChange = function (id) { @@ -1008,17 +1004,36 @@ define(function (require, exports, module) { * @param {string} id Name of the new Scope * @param {?string} addBefore Name of the Scope before which this new one is added */ - _addToScopeOrder: function (id, addBefore) { + addToScopeOrder: function (id, addBefore) { var defaultScopeOrder = this._defaultContext.scopeOrder; + var scope = this._scopes[id], + $this = $(this); + + $(scope).on(PREFERENCE_CHANGE + ".prefsys", function (e, data) { + $this.trigger(PREFERENCE_CHANGE, data); + }); + if (!addBefore) { defaultScopeOrder.unshift(id); - this._processPendingEvents(id); + $this.trigger(SCOPEORDER_CHANGE, { + id: id, + action: "added" + }); + $this.trigger(PREFERENCE_CHANGE, { + ids: scope.getKeys() + }); } else { var addIndex = defaultScopeOrder.indexOf(addBefore); if (addIndex > -1) { defaultScopeOrder.splice(addIndex, 0, id); - this._processPendingEvents(id); + $this.trigger(SCOPEORDER_CHANGE, { + id: id, + action: "added" + }); + $this.trigger(PREFERENCE_CHANGE, { + ids: scope.getKeys() + }); } else { var queue = this._pendingScopes[addBefore]; if (!queue) { @@ -1032,46 +1047,54 @@ define(function (require, exports, module) { var pending = this._pendingScopes[id]; delete this._pendingScopes[id]; pending.forEach(function (scopeID) { - this._addToScopeOrder(scopeID, id); + this.addToScopeOrder(scopeID, id); }.bind(this)); } }, /** - * @private + * Removes a scope from the default scope order. * - * When a Scope is loading and hasn't yet been added to the `scopeOrder`, - * we accumulate any change notifications that it sends and re-send them - * once the Scope has been added to `scopeOrder`. If the notifications were - * sent out before the Scope has been added, then listeners who request the - * changed values will actually get the old values. - * - * @param {string} id Name of the Scope that has been added. + * @param {string} id Name of the Scope to remove from the default scope order. */ - _processPendingEvents: function (id) { - // Remove the preload listener and add the final listener - var $scope = $(this._scopes[id]); - $scope.off(".preload"); - $scope.on(PREFERENCE_CHANGE, function (e, data) { - $(this).trigger(PREFERENCE_CHANGE, data); - }.bind(this)); - - // Resend preference IDs from the preload events - if (this._pendingEvents[id]) { - var ids = _.union.apply(null, this._pendingEvents[id]); - delete this._pendingEvents[id]; - $(this).trigger(PREFERENCE_CHANGE, { - ids: ids + removeFromScopeOrder: function (id) { + var scope = this._scopes[id]; + if (scope) { + _.pull(this._defaultContext.scopeOrder, id); + var $this = $(this); + $this.trigger(SCOPEORDER_CHANGE, { + id: id, + action: "removed" }); + $this.trigger(PREFERENCE_CHANGE, { + ids: scope.getKeys() + }); + $(scope).off(".prefsys"); } }, + /** + * @private + * + * Normalizes the context to be one of: + * + * 1. a context object that was passed in + * 2. the default context + * + * @param {Object} context Context that was passed in + * @return {{scopeOrder: string, filename: ?string}} context object + */ + _getContext: function (context) { + context = context || this._defaultContext; + return context; + }, + /** * Adds a new Scope. New Scopes are added at the highest precedence, unless the "before" option * is given. The new Scope is automatically loaded. * * @param {string} id Name of the Scope - * @param {Scope|Storage} scope the Scope object itself. Optionally, can be given a Storage directly for convenience + * @param {Scope|Storage} scope the Scope object itself. Optionally, can be given a Storage directly for convenience. * @param {{before: string}} options optional behavior when adding (e.g. setting which scope this comes before) * @return {Promise} Promise that is resolved when the Scope is loaded. It is resolved * with id and scope. @@ -1083,25 +1106,17 @@ define(function (require, exports, module) { throw new Error("Attempt to redefine preferences scope: " + id); } - // Check to see if "scope" might be a Storage instead + // Check to see if scope is a Storage that needs to be wrapped if (!scope.get) { scope = new Scope(scope); } - // Change events from the Scope should propagate to listeners - $(scope).on(PREFERENCE_CHANGE + ".preload", function (e, data) { - if (!this._pendingEvents[id]) { - this._pendingEvents[id] = []; - } - this._pendingEvents[id].push(data.ids); - }.bind(this)); - var deferred = $.Deferred(); scope.load() .then(function () { this._scopes[id] = scope; - this._addToScopeOrder(id, options.before); + this.addToScopeOrder(id, options.before); deferred.resolve(id, scope); }.bind(this)) .fail(function (err) { @@ -1127,13 +1142,26 @@ define(function (require, exports, module) { if (!scope) { return; } + + this.removeFromScopeOrder(id); + delete this._scopes[id]; - _.pull(this._defaultContext.scopeOrder, id); + $(scope).off(PREFERENCE_CHANGE); - var keys = scope.getKeys(); - $(this).trigger(PREFERENCE_CHANGE, { - ids: keys - }); + }, + + /** + * @private + * + * Retrieves the appropriate scopeOrder based on the given context. + * If the context contains a scopeOrder, that will be used. If not, + * the default scopeOrder is used. + * + * @param {{scopeOrder: ?Array., filename: ?string} context + * @return {Array.} list of scopes in the correct order for traversal + */ + _getScopeOrder: function (context) { + return context.scopeOrder || this._defaultContext.scopeOrder; }, /** @@ -1141,20 +1169,22 @@ define(function (require, exports, module) { * change scope ordering or the reference filename for path-based scopes. * * @param {string} id Name of the preference for which the value should be retrieved - * @param {?Object} context Optional context object to change the preference lookup + * @param {Object|string=} context Optional context object or name of context to change the preference lookup */ get: function (id, context) { var scopeCounter; - context = context || this._defaultContext; + context = this._getContext(context); - var scopeOrder = context.scopeOrder || this._defaultContext.scopeOrder; + var scopeOrder = this._getScopeOrder(context); for (scopeCounter = 0; scopeCounter < scopeOrder.length; scopeCounter++) { var scope = this._scopes[scopeOrder[scopeCounter]]; - var result = scope.get(id, context); - if (result !== undefined) { - return result; + if (scope) { + var result = scope.get(id, context); + if (result !== undefined) { + return result; + } } } }, @@ -1170,17 +1200,19 @@ define(function (require, exports, module) { var scopeCounter, scopeName; - context = context || this._defaultContext; + context = this._getContext(context); - var scopeOrder = context.scopeOrder || this._defaultContext.scopeOrder; + var scopeOrder = this._getScopeOrder(context); for (scopeCounter = 0; scopeCounter < scopeOrder.length; scopeCounter++) { scopeName = scopeOrder[scopeCounter]; var scope = this._scopes[scopeName]; - var result = scope.getPreferenceLocation(id, context); - if (result !== undefined) { - result.scope = scopeName; - return result; + if (scope) { + var result = scope.getPreferenceLocation(id, context); + if (result !== undefined) { + result.scope = scopeName; + return result; + } } } }, @@ -1199,7 +1231,7 @@ define(function (require, exports, module) { */ set: function (id, value, options) { options = options || {}; - var context = options.context || this._defaultContext, + var context = this._getContext(options.context), // The case where the "default" scope was chosen specifically is special. // Usually "default" would come up only when a preference did not have any @@ -1208,11 +1240,13 @@ define(function (require, exports, module) { location = options.location || this.getPreferenceLocation(id, context); if (!location || (location.scope === "default" && !forceDefault)) { + var scopeOrder = this._getScopeOrder(context); + // The default scope for setting a preference is the lowest priority // scope after "default". - if (context.scopeOrder.length > 1) { + if (scopeOrder.length > 1) { location = { - scope: context.scopeOrder[context.scopeOrder.length - 2] + scope: scopeOrder[scopeOrder.length - 2] }; } else { return false; @@ -1272,131 +1306,34 @@ define(function (require, exports, module) { }, /** - * Path Scopes provide special handling for scopes that are managed by a - * collection of files in the file tree. The idea is that files are - * searched for going up the file tree to the root. + * Sets the default filename used for computing preferences when there are PathLayers. + * This should be the filename of the file being edited. * - * This function just sets up the path scopes. You need to call - * `setPathScopeContext` to activate the path scopes. If a path scope context - * is already set, the new path scopes will be activated automatically. - * - * The `scopeGenerator` is an object that provides the following: - * * `before`: all scopes added will be before (higher precedence) this named scope - * * `checkExists`: takes an absolute filename and determines if there is a valid file there. Returns a promise resolving to a boolean. - * * `getScopeForFile`: Called after checkExists. Synchronously returns a Scope object for the given file. Only called where `checkExists` is true. - * - * @param {string} preferencesFilename Name for the preferences files managed by this scopeGenerator (e.g. `.brackets.json`) - * @param {ScopeGenerator} scopeGenerator defines the behavior used to generate scopes for these files - * @return {Promise} promise resolved when the scopes have been added. + * @param {string} filename New filename used to resolve preferences */ - addPathScopes: function (preferencesFilename, scopeGenerator) { - this._pathScopes[preferencesFilename] = scopeGenerator; - - if (this._defaultContext.filename) { - return this.setPathScopeContext(this._defaultContext.filename); - } else { - return new $.Deferred().resolve().promise(); + setDefaultFilename: function (filename) { + var oldFilename = this._defaultContext.filename; + if (oldFilename === filename) { + return; } - }, - - /** - * Sets the current path scope context. This causes a reloading of paths as needed. - * Paths that are common between the old and new context files are not reloaded. - * All path scopes are updated by this function. - * - * Notifications are sent for any preferences that may have changed value as a result - * of this operation. - * - * @param {string} contextFilename New filename used to resolve preferences - * @return {Promise} resolved when the path scope context change is complete. Note that *this promise is resolved before the scopes are done loading*. - */ - setPathScopeContext: function (contextFilename) { - var defaultContext = this._defaultContext, - oldFilename = this._defaultContext.filename, - oldContext = { - filename: oldFilename - }, - oldParts = oldFilename ? oldFilename.split("/") : [], - parts = _.initial(contextFilename.split("/")), - loadingPromises = [], - self = this, - result = new $.Deferred(), - scopesToCheck = [], - scopeAdders = [], - notificationKeys = []; - defaultContext.filename = contextFilename; + var changes = []; - // Loop over the path scopes - _.forIn(this._pathScopes, function (scopeGenerator, preferencesFilename) { - var lastSeen = scopeGenerator.before, - counter, - scopeNameToRemove; - - // First, look for how much is common with the old filename - for (counter = 0; counter < parts.length && counter < oldParts.length; counter++) { - if (parts[counter] !== oldParts[counter]) { - break; - } - } - - // Remove all of the scopes that weren't the same in old and new - for (counter = counter + 1; counter < oldParts.length; counter++) { - scopeNameToRemove = "path:" + _.first(oldParts, counter).join("/") + "/" + preferencesFilename; - self.removeScope(scopeNameToRemove); + _.each(this._scopes, function (scope) { + var changedInScope = scope.defaultFilenameChanged(filename, oldFilename); + if (changedInScope) { + changes.push(changedInScope); } - - // Now add new scopes as required - _.forEach(parts, function (part, i) { - var prefDirectory, filename, scope, scopeName, pathLayer, pathLayerFilename; - prefDirectory = _.first(parts, i + 1).join("/") + "/"; - filename = prefDirectory + preferencesFilename; - scopeName = "path:" + filename; - scope = self._scopes[scopeName]; - - // Check to see if the scope already exists - if (scope) { - lastSeen = scopeName; - // The old values could have changed, as well as the new values - notificationKeys.push(scope.getKeys(oldContext)); - notificationKeys.push(scope.getKeys(defaultContext)); - } else { - // New scope. First check to see if the file exists. - scopesToCheck.unshift(scopeGenerator.checkExists(filename)); - - // Keep a function closure for the scope that will be added - // if checkExists is true. We store these so that we can - // run them in order. - scopeAdders.unshift(new PathScopeAdder(filename, scopeName, scopeGenerator, lastSeen)); - } - }); }); - // Notify listeners of all possible key changes for already loaded scopes - // New scopes will notify as soon as the data is loaded. - if (notificationKeys.length > 0) { + this._defaultContext.filename = filename; + + changes = _.union.apply(null, changes); + if (changes.length > 0) { $(this).trigger(PREFERENCE_CHANGE, { - ids: _.union.apply(null, notificationKeys) + ids: changes }); } - - // When all of the scope checks are done, run through them in order - // and then call the adders for each file that exists. - $.when.apply(this, scopesToCheck).done(function () { - var i, before, scopeAdder; - for (i = 0; i < arguments.length; i++) { - if (arguments[i]) { - scopeAdder = scopeAdders[i]; - if (!before) { - before = scopeAdder.before; - } - loadingPromises.push(scopeAdder.add(self, before)); - } - } - result.resolve(); - }); - - return result.promise(); }, /** diff --git a/src/preferences/PreferencesManager.js b/src/preferences/PreferencesManager.js index b8d9deab61d..34b4ee28e6f 100644 --- a/src/preferences/PreferencesManager.js +++ b/src/preferences/PreferencesManager.js @@ -227,31 +227,92 @@ define(function (require, exports, module) { var preferencesManager = new PreferencesBase.PreferencesSystem(); - var userScope = preferencesManager.addScope("user", new PreferencesBase.FileStorage(userPrefFile, true)); + var userScopeLoading = preferencesManager.addScope("user", new PreferencesBase.FileStorage(userPrefFile, true)); // Set up the .brackets.json file handling - userScope - .done(function () { - preferencesManager.addPathScopes(".brackets.json", { - before: "user", - checkExists: function (filename) { - var result = new $.Deferred(), - file = FileSystem.getFileForPath(filename); - file.exists(function (err, doesExist) { - result.resolve(doesExist); - }); - return result.promise(); - }, - getScopeForFile: function (filename) { - return new PreferencesBase.Scope(new PreferencesBase.FileStorage(filename)); - } - }) - .done(function () { - // Session Scope is for storing prefs in memory only but with the highest precedence. - preferencesManager.addScope("session", new PreferencesBase.MemoryStorage()); - }); - }); - + userScopeLoading.done(function () { + // Session Scope is for storing prefs in memory only but with the highest precedence. + preferencesManager.addScope("session", new PreferencesBase.MemoryStorage()); + }); + + // Create a Project scope + var projectStorage = new PreferencesBase.FileStorage(undefined, true), + projectScope = new PreferencesBase.Scope(projectStorage), + projectPathLayer = new PreferencesBase.PathLayer(), + projectDirectory = null, + currentEditedFile = null, + projectScopeIsIncluded = true; + + projectScope.addLayer(projectPathLayer); + + preferencesManager.addScope("project", projectScope, { + before: "user" + }); + + /** + * @private + * + * Determines whether the project Scope should be included based on whether + * the currently edited file is within the project. + * + * @param {string=} filename Full path to edited file + * @return {boolean} true if the project Scope should be included. + */ + function _includeProjectScope(filename) { + filename = filename || currentEditedFile; + if (!filename || !projectDirectory) { + return false; + } + return FileUtils.getRelativeFilename(projectDirectory, filename) ? true : false; + } + + /** + * @private + * + * Adds or removes the project Scope as needed based on whether the currently + * edited file is within the project. + */ + function _toggleProjectScope() { + if (_includeProjectScope() === projectScopeIsIncluded) { + return; + } + if (projectScopeIsIncluded) { + preferencesManager.removeFromScopeOrder("project"); + } else { + preferencesManager.addToScopeOrder("project", "user"); + } + projectScopeIsIncluded = !projectScopeIsIncluded; + } + + /** + * @private + * + * This is used internally within Brackets for the ProjectManager to signal + * which file contains the project-level preferences. + * + * @param {string} settingsFile Full path to the project's settings file + */ + function _setProjectSettingsFile(settingsFile) { + projectDirectory = FileUtils.getDirectoryPath(settingsFile); + _toggleProjectScope(); + projectPathLayer.setPrefFilePath(settingsFile); + projectStorage.setPath(settingsFile); + } + + /** + * @private + * + * This is used internally within Brackets for the EditorManager to signal + * to the preferences what the currently edited file is. + * + * @param {string} currentFile Full path to currently edited file + */ + function _setCurrentEditingFile(currentFile) { + currentEditedFile = currentFile; + _toggleProjectScope(); + preferencesManager.setDefaultFilename(currentFile); + } + /** * Creates an extension-specific preferences manager using the prefix given. * A `.` character will be appended to the prefix. So, a preference named `foo` @@ -280,7 +341,7 @@ define(function (require, exports, module) { * @param {Object} rules Rules for conversion (as defined above) */ function convertPreferences(clientID, rules) { - userScope.done(function () { + userScopeLoading.done(function () { var prefs = getPreferenceStorage(clientID, null, true); if (!prefs) { @@ -310,27 +371,170 @@ define(function (require, exports, module) { stateManager.addScope("user", new PreferencesBase.FileStorage(userStateFile, true)); + // Constants for preference lookup contexts. + + /** + * Context to look up preferences in the current project. + * @type {Object} + */ + var CURRENT_PROJECT = {}; + + /** + * Context to look up preferences for the currently edited file. + * This is undefined because this is the default behavior of PreferencesSystem.get. + * + * @type {Object} + */ + var CURRENT_FILE; + + /** + * Cached copy of the scopeOrder with the project Scope + */ + var scopeOrderWithProject = null; + + /** + * Cached copy of the scopeOrder without the project Scope + */ + var scopeOrderWithoutProject = null; + + /** + * @private + * + * Adjusts scopeOrder to have the project Scope if necessary. + * Returns a new array if changes are needed, otherwise returns + * the original array. + * + * @param {Array.} scopeOrder initial scopeOrder + * @param {boolean} includeProject Whether the project Scope should be included + * @return {Array.} array with or without project Scope as needed. + */ + function _adjustScopeOrderForProject(scopeOrder, includeProject) { + var hasProject = scopeOrder.indexOf("project") > -1; + + if (hasProject === includeProject) { + return scopeOrder; + } + + var newScopeOrder; + + if (includeProject) { + var before = scopeOrder.indexOf("user"); + if (before === -1) { + before = scopeOrder.length - 2; + } + newScopeOrder = _.first(scopeOrder, before); + newScopeOrder.push("project"); + newScopeOrder.push.apply(newScopeOrder, _.rest(scopeOrder, before)); + } else { + newScopeOrder = _.without(scopeOrder, "project"); + } + return newScopeOrder; + } + + /** + * @private + * + * Normalizes the context object to be something that the PreferencesSystem + * understands. This is how we support CURRENT_FILE and CURRENT_PROJECT + * preferences. + * + * @param {Object|string} context CURRENT_FILE, CURRENT_PROJECT or a filename + */ + function _normalizeContext(context) { + if (typeof context === "string") { + context = { + filename: context + }; + context.scopeOrder = _includeProjectScope(context.filename) ? + scopeOrderWithProject : + scopeOrderWithoutProject; + } + return context; + } + + /** + * @private + * + * Updates the CURRENT_PROJECT context to have the correct scopes. + */ + function _updateCurrentProjectContext() { + var context = preferencesManager.buildContext({}); + delete context.filename; + scopeOrderWithProject = _adjustScopeOrderForProject(context.scopeOrder, true); + scopeOrderWithoutProject = _adjustScopeOrderForProject(context.scopeOrder, false); + CURRENT_PROJECT.scopeOrder = scopeOrderWithProject; + } + + _updateCurrentProjectContext(); + + preferencesManager.on("scopeOrderChange", _updateCurrentProjectContext); + + /** + * Look up a preference in the given context. The default is + * CURRENT_FILE (preferences as they would be applied to the + * currently edited file). + * + * @param {string} id Preference ID to retrieve the value of + * @param {Object|string=} context CURRENT_FILE, CURRENT_PROJECT or a filename + */ + function get(id, context) { + context = _normalizeContext(context); + return preferencesManager.get(id, context); + } + + /** + * Sets a preference and notifies listeners that there may + * have been a change. By default, the preference is set in the same location in which + * it was defined except for the "default" scope. If the current value of the preference + * comes from the "default" scope, the new value will be set at the level just above + * default. + * + * As with the `get()` function, the context can be a filename, + * CURRENT_FILE, CURRENT_PROJECT or a full context object as supported by + * PreferencesSystem. + * + * @param {string} id Identifier of the preference to set + * @param {Object} value New value for the preference + * @param {{location: ?Object, context: ?Object|string}=} options Specific location in which to set the value or the context to use when setting the value + * @return {boolean} true if a value was set + */ + function set(id, value, options) { + if (options && options.context) { + options.context = _normalizeContext(options.context); + } + return preferencesManager.set(id, value, options); + } + /** * Convenience function that sets a preference and then saves the file, mimicking the * old behavior a bit more closely. * * @param {string} id preference to set * @param {*} value new value for the preference + * @param {{location: ?Object, context: ?Object|string}=} options Specific location in which to set the value or the context to use when setting the value + * @return {boolean} true if a value was set */ - function setValueAndSave(id, value) { - preferencesManager.set(id, value); + function setValueAndSave(id, value, options) { + var changed = set(id, value, options); preferencesManager.save(); + return changed; } + // Private API for unit testing and use elsewhere in Brackets core - exports._manager = preferencesManager; - exports._setCurrentEditingFile = preferencesManager.setPathScopeContext.bind(preferencesManager); + exports._manager = preferencesManager; + exports._setCurrentEditingFile = _setCurrentEditingFile; + exports._setProjectSettingsFile = _setProjectSettingsFile; // Public API + // Context names for preference lookups + exports.CURRENT_FILE = CURRENT_FILE; + exports.CURRENT_PROJECT = CURRENT_PROJECT; + exports.getUserPrefFile = getUserPrefFile; - exports.get = preferencesManager.get.bind(preferencesManager); - exports.set = preferencesManager.set.bind(preferencesManager); + exports.get = get; + exports.set = set; exports.save = preferencesManager.save.bind(preferencesManager); exports.on = preferencesManager.on.bind(preferencesManager); exports.off = preferencesManager.off.bind(preferencesManager); diff --git a/src/project/ProjectManager.js b/src/project/ProjectManager.js index 14c1f168a02..2c48261da9d 100644 --- a/src/project/ProjectManager.js +++ b/src/project/ProjectManager.js @@ -2152,6 +2152,18 @@ define(function (require, exports, module) { }; _prefs = PreferencesManager.getPreferenceStorage(module, defaults); + function _reloadProjectPreferencesScope() { + var root = getProjectRoot(); + if (root) { + // Alias the "project" Scope to the path Scope for the project-level settings file + PreferencesManager._setProjectSettingsFile(root.fullPath + SETTINGS_FILENAME); + } else { + PreferencesManager._setProjectSettingsFile(); + } + } + + $(exports).on("projectOpen", _reloadProjectPreferencesScope); + // Event Handlers $(FileViewController).on("documentSelectionFocusChange", _documentSelectionFocusChange); $(FileViewController).on("fileViewFocusChange", _fileViewFocusChange); diff --git a/test/spec/PreferencesBase-test.js b/test/spec/PreferencesBase-test.js index 8c421796d69..d60b5d24631 100644 --- a/test/spec/PreferencesBase-test.js +++ b/test/spec/PreferencesBase-test.js @@ -533,29 +533,49 @@ define(function (require, exports, module) { }); }); - it("can notify of preference changes via scope changes", function () { + it("can notify of preference changes via scope changes and scope changes", function () { var pm = new PreferencesBase.PreferencesSystem(); pm.definePreference("spaceUnits", "number", 4); - var eventData = []; + var eventData = [], + scopeEvents = []; + pm.on("change", function (e, data) { eventData.push(data); }); + pm.on("scopeOrderChange", function (e, data) { + scopeEvents.push(data); + }); + pm.addScope("user", new PreferencesBase.MemoryStorage({ spaceUnits: 4, elephants: "charging" })); + expect(pm._defaultContext.scopeOrder).toEqual(["user", "default"]); + expect(eventData).toEqual([{ ids: ["spaceUnits", "elephants"] }]); + expect(scopeEvents).toEqual([{ + id: "user", + action: "added" + }]); + + scopeEvents = []; eventData = []; pm.removeScope("user"); + expect(pm._defaultContext.scopeOrder).toEqual(["default"]); expect(eventData).toEqual([{ ids: ["spaceUnits", "elephants"] }]); + + expect(scopeEvents).toEqual([{ + id: "user", + action: "removed" + }]); }); it("notifies when there are layer changes", function () { @@ -565,7 +585,7 @@ define(function (require, exports, module) { spaceUnits: 4, useTabChar: false, path: { - "foo.txt": { + "*.txt": { spaceUnits: 2, alpha: "bravo" } @@ -588,7 +608,7 @@ define(function (require, exports, module) { // Extra verification that layer keys works correctly var keys = scope._layers[0].getKeys(scope.data.path, { - filename: "/bar.txt" + filename: "/bar.md" }); expect(keys).toEqual([]); @@ -596,6 +616,25 @@ define(function (require, exports, module) { filename: "/foo.txt" }); expect(keys.sort()).toEqual(["spaceUnits", "alpha"].sort()); + + expect(pm.get("spaceUnits")).toBe(4); + eventData = []; + pm.setDefaultFilename("/foo.txt"); + expect(pm.get("spaceUnits")).toBe(2); + expect(eventData).toEqual([{ + ids: ["spaceUnits", "alpha"] + }]); + + eventData = []; + pm.setDefaultFilename("/README.txt"); + expect(eventData).toEqual([]); + + // Test to make sure there are no exceptions when there is no path data + delete data.path; + scope.load(); + expect(scope.data).toEqual(data); + pm.setDefaultFilename("/foo.txt"); + pm.setDefaultFilename("/bar.md"); }); it("can notify changes for single preference objects", function () { @@ -633,6 +672,42 @@ define(function (require, exports, module) { expect(changes).toEqual(1); }); + it("can dynamically modify the default scope order", function () { + var pm = new PreferencesBase.PreferencesSystem(); + pm.addScope("user", new PreferencesBase.MemoryStorage({ + spaceUnits: 1 + })); + pm.addScope("project", new PreferencesBase.MemoryStorage({ + spaceUnits: 2 + })); + pm.addScope("session", new PreferencesBase.MemoryStorage()); + expect(pm.get("spaceUnits")).toBe(2); + expect(pm._defaultContext.scopeOrder).toEqual(["session", "project", "user", "default"]); + + var eventData = []; + pm.on("change", function (e, data) { + eventData.push(data); + }); + pm.removeFromScopeOrder("project"); + expect(pm._defaultContext.scopeOrder).toEqual(["session", "user", "default"]); + expect(eventData).toEqual([{ + ids: ["spaceUnits"] + }]); + + expect(pm.get("spaceUnits")).toBe(1); + expect(pm.get("spaceUnits", { + scopeOrder: ["session", "project", "user", "default"] + })).toBe(2); + + eventData = []; + pm.addToScopeOrder("project", "user"); + expect(pm._defaultContext.scopeOrder).toEqual(["session", "project", "user", "default"]); + expect(eventData).toEqual([{ + ids: ["spaceUnits"] + }]); + expect(pm.get("spaceUnits")).toBe(2); + }); + it("can set preference values at any level", function () { var pm = new PreferencesBase.PreferencesSystem(), pref = pm.definePreference("spaceUnits", "number", 4), @@ -708,13 +783,14 @@ define(function (require, exports, module) { expect(pm.get("spaceUnits")).toBe(7); expect(Object.keys(session.data)).toEqual([]); - pm.setPathScopeContext("/index.html"); + pm.setDefaultFilename("/index.html"); + expect(changes).toBe(6); expect(pm.get("spaceUnits")).toBe(2); expect(pm.set("spaceUnits", 10)).toBe(true); - expect(changes).toBe(6); + expect(changes).toBe(7); expect(project.data.path["**.html"].spaceUnits).toBe(10); - pm.setPathScopeContext("/foo.txt"); + pm.setDefaultFilename("/foo.txt"); expect(pm.getPreferenceLocation("spaceUnits")).toEqual({ scope: "user" }); @@ -776,169 +852,6 @@ define(function (require, exports, module) { ]); }); - it("can manage preferences files in the file tree", function () { - var pm = new PreferencesBase.PreferencesSystem(); - - pm.addScope("user", new PreferencesBase.MemoryStorage({ - spaceUnits: 99 - })); - - pm.addScope("session", new PreferencesBase.MemoryStorage({})); - - var requestedFiles = []; - var testScopes = {}; - function getScopeForFile(filename) { - requestedFiles.push(filename); - return testScopes[filename]; - } - - function checkExists(filename) { - var exists = testScopes[filename] !== undefined; - return new $.Deferred().resolve(exists).promise(); - } - - testScopes["/.brackets.json"] = new PreferencesBase.Scope(new PreferencesBase.MemoryStorage({ - spaceUnits: 1, - first: 1, - path: { - "foo.js": { - spaceUnits: 2, - second: 2 - }, - "bar/baz.js": { - spaceUnits: 3, - third: 3 - }, - "projects/**": { - spaceUnits: 4, - fourth: 4 - } - } - })); - - testScopes["/projects/brackets/.brackets.json"] = new PreferencesBase.Scope(new PreferencesBase.MemoryStorage({ - spaceUnits: 5, - fifth: 5, - path: { - "thirdparty/**": { - spaceUnits: 6, - sixth: 6 - } - } - })); - testScopes["/projects/brackets/thirdparty/codemirror/.brackets.json"] = new PreferencesBase.Scope(new PreferencesBase.MemoryStorage({ - spaceUnits: 7, - seventh: 7 - })); - pm.addPathScopes(".brackets.json", { - getScopeForFile: getScopeForFile, - checkExists: checkExists, - before: "user" - }); - - var didComplete = false; - - var events = []; - pm.on("change", function (e, data) { - events.push(data); - }); - - // this should resolve synchronously - pm.setPathScopeContext("/README.txt").done(function () { - didComplete = true; - expect(requestedFiles).toEqual(["/.brackets.json"]); - expect(pm.get("spaceUnits")).toBe(1); - expect(pm._defaultContext.scopeOrder).toEqual(["session", "path:/.brackets.json", "user", "default"]); - expect(events.length).toEqual(1); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "second", "third", "fourth"].sort()); - }); - - requestedFiles = []; - events = []; - pm.setPathScopeContext("/foo.js").done(function () { - expect(requestedFiles).toEqual([]); - expect(pm.get("spaceUnits")).toBe(2); - expect(events.length).toBe(1); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "second"].sort()); - }); - - events = []; - pm.setPathScopeContext("/bar/baz.js").done(function () { - expect(requestedFiles).toEqual([]); - expect(pm.get("spaceUnits")).toBe(3); - expect(events.length).toBe(1); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "second", "third"].sort()); - }); - - events = []; - pm.setPathScopeContext("/projects/README.txt").done(function () { - expect(requestedFiles).toEqual([]); - expect(pm.get("spaceUnits")).toBe(4); - expect(events.length).toBe(1); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "third", "fourth"].sort()); - }); - - events = []; - pm.setPathScopeContext("/projects/brackets/README.md").done(function () { - expect(requestedFiles).toEqual(["/projects/brackets/.brackets.json"]); - expect(pm._defaultContext.scopeOrder).toEqual( - ["session", "path:/projects/brackets/.brackets.json", - "path:/.brackets.json", "user", "default"] - ); - expect(pm.get("spaceUnits")).toBe(5); - expect(events.length).toBe(2); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "fourth"].sort()); - expect(events[1].ids.sort()).toEqual(["spaceUnits", "fifth", "sixth"].sort()); - }); - - requestedFiles = []; - events = []; - pm.setPathScopeContext("/projects/brackets/thirdparty/requirejs/require.js") - .done(function () { - expect(requestedFiles).toEqual([]); - expect(pm.get("spaceUnits")).toBe(6); - expect(events.length).toBe(1); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "fourth", "fifth", "sixth"].sort()); - }); - - events = []; - pm.setPathScopeContext("/projects/brackets/thirdparty/codemirror/cm.js") - .done(function () { - expect(requestedFiles) - .toEqual(["/projects/brackets/thirdparty/codemirror/.brackets.json"]); - expect(pm.get("spaceUnits")).toBe(7); - expect(events.length).toBe(2); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "fourth", "fifth", "sixth"].sort()); - expect(events[1].ids.sort()).toEqual(["spaceUnits", "seventh"].sort()); - }); - - events = []; - requestedFiles = []; - pm.setPathScopeContext("/README.md").done(function () { - expect(requestedFiles).toEqual([]); - expect(pm.get("spaceUnits")).toBe(1); - expect(pm._defaultContext.scopeOrder).toEqual(["session", "path:/.brackets.json", "user", "default"]); - expect(events.length).toBe(3); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "fifth", "sixth"].sort()); - expect(events[1].ids.sort()).toEqual(["spaceUnits", "seventh"].sort()); - expect(events[2].ids.sort()).toEqual(["spaceUnits", "first", "fourth"].sort()); - }); - - events = []; - requestedFiles = []; - pm.setPathScopeContext("/projects/brackets/thirdparty/codemirror/cm.js").done(function () { - expect(_.keys(pm._scopes).length).toBe(6); - expect(requestedFiles.length).toBe(2); - expect(pm.get("spaceUnits")).toBe(7); - expect(events.length).toBe(3); - expect(events[0].ids.sort()).toEqual(["spaceUnits", "first", "fourth"].sort()); - expect(events[1].ids.sort()).toEqual(["spaceUnits", "seventh"].sort()); - expect(events[2].ids.sort()).toEqual(["spaceUnits", "fifth", "sixth"].sort()); - }); - - expect(didComplete).toBe(true); - }); - it("can provide an automatically prefixed version of itself", function () { var pm = new PreferencesBase.PreferencesSystem(); pm.addScope("user", new PreferencesBase.MemoryStorage()); diff --git a/test/spec/PreferencesManager-test.js b/test/spec/PreferencesManager-test.js index 7a081a09eb9..bad9df8a505 100644 --- a/test/spec/PreferencesManager-test.js +++ b/test/spec/PreferencesManager-test.js @@ -30,6 +30,7 @@ define(function (require, exports, module) { var PreferenceStorage = require("preferences/PreferenceStorage").PreferenceStorage, SpecRunnerUtils = require("spec/SpecRunnerUtils"), testPath = SpecRunnerUtils.getTestPath("/spec/PreferencesBase-test-files"), + nonProjectFile = SpecRunnerUtils.getTestPath("/spec/PreferencesBase-test.js"), PreferencesManager, testWindow; @@ -128,7 +129,8 @@ define(function (require, exports, module) { }); it("should find preferences in the project", function () { - var projectWithoutSettings = SpecRunnerUtils.getTestPath("/spec/WorkingSetView-test-files"); + var projectWithoutSettings = SpecRunnerUtils.getTestPath("/spec/WorkingSetView-test-files"), + FileViewController = testWindow.brackets.test.FileViewController; waitsForDone(SpecRunnerUtils.openProjectFiles(".brackets.json")); function projectPrefsAreSet() { // The test project file, the Brackets repo file, @@ -138,6 +140,14 @@ define(function (require, exports, module) { waitsFor(projectPrefsAreSet, "prefs appear to be loaded"); runs(function () { expect(PreferencesManager.get("spaceUnits")).toBe(92); + }); + + waitsForDone(FileViewController.openAndSelectDocument(nonProjectFile, + FileViewController.WORKING_SET_VIEW)); + + runs(function () { + expect(PreferencesManager.get("spaceUnits")).not.toBe(92); + // Changing projects will force a change in the project scope. SpecRunnerUtils.loadProjectInTestWindow(projectWithoutSettings); });