diff --git a/internal_packages/settings/lib/apm-wrapper.coffee b/internal_packages/settings/lib/apm-wrapper.coffee
index 99b63a092..44d86b4f6 100644
--- a/internal_packages/settings/lib/apm-wrapper.coffee
+++ b/internal_packages/settings/lib/apm-wrapper.coffee
@@ -25,7 +25,7 @@ class APMWrapper
args.push('--no-color')
new BufferedProcess({command, args, stdout, stderr, exit, options})
- runCommandReturningPackages: (args, callback) ->
+ runCommandReturningPackages: (args, errorMessage, callback) ->
@runCommand args, (code, stdout, stderr) ->
if code is 0
try
@@ -43,7 +43,7 @@ class APMWrapper
loadInstalled: (callback) ->
args = ['ls', '--json']
errorMessage = 'Fetching local packages failed.'
- apmProcess = @runCommandReturningPackages(args, callback)
+ apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
@@ -54,7 +54,7 @@ class APMWrapper
args.push('--compatible', version) if semver.valid(version)
errorMessage = 'Fetching featured packages failed.'
- apmProcess = @runCommandReturningPackages(args, callback)
+ apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
loadOutdated: (callback) ->
@@ -63,21 +63,21 @@ class APMWrapper
args.push('--compatible', version) if semver.valid(version)
errorMessage = 'Fetching outdated packages and themes failed.'
- apmProcess = @runCommandReturningPackages(args, callback)
+ apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
loadPackage: (packageName, callback) ->
args = ['view', packageName, '--json']
errorMessage = "Fetching package '#{packageName}' failed."
- apmProcess = @runCommandReturningPackages(args, callback)
+ apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
loadCompatiblePackageVersion: (packageName, callback) ->
args = ['view', packageName, '--json', '--compatible', @normalizeVersion(atom.getVersion())]
errorMessage = "Fetching package '#{packageName}' failed."
- apmProcess = @runCommandReturningPackages(args, callback)
+ apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
getInstalled: ->
diff --git a/internal_packages/thread-list/lib/draft-list.cjsx b/internal_packages/thread-list/lib/draft-list.cjsx
index 15a14f8f6..af518da78 100644
--- a/internal_packages/thread-list/lib/draft-list.cjsx
+++ b/internal_packages/thread-list/lib/draft-list.cjsx
@@ -59,6 +59,7 @@ class DraftList extends React.Component
commands={@commands}
onDoubleClick={@_onDoubleClick}
itemPropsProvider={ -> {} }
+ itemHeight={39}
className="draft-list"
collection="draft" />
diff --git a/internal_packages/thread-list/lib/thread-list-store.coffee b/internal_packages/thread-list/lib/thread-list-store.coffee
index e0adce41d..1a2031ec1 100644
--- a/internal_packages/thread-list/lib/thread-list-store.coffee
+++ b/internal_packages/thread-list/lib/thread-list-store.coffee
@@ -73,7 +73,7 @@ ThreadListStore = Reflux.createStore
@setView new DatabaseView Thread, {matchers}, (item) ->
DatabaseStore.findAll(Message, {threadId: item.id})
- Actions.focusInCollection(collection: 'thread', item: null)
+ Actions.setFocus(collection: 'thread', item: null)
# Inbound Events
@@ -128,9 +128,9 @@ ThreadListStore = Reflux.createStore
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
Actions.queueTask(task)
if thread.id is focusedId
- Actions.focusInCollection(collection: 'thread', item: null)
+ Actions.setFocus(collection: 'thread', item: null)
if thread.id is keyboardId
- Actions.focusKeyboardInCollection(collection: 'thread', item: null)
+ Actions.setCursorPosition(collection: 'thread', item: null)
@_view.selection.clear()
@@ -175,10 +175,13 @@ ThreadListStore = Reflux.createStore
nextFocus = null
@_afterViewUpdate.push ->
- Actions.focusInCollection(collection: 'thread', item: nextFocus)
- Actions.focusKeyboardInCollection(collection: 'thread', item: nextKeyboard)
+ Actions.setFocus(collection: 'thread', item: nextFocus)
+ Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)
_autofocusForLayoutMode: ->
- focusedId = FocusedContentStore.focusedId('thread')
- if WorkspaceStore.layoutMode() is "split" and not focusedId
- _.defer => Actions.focusInCollection(collection: 'thread', item: @_view.get(0))
+ layoutMode = WorkspaceStore.layoutMode()
+ focused = FocusedContentStore.focused('thread')
+ if layoutMode is 'split' and not focused and @_view.selection.count() is 0
+ item = @_view.get(0)
+ _.defer =>
+ Actions.setFocus({collection: 'thread', item: item})
diff --git a/internal_packages/thread-list/lib/thread-list.cjsx b/internal_packages/thread-list/lib/thread-list.cjsx
index 8c57fb640..b43250980 100644
--- a/internal_packages/thread-list/lib/thread-list.cjsx
+++ b/internal_packages/thread-list/lib/thread-list.cjsx
@@ -44,6 +44,10 @@ class ThreadList extends React.Component
@containerRequired: false
+ constructor: (@props) ->
+ @state =
+ style: 'unknown'
+
componentWillMount: =>
labelComponents = (thread) =>
for label in @state.threadLabelComponents
@@ -88,7 +92,29 @@ class ThreadList extends React.Component
resolver: (thread) =>
{timestamp(thread.lastMessageTimestamp)}
- @columns = [c1, c2, c3, c4]
+ @wideColumns = [c1, c2, c3, c4]
+
+ cNarrow = new ListTabular.Column
+ name: "Item"
+ resolver: (thread) =>
+ pencil = []
+ hasDraft = _.find (thread.metadata ? []), (m) -> m.draft
+ if hasDraft
+ pencil =
+
+
+
+
+
+ {timestamp(thread.lastMessageTimestamp)}
+ {pencil}
+
+
{subject(thread.subject)}
+
{thread.snippet}
+
+
+ @narrowColumns = [cNarrow]
+
@commands =
'core:remove-item': @_onArchive
'core:remove-and-previous': -> Actions.archiveAndPrevious()
@@ -100,15 +126,42 @@ class ThreadList extends React.Component
className: classNames
'unread': item.isUnread()
+ componentDidMount: =>
+ window.addEventListener('resize', @_onResize, true)
+ @_onResize()
+
+ componentWillUnmount: =>
+ window.removeEventListener('resize', @_onResize)
+
render: =>
-
+ if @state.style is 'wide'
+
+ else if @state.style is 'narrow'
+
+ else
+
+
+ _onResize: (event) =>
+ current = @state.style
+ desired = if React.findDOMNode(@).offsetWidth < 540 then 'narrow' else 'wide'
+ if current isnt desired
+ @setState(style: desired)
# Additional Commands
diff --git a/internal_packages/thread-list/stylesheets/thread-list.less b/internal_packages/thread-list/stylesheets/thread-list.less
index 383d7f4b3..dae3dcb7d 100644
--- a/internal_packages/thread-list/stylesheets/thread-list.less
+++ b/internal_packages/thread-list/stylesheets/thread-list.less
@@ -180,3 +180,33 @@
background-image:url(../static/images/thread-list/icon-star-hover-@2x.png);
background-size: 16px;
}
+
+.thread-list-narrow {
+ .timestamp {
+ flex: 1;
+ order: 100;
+ }
+ .participants {
+ font-size: @font-size-base;
+ }
+ .thread-icon {
+ margin-right:6px;
+ }
+ .subject {
+ font-size: @font-size-base;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: left;
+ padding-top:2px;
+ padding-bottom:2px;
+ margin-left:30px;
+ }
+ .snippet {
+ font-size: @font-size-small;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.7;
+ text-align: left;
+ margin-left:30px;
+ }
+}
diff --git a/internal_packages/unread-notifications/lib/main.coffee b/internal_packages/unread-notifications/lib/main.coffee
index 6eba230f6..377ff9827 100644
--- a/internal_packages/unread-notifications/lib/main.coffee
+++ b/internal_packages/unread-notifications/lib/main.coffee
@@ -58,7 +58,7 @@ module.exports =
notif.onclick = ->
atom.displayWindow()
Actions.focusTag(new Tag(name: "inbox", id: "inbox"))
- Actions.focusInCollection(collection: 'thread', item: threads[msg.threadId])
+ Actions.setFocus(collection: 'thread', item: threads[msg.threadId])
if newUnreadInInbox.length > 1
new Notification("#{newUnreadInInbox.length} Unread Messages", {
diff --git a/spec-nylas/components/multiselect-list-interaction-handler-spec.coffee b/spec-nylas/components/multiselect-list-interaction-handler-spec.coffee
new file mode 100644
index 000000000..189f159f3
--- /dev/null
+++ b/spec-nylas/components/multiselect-list-interaction-handler-spec.coffee
@@ -0,0 +1,107 @@
+MultiselectListInteractionHandler = require '../../src/components/multiselect-list-interaction-handler'
+WorkspaceStore = require '../../src/flux/stores/workspace-store'
+FocusedContentStore = require '../../src/flux/stores/focused-content-store'
+Thread = require '../../src/flux/models/thread'
+Actions = require '../../src/flux/actions'
+_ = require 'underscore'
+
+describe "MultiselectListInteractionHandler", ->
+ beforeEach ->
+ @item = new Thread(id:'123')
+ @itemFocus = new Thread({id: 'focus'})
+ @itemKeyboardFocus = new Thread({id: 'keyboard-focus'})
+ @itemAfterFocus = new Thread(id:'after-focus')
+ @itemAfterKeyboardFocus = new Thread(id:'after-keyboard-focus')
+
+ data = [@item, @itemFocus, @itemAfterFocus, @itemKeyboardFocus, @itemAfterKeyboardFocus]
+
+ @dataView =
+ selection:
+ toggle: jasmine.createSpy('toggle')
+ expandTo: jasmine.createSpy('expandTo')
+ walk: jasmine.createSpy('walk')
+ get: (idx) ->
+ data[idx]
+ getById: (id) ->
+ _.find data, (item) -> item.id is id
+ indexOfId: (id) ->
+ _.findIndex data, (item) -> item.id is id
+ count: -> data.length
+
+ @collection = 'threads'
+ @handler = new MultiselectListInteractionHandler(@dataView, @collection)
+ @isRootSheet = true
+
+ spyOn(WorkspaceStore, 'topSheet').andCallFake => {root: @isRootSheet}
+ spyOn(FocusedContentStore, 'keyboardCursorId').andCallFake -> 'keyboard-focus'
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
+ spyOn(Actions, 'setFocus')
+ spyOn(Actions, 'setCursorPosition')
+
+ it "should never show focus", ->
+ expect(@handler.shouldShowFocus()).toEqual(false)
+
+ it "should always show the keyboard cursor", ->
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(true)
+
+ describe "onClick", ->
+ it "should focus list items", ->
+ @handler.onClick(@item)
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @item})
+
+ describe "onMetaClick", ->
+ it "shoud toggle selection", ->
+ @handler.onMetaClick(@item)
+ expect(@dataView.selection.toggle).toHaveBeenCalledWith(@item)
+
+ it "should focus the keyboard on the clicked item", ->
+ @handler.onMetaClick(@item)
+ expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @item})
+
+ describe "onShiftClick", ->
+ it "should expand selection", ->
+ @handler.onShiftClick(@item)
+ expect(@dataView.selection.expandTo).toHaveBeenCalledWith(@item)
+
+ it "should focus the keyboard on the clicked item", ->
+ @handler.onShiftClick(@item)
+ expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @item})
+
+ describe "onEnter", ->
+ it "should focus the item with the current keyboard selection", ->
+ @handler.onEnter()
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @itemKeyboardFocus})
+
+ describe "onSelect (x key on keyboard)", ->
+ describe "on the root view", ->
+ it "should toggle the selection of the keyboard item", ->
+ @isRootSheet = true
+ @handler.onSelect()
+ expect(@dataView.selection.toggle).toHaveBeenCalledWith(@itemKeyboardFocus)
+
+ describe "on the thread view", ->
+ it "should toggle the selection of the focused item", ->
+ @isRootSheet = false
+ @handler.onSelect()
+ expect(@dataView.selection.toggle).toHaveBeenCalledWith(@itemFocus)
+
+ describe "onShift", ->
+ describe "on the root view", ->
+ beforeEach ->
+ @isRootSheet = true
+
+ it "should shift the keyboard item", ->
+ @handler.onShift(1, {})
+ expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @itemAfterKeyboardFocus})
+
+ it "should walk selection if the select option is passed", ->
+ @handler.onShift(1, select: true)
+ expect(@dataView.selection.walk).toHaveBeenCalledWith({current: @itemKeyboardFocus, next: @itemAfterKeyboardFocus})
+
+ describe "on the thread view", ->
+ beforeEach ->
+ @isRootSheet = false
+
+ it "should shift the focused item", ->
+ @handler.onShift(1, {})
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @itemAfterFocus})
diff --git a/spec-nylas/components/multiselect-split-interaction-handler-spec.coffee b/spec-nylas/components/multiselect-split-interaction-handler-spec.coffee
new file mode 100644
index 000000000..bae6e9b8f
--- /dev/null
+++ b/spec-nylas/components/multiselect-split-interaction-handler-spec.coffee
@@ -0,0 +1,160 @@
+MultiselectSplitInteractionHandler = require '../../src/components/multiselect-split-interaction-handler'
+WorkspaceStore = require '../../src/flux/stores/workspace-store'
+FocusedContentStore = require '../../src/flux/stores/focused-content-store'
+Thread = require '../../src/flux/models/thread'
+Actions = require '../../src/flux/actions'
+_ = require 'underscore'
+
+describe "MultiselectSplitInteractionHandler", ->
+ beforeEach ->
+ @item = new Thread(id:'123')
+ @itemFocus = new Thread({id: 'focus'})
+ @itemKeyboardFocus = new Thread({id: 'keyboard-focus'})
+ @itemAfterFocus = new Thread(id:'after-focus')
+ @itemAfterKeyboardFocus = new Thread(id:'after-keyboard-focus')
+
+ data = [@item, @itemFocus, @itemAfterFocus, @itemKeyboardFocus, @itemAfterKeyboardFocus]
+ @selection = []
+ @dataView =
+ selection:
+ toggle: jasmine.createSpy('toggle')
+ expandTo: jasmine.createSpy('expandTo')
+ add: jasmine.createSpy('add')
+ walk: jasmine.createSpy('walk')
+ clear: jasmine.createSpy('clear')
+ count: => @selection.length
+ items: => @selection
+ top: => @selection[-1]
+
+ get: (idx) ->
+ data[idx]
+ getById: (id) ->
+ _.find data, (item) -> item.id is id
+ indexOfId: (id) ->
+ _.findIndex data, (item) -> item.id is id
+ count: -> data.length
+
+ @collection = 'threads'
+ @handler = new MultiselectSplitInteractionHandler(@dataView, @collection)
+ @isRootSheet = true
+
+ spyOn(WorkspaceStore, 'topSheet').andCallFake => {root: @isRootSheet}
+ spyOn(Actions, 'setFocus')
+ spyOn(Actions, 'setCursorPosition')
+
+ it "should always show focus", ->
+ expect(@handler.shouldShowFocus()).toEqual(true)
+
+ it "should show the keyboard cursor when multiple items are selected", ->
+ @selection = []
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(false)
+ @selection = [@item]
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(false)
+ @selection = [@item, @itemFocus]
+ expect(@handler.shouldShowKeyboardCursor()).toEqual(true)
+
+ describe "onClick", ->
+ it "should focus list items", ->
+ @handler.onClick(@item)
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @item})
+
+ describe "onMetaClick", ->
+ describe "when there is currently a focused item", ->
+ beforeEach ->
+ spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
+
+ it "should turn the focused item into the first selected item", ->
+ @handler.onMetaClick(@item)
+ expect(@dataView.selection.add).toHaveBeenCalledWith(@itemFocus)
+
+ it "should clear the focus", ->
+ @handler.onMetaClick(@item)
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: null})
+
+ it "should toggle selection", ->
+ @handler.onMetaClick(@item)
+ expect(@dataView.selection.toggle).toHaveBeenCalledWith(@item)
+
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "onShiftClick", ->
+ describe "when there is currently a focused item", ->
+ beforeEach ->
+ spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
+
+ it "should turn the focused item into the first selected item", ->
+ @handler.onMetaClick(@item)
+ expect(@dataView.selection.add).toHaveBeenCalledWith(@itemFocus)
+
+ it "should clear the focus", ->
+ @handler.onMetaClick(@item)
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: null})
+
+ it "should expand selection", ->
+ @handler.onShiftClick(@item)
+ expect(@dataView.selection.expandTo).toHaveBeenCalledWith(@item)
+
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "onEnter", ->
+
+ describe "onSelect (x key on keyboard)", ->
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "onShift", ->
+ it "should call _checkSelectionAndFocusConsistency", ->
+ spyOn(@handler, '_checkSelectionAndFocusConsistency')
+ @handler.onMetaClick(@item)
+ expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
+
+ describe "when the select option is passed", ->
+ it "should turn the existing focused item into a selected item", ->
+ spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
+ @handler.onShift(1, {select: true})
+ expect(@dataView.selection.add).toHaveBeenCalledWith(@itemFocus)
+
+ it "should walk the selection to the shift target", ->
+ spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
+ @handler.onShift(1, {select: true})
+ expect(@dataView.selection.walk).toHaveBeenCalledWith({current: @itemFocus, next: @itemAfterFocus})
+
+ describe "when one or more items is selected", ->
+ it "should move the keyboard cursor", ->
+ @selection = [@itemFocus, @itemAfterFocus, @itemKeyboardFocus]
+ spyOn(FocusedContentStore, 'keyboardCursor').andCallFake => @itemKeyboardFocus
+ spyOn(FocusedContentStore, 'keyboardCursorId').andCallFake -> 'keyboard-focus'
+ @handler.onShift(1, {})
+ expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @itemAfterKeyboardFocus})
+
+ describe "when no items are selected", ->
+ it "should move the focus", ->
+ spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
+ @handler.onShift(1, {})
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @itemAfterFocus})
+
+
+ describe "_checkSelectionAndFocusConsistency", ->
+ describe "when only one item is selected", ->
+ beforeEach ->
+ spyOn(FocusedContentStore, 'focused').andCallFake -> null
+ spyOn(FocusedContentStore, 'focusedId').andCallFake -> null
+ @selection = [@item]
+
+ it "should clear the selection and make the item focused", ->
+ @handler._checkSelectionAndFocusConsistency()
+ expect(@dataView.selection.clear).toHaveBeenCalled()
+ expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @item})
diff --git a/spec-nylas/stores/focused-content-store-spec.coffee b/spec-nylas/stores/focused-content-store-spec.coffee
index eaa1d2f6c..1fedc74fa 100644
--- a/spec-nylas/stores/focused-content-store-spec.coffee
+++ b/spec-nylas/stores/focused-content-store-spec.coffee
@@ -7,7 +7,7 @@ Actions = require '../../src/flux/actions'
testThread = new Thread(id: '123')
describe "FocusedContentStore", ->
- describe "onFocusInCollection", ->
+ describe "onSetFocus", ->
it "should not trigger if the thread is already focused", ->
FocusedContentStore._onFocus({collection: 'thread', item: testThread})
spyOn(FocusedContentStore, 'trigger')
diff --git a/src/components/list-narrow-item.cjsx b/src/components/list-narrow-item.cjsx
deleted file mode 100644
index 3e01b92f7..000000000
--- a/src/components/list-narrow-item.cjsx
+++ /dev/null
@@ -1,43 +0,0 @@
-_ = require 'underscore'
-React = require 'react/addons'
-classNames = require 'classnames'
-{ComponentRegistry} = require 'nylas-exports'
-
-ThreadListItemMixin = require './thread-list-item-mixin'
-
-DefaultParticipants = React.createClass
- render: ->
-
- {_.pluck(@props.participants, "email").join ", "}
-
-
-module.exports =
-ThreadListNarrowItem = React.createClass
- mixins: [ComponentRegistry.Mixin, ThreadListItemMixin]
- displayName: 'ThreadListNarrowItem'
- components: ["Participants"]
-
- render: ->
- Participants = @state.Participants ? DefaultParticipants
-
-
-
-
- {@threadTime()}
-
-
-
-
- {@_subject()}
- {@_snippet()}
-
-
-
- _containerClasses: ->
- classNames
- 'unread': @props.unread
- 'selected': @props.selected
- 'thread-list-item': true
- 'thread-list-narrow-item': true
diff --git a/src/components/list-tabular.cjsx b/src/components/list-tabular.cjsx
index 87311a6ad..6f0c5da32 100644
--- a/src/components/list-tabular.cjsx
+++ b/src/components/list-tabular.cjsx
@@ -46,7 +46,7 @@ class ListTabularItem extends React.Component
+ className="list-column list-column-#{column.name}">
{column.resolver(@props.item, @)}
@@ -66,11 +66,15 @@ class ListTabular extends React.Component
columns: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
dataView: React.PropTypes.object
itemPropsProvider: React.PropTypes.func
+ itemHeight: React.PropTypes.number
onSelect: React.PropTypes.func
onClick: React.PropTypes.func
onDoubleClick: React.PropTypes.func
constructor: (@props) ->
+ if not @props.itemHeight
+ throw new Error("ListTabular: You must provide an itemHeight - raising to avoid divide by zero errors.")
+
@state =
renderedRangeStart: -1
renderedRangeEnd: -1
@@ -103,7 +107,7 @@ class ListTabular extends React.Component
# If we've shifted enough pixels from our previous scrollTop to require
# new rows to be rendered, update our state!
- if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @_rowHeight() * RangeChunkSize
+ if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @props.itemHeight * RangeChunkSize
@updateRangeState()
onDoneReceivingScrollEvents: =>
@@ -114,11 +118,9 @@ class ListTabular extends React.Component
updateRangeState: =>
scrollTop = @refs.container.scrollTop
- rowHeight = @_rowHeight()
-
# Determine the exact range of rows we want onscreen
- rangeStart = Math.floor(scrollTop / rowHeight)
- rangeEnd = rangeStart + window.innerHeight / rowHeight
+ rangeStart = Math.floor(scrollTop / @props.itemHeight)
+ rangeEnd = rangeStart + window.innerHeight / @props.itemHeight
# 1. Clip this range to the number of available items
#
@@ -154,7 +156,7 @@ class ListTabular extends React.Component
render: =>
innerStyles =
- height: @props.dataView.count() * @_rowHeight()
+ height: @props.dataView.count() * @props.itemHeight
pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
@@ -164,9 +166,6 @@ class ListTabular extends React.Component
- _rowHeight: =>
- 39
-
_headers: =>
return [] unless @props.displayHeaders
@@ -182,7 +181,6 @@ class ListTabular extends React.Component
_rows: =>
- rowHeight = @_rowHeight()
rows = []
for idx in [@state.renderedRangeStart..@state.renderedRangeEnd-1]
@@ -196,7 +194,7 @@ class ListTabular extends React.Component
rows.push
+
+ shouldShowFocus: ->
+ false
+
+ shouldShowKeyboardCursor: ->
+ true
+
+ onClick: (item) ->
+ Actions.setFocus({collection: @collection, item: item})
+
+ onMetaClick: (item) ->
+ @dataView.selection.toggle(item)
+ Actions.setCursorPosition({collection: @collection, item: item})
+
+ onShiftClick: (item) ->
+ @dataView.selection.expandTo(item)
+ Actions.setCursorPosition({collection: @collection, item: item})
+
+ onEnter: ->
+ keyboardCursorId = FocusedContentStore.keyboardCursorId(@collection)
+ if keyboardCursorId
+ item = @dataView.getById(keyboardCursorId)
+ Actions.setFocus({collection: @collection, item: item})
+
+ onSelect: ->
+ {id} = @_keyboardContext()
+ return unless id
+ @dataView.selection.toggle(@dataView.getById(id))
+
+ onShift: (delta, options = {}) ->
+ {id, action} = @_keyboardContext()
+
+ current = @dataView.getById(id)
+ index = @dataView.indexOfId(id)
+ index = Math.max(0, Math.min(index + delta, @dataView.count() - 1))
+ next = @dataView.get(index)
+
+ action({collection: @collection, item: next})
+ if options.select
+ @dataView.selection.walk({current, next})
+
+ _keyboardContext: ->
+ if WorkspaceStore.topSheet().root
+ {id: FocusedContentStore.keyboardCursorId(@collection), action: Actions.setCursorPosition}
+ else
+ {id: FocusedContentStore.focusedId(@collection), action: Actions.setFocus}
diff --git a/src/components/multiselect-list.cjsx b/src/components/multiselect-list.cjsx
index e85634bf1..d0a2c07ed 100644
--- a/src/components/multiselect-list.cjsx
+++ b/src/components/multiselect-list.cjsx
@@ -11,6 +11,9 @@ Spinner = require './spinner'
NamespaceStore} = require 'nylas-exports'
EventEmitter = require('events').EventEmitter
+MultiselectListInteractionHandler = require './multiselect-list-interaction-handler'
+MultiselectSplitInteractionHandler = require './multiselect-split-interaction-handler'
+
###
Public: MultiselectList wraps {ListTabular} and makes it easy to present a
{ModelView} with selection support. It adds a checkbox column to the columns
@@ -32,6 +35,7 @@ class MultiselectList extends React.Component
columns: React.PropTypes.array.isRequired
dataStore: React.PropTypes.object.isRequired
itemPropsProvider: React.PropTypes.func.isRequired
+ itemHeight: React.PropTypes.number.isRequired
scrollTooltipComponent: React.PropTypes.func
constructor: (@props) ->
@@ -77,21 +81,9 @@ class MultiselectList extends React.Component
context = {focusedId: @state.focusedId}
props.commands[key](context)
- unless props.columns[0].name is 'Check'
- checkmarkColumn = new ListTabular.Column
- name: "Check"
- resolver: (thread) =>
- toggle = (event) =>
- if event.shiftKey
- props.dataStore.view().selection.expandTo(thread)
- else
- props.dataStore.view().selection.toggle(thread)
- event.stopPropagation()
-
- props.columns.splice(0, 0, checkmarkColumn)
-
@unsubscribers = []
@unsubscribers.push props.dataStore.listen @_onChange
+ @unsubscribers.push WorkspaceStore.listen @_onChange
@unsubscribers.push FocusedContentStore.listen @_onChange
@command_unsubscriber = atom.commands.add('body', commands)
@@ -111,18 +103,19 @@ class MultiselectList extends React.Component
props.className ?= ''
props.className += " " + classNames
'selected': item.id in @state.selectedIds
- 'focused': @state.showFocus and item.id is @state.focusedId
- 'keyboard-cursor': @state.showKeyboardCursor and item.id is @state.keyboardCursorId
+ 'focused': @state.handler.shouldShowFocus() and item.id is @state.focusedId
+ 'keyboard-cursor': @state.handler.shouldShowKeyboardCursor() and item.id is @state.keyboardCursorId
props
if @state.dataView
@@ -135,58 +128,24 @@ class MultiselectList extends React.Component
_onClickItem: (item, event) =>
if event.metaKey
- @state.dataView.selection.toggle(item)
- if @state.showKeyboardCursor
- Actions.focusKeyboardInCollection({collection: @props.collection, item: item})
+ @state.handler.onMetaClick(item)
else if event.shiftKey
- @state.dataView.selection.expandTo(item)
- if @state.showKeyboardCursor
- Actions.focusKeyboardInCollection({collection: @props.collection, item: item})
+ @state.handler.onShiftClick(item)
else
- Actions.focusInCollection({collection: @props.collection, item: item})
+ @state.handler.onClick(item)
_onEnter: =>
- return unless @state.showKeyboardCursor
- item = @state.dataView.getById(@state.keyboardCursorId)
- if item
- Actions.focusInCollection({collection: @props.collection, item: item})
+ @state.handler.onEnter()
_onSelect: =>
- if @state.showKeyboardCursor and @_visible()
- id = @state.keyboardCursorId
- else
- id = @state.focusedId
-
- return unless id
- @state.dataView.selection.toggle(@state.dataView.getById(id))
+ @state.handler.onSelect()
_onDeselect: =>
return unless @_visible()
@state.dataView.selection.clear()
_onShift: (delta, options = {}) =>
- if @state.showKeyboardCursor and @_visible()
- id = @state.keyboardCursorId
- action = Actions.focusKeyboardInCollection
- else
- id = @state.focusedId
- action = Actions.focusInCollection
-
- current = @state.dataView.getById(id)
- index = @state.dataView.indexOfId(id)
- index = Math.max(0, Math.min(index + delta, @state.dataView.count() - 1))
- next = @state.dataView.get(index)
-
- action({collection: @props.collection, item: next})
-
- if options.select
- @state.dataView.selection.walk({current, next})
-
- _visible: =>
- if WorkspaceStore.layoutMode() is "list"
- WorkspaceStore.topSheet().root
- else
- true
+ @state.handler.onShift(delta, options)
# This onChange handler can be called many times back to back and setState
# sometimes triggers an immediate render. Ensure that we never render back-to-back,
@@ -198,19 +157,44 @@ class MultiselectList extends React.Component
, 1
@_onChangeDebounced()
+ _visible: =>
+ if WorkspaceStore.layoutMode() is "list"
+ WorkspaceStore.topSheet().root
+ else
+ true
+
+ _getCheckmarkColumn: =>
+ new ListTabular.Column
+ name: 'Check'
+ resolver: (item) =>
+ toggle = (event) =>
+ if event.shiftKey
+ @state.handler.onShiftClick(item)
+ else
+ @state.handler.onMetaClick(item)
+ event.stopPropagation()
+
+
_getStateFromStores: (props) =>
props ?= @props
view = props.dataStore?.view()
return {} unless view
+ columns = [].concat(props.columns)
+
+ if WorkspaceStore.layoutMode() is 'list'
+ handler = new MultiselectListInteractionHandler(view, props.collection)
+ columns.splice(0, 0, @_getCheckmarkColumn())
+ else
+ handler = new MultiselectSplitInteractionHandler(view, props.collection)
+
dataView: view
+ columns: columns
+ handler: handler
ready: view.loaded()
selectedIds: view.selection.ids()
focusedId: FocusedContentStore.focusedId(props.collection)
keyboardCursorId: FocusedContentStore.keyboardCursorId(props.collection)
- showFocus: !FocusedContentStore.keyboardCursorEnabled()
- showKeyboardCursor: FocusedContentStore.keyboardCursorEnabled()
-
module.exports = MultiselectList
diff --git a/src/components/multiselect-split-interaction-handler.coffee b/src/components/multiselect-split-interaction-handler.coffee
new file mode 100644
index 000000000..6f0218566
--- /dev/null
+++ b/src/components/multiselect-split-interaction-handler.coffee
@@ -0,0 +1,75 @@
+_ = require 'underscore'
+{Actions,
+ WorkspaceStore,
+ FocusedContentStore} = require 'nylas-exports'
+
+module.exports =
+class MultiselectSplitInteractionHandler
+ constructor: (@dataView, @collection) ->
+
+ shouldShowFocus: ->
+ true
+
+ shouldShowKeyboardCursor: ->
+ @dataView.selection.count() > 1
+
+ onClick: (item) ->
+ Actions.setFocus({collection: @collection, item: item})
+ @dataView.selection.clear()
+ @_checkSelectionAndFocusConsistency()
+
+ onMetaClick: (item) ->
+ @_turnFocusIntoSelection()
+ @dataView.selection.toggle(item)
+ @_checkSelectionAndFocusConsistency()
+
+ onShiftClick: (item) ->
+ @_turnFocusIntoSelection()
+ @dataView.selection.expandTo(item)
+ @_checkSelectionAndFocusConsistency()
+
+ onEnter: ->
+
+ onSelect: ->
+ @_checkSelectionAndFocusConsistency()
+
+ onShift: (delta, options) ->
+ if options.select
+ @_turnFocusIntoSelection()
+
+ if @dataView.selection.count() > 0
+ selection = @dataView.selection
+ keyboardId = FocusedContentStore.keyboardCursorId(@collection)
+ id = keyboardId ? @dataView.selection.top().id
+ action = Actions.setCursorPosition
+ else
+ id = FocusedContentStore.focusedId(@collection)
+ action = Actions.setFocus
+
+ current = @dataView.getById(id)
+ index = @dataView.indexOfId(id)
+ index = Math.max(0, Math.min(index + delta, @dataView.count() - 1))
+ next = @dataView.get(index)
+
+ action({collection: @collection, item: next})
+ if options.select
+ @dataView.selection.walk({current, next})
+
+ @_checkSelectionAndFocusConsistency()
+
+ _turnFocusIntoSelection: ->
+ focused = FocusedContentStore.focused(@collection)
+ Actions.setFocus({collection: @collection, item: null})
+ @dataView.selection.add(focused)
+
+ _checkSelectionAndFocusConsistency: ->
+ focused = FocusedContentStore.focused(@collection)
+ selection = @dataView.selection
+
+ if focused and selection.count() > 0
+ @dataView.selection.add(focused)
+ Actions.setFocus({collection: @collection, item: null})
+
+ if selection.count() is 1 and !focused
+ Actions.setFocus({collection: @collection, item: selection.items()[0]})
+ @dataView.selection.clear()
diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee
index e8b7c66c8..d0c136008 100644
--- a/src/flux/actions.coffee
+++ b/src/flux/actions.coffee
@@ -185,7 +185,7 @@ class Actions
Actions.selectLayoutMode(collection: 'thread', item:
)
```
###
- @focusKeyboardInCollection: ActionScopeWindow
+ @setCursorPosition: ActionScopeWindow
###
Public: Focus on an item in a collection. This action changes the selection
@@ -194,10 +194,10 @@ class Actions
*Scope: Window*
```
- Actions.focusInCollection(collection: 'thread', item: )
+ Actions.setFocus(collection: 'thread', item: )
```
###
- @focusInCollection: ActionScopeWindow
+ @setFocus: ActionScopeWindow
###
Public: Focus the interface on a specific {Tag}.
@@ -226,7 +226,7 @@ class Actions
###
Public: Create a new reply to the provided threadId and messageId. Note that
this action does not focus on the thread, so you may not be able to see the new draft
- unless you also call {::focusInCollection}.
+ unless you also call {::setFocus}.
*Scope: Window*
@@ -435,7 +435,7 @@ class Actions
###
Public: Push a sheet of a specific type onto the Sheet stack maintained by the
{WorkspaceStore}. Note that sheets have no state. To show a *specific* thread,
- you should push a Thread sheet and call `focusInCollection` to select the thread.
+ you should push a Thread sheet and call `setFocus` to select the thread.
*Scope: Window*
diff --git a/src/flux/stores/focused-content-store.coffee b/src/flux/stores/focused-content-store.coffee
index 21e0b3ce7..f4f286faf 100644
--- a/src/flux/stores/focused-content-store.coffee
+++ b/src/flux/stores/focused-content-store.coffee
@@ -18,8 +18,8 @@ selection public so that you can observe focus changes and trigger your own chan
to focus.
Since {FocusedContentStore} is a Flux-compatible Store, you do not call setters
-on it directly. Instead, use {Actions::focusInCollection} or
-{Actions::focusKeyboardInCollection} to set focus. The FocusedContentStore observes
+on it directly. Instead, use {Actions::setFocus} or
+{Actions::setCursorPosition} to set focus. The FocusedContentStore observes
these models, changes it's state, and broadcasts to it's observers.
Note: The {FocusedContentStore} triggers when a focused model is changed, even if
@@ -62,8 +62,8 @@ class FocusedContentStore
@listenTo NamespaceStore, @_onClear
@listenTo WorkspaceStore, @_onWorkspaceChange
@listenTo DatabaseStore, @_onDataChange
- @listenTo Actions.focusInCollection, @_onFocus
- @listenTo Actions.focusKeyboardInCollection, @_onFocusKeyboard
+ @listenTo Actions.setFocus, @_onFocus
+ @listenTo Actions.setCursorPosition, @_onFocusKeyboard
_resetInstanceVars: =>
@_focused = {}
diff --git a/src/flux/stores/model-view-selection.coffee b/src/flux/stores/model-view-selection.coffee
index d848ae1f1..4635b997e 100644
--- a/src/flux/stores/model-view-selection.coffee
+++ b/src/flux/stores/model-view-selection.coffee
@@ -17,6 +17,9 @@ class ModelViewSelection
items: ->
@_items
+ top: ->
+ @_items[@_items.length - 1]
+
clear: ->
@set([])
@@ -49,6 +52,16 @@ class ModelViewSelection
@_items.push(item)
@trigger(@)
+ add: (item) ->
+ return unless item
+ throw new Error("add must be called with a Model") unless item instanceof Model
+
+ updated = _.reject @_items, (t) -> t.id is item.id
+ updated.push(item)
+ if updated.length isnt @_items.length
+ @_items = updated
+ @trigger(@)
+
remove: (item) ->
return unless item
throw new Error("remove must be called with a Model") unless item instanceof Model
diff --git a/src/flux/stores/workspace-store.coffee b/src/flux/stores/workspace-store.coffee
index 3097f0fe7..eac392e34 100644
--- a/src/flux/stores/workspace-store.coffee
+++ b/src/flux/stores/workspace-store.coffee
@@ -28,7 +28,7 @@ class WorkspaceStore
@listenTo Actions.selectRootSheet, @_onSelectRootSheet
@listenTo Actions.selectLayoutMode, @_onSelectLayoutMode
- @listenTo Actions.focusInCollection, @_onFocusInCollection
+ @listenTo Actions.setFocus, @_onSetFocus
@listenTo Actions.popSheet, @popSheet
@listenTo Actions.searchQueryCommitted, @popToRootSheet
@@ -74,7 +74,7 @@ class WorkspaceStore
@_preferredLayoutMode = mode
@trigger(@)
- _onFocusInCollection: ({collection, item}) =>
+ _onSetFocus: ({collection, item}) =>
if collection is 'thread'
if @layoutMode() is 'list'
if item and @topSheet() isnt Sheet.Thread
@@ -182,7 +182,7 @@ class WorkspaceStore
@trigger()
if Sheet.Thread and sheet is Sheet.Thread
- Actions.focusInCollection(collection: 'thread', item: null)
+ Actions.setFocus(collection: 'thread', item: null)
# Return to the root sheet. This method triggers, allowing observers
# to update.
diff --git a/src/sheet-container.cjsx b/src/sheet-container.cjsx
index 23924c049..1b200a25c 100644
--- a/src/sheet-container.cjsx
+++ b/src/sheet-container.cjsx
@@ -36,7 +36,7 @@ class SheetContainer extends React.Component
{@_toolbarContainerElement()}
-
+
@refs["toolbar-#{sheet.props.depth}"]?.recomputeLayout()
+ window.dispatchEvent(new Event('resize'))
_onStoreChange: =>
_.defer => @setState(@_getStateFromStores())
@@ -97,4 +98,4 @@ class SheetContainer extends React.Component
stack: WorkspaceStore.sheetStack()
-module.exports = SheetContainer
\ No newline at end of file
+module.exports = SheetContainer
diff --git a/static/components/list-tabular.less b/static/components/list-tabular.less
index 630527a30..45dd7f712 100644
--- a/static/components/list-tabular.less
+++ b/static/components/list-tabular.less
@@ -113,7 +113,7 @@
}
&.keyboard-cursor {
- .checkmark {
+ .list-column:first-child {
border-left:4px solid @list-focused-bg;
padding-left:8px;
}