feat(counts): Unread counts for all folders and labels across all accounts

Summary:
This diff replaces the UnreadCountStore with a better approach that is able to track unread counts for all folders/labels without continuous (and cripplingly slow) SELECT COUNT(*) queries.

When models are written to the database, we currently don't send out notifications with the "previous" state of those objects in the database. This makes it hard to determine how to update counters. (In the future, we may need to do this for live queries). Unfortunately, getting the "previous" state is going to be very hard, because multiple windows write to the database and the "previous" state we have might be outdated. We'd almost have to run a "SELECT" right before every "REPLACE INTO".

I created an API that allows you to register observers around persistModel and unpersistModel. With this API, you can run queries before and after the database changes are made and pluck just the "before" state you're interested in.

The `ThreadCountsStore` uses this API to determine the impact of persisting a set of threads on the unread counts of different labels. Before the threads are saved, it says "how much do these thread IDs contribute to unread counts currently?". After the write is complete it looks at the models and computes the difference between the old count impact and the new count impact, and updates the counters.

I decided not to attach the unread count to the Label objects themselves because 1) they update frequently and 2) most things observing the DatabaseStore for categories do not care about counts, so they would be updating unnecessarily.

The AccountSidebar now listens to the ThreadCountsStore as well as the CategoryStore, and there's a new preference in the General tab for turning off the counts.

Test Plan: Tests are a work in progress, want to get feedback first!

Reviewers: juan, evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2232
This commit is contained in:
Ben Gotow 2015-11-23 17:12:22 -08:00
parent c1d023a572
commit e1882ab61a
20 changed files with 679 additions and 295 deletions

View file

