fix(initial-sync): Make initial sync more robust, show progress, retry on failure

Summary:
Rename ActivityBar => DeveloperBar

Expose sync workers and make them observable

New activity sidebar that replaces momentary notifications

Updated specs

Test Plan: Run new specs!

Reviewers: evan

Reviewed By: evan

Maniphest Tasks: T1131

Differential Revision: https://phab.nylas.com/D1521
This commit is contained in:
Ben Gotow 2015-05-19 15:59:37 -07:00
parent a265b54f48
commit 0efdec5fd5
24 changed files with 436 additions and 171 deletions

View file

@ -68,7 +68,7 @@ module.exports =
# watching any files, holding external resources, providing commands or
# subscribing to events, release them here.
deactivate: ->
ComponentRegistry.unregister('TranslateButton')
ComponentRegistry.unregister(TranslateButton)
```

View file

@ -1,7 +1,7 @@
React = require 'react/addons'
class ActivityBarCurlItem extends React.Component
@displayName: 'ActivityBarCurlItem'
class DeveloperBarCurlItem extends React.Component
@displayName: 'DeveloperBarCurlItem'
render: =>
<div className={"item status-code-#{@props.item.statusCode}"}>
@ -29,4 +29,4 @@ class ActivityBarCurlItem extends React.Component
shell.openItem(curlFile)
module.exports = ActivityBarCurlItem
module.exports = DeveloperBarCurlItem

View file

@ -2,8 +2,8 @@ React = require 'react/addons'
moment = require 'moment'
{Utils} = require 'nylas-exports'
class ActivityBarLongPollItem extends React.Component
@displayName: 'ActivityBarLongPollItem'
class DeveloperBarLongPollItem extends React.Component
@displayName: 'DeveloperBarLongPollItem'
constructor: (@props) ->
@state = expanded: false
@ -33,4 +33,4 @@ class ActivityBarLongPollItem extends React.Component
module.exports = ActivityBarLongPollItem
module.exports = DeveloperBarLongPollItem

View file

@ -5,7 +5,7 @@ _ = require 'underscore-plus'
curlItemId = 0
ActivityBarStore = Reflux.createStore
DeveloperBarStore = Reflux.createStore
init: ->
@_setStoreDefaults()
@_registerListeners()
@ -86,4 +86,4 @@ ActivityBarStore = Reflux.createStore
@triggerThrottled(@)
module.exports = ActivityBarStore
module.exports = DeveloperBarStore

View file

@ -3,8 +3,8 @@ classNames = require 'classnames'
_ = require 'underscore-plus'
{Utils} = require 'nylas-exports'
class ActivityBarTask extends React.Component
@displayName: 'ActivityBarTask'
class DeveloperBarTask extends React.Component
@displayName: 'DeveloperBarTask'
constructor: (@props) ->
@state = expanded: false
@ -52,4 +52,4 @@ class ActivityBarTask extends React.Component
"task-success": qs.performedLocal and qs.performedRemote
module.exports = ActivityBarTask
module.exports = DeveloperBarTask

View file

@ -9,28 +9,28 @@ React = require 'react/addons'
Message} = require 'nylas-exports'
{ResizableRegion} = require 'nylas-component-kit'
ActivityBarStore = require './activity-bar-store'
ActivityBarTask = require './activity-bar-task'
ActivityBarCurlItem = require './activity-bar-curl-item'
ActivityBarLongPollItem = require './activity-bar-long-poll-item'
DeveloperBarStore = require './developer-bar-store'
DeveloperBarTask = require './developer-bar-task'
DeveloperBarCurlItem = require './developer-bar-curl-item'
DeveloperBarLongPollItem = require './developer-bar-long-poll-item'
ActivityBarClosedHeight = 30
DeveloperBarClosedHeight = 30
class ActivityBar extends React.Component
@displayName: "ActivityBar"
class DeveloperBar extends React.Component
@displayName: "DeveloperBar"
@containerRequired: false
constructor: (@props) ->
@state = _.extend @_getStateFromStores(),
height: ActivityBarClosedHeight
height: DeveloperBarClosedHeight
section: 'curl'
filter: ''
componentDidMount: =>
ipc.on 'report-issue', => @_onFeedback()
@taskQueueUnsubscribe = TaskQueue.listen @_onChange
@activityStoreUnsubscribe = ActivityBarStore.listen @_onChange
@activityStoreUnsubscribe = DeveloperBarStore.listen @_onChange
componentWillUnmount: =>
@taskQueueUnsubscribe() if @taskQueueUnsubscribe
@ -39,9 +39,9 @@ class ActivityBar extends React.Component
render: =>
return <div></div> unless @state.visible
<ResizableRegion className="activity-bar"
<ResizableRegion className="developer-bar"
initialHeight={@state.height}
minHeight={ActivityBarClosedHeight}
minHeight={DeveloperBarClosedHeight}
handle={ResizableRegion.Handle.Top}>
<div className="controls">
{@_caret()}
@ -76,7 +76,7 @@ class ActivityBar extends React.Component
</ResizableRegion>
_caret: =>
if @state.height > ActivityBarClosedHeight
if @state.height > DeveloperBarClosedHeight
<i className="fa fa-caret-square-o-down" onClick={@_onHide}></i>
else
<i className="fa fa-caret-square-o-up" onClick={@_onShow}></i>
@ -90,26 +90,26 @@ class ActivityBar extends React.Component
if @state.section == 'curl'
itemDivs = @state.curlHistory.filter(matchingFilter).map (item) ->
<ActivityBarCurlItem item={item} key={item.id}/>
<DeveloperBarCurlItem item={item} key={item.id}/>
expandedDiv = <div className="expanded-section curl-history">{itemDivs}</div>
else if @state.section == 'long-polling'
itemDivs = @state.longPollHistory.filter(matchingFilter).map (item) ->
<ActivityBarLongPollItem item={item} key={item.cursor}/>
<DeveloperBarLongPollItem item={item} key={item.cursor}/>
expandedDiv = <div className="expanded-section long-polling">{itemDivs}</div>
else if @state.section == 'queue'
queue = @state.queue.filter(matchingFilter)
queueDivs = for i in [@state.queue.length - 1..0] by -1
task = @state.queue[i]
<ActivityBarTask task={task}
<DeveloperBarTask task={task}
key={task.id}
type="queued" />
queueCompleted = @state.completed.filter(matchingFilter)
queueCompletedDivs = for i in [@state.completed.length - 1..0] by -1
task = @state.completed[i]
<ActivityBarTask task={task}
<DeveloperBarTask task={task}
key={task.id}
type="completed" />
@ -140,7 +140,7 @@ class ActivityBar extends React.Component
_onHide: =>
@setState
height: ActivityBarClosedHeight
height: DeveloperBarClosedHeight
_onShow: =>
@setState(height: 200) if @state.height < 100
@ -200,12 +200,12 @@ class ActivityBar extends React.Component
Actions.composePopoutDraft(localId)
_getStateFromStores: =>
visible: ActivityBarStore.visible()
visible: DeveloperBarStore.visible()
queue: TaskQueue._queue
completed: TaskQueue._completed
curlHistory: ActivityBarStore.curlHistory()
longPollHistory: ActivityBarStore.longPollHistory()
longPollState: ActivityBarStore.longPollState()
curlHistory: DeveloperBarStore.curlHistory()
longPollHistory: DeveloperBarStore.longPollHistory()
longPollState: DeveloperBarStore.longPollState()
module.exports = ActivityBar
module.exports = DeveloperBar

View file

@ -1,13 +1,13 @@
React = require 'react'
{ComponentRegistry, WorkspaceStore} = require 'nylas-exports'
ActivityBar = require './activity-bar'
DeveloperBar = require './developer-bar'
module.exports =
item: null
activate: (@state={}) ->
ComponentRegistry.register ActivityBar,
ComponentRegistry.register DeveloperBar,
location: WorkspaceStore.Sheet.Global.Footer
deactivate: ->
ComponentRegistry.unregister ActivityBar
ComponentRegistry.unregister DeveloperBar

View file

@ -1,8 +1,8 @@
{
"name": "inbox-activity-bar",
"name": "developer-bar",
"version": "0.1.0",
"main": "./lib/main",
"description": "Activity bar at the very bottom of the window",
"description": "Developer bar at the very bottom of the window",
"license": "Proprietary",
"private": true,
"engines": {

View file

@ -1,6 +1,6 @@
@import "ui-variables";
.activity-bar {
.developer-bar {
-webkit-font-smoothing: auto;
background-color: rgba(80,80,80,1);
border-top:1px solid rgba(0,0,0,0.7);

View file

@ -0,0 +1,122 @@
React = require 'react'
_ = require 'underscore-plus'
classNames = require 'classnames'
NotificationStore = require './notifications-store'
{Actions,
TaskQueue,
NamespaceStore,
NylasAPI} = require 'nylas-exports'
{TimeoutTransitionGroup} = require 'nylas-component-kit'
class ActivitySidebar extends React.Component
@displayName: 'ActivitySidebar'
@containerRequired: false
constructor: (@props) ->
@state = @_getStateFromStores()
componentDidMount: =>
@_unlisteners = []
@_unlisteners.push NamespaceStore.listen @_onNamespacesChanged
@_unlisteners.push TaskQueue.listen @_onDataChanged
@_unlisteners.push NotificationStore.listen @_onDataChanged
@_onNamespacesChanged()
componentWillUnmount: =>
unlisten() for unlisten in @_unlisteners
@_workerUnlisten() if @_workerUnlisten
render: =>
items = [].concat(@_renderSyncActivityItem(), @_renderNotificationActivityItems(), @_renderTaskActivityItems())
names = classNames
"sidebar-activity": true
"sidebar-activity-empty": items.length is 0
"sidebar-activity-error": error?
<TimeoutTransitionGroup
className={names}
leaveTimeout={625}
enterTimeout={125}
transitionName="activity-item">
{items}
</TimeoutTransitionGroup>
_renderSyncActivityItem: =>
count = 0
fetched = 0
progress = 0
incomplete = 0
error = null
for model, modelState of @state.sync
incomplete += 1 unless modelState.complete
error ?= modelState.error
if modelState.count
count += modelState.count / 1
fetched += modelState.fetched / 1
progress = (fetched / count) * 100 if count > 0
if incomplete is 0
return []
else if error
<div className="item">
<div className="inner">Initial sync encountered an error. Waiting to retry...
<div className="btn btn-emphasis" onClick={@_onTryAgain}>Try Again</div>
</div>
</div>
else
<div className="item">
<div className="progress-track">
<div className="progress" style={width: "#{progress}%"}></div>
</div>
<div className="inner">Syncing mail data...</div>
</div>
_renderTaskActivityItems: =>
summary = {}
@state.tasks.map (task) ->
label = task.label?()
return unless label
summary[label] ?= 0
summary[label] += 1
_.pairs(summary).map ([label, count]) ->
<div className="item" key={label}>
<div className="inner">
{label} <span className="count">({count})</span>
</div>
</div>
_renderNotificationActivityItems: =>
@state.notifications.map (notification) ->
<div className="item" key={notification.id}>
<div className="inner">
{notification.message}
</div>
</div>
_onNamespacesChanged: =>
namespace = NamespaceStore.current()
return unless namespace
@_worker = NylasAPI.workerForNamespace(namespace)
@_workerUnlisten() if @_workerUnlisten
@_workerUnlisten = @_worker.listen(@_onDataChanged, @)
@_onDataChanged()
_onTryAgain: =>
@_worker.resumeFetches()
_onDataChanged: =>
@setState(@_getStateFromStores())
_getStateFromStores: =>
tasks: TaskQueue.queue()
notifications: NotificationStore.notifications()
sync: @_worker?.state()
module.exports = ActivitySidebar

View file

@ -1,5 +1,5 @@
React = require "react"
Notifications = require "./notifications"
ActivitySidebar = require "./activity-sidebar"
NotificationsStickyBar = require "./notifications-sticky-bar"
{ComponentRegistry, WorkspaceStore} = require("nylas-exports")
@ -7,14 +7,14 @@ module.exports =
item: null # The DOM item the main React component renders into
activate: (@state={}) ->
ComponentRegistry.register Notifications,
ComponentRegistry.register ActivitySidebar,
location: WorkspaceStore.Location.RootSidebar
ComponentRegistry.register NotificationsStickyBar,
location: WorkspaceStore.Sheet.Global.Header
deactivate: ->
ComponentRegistry.unregister('NotificationsStickyBar')
ComponentRegistry.unregister('Notifications')
ComponentRegistry.unregister(ActivitySidebar)
ComponentRegistry.unregister(NotificationsStickyBar)
serialize: -> @state

View file

@ -1,35 +0,0 @@
React = require 'react'
NotificationStore = require './notifications-store'
class Notifications extends React.Component
@displayName: "Notifications"
@containerRequired: false
constructor: (@props) ->
@state = notifications: NotificationStore.notifications()
componentDidMount: =>
@unsubscribeStore = NotificationStore.listen @_onStoreChange
componentWillUnmount: =>
@unsubscribeStore() if @unsubscribeStore
render: =>
<div className="notifications-momentary">
{@_notificationComponents()}
</div>
_notificationComponents: =>
@state.notifications.map (notification) ->
<div key={notification.id}
className={"notification-item notification-#{notification.type}"}>
{notification.message}
</div>
_onStoreChange: =>
@setState
notifications: NotificationStore.notifications()
module.exports = Notifications

View file

@ -1,30 +1,77 @@
@import "ui-variables";
@import "ui-mixins";
// Notifications Above Threads
.notifications-momentary {
.sidebar-activity {
width: 100%;
bottom: 0;
position: absolute;
order:2;
background: @background-off-primary;
border-top: 1px solid @border-secondary-bg;
box-shadow: @standard-shadow-up;
font-size: @font-size-small;
color: @text-color-subtle;
line-height:@line-height-computed * 0.95;
height:140px;
overflow-y:scroll;
box-shadow:inset 0 1px 0 @border-color-divider;
.notification-info { border-color: @background-color-info; }
.notification-error {
border-color: @background-color-error;
color: @error-color;
.item {
border-bottom:1px solid @border-color-divider;
.inner {
padding: @padding-large-vertical @padding-base-horizontal @padding-large-vertical @padding-base-horizontal;
margin-top:3px;
}
.count {
color: @text-color-very-subtle;
float:right;
}
.btn {
display:block;
text-align:center;
margin-top:4px;
margin-bottom:4px;
font-size: @font-size-small;
}
.progress-track {
display:block;
height:3px;
font-size:0;
.progress {
transition: width 0.4s;
height:3px;
background-color: @background-color-info;
}
}
}
.notification-success { border-color: @background-color-success; }
.notification-item {
text-align: center;
border-top-width: 3px;
border-top-style: solid;
padding: @spacing-standard;
transition: height 0.4s;
transition-delay: 2s;
&.sidebar-activity-error {
.progress {
background-color: @error-color;
}
}
}
.activity-item-enter {
opacity:0;
transition: opacity .125s ease-out;
}
.activity-item-enter.activity-item-enter-active {
opacity:1;
}
.activity-item-leave {
opacity:1;
transition: opacity .125s ease-in;
transition-delay: 0.5s;
}
.activity-item-leave.activity-item-leave-active {
transition-delay: 0.5s;
opacity:0;
}
.notifications-sticky {
width:100%;
@ -71,4 +118,4 @@
margin-right:@padding-base-horizontal;
}
}
}
}

View file

@ -139,7 +139,6 @@ ThreadListStore = Reflux.createStore
# Archive the current thread
task = new AddRemoveTagsTask(focused, ['archive'], ['inbox'])
Actions.queueTask(task)
Actions.postNotification({message: "Archived thread", type: 'success'})
# Remove the current thread from selection
@_view.selection.remove(focused)

View file

@ -7,62 +7,154 @@ describe "NylasSyncWorker", ->
beforeEach ->
@apiRequests = []
@api =
makeRequest: (requestOptions) =>
@apiRequests.push({requestOptions})
getCollection: (namespace, model, params, requestOptions) =>
@apiRequests.push({namespace, model, params, requestOptions})
getThreads: (namespace, params, requestOptions) =>
@apiRequests.push({namespace, model:'threads', params, requestOptions})
@state =
"contacts": {busy: true}
"calendars": {complete: true}
spyOn(atom.config, 'get').andCallFake (key) =>
expected = "nylas.namespace-id.worker-state"
return throw new Error("Not stubbed!") unless key is expected
return @state
return throw new Error("Not stubbed! #{key}") unless key is expected
return _.extend {}, {
"contacts":
busy: true
complete: false
"calendars":
busy:false
complete: true
}
spyOn(atom.config, 'set').andCallFake (key, val) =>
expected = "nylas.namespace-id.worker-state"
return throw new Error("Not stubbed!") unless key is expected
@state = val
return
@worker = new NylasSyncWorker(@api, 'namespace-id')
@connection = @worker.connection()
it "should reset `busy` to false when reading state from disk", ->
state = @worker.state()
expect(state.contacts.busy).toEqual(false)
describe "start", ->
it "should open the long polling connection", ->
spyOn(@connection, 'start')
@worker.start()
expect(@connection.start).toHaveBeenCalled()
it "should start querying for model collections that haven't been fully cached", ->
it "should start querying for model collections and counts that haven't been fully cached", ->
@worker.start()
expect(@apiRequests.length).toBe(3)
modelsRequested = _.map @apiRequests, (r) -> r.model
expect(@apiRequests.length).toBe(6)
modelsRequested = _.compact _.map @apiRequests, ({model}) -> model
expect(modelsRequested).toEqual(['threads', 'contacts', 'files'])
countsRequested = _.compact _.map @apiRequests, ({requestOptions}) ->
if requestOptions.qs?.view is 'count'
return requestOptions.path
expect(modelsRequested).toEqual(['threads', 'contacts', 'files'])
expect(countsRequested).toEqual(['/n/namespace-id/threads', '/n/namespace-id/contacts', '/n/namespace-id/files'])
it "should mark incomplete collections as `busy`", ->
@worker.start()
expect(@state).toEqual({
"contacts": {busy: true}
"threads": {busy: true}
"files": {busy: true}
"calendars": {complete: true}
})
nextState = @worker.state()
describe "when an API request completes", ->
for collection in ['contacts','threads','files']
expect(nextState[collection].busy).toEqual(true)
it "should initialize count and fetched to 0", ->
@worker.start()
nextState = @worker.state()
for collection in ['contacts','threads','files']
expect(nextState[collection].fetched).toEqual(0)
expect(nextState[collection].count).toEqual(0)
it "should periodically try to restart failed collection syncs", ->
spyOn(@worker, 'resumeFetches').andCallThrough()
@worker.start()
advanceClock(50000)
expect(@worker.resumeFetches.callCount).toBe(2)
describe "when a count request completes", ->
beforeEach ->
@worker.start()
@request = @apiRequests[0]
@apiRequests = []
it "should update the count on the collection", ->
@request.requestOptions.success({count: 1001})
nextState = @worker.state()
expect(nextState.threads.count).toEqual(1001)
describe "resumeFetches", ->
it "should fetch collections", ->
spyOn(@worker, 'fetchCollection')
@worker.resumeFetches()
expect(@worker.fetchCollection.calls.map (call) -> call.args[0]).toEqual(['threads', 'calendars', 'contacts', 'files'])
describe "fetchCollection", ->
beforeEach ->
@apiRequests = []
it "should not start if the collection sync is already in progress", ->
@worker._state.threads = {
'busy': true
'complete': false
}
@worker.fetchCollection('threads')
expect(@apiRequests.length).toBe(0)
it "should not start if the collection sync is already complete", ->
@worker._state.threads = {
'busy': false
'complete': true
}
@worker.fetchCollection('threads')
expect(@apiRequests.length).toBe(0)
it "should start the request for the model count", ->
@worker._state.threads = {
'busy': false
'complete': false
}
@worker.fetchCollection('threads')
expect(@apiRequests[0].requestOptions.path).toBe('/n/namespace-id/threads')
expect(@apiRequests[0].requestOptions.qs.view).toBe('count')
it "should start the first request for models", ->
@worker._state.threads = {
'busy': false
'complete': false
}
@worker.fetchCollection('threads')
expect(@apiRequests[1].model).toBe('threads')
expect(@apiRequests[1].params.offset).toBe(0)
describe "when an API request completes", ->
beforeEach ->
@worker.start()
@request = @apiRequests[1]
@apiRequests = []
describe "successfully, with models", ->
it "should request the next page", ->
pageSize = @request.params.limit
models = []
models.push(new Thread) for i in [0..249]
models.push(new Thread) for i in [0..(pageSize-1)]
@request.requestOptions.success(models)
expect(@apiRequests.length).toBe(1)
expect(@apiRequests[0].params).toEqual({limit:250; offset: 250})
expect(@apiRequests[0].params).toEqual
limit: pageSize,
offset: @request.params.offset + pageSize
it "should update the fetched count on the collection", ->
expect(@worker.state().threads.fetched).toEqual(0)
pageSize = @request.params.limit
models = []
models.push(new Thread) for i in [0..(pageSize-1)]
@request.requestOptions.success(models)
expect(@worker.state().threads.fetched).toEqual(pageSize)
describe "successfully, with fewer models than requested", ->
beforeEach ->
@ -71,17 +163,14 @@ describe "NylasSyncWorker", ->
@request.requestOptions.success(models)
it "should not request another page", ->
@request.requestOptions.success([])
expect(@apiRequests.length).toBe(0)
it "should update the state to complete", ->
@request.requestOptions.success([])
expect(@state).toEqual({
"contacts": {busy: true}
"files": {busy: true}
"threads": {complete : true}
"calendars": {complete: true}
})
expect(@worker.state().threads.busy).toEqual(false)
expect(@worker.state().threads.complete).toEqual(true)
it "should update the fetched count on the collection", ->
expect(@worker.state().threads.fetched).toEqual(101)
describe "successfully, with no models", ->
it "should not request another page", ->
@ -90,23 +179,16 @@ describe "NylasSyncWorker", ->
it "should update the state to complete", ->
@request.requestOptions.success([])
expect(@state).toEqual({
"contacts": {busy: true}
"files": {busy: true}
"threads": {complete : true}
"calendars": {complete: true}
})
expect(@worker.state().threads.busy).toEqual(false)
expect(@worker.state().threads.complete).toEqual(true)
describe "with an error", ->
it "should log the error to the state", ->
err = new Error("Oh no a network error")
@request.requestOptions.error(err)
expect(@state).toEqual({
"contacts": {busy: true}
"files": {busy: true}
"threads": {busy: false, error: err.toString()}
"calendars": {complete: true}
})
expect(@worker.state().threads.busy).toEqual(false)
expect(@worker.state().threads.complete).toEqual(false)
expect(@worker.state().threads.error).toEqual(err.toString())
it "should not request another page", ->
@request.requestOptions.error(new Error("Oh no a network error"))
@ -117,3 +199,9 @@ describe "NylasSyncWorker", ->
spyOn(@connection, 'end')
@worker.cleanup()
expect(@connection.end).toHaveBeenCalled()
it "should stop trying to restart failed collection syncs", ->
spyOn(@worker, 'resumeFetches').andCallThrough()
@worker.cleanup()
advanceClock(50000)
expect(@worker.resumeFetches.callCount).toBe(0)

View file

@ -140,10 +140,6 @@ describe "SendDraftTask", ->
waitsForPromise => @task.performRemote().then ->
expect(atom.playSound).toHaveBeenCalledWith("mail_sent.ogg")
it "post a notification", ->
waitsForPromise => @task.performRemote().then ->
expect(Actions.postNotification).toHaveBeenCalled()
it "should start an API request to /send", ->
waitsForPromise =>
@task.performRemote().then =>

View file

@ -117,6 +117,8 @@ beforeEach ->
spyOn(_._, "now").andCallFake -> window.now
spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout
spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout
spyOn(window, "setInterval").andCallFake window.fakeSetInterval
spyOn(window, "clearInterval").andCallFake window.fakeClearInterval
atom.packages.packageStates = {}

View file

@ -52,7 +52,7 @@ class NylasAPI
return unless atom.isMainWindow()
namespaces = NamespaceStore.items()
workers = _.map(namespaces, @_workerForNamespace)
workers = _.map(namespaces, @workerForNamespace)
# Stop the workers that are not in the new workers list.
# These namespaces are no longer in our database, so we shouldn't
@ -62,12 +62,10 @@ class NylasAPI
@_workers = workers
_cleanupNamespaceWorkers: ->
for worker in @_workers
worker.cleanup()
@_workers = []
workers: =>
@_workers
_workerForNamespace: (namespace) =>
workerForNamespace: (namespace) =>
worker = _.find @_workers, (c) ->
c.namespaceId() is namespace.id
return worker if worker
@ -91,6 +89,12 @@ class NylasAPI
worker.start()
worker
_cleanupNamespaceWorkers: ->
for worker in @_workers
worker.cleanup()
@_workers = []
# Delegates to node's request object.
# On success, it will call the passed in success callback with options.
# On error it will create a new APIError object that wraps the error,

View file

@ -1,11 +1,17 @@
_ = require 'underscore-plus'
NylasLongConnection = require './nylas-long-connection'
{Publisher} = require './modules/reflux-coffee'
CoffeeHelpers = require './coffee-helpers'
PAGE_SIZE = 250
module.exports =
class NylasSyncWorker
@include: CoffeeHelpers.includeModule
@include Publisher
constructor: (api, namespaceId) ->
@_api = api
@_namespaceId = namespaceId
@ -13,6 +19,9 @@ class NylasSyncWorker
@_terminated = false
@_connection = new NylasLongConnection(api, namespaceId)
@_state = atom.config.get("nylas.#{namespaceId}.worker-state") ? {}
for model, modelState of @_state
modelState.busy = false
@
namespaceId: ->
@ -21,53 +30,80 @@ class NylasSyncWorker
connection: ->
@_connection
state: ->
@_state
start: ->
@_resumeTimer = setInterval(@resumeFetches, 20000)
@_connection.start()
@resumeFetches()
cleanup: ->
clearInterval(@_resumeTimer)
@_connection.end()
@_terminated = true
@
resumeFetches: =>
@fetchCollection('threads')
@fetchCollection('calendars')
@fetchCollection('contacts')
@fetchCollection('files')
cleanup: ->
@_connection.end()
@_terminated = true
@
fetchCollection: (model, options = {}, callback) ->
fetchCollection: (model, options = {}) ->
return if @_state[model]?.complete and not options.force?
return if @_state[model]?.busy
@_state[model] = {busy: true}
@_state[model] =
complete: false
error: null
busy: true
count: 0
fetched: 0
@writeState()
params =
offset: 0
limit: PAGE_SIZE
@fetchCollectionPage(model, params, callback)
@fetchCollectionCount(model)
@fetchCollectionPage(model, {offset: 0, limit: PAGE_SIZE})
fetchCollectionCount: (model) ->
@_api.makeRequest
path: "/n/#{@_namespaceId}/#{model}"
returnsModel: false
qs:
view: 'count'
success: (response) =>
return if @_terminated
@updateTransferState(model, count: response.count)
error: (err) =>
return if @_terminated
fetchCollectionPage: (model, params = {}, callback) ->
fetchCollectionPage: (model, params = {}) ->
requestOptions =
error: (err) =>
return if @_terminated
@_state[model] = {busy: false, error: err.toString()}
@writeState()
callback(err) if callback
@updateTransferState(model, {busy: false, complete: false, error: err.toString()})
success: (json) =>
return if @_terminated
lastReceivedIndex = params.offset + json.length
if json.length is params.limit
params.offset = params.offset + json.length
@fetchCollectionPage(model, params, callback)
nextParams = _.extend({}, params, {offset: lastReceivedIndex})
@fetchCollectionPage(model, nextParams)
@updateTransferState(model, {fetched: lastReceivedIndex})
else
@_state[model] = {complete: true}
@writeState()
callback() if callback
@updateTransferState(model, {fetched: lastReceivedIndex, busy: false, complete: true})
if model is 'threads'
@_api.getThreads(@_namespaceId, params, requestOptions)
else
@_api.getCollection(@_namespaceId, model, params, requestOptions)
updateTransferState: (model, {busy, error, complete, fetched, count}) ->
@_state[model] = _.defaults({busy, error, complete, fetched, count}, @_state[model])
@writeState()
writeState: ->
@_writeState ?= _.debounce =>
atom.config.set("nylas.#{@_namespaceId}.worker-state", @_state)
,100
@_writeState()
@trigger()

View file

@ -100,6 +100,9 @@ class TaskQueue
performedRemote: false
notifiedOffline: false
queue: =>
@_queue
findTask: ({object, matchKey, matchValue}) ->
for other in @_queue by -1
if object is object and other[matchKey] is matchValue

View file

@ -13,7 +13,8 @@ class AddRemoveTagsTask extends Task
constructor: (@thread, @tagIdsToAdd = [], @tagIdsToRemove = []) ->
super
tagForId: (id) ->
label: ->
"Applying tags..."
performLocal: (versionIncrement = 1) ->
new Promise (resolve, reject) =>

View file

@ -1,4 +1,5 @@
fs = require 'fs'
_ = require 'underscore-plus'
pathUtils = require 'path'
Task = require './task'
File = require '../models/file'
@ -122,7 +123,7 @@ class FileUploadTask extends Task
fileName: pathUtils.basename(@filePath)
@_memoUploadData.bytesUploaded = @_getBytesUploaded()
@_memoUploadData.state = state if state?
return @_memoUploadData
return _.extend({}, @_memoUploadData)
_getFileSize: (path) ->
fs.statSync(path)["size"]

View file

@ -14,6 +14,9 @@ class SendDraftTask extends Task
constructor: (@draftLocalId, {@fromPopout}={}) ->
super
label: ->
"Sending draft..."
shouldDequeueOtherTask: (other) ->
other instanceof SendDraftTask and other.draftLocalId is @draftLocalId
@ -25,7 +28,6 @@ class SendDraftTask extends Task
# it actually succeeds. We don't want users to think messages have
# already sent when they haven't!
return Promise.reject("Attempt to call SendDraftTask.performLocal without @draftLocalId") unless @draftLocalId
Actions.postNotification({message: "Sending message…", type: 'info'})
Promise.resolve()
@ -59,7 +61,6 @@ class SendDraftTask extends Task
_onSendDraftSuccess: (draft, resolve, reject) => (newMessage) =>
newMessage = (new Message).fromJSON(newMessage)
atom.playSound('mail_sent.ogg')
Actions.postNotification({message: "Sent!", type: 'success'})
Actions.sendDraftSuccess
draftLocalId: @draftLocalId
newMessage: newMessage