diff --git a/apm/package.json b/apm/package.json index ab1c265f3..ffbf9e421 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "0.97.0" + "atom-package-manager": "0.98.0" } } diff --git a/build/package.json b/build/package.json index 422a48693..e25e02fc0 100644 --- a/build/package.json +++ b/build/package.json @@ -8,7 +8,6 @@ "dependencies": { "async": "~0.2.9", "donna": "1.0.1", - "tello": "1.0.3", "formidable": "~1.0.14", "fs-plus": "2.x", "github-releases": "~0.2.0", @@ -35,6 +34,8 @@ "request": "~2.27.0", "rimraf": "~2.2.2", "runas": "~1.0.1", + "tello": "1.0.3", + "temp": "~0.8.1", "underscore-plus": "1.x", "unzip": "~0.1.9", "vm-compatibility-layer": "~0.1.0" diff --git a/build/tasks/codesign-task.coffee b/build/tasks/codesign-task.coffee index d24664c99..82ed5701c 100644 --- a/build/tasks/codesign-task.coffee +++ b/build/tasks/codesign-task.coffee @@ -26,7 +26,7 @@ module.exports = (grunt) -> switch process.platform when 'darwin' cmd = 'codesign' - args = ['-f', '-v', '-s', 'Developer ID Application: GitHub', grunt.config.get('atom.shellAppDir')] + args = ['--deep', '--force', '--verbose', '--sign', 'Developer ID Application: GitHub', grunt.config.get('atom.shellAppDir')] spawn {cmd, args}, (error) -> callback(error) when 'win32' spawn {cmd: 'taskkill', args: ['/F', '/IM', 'atom.exe']}, -> diff --git a/build/tasks/install-task.coffee b/build/tasks/install-task.coffee index 13d349a50..650ac2372 100644 --- a/build/tasks/install-task.coffee +++ b/build/tasks/install-task.coffee @@ -3,6 +3,7 @@ path = require 'path' _ = require 'underscore-plus' fs = require 'fs-plus' runas = null +temp = require 'temp' module.exports = (grunt) -> {cp, mkdir, rm} = require('./task-helpers')(grunt) @@ -22,7 +23,11 @@ module.exports = (grunt) -> else if process.platform is 'darwin' rm installDir mkdir path.dirname(installDir) - cp shellAppDir, installDir + + tempFolder = temp.path() + mkdir tempFolder + cp shellAppDir, tempFolder + fs.renameSync(tempFolder, installDir) else binDir = path.join(installDir, 'bin') shareDir = path.join(installDir, 'share', 'atom') diff --git a/coffeelint.json b/coffeelint.json index 9535d90ac..d8e19fc46 100644 --- a/coffeelint.json +++ b/coffeelint.json @@ -10,5 +10,8 @@ }, "no_interpolation_in_single_quotes": { "level": "error" + }, + "no_debugger": { + "level": "error" } } diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md index afd9a2292..9831ad2a0 100644 --- a/docs/advanced/configuration.md +++ b/docs/advanced/configuration.md @@ -48,15 +48,6 @@ but you can programmatically write to it with `atom.config.set`: atom.config.set("core.showInvisibles", true) ``` -You should never mutate the value of a config key, because that would circumvent -the notification of observers. You can however use methods like `pushAtKeyPath`, -`unshiftAtKeyPath`, and `removeAtKeyPath` to manipulate mutable config values. - -```coffeescript -atom.config.pushAtKeyPath("core.disabledPackages", "wrap-guide") -atom.config.removeAtKeyPath("core.disabledPackages", "terminal") -``` - You can also use `setDefaults`, which will assign default values for keys that are always overridden by values assigned with `set`. Defaults are not written out to the the `config.json` file to prevent it from becoming cluttered. diff --git a/docs/creating-a-package.md b/docs/creating-a-package.md index 288b5edd0..393eaafd5 100644 --- a/docs/creating-a-package.md +++ b/docs/creating-a-package.md @@ -321,6 +321,29 @@ extensions your grammar supports: ] ``` +## Adding Configuration Settings + +You can support config settings in your package that are editable in the +settings view. Specify a `config` key in your package main: + +```coffeescript +module.exports = + # Your config schema! + config: + someInt: + type: 'integer' + default: 23 + minimum: 1 + activate: (state) -> # ... + # ... +``` + +To define the configuration, we use [json schema][json-schema] which allows you +to indicate the type your value should be, its default, etc. + +See the [Config API Docs](https://atom.io/docs/api/latest/Config) for more +details specifying your configuration. + ## Bundle External Resources It's common to ship external resources like images and fonts in the package, to @@ -392,3 +415,4 @@ all the other available commands. [first-package]: your-first-package.html [convert-bundle]: converting-a-text-mate-bundle.html [convert-theme]: converting-a-text-mate-theme.html +[json-schema]: http://json-schema.org/ diff --git a/menus/darwin.cson b/menus/darwin.cson index b892c56cc..f5bd9e168 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -196,11 +196,27 @@ ] 'context-menu': - '.overlayer': - 'Undo': 'core:undo' - 'Redo': 'core:redo' - 'Cut': 'core:cut' - 'Copy': 'core:copy' - 'Paste': 'core:paste' - 'Delete': 'core:delete' - 'Select All': 'core:select-all' + '.overlayer': [ + {label: 'Undo', command: 'core:undo'} + {label: 'Redo', command: 'core:redo'} + {type: 'separator'} + {label: 'Cut', command: 'core:cut'} + {label: 'Copy', command: 'core:copy'} + {label: 'Paste', command: 'core:paste'} + {label: 'Delete', command: 'core:delete'} + {label: 'Select All', command: 'core:select-all'} + {type: 'separator'} + {label: 'Split Up', command: 'pane:split-up'} + {label: 'Split Down', command: 'pane:split-down'} + {label: 'Split Left', command: 'pane:split-left'} + {label: 'Split Right', command: 'pane:split-right'} + {type: 'separator'} + ] + '.pane': [ + {type: 'separator'} + {label: 'Split Up', command: 'pane:split-up'} + {label: 'Split Down', command: 'pane:split-down'} + {label: 'Split Left', command: 'pane:split-left'} + {label: 'Split Right', command: 'pane:split-right'} + {type: 'separator'} + ] diff --git a/menus/linux.cson b/menus/linux.cson index ba87732dd..d65536c10 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -153,11 +153,27 @@ ] 'context-menu': - '.overlayer': - 'Undo': 'core:undo' - 'Redo': 'core:redo' - 'Cut': 'core:cut' - 'Copy': 'core:copy' - 'Paste': 'core:paste' - 'Delete': 'core:delete' - 'Select All': 'core:select-all' + '.overlayer': [ + {label: 'Undo', command: 'core:undo'} + {label: 'Redo', command: 'core:redo'} + {type: 'separator'} + {label: 'Cut', command: 'core:cut'} + {label: 'Copy', command: 'core:copy'} + {label: 'Paste', command: 'core:paste'} + {label: 'Delete', command: 'core:delete'} + {label: 'Select All', command: 'core:select-all'} + {type: 'separator'} + {label: 'Split Up', command: 'pane:split-up'} + {label: 'Split Down', command: 'pane:split-down'} + {label: 'Split Left', command: 'pane:split-left'} + {label: 'Split Right', command: 'pane:split-right'} + {type: 'separator'} + ] + '.pane': [ + {type: 'separator'} + {label: 'Split Up', command: 'pane:split-up'} + {label: 'Split Down', command: 'pane:split-down'} + {label: 'Split Left', command: 'pane:split-left'} + {label: 'Split Right', command: 'pane:split-right'} + {type: 'separator'} + ] diff --git a/menus/win32.cson b/menus/win32.cson index 588e21978..8e094a8d8 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -171,11 +171,27 @@ ] 'context-menu': - '.overlayer': - 'Undo': 'core:undo' - 'Redo': 'core:redo' - 'Cut': 'core:cut' - 'Copy': 'core:copy' - 'Paste': 'core:paste' - 'Delete': 'core:delete' - 'Select All': 'core:select-all' + '.overlayer': [ + {label: 'Undo', command: 'core:undo'} + {label: 'Redo', command: 'core:redo'} + {type: 'separator'} + {label: 'Cut', command: 'core:cut'} + {label: 'Copy', command: 'core:copy'} + {label: 'Paste', command: 'core:paste'} + {label: 'Delete', command: 'core:delete'} + {label: 'Select All', command: 'core:select-all'} + {type: 'separator'} + {label: 'Split Up', command: 'pane:split-up'} + {label: 'Split Down', command: 'pane:split-down'} + {label: 'Split Left', command: 'pane:split-left'} + {label: 'Split Right', command: 'pane:split-right'} + {type: 'separator'} + ] + '.pane': [ + {type: 'separator'} + {label: 'Split Up', command: 'pane:split-up'} + {label: 'Split Down', command: 'pane:split-down'} + {label: 'Split Left', command: 'pane:split-left'} + {label: 'Split Right', command: 'pane:split-right'} + {type: 'separator'} + ] diff --git a/package.json b/package.json index 34159f364..0a95c93fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "0.133.0", + "version": "0.135.0", "description": "A hackable text editor for the 21st Century.", "main": "./src/browser/main.js", "repository": { @@ -17,10 +17,10 @@ "url": "http://github.com/atom/atom/raw/master/LICENSE.md" } ], - "atomShellVersion": "0.16.2", + "atomShellVersion": "0.17.1", "dependencies": { "async": "0.2.6", - "atom-keymap": "^2.1.3", + "atom-keymap": "^2.2.0", "bootstrap": "git+https://github.com/atom/bootstrap.git#6af81906189f1747fd6c93479e3d998ebe041372", "clear-cut": "0.4.0", "coffee-script": "1.7.0", @@ -28,8 +28,8 @@ "delegato": "^1", "emissary": "^1.3.1", "event-kit": "0.7.2", - "first-mate": "^2.1.2", - "fs-plus": "^2.2.6", + "first-mate": "^2.2.0", + "fs-plus": "^2.3.1", "fstream": "0.1.24", "fuzzaldrin": "^2.1", "git-utils": "^2.1.4", @@ -83,12 +83,12 @@ "dev-live-reload": "0.34.0", "exception-reporting": "0.20.0", "feedback": "0.33.0", - "find-and-replace": "0.138.0", + "find-and-replace": "0.139.0", "fuzzy-finder": "0.58.0", "git-diff": "0.39.0", "go-to-line": "0.25.0", "grammar-selector": "0.34.0", - "image-view": "0.36.0", + "image-view": "0.37.0", "incompatible-packages": "0.9.0", "keybinding-resolver": "0.20.0", "link": "0.25.0", @@ -97,13 +97,13 @@ "open-on-github": "0.30.0", "package-generator": "0.31.0", "release-notes": "0.36.0", - "settings-view": "0.147.0", + "settings-view": "0.149.0", "snippets": "0.53.0", "spell-check": "0.42.0", "status-bar": "0.46.0", "styleguide": "0.30.0", "symbols-view": "0.66.0", - "tabs": "0.53.0", + "tabs": "0.54.0", "timecop": "0.22.0", "tree-view": "0.127.0", "update-package-dependencies": "0.6.0", @@ -111,7 +111,7 @@ "whitespace": "0.25.0", "wrap-guide": "0.22.0", "language-c": "0.28.0", - "language-coffee-script": "0.34.0", + "language-coffee-script": "0.35.0", "language-css": "0.17.0", "language-gfm": "0.50.0", "language-git": "0.9.0", @@ -138,7 +138,7 @@ "language-text": "0.6.0", "language-todo": "0.12.0", "language-toml": "0.12.0", - "language-xml": "0.21.0", + "language-xml": "0.22.0", "language-yaml": "0.17.0" }, "private": true, diff --git a/spec/atom-spec.coffee b/spec/atom-spec.coffee index 9fcfa1f2b..31fc9b220 100644 --- a/spec/atom-spec.coffee +++ b/spec/atom-spec.coffee @@ -47,3 +47,8 @@ describe "the `atom` global", -> [event, version, notes] = updateAvailableHandler.mostRecentCall.args expect(notes).toBe 'notes' expect(version).toBe 'version' + + describe "loading default config", -> + it 'loads the default core config', -> + expect(atom.config.get('core.excludeVcsIgnoredPaths')).toBe true + expect(atom.config.get('editor.showInvisibles')).toBe false diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index 117bfe2fd..0c9251054 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -12,7 +12,7 @@ describe "Config", -> describe ".get(keyPath)", -> it "allows a key path's value to be read", -> - expect(atom.config.set("foo.bar.baz", 42)).toBe 42 + expect(atom.config.set("foo.bar.baz", 42)).toBe true expect(atom.config.get("foo.bar.baz")).toBe 42 expect(atom.config.get("bogus.key.path")).toBeUndefined() @@ -37,7 +37,7 @@ describe "Config", -> describe ".set(keyPath, value)", -> it "allows a key path's value to be written", -> - expect(atom.config.set("foo.bar.baz", 42)).toBe 42 + expect(atom.config.set("foo.bar.baz", 42)).toBe true expect(atom.config.get("foo.bar.baz")).toBe 42 it "updates observers and saves when a key path is set", -> @@ -48,7 +48,7 @@ describe "Config", -> atom.config.set("foo.bar.baz", 42) expect(atom.config.save).toHaveBeenCalled() - expect(observeHandler).toHaveBeenCalledWith 42, {previous: undefined} + expect(observeHandler).toHaveBeenCalledWith 42 describe "when the value equals the default value", -> it "does not store the value", -> @@ -139,7 +139,7 @@ describe "Config", -> expect(atom.config.pushAtKeyPath("foo.bar.baz", "b")).toBe 2 expect(atom.config.get("foo.bar.baz")).toEqual ["a", "b"] - expect(observeHandler).toHaveBeenCalledWith atom.config.get("foo.bar.baz"), {previous: ['a']} + expect(observeHandler).toHaveBeenCalledWith atom.config.get("foo.bar.baz") describe ".unshiftAtKeyPath(keyPath, value)", -> it "unshifts the given value to the array at the key path and updates observers", -> @@ -150,7 +150,7 @@ describe "Config", -> expect(atom.config.unshiftAtKeyPath("foo.bar.baz", "a")).toBe 2 expect(atom.config.get("foo.bar.baz")).toEqual ["a", "b"] - expect(observeHandler).toHaveBeenCalledWith atom.config.get("foo.bar.baz"), {previous: ['b']} + expect(observeHandler).toHaveBeenCalledWith atom.config.get("foo.bar.baz") describe ".removeAtKeyPath(keyPath, value)", -> it "removes the given value from the array at the key path and updates observers", -> @@ -161,16 +161,22 @@ describe "Config", -> expect(atom.config.removeAtKeyPath("foo.bar.baz", "b")).toEqual ["a", "c"] expect(atom.config.get("foo.bar.baz")).toEqual ["a", "c"] - expect(observeHandler).toHaveBeenCalledWith atom.config.get("foo.bar.baz"), {previous: ['a', 'b', 'c']} + expect(observeHandler).toHaveBeenCalledWith atom.config.get("foo.bar.baz") describe ".getPositiveInt(keyPath, defaultValue)", -> - it "returns the proper current or default value", -> + it "returns the proper coerced value", -> atom.config.set('editor.preferredLineLength', 0) - expect(atom.config.getPositiveInt('editor.preferredLineLength', 80)).toBe 80 + expect(atom.config.getPositiveInt('editor.preferredLineLength', 80)).toBe 1 + + it "returns the proper coerced value", -> atom.config.set('editor.preferredLineLength', -1234) - expect(atom.config.getPositiveInt('editor.preferredLineLength', 80)).toBe 80 + expect(atom.config.getPositiveInt('editor.preferredLineLength', 80)).toBe 1 + + it "returns the default value when a string is passed in", -> atom.config.set('editor.preferredLineLength', 'abcd') expect(atom.config.getPositiveInt('editor.preferredLineLength', 80)).toBe 80 + + it "returns the default value when null is passed in", -> atom.config.set('editor.preferredLineLength', null) expect(atom.config.getPositiveInt('editor.preferredLineLength', 80)).toBe 80 @@ -226,11 +232,54 @@ describe "Config", -> it "emits an updated event", -> updatedCallback = jasmine.createSpy('updated') - atom.config.observe('foo.bar.baz.a', callNow: false, updatedCallback) + atom.config.onDidChange('foo.bar.baz.a', updatedCallback) expect(updatedCallback.callCount).toBe 0 atom.config.setDefaults("foo.bar.baz", a: 2) expect(updatedCallback.callCount).toBe 1 + describe ".onDidChange(keyPath)", -> + [observeHandler, observeSubscription] = [] + + describe 'when a keyPath is specified', -> + beforeEach -> + observeHandler = jasmine.createSpy("observeHandler") + atom.config.set("foo.bar.baz", "value 1") + observeSubscription = atom.config.onDidChange "foo.bar.baz", observeHandler + + it "does not fire the given callback with the current value at the keypath", -> + expect(observeHandler).not.toHaveBeenCalled() + + it "fires the callback every time the observed value changes", -> + observeHandler.reset() # clear the initial call + atom.config.set('foo.bar.baz', "value 2") + expect(observeHandler).toHaveBeenCalledWith({newValue: 'value 2', oldValue: 'value 1', keyPath: 'foo.bar.baz'}) + observeHandler.reset() + + atom.config.set('foo.bar.baz', "value 1") + expect(observeHandler).toHaveBeenCalledWith({newValue: 'value 1', oldValue: 'value 2', keyPath: 'foo.bar.baz'}) + + describe 'when a keyPath is not specified', -> + beforeEach -> + observeHandler = jasmine.createSpy("observeHandler") + atom.config.set("foo.bar.baz", "value 1") + observeSubscription = atom.config.onDidChange observeHandler + + it "does not fire the given callback initially", -> + expect(observeHandler).not.toHaveBeenCalled() + + it "fires the callback every time any value changes", -> + observeHandler.reset() # clear the initial call + atom.config.set('foo.bar.baz', "value 2") + expect(observeHandler).toHaveBeenCalledWith({newValue: 'value 2', oldValue: 'value 1', keyPath: 'foo.bar.baz'}) + + observeHandler.reset() + atom.config.set('foo.bar.baz', "value 1") + expect(observeHandler).toHaveBeenCalledWith({newValue: 'value 1', oldValue: 'value 2', keyPath: 'foo.bar.baz'}) + + observeHandler.reset() + atom.config.set('foo.bar.int', 1) + expect(observeHandler).toHaveBeenCalledWith({newValue: 1, oldValue: undefined, keyPath: 'foo.bar.int'}) + describe ".observe(keyPath)", -> [observeHandler, observeSubscription] = [] @@ -245,26 +294,25 @@ describe "Config", -> it "fires the callback every time the observed value changes", -> observeHandler.reset() # clear the initial call atom.config.set('foo.bar.baz', "value 2") - expect(observeHandler).toHaveBeenCalledWith("value 2", {previous: 'value 1'}) + expect(observeHandler).toHaveBeenCalledWith("value 2") observeHandler.reset() atom.config.set('foo.bar.baz', "value 1") - expect(observeHandler).toHaveBeenCalledWith("value 1", {previous: 'value 2'}) + expect(observeHandler).toHaveBeenCalledWith("value 1") it "fires the callback when the observed value is deleted", -> observeHandler.reset() # clear the initial call atom.config.set('foo.bar.baz', undefined) - expect(observeHandler).toHaveBeenCalledWith(undefined, {previous: 'value 1'}) + expect(observeHandler).toHaveBeenCalledWith(undefined) it "fires the callback when the full key path goes into and out of existence", -> observeHandler.reset() # clear the initial call atom.config.set("foo.bar", undefined) + expect(observeHandler).toHaveBeenCalledWith(undefined) - expect(observeHandler).toHaveBeenCalledWith(undefined, {previous: 'value 1'}) observeHandler.reset() - atom.config.set("foo.bar.baz", "i'm back") - expect(observeHandler).toHaveBeenCalledWith("i'm back", {previous: undefined}) + expect(observeHandler).toHaveBeenCalledWith("i'm back") it "does not fire the callback once the observe subscription is off'ed", -> observeHandler.reset() # clear the initial call @@ -311,11 +359,19 @@ describe "Config", -> describe "when the config file contains valid cson", -> beforeEach -> fs.writeFileSync(atom.config.configFilePath, "foo: bar: 'baz'") - atom.config.loadUserConfig() it "updates the config data based on the file contents", -> + atom.config.loadUserConfig() expect(atom.config.get("foo.bar")).toBe 'baz' + it "notifies observers for updated keypaths on load", -> + observeHandler = jasmine.createSpy("observeHandler") + observeSubscription = atom.config.observe "foo.bar", observeHandler + + atom.config.loadUserConfig() + + expect(observeHandler).toHaveBeenCalledWith 'baz' + describe "when the config file contains invalid cson", -> beforeEach -> spyOn(console, 'error') @@ -334,10 +390,43 @@ describe "Config", -> expect(fs.existsSync(atom.config.configFilePath)).toBe true expect(CSON.readFileSync(atom.config.configFilePath)).toEqual {} + describe "when a schema is specified", -> + beforeEach -> + schema = + type: 'object' + properties: + bar: + type: 'string' + default: 'def' + int: + type: 'integer' + default: 12 + + atom.config.setSchema('foo', schema) + + describe "when the config file contains values that do not adhere to the schema", -> + warnSpy = null + beforeEach -> + warnSpy = spyOn console, 'warn' + fs.writeFileSync atom.config.configFilePath, """ + foo: + bar: 'baz' + int: 'bad value' + """ + atom.config.loadUserConfig() + + it "updates the only the settings that have values matching the schema", -> + expect(atom.config.get("foo.bar")).toBe 'baz' + expect(atom.config.get("foo.int")).toBe 12 + + expect(warnSpy).toHaveBeenCalled() + expect(warnSpy.mostRecentCall.args[0]).toContain "'foo.int' could not be set" + describe ".observeUserConfig()", -> updatedHandler = null beforeEach -> + atom.config.setDefaults('foo', bar: 'def') atom.config.configDirPath = dotAtomPath atom.config.configFilePath = path.join(atom.config.configDirPath, "atom.config.cson") expect(fs.existsSync(atom.config.configDirPath)).toBeFalsy() @@ -345,7 +434,7 @@ describe "Config", -> atom.config.loadUserConfig() atom.config.observeUserConfig() updatedHandler = jasmine.createSpy("updatedHandler") - atom.config.on 'updated', updatedHandler + atom.config.onDidChange updatedHandler afterEach -> atom.config.unobserveUserConfig() @@ -359,6 +448,60 @@ describe "Config", -> expect(atom.config.get('foo.bar')).toBe 'quux' expect(atom.config.get('foo.baz')).toBe 'bar' + it "does not fire a change event for paths that did not change", -> + atom.config.onDidChange 'foo.bar', noChangeSpy = jasmine.createSpy() + + fs.writeFileSync(atom.config.configFilePath, "foo: { bar: 'baz', omg: 'ok'}") + waitsFor 'update event', -> updatedHandler.callCount > 0 + runs -> + expect(noChangeSpy).not.toHaveBeenCalled() + expect(atom.config.get('foo.bar')).toBe 'baz' + expect(atom.config.get('foo.omg')).toBe 'ok' + + describe 'when the default value is a complex value', -> + beforeEach -> + fs.writeFileSync(atom.config.configFilePath, "foo: { bar: ['baz', 'ok']}") + waitsFor 'update event', -> updatedHandler.callCount > 0 + runs -> updatedHandler.reset() + + it "does not fire a change event for paths that did not change", -> + atom.config.onDidChange 'foo.bar', noChangeSpy = jasmine.createSpy() + + fs.writeFileSync(atom.config.configFilePath, "foo: { bar: ['baz', 'ok'], omg: 'another'}") + waitsFor 'update event', -> updatedHandler.callCount > 0 + runs -> + expect(noChangeSpy).not.toHaveBeenCalled() + expect(atom.config.get('foo.bar')).toEqual ['baz', 'ok'] + expect(atom.config.get('foo.omg')).toBe 'another' + + describe "when the config file changes to omit a setting with a default", -> + it "resets the setting back to the default", -> + fs.writeFileSync(atom.config.configFilePath, "foo: { baz: 'new'}") + waitsFor 'update event', -> updatedHandler.callCount > 0 + runs -> + expect(atom.config.get('foo.bar')).toBe 'def' + expect(atom.config.get('foo.baz')).toBe 'new' + + describe "when the config file changes to be empty", -> + beforeEach -> + fs.writeFileSync(atom.config.configFilePath, "") + waitsFor 'update event', -> updatedHandler.callCount > 0 + + it "resets all settings back to the defaults", -> + expect(updatedHandler.callCount).toBe 1 + expect(atom.config.get('foo.bar')).toBe 'def' + atom.config.set("hair", "blonde") # trigger a save + expect(atom.config.save).toHaveBeenCalled() + + describe "when the config file subsequently changes again to contain configuration", -> + beforeEach -> + updatedHandler.reset() + fs.writeFileSync(atom.config.configFilePath, "foo: bar: 'newVal'") + waitsFor 'update event', -> updatedHandler.callCount > 0 + + it "sets the setting to the value specified in the config file", -> + expect(atom.config.get('foo.bar')).toBe 'newVal' + describe "when the config file changes to contain invalid cson", -> beforeEach -> spyOn(console, 'error') @@ -373,9 +516,349 @@ describe "Config", -> describe "when the config file subsequently changes again to contain valid cson", -> beforeEach -> - fs.writeFileSync(atom.config.configFilePath, "foo: bar: 'baz'") + fs.writeFileSync(atom.config.configFilePath, "foo: bar: 'newVal'") waitsFor 'update event', -> updatedHandler.callCount > 0 it "updates the config data and resumes saving", -> atom.config.set("hair", "blonde") expect(atom.config.save).toHaveBeenCalled() + + describe "when a schema is specified", -> + schema = null + + describe '.setSchema(keyPath, schema)', -> + it 'creates a properly nested schema', -> + schema = + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + + atom.config.setSchema('foo.bar', schema) + + expect(atom.config.getSchema('foo')).toEqual + type: 'object' + properties: + bar: + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + + it 'sets defaults specified by the schema', -> + schema = + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + anObject: + type: 'object' + properties: + nestedInt: + type: 'integer' + default: 24 + nestedObject: + type: 'object' + properties: + superNestedInt: + type: 'integer' + default: 36 + + atom.config.setSchema('foo.bar', schema) + expect(atom.config.get("foo.bar.anInt")).toBe 12 + expect(atom.config.get("foo.bar.anObject")).toEqual + nestedInt: 24 + nestedObject: + superNestedInt: 36 + + it 'can set a non-object schema', -> + schema = + type: 'integer' + default: 12 + + atom.config.setSchema('foo.bar.anInt', schema) + expect(atom.config.get("foo.bar.anInt")).toBe 12 + expect(atom.config.getSchema('foo.bar.anInt')).toEqual + type: 'integer' + default: 12 + + describe '.getSchema(keyPath)', -> + schema = + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + + atom.config.setSchema('foo.bar', schema) + + expect(atom.config.getSchema('foo.bar')).toEqual + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + + expect(atom.config.getSchema('foo.bar.anInt')).toEqual + type: 'integer' + default: 12 + + describe 'when the value has an "integer" type', -> + beforeEach -> + schema = + type: 'integer' + default: 12 + atom.config.setSchema('foo.bar.anInt', schema) + + it 'coerces a string to an int', -> + atom.config.set('foo.bar.anInt', '123') + expect(atom.config.get('foo.bar.anInt')).toBe 123 + + it 'does not allow infinity', -> + atom.config.set('foo.bar.anInt', Infinity) + expect(atom.config.get('foo.bar.anInt')).toBe 12 + + it 'coerces a float to an int', -> + atom.config.set('foo.bar.anInt', 12.3) + expect(atom.config.get('foo.bar.anInt')).toBe 12 + + it 'will not set non-integers', -> + atom.config.set('foo.bar.anInt', null) + expect(atom.config.get('foo.bar.anInt')).toBe 12 + + atom.config.set('foo.bar.anInt', 'nope') + expect(atom.config.get('foo.bar.anInt')).toBe 12 + + describe 'when the minimum and maximum keys are used', -> + beforeEach -> + schema = + type: 'integer' + minimum: 10 + maximum: 20 + default: 12 + atom.config.setSchema('foo.bar.anInt', schema) + + it 'keeps the specified value within the specified range', -> + atom.config.set('foo.bar.anInt', '123') + expect(atom.config.get('foo.bar.anInt')).toBe 20 + + atom.config.set('foo.bar.anInt', '1') + expect(atom.config.get('foo.bar.anInt')).toBe 10 + + describe 'when the value has an "integer" and "string" type', -> + beforeEach -> + schema = + type: ['integer', 'string'] + default: 12 + atom.config.setSchema('foo.bar.anInt', schema) + + it 'can coerce an int, and fallback to a string', -> + atom.config.set('foo.bar.anInt', '123') + expect(atom.config.get('foo.bar.anInt')).toBe 123 + + atom.config.set('foo.bar.anInt', 'cats') + expect(atom.config.get('foo.bar.anInt')).toBe 'cats' + + describe 'when the value has an "string" and "boolean" type', -> + beforeEach -> + schema = + type: ['string', 'boolean'] + default: 'def' + atom.config.setSchema('foo.bar', schema) + + it 'can set a string, a boolean, and revert back to the default', -> + atom.config.set('foo.bar', 'ok') + expect(atom.config.get('foo.bar')).toBe 'ok' + + atom.config.set('foo.bar', false) + expect(atom.config.get('foo.bar')).toBe false + + atom.config.set('foo.bar', undefined) + expect(atom.config.get('foo.bar')).toBe 'def' + + describe 'when the value has a "number" type', -> + beforeEach -> + schema = + type: 'number' + default: 12.1 + atom.config.setSchema('foo.bar.aFloat', schema) + + it 'coerces a string to a float', -> + atom.config.set('foo.bar.aFloat', '12.23') + expect(atom.config.get('foo.bar.aFloat')).toBe 12.23 + + it 'will not set non-numbers', -> + atom.config.set('foo.bar.aFloat', null) + expect(atom.config.get('foo.bar.aFloat')).toBe 12.1 + + atom.config.set('foo.bar.aFloat', 'nope') + expect(atom.config.get('foo.bar.aFloat')).toBe 12.1 + + describe 'when the minimum and maximum keys are used', -> + beforeEach -> + schema = + type: 'number' + minimum: 11.2 + maximum: 25.4 + default: 12.1 + atom.config.setSchema('foo.bar.aFloat', schema) + + it 'keeps the specified value within the specified range', -> + atom.config.set('foo.bar.aFloat', '123.2') + expect(atom.config.get('foo.bar.aFloat')).toBe 25.4 + + atom.config.set('foo.bar.aFloat', '1.0') + expect(atom.config.get('foo.bar.aFloat')).toBe 11.2 + + describe 'when the value has a "boolean" type', -> + beforeEach -> + schema = + type: 'boolean' + default: true + atom.config.setSchema('foo.bar.aBool', schema) + + it 'coerces various types to a boolean', -> + atom.config.set('foo.bar.aBool', 'true') + expect(atom.config.get('foo.bar.aBool')).toBe true + atom.config.set('foo.bar.aBool', 'false') + expect(atom.config.get('foo.bar.aBool')).toBe false + atom.config.set('foo.bar.aBool', 'TRUE') + expect(atom.config.get('foo.bar.aBool')).toBe true + atom.config.set('foo.bar.aBool', 'FALSE') + expect(atom.config.get('foo.bar.aBool')).toBe false + atom.config.set('foo.bar.aBool', 1) + expect(atom.config.get('foo.bar.aBool')).toBe false + atom.config.set('foo.bar.aBool', 0) + expect(atom.config.get('foo.bar.aBool')).toBe false + atom.config.set('foo.bar.aBool', {}) + expect(atom.config.get('foo.bar.aBool')).toBe false + atom.config.set('foo.bar.aBool', null) + expect(atom.config.get('foo.bar.aBool')).toBe false + + it 'reverts back to the default value when undefined is passed to set', -> + atom.config.set('foo.bar.aBool', 'false') + expect(atom.config.get('foo.bar.aBool')).toBe false + + atom.config.set('foo.bar.aBool', undefined) + expect(atom.config.get('foo.bar.aBool')).toBe true + + describe 'when the value has an "string" type', -> + beforeEach -> + schema = + type: 'string' + default: 'ok' + atom.config.setSchema('foo.bar.aString', schema) + + it 'allows strings', -> + atom.config.set('foo.bar.aString', 'yep') + expect(atom.config.get('foo.bar.aString')).toBe 'yep' + + it 'will only set strings', -> + expect(atom.config.set('foo.bar.aString', 123)).toBe false + expect(atom.config.get('foo.bar.aString')).toBe 'ok' + + expect(atom.config.set('foo.bar.aString', true)).toBe false + expect(atom.config.get('foo.bar.aString')).toBe 'ok' + + expect(atom.config.set('foo.bar.aString', null)).toBe false + expect(atom.config.get('foo.bar.aString')).toBe 'ok' + + expect(atom.config.set('foo.bar.aString', [])).toBe false + expect(atom.config.get('foo.bar.aString')).toBe 'ok' + + expect(atom.config.set('foo.bar.aString', nope: 'nope')).toBe false + expect(atom.config.get('foo.bar.aString')).toBe 'ok' + + describe 'when the value has an "object" type', -> + beforeEach -> + schema = + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + nestedObject: + type: 'object' + properties: + nestedBool: + type: 'boolean' + default: false + atom.config.setSchema('foo.bar', schema) + + it 'converts and validates all the children', -> + atom.config.set 'foo.bar', + anInt: '23' + nestedObject: + nestedBool: 'true' + expect(atom.config.get('foo.bar')).toEqual + anInt: 23 + nestedObject: + nestedBool: true + + it 'will set only the values that adhere to the schema', -> + expect(atom.config.set 'foo.bar', + anInt: 'nope' + nestedObject: + nestedBool: true + ).toBe true + expect(atom.config.get('foo.bar.anInt')).toEqual 12 + expect(atom.config.get('foo.bar.nestedObject.nestedBool')).toEqual true + + describe 'when the value has an "array" type', -> + beforeEach -> + schema = + type: 'array' + default: [1, 2, 3] + items: + type: 'integer' + atom.config.setSchema('foo.bar', schema) + + it 'converts an array of strings to an array of ints', -> + atom.config.set 'foo.bar', ['2', '3', '4'] + expect(atom.config.get('foo.bar')).toEqual [2, 3, 4] + + describe 'when the `enum` key is used', -> + beforeEach -> + schema = + type: 'object' + properties: + str: + type: 'string' + default: 'ok' + enum: ['ok', 'one', 'two'] + int: + type: 'integer' + default: 2 + enum: [2, 3, 5] + arr: + type: 'array' + default: ['one', 'two'] + items: + type: 'string' + enum: ['one', 'two', 'three'] + + atom.config.setSchema('foo.bar', schema) + + it 'will only set a string when the string is in the enum values', -> + expect(atom.config.set('foo.bar.str', 'nope')).toBe false + expect(atom.config.get('foo.bar.str')).toBe 'ok' + + expect(atom.config.set('foo.bar.str', 'one')).toBe true + expect(atom.config.get('foo.bar.str')).toBe 'one' + + it 'will only set an integer when the integer is in the enum values', -> + expect(atom.config.set('foo.bar.int', '400')).toBe false + expect(atom.config.get('foo.bar.int')).toBe 2 + + expect(atom.config.set('foo.bar.int', '3')).toBe true + expect(atom.config.get('foo.bar.int')).toBe 3 + + it 'will only set an array when the array values are in the enum values', -> + expect(atom.config.set('foo.bar.arr', ['one', 'five'])).toBe true + expect(atom.config.get('foo.bar.arr')).toEqual ['one'] + + expect(atom.config.set('foo.bar.arr', ['two', 'three'])).toBe true + expect(atom.config.get('foo.bar.arr')).toEqual ['two', 'three'] diff --git a/spec/context-menu-manager-spec.coffee b/spec/context-menu-manager-spec.coffee index 3cd2b28ff..821cb115d 100644 --- a/spec/context-menu-manager-spec.coffee +++ b/spec/context-menu-manager-spec.coffee @@ -3,173 +3,150 @@ ContextMenuManager = require '../src/context-menu-manager' describe "ContextMenuManager", -> - [contextMenu] = [] + [contextMenu, parent, child, grandchild] = [] beforeEach -> {resourcePath} = atom.getLoadSettings() contextMenu = new ContextMenuManager({resourcePath}) - describe "adding definitions", -> - it 'loads', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' + parent = document.createElement("div") + child = document.createElement("div") + grandchild = document.createElement("div") + parent.classList.add('parent') + child.classList.add('child') + grandchild.classList.add('grandchild') + child.appendChild(grandchild) + parent.appendChild(child) - expect(contextMenu.definitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.definitions['.selector'][0].command).toEqual 'command' + describe "::add(itemsBySelector)", -> + it "can add top-level menu items that can be removed with the returned disposable", -> + disposable = contextMenu.add + '.parent': [{label: 'A', command: 'a'}] + '.child': [{label: 'B', command: 'b'}] + '.grandchild': [{label: 'C', command: 'c'}] - it 'does not add duplicate menu items', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' + expect(contextMenu.templateForElement(grandchild)).toEqual [ + {label: 'C', command: 'c'} + {label: 'B', command: 'b'} + {label: 'A', command: 'a'} + ] - contextMenu.add 'file-path', - '.selector': - 'label': 'command' + disposable.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [] - expect(contextMenu.definitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.definitions['.selector'][0].command).toEqual 'command' - expect(contextMenu.definitions['.selector'].length).toBe 1 + it "can add submenu items to existing menus that can be removed with the returned disposable", -> + disposable1 = contextMenu.add + '.grandchild': [{label: 'A', submenu: [{label: 'B', command: 'b'}]}] + disposable2 = contextMenu.add + '.grandchild': [{label: 'A', submenu: [{label: 'C', command: 'c'}]}] - it 'allows multiple separators', -> - contextMenu.add 'file-path', - '.selector': - 'separator1': '-' - 'separator2': '-' - - expect(contextMenu.definitions['.selector'].length).toBe 2 - expect(contextMenu.definitions['.selector'][0].type).toEqual 'separator' - expect(contextMenu.definitions['.selector'][1].type).toEqual 'separator' - - it 'allows duplicate commands with different labels', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' - - contextMenu.add 'file-path', - '.selector': - 'another label': 'command' - - expect(contextMenu.definitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.definitions['.selector'][0].command).toEqual 'command' - expect(contextMenu.definitions['.selector'][1].label).toEqual 'another label' - expect(contextMenu.definitions['.selector'][1].command).toEqual 'command' - - it "loads submenus", -> - contextMenu.add 'file-path', - '.selector': - 'parent': - 'child-1': 'child-1:trigger' - 'child-2': 'child-2:trigger' - 'parent-2': 'parent-2:trigger' - - expect(contextMenu.definitions['.selector'].length).toBe 2 - expect(contextMenu.definitions['.selector'][0].label).toEqual 'parent' - expect(contextMenu.definitions['.selector'][0].submenu.length).toBe 2 - expect(contextMenu.definitions['.selector'][0].submenu[0].label).toBe 'child-1' - expect(contextMenu.definitions['.selector'][0].submenu[0].command).toBe 'child-1:trigger' - expect(contextMenu.definitions['.selector'][0].submenu[1].label).toBe 'child-2' - expect(contextMenu.definitions['.selector'][0].submenu[1].command).toBe 'child-2:trigger' - - describe 'dev mode', -> - it 'loads', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' - , devMode: true - - expect(contextMenu.devModeDefinitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.devModeDefinitions['.selector'][0].command).toEqual 'command' - - describe "building a menu template", -> - beforeEach -> - contextMenu.definitions = { - '.parent':[ - label: 'parent' - command: 'command-p' - ] - '.child': [ - label: 'child' - command: 'command-c' + expect(contextMenu.templateForElement(grandchild)).toEqual [{ + label: 'A', + submenu: [ + {label: 'B', command: 'b'} + {label: 'C', command: 'c'} ] + }] + + disposable2.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{ + label: 'A', + submenu: [ + {label: 'B', command: 'b'} + ] + }] + + disposable1.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [] + + it "favors the most specific / recently added item in the case of a duplicate label", -> + grandchild.classList.add('foo') + + disposable1 = contextMenu.add + '.grandchild': [{label: 'A', command: 'a'}] + disposable2 = contextMenu.add + '.grandchild.foo': [{label: 'A', command: 'b'}] + disposable3 = contextMenu.add + '.grandchild': [{label: 'A', command: 'c'}] + disposable4 = contextMenu.add + '.child': [{label: 'A', command: 'd'}] + + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'b'}] + + disposable2.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'c'}] + + disposable3.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'a'}] + + disposable1.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'd'}] + + it "allows multiple separators, but not adjacent to each other", -> + contextMenu.add + '.grandchild': [ + {label: 'A', command: 'a'}, + {type: 'separator'}, + {type: 'separator'}, + {label: 'B', command: 'b'}, + {type: 'separator'}, + {type: 'separator'}, + {label: 'C', command: 'c'} + ] + + expect(contextMenu.templateForElement(grandchild)).toEqual [ + {label: 'A', command: 'a'}, + {type: 'separator'}, + {label: 'B', command: 'b'}, + {type: 'separator'}, + {label: 'C', command: 'c'} + ] + + it "excludes items marked for display in devMode unless in dev mode", -> + disposable1 = contextMenu.add + '.grandchild': [{label: 'A', command: 'a', devMode: true}, {label: 'B', command: 'b', devMode: false}] + + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'B', command: 'b'}] + + contextMenu.devMode = true + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'a'}, {label: 'B', command: 'b'}] + + it "allows items to be associated with `created` hooks which are invoked on template construction with the item and event", -> + createdEvent = null + + item = { + label: 'A', + command: 'a', + created: (event) -> + @command = 'b' + createdEvent = event } - contextMenu.devModeDefinitions = - '.parent': [ - label: 'dev-label' - command: 'dev-command' - ] + contextMenu.add('.grandchild': [item]) - describe "on a single element", -> - [element] = [] + dispatchedEvent = {target: grandchild} + expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual [{label: 'A', command: 'b'}] + expect(item.command).toBe 'a' # doesn't modify original item template + expect(createdEvent).toBe dispatchedEvent - beforeEach -> - element = ($$ -> @div class: 'parent')[0] + it "allows items to be associated with `shouldDisplay` hooks which are invoked on construction to determine whether the item should be included", -> + shouldDisplayEvent = null + shouldDisplay = true - it "creates a menu with a single item", -> - menu = contextMenu.combinedMenuTemplateForElement(element) + item = { + label: 'A', + command: 'a', + shouldDisplay: (event) -> + @foo = 'bar' + shouldDisplayEvent = event + shouldDisplay + } + contextMenu.add('.grandchild': [item]) - expect(menu[0].label).toEqual 'parent' - expect(menu[0].command).toEqual 'command-p' - expect(menu[1]).toBeUndefined() + dispatchedEvent = {target: grandchild} + expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual [{label: 'A', command: 'a'}] + expect(item.foo).toBeUndefined() # doesn't modify original item template + expect(shouldDisplayEvent).toBe dispatchedEvent - describe "in devMode", -> - beforeEach -> contextMenu.devMode = true - - it "creates a menu with development items", -> - menu = contextMenu.combinedMenuTemplateForElement(element) - - expect(menu[0].label).toEqual 'parent' - expect(menu[0].command).toEqual 'command-p' - expect(menu[1].type).toEqual 'separator' - expect(menu[2].label).toEqual 'dev-label' - expect(menu[2].command).toEqual 'dev-command' - - - describe "on multiple elements", -> - [element] = [] - - beforeEach -> - element = $$ -> - @div class: 'parent', => - @div class: 'child' - - element = element.find('.child')[0] - - it "creates a menu with a two items", -> - menu = contextMenu.combinedMenuTemplateForElement(element) - - expect(menu[0].label).toEqual 'child' - expect(menu[0].command).toEqual 'command-c' - expect(menu[1].label).toEqual 'parent' - expect(menu[1].command).toEqual 'command-p' - expect(menu[2]).toBeUndefined() - - describe "in devMode", -> - beforeEach -> contextMenu.devMode = true - - xit "creates a menu with development items", -> - menu = contextMenu.combinedMenuTemplateForElement(element) - - expect(menu[0].label).toEqual 'child' - expect(menu[0].command).toEqual 'command-c' - expect(menu[1].label).toEqual 'parent' - expect(menu[1].command).toEqual 'command-p' - expect(menu[2].label).toEqual 'dev-label' - expect(menu[2].command).toEqual 'dev-command' - expect(menu[3]).toBeUndefined() - - describe "executeBuildHandlers", -> - menuTemplate = [ - label: 'label' - executeAtBuild: -> - ] - event = - target: null - - it 'should invoke the executeAtBuild fn', -> - buildFn = spyOn(menuTemplate[0], 'executeAtBuild') - contextMenu.executeBuildHandlers(event, menuTemplate) - - expect(buildFn).toHaveBeenCalled() - expect(buildFn.mostRecentCall.args[0]).toBe event + shouldDisplay = false + expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual [] diff --git a/spec/deserializer-manager-spec.coffee b/spec/deserializer-manager-spec.coffee index 3a2bf95e4..15c8a2648 100644 --- a/spec/deserializer-manager-spec.coffee +++ b/spec/deserializer-manager-spec.coffee @@ -1,33 +1,44 @@ DeserializerManager = require '../src/deserializer-manager' -describe ".deserialize(state)", -> - deserializer = null +describe "DeserializerManager", -> + manager = null class Foo @deserialize: ({name}) -> new Foo(name) constructor: (@name) -> beforeEach -> - deserializer = new DeserializerManager() - deserializer.add(Foo) + manager = new DeserializerManager - it "calls deserialize on the deserializer for the given state object, or returns undefined if one can't be found", -> - spyOn(console, 'warn') - object = deserializer.deserialize({ deserializer: 'Foo', name: 'Bar' }) - expect(object.name).toBe 'Bar' - expect(deserializer.deserialize({ deserializer: 'Bogus' })).toBeUndefined() + describe "::add(deserializer)", -> + it "returns a disposable that can be used to remove the manager", -> + disposable = manager.add(Foo) + expect(manager.deserialize({deserializer: 'Foo', name: 'Bar'})).toBeDefined() + disposable.dispose() + spyOn(console, 'warn') + expect(manager.deserialize({deserializer: 'Foo', name: 'Bar'})).toBeUndefined() - describe "when the deserializer has a version", -> + describe "::deserialize(state)", -> beforeEach -> - Foo.version = 2 + manager.add(Foo) - describe "when the deserialized state has a matching version", -> - it "attempts to deserialize the state", -> - object = deserializer.deserialize({ deserializer: 'Foo', version: 2, name: 'Bar' }) - expect(object.name).toBe 'Bar' + it "calls deserialize on the manager for the given state object, or returns undefined if one can't be found", -> + spyOn(console, 'warn') + object = manager.deserialize({deserializer: 'Foo', name: 'Bar'}) + expect(object.name).toBe 'Bar' + expect(manager.deserialize({deserializer: 'Bogus'})).toBeUndefined() - describe "when the deserialized state has a non-matching version", -> - it "returns undefined", -> - expect(deserializer.deserialize({ deserializer: 'Foo', version: 3, name: 'Bar' })).toBeUndefined() - expect(deserializer.deserialize({ deserializer: 'Foo', version: 1, name: 'Bar' })).toBeUndefined() - expect(deserializer.deserialize({ deserializer: 'Foo', name: 'Bar' })).toBeUndefined() + describe "when the manager has a version", -> + beforeEach -> + Foo.version = 2 + + describe "when the deserialized state has a matching version", -> + it "attempts to deserialize the state", -> + object = manager.deserialize({deserializer: 'Foo', version: 2, name: 'Bar'}) + expect(object.name).toBe 'Bar' + + describe "when the deserialized state has a non-matching version", -> + it "returns undefined", -> + expect(manager.deserialize({deserializer: 'Foo', version: 3, name: 'Bar'})).toBeUndefined() + expect(manager.deserialize({deserializer: 'Foo', version: 1, name: 'Bar'})).toBeUndefined() + expect(manager.deserialize({deserializer: 'Foo', name: 'Bar'})).toBeUndefined() diff --git a/spec/fixtures/packages/package-with-config-schema/index.coffee b/spec/fixtures/packages/package-with-config-schema/index.coffee new file mode 100644 index 000000000..7da1d67b7 --- /dev/null +++ b/spec/fixtures/packages/package-with-config-schema/index.coffee @@ -0,0 +1,13 @@ +module.exports = + config: + numbers: + type: 'object' + properties: + one: + type: 'integer' + default: 1 + two: + type: 'integer' + default: 2 + + activate: -> # no-op diff --git a/spec/fixtures/packages/package-with-menus/menus/menu-1.cson b/spec/fixtures/packages/package-with-menus/menus/menu-1.cson index 6cb447eb1..0c95f5972 100644 --- a/spec/fixtures/packages/package-with-menus/menus/menu-1.cson +++ b/spec/fixtures/packages/package-with-menus/menus/menu-1.cson @@ -1,7 +1,8 @@ 'menu': [ - { 'label': 'Second to Last' } + {'label': 'Second to Last'} ] 'context-menu': - '.test-1': - 'Menu item 1': 'command-1' + '.test-1': [ + {label: 'Menu item 1', command: 'command-1'} + ] diff --git a/spec/fixtures/packages/package-with-menus/menus/menu-2.cson b/spec/fixtures/packages/package-with-menus/menus/menu-2.cson index be2419a56..46c3fb9ef 100644 --- a/spec/fixtures/packages/package-with-menus/menus/menu-2.cson +++ b/spec/fixtures/packages/package-with-menus/menus/menu-2.cson @@ -3,5 +3,6 @@ ] 'context-menu': - '.test-1': - 'Menu item 2': 'command-2' + '.test-1': [ + {label: 'Menu item 2', command: 'command-2'} + ] diff --git a/spec/fixtures/packages/package-with-menus/menus/menu-3.cson b/spec/fixtures/packages/package-with-menus/menus/menu-3.cson index 932cd4d5b..445028db6 100644 --- a/spec/fixtures/packages/package-with-menus/menus/menu-3.cson +++ b/spec/fixtures/packages/package-with-menus/menus/menu-3.cson @@ -3,5 +3,6 @@ ] 'context-menu': - '.test-1': - 'Menu item 3': 'command-3' + '.test-1': [ + {label: 'Menu item 3', command: 'command-3'} + ] diff --git a/spec/git-spec.coffee b/spec/git-spec.coffee index f29f71397..3774f14c4 100644 --- a/spec/git-spec.coffee +++ b/spec/git-spec.coffee @@ -223,7 +223,7 @@ describe "GitRepository", -> [editor] = [] beforeEach -> - atom.project.setPath(copyRepository()) + atom.project.setPaths([copyRepository()]) waitsForPromise -> atom.workspace.open('other.txt').then (o) -> editor = o @@ -232,7 +232,7 @@ describe "GitRepository", -> editor.insertNewline() statusHandler = jasmine.createSpy('statusHandler') - atom.project.getRepo().onDidChangeStatus statusHandler + atom.project.getRepositories()[0].onDidChangeStatus statusHandler editor.save() expect(statusHandler.callCount).toBe 1 expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256} @@ -241,7 +241,7 @@ describe "GitRepository", -> fs.writeFileSync(editor.getPath(), 'changed') statusHandler = jasmine.createSpy('statusHandler') - atom.project.getRepo().onDidChangeStatus statusHandler + atom.project.getRepositories()[0].onDidChangeStatus statusHandler editor.getBuffer().reload() expect(statusHandler.callCount).toBe 1 expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256} @@ -252,7 +252,7 @@ describe "GitRepository", -> fs.writeFileSync(editor.getPath(), 'changed') statusHandler = jasmine.createSpy('statusHandler') - atom.project.getRepo().onDidChangeStatus statusHandler + atom.project.getRepositories()[0].onDidChangeStatus statusHandler editor.getBuffer().emitter.emit 'did-change-path' expect(statusHandler.callCount).toBe 1 expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256} @@ -266,7 +266,7 @@ describe "GitRepository", -> project2?.destroy() it "subscribes to all the serialized buffers in the project", -> - atom.project.setPath(copyRepository()) + atom.project.setPaths([copyRepository()]) waitsForPromise -> atom.workspace.open('file.txt') @@ -283,7 +283,7 @@ describe "GitRepository", -> buffer.append('changes') statusHandler = jasmine.createSpy('statusHandler') - project2.getRepo().onDidChangeStatus statusHandler + project2.getRepositories()[0].onDidChangeStatus statusHandler buffer.save() expect(statusHandler.callCount).toBe 1 expect(statusHandler).toHaveBeenCalledWith {path: buffer.getPath(), pathStatus: 256} diff --git a/spec/menu-manager-spec.coffee b/spec/menu-manager-spec.coffee new file mode 100644 index 000000000..d92c921c4 --- /dev/null +++ b/spec/menu-manager-spec.coffee @@ -0,0 +1,48 @@ +MenuManager = require '../src/menu-manager' + +describe "MenuManager", -> + menu = null + + beforeEach -> + menu = new MenuManager(resourcePath: atom.getLoadSettings().resourcePath) + + describe "::add(items)", -> + it "can add new menus that can be removed with the returned disposable", -> + disposable = menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] + expect(menu.template).toEqual [{label: "A", submenu: [{label: "B", command: "b"}]}] + disposable.dispose() + expect(menu.template).toEqual [] + + it "can add submenu items to existing menus that can be removed with the returned disposable", -> + disposable1 = menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] + disposable2 = menu.add [{label: "A", submenu: [{label: "C", submenu: [{label: "D", command: 'd'}]}]}] + disposable3 = menu.add [{label: "A", submenu: [{label: "C", submenu: [{label: "E", command: 'e'}]}]}] + + expect(menu.template).toEqual [{ + label: "A", + submenu: [ + {label: "B", command: "b"}, + {label: "C", submenu: [{label: 'D', command: 'd'}, {label: 'E', command: 'e'}]} + ] + }] + + disposable3.dispose() + expect(menu.template).toEqual [{ + label: "A", + submenu: [ + {label: "B", command: "b"}, + {label: "C", submenu: [{label: 'D', command: 'd'}]} + ] + }] + + disposable2.dispose() + expect(menu.template).toEqual [{label: "A", submenu: [{label: "B", command: "b"}]}] + + disposable1.dispose() + expect(menu.template).toEqual [] + + it "does not add duplicate labels to the same menu", -> + originalItemCount = menu.template.length + menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] + menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] + expect(menu.template[originalItemCount]).toEqual {label: "A", submenu: [{label: "B", command: "b"}]} diff --git a/spec/package-manager-spec.coffee b/spec/package-manager-spec.coffee index a4f7cfc7a..697c5b732 100644 --- a/spec/package-manager-spec.coffee +++ b/spec/package-manager-spec.coffee @@ -82,7 +82,21 @@ describe "PackageManager", -> expect(indexModule.activate).toHaveBeenCalled() expect(pack.mainModule).toBe indexModule - it "assigns config defaults from the module", -> + it "assigns config schema, including defaults when package contains a schema", -> + expect(atom.config.get('package-with-config-schema.numbers.one')).toBeUndefined() + + waitsForPromise -> + atom.packages.activatePackage('package-with-config-schema') + + runs -> + expect(atom.config.get('package-with-config-schema.numbers.one')).toBe 1 + expect(atom.config.get('package-with-config-schema.numbers.two')).toBe 2 + + expect(atom.config.set('package-with-config-schema.numbers.one', 'nope')).toBe false + expect(atom.config.set('package-with-config-schema.numbers.one', '10')).toBe true + expect(atom.config.get('package-with-config-schema.numbers.one')).toBe 10 + + it "still assigns configDefaults from the module though deprecated", -> expect(atom.config.get('package-with-config-defaults.numbers.one')).toBeUndefined() waitsForPromise -> @@ -216,30 +230,30 @@ describe "PackageManager", -> it "loads all the .cson/.json files in the menus directory", -> element = ($$ -> @div class: 'test-1')[0] - expect(atom.contextMenu.definitionsForElement(element)).toEqual [] + expect(atom.contextMenu.templateForElement(element)).toEqual [] atom.packages.activatePackage("package-with-menus") expect(atom.menu.template.length).toBe 2 expect(atom.menu.template[0].label).toBe "Second to Last" expect(atom.menu.template[1].label).toBe "Last" - expect(atom.contextMenu.definitionsForElement(element)[0].label).toBe "Menu item 1" - expect(atom.contextMenu.definitionsForElement(element)[1].label).toBe "Menu item 2" - expect(atom.contextMenu.definitionsForElement(element)[2].label).toBe "Menu item 3" + expect(atom.contextMenu.templateForElement(element)[0].label).toBe "Menu item 1" + expect(atom.contextMenu.templateForElement(element)[1].label).toBe "Menu item 2" + expect(atom.contextMenu.templateForElement(element)[2].label).toBe "Menu item 3" describe "when the metadata contains a 'menus' manifest", -> it "loads only the menus specified by the manifest, in the specified order", -> element = ($$ -> @div class: 'test-1')[0] - expect(atom.contextMenu.definitionsForElement(element)).toEqual [] + expect(atom.contextMenu.templateForElement(element)).toEqual [] atom.packages.activatePackage("package-with-menus-manifest") expect(atom.menu.template[0].label).toBe "Second to Last" expect(atom.menu.template[1].label).toBe "Last" - expect(atom.contextMenu.definitionsForElement(element)[0].label).toBe "Menu item 2" - expect(atom.contextMenu.definitionsForElement(element)[1].label).toBe "Menu item 1" - expect(atom.contextMenu.definitionsForElement(element)[2]).toBeUndefined() + expect(atom.contextMenu.templateForElement(element)[0].label).toBe "Menu item 2" + expect(atom.contextMenu.templateForElement(element)[1].label).toBe "Menu item 1" + expect(atom.contextMenu.templateForElement(element)[2]).toBeUndefined() describe "stylesheet loading", -> describe "when the metadata contains a 'stylesheets' manifest", -> diff --git a/spec/pane-container-view-spec.coffee b/spec/pane-container-view-spec.coffee index b5cbb4372..fb069cad5 100644 --- a/spec/pane-container-view-spec.coffee +++ b/spec/pane-container-view-spec.coffee @@ -6,11 +6,11 @@ PaneView = require '../src/pane-view' {$, View, $$} = require 'atom' describe "PaneContainerView", -> - [TestView, container, pane1, pane2, pane3] = [] + [TestView, container, pane1, pane2, pane3, deserializerDisposable] = [] beforeEach -> class TestView extends View - atom.deserializers.add(this) + deserializerDisposable = atom.deserializers.add(this) @deserialize: ({name}) -> new TestView(name) @content: -> @div tabindex: -1 initialize: (@name) -> @text(@name) @@ -26,7 +26,7 @@ describe "PaneContainerView", -> pane3 = pane2.splitDown(new TestView('3')) afterEach -> - atom.deserializers.remove(TestView) + deserializerDisposable.dispose() describe ".getActivePaneView()", -> it "returns the most-recently focused pane", -> diff --git a/spec/pane-spec.coffee b/spec/pane-spec.coffee index 01dcda025..748de6c36 100644 --- a/spec/pane-spec.coffee +++ b/spec/pane-spec.coffee @@ -4,6 +4,8 @@ PaneAxis = require '../src/pane-axis' PaneContainer = require '../src/pane-container' describe "Pane", -> + deserializerDisposable = null + class Item extends Model @deserialize: ({name, uri}) -> new this(name, uri) constructor: (@name, @uri) -> @@ -13,10 +15,10 @@ describe "Pane", -> isEqual: (other) -> @name is other?.name beforeEach -> - atom.deserializers.add(Item) + deserializerDisposable = atom.deserializers.add(Item) afterEach -> - atom.deserializers.remove(Item) + deserializerDisposable.dispose() describe "construction", -> it "sets the active item to the first item", -> diff --git a/spec/pane-view-spec.coffee b/spec/pane-view-spec.coffee index 3550950c7..6867dbd14 100644 --- a/spec/pane-view-spec.coffee +++ b/spec/pane-view-spec.coffee @@ -7,7 +7,7 @@ path = require 'path' temp = require 'temp' describe "PaneView", -> - [container, containerModel, view1, view2, editor1, editor2, pane, paneModel] = [] + [container, containerModel, view1, view2, editor1, editor2, pane, paneModel, deserializerDisposable] = [] class TestView extends View @deserialize: ({id, text}) -> new TestView({id, text}) @@ -23,7 +23,7 @@ describe "PaneView", -> @emitter.on 'did-change-title', callback beforeEach -> - atom.deserializers.add(TestView) + deserializerDisposable = atom.deserializers.add(TestView) container = atom.workspace.getView(new PaneContainer).__spacePenView containerModel = container.model view1 = new TestView(id: 'view-1', text: 'View 1') @@ -40,7 +40,7 @@ describe "PaneView", -> paneModel.addItems([view1, editor1, view2, editor2]) afterEach -> - atom.deserializers.remove(TestView) + deserializerDisposable.dispose() describe "when the active pane item changes", -> it "hides all item views except the active one", -> diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee index 06e3ee29d..324772459 100644 --- a/spec/project-spec.coffee +++ b/spec/project-spec.coffee @@ -9,7 +9,7 @@ BufferedProcess = require '../src/buffered-process' describe "Project", -> beforeEach -> - atom.project.setPath(atom.project.resolve('dir')) + atom.project.setPaths([atom.project.resolve('dir')]) describe "serialization", -> deserializedProject = null @@ -41,8 +41,8 @@ describe "Project", -> describe "when an editor is saved and the project has no path", -> it "sets the project's path to the saved file's parent directory", -> tempFile = temp.openSync().path - atom.project.setPath(undefined) - expect(atom.project.getPath()).toBeUndefined() + atom.project.setPaths([]) + expect(atom.project.getPaths()[0]).toBeUndefined() editor = null waitsForPromise -> @@ -50,7 +50,7 @@ describe "Project", -> runs -> editor.saveAs(tempFile) - expect(atom.project.getPath()).toBe path.dirname(tempFile) + expect(atom.project.getPaths()[0]).toBe path.dirname(tempFile) describe ".open(path)", -> [absolutePath, newBufferHandler] = [] @@ -164,7 +164,7 @@ describe "Project", -> describe "when the project has no path", -> it "returns undefined for relative URIs", -> - atom.project.setPath() + atom.project.setPaths([]) expect(atom.project.resolve('test.txt')).toBeUndefined() expect(atom.project.resolve('http://github.com')).toBe 'http://github.com' absolutePath = fs.absolute(__dirname) @@ -173,33 +173,33 @@ describe "Project", -> describe ".setPath(path)", -> describe "when path is a file", -> it "sets its path to the files parent directory and updates the root directory", -> - atom.project.setPath(require.resolve('./fixtures/dir/a')) - expect(atom.project.getPath()).toEqual path.dirname(require.resolve('./fixtures/dir/a')) + atom.project.setPaths([require.resolve('./fixtures/dir/a')]) + expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a')) expect(atom.project.getRootDirectory().path).toEqual path.dirname(require.resolve('./fixtures/dir/a')) describe "when path is a directory", -> it "sets its path to the directory and updates the root directory", -> directory = fs.absolute(path.join(__dirname, 'fixtures', 'dir', 'a-dir')) - atom.project.setPath(directory) - expect(atom.project.getPath()).toEqual directory + atom.project.setPaths([directory]) + expect(atom.project.getPaths()[0]).toEqual directory expect(atom.project.getRootDirectory().path).toEqual directory describe "when path is null", -> it "sets its path and root directory to null", -> - atom.project.setPath(null) - expect(atom.project.getPath()?).toBeFalsy() + atom.project.setPaths([]) + expect(atom.project.getPaths()[0]?).toBeFalsy() expect(atom.project.getRootDirectory()?).toBeFalsy() it "normalizes the path to remove consecutive slashes, ., and .. segments", -> - atom.project.setPath("#{require.resolve('./fixtures/dir/a')}#{path.sep}b#{path.sep}#{path.sep}..") - expect(atom.project.getPath()).toEqual path.dirname(require.resolve('./fixtures/dir/a')) + atom.project.setPaths(["#{require.resolve('./fixtures/dir/a')}#{path.sep}b#{path.sep}#{path.sep}.."]) + expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a')) expect(atom.project.getRootDirectory().path).toEqual path.dirname(require.resolve('./fixtures/dir/a')) describe ".replace()", -> [filePath, commentFilePath, sampleContent, sampleCommentContent] = [] beforeEach -> - atom.project.setPath(atom.project.resolve('../')) + atom.project.setPaths([atom.project.resolve('../')]) filePath = atom.project.resolve('sample.js') commentFilePath = atom.project.resolve('sample-with-comments.js') @@ -332,7 +332,7 @@ describe "Project", -> it "works on evil filenames", -> platform.generateEvilFiles() - atom.project.setPath(path.join(__dirname, 'fixtures', 'evil-files')) + atom.project.setPaths([path.join(__dirname, 'fixtures', 'evil-files')]) paths = [] matches = [] waitsForPromise -> @@ -387,7 +387,7 @@ describe "Project", -> fs.removeSync(projectPath) if fs.existsSync(projectPath) it "excludes ignored files", -> - atom.project.setPath(projectPath) + atom.project.setPaths([projectPath]) atom.config.set('core.excludeVcsIgnoredPaths', true) resultHandler = jasmine.createSpy("result found") waitsForPromise -> @@ -399,7 +399,7 @@ describe "Project", -> it "includes only files when a directory filter is specified", -> projectPath = path.join(path.join(__dirname, 'fixtures', 'dir')) - atom.project.setPath(projectPath) + atom.project.setPaths([projectPath]) filePath = path.join(projectPath, 'a-dir', 'oh-git') @@ -419,7 +419,7 @@ describe "Project", -> projectPath = temp.mkdirSync() filePath = path.join(projectPath, '.text') fs.writeFileSync(filePath, 'match this') - atom.project.setPath(projectPath) + atom.project.setPaths([projectPath]) paths = [] matches = [] waitsForPromise -> diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 08531106c..68c382d47 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -69,7 +69,7 @@ beforeEach -> $.fx.off = true documentTitle = null projectPath = specProjectPath ? path.join(@specDirectory, 'fixtures') - atom.project = new Project(path: projectPath) + atom.project = new Project(paths: [projectPath]) atom.workspace = new Workspace() atom.keymaps.keyBindings = _.clone(keyBindingsToRestore) atom.commands.setRootNode(document.body) @@ -98,16 +98,16 @@ beforeEach -> config = new Config({resourcePath, configDirPath: atom.getConfigDirPath()}) spyOn(config, 'load') spyOn(config, 'save') - config.setDefaults('core', WorkspaceView.configDefaults) - config.setDefaults('editor', TextEditorView.configDefaults) + atom.config = config + atom.loadConfig() config.set "core.destroyEmptyPanes", false config.set "editor.fontFamily", "Courier" config.set "editor.fontSize", 16 config.set "editor.autoIndent", false config.set "core.disabledPackages", ["package-that-throws-an-exception", "package-with-broken-package-json", "package-with-broken-keymap"] + config.load.reset() config.save.reset() - atom.config = config # make editor display updates synchronous spyOn(TextEditorView.prototype, 'requestDisplayUpdate').andCallFake -> @updateDisplay() @@ -132,6 +132,7 @@ beforeEach -> afterEach -> atom.packages.deactivatePackages() atom.menu.template = [] + atom.contextMenu.clear() atom.workspaceView?.remove?() atom.workspaceView = null diff --git a/spec/text-editor-component-spec.coffee b/spec/text-editor-component-spec.coffee index cc76c4f6f..b3f268b56 100644 --- a/spec/text-editor-component-spec.coffee +++ b/spec/text-editor-component-spec.coffee @@ -1734,11 +1734,11 @@ describe "TextEditorComponent", -> nextAnimationFrame() expect(verticalScrollbarNode.scrollTop).toBe 10 - it "parses negative scrollSensitivity values as positive", -> + it "parses negative scrollSensitivity values at the minimum", -> atom.config.set('editor.scrollSensitivity', -50) componentNode.dispatchEvent(new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -10)) nextAnimationFrame() - expect(verticalScrollbarNode.scrollTop).toBe 5 + expect(verticalScrollbarNode.scrollTop).toBe 1 describe "when the mousewheel event's target is a line", -> it "keeps the line on the DOM if it is scrolled off-screen", -> @@ -2264,6 +2264,10 @@ describe "TextEditorComponent", -> wrapperView.detach() wrapperView.attachToDom() + wrapperView.trigger('core:move-right') + + expect(editor.getCursorBufferPosition()).toEqual [0, 1] + buildMouseEvent = (type, properties...) -> properties = extend({bubbles: true, cancelable: true}, properties...) properties.detail ?= 1 diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee index 4a037d2f9..c7b64fec2 100644 --- a/spec/theme-manager-spec.coffee +++ b/spec/theme-manager-spec.coffee @@ -52,7 +52,7 @@ describe "ThemeManager", -> expect(themeManager.getEnabledThemeNames()).toEqual ['atom-dark-ui', 'atom-light-ui'] - describe "getImportPaths()", -> + describe "::getImportPaths()", -> it "returns the theme directories before the themes are loaded", -> atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui', 'atom-light-ui']) @@ -129,7 +129,7 @@ describe "ThemeManager", -> spyOn(console, 'warn') expect(-> atom.packages.activatePackage('a-theme-that-will-not-be-found')).toThrow() - describe "requireStylesheet(path)", -> + describe "::requireStylesheet(path)", -> it "synchronously loads css at the given path and installs a style tag for it in the head", -> themeManager.onDidChangeStylesheets stylesheetsChangedHandler = jasmine.createSpy("stylesheetsChangedHandler") themeManager.onDidAddStylesheet stylesheetAddedHandler = jasmine.createSpy("stylesheetAddedHandler") @@ -185,18 +185,17 @@ describe "ThemeManager", -> $('head style[id*="css.css"]').remove() $('head style[id*="sample.less"]').remove() - describe ".removeStylesheet(path)", -> - it "removes styling applied by given stylesheet path", -> + it "returns a disposable allowing styles applied by the given path to be removed", -> cssPath = require.resolve('./fixtures/css.css') expect($(document.body).css('font-weight')).not.toBe("bold") - themeManager.requireStylesheet(cssPath) + disposable = themeManager.requireStylesheet(cssPath) expect($(document.body).css('font-weight')).toBe("bold") themeManager.onDidRemoveStylesheet stylesheetRemovedHandler = jasmine.createSpy("stylesheetRemovedHandler") themeManager.onDidChangeStylesheets stylesheetsChangedHandler = jasmine.createSpy("stylesheetsChangedHandler") - themeManager.removeStylesheet(cssPath) + disposable.dispose() expect($(document.body).css('font-weight')).not.toBe("bold") diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index ec1dc5009..51d48b5eb 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -586,7 +586,7 @@ describe "TokenizedBuffer", -> atom.config.set('editor.tabLength', 1) expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' atom.config.set('editor.tabLength', 0) - expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' describe "when the invisibles value changes", -> beforeEach -> diff --git a/spec/window-spec.coffee b/spec/window-spec.coffee index c35d607de..b5bf3ddc7 100644 --- a/spec/window-spec.coffee +++ b/spec/window-spec.coffee @@ -8,7 +8,7 @@ describe "Window", -> beforeEach -> spyOn(atom, 'hide') - initialPath = atom.project.getPath() + initialPath = atom.project.getPaths()[0] spyOn(atom, 'getLoadSettings').andCallFake -> loadSettings = atom.getLoadSettings.originalValue.call(atom) loadSettings.initialPath = initialPath @@ -16,7 +16,7 @@ describe "Window", -> atom.project.destroy() windowEventHandler = new WindowEventHandler() atom.deserializeEditorWindow() - projectPath = atom.project.getPath() + projectPath = atom.project.getPaths()[0] afterEach -> windowEventHandler.unsubscribe() @@ -263,19 +263,19 @@ describe "Window", -> describe "when the project does not have a path", -> beforeEach -> - atom.project.setPath() + atom.project.setPaths([]) describe "when the opened path exists", -> it "sets the project path to the opened path", -> $(window).trigger('window:open-path', [{pathToOpen: __filename}]) - expect(atom.project.getPath()).toBe __dirname + expect(atom.project.getPaths()[0]).toBe __dirname describe "when the opened path does not exist but its parent directory does", -> it "sets the project path to the opened path's parent directory", -> $(window).trigger('window:open-path', [{pathToOpen: path.join(__dirname, 'this-path-does-not-exist.txt')}]) - expect(atom.project.getPath()).toBe __dirname + expect(atom.project.getPaths()[0]).toBe __dirname describe "when the opened path is a file", -> it "opens it in the workspace", -> diff --git a/spec/workspace-spec.coffee b/spec/workspace-spec.coffee index 7cd0b17d1..a156d80f7 100644 --- a/spec/workspace-spec.coffee +++ b/spec/workspace-spec.coffee @@ -7,7 +7,7 @@ describe "Workspace", -> workspace = null beforeEach -> - atom.project.setPath(atom.project.resolve('dir')) + atom.project.setPaths([atom.project.resolve('dir')]) atom.workspace = workspace = new Workspace describe "::open(uri, options)", -> @@ -222,8 +222,8 @@ describe "Workspace", -> it "returns the resource returned by the custom opener", -> fooOpener = (pathToOpen, options) -> { foo: pathToOpen, options } if pathToOpen?.match(/\.foo/) barOpener = (pathToOpen) -> { bar: pathToOpen } if pathToOpen?.match(/^bar:\/\//) - workspace.registerOpener(fooOpener) - workspace.registerOpener(barOpener) + workspace.addOpener(fooOpener) + workspace.addOpener(barOpener) waitsForPromise -> pathToOpen = atom.project.resolve('a.foo') @@ -387,25 +387,25 @@ describe "Workspace", -> it "sets the title to the pane item's title plus the project path", -> item = atom.workspace.getActivePaneItem() console.log item.getTitle() - expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPath()}" + expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPaths()[0]}" describe "when the title of the active pane item changes", -> it "updates the window title based on the item's new title", -> editor = atom.workspace.getActivePaneItem() editor.buffer.setPath(path.join(temp.dir, 'hi')) - expect(document.title).toBe "#{editor.getTitle()} - #{atom.project.getPath()}" + expect(document.title).toBe "#{editor.getTitle()} - #{atom.project.getPaths()[0]}" describe "when the active pane's item changes", -> it "updates the title to the new item's title plus the project path", -> atom.workspace.getActivePane().activateNextItem() item = atom.workspace.getActivePaneItem() - expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPath()}" + expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPaths()[0]}" describe "when the last pane item is removed", -> it "updates the title to contain the project's path", -> atom.workspace.getActivePane().destroy() expect(atom.workspace.getActivePaneItem()).toBeUndefined() - expect(document.title).toBe atom.project.getPath() + expect(document.title).toBe atom.project.getPaths()[0] describe "when an inactive pane's item changes", -> it "does not update the title", -> @@ -424,7 +424,7 @@ describe "Workspace", -> console.log atom.workspace.getActivePaneItem() workspace2 = atom.workspace.testSerialization() item = atom.workspace.getActivePaneItem() - expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPath()}" + expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPaths()[0]}" workspace2.destroy() describe "document edited status", -> diff --git a/spec/workspace-view-spec.coffee b/spec/workspace-view-spec.coffee index 8fc7f29be..379856137 100644 --- a/spec/workspace-view-spec.coffee +++ b/spec/workspace-view-spec.coffee @@ -10,7 +10,7 @@ describe "WorkspaceView", -> pathToOpen = null beforeEach -> - atom.project.setPath(atom.project.resolve('dir')) + atom.project.setPaths([atom.project.resolve('dir')]) pathToOpen = atom.project.resolve('a') atom.workspace = new Workspace atom.workspaceView = atom.workspace.getView(atom.workspace).__spacePenView @@ -49,7 +49,7 @@ describe "WorkspaceView", -> expect(atom.workspaceView.getEditorViews().length).toBe 2 expect(atom.workspaceView.getActivePaneView()).toBe atom.workspaceView.getPaneViews()[1] - expect(document.title).toBe "untitled - #{atom.project.getPath()}" + expect(document.title).toBe "untitled - #{atom.project.getPaths()[0]}" describe "when there are open editors", -> it "constructs the view with the same panes", -> @@ -106,7 +106,7 @@ describe "WorkspaceView", -> expect(editorView3).not.toHaveFocus() expect(editorView4).not.toHaveFocus() - expect(document.title).toBe "#{path.basename(editorView2.getEditor().getPath())} - #{atom.project.getPath()}" + expect(document.title).toBe "#{path.basename(editorView2.getEditor().getPath())} - #{atom.project.getPaths()[0]}" describe "where there are no open editors", -> it "constructs the view with no open editors", -> diff --git a/src/atom.coffee b/src/atom.coffee index ba209151f..a18cafe4d 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -466,9 +466,7 @@ class Atom extends Model console.warn error.message if error? dimensions = @restoreWindowDimensions() - @config.load() - @config.setDefaults('core', require('./workspace-view').configDefaults) - @config.setDefaults('editor', require('./text-editor-view').configDefaults) + @loadConfig() @keymaps.loadBundledKeymaps() @themes.loadBaseStylesheets() @packages.loadPackages() @@ -579,7 +577,7 @@ class Atom extends Model Project = require './project' startTime = Date.now() - @project ?= @deserializers.deserialize(@state.project) ? new Project(path: @getLoadSettings().initialPath) + @project ?= @deserializers.deserialize(@state.project) ? new Project(paths: [@getLoadSettings().initialPath]) @deserializeTimings.project = Date.now() - startTime deserializeWorkspaceView: -> @@ -604,6 +602,10 @@ class Atom extends Model @deserializeProject() @deserializeWorkspaceView() + loadConfig: -> + @config.setSchema null, {type: 'object', properties: _.clone(require('./config-schema'))} + @config.load() + loadThemes: -> @themes.load() @@ -617,8 +619,8 @@ class Atom extends Model # Notify the browser project of the window's current project path watchProjectPath: -> onProjectPathChanged = => - ipc.send('window-command', 'project-path-changed', @project.getPath()) - @subscribe @project, 'path-changed', onProjectPathChanged + ipc.send('window-command', 'project-path-changed', @project.getPaths()[0]) + @subscribe @project.onDidChangePaths(onProjectPathChanged) onProjectPathChanged() exit: (status) -> diff --git a/src/browser/atom-window.coffee b/src/browser/atom-window.coffee index c8e395720..bca8716ce 100644 --- a/src/browser/atom-window.coffee +++ b/src/browser/atom-window.coffee @@ -28,6 +28,7 @@ class AtomWindow title: 'Atom' icon: @constructor.iconPath 'web-preferences': + 'direct-write': false 'subpixel-font-scaling': false global.atomApplication.addWindow(this) diff --git a/src/browser/context-menu.coffee b/src/browser/context-menu.coffee index b3a043fda..2b4a8206e 100644 --- a/src/browser/context-menu.coffee +++ b/src/browser/context-menu.coffee @@ -13,12 +13,12 @@ class ContextMenu createClickHandlers: (template) -> for item in template if item.command - item.commandOptions ?= {} - item.commandOptions.contextCommand = true - item.commandOptions.atomWindow = @atomWindow + item.commandDetail ?= {} + item.commandDetail.contextCommand = true + item.commandDetail.atomWindow = @atomWindow do (item) => item.click = => - global.atomApplication.sendCommandToWindow(item.command, @atomWindow, item.commandOptions) + global.atomApplication.sendCommandToWindow(item.command, @atomWindow, item.commandDetail) else if item.submenu @createClickHandlers(item.submenu) item diff --git a/src/config-schema.coffee b/src/config-schema.coffee new file mode 100644 index 000000000..d841907cc --- /dev/null +++ b/src/config-schema.coffee @@ -0,0 +1,115 @@ +path = require 'path' +fs = require 'fs-plus' + +# This is loaded by atom.coffee +module.exports = + core: + type: 'object' + properties: + ignoredNames: + type: 'array' + default: [".git", ".hg", ".svn", ".DS_Store", "Thumbs.db"] + items: + type: 'string' + excludeVcsIgnoredPaths: + type: 'boolean' + default: true + title: 'Exclude VCS Ignored Paths' + disabledPackages: + type: 'array' + default: [] + items: + type: 'string' + themes: + type: 'array' + default: ['atom-dark-ui', 'atom-dark-syntax'] + items: + type: 'string' + projectHome: + type: 'string' + default: path.join(fs.getHomeDirectory(), 'github') + audioBeep: + type: 'boolean' + default: true + destroyEmptyPanes: + type: 'boolean' + default: true + + editor: + type: 'object' + properties: + fontFamily: + type: 'string' + default: '' + fontSize: + type: 'integer' + default: 16 + minimum: 1 + lineHeight: + type: ['string', 'number'] + default: 1.3 + showInvisibles: + type: 'boolean' + default: false + showIndentGuide: + type: 'boolean' + default: false + showLineNumbers: + type: 'boolean' + default: true + autoIndent: + type: 'boolean' + default: true + normalizeIndentOnPaste: + type: 'boolean' + default: true + nonWordCharacters: + type: 'string' + default: "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-" + preferredLineLength: + type: 'integer' + default: 80 + minimum: 1 + tabLength: + type: 'integer' + default: 2 + minimum: 1 + softWrap: + type: 'boolean' + default: false + softTabs: + type: 'boolean' + default: true + softWrapAtPreferredLineLength: + type: 'boolean' + default: false + scrollSensitivity: + type: 'integer' + default: 40 + minimum: 10 + maximum: 200 + scrollPastEnd: + type: 'boolean' + default: false + useHardwareAcceleration: + type: 'boolean' + default: true + confirmCheckoutHeadRevision: + type: 'boolean' + default: true + title: 'Confirm Checkout HEAD Revision' + invisibles: + type: 'object' + properties: + eol: + type: ['boolean', 'string'] + default: '\u00ac' + space: + type: ['boolean', 'string'] + default: '\u00b7' + tab: + type: ['boolean', 'string'] + default: '\u00bb' + cr: + type: ['boolean', 'string'] + default: '\u00a4' diff --git a/src/config.coffee b/src/config.coffee index bdf2dde42..56d3efa9d 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -1,39 +1,510 @@ _ = require 'underscore-plus' fs = require 'fs-plus' -{Emitter} = require 'emissary' +EmitterMixin = require('emissary').Emitter +{Emitter} = require 'event-kit' CSON = require 'season' path = require 'path' async = require 'async' pathWatcher = require 'pathwatcher' +{deprecate} = require 'grim' # Essential: Used to access all of Atom's configuration details. # # An instance of this class is always available as the `atom.config` global. # -# ## Best practices -# -# * Create your own root keypath using your package's name. -# * Don't depend on (or write to) configuration keys outside of your keypath. -# -# ## Examples +# ## Getting and setting config settings. Note that with no value set, {::get} +# returns the setting's default value. # # ```coffee -# atom.config.set('my-package.key', 'value') -# atom.config.observe 'my-package.key', -> -# console.log 'My configuration changed:', atom.config.get('my-package.key') +# atom.config.get('my-package.myKey') # -> 'defaultValue' +# +# atom.config.set('my-package.myKey', 'value') +# atom.config.get('my-package.myKey') # -> 'value' # ``` +# +# You may want to watch for changes. Use {::observe} to catch changes to the setting. +# +# ```coffee +# atom.config.set('my-package.myKey', 'value') +# atom.config.observe 'my-package.myKey', (newValue) -> +# # `observe` calls immediately and every time the value is changed +# console.log 'My configuration changed:', newValue +# ``` +# +# If you want a notification only when the value changes, use {::onDidChange}. +# +# ```coffee +# atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) -> +# console.log 'My configuration changed:', newValue, oldValue +# ``` +# +# ### Value Coercion +# +# Config settings each have a type specified by way of a +# [schema](json-schema.org). For example we might an integer setting that only +# allows integers greater than `0`: +# +# ```coffee +# # When no value has been set, `::get` returns the setting's default value +# atom.config.get('my-package.anInt') # -> 12 +# +# # The string will be coerced to the integer 123 +# atom.config.set('my-package.anInt', '123') +# atom.config.get('my-package.anInt') # -> 123 +# +# # The string will be coerced to an integer, but it must be greater than 0, so is set to 1 +# atom.config.set('my-package.anInt', '-20') +# atom.config.get('my-package.anInt') # -> 1 +# ``` +# +# ## Defining settings for your package +# +# Define a schema under a `config` key in your package main. +# +# ```coffee +# module.exports = +# # Your config schema +# config: +# someInt: +# type: 'integer' +# default: 23 +# minimum: 1 +# +# activate: (state) -> # ... +# # ... +# ``` +# +# See [Creating a Package](https://atom.io/docs/latest/creating-a-package) for +# more info. +# +# ## Config Schemas +# +# We use [json schema](json-schema.org) which allows you to define your value's +# default, the type it should be, etc. A simple example: +# +# ```coffee +# # We want to provide an `enableThing`, and a `thingVolume` +# config: +# enableThing: +# type: 'boolean' +# default: false +# thingVolume: +# type: 'integer' +# default: 5 +# minimum: 1 +# maximum: 11 +# ``` +# +# The type keyword allows for type coercion and validation. If a `thingVolume` is +# set to a string `'10'`, it will be coerced into an integer. +# +# ```coffee +# atom.config.set('my-package.thingVolume', '10') +# atom.config.get('my-package.thingVolume') # -> 10 +# +# # It respects the min / max +# atom.config.set('my-package.thingVolume', '400') +# atom.config.get('my-package.thingVolume') # -> 11 +# +# # If it cannot be coerced, the value will not be set +# atom.config.set('my-package.thingVolume', 'cats') +# atom.config.get('my-package.thingVolume') # -> 11 +# ``` +# +# ### Supported Types +# +# The `type` keyword can be a string with any one of the following. You can also +# chain them by specifying multiple in an an array. For example +# +# ```coffee +# config: +# someSetting: +# type: ['boolean', 'integer'] +# default: 5 +# +# # Then +# atom.config.set('my-package.someSetting', 'true') +# atom.config.get('my-package.someSetting') # -> true +# +# atom.config.set('my-package.someSetting', '12') +# atom.config.get('my-package.someSetting') # -> 12 +# ``` +# +# #### string +# +# Values must be a string. +# +# ```coffee +# config: +# someSetting: +# type: 'string' +# default: 'hello' +# ``` +# +# #### integer +# +# Values will be coerced into integer. Supports the (optional) `minimum` and +# `maximum` keys. +# +# ```coffee +# config: +# someSetting: +# type: 'integer' +# default: 5 +# minimum: 1 +# maximum: 11 +# ``` +# +# #### number +# +# Values will be coerced into a number, including real numbers. Supports the +# (optional) `minimum` and `maximum` keys. +# +# ```coffee +# config: +# someSetting: +# type: 'number' +# default: 5.3 +# minimum: 1.5 +# maximum: 11.5 +# ``` +# +# #### boolean +# +# Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into +# a boolean. Numbers, arrays, objects, and anything else will not be coerced. +# +# ```coffee +# config: +# someSetting: +# type: 'boolean' +# default: false +# ``` +# +# #### array +# +# Value must be an Array. The types of the values can be specified by a +# subschema in the `items` key. +# +# ```coffee +# config: +# someSetting: +# type: 'array' +# default: [1, 2, 3] +# items: +# type: 'integer' +# minimum: 1.5 +# maximum: 11.5 +# ``` +# +# #### object +# +# Value must be an object. This allows you to nest config options. Sub options +# must be under a `properties key` +# +# ```coffee +# config: +# someSetting: +# type: 'object' +# properties: +# myChildIntOption: +# type: 'integer' +# minimum: 1.5 +# maximum: 11.5 +# ``` +# +# ### Other Supported Keys +# +# #### enum +# +# All types support an `enum` key. The enum key lets you specify all values +# that the config setting can possibly be. `enum` _must_ be an array of values +# of your specified type. +# +# ```coffee +# config: +# someSetting: +# type: 'integer' +# default: 4 +# enum: [2, 4, 6, 8] +# ``` +# +# ```coffee +# atom.config.set('my-package.someSetting', '2') +# atom.config.get('my-package.someSetting') # -> 2 +# +# # will not set values outside of the enum values +# atom.config.set('my-package.someSetting', '3') +# atom.config.get('my-package.someSetting') # -> 2 +# +# # If it cannot be coerced, the value will not be set +# atom.config.set('my-package.someSetting', '4') +# atom.config.get('my-package.someSetting') # -> 4 +# ``` +# +# #### title and description +# +# The settings view will use the `title` and `description` keys to display your +# config setting in a readable way. By default the settings view humanizes your +# config key, so `someSetting` becomes `Some Setting`. In some cases, this is +# confusing for users, and a more descriptive title is useful. +# +# Descriptions will be displayed below the title in the settings view. +# +# ```coffee +# config: +# someSetting: +# title: 'Setting Magnitude' +# description: 'This will affect the blah and the other blah' +# type: 'integer' +# default: 4 +# ``` +# +# __Note__: You should strive to be so clear in your naming of the setting that +# you do not need to specify a title or description! +# +# ## Best practices +# +# * Don't depend on (or write to) configuration keys outside of your keypath. +# module.exports = class Config - Emitter.includeInto(this) + EmitterMixin.includeInto(this) + @schemaEnforcers = {} + + @addSchemaEnforcer: (typeName, enforcerFunction) -> + @schemaEnforcers[typeName] ?= [] + @schemaEnforcers[typeName].push(enforcerFunction) + + @addSchemaEnforcers: (filters) -> + for typeName, functions of filters + for name, enforcerFunction of functions + @addSchemaEnforcer(typeName, enforcerFunction) + + @executeSchemaEnforcers: (keyPath, value, schema) -> + error = null + types = schema.type + types = [types] unless Array.isArray(types) + for type in types + try + enforcerFunctions = @schemaEnforcers[type].concat(@schemaEnforcers['*']) + for enforcer in enforcerFunctions + # At some point in one's life, one must call upon an enforcer. + value = enforcer.call(this, keyPath, value, schema) + error = null + break + catch e + error = e + + throw error if error? + value # Created during initialization, available as `atom.config` constructor: ({@configDirPath, @resourcePath}={}) -> + @emitter = new Emitter + @schema = + type: 'object' + properties: {} @defaultSettings = {} @settings = {} @configFileHasErrors = false @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson']) @configFilePath ?= path.join(@configDirPath, 'config.cson') + ### + Section: Config Subscription + ### + + # Essential: Add a listener for changes to a given key path. This is different + # than {::onDidChange} in that it will immediately call your callback with the + # current value of the config entry. + # + # * `keyPath` {String} name of the key to observe + # * `callback` {Function} to call when the value of the key changes. + # * `value` the new value of the key + # + # Returns a {Disposable} with the following keys on which you can call + # `.dispose()` to unsubscribe. + observe: (keyPath, options={}, callback) -> + if _.isFunction(options) + callback = options + options = {} + else + message = "" + message = "`callNow` was set to false. Use ::onDidChange instead. Note that ::onDidChange calls back with different arguments." if options.callNow == false + deprecate "Config::observe no longer supports options. #{message}" + + callback(_.clone(@get(keyPath))) unless options.callNow == false + @emitter.on 'did-change', (event) -> + callback(event.newValue) if keyPath? and keyPath.indexOf(event?.keyPath) is 0 + + # Essential: Add a listener for changes to a given key path. If `keyPath` is + # not specified, your callback will be called on changes to any key. + # + # * `keyPath` (optional) {String} name of the key to observe + # * `callback` {Function} to call when the value of the key changes. + # * `event` {Object} + # * `newValue` the new value of the key + # * `oldValue` the prior value of the key. + # * `keyPath` the keyPath of the changed key + # + # Returns a {Disposable} with the following keys on which you can call + # `.dispose()` to unsubscribe. + onDidChange: (keyPath, callback) -> + if arguments.length is 1 + callback = keyPath + keyPath = undefined + + @emitter.on 'did-change', (event) -> + callback(event) if not keyPath? or (keyPath? and keyPath.indexOf(event?.keyPath) is 0) + + ### + Section: Managing Settings + ### + + # Essential: Retrieves the setting for the given key. + # + # * `keyPath` The {String} name of the key to retrieve. + # + # Returns the value from Atom's default settings, the user's configuration + # file in the type specified by the configuration schema. + get: (keyPath) -> + value = _.valueForKeyPath(@settings, keyPath) + defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) + + if value? + value = _.deepClone(value) + valueIsObject = _.isObject(value) and not _.isArray(value) + defaultValueIsObject = _.isObject(defaultValue) and not _.isArray(defaultValue) + if valueIsObject and defaultValueIsObject + _.defaults(value, defaultValue) + else + value = _.deepClone(defaultValue) + + value + + # Essential: Sets the value for a configuration setting. + # + # This value is stored in Atom's internal configuration file. + # + # * `keyPath` The {String} name of the key. + # * `value` The value of the setting. Passing `undefined` will revert the + # setting to the default value. + # + # Returns a {Boolean} + # * `true` if the value was set. + # * `false` if the value was not able to be coerced to the type specified in the setting's schema. + set: (keyPath, value) -> + unless value == undefined + try + value = @makeValueConformToSchema(keyPath, value) + catch e + return false + + @setRawValue(keyPath, value) + @save() unless @configFileHasErrors + true + + # Extended: Restore the key path to its default value. + # + # * `keyPath` The {String} name of the key. + # + # Returns the new value. + restoreDefault: (keyPath) -> + @set(keyPath, _.valueForKeyPath(@defaultSettings, keyPath)) + @get(keyPath) + + # Extended: Get the default value of the key path. _Please note_ that in most + # cases calling this is not necessary! {::get} returns the default value when + # a custom value is not specified. + # + # * `keyPath` The {String} name of the key. + # + # Returns the default value. + getDefault: (keyPath) -> + defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) + _.deepClone(defaultValue) + + # Extended: Is the key path value its default value? + # + # * `keyPath` The {String} name of the key. + # + # Returns a {Boolean}, `true` if the current value is the default, `false` + # otherwise. + isDefault: (keyPath) -> + not _.valueForKeyPath(@settings, keyPath)? + + # Extended: Retrieve the schema for a specific key path. The shema will tell + # you what type the keyPath expects, and other metadata about the config + # option. + # + # * `keyPath` The {String} name of the key. + # + # Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`. + # Returns `null` when the keyPath has no schema specified. + getSchema: (keyPath) -> + keys = keyPath.split('.') + schema = @schema + for key in keys + break unless schema? + schema = schema.properties[key] + schema + + # Extended: Returns a new {Object} containing all of settings and defaults. + getSettings: -> + _.deepExtend(@settings, @defaultSettings) + + # Extended: Get the {String} path to the config file being used. + getUserConfigPath: -> + @configFilePath + + ### + Section: Deprecated + ### + + getInt: (keyPath) -> + deprecate '''Config::getInt is no longer necessary. Use ::get instead. + Make sure the config option you are accessing has specified an `integer` + schema. See the schema section of + https://atom.io/docs/api/latest/Config for more info.''' + parseInt(@get(keyPath)) + + getPositiveInt: (keyPath, defaultValue=0) -> + deprecate '''Config::getPositiveInt is no longer necessary. Use ::get instead. + Make sure the config option you are accessing has specified an `integer` + schema with `minimum: 1`. See the schema section of + https://atom.io/docs/api/latest/Config for more info.''' + Math.max(@getInt(keyPath), 0) or defaultValue + + toggle: (keyPath) -> + deprecate 'Config::toggle is no longer supported. Please remove from your code.' + @set(keyPath, !@get(keyPath)) + + unobserve: (keyPath) -> + deprecate 'Config::unobserve no longer does anything. Call `.dispose()` on the object returned by Config::observe instead.' + + ### + Section: Private + ### + + pushAtKeyPath: (keyPath, value) -> + arrayValue = @get(keyPath) ? [] + result = arrayValue.push(value) + @set(keyPath, arrayValue) + result + + unshiftAtKeyPath: (keyPath, value) -> + arrayValue = @get(keyPath) ? [] + result = arrayValue.unshift(value) + @set(keyPath, arrayValue) + result + + removeAtKeyPath: (keyPath, value) -> + arrayValue = @get(keyPath) ? [] + result = _.remove(arrayValue, value) + @set(keyPath, arrayValue) + result + initializeConfigDirectory: (done) -> return if fs.existsSync(@configDirPath) @@ -62,9 +533,8 @@ class Config try userConfig = CSON.readFileSync(@configFilePath) - _.extend(@settings, userConfig) + @setAll(userConfig) @configFileHasErrors = false - @emit 'updated' catch error @configFileHasErrors = true console.error "Failed to load user config '#{@configFilePath}'", error.message @@ -82,194 +552,196 @@ class Config @watchSubscription?.close() @watchSubscription = null - setDefaults: (keyPath, defaults) -> - keys = keyPath.split('.') - hash = @defaultSettings - for key in keys - hash[key] ?= {} - hash = hash[key] - - _.extend hash, defaults - @emit 'updated' - - # Extended: Get the {String} path to the config file being used. - getUserConfigPath: -> - @configFilePath - - # Extended: Returns a new {Object} containing all of settings and defaults. - getSettings: -> - _.deepExtend(@settings, @defaultSettings) - - # Essential: Retrieves the setting for the given key. - # - # * `keyPath` The {String} name of the key to retrieve. - # - # Returns the value from Atom's default settings, the user's configuration - # file, or `null` if the key doesn't exist in either. - get: (keyPath) -> - value = _.valueForKeyPath(@settings, keyPath) - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) - - if value? - value = _.deepClone(value) - valueIsObject = _.isObject(value) and not _.isArray(value) - defaultValueIsObject = _.isObject(defaultValue) and not _.isArray(defaultValue) - if valueIsObject and defaultValueIsObject - _.defaults(value, defaultValue) - else - value = _.deepClone(defaultValue) - - value - - # Extended: Retrieves the setting for the given key as an integer. - # - # * `keyPath` The {String} name of the key to retrieve - # - # Returns the value from Atom's default settings, the user's configuration - # file, or `NaN` if the key doesn't exist in either. - getInt: (keyPath) -> - parseInt(@get(keyPath)) - - # Extended: Retrieves the setting for the given key as a positive integer. - # - # * `keyPath` The {String} name of the key to retrieve - # * `defaultValue` The integer {Number} to fall back to if the value isn't - # positive, defaults to 0. - # - # Returns the value from Atom's default settings, the user's configuration - # file, or `defaultValue` if the key value isn't greater than zero. - getPositiveInt: (keyPath, defaultValue=0) -> - Math.max(@getInt(keyPath), 0) or defaultValue - - # Essential: Sets the value for a configuration setting. - # - # This value is stored in Atom's internal configuration file. - # - # * `keyPath` The {String} name of the key. - # * `value` The value of the setting. - # - # Returns the `value`. - set: (keyPath, value) -> - if @get(keyPath) isnt value - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) - value = undefined if _.isEqual(defaultValue, value) - _.setValueForKeyPath(@settings, keyPath, value) - @update() - value - - # Extended: Toggle the value at the key path. - # - # The new value will be `true` if the value is currently falsy and will be - # `false` if the value is currently truthy. - # - # * `keyPath` The {String} name of the key. - # - # Returns the new value. - toggle: (keyPath) -> - @set(keyPath, !@get(keyPath)) - - # Extended: Restore the key path to its default value. - # - # * `keyPath` The {String} name of the key. - # - # Returns the new value. - restoreDefault: (keyPath) -> - @set(keyPath, _.valueForKeyPath(@defaultSettings, keyPath)) - - # Extended: Get the default value of the key path. - # - # * `keyPath` The {String} name of the key. - # - # Returns the default value. - getDefault: (keyPath) -> - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) - _.deepClone(defaultValue) - - # Extended: Is the key path value its default value? - # - # * `keyPath` The {String} name of the key. - # - # Returns a {Boolean}, `true` if the current value is the default, `false` - # otherwise. - isDefault: (keyPath) -> - not _.valueForKeyPath(@settings, keyPath)? - - # Extended: Push the value to the array at the key path. - # - # * `keyPath` The {String} key path. - # * `value` The value to push to the array. - # - # Returns the new array length {Number} of the setting. - pushAtKeyPath: (keyPath, value) -> - arrayValue = @get(keyPath) ? [] - result = arrayValue.push(value) - @set(keyPath, arrayValue) - result - - # Extended: Add the value to the beginning of the array at the key path. - # - # * `keyPath` The {String} key path. - # * `value` The value to shift onto the array. - # - # Returns the new array length {Number} of the setting. - unshiftAtKeyPath: (keyPath, value) -> - arrayValue = @get(keyPath) ? [] - result = arrayValue.unshift(value) - @set(keyPath, arrayValue) - result - - # Public: Remove the value from the array at the key path. - # - # * `keyPath` The {String} key path. - # * `value` The value to remove from the array. - # - # Returns the new array value of the setting. - removeAtKeyPath: (keyPath, value) -> - arrayValue = @get(keyPath) ? [] - result = _.remove(arrayValue, value) - @set(keyPath, arrayValue) - result - - # Essential: Add a listener for changes to a given key path. - # - # * `keyPath` The {String} name of the key to observe - # * `options` An optional {Object} containing the `callNow` key. - # * `callback` The {Function} to call when the value of the key changes. - # The first argument will be the new value of the key and the - #   second argument will be an {Object} with a `previous` property - # that is the prior value of the key. - # - # Returns an {Object} with the following keys: - # * `off` A {Function} that unobserves the `keyPath` when called. - observe: (keyPath, options={}, callback) -> - if _.isFunction(options) - callback = options - options = {} - - value = @get(keyPath) - previousValue = _.clone(value) - updateCallback = => - value = @get(keyPath) - unless _.isEqual(value, previousValue) - previous = previousValue - previousValue = _.clone(value) - callback(value, {previous}) - - eventName = "updated.#{keyPath.replace(/\./, '-')}" - subscription = @on eventName, updateCallback - callback(value) if options.callNow ? true - subscription - - # Unobserve all callbacks on a given key. - # - # * `keyPath` The {String} name of the key to unobserve. - unobserve: (keyPath) -> - @off("updated.#{keyPath.replace(/\./, '-')}") - - update: -> - return if @configFileHasErrors - @save() - @emit 'updated' - save: -> CSON.writeFileSync(@configFilePath, @settings) + + setRawValue: (keyPath, value) -> + defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) + value = undefined if _.isEqual(defaultValue, value) + + oldValue = _.clone(@get(keyPath)) + _.setValueForKeyPath(@settings, keyPath, value) + newValue = @get(keyPath) + @emitter.emit 'did-change', {oldValue, newValue, keyPath} unless _.isEqual(newValue, oldValue) + + setRawDefault: (keyPath, value) -> + oldValue = _.clone(@get(keyPath)) + _.setValueForKeyPath(@defaultSettings, keyPath, value) + newValue = @get(keyPath) + @emitter.emit 'did-change', {oldValue, newValue, keyPath} unless _.isEqual(newValue, oldValue) + + setRecursive: (keyPath, value) -> + if isPlainObject(value) + keys = if keyPath? then keyPath.split('.') else [] + for key, childValue of value + continue unless value.hasOwnProperty(key) + @setRecursive(keys.concat([key]).join('.'), childValue) + else + try + value = @makeValueConformToSchema(keyPath, value) + @setRawValue(keyPath, value) + catch e + console.warn("'#{keyPath}' could not be set. Attempted value: #{JSON.stringify(value)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") + + setAll: (newSettings) -> + unless isPlainObject(newSettings) + @settings = {} + @emitter.emit 'did-change' + return + + unsetUnspecifiedValues = (keyPath, value) => + if isPlainObject(value) + keys = if keyPath? then keyPath.split('.') else [] + for key, childValue of value + continue unless value.hasOwnProperty(key) + unsetUnspecifiedValues(keys.concat([key]).join('.'), childValue) + else + @setRawValue(keyPath, undefined) unless _.valueForKeyPath(newSettings, keyPath)? + return + + @setRecursive(null, newSettings) + unsetUnspecifiedValues(null, @settings) + + setDefaults: (keyPath, defaults) -> + if defaults? and isPlainObject(defaults) + keys = if keyPath? then keyPath.split('.') else [] + for key, childValue of defaults + continue unless defaults.hasOwnProperty(key) + @setDefaults(keys.concat([key]).join('.'), childValue) + else + try + defaults = @makeValueConformToSchema(keyPath, defaults) + @setRawDefault(keyPath, defaults) + catch e + console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") + + setSchema: (keyPath, schema) -> + unless isPlainObject(schema) + throw new Error("Error loading schema for #{keyPath}: schemas can only be objects!") + + unless typeof schema.type? + throw new Error("Error loading schema for #{keyPath}: schema objects must have a type attribute") + + rootSchema = @schema + if keyPath + for key in keyPath.split('.') + rootSchema.type = 'object' + rootSchema.properties ?= {} + properties = rootSchema.properties + properties[key] ?= {} + rootSchema = properties[key] + + _.extend rootSchema, schema + @setDefaults(keyPath, @extractDefaultsFromSchema(schema)) + + extractDefaultsFromSchema: (schema) -> + if schema.default? + schema.default + else if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties) + defaults = {} + properties = schema.properties or {} + defaults[key] = @extractDefaultsFromSchema(value) for key, value of properties + defaults + + makeValueConformToSchema: (keyPath, value) -> + value = @constructor.executeSchemaEnforcers(keyPath, value, schema) if schema = @getSchema(keyPath) + value + +# Base schema enforcers. These will coerce raw input into the specified type, +# and will throw an error when the value cannot be coerced. Throwing the error +# will indicate that the value should not be set. +# +# Enforcers are run from most specific to least. For a schema with type +# `integer`, all the enforcers for the `integer` type will be run first, in +# order of specification. Then the `*` enforcers will be run, in order of +# specification. +Config.addSchemaEnforcers + 'integer': + coerce: (keyPath, value, schema) -> + value = parseInt(value) + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into an int") if isNaN(value) or not isFinite(value) + value + + 'number': + coerce: (keyPath, value, schema) -> + value = parseFloat(value) + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a number") if isNaN(value) or not isFinite(value) + value + + 'boolean': + coerce: (keyPath, value, schema) -> + switch typeof value + when 'string' + if value.toLowerCase() is 'true' + true + else if value.toLowerCase() is 'false' + false + else + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'") + when 'boolean' + value + else + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'") + + 'string': + validate: (keyPath, value, schema) -> + unless typeof value is 'string' + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a string") + value + + 'null': + # null sort of isnt supported. It will just unset in this case + coerce: (keyPath, value, schema) -> + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value == null + value + + 'object': + coerce: (keyPath, value, schema) -> + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value) + return value unless schema.properties? + + newValue = {} + for prop, childSchema of schema.properties + continue unless value.hasOwnProperty(prop) + try + newValue[prop] = @executeSchemaEnforcers("#{keyPath}.#{prop}", value[prop], childSchema) + catch error + console.warn "Error setting item in object: #{error.message}" + newValue + + 'array': + coerce: (keyPath, value, schema) -> + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an array") unless Array.isArray(value) + itemSchema = schema.items + if itemSchema? + newValue = [] + for item in value + try + newValue.push @executeSchemaEnforcers(keyPath, item, itemSchema) + catch error + console.warn "Error setting item in array: #{error.message}" + newValue + else + value + + '*': + coerceMinimumAndMaximum: (keyPath, value, schema) -> + return value unless typeof value is 'number' + if schema.minimum? and typeof schema.minimum is 'number' + value = Math.max(value, schema.minimum) + if schema.maximum? and typeof schema.maximum is 'number' + value = Math.min(value, schema.maximum) + value + + validateEnum: (keyPath, value, schema) -> + possibleValues = schema.enum + return value unless possibleValues? and Array.isArray(possibleValues) and possibleValues.length + + for possibleValue in possibleValues + # Using `isEqual` for possibility of placing enums on array and object schemas + return value if _.isEqual(possibleValue, value) + + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} is not one of #{JSON.stringify(possibleValues)}") + +isPlainObject = (value) -> + _.isObject(value) and not _.isArray(value) and not _.isFunction(value) and not _.isString(value) diff --git a/src/context-menu-manager.coffee b/src/context-menu-manager.coffee index a55daabf0..5514cbb65 100644 --- a/src/context-menu-manager.coffee +++ b/src/context-menu-manager.coffee @@ -4,6 +4,12 @@ remote = require 'remote' path = require 'path' CSON = require 'season' fs = require 'fs-plus' +{specificity} = require 'clear-cut' +{Disposable} = require 'event-kit' +Grim = require 'grim' +MenuHelpers = require './menu-helpers' + +SpecificityCache = {} # Extended: Provides a registry for commands that you'd like to appear in the # context menu. @@ -13,16 +19,8 @@ fs = require 'fs-plus' module.exports = class ContextMenuManager constructor: ({@resourcePath, @devMode}) -> - @definitions = {} - @devModeDefinitions = {} - @activeElement = null - - @devModeDefinitions['.workspace'] = [ - label: 'Inspect Element' - command: 'application:inspect' - executeAtBuild: (e) -> - @commandOptions = x: e.pageX, y: e.pageY - ] + @definitions = {'.overlayer': []} # TODO: Remove once color picker package stops touching private data + @clear() atom.keymaps.onDidLoadBundledKeymaps => @loadPlatformItems() @@ -30,102 +28,143 @@ class ContextMenuManager menusDirPath = path.join(@resourcePath, 'menus') platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) map = CSON.readFileSync(platformMenuPath) - atom.contextMenu.add(platformMenuPath, map['context-menu']) + atom.contextMenu.add(map['context-menu']) - # Public: Creates menu definitions from the object specified by the menu - # JSON API. + # Public: Add context menu items scoped by CSS selectors. # - # * `name` The path of the file that contains the menu definitions. - # * `object` The 'context-menu' object specified in the menu JSON API. - # * `options` An optional {Object} with the following keys: - # * `devMode` Determines whether the entries should only be shown when - # the window is in dev mode. - add: (name, object, {devMode}={}) -> - for selector, items of object - for label, commandOrSubmenu of items + # ## Examples + # + # To add a context menu, pass a selector matching the elements to which you + # want the menu to apply as the top level key, followed by a menu descriptor. + # The invocation below adds a global 'Help' context menu item and a 'History' + # submenu on the editor supporting undo/redo. This is just for example + # purposes and not the way the menu is actually configured in Atom by default. + # + # ```coffee + # atom.contextMenu.add { + # '.workspace': [{label: 'Help', command: 'application:open-documentation'}] + # '.editor': [{ + # label: 'History', + # submenu: [ + # {label: 'Undo': command:'core:undo'} + # {label: 'Redo': command:'core:redo'} + # ] + # }] + # } + # ``` + # + # ## Arguments + # + # * `itemsBySelector` An {Object} whose keys are CSS selectors and whose + # values are {Array}s of item {Object}s containing the following keys: + # * `label` (Optional) A {String} containing the menu item's label. + # * `command` (Optional) A {String} containing the command to invoke on the + # target of the right click that invoked the context menu. + # * `submenu` (Optional) An {Array} of additional items. + # * `type` (Optional) If you want to create a separator, provide an item + # with `type: 'separator'` and no other keys. + # * `created` (Optional) A {Function} that is called on the item each time a + # context menu is created via a right click. You can assign properties to + # `this` to dynamically compute the command, label, etc. This method is + # actually called on a clone of the original item template to prevent state + # from leaking across context menu deployments. Called with the following + # argument: + # * `event` The click event that deployed the context menu. + # * `shouldDisplay` (Optional) A {Function} that is called to determine + # whether to display this item on a given context menu deployment. Called + # with the following argument: + # * `event` The click event that deployed the context menu. + add: (itemsBySelector) -> + # Detect deprecated file path as first argument + unless typeof itemsBySelector is 'object' + Grim.deprecate("ContextMenuManage::add has changed to take a single object as its argument. Please consult the documentation.") + itemsBySelector = arguments[1] + devMode = arguments[2]?.devMode + + # Detect deprecated format for items object + for key, value of itemsBySelector + unless _.isArray(value) + Grim.deprecate("The format for declaring context menu items has changed. Please consult the documentation.") + itemsBySelector = @convertLegacyItems(itemsBySelector, devMode) + + addedItemSets = [] + + for selector, items of itemsBySelector + itemSet = new ContextMenuItemSet(selector, items) + addedItemSets.push(itemSet) + @itemSets.push(itemSet) + + new Disposable => + for itemSet in addedItemSets + @itemSets.splice(@itemSets.indexOf(itemSet), 1) + + templateForElement: (target) -> + @templateForEvent({target}) + + templateForEvent: (event) -> + template = [] + currentTarget = event.target + + while currentTarget? + currentTargetItems = [] + matchingItemSets = + @itemSets.filter (itemSet) -> currentTarget.webkitMatchesSelector(itemSet.selector) + + for itemSet in matchingItemSets + for item in itemSet.items + continue if item.devMode and not @devMode + item = Object.create(item) + if typeof item.shouldDisplay is 'function' + continue unless item.shouldDisplay(event) + item.created?(event) + MenuHelpers.merge(currentTargetItems, MenuHelpers.cloneMenuItem(item), itemSet.specificity) + + for item in currentTargetItems + MenuHelpers.merge(template, item, false) + + currentTarget = currentTarget.parentElement + + template + + convertLegacyItems: (legacyItems, devMode) -> + itemsBySelector = {} + + for selector, commandsByLabel of legacyItems + itemsBySelector[selector] = items = [] + + for label, commandOrSubmenu of commandsByLabel if typeof commandOrSubmenu is 'object' - submenu = [] - for submenuLabel, command of commandOrSubmenu - submenu.push(@buildMenuItem(submenuLabel, command)) - @addBySelector(selector, {label: label, submenu: submenu}, {devMode}) + items.push({label, submenu: @convertLegacyItems(commandOrSubmenu, devMode), devMode}) + else if commandOrSubmenu is '-' + items.push({type: 'separator'}) else - menuItem = @buildMenuItem(label, commandOrSubmenu) - @addBySelector(selector, menuItem, {devMode}) + items.push({label, command: commandOrSubmenu, devMode}) - undefined - - buildMenuItem: (label, command) -> - if command is '-' - {type: 'separator'} - else - {label, command} - - # Registers a command to be displayed when the relevant item is right - # clicked. - # - # * `selector` The css selector for the active element which should include - # the given command in its context menu. - # * `definition` The object containing keys which match the menu template API. - # * `options` An optional {Object} with the following keys: - # * `devMode` Indicates whether this command should only appear while the - # editor is in dev mode. - addBySelector: (selector, definition, {devMode}={}) -> - definitions = if devMode then @devModeDefinitions else @definitions - if not _.findWhere(definitions[selector], definition) or _.isEqual(definition, {type: 'separator'}) - (definitions[selector] ?= []).push(definition) - - # Returns definitions which match the element and devMode. - definitionsForElement: (element, {devMode}={}) -> - definitions = if devMode then @devModeDefinitions else @definitions - matchedDefinitions = [] - for selector, items of definitions when element.webkitMatchesSelector(selector) - matchedDefinitions.push(_.clone(item)) for item in items - - matchedDefinitions - - # Used to generate the context menu for a specific element and it's - # parents. - # - # The menu items are sorted such that menu items that match closest to the - # active element are listed first. The further down the list you go, the higher - # up the ancestor hierarchy they match. - # - # * `element` The DOM element to generate the menu template for. - menuTemplateForMostSpecificElement: (element, {devMode}={}) -> - menuTemplate = @definitionsForElement(element, {devMode}) - if element.parentElement - menuTemplate.concat(@menuTemplateForMostSpecificElement(element.parentElement, {devMode})) - else - menuTemplate - - # Returns a menu template for both normal entries as well as - # development mode entries. - combinedMenuTemplateForElement: (element) -> - normalItems = @menuTemplateForMostSpecificElement(element) - devItems = if @devMode then @menuTemplateForMostSpecificElement(element, devMode: true) else [] - - menuTemplate = normalItems - menuTemplate.push({ type: 'separator' }) if normalItems.length > 0 and devItems.length > 0 - menuTemplate.concat(devItems) - - # Executes `executeAtBuild` if defined for each menu item with - # the provided event and then removes the `executeAtBuild` property from - # the menu item. - # - # This is useful for commands that need to provide data about the event - # to the command. - executeBuildHandlers: (event, menuTemplate) -> - for template in menuTemplate - template?.executeAtBuild?.call(template, event) - delete template.executeAtBuild + itemsBySelector # Public: Request a context menu to be displayed. # # * `event` A DOM event. showForEvent: (event) -> @activeElement = event.target - menuTemplate = @combinedMenuTemplateForElement(event.target) + menuTemplate = @templateForEvent(event) + return unless menuTemplate?.length > 0 - @executeBuildHandlers(event, menuTemplate) remote.getCurrentWindow().emit('context-menu', menuTemplate) - undefined + return + + clear: -> + @activeElement = null + @itemSets = [] + @add '.workspace': [{ + label: 'Inspect Element' + command: 'application:inspect' + devMode: true + created: (event) -> + {pageX, pageY} = event + @commandDetail = {x: pageX, y: pageY} + }] + +class ContextMenuItemSet + constructor: (@selector, @items) -> + @specificity = (SpecificityCache[@selector] ?= specificity(@selector)) diff --git a/src/deserializer-manager.coffee b/src/deserializer-manager.coffee index 9abefc4c6..50becb31a 100644 --- a/src/deserializer-manager.coffee +++ b/src/deserializer-manager.coffee @@ -1,3 +1,6 @@ +{Disposable} = require 'event-kit' +Grim = require 'grim' + # Extended: Manages the deserializers used for serialized state # # An instance of this class is always available as the `atom.deserializers` @@ -24,14 +27,17 @@ class DeserializerManager # Public: Register the given class(es) as deserializers. # - # * `classes` One or more classes to register. - add: (classes...) -> - @deserializers[klass.name] = klass for klass in classes + # * `deserializers` One or more deserializers to register. A deserializer can + # be any object with a `.name` property and a `.deserialize()` method. A + # common approach is to register a *constructor* as the deserializer for its + # instances by adding a `.deserialize()` class method. + add: (deserializers...) -> + @deserializers[deserializer.name] = deserializer for deserializer in deserializers + new Disposable => + delete @deserializers[deserializer.name] for deserializer in deserializers - # Public: Remove the given class(es) as deserializers. - # - # * `classes` One or more classes to remove. remove: (classes...) -> + Grim.deprecate("Call .dispose() on the Disposable return from ::add instead") delete @deserializers[name] for {name} in classes # Public: Deserialize the state and params. diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 60430692b..fc465e5d7 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -59,10 +59,10 @@ class DisplayBuffer extends Model @subscribe @buffer.onDidUpdateMarkers @handleBufferMarkersUpdated @subscribe @buffer.onDidCreateMarker @handleBufferMarkerCreated - @subscribe atom.config.observe 'editor.preferredLineLength', callNow: false, => + @subscribe atom.config.onDidChange 'editor.preferredLineLength', => @updateWrappedScreenLines() if @isSoftWrapped() and atom.config.get('editor.softWrapAtPreferredLineLength') - @subscribe atom.config.observe 'editor.softWrapAtPreferredLineLength', callNow: false, => + @subscribe atom.config.onDidChange 'editor.softWrapAtPreferredLineLength', => @updateWrappedScreenLines() if @isSoftWrapped() @updateAllScreenLines() @@ -439,7 +439,7 @@ class DisplayBuffer extends Model getSoftWrapColumn: -> if atom.config.get('editor.softWrapAtPreferredLineLength') - Math.min(@getEditorWidthInChars(), atom.config.getPositiveInt('editor.preferredLineLength', @getEditorWidthInChars())) + Math.min(@getEditorWidthInChars(), atom.config.get('editor.preferredLineLength')) else @getEditorWidthInChars() diff --git a/src/menu-helpers.coffee b/src/menu-helpers.coffee new file mode 100644 index 000000000..c47e9ae8b --- /dev/null +++ b/src/menu-helpers.coffee @@ -0,0 +1,52 @@ +_ = require 'underscore-plus' + +ItemSpecificities = new WeakMap + +merge = (menu, item, itemSpecificity=Infinity) -> + ItemSpecificities.set(item, itemSpecificity) if itemSpecificity + matchingItemIndex = findMatchingItemIndex(menu, item) + matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1 + + if matchingItem? + if item.submenu? + merge(matchingItem.submenu, submenuItem, itemSpecificity) for submenuItem in item.submenu + else if itemSpecificity + unless itemSpecificity < ItemSpecificities.get(matchingItem) + menu[matchingItemIndex] = item + else unless item.type is 'separator' and _.last(menu)?.type is 'separator' + menu.push(item) + +unmerge = (menu, item) -> + matchingItemIndex = findMatchingItemIndex(menu, item) + matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1 + + if matchingItem? + if item.submenu? + unmerge(matchingItem.submenu, submenuItem) for submenuItem in item.submenu + + unless matchingItem.submenu?.length > 0 + menu.splice(matchingItemIndex, 1) + +findMatchingItemIndex = (menu, {type, label, submenu}) -> + return -1 if type is 'separator' + for item, index in menu + if normalizeLabel(item.label) is normalizeLabel(label) and item.submenu? is submenu? + return index + -1 + +normalizeLabel = (label) -> + return undefined unless label? + + if process.platform is 'darwin' + label + else + label.replace(/\&/g, '') + + +cloneMenuItem = (item) -> + item = _.pick(item, 'type', 'label', 'command', 'submenu', 'commandDetail') + if item.submenu? + item.submenu = item.submenu.map (submenuItem) -> cloneMenuItem(submenuItem) + item + +module.exports = {merge, unmerge, normalizeLabel, cloneMenuItem} diff --git a/src/menu-manager.coffee b/src/menu-manager.coffee index 89462ec7a..d7190e80c 100644 --- a/src/menu-manager.coffee +++ b/src/menu-manager.coffee @@ -4,6 +4,9 @@ _ = require 'underscore-plus' ipc = require 'ipc' CSON = require 'season' fs = require 'fs-plus' +{Disposable} = require 'event-kit' + +MenuHelpers = require './menu-helpers' # Extended: Provides a registry for menu items that you'd like to appear in the # application menu. @@ -34,10 +37,18 @@ class MenuManager # * `submenu` An optional {Array} of sub menu items. # * `command` An optional {String} command to trigger when the item is # clicked. + # + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # added menu items. add: (items) -> + items = _.deepClone(items) @merge(@template, item) for item in items @update() - undefined + new Disposable => @remove(items) + + remove: (items) -> + @unmerge(@template, item) for item in items + @update() # Should the binding for the given selector be included in the menu # commands. @@ -95,12 +106,10 @@ class MenuManager # Merges an item in a submenu aware way such that new items are always # appended to the bottom of existing menus where possible. merge: (menu, item) -> - item = _.deepClone(item) + MenuHelpers.merge(menu, item) - if item.submenu? and match = _.find(menu, ({label, submenu}) => submenu? and label and @normalizeLabel(label) is @normalizeLabel(item.label)) - @merge(match.submenu, i) for i in item.submenu - else - menu.push(item) unless _.find(menu, ({label}) => label and @normalizeLabel(label) is @normalizeLabel(item.label)) + unmerge: (menu, item) -> + MenuHelpers.unmerge(menu, item) # OSX can't handle displaying accelerators for multiple keystrokes. # If they are sent across, it will stop processing accelerators for the rest @@ -119,25 +128,17 @@ class MenuManager keystrokesByCommand = @filterMultipleKeystroke(keystrokesByCommand) ipc.send 'update-application-menu', template, keystrokesByCommand - normalizeLabel: (label) -> - return undefined unless label? - - if process.platform is 'darwin' - label - else - label.replace(/\&/g, '') - # Get an {Array} of {String} classes for the given element. classesForElement: (element) -> element?.classList.toString().split(' ') ? [] sortPackagesMenu: -> - packagesMenu = @template.find ({label}) => @normalizeLabel(label) is 'Packages' + packagesMenu = @template.find ({label}) => MenuHelpers.normalizeLabel(label) is 'Packages' return unless packagesMenu?.submenu? packagesMenu.submenu.sort (item1, item2) => if item1.label and item2.label - @normalizeLabel(item1.label).localeCompare(@normalizeLabel(item2.label)) + MenuHelpers.normalizeLabel(item1.label).localeCompare(MenuHelpers.normalizeLabel(item2.label)) else 0 @update() diff --git a/src/package-manager.coffee b/src/package-manager.coffee index 838eda68e..58d69f9dc 100644 --- a/src/package-manager.coffee +++ b/src/package-manager.coffee @@ -260,9 +260,9 @@ class PackageManager @disabledPackagesSubscription = null observeDisabledPackages: -> - @disabledPackagesSubscription ?= atom.config.observe 'core.disabledPackages', callNow: false, (disabledPackages, {previous}) => - packagesToEnable = _.difference(previous, disabledPackages) - packagesToDisable = _.difference(disabledPackages, previous) + @disabledPackagesSubscription ?= atom.config.onDidChange 'core.disabledPackages', ({newValue, oldValue}) => + packagesToEnable = _.difference(oldValue, newValue) + packagesToDisable = _.difference(newValue, oldValue) @deactivatePackage(packageName) for packageName in packagesToDisable when @getActivePackage(packageName) @activatePackage(packageName) for packageName in packagesToEnable diff --git a/src/package.coffee b/src/package.coffee index 30cd1ad2f..143629d38 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -143,7 +143,13 @@ class Package @requireMainModule() if @mainModule? - atom.config.setDefaults(@name, @mainModule.configDefaults) + if @mainModule.config? and typeof @mainModule.config is 'object' + atom.config.setSchema @name, {type: 'object', properties: @mainModule.config} + else if @mainModule.configDefaults? and typeof @mainModule.configDefaults is 'object' + deprecate """Use a config schema instead. See the configuration section + of https://atom.io/docs/latest/creating-a-package and + https://atom.io/docs/api/latest/Config for more details""" + atom.config.setDefaults(@name, @mainModule.configDefaults) @mainModule.activateConfig?() @configActivated = true @@ -157,7 +163,7 @@ class Package activateResources: -> atom.keymaps.add(keymapPath, map) for [keymapPath, map] in @keymaps - atom.contextMenu.add(menuPath, map['context-menu']) for [menuPath, map] in @menus + atom.contextMenu.add(map['context-menu']) for [menuPath, map] in @menus atom.menu.add(map.menu) for [menuPath, map] in @menus when map.menu unless @grammarsActivated diff --git a/src/project.coffee b/src/project.coffee index 805afa49b..7493f684a 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -6,10 +6,12 @@ fs = require 'fs-plus' Q = require 'q' {deprecate} = require 'grim' {Model} = require 'theorist' -{Emitter, Subscriber} = require 'emissary' +{Subscriber} = require 'emissary' +{Emitter} = require 'event-kit' Serializable = require 'serializable' TextBuffer = require 'text-buffer' {Directory} = require 'pathwatcher' +Grim = require 'grim' TextEditor = require './text-editor' Task = require './task' @@ -33,14 +35,17 @@ class Project extends Model Section: Construction and Destruction ### - constructor: ({path, @buffers}={}) -> + constructor: ({path, paths, @buffers}={}) -> + @emitter = new Emitter @buffers ?= [] for buffer in @buffers do (buffer) => buffer.onDidDestroy => @removeBuffer(buffer) - @setPath(path) + Grim.deprecate("Pass 'paths' array instead of 'path' to project constructor") if path? + paths ?= _.compact([path]) + @setPaths(paths) destroyed: -> buffer.destroy() for buffer in @getBuffers() @@ -66,25 +71,47 @@ class Project extends Model params.buffers = params.buffers.map (bufferState) -> atom.deserializers.deserialize(bufferState) params + + ### + Section: Event Subscription + ### + + onDidChangePaths: (callback) -> + @emitter.on 'did-change-paths', callback + + on: (eventName) -> + if eventName is 'path-changed' + Grim.deprecate("Use Project::onDidChangePaths instead") + super + ### Section: Accessing the git repository ### - # Public: Returns the {GitRepository} if available. - getRepo: -> @repo + # Public: Get an {Array} of {GitRepository}s associated with the project's + # directories. + getRepositories: -> _.compact([@repo]) + getRepo: -> + Grim.deprecate("Use ::getRepositories instead") + @repo ### Section: Managing Paths ### - # Public: Returns the project's {String} fullpath. + + # Public: Get an {Array} of {String}s containing the paths of the project's + # directories. + getPaths: -> _.compact([@rootDirectory?.path]) getPath: -> + Grim.deprecate("Use ::getPaths instead") @rootDirectory?.path - # Public: Sets the project's fullpath. + # Public: Set the paths of the project's directories. # - # * `projectPath` {String} path - setPath: (projectPath) -> + # * `projectPaths` {Array} of {String} paths. + setPaths: (projectPaths) -> + [projectPath] = projectPaths projectPath = path.normalize(projectPath) if projectPath @path = projectPath @rootDirectory?.off() @@ -100,9 +127,16 @@ class Project extends Model @rootDirectory = null @emit "path-changed" + @emitter.emit 'did-change-paths', projectPaths + setPath: (path) -> + Grim.deprecate("Use ::setPaths instead") + @setPaths([path]) - # Public: Returns the root {Directory} object for this project. + # Public: Get an {Array} of {Directory}s associated with this project. + getDirectories: -> + [@rootDirectory] getRootDirectory: -> + Grim.deprecate("Use ::getDirectories instead") @rootDirectory # Public: Given a uri, this resolves it relative to the project directory. If @@ -120,7 +154,7 @@ class Project extends Model else if fs.isAbsolute(uri) path.normalize(fs.absolute(uri)) - else if projectPath = @getPath() + else if projectPath = @getPaths()[0] path.normalize(fs.absolute(path.join(projectPath, uri))) else undefined @@ -345,12 +379,12 @@ class Project extends Model # Deprecated: delegate registerOpener: (opener) -> - deprecate("Use Workspace::registerOpener instead") + deprecate("Use Workspace::addOpener instead") atom.workspace.registerOpener(opener) # Deprecated: delegate unregisterOpener: (opener) -> - deprecate("Use Workspace::unregisterOpener instead") + deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") atom.workspace.unregisterOpener(opener) # Deprecated: delegate diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 8fb98662e..5b2dd61d4 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -500,22 +500,22 @@ TextEditorComponent = React.createClass 'editor:fold-at-indent-level-9': -> editor.foldAllAtIndentLevel(8) 'editor:toggle-line-comments': -> editor.toggleLineCommentsInSelection() 'editor:log-cursor-scope': -> editor.logCursorScope() - 'editor:checkout-head-revision': -> atom.project.getRepo()?.checkoutHeadForEditor(editor) + 'editor:checkout-head-revision': -> atom.project.getRepositories()[0]()?.checkoutHeadForEditor(editor) 'editor:copy-path': -> editor.copyPathToClipboard() 'editor:move-line-up': -> editor.moveLineUp() 'editor:move-line-down': -> editor.moveLineDown() 'editor:duplicate-lines': -> editor.duplicateLines() 'editor:join-lines': -> editor.joinLines() - 'editor:toggle-indent-guide': -> atom.config.toggle('editor.showIndentGuide') - 'editor:toggle-line-numbers': -> atom.config.toggle('editor.showLineNumbers') + 'editor:toggle-indent-guide': -> atom.config.set('editor.showIndentGuide', not atom.config.get('editor.showIndentGuide')) + 'editor:toggle-line-numbers': -> atom.config.set('editor.showLineNumbers', not atom.config.get('editor.showLineNumbers')) 'editor:scroll-to-cursor': -> editor.scrollToCursorPosition() 'benchmark:scroll': @runScrollBenchmark addCommandListeners: (listenersByCommandName) -> {parentView} = @props - addListener = (command, listener) -> - parentView.command command, (event) -> + addListener = (command, listener) => + @subscribe parentView.command command, (event) -> event.stopPropagation() listener(event) diff --git a/src/text-editor-view.coffee b/src/text-editor-view.coffee index 48ed5b4c1..b84a8adcf 100644 --- a/src/text-editor-view.coffee +++ b/src/text-editor-view.coffee @@ -37,31 +37,6 @@ TextEditorComponent = require './text-editor-component' # ``` module.exports = class TextEditorView extends View - @configDefaults: - fontFamily: '' - fontSize: 16 - lineHeight: 1.3 - showInvisibles: false - showIndentGuide: false - showLineNumbers: true - autoIndent: true - normalizeIndentOnPaste: true - nonWordCharacters: "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-" - preferredLineLength: 80 - tabLength: 2 - softWrap: false - softTabs: true - softWrapAtPreferredLineLength: false - scrollSensitivity: 40 - useHardwareAcceleration: true - confirmCheckoutHeadRevision: true - invisibles: - eol: '\u00ac' - space: '\u00b7' - tab: '\u00bb' - cr: '\u00a4' - scrollPastEnd: false - @content: (params) -> attributes = params.attributes ? {} attributes.class = 'editor react editor-colors' diff --git a/src/text-editor.coffee b/src/text-editor.coffee index b941f4af0..2bd07102a 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -113,8 +113,8 @@ class TextEditor extends Model @emit 'scroll-left-changed', scrollLeft @emitter.emit 'did-change-scroll-left', scrollLeft - @subscribe atom.config.observe 'editor.showInvisibles', callNow: false, (show) => @updateInvisibles() - @subscribe atom.config.observe 'editor.invisibles', callNow: false, => @updateInvisibles() + @subscribe atom.config.onDidChange 'editor.showInvisibles', => @updateInvisibles() + @subscribe atom.config.onDidChange 'editor.invisibles', => @updateInvisibles() atom.workspace?.editorAdded(this) if registerEditor @@ -133,8 +133,8 @@ class TextEditor extends Model subscribeToBuffer: -> @buffer.retain() @subscribe @buffer.onDidChangePath => - unless atom.project.getPath()? - atom.project.setPath(path.dirname(@getPath())) + unless atom.project.getPaths()[0]? + atom.project.setPaths([path.dirname(@getPath())]) @emit "title-changed" @emitter.emit 'did-change-title', @getTitle() @emit "path-changed" diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee index 1aebe757b..9ea248a26 100644 --- a/src/theme-manager.coffee +++ b/src/theme-manager.coffee @@ -2,7 +2,7 @@ path = require 'path' _ = require 'underscore-plus' EmitterMixin = require('emissary').Emitter -{Emitter} = require 'event-kit' +{Emitter, Disposable} = require 'event-kit' {File} = require 'pathwatcher' fs = require 'fs-plus' Q = require 'q' @@ -182,16 +182,16 @@ class ThemeManager # * `stylesheetPath` A {String} path to the stylesheet that can be an absolute # path or a relative path that will be resolved against the load path. # - # Returns the absolute path to the required stylesheet. + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # required stylesheet. requireStylesheet: (stylesheetPath, type='bundled') -> if fullPath = @resolveStylesheet(stylesheetPath) content = @loadStylesheet(fullPath) @applyStylesheet(fullPath, content, type) + new Disposable => @removeStylesheet(fullPath) else throw new Error("Could not find a file at path '#{stylesheetPath}'") - fullPath - unwatchUserStylesheet: -> @userStylesheetFile?.off() @userStylesheetFile = null diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 8872b6e01..ae2fa559a 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -25,7 +25,7 @@ class TokenizedBuffer extends Model constructor: ({@buffer, @tabLength, @invisibles}) -> @emitter = new Emitter - @tabLength ?= atom.config.getPositiveInt('editor.tabLength', 2) + @tabLength ?= atom.config.get('editor.tabLength') @subscribe atom.syntax.onDidAddGrammar(@grammarAddedOrUpdated) @subscribe atom.syntax.onDidUpdateGrammar(@grammarAddedOrUpdated) @@ -35,8 +35,7 @@ class TokenizedBuffer extends Model @subscribe @$tabLength.changes, (tabLength) => @retokenizeLines() - @subscribe atom.config.observe 'editor.tabLength', callNow: false, => - @setTabLength(atom.config.getPositiveInt('editor.tabLength', 2)) + @subscribe atom.config.onDidChange 'editor.tabLength', ({newValue}) => @setTabLength(newValue) @reloadGrammar() diff --git a/src/workspace-view.coffee b/src/workspace-view.coffee index ffb0ac7f9..cee296bf6 100644 --- a/src/workspace-view.coffee +++ b/src/workspace-view.coffee @@ -7,6 +7,7 @@ Delegator = require 'delegato' scrollbarStyle = require 'scrollbar-style' {$, $$, View} = require './space-pen-extensions' fs = require 'fs-plus' +Workspace = require './workspace' PaneView = require './pane-view' PaneContainerView = require './pane-container-view' TextEditor = require './text-editor' @@ -56,15 +57,6 @@ class WorkspaceView extends View 'saveActivePaneItem', 'saveActivePaneItemAs', 'saveAll', 'destroyActivePaneItem', 'destroyActivePane', 'increaseFontSize', 'decreaseFontSize', toProperty: 'model' - @configDefaults: - ignoredNames: [".git", ".hg", ".svn", ".DS_Store", "Thumbs.db"] - excludeVcsIgnoredPaths: true - disabledPackages: [] - themes: ['atom-dark-ui', 'atom-dark-syntax'] - projectHome: path.join(fs.getHomeDirectory(), 'github') - audioBeep: true - destroyEmptyPanes: true - constructor: (@element) -> unless @element? return atom.workspace.getView(atom.workspace).__spacePenView diff --git a/src/workspace.coffee b/src/workspace.coffee index 1ada6d89b..e225763e3 100644 --- a/src/workspace.coffee +++ b/src/workspace.coffee @@ -6,7 +6,7 @@ Q = require 'q' Serializable = require 'serializable' Delegator = require 'delegato' {Emitter, Disposable, CompositeDisposable} = require 'event-kit' -CommandInstaller = require './command-installer' +Grim = require 'grim' TextEditor = require './text-editor' PaneContainer = require './pane-container' Pane = require './pane' @@ -47,7 +47,7 @@ class Workspace extends Model @subscribeToActiveItem() - @registerOpener (filePath) => + @addOpener (filePath) => switch filePath when 'atom://.atom/stylesheet' @open(atom.themes.getUserStylesheetPath()) @@ -107,7 +107,7 @@ class Workspace extends Model subscribeToActiveItem: -> @updateWindowTitle() @updateDocumentEdited() - atom.project.on 'path-changed', @updateWindowTitle + atom.project.onDidChangePaths @updateWindowTitle @observeActivePaneItem (item) => @updateWindowTitle() @@ -136,7 +136,7 @@ class Workspace extends Model # Updates the application's title and proxy icon based on whichever file is # open. updateWindowTitle: => - if projectPath = atom.project?.getPath() + if projectPath = atom.project?.getPaths()[0] if item = @getActivePaneItem() document.title = "#{item.getTitle?() ? 'untitled'} - #{projectPath}" atom.setRepresentedFilename(item.getPath?() ? projectPath) @@ -414,8 +414,6 @@ class Workspace extends Model if uri = @destroyedItemUris.pop() @openSync(uri) - # TODO: make ::registerOpener() return a disposable - # Public: Register an opener for a uri. # # An {TextEditor} will be used if no openers return a value. @@ -423,17 +421,24 @@ class Workspace extends Model # ## Examples # # ```coffee - # atom.project.registerOpener (uri) -> + # atom.project.addOpener (uri) -> # if path.extname(uri) is '.toml' # return new TomlEditor(uri) # ``` # # * `opener` A {Function} to be called when a path is being opened. - registerOpener: (opener) -> + # + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # opener. + addOpener: (opener) -> @openers.push(opener) + new Disposable => _.remove(@openers, opener) + registerOpener: (opener) -> + Grim.deprecate("Call Workspace::addOpener instead") + @addOpener(opener) - # Unregister an opener registered with {::registerOpener}. unregisterOpener: (opener) -> + Grim.deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") _.remove(@openers, opener) getOpeners: ->