@ -2,7 +2,7 @@ React = require 'react'
classNames = require 'classnames'
{Actions,
Utils,
UnreadCountStore,
ThreadCountsStore,
WorkspaceStore,
AccountStore,
FocusedMailViewStore,
@ -17,29 +17,16 @@ class AccountSidebarMailViewItem extends React.Component
@propTypes:
select: React.PropTypes.bool
item: React.PropTypes.object.isRequired
itemUnreadCount: React.PropTypes.number
mailView: React.PropTypes.object.isRequired
constructor: (@props) ->
@state =
unreadCount: UnreadCountStore.count() ? 0
componentWillMount: =>
@_usub = UnreadCountStore.listen @_onUnreadCountChange
componentWillUnmount: =>
@_usub()
_onUnreadCountChange: =>
@setState unreadCount: UnreadCountStore.count()
@state = {}
shouldComponentUpdate: (nextProps, nextState) =>
!Utils.isEqualReact(@props, nextProps) or !Utils.isEqualReact(@state, nextState)
render: =>
unread = []
if @props.mailView.category?.name is "inbox" and @state.unreadCount > 0
unread = <div className="unread item-count-box">{@state.unreadCount}</div>
containerClass = classNames
'item': true
'selected': @props.select
@ -51,12 +38,17 @@ class AccountSidebarMailViewItem extends React.Component
shouldAcceptDrop={@_shouldAcceptDrop}
onDragStateChange={ ({isDropping}) => @setState({isDropping}) }
onDrop={@_onDrop}>
{unread}
{@_renderUnreadCount()}
<div className="icon">{@_renderIcon()}</div>
<div className="name">{@props.item.name}</div>
</DropZone>
_renderUnreadCount: =>
return false if @props.itemUnreadCount is 0
className = 'item-count-box '
className += @props.mailView.category?.name
<div className={className}>{@props.itemUnreadCount}</div>
_renderIcon: ->
<RetinaImg name={@props.mailView.iconName} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />

View file

@ -1,6 +1,6 @@
React = require 'react'
_ = require 'underscore'
{Actions, MailViewFilter, WorkspaceStore} = require("nylas-exports")
{Actions, MailViewFilter, WorkspaceStore, ThreadCountsStore} = require("nylas-exports")
{ScrollRegion, Flexbox} = require("nylas-component-kit")
SidebarDividerItem = require("./account-sidebar-divider-item")
SidebarSheetItem = require("./account-sidebar-sheet-item")
@ -38,9 +38,12 @@ class AccountSidebar extends React.Component
componentDidMount: =>
@unsubscribers = []
@unsubscribers.push AccountSidebarStore.listen @_onStoreChange
@unsubscribers.push ThreadCountsStore.listen @_onStoreChange
@configSubscription = NylasEnv.config.observe('core.workspace.showUnreadForAllCategories', @_onStoreChange)
componentWillUnmount: =>
unsubscribe() for unsubscribe in @unsubscribers
@configSubscription?.dispose()
render: =>
<ScrollRegion style={flex:1} id="account-sidebar">
@ -79,6 +82,12 @@ class AccountSidebar extends React.Component
components
_itemUnreadCount: (item) =>
category = item.mailViewFilter.category
if category and (category.name is 'inbox' or @state.unreadCountsForAll)
return @state.unreadCounts[category.id]
return 0
_itemComponent: (item) =>
unless item instanceof WorkspaceStore.SidebarItem
throw new Error("AccountSidebar:_itemComponents: sections contained an \
@ -93,6 +102,7 @@ class AccountSidebar extends React.Component
else if item.mailViewFilter
<AccountSidebarMailViewItem
item={item}
itemUnreadCount={@_itemUnreadCount(item)}
mailView={item.mailViewFilter}
select={item.mailViewFilter.isEqual(@state.selected)} />
@ -117,6 +127,8 @@ class AccountSidebar extends React.Component
_getStateFromStores: =>
sections: AccountSidebarStore.sections()
selected: AccountSidebarStore.selected()
unreadCounts: ThreadCountsStore.unreadCounts()
unreadCountsForAll: NylasEnv.config.get('core.workspace.showUnreadForAllCategories')
module.exports = AccountSidebar

View file

@ -87,15 +87,21 @@
order: 3;
flex-shrink: 0;
font-weight: @font-weight-semi-bold;
color: @source-list-active-bg;
color: fadeout(@text-color-subtle, 50%);
margin-left: @padding-small-horizontal * 0.8;
box-shadow: inset 0 0 1px @text-color-subtle;
}
.unread {
.item-count-box.inbox {
color: @source-list-active-bg;
background: @source-list-active-color;
box-shadow: none;
}
.count {
background: #b4bbc3;
.item-count-box.archive,
.item-count-box.all,
.item-count-box.spam {
display: none;
}
&.selected {
background: @source-list-active-bg;
color: @source-list-active-color;

View file

@ -105,6 +105,11 @@ class WorkspaceSection extends React.Component
keyPath="core.workspace.showImportant"
config={@props.config} />
<ConfigSchemaItem
configSchema={@props.configSchema.properties.workspace.properties.showUnreadForAllCategories}
keyPath="core.workspace.showUnreadForAllCategories"
config={@props.config} />
<div className="item">
<input type="checkbox"
id="dark"

View file

@ -2,7 +2,7 @@ import path from 'path';
import remote from 'remote';
import ipc from 'ipc';
import NylasStore from 'nylas-store';
import {UnreadCountStore, CanvasUtils} from 'nylas-exports';
import {UnreadBadgeStore, CanvasUtils} from 'nylas-exports';
const NativeImage = remote.require('native-image');
const Menu = remote.require('menu');
const {canvasWithSystemTrayIconAndText} = CanvasUtils;
@ -49,10 +49,10 @@ class TrayStore extends NylasStore {
this._unreadIcon = NativeImage.createFromPath(UNREAD_ICON_PATH);
this._baseIcon = NativeImage.createFromPath(BASE_ICON_PATH);
this._unreadCount = UnreadCountStore.count() || 0;
this._unreadCount = UnreadBadgeStore.count() || 0;
this._menu = _buildMenu(platform);
this._icon = this._getIconImg();
this.listenTo(UnreadCountStore, this._onUnreadCountChanged);
this.listenTo(UnreadBadgeStore, this._onUnreadCountChanged);
}
unreadCount() {
@ -96,7 +96,7 @@ class TrayStore extends NylasStore {
}
_onUnreadCountChanged() {
this._unreadCount = UnreadCountStore.count();
this._unreadCount = UnreadBadgeStore.count();
this._icon = this._getIconImg();
this.trigger();
}

View file

@ -108,7 +108,5 @@ TestModel.configureWithAdditionalSQLiteConfig = ->
TestModel.additionalSQLiteConfig =
setup: ->
['CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)']
writeModel: jasmine.createSpy('additionalWriteModel')
deleteModel: jasmine.createSpy('additionalDeleteModel')
module.exports = TestModel

View file

@ -119,24 +119,14 @@ describe "DatabaseStore", ->
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' ")
describe "persistModel", ->
it "should cause the DatabaseStore to trigger with a change that contains the model", ->
waitsForPromise ->
DatabaseStore.persistModel(testModelInstance).then ->
expect(DatabaseStore._accumulateAndTrigger).toHaveBeenCalled()
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'persist'})
.catch (err) ->
console.log err
it "should call through to _writeModels", ->
spyOn(DatabaseStore, '_writeModels').andReturn Promise.resolve()
DatabaseStore.persistModel(testModelInstance)
expect(DatabaseStore._writeModels.callCount).toBe(1)
it "should throw an exception if the model is not a subclass of Model", ->
expect(-> DatabaseStore.persistModel({id: 'asd', subject: 'bla'})).toThrow()
it "should call through to persistModels", ->
spyOn(DatabaseStore, 'persistModels').andReturn Promise.resolve()
DatabaseStore.persistModel(testModelInstance)
expect(DatabaseStore.persistModels.callCount).toBe(1)
describe "persistModels", ->
it "should cause the DatabaseStore to trigger with a change that contains the models", ->
waitsForPromise ->
@ -152,6 +142,7 @@ describe "DatabaseStore", ->
it "should call through to _writeModels after checking them", ->
spyOn(DatabaseStore, '_writeModels').andReturn Promise.resolve()
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
advanceClock()
expect(DatabaseStore._writeModels.callCount).toBe(1)
it "should throw an exception if the models are not the same class,\
@ -161,6 +152,72 @@ describe "DatabaseStore", ->
it "should throw an exception if the models are not a subclass of Model", ->
expect(-> DatabaseStore.persistModels([{id: 'asd', subject: 'bla'}])).toThrow()
describe "mutationHooks", ->
beforeEach ->
@beforeShouldThrow = false
@beforeShouldReject = false
@beforeDatabaseChange = jasmine.createSpy('beforeDatabaseChange').andCallFake =>
throw new Error("beforeShouldThrow") if @beforeShouldThrow
new Promise (resolve, reject) =>
setTimeout =>
return resolve(new Error("beforeShouldReject")) if @beforeShouldReject
resolve("value")
, 1000
@afterDatabaseChange = jasmine.createSpy('afterDatabaseChange').andCallFake =>
new Promise (resolve, reject) ->
setTimeout(( => resolve()), 1000)
@hook = {@beforeDatabaseChange, @afterDatabaseChange}
DatabaseStore.addMutationHook(@hook)
@writeModelsResolve = null
spyOn(DatabaseStore, '_writeModels').andCallFake =>
new Promise (resolve, reject) =>
@writeModelsResolve = resolve
afterEach ->
DatabaseStore.removeMutationHook(@hook)
it "should run pre-mutation hooks, wait to write models, and then run post-mutation hooks", ->
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
expect(@beforeDatabaseChange).toHaveBeenCalledWith(
DatabaseStore._query,
[testModelInstanceA, testModelInstanceB],
[testModelInstanceA.id, testModelInstanceB.id],
undefined
)
expect(DatabaseStore._writeModels).not.toHaveBeenCalled()
advanceClock(1100)
advanceClock()
expect(DatabaseStore._writeModels).toHaveBeenCalled()
expect(@afterDatabaseChange).not.toHaveBeenCalled()
@writeModelsResolve()
advanceClock()
advanceClock()
expect(@afterDatabaseChange).toHaveBeenCalledWith(
DatabaseStore._query,
[testModelInstanceA, testModelInstanceB],
[testModelInstanceA.id, testModelInstanceB.id],
"value"
)
it "should carry on if a pre-mutation hook throws", ->
@beforeShouldThrow = true
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
expect(@beforeDatabaseChange).toHaveBeenCalled()
advanceClock()
advanceClock()
expect(DatabaseStore._writeModels).toHaveBeenCalled()
it "should carry on if a pre-mutation hook rejects", ->
@beforeShouldReject = true
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB])
expect(@beforeDatabaseChange).toHaveBeenCalled()
advanceClock()
advanceClock()
expect(DatabaseStore._writeModels).toHaveBeenCalled()
describe "unpersistModel", ->
it "should delete the model by Id", -> waitsForPromise =>
DatabaseStore.unpersistModel(testModelInstance).then =>
@ -176,20 +233,6 @@ describe "DatabaseStore", ->
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'unpersist'})
describe "when the model provides additional sqlite config", ->
beforeEach ->
TestModel.configureWithAdditionalSQLiteConfig()
it "should call the deleteModel method and provide the model", ->
waitsForPromise ->
DatabaseStore.unpersistModel(testModelInstance).then ->
expect(TestModel.additionalSQLiteConfig.deleteModel).toHaveBeenCalled()
expect(TestModel.additionalSQLiteConfig.deleteModel.mostRecentCall.args[0]).toBe(testModelInstance)
it "should not fail if additional config is present, but deleteModel is not defined", ->
delete TestModel.additionalSQLiteConfig['deleteModel']
expect( => DatabaseStore.unpersistModel(testModelInstance)).not.toThrow()
describe "when the model has collection attributes", ->
it "should delete all of the elements in the join tables", ->
TestModel.configureWithCollectionAttribute()
@ -315,20 +358,6 @@ describe "DatabaseStore", ->
DatabaseStore._writeModels([@m])
expect(@performed.length).toBe(1)
describe "when the model provides additional sqlite config", ->
beforeEach ->
TestModel.configureWithAdditionalSQLiteConfig()
it "should call the writeModel method and provide the model", ->
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
DatabaseStore._writeModels([@m])
expect(TestModel.additionalSQLiteConfig.writeModel).toHaveBeenCalledWith(@m)
it "should not fail if additional config is present, but writeModel is not defined", ->
delete TestModel.additionalSQLiteConfig['writeModel']
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
expect( => DatabaseStore._writeModels([@m])).not.toThrow()
describe "atomically", ->
beforeEach ->
DatabaseStore._atomicPromise = null

