component.
+
+Frequently components will want to listen to a keyboard command regardless
+of where it was fired from. For those, use the `globalHandlers` prop. The
+DOM event will NOT be passed to `globalHandlers` callbacks.
+
+Components may also want to listen to keyboard commands that originate
+within one of their descendents. For those use the `localHandlers` prop.
+The DOM event WILL be passed to `localHandlers` callback because it is
+sometimes valuable to call `stopPropagataion` on the custom command event.
+
+Props:
+
+- `localHandlers` A mapping between key commands and callbacks for key command events that originate within a descendent of this component.
+- `globalHandlers` A mapping between key commands and callbacks for key
+commands that originate from anywhere and are global in scope.
+- `className` The unique class name that shows up in your keymap.cson
+
+Example:
+
+In `my-package/lib/my-component.cjsx`:
+
+```coffee
+class MyComponent extends React.Component
+ render: ->
+
+ ... sweet component ...
+
+
+ globalHandlers: ->
+ "core:moveDown": @onMoveDown
+ "core:selectItem": @onSelectItem
+
+ localHandlers: ->
+ "custom:send": (event) => @onSelectItem(); event.stopPropagation()
+ "custom:move": @onCustomMove
+```
+
+In `my-package/keymaps/my-package.cson`:
+
+```coffee
+".my-component":
+ "cmd-t": "selectItem"
+ "cmd-enter": "sendMessage"
+```
+
+###
+class KeyCommandsRegion extends React.Component
+ @displayName: "KeyCommandsRegion"
+
+ @propTypes:
+ className: React.PropTypes.string
+ localHandlers: React.PropTypes.object
+ globalHandlers: React.PropTypes.object
+
+ @defaultProps:
+ className: ""
+ localHandlers: {}
+ globalHandlers: {}
+
+ componentWillReceiveProps: (newProps) ->
+ @_unmountListeners()
+ @_setupListeners(newProps)
+
+ componentDidMount: ->
+ @_mounted = true
+ @_setupListeners(@props)
+
+ componentWillUnmount: ->
+ @_unmountListeners()
+
+ # When the {KeymapManager} finds a valid keymap in a `.cson` file, it
+ # will create a CustomEvent with the command name as its type. That
+ # custom event will be fired at the originating target and propogate
+ # updwards until it reaches the root window level.
+ #
+ # An event is scoped in the `.cson` files. Since we use that to
+ # determine which keymappings can fire a particular command in a
+ # particular scope, we simply need to listen at the root window level
+ # here for all commands coming in.
+ _setupListeners: (props) ->
+ _.each props.globalHandlers, (callback, handler) ->
+ window.addEventListener(handler, callback)
+
+ return unless @_mounted
+ $el = React.findDOMNode(@)
+ _.each props.localHandlers, (callback, handler) ->
+ $el.addEventListener(handler, callback)
+
+ _unmountListeners: ->
+ _.each @props.globalHandlers, (callback, handler) ->
+ window.removeEventListener(handler, callback)
+
+ return unless @_mounted
+ $el = React.findDOMNode(@)
+ _.each @props.localHandlers, (callback, handler) ->
+ $el.removeEventListener(handler, callback)
+
+ render: ->
+
+ {@props.children}
+
+
+module.exports = KeyCommandsRegion
diff --git a/src/components/multiselect-list.cjsx b/src/components/multiselect-list.cjsx
index f7dcc3015..be3c9f712 100644
--- a/src/components/multiselect-list.cjsx
+++ b/src/components/multiselect-list.cjsx
@@ -8,6 +8,7 @@ Spinner = require './spinner'
WorkspaceStore,
FocusedContentStore,
AccountStore} = require 'nylas-exports'
+{KeyCommandsRegion} = require 'nylas-component-kit'
EventEmitter = require('events').EventEmitter
MultiselectListInteractionHandler = require './multiselect-list-interaction-handler'
@@ -30,7 +31,6 @@ class MultiselectList extends React.Component
@propTypes =
className: React.PropTypes.string.isRequired
collection: React.PropTypes.string.isRequired
- commands: React.PropTypes.object.isRequired
columns: React.PropTypes.array.isRequired
dataStore: React.PropTypes.object.isRequired
itemPropsProvider: React.PropTypes.func.isRequired
@@ -66,30 +66,23 @@ class MultiselectList extends React.Component
teardownForProps: =>
return unless @unsubscribers
unsubscribe() for unsubscribe in @unsubscribers
- @command_unsubscriber.dispose()
setupForProps: (props) =>
- commands = _.extend {},
- 'core:focus-item': => @_onEnter()
- 'core:select-item': => @_onSelect()
- 'core:next-item': => @_onShift(1)
- 'core:previous-item': => @_onShift(-1)
- 'core:select-down': => @_onShift(1, {select: true})
- 'core:select-up': => @_onShift(-1, {select: true})
- 'core:list-page-up': => @_onScrollByPage(-1)
- 'core:list-page-down': => @_onScrollByPage(1)
- 'application:pop-sheet': => @_onDeselect()
-
- Object.keys(props.commands).forEach (key) =>
- commands[key] = =>
- context = {focusedId: @state.focusedId}
- props.commands[key](context)
-
@unsubscribers = []
@unsubscribers.push props.dataStore.listen @_onChange
@unsubscribers.push WorkspaceStore.listen @_onChange
@unsubscribers.push FocusedContentStore.listen @_onChange
- @command_unsubscriber = atom.commands.add('body', commands)
+
+ _keymapHandlers: ->
+ 'core:focus-item': => @_onEnter()
+ 'core:select-item': => @_onSelect()
+ 'core:next-item': => @_onShift(1)
+ 'core:previous-item': => @_onShift(-1)
+ 'core:select-down': => @_onShift(1, {select: true})
+ 'core:select-up': => @_onShift(-1, {select: true})
+ 'core:list-page-up': => @_onScrollByPage(-1)
+ 'core:list-page-down': => @_onScrollByPage(1)
+ 'application:pop-sheet': => @_onDeselect()
render: =>
# IMPORTANT: DO NOT pass inline functions as props. _.isEqual thinks these
@@ -122,19 +115,21 @@ class MultiselectList extends React.Component
spinnerElement =
-
-
- {spinnerElement}
- {emptyElement}
-
+
+
+
+ {spinnerElement}
+ {emptyElement}
+
+
else
diff --git a/src/flux/stores/focused-mail-view-store.coffee b/src/flux/stores/focused-mail-view-store.coffee
index a8cb1b934..e02d57f8f 100644
--- a/src/flux/stores/focused-mail-view-store.coffee
+++ b/src/flux/stores/focused-mail-view-store.coffee
@@ -1,4 +1,5 @@
NylasStore = require 'nylas-store'
+WorkspaceStore = require './workspace-store'
MailViewFilter = require '../../mail-view-filter'
CategoryStore = require './category-store'
AccountStore = require './account-store'
@@ -18,8 +19,9 @@ class FocusedMailViewStore extends NylasStore
else if not CategoryStore.byId(@_mailView.categoryId())
@_setMailView(@_defaultMailView())
- _onFocusMailView: (filter) ->
+ _onFocusMailView: (filter) =>
return if filter.isEqual(@_mailView)
+ Actions.selectRootSheet(WorkspaceStore.Sheet.Threads)
Actions.searchQueryCommitted('')
@_setMailView(filter)
@@ -36,7 +38,7 @@ class FocusedMailViewStore extends NylasStore
@_mailViewBeforeSearch = null
_defaultMailView: ->
- category = CategoryStore.getStandardCategory('inbox')
+ category = CategoryStore.getStandardCategory("inbox")
return null unless category
MailViewFilter.forCategory(category)
diff --git a/src/flux/stores/workspace-store.coffee b/src/flux/stores/workspace-store.coffee
index e69be15f4..3e170aa82 100644
--- a/src/flux/stores/workspace-store.coffee
+++ b/src/flux/stores/workspace-store.coffee
@@ -1,6 +1,8 @@
_ = require 'underscore'
Actions = require '../actions'
AccountStore = require './account-store'
+CategoryStore = require './category-store'
+MailViewFilter = require '../../mail-view-filter'
NylasStore = require 'nylas-store'
Sheet = {}
@@ -41,8 +43,38 @@ class WorkspaceStore extends NylasStore
@popToRootSheet()
@trigger()
- atom.commands.add 'body',
- 'application:pop-sheet': => @popSheet()
+ atom.commands.add 'body', @_navigationCommands()
+
+ _navigationCommands: ->
+ 'application:pop-sheet' : => @popSheet()
+ 'navigation:go-to-inbox' : => @_setMailViewByName("inbox")
+ 'navigation:go-to-starred' : => @_selectStarredView()
+ 'navigation:go-to-sent' : => @_setMailViewByName("sent")
+ 'navigation:go-to-drafts' : => @_selectDraftsSheet()
+ 'navigation:go-to-all' : => @_selectAllView()
+ 'navigation:go-to-contacts': => ## TODO
+ 'navigation:go-to-tasks' : => ## TODO
+ 'navigation:go-to-label' : => ## TODO
+
+ _setMailViewByName: (categoryName) ->
+ category = CategoryStore.getStandardCategory(categoryName)
+ return unless category
+ view = MailViewFilter.forCategory(category)
+ return unless view
+ Actions.focusMailView(view)
+
+ _selectDraftsSheet: ->
+ Actions.selectRootSheet(@Sheet.Drafts)
+
+ _selectAllView: ->
+ category = CategoryStore.getArchiveCategory()
+ return unless category
+ view = MailViewFilter.forCategory(category)
+ return unless view
+ Actions.focusMailView(view)
+
+ _selectStarredView: ->
+ Actions.focusMailView MailViewFilter.forStarred()
_resetInstanceVars: =>
@Location = Location = {}
diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee
index 91eca954e..6f58079ed 100644
--- a/src/global/nylas-component-kit.coffee
+++ b/src/global/nylas-component-kit.coffee
@@ -23,6 +23,7 @@ class NylasComponentKit
@load "ButtonDropdown", 'button-dropdown'
@load "Contenteditable", 'contenteditable/contenteditable'
@load "MultiselectList", 'multiselect-list'
+ @load "KeyCommandsRegion", 'key-commands-region'
@load "InjectedComponent", 'injected-component'
@load "TokenizingTextField", 'tokenizing-text-field'
@load "MultiselectActionBar", 'multiselect-action-bar'
diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee
index 15727e53a..1ba7ed65e 100644
--- a/src/global/nylas-exports.coffee
+++ b/src/global/nylas-exports.coffee
@@ -146,6 +146,6 @@ class NylasExports
@get "APMWrapper", -> require('../apm-wrapper')
# Testing
- @get "NylasTestUtils", -> require '../../spec/test_utils'
+ @get "NylasTestUtils", -> require '../../spec/nylas-test-utils'
module.exports = NylasExports
diff --git a/src/keymap-extensions.coffee b/src/keymap-extensions.coffee
deleted file mode 100644
index 3995c2422..000000000
--- a/src/keymap-extensions.coffee
+++ /dev/null
@@ -1,83 +0,0 @@
-fs = require 'fs-plus'
-path = require 'path'
-KeymapManager = require 'atom-keymap'
-CSON = require 'season'
-{jQuery} = require 'space-pen'
-Grim = require 'grim'
-
-KeymapManager::onDidLoadBundledKeymaps = (callback) ->
- @emitter.on 'did-load-bundled-keymaps', callback
-
-KeymapManager::loadBundledKeymaps = ->
- # Load the base keymap and the base.platform keymap
- baseKeymap = fs.resolve(path.join(@resourcePath, 'keymaps'), 'base', ['cson', 'json'])
- basePlatformKeymap = fs.resolve(path.join(@resourcePath, 'keymaps'), "base-#{process.platform}", ['cson', 'json'])
- @loadKeymap(baseKeymap)
- @loadKeymap(basePlatformKeymap)
-
- # Load the template keymap (Gmail, Mail.app, etc.) the user has chosen
- templateConfigKey = 'core.keymapTemplate'
- templateKeymapPath = null
- reloadTemplateKeymap = =>
- @removeBindingsFromSource(templateKeymapPath) if templateKeymapPath
- templateFile = atom.config.get(templateConfigKey)
- if templateFile
- templateKeymapPath = fs.resolve(path.join(@resourcePath, 'keymaps', 'templates'), templateFile, ['cson', 'json'])
- if fs.existsSync(templateKeymapPath)
- @loadKeymap(templateKeymapPath)
- @emitter.emit('did-reload-keymap', {path: templateKeymapPath})
- else
- console.warn("Could not find #{templateKeymapPath}")
-
- atom.config.observe(templateConfigKey, reloadTemplateKeymap)
- reloadTemplateKeymap()
-
- @emit 'bundled-keymaps-loaded' if Grim.includeDeprecatedAPIs
- @emitter.emit 'did-load-bundled-keymaps'
-
-KeymapManager::getUserKeymapPath = ->
- if userKeymapPath = CSON.resolve(path.join(@configDirPath, 'keymap'))
- userKeymapPath
- else
- path.join(@configDirPath, 'keymap.cson')
-
-KeymapManager::loadUserKeymap = ->
- userKeymapPath = @getUserKeymapPath()
- return unless fs.isFileSync(userKeymapPath)
-
- try
- @loadKeymap(userKeymapPath, watch: true, suppressErrors: true)
- catch error
- if error.message.indexOf('Unable to watch path') > -1
- message = """
- Unable to watch path: `#{path.basename(userKeymapPath)}`. Make sure you
- have permission to read `#{userKeymapPath}`.
-
- On linux there are currently problems with watch sizes. See
- [this document][watches] for more info.
- [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path
- """
- console.error(message, {dismissable: true})
- else
- detail = error.path
- stack = error.stack
- atom.notifications.addFatalError(error.message, {detail, stack, dismissable: true})
-
-KeymapManager::subscribeToFileReadFailure = ->
- @onDidFailToReadFile (error) =>
- userKeymapPath = @getUserKeymapPath()
- message = "Failed to load `#{userKeymapPath}`"
-
- detail = if error.location?
- error.stack
- else
- error.message
-
- console.error(message, {detail: detail, dismissable: true})
-
-# This enables command handlers registered via jQuery to call
-# `.abortKeyBinding()` on the `jQuery.Event` object passed to the handler.
-jQuery.Event::abortKeyBinding = ->
- @originalEvent?.abortKeyBinding?()
-
-module.exports = KeymapManager
diff --git a/src/keymap-manager.coffee b/src/keymap-manager.coffee
new file mode 100644
index 000000000..f66afa479
--- /dev/null
+++ b/src/keymap-manager.coffee
@@ -0,0 +1,97 @@
+fs = require 'fs-plus'
+path = require 'path'
+CSON = require 'season'
+AtomKeymap = require 'atom-keymap'
+
+class KeymapManager extends AtomKeymap
+
+ constructor: ->
+ super
+ @subscribeToFileReadFailure()
+
+ onDidLoadBundledKeymaps: (callback) ->
+ @emitter.on 'did-load-bundled-keymaps', callback
+
+ # N1 adds the `cmdctrl` extension. This will use `cmd` or `ctrl` on a
+ # mac, and `ctrl` only on windows and linux.
+ readKeymap: (args...) ->
+ re = /(cmdctrl|ctrlcmd)/i
+ keymap = super(args...)
+ for selector, keyBindings of keymap
+ normalizedBindings = {}
+ for keystrokes, command of keyBindings
+ if re.test keystrokes
+ if process.platform is "darwin"
+ newKeystrokes1= keystrokes.replace(re, "ctrl")
+ newKeystrokes2= keystrokes.replace(re, "cmd")
+ normalizedBindings[newKeystrokes1] = command
+ normalizedBindings[newKeystrokes2] = command
+ else
+ newKeystrokes = keystrokes.replace(re, "ctrl")
+ normalizedBindings[newKeystrokes] = command
+ else
+ normalizedBindings[keystrokes] = command
+ keymap[selector] = normalizedBindings
+
+ return keymap
+
+ loadBundledKeymaps: ->
+ # Load the base keymap and the base.platform keymap
+ baseKeymap = fs.resolve(path.join(@resourcePath, 'keymaps'), 'base', ['cson', 'json'])
+ inputResetKeymap = fs.resolve(path.join(@resourcePath, 'keymaps'), 'input-reset', ['cson', 'json'])
+ basePlatformKeymap = fs.resolve(path.join(@resourcePath, 'keymaps'), "base-#{process.platform}", ['cson', 'json'])
+ @loadKeymap(baseKeymap)
+ @loadKeymap(inputResetKeymap)
+ @loadKeymap(basePlatformKeymap)
+
+ # Load the template keymap (Gmail, Mail.app, etc.) the user has chosen
+ templateConfigKey = 'core.keymapTemplate'
+ templateKeymapPath = null
+ reloadTemplateKeymap = =>
+ @removeBindingsFromSource(templateKeymapPath) if templateKeymapPath
+ templateFile = atom.config.get(templateConfigKey)
+ if templateFile
+ templateKeymapPath = fs.resolve(path.join(@resourcePath, 'keymaps', 'templates'), templateFile, ['cson', 'json'])
+ if fs.existsSync(templateKeymapPath)
+ @loadKeymap(templateKeymapPath)
+ @emitter.emit('did-reload-keymap', {path: templateKeymapPath})
+ else
+ console.warn("Could not find #{templateKeymapPath}")
+
+ atom.config.observe(templateConfigKey, reloadTemplateKeymap)
+ reloadTemplateKeymap()
+
+ @emitter.emit 'did-load-bundled-keymaps'
+
+ getUserKeymapPath: ->
+ if userKeymapPath = CSON.resolve(path.join(@configDirPath, 'keymap'))
+ userKeymapPath
+ else
+ path.join(@configDirPath, 'keymap.cson')
+
+ loadUserKeymap: ->
+ userKeymapPath = @getUserKeymapPath()
+ return unless fs.isFileSync(userKeymapPath)
+
+ try
+ @loadKeymap(userKeymapPath, watch: true, suppressErrors: true)
+ catch error
+ message = """
+ Unable to watch path: `#{path.basename(userKeymapPath)}`. Make sure you
+ have permission to read `#{userKeymapPath}`.
+ """
+ console.error(message, {dismissable: true})
+
+ subscribeToFileReadFailure: ->
+ @onDidFailToReadFile (error) =>
+ userKeymapPath = @getUserKeymapPath()
+ message = "Failed to load `#{userKeymapPath}`"
+
+ detail = if error.location?
+ error.stack
+ else
+ error.message
+
+ console.error(message, {detail: detail, dismissable: true})
+
+module.exports = KeymapManager
diff --git a/src/mail-view-filter.coffee b/src/mail-view-filter.coffee
index 65e3f3140..24415b660 100644
--- a/src/mail-view-filter.coffee
+++ b/src/mail-view-filter.coffee
@@ -21,6 +21,9 @@ class MailViewFilter
@forSearch: (query) ->
new SearchMailViewFilter(query)
+ @forAll: ->
+ new AllMailViewFilter()
+
# Instance Methods
constructor: ->
@@ -83,6 +86,31 @@ class SearchMailViewFilter extends MailViewFilter
categoryId: ->
null
+class AllMailViewFilter extends MailViewFilter
+ constructor: ->
+ @name = "All"
+ @iconName = "all-mail.png"
+ @
+
+ isEqual: (other) ->
+ super(other) and other.searchQuery is @searchQuery
+
+ matchers: ->
+ account = AccountStore.current()
+ [Thread.attributes.accountId.equal(account.id)]
+
+ canApplyToThreads: ->
+ true
+
+ canArchiveThreads: ->
+ false
+
+ canTrashThreads: ->
+ false
+
+ categoryId: ->
+ CategoryStore.getStandardCategory("all")?.id
+
class StarredMailViewFilter extends MailViewFilter
constructor: ->
diff --git a/src/package.coffee b/src/package.coffee
index 99d95f926..6fcaf9524 100644
--- a/src/package.coffee
+++ b/src/package.coffee
@@ -245,7 +245,7 @@ class Package
if @bundledPackage and packagesCache[@name]?
@keymaps = (["#{atom.packages.resourcePath}#{path.sep}#{keymapPath}", keymapObject] for keymapPath, keymapObject of packagesCache[@name].keymaps)
else
- @keymaps = @getKeymapPaths().map (keymapPath) -> [keymapPath, CSON.readFileSync(keymapPath) ? {}]
+ @keymaps = @getKeymapPaths().map (keymapPath) -> [keymapPath, atom.keymaps.readKeymap(keymapPath) ? {}]
loadMenus: ->
if @bundledPackage and packagesCache[@name]?
diff --git a/static/components/key-commands-region.less b/static/components/key-commands-region.less
new file mode 100644
index 000000000..9989db013
--- /dev/null
+++ b/static/components/key-commands-region.less
@@ -0,0 +1,5 @@
+.key-commands-region {
+ position: relative;
+ height: 100%;
+ width: 100%;
+}
diff --git a/static/index.less b/static/index.less
index 48750e5e5..ec0b68b68 100644
--- a/static/index.less
+++ b/static/index.less
@@ -25,3 +25,4 @@
@import "components/spinner";
@import "components/generated-form";
@import "components/unsafe";
+@import "components/key-commands-region";