View file

@ -0,0 +1,246 @@
_ = require 'underscore'
DatabaseStore = require '../../src/flux/stores/database-store'
ThreadCountsStore = require '../../src/flux/stores/thread-counts-store'
Thread = require '../../src/flux/models/thread'
Folder = require '../../src/flux/models/folder'
Label = require '../../src/flux/models/label'
Matcher = require '../../src/flux/attributes/matcher'
describe "ThreadCountsStore", ->
describe "unreadCountForCategoryId", ->
it "returns null if no count exists for the category id", ->
expect(ThreadCountsStore.unreadCountForCategoryId('nan')).toBe(null)
it "returns the count plus any unsaved deltas", ->
ThreadCountsStore._counts =
'b': 3
'a': 5
ThreadCountsStore._deltas =
'a': -1
expect(ThreadCountsStore.unreadCountForCategoryId('a')).toBe(4)
expect(ThreadCountsStore.unreadCountForCategoryId('b')).toBe(3)
describe "when the mutation observer reports count changes", ->
it "should merge count deltas into existing count detlas", ->
ThreadCountsStore._deltas =
'l1': -1
'l2': 2
ThreadCountsStore._onCountsChanged({'l1': -1, 'l2': 1, 'l3': 2})
expect(ThreadCountsStore._deltas).toEqual({
'l1': -2,
'l2': 3,
'l3': 2
})
it "should queue a save of the counts", ->
spyOn(ThreadCountsStore, '_saveCountsSoon')
ThreadCountsStore._onCountsChanged({'l1': -1, 'l2': 1, 'l3': 2})
expect(ThreadCountsStore._saveCountsSoon).toHaveBeenCalled()
describe "when a folder or label is persisted", ->
beforeEach ->
@lExisting = new Label(id: "l1", name: "inbox", displayName: "Inbox")
ThreadCountsStore._categories = [@lExisting]
@lCreated = new Label(id: "lNew", displayName: "Hi there!")
@lUpdated = @lExisting.clone()
@lUpdated.displayName = "Inbox Edited"
spyOn(ThreadCountsStore, '_fetchCountsMissing')
describe "in the work window", ->
beforeEach ->
spyOn(NylasEnv, 'isWorkWindow').andReturn(true)
it "should add or update it in it's local categories cache", ->
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated]})
expect(ThreadCountsStore._categories).toEqual([@lExisting, @lCreated])
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lUpdated]})
expect(ThreadCountsStore._categories).toEqual([@lUpdated, @lCreated])
ThreadCountsStore._categories = []
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated, @lUpdated]})
expect(ThreadCountsStore._categories).toEqual([@lCreated, @lUpdated])
it "should run _fetchCountsMissing", ->
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lUpdated]})
expect(ThreadCountsStore._fetchCountsMissing).toHaveBeenCalled()
describe "in other windows", ->
beforeEach ->
spyOn(NylasEnv, 'isWorkWindow').andReturn(false)
it "should do nothing", ->
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated]})
expect(ThreadCountsStore._categories).toEqual([@lExisting])
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
describe "when counts are persisted", ->
it "should update it's _counts cache and trigger", ->
newCounts = {
'abc': 1
}
spyOn(ThreadCountsStore, 'trigger')
ThreadCountsStore._onDatabaseChanged({objectClass: 'JSONObject', objects: [{key: 'UnreadCounts', json: newCounts}]})
expect(ThreadCountsStore._counts).toEqual(newCounts)
expect(ThreadCountsStore.trigger).toHaveBeenCalled()
describe "_fetchCountsMissing", ->
beforeEach ->
ThreadCountsStore._categories = [
new Label(id: "l1", name: "inbox", displayName: "Inbox", accountId: 'a1'),
new Label(id: "l2", name: "archive", displayName: "Archive", accountId: 'a1'),
new Label(id: "l3", displayName: "Happy Days", accountId: 'a1'),
new Label(id: "l4", displayName: "Sad Days", accountId: 'a1')
]
ThreadCountsStore._counts =
l1: 10
l2: 0
it "should call _fetchCountForCategory for the first category not already in the counts cache", ->
spyOn(ThreadCountsStore, '_fetchCountForCategory').andCallFake ->
new Promise (resolve, reject) ->
ThreadCountsStore._fetchCountsMissing()
calls = ThreadCountsStore._fetchCountForCategory.calls
expect(calls.length).toBe(1)
expect(calls[0].args[0]).toBe(ThreadCountsStore._categories[2])
describe "when the count promsie finishes", ->
beforeEach ->
@countResolve = null
@countReject = null
spyOn(ThreadCountsStore, '_fetchCountForCategory').andCallFake =>
new Promise (resolve, reject) =>
@countResolve = resolve
@countReject = reject
it "should add it to the count cache", ->
ThreadCountsStore._fetchCountsMissing()
advanceClock()
@countResolve(4)
advanceClock()
expect(ThreadCountsStore._counts).toEqual({
l1: 10
l2: 0
l3: 4
})
it "should call _fetchCountsMissing again to populate the next missing count", ->
ThreadCountsStore._fetchCountsMissing()
spyOn(ThreadCountsStore, '_fetchCountsMissing')
advanceClock()
@countResolve(4)
advanceClock()
expect(ThreadCountsStore._fetchCountsMissing).toHaveBeenCalled()
describe "when a count fails", ->
it "should not immediately try to count any other categories", ->
ThreadCountsStore._fetchCountsMissing()
spyOn(ThreadCountsStore, '_fetchCountsMissing')
spyOn(console, 'error')
advanceClock()
@countReject(new Error("Oh man something really bad."))
advanceClock()
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
describe "_fetchCountForCategory", ->
it "should make the appropriate label or folder database query", ->
spyOn(DatabaseStore, 'count')
Matcher.muid = 0
ThreadCountsStore._fetchCountForCategory(new Label(id: 'l1', accountId: 'a1'))
Matcher.muid = 0
expect(DatabaseStore.count).toHaveBeenCalledWith(Thread, [
Thread.attributes.accountId.equal('a1'),
Thread.attributes.unread.equal(true),
Thread.attributes.labels.contains('l1')
])
Matcher.muid = 0
ThreadCountsStore._fetchCountForCategory(new Folder(id: 'l1', accountId: 'a1'))
Matcher.muid = 0
expect(DatabaseStore.count).toHaveBeenCalledWith(Thread, [
Thread.attributes.accountId.equal('a1'),
Thread.attributes.unread.equal(true),
Thread.attributes.folders.contains('l1')
])
describe "_saveCounts", ->
beforeEach ->
ThreadCountsStore._counts =
'b': 3
'a': 5
ThreadCountsStore._deltas =
'a': -1
'c': 2
it "should merge the deltas into the counts and reset the deltas, ignoring any deltas for which the initial count has not been run", ->
ThreadCountsStore._saveCounts()
expect(ThreadCountsStore._counts).toEqual({
'b': 3
'a': 4
})
it "should persist the new counts to the database", ->
spyOn(DatabaseStore, 'persistJSONObject')
ThreadCountsStore._saveCounts()
expect(DatabaseStore.persistJSONObject).toHaveBeenCalledWith('UnreadCounts', ThreadCountsStore._counts)
describe "CategoryDatabaseMutationObserver", ->
beforeEach ->
@label1 = new Label(id: "l1", name: "inbox", displayName: "Inbox")
@label2 = new Label(id: "l2", name: "archive", displayName: "Archive")
@label3 = new Label(id: "l3", displayName: "Happy Days")
@label4 = new Label(id: "l4", displayName: "Sad Days")
@threadA = new Thread
id: "A"
unread: true
labels: [@label1, @label4]
@threadB = new Thread
id: "B"
unread: true
labels: [@label3]
@threadC = new Thread
id: "C"
unread: false
labels: [@label1, @label3]
describe "given a set of modifying models", ->
it "should call countsDidChange with the folder / label membership deltas", ->
queryResolves = []
query = jasmine.createSpy('query').andCallFake =>
new Promise (resolve, reject) ->
queryResolves.push(resolve)
countsDidChange = jasmine.createSpy('countsDidChange')
m = new ThreadCountsStore.CategoryDatabaseMutationObserver(countsDidChange)
beforePromise = m.beforeDatabaseChange(query, [@threadA, @threadB, @threadC], [@threadA.id, @threadB.id, @threadC.id])
expect(query.callCount).toBe(2)
expect(query.calls[0].args[0]).toEqual("SELECT `Thread`.id as id, `Thread-Label`.`value` as catId FROM `Thread` INNER JOIN `Thread-Label` ON `Thread`.`id` = `Thread-Label`.`id` WHERE `Thread`.id IN ('A','B','C') AND `Thread`.unread = 1")
expect(query.calls[1].args[0]).toEqual("SELECT `Thread`.id as id, `Thread-Folder`.`value` as catId FROM `Thread` INNER JOIN `Thread-Folder` ON `Thread`.`id` = `Thread-Folder`.`id` WHERE `Thread`.id IN ('A','B','C') AND `Thread`.unread = 1")
queryResolves[0]([
{id: @threadA.id, catId: @label1.id},
{id: @threadA.id, catId: @label3.id},
{id: @threadB.id, catId: @label2.id},
{id: @threadB.id, catId: @label3.id},
])
queryResolves[1]([])
waitsForPromise =>
beforePromise.then (result) =>
expect(result).toEqual({
categories: {
l1: -1,
l3: -2,
l2: -1
}
})
m.afterDatabaseChange(query, [@threadA, @threadB, @threadC], [@threadA.id, @threadB.id, @threadC.id], result)
expect(countsDidChange).toHaveBeenCalledWith({
l3: -1,
l2: -1,
l4: 1
})

View file

@ -0,0 +1,15 @@
Label = require '../../src/flux/models/label'
UnreadBadgeStore = require '../../src/flux/stores/unread-badge-store'
describe "UnreadBadgeStore", ->
describe "_setBadgeForCount", ->
it "should set the badge correctly", ->
spyOn(UnreadBadgeStore, '_setBadge')
UnreadBadgeStore._setBadgeForCount(0)
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("")
UnreadBadgeStore._setBadgeForCount(1)
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("1")
UnreadBadgeStore._setBadgeForCount(100)
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("100")
UnreadBadgeStore._setBadgeForCount(1000)
expect(UnreadBadgeStore._setBadge).toHaveBeenCalledWith("999+")

View file

@ -1,75 +0,0 @@
UnreadCountStore = require '../../src/flux/stores/unread-count-store'
AccountStore = require '../../src/flux/stores/account-store'
DatabaseStore = require '../../src/flux/stores/database-store'
Folder = require '../../src/flux/models/folder'
Label = require '../../src/flux/models/label'
Thread = require '../../src/flux/models/thread'
Category = require '../../src/flux/models/category'
describe "UnreadCountStore", ->
describe "_fetchCount", ->
beforeEach ->
NylasEnv.testOrganizationUnit = 'folder'
spyOn(DatabaseStore, 'findBy').andCallFake =>
Promise.resolve(new Category({id: 'inbox-category-id'}))
spyOn(DatabaseStore, 'count').andCallFake =>
Promise.resolve(100)
it "should create the correct query when using folders", ->
NylasEnv.testOrganizationUnit = 'folder'
UnreadCountStore._fetchCount()
advanceClock()
expect(DatabaseStore.findBy).toHaveBeenCalledWith(Folder, {name: 'inbox', accountId: TEST_ACCOUNT_ID})
[Model, Matchers] = DatabaseStore.count.calls[0].args
expect(Model).toBe(Thread)
expect(Matchers[0].attr.modelKey).toBe('accountId')
expect(Matchers[1].attr.modelKey).toBe('unread')
expect(Matchers[1].val).toBe(true)
expect(Matchers[2].attr.modelKey).toBe('folders')
expect(Matchers[2].val).toBe('inbox-category-id')
it "should create the correct query when using labels", ->
NylasEnv.testOrganizationUnit = 'label'
UnreadCountStore._fetchCount()
advanceClock()
expect(DatabaseStore.findBy).toHaveBeenCalledWith(Label, {name: 'inbox', accountId: TEST_ACCOUNT_ID})
[Model, Matchers] = DatabaseStore.count.calls[0].args
expect(Matchers[0].attr.modelKey).toBe('accountId')
expect(Matchers[1].attr.modelKey).toBe('unread')
expect(Matchers[1].val).toBe(true)
expect(Matchers[2].attr.modelKey).toBe('labels')
expect(Matchers[2].val).toBe('inbox-category-id')
it "should not trigger if the unread count is the same", ->
spyOn(UnreadCountStore, 'trigger')
UnreadCountStore._count = 100
UnreadCountStore._fetchCount()
advanceClock()
expect(UnreadCountStore.trigger).not.toHaveBeenCalled()
UnreadCountStore._count = 101
UnreadCountStore._fetchCount()
advanceClock()
expect(UnreadCountStore.trigger).toHaveBeenCalled()
it "should update the badge count", ->
UnreadCountStore._count = 101
spyOn(UnreadCountStore, '_updateBadgeForCount')
UnreadCountStore._fetchCount()
advanceClock()
expect(UnreadCountStore._updateBadgeForCount).toHaveBeenCalled()
describe "_updateBadgeForCount", ->
it "should set the badge correctly", ->
spyOn(UnreadCountStore, '_setBadge')
spyOn(NylasEnv, 'isMainWindow').andCallFake -> true
UnreadCountStore._updateBadgeForCount(0)
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("")
UnreadCountStore._updateBadgeForCount(1)
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("1")
UnreadCountStore._updateBadgeForCount(100)
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("100")
UnreadCountStore._updateBadgeForCount(1000)
expect(UnreadCountStore._setBadge).toHaveBeenCalledWith("999+")

View file

@ -18,6 +18,10 @@ module.exports =
type: 'boolean'
default: true
title: "Show Gmail-style important markers (Gmail Only)"
showUnreadForAllCategories:
type: 'boolean'
default: false
title: "Show unread counts for all folders / labels"
disabledPackages:
type: 'array'
default: []

View file

@ -32,6 +32,10 @@ class Category extends Model
modelKey: 'displayName'
jsonKey: 'display_name'
'unread': Attributes.Number
queryable: true
modelKey: 'unread'
hue: ->
return 0 unless @displayName
hue = 0

View file

@ -90,7 +90,11 @@ module.exports =
err = @validateListening(listenable)
throw err if err
@fetchInitialState listenable, defaultCallback
desub = listenable.listen(@[callback] or callback, this)
resolvedCallback = @[callback] or callback
if not resolvedCallback
throw new Error("@listenTo called with undefined callback")
desub = listenable.listen(resolvedCallback, this)
unsubscriber = ->
index = subs.indexOf(subscriptionobj)
@ -150,6 +154,9 @@ module.exports =
@_emitter.setMaxListeners(50)
listen: (callback, bindContext) ->
if not callback
throw new Error("@listen called with undefined callback")
@setupEmitter()
bindContext ?= @
aborted = false

View file

@ -90,6 +90,8 @@ class DatabaseStore extends NylasStore
else
@_databasePath = path.join(NylasEnv.getConfigDirPath(),'edgehill.db')
@_databaseMutationHooks = []
# Listen to events from the application telling us when the database is ready,
# should be closed so it can be deleted, etc.
ipc.on('database-phase-change', @_onPhaseChange)
@ -413,9 +415,7 @@ class DatabaseStore extends NylasStore
persistModel: (model) =>
unless model and model instanceof Model
throw new Error("DatabaseStore::persistModel - You must pass an instance of the Model class.")
@_writeModels([model]).then =>
@_accumulateAndTrigger({objectClass: model.constructor.name, objects: [model], type: 'persist'})
@persistModels([model])
# Public: Asynchronously writes `models` to the cache and triggers a single change
# event. Note: Models must be of the same class to be persisted in a batch operation.
@ -429,10 +429,11 @@ class DatabaseStore extends NylasStore
# callbacks failed
persistModels: (models=[]) =>
return Promise.resolve() if models.length is 0
klass = models[0].constructor
ids = {}
if not models[0] instanceof Model
unless models[0] instanceof Model
throw new Error("DatabaseStore::persistModels - You must pass an array of items which descend from the Model class.")
for model in models
@ -442,8 +443,15 @@ class DatabaseStore extends NylasStore
throw new Error("DatabaseStore::persistModels - You must pass an array of models with different ids. ID #{model.id} is in the set multiple times.")
ids[model.id] = true
@_writeModels(models).then =>
@_accumulateAndTrigger({objectClass: models[0].constructor.name, objects: models, type: 'persist'})
ids = Object.keys(ids)
@_runMutationHooks('beforeDatabaseChange', models, ids).then (data) =>
@_writeModels(models).then =>
@_runMutationHooks('afterDatabaseChange', models, ids, data)
@_accumulateAndTrigger({
objectClass: models[0].constructor.name
objects: models
type: 'persist'
})
# Public: Asynchronously removes `model` from the cache and triggers a change event.
#
@ -455,8 +463,14 @@ class DatabaseStore extends NylasStore
# - rejects if any databse query fails or one of the triggering
# callbacks failed
unpersistModel: (model) =>
@_deleteModel(model).then =>
@_accumulateAndTrigger({objectClass: model.constructor.name, objects: [model], type: 'unpersist'})
@_runMutationHooks('beforeDatabaseChange', [model], [model.id]).then (data) =>
@_deleteModel(model).then =>
@_runMutationHooks('afterDatabaseChange', [model], [model.id], data)
@_accumulateAndTrigger({
objectClass: model.constructor.name,
objects: [model],
type: 'unpersist'
})
persistJSONObject: (key, json) ->
jsonString = serializeRegisteredObjects(json)
@ -469,6 +483,24 @@ class DatabaseStore extends NylasStore
data = deserializeRegisteredObjects(results[0].data)
Promise.resolve(data)
addMutationHook: ({beforeDatabaseChange, afterDatabaseChange}) ->
throw new Error("DatabaseStore:addMutationHook - You must provide a beforeDatabaseChange function") unless beforeDatabaseChange
throw new Error("DatabaseStore:addMutationHook - You must provide a afterDatabaseChange function") unless afterDatabaseChange
@_databaseMutationHooks.push({beforeDatabaseChange, afterDatabaseChange})
removeMutationHook: (hook) ->
@_databaseMutationHooks = _.without(@_databaseMutationHooks, hook)
_runMutationHooks: (selectorName, models, ids, data = []) ->
beforePromises = @_databaseMutationHooks.map (hook, idx) =>
Promise.try =>
hook[selectorName](@_query, models, ids, data[idx])
Promise.all(beforePromises).catch (e) =>
unless NylasEnv.inSpecMode()
console.warn("DatabaseStore Hook: #{selectorName} failed", e)
Promise.resolve([])
atomically: (fn) =>
maxConcurrent = 1
maxQueue = Infinity
@ -545,7 +577,6 @@ class DatabaseStore extends NylasStore
klass = models[0].constructor
attributes = _.values(klass.attributes)
ids = []
columnAttributes = _.filter attributes, (attr) ->
attr.queryable && attr.columnSQL && attr.jsonKey != 'id'
@ -563,6 +594,7 @@ class DatabaseStore extends NylasStore
# an array of the values and a corresponding question mark set
values = []
marks = []
ids = []
for model in models
json = model.toJSON(joined: false)
ids.push(model.id)
@ -615,13 +647,6 @@ class DatabaseStore extends NylasStore
if model[attr.modelKey]?
promises.push @_query("REPLACE INTO `#{attr.modelTable}` (`id`, `value`) VALUES (?, ?)", [model.id, model[attr.modelKey]])
# For each model, execute any other code the model wants to run.
# This allows model classes to do things like update a full-text table
# that holds a composite of several fields
if klass.additionalSQLiteConfig?.writeModel?
for model in models
promises = promises.concat klass.additionalSQLiteConfig.writeModel(model)
return Promise.all(promises)
# Fires the queries required to delete models to the DB
@ -653,12 +678,6 @@ class DatabaseStore extends NylasStore
joinedDataAttributes.forEach (attr) =>
promises.push @_query("DELETE FROM `#{attr.modelTable}` WHERE `id` = ?", [model.id])
# Execute any other code the model wants to run.
# This allows model classes to do things like update a full-text table
# that holds a composite of several fields, or update entirely
# separate database systems
promises = promises.concat klass.additionalSQLiteConfig?.deleteModel?(model)
return Promise.all(promises)

View file

@ -0,0 +1,144 @@
Reflux = require 'reflux'
_ = require 'underscore'
NylasStore = require 'nylas-store'
CategoryStore = require './category-store'
AccountStore = require './account-store'
DatabaseStore = require './database-store'
Actions = require '../actions'
Thread = require '../models/thread'
Folder = require '../models/folder'
Label = require '../models/label'
class CategoryDatabaseMutationObserver
constructor: (@_countsDidChange) ->
beforeDatabaseChange: (query, models, ids) =>
if models[0].constructor.name is 'Thread'
idString = "'" + ids.join("','") + "'"
Promise.props
labelData: query("SELECT `Thread`.id as id, `Thread-Label`.`value` as catId FROM `Thread` INNER JOIN `Thread-Label` ON `Thread`.`id` = `Thread-Label`.`id` WHERE `Thread`.id IN (#{idString}) AND `Thread`.unread = 1", [])
folderData: query("SELECT `Thread`.id as id, `Thread-Folder`.`value` as catId FROM `Thread` INNER JOIN `Thread-Folder` ON `Thread`.`id` = `Thread-Folder`.`id` WHERE `Thread`.id IN (#{idString}) AND `Thread`.unread = 1", [])
.then ({labelData, folderData}) =>
categories = {}
for collection in [labelData, folderData]
for {id, catId} in collection
categories[catId] ?= 0
categories[catId] -= 1
Promise.resolve({categories})
else
Promise.resolve()
afterDatabaseChange: (query, models, ids, beforeResolveValue) =>
if models[0].constructor.name is 'Thread'
{categories} = beforeResolveValue
for thread in models
continue unless thread.unread
for collection in ['labels', 'folders']
if thread[collection]
for cat in thread[collection]
categories[cat.id] ?= 0
categories[cat.id] += 1
for key, val of categories
delete categories[key] if val is 0
if Object.keys(categories).length > 0
@_countsDidChange(categories)
Promise.resolve()
class ThreadCountsStore extends NylasStore
CategoryDatabaseMutationObserver: CategoryDatabaseMutationObserver
constructor: ->
@_counts = {}
@_deltas = {}
@_categories = []
@_saveCountsSoon ?= _.throttle(@_saveCounts, 1000)
@listenTo DatabaseStore, @_onDatabaseChanged
DatabaseStore.findJSONObject('UnreadCounts').then (json) =>
@_counts = json ? {}
@trigger()
if NylasEnv.isWorkWindow()
@_observer = new CategoryDatabaseMutationObserver(@_onCountsChanged)
DatabaseStore.addMutationHook(@_observer)
@_loadCategories().then =>
@_fetchCountsMissing()
unreadCountForCategoryId: (catId) =>
return null unless @_counts[catId]
@_counts[catId] + (@_deltas[catId] || 0)
unreadCounts: =>
@_counts
_onDatabaseChanged: (change) =>
if NylasEnv.isWorkWindow()
if change.objectClass in [Folder.name, Label.name]
for obj in change.objects
objIdx = _.findIndex @_categories, (cat) -> cat.id is obj.id
if objIdx isnt -1
@_categories[objIdx] = obj
else
@_categories.push(obj)
@_fetchCountsMissing()
if change.objectClass is 'JSONObject' and change.objects[0].key is 'UnreadCounts'
@_counts = change.objects[0].json ? {}
@trigger()
_onCountsChanged: (metadata) =>
for catId, unread of metadata
@_deltas[catId] ?= 0
@_deltas[catId] += unread
@_saveCountsSoon()
_loadCategories: =>
Promise.props({
folders: DatabaseStore.findAll(Folder)
labels: DatabaseStore.findAll(Label)
}).then ({folders, labels}) =>
@_categories = [].concat(folders, labels)
Promise.resolve()
_fetchCountsMissing: =>
# Find a category missing a count
category = _.find @_categories, (cat) => !@_counts[cat.id]?
return @_saveCountsSoon() unless category
# Fetch the count, populate it in the cache, and then call ourselves to
# populate the next missing count
@_fetchCountForCategory(category).then (unread) =>
@_counts[category.id] = unread
@_fetchCountsMissing()
# This method is not intended to return a promise and it
# could cause strange chaining.
return null
_saveCounts: =>
for key, count of @_deltas
continue if @_counts[key] is undefined
@_counts[key] += count
delete @_deltas[key]
DatabaseStore.persistJSONObject('UnreadCounts', @_counts)
_fetchCountForCategory: (cat) =>
if cat instanceof Label
categoryAttribute = Thread.attributes.labels
else if cat instanceof Folder
categoryAttribute = Thread.attributes.folders
else
throw new Error("Unexpected cat class")
DatabaseStore.count(Thread, [
Thread.attributes.accountId.equal(cat.accountId),
Thread.attributes.unread.equal(true),
categoryAttribute.contains(cat.id)
])
module.exports = new ThreadCountsStore

View file

@ -0,0 +1,61 @@
Reflux = require 'reflux'
_ = require 'underscore'
NylasStore = require 'nylas-store'
CategoryStore = require './category-store'
DatabaseStore = require './database-store'
ThreadCountsStore = require './thread-counts-store'
class UnreadBadgeStore extends NylasStore
constructor: ->
@listenTo CategoryStore, @_onCategoriesChanged
@listenTo ThreadCountsStore, @_onCountsChanged
@_category = CategoryStore.getStandardCategory('inbox')
NylasEnv.config.observe 'core.showUnreadBadge', (val) =>
if val is true
@_setBadgeForCount(@_count)
else
@_setBadge("")
@_updateCount()
# Public: Returns the number of unread threads in the user's mailbox
count: ->
@_count
_onCategoriesChanged: =>
cat = CategoryStore.getStandardCategory('inbox')
return if @_category and cat.id is @_category.id
@_category = cat
@_updateCount()
_onCountsChanged: =>
@_updateCount()
_updateCount: =>
return unless NylasEnv.isMainWindow()
return unless @_category
count = ThreadCountsStore.unreadCountForCategoryId(@_category.id) ? 0
return if @_count is count
@_count = count
@_setBadgeForCount(count)
@trigger()
_setBadgeForCount: (count) =>
if count > 999
@_setBadge("999+")
else if count > 0
@_setBadge("#{count}")
else
@_setBadge("")
_setBadge: (val) =>
# NOTE: Do not underestimate how long this can take. It's a synchronous
# remote call and can take ~50+msec.
return if NylasEnv.config.get('core.showUnreadBadge') is false
require('ipc').send('set-badge-value', val)
module.exports = new UnreadBadgeStore()

View file

@ -1,92 +0,0 @@
Reflux = require 'reflux'
_ = require 'underscore'
CategoryStore = require './category-store'
AccountStore = require './account-store'
DatabaseStore = require './database-store'
Actions = require '../actions'
Thread = require '../models/thread'
Folder = require '../models/folder'
Label = require '../models/label'
###
Public: The UnreadCountStore exposes a simple API for getting the number of
unread threads in the user's inbox. If you plugin needs the current unread count,
it's more efficient to observe the UnreadCountStore than retrieve the value
yourself from the database.
###
UnreadCountStore = Reflux.createStore
init: ->
@listenTo AccountStore, @_onAccountChanged
@listenTo DatabaseStore, @_onDataChanged
NylasEnv.config.observe 'core.notifications.unreadBadge', (val) =>
if val is true
@_updateBadgeForCount()
else
@_setBadge("")
@_count = null
@_fetchCountDebounced ?= _.debounce(@_fetchCount, 5000)
_.defer => @_fetchCount()
# Public: Returns the number of unread threads in the user's mailbox
count: ->
@_count
_onAccountChanged: ->
@_count = 0
@_updateBadgeForCount(0)
@trigger()
@_fetchCount()
_onDataChanged: (change) ->
if change && change.objectClass is Thread.name
@_fetchCountDebounced()
_fetchCount: ->
account = AccountStore.current()
return @_setBadge("") unless account
if account.usesFolders()
[CategoryClass, CategoryAttribute] = [Folder, Thread.attributes.folders]
else if account.usesLabels()
[CategoryClass, CategoryAttribute] = [Label, Thread.attributes.labels]
else
return
# Note: We can't use the convenience methods on CategoryStore to fetch the
# category because it may not have been loaded yet
DatabaseStore.findBy(CategoryClass, {name: 'inbox', accountId: account.id}).then (category) =>
return unless category
matchers = [
Thread.attributes.accountId.equal(account.id),
Thread.attributes.unread.equal(true),
CategoryAttribute.contains(category.id)
]
DatabaseStore.count(Thread, matchers).then (count) =>
return if @_count is count
@_count = count
@_updateBadgeForCount(count)
@trigger()
.catch (err) =>
console.warn("Failed to fetch unread count: #{err}")
_updateBadgeForCount: (count) ->
return unless NylasEnv.isMainWindow()
return if NylasEnv.config.get('core.notifications.unreadBadge') is false
if count > 999
@_setBadge("999+")
else if count > 0
@_setBadge("#{count}")
else
@_setBadge("")
_setBadge: (val) ->
# NOTE: Do not underestimate how long this can take. It's a synchronous
# remote call and can take ~50+msec.
ipc = require 'ipc'
ipc.send('set-badge-value', val)
module.exports = UnreadCountStore

View file

@ -104,7 +104,8 @@ class NylasExports
@require "WorkspaceStore", 'flux/stores/workspace-store'
@require "DraftCountStore", 'flux/stores/draft-count-store'
@require "FileUploadStore", 'flux/stores/file-upload-store'
@require "UnreadCountStore", 'flux/stores/unread-count-store'
@require "ThreadCountsStore", 'flux/stores/thread-counts-store'
@require "UnreadBadgeStore", 'flux/stores/unread-badge-store'
@require "FileDownloadStore", 'flux/stores/file-download-store'
@require "DraftStoreExtension", 'flux/stores/draft-store-extension'
@require "FocusedContentStore", 'flux/stores/focused-content-store'

View file

@ -10,14 +10,6 @@ setupGlobals = ->
trace: ->
global.__defineGetter__ 'console', -> console
fs = require 'fs'
fs.existsSync = (path) ->
try
fs.accessSync(path)
return true
catch
return false
global.document =
createElement: ->
setAttribute: ->
@ -32,7 +24,7 @@ setupGlobals = ->
global.emit = (event, args...) ->
process.send({event, args})
global.navigator = {userAgent: userAgent}
global.navigator = {userAgent}
global.window = global
handleEvents = ->

View file

@ -1,10 +1,15 @@
_ = require 'underscore'
{fork} = require 'child_process'
{Emitter} = require 'emissary'
ChildProcess = require 'child_process'
{Emitter} = require 'event-kit'
Grim = require 'grim'
# Extended: Run a node script in a separate process.
#
# Used by the fuzzy-finder and [find in project](https://github.com/atom/atom/blob/master/src/scan-handler.coffee).
#
# For a real-world example, see the [scan-handler](https://github.com/atom/atom/blob/master/src/scan-handler.coffee)
# and the [instantiation of the task](https://github.com/atom/atom/blob/4a20f13162f65afc816b512ad7201e528c3443d7/src/project.coffee#L245).
#
# ## Examples
#
# In your package code:
@ -33,8 +38,6 @@ Grim = require 'grim'
# ```
module.exports =
class Task
Emitter.includeInto(this)
# Public: A helper method to easily launch and run a task once.
#
# * `taskPath` The {String} path to the CoffeeScript/JavaScript file which
@ -61,12 +64,13 @@ class Task
# * `taskPath` The {String} path to the CoffeeScript/JavaScript file that
# exports a single {Function} to execute.
constructor: (taskPath) ->
coffeeCacheRequire = "require('#{require.resolve('./coffee-cache')}').register();"
coffeeScriptRequire = "require('#{require.resolve('coffee-script')}').register();"
@emitter = new Emitter
compileCacheRequire = "require('#{require.resolve('./compile-cache')}')"
compileCachePath = require('./compile-cache').getCacheDirectory()
taskBootstrapRequire = "require('#{require.resolve('./task-bootstrap')}');"
bootstrap = """
#{coffeeScriptRequire}
#{coffeeCacheRequire}
#{compileCacheRequire}.setCacheDirectory('#{compileCachePath}');
#{taskBootstrapRequire}
"""
bootstrap = bootstrap.replace(/\\/g, "\\\\")
@ -74,8 +78,8 @@ class Task
taskPath = require.resolve(taskPath)
taskPath = taskPath.replace(/\\/g, "\\\\")
env = _.extend({}, process.env, {taskPath, userAgent: process.env.userAgent})
@childProcess = fork '--eval', [bootstrap], {env, silent: true}
env = _.extend({}, process.env, {taskPath, userAgent: navigator.userAgent})
@childProcess = ChildProcess.fork '--eval', [bootstrap], {env, silent: true}
@on "task:log", -> console.log(arguments...)
@on "task:warn", -> console.warn(arguments...)
@ -91,12 +95,16 @@ class Task
handleEvents: ->
@childProcess.removeAllListeners()
@childProcess.on 'message', ({event, args}) =>
@emit(event, args...) if @childProcess?
@emitter.emit(event, args) if @childProcess?
# Catch the errors that happened before task-bootstrap.
@childProcess.stdout.on 'data', (data) ->
console.log data.toString()
@childProcess.stderr.on 'data', (data) ->
console.error data.toString()
if @childProcess.stdout?
@childProcess.stdout.removeAllListeners()
@childProcess.stdout.on 'data', (data) -> console.log data.toString()
if @childProcess.stderr?
@childProcess.stderr.removeAllListeners()
@childProcess.stderr.on 'data', (data) -> console.error data.toString()
# Public: Starts the task.
#
@ -129,27 +137,35 @@ class Task
throw new Error('Cannot send message to terminated process')
undefined
# Public: Describe the function of the task. Each task should override this
# to explain its individual function
description: ->
''
# Public: Call a function when an event is emitted by the child process
#
# * `eventName` The {String} name of the event to handle.
# * `callback` The {Function} to call when the event is emitted.
#
# Returns a {Disposable} that can be used to stop listening for the event.
on: (eventName, callback) -> Emitter::on.call(this, eventName, callback)
on: (eventName, callback) -> @emitter.on eventName, (args) -> callback(args...)
once: (eventName, callback) ->
disposable = @on eventName, (args...) ->
disposable.dispose()
callback(args...)
# Public: Forcefully stop the running task.
#
# No more events are emitted once this method is called.
terminate: ->
return unless @childProcess?
return false unless @childProcess?
@childProcess.removeAllListeners()
@childProcess.stdout?.removeAllListeners()
@childProcess.stderr?.removeAllListeners()
@childProcess.kill()
@childProcess = null
undefined
true
cancel: ->
didForcefullyTerminate = @terminate()
if didForcefullyTerminate
@emitter.emit('task:cancelled')
didForcefullyTerminate