diff --git a/internal_packages/account-sidebar/lib/account-sidebar-mail-view-item.cjsx b/internal_packages/account-sidebar/lib/account-sidebar-mail-view-item.cjsx index 2ce94377c..f2bc6f61e 100644 --- a/internal_packages/account-sidebar/lib/account-sidebar-mail-view-item.cjsx +++ b/internal_packages/account-sidebar/lib/account-sidebar-mail-view-item.cjsx @@ -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 =
{@state.unreadCount}
- 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()}
{@_renderIcon()}
{@props.item.name}
+ _renderUnreadCount: => + return false if @props.itemUnreadCount is 0 + className = 'item-count-box ' + className += @props.mailView.category?.name +
{@props.itemUnreadCount}
+ _renderIcon: -> diff --git a/internal_packages/account-sidebar/lib/account-sidebar.cjsx b/internal_packages/account-sidebar/lib/account-sidebar.cjsx index 51686b522..7dfbfafee 100644 --- a/internal_packages/account-sidebar/lib/account-sidebar.cjsx +++ b/internal_packages/account-sidebar/lib/account-sidebar.cjsx @@ -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: => @@ -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 @@ -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 diff --git a/internal_packages/account-sidebar/stylesheets/account-sidebar.less b/internal_packages/account-sidebar/stylesheets/account-sidebar.less index cdf74dec1..3690bdd87 100644 --- a/internal_packages/account-sidebar/stylesheets/account-sidebar.less +++ b/internal_packages/account-sidebar/stylesheets/account-sidebar.less @@ -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; diff --git a/internal_packages/preferences/lib/tabs/workspace-section.cjsx b/internal_packages/preferences/lib/tabs/workspace-section.cjsx index 6b87a1ad3..6163a832f 100644 --- a/internal_packages/preferences/lib/tabs/workspace-section.cjsx +++ b/internal_packages/preferences/lib/tabs/workspace-section.cjsx @@ -105,6 +105,11 @@ class WorkspaceSection extends React.Component keyPath="core.workspace.showImportant" config={@props.config} /> + +
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 diff --git a/spec/stores/database-store-spec.coffee b/spec/stores/database-store-spec.coffee index a10086334..b9bdc1d32 100644 --- a/spec/stores/database-store-spec.coffee +++ b/spec/stores/database-store-spec.coffee @@ -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 diff --git a/spec/stores/thread-counts-store-spec.coffee b/spec/stores/thread-counts-store-spec.coffee new file mode 100644 index 000000000..78fb448d8 --- /dev/null +++ b/spec/stores/thread-counts-store-spec.coffee @@ -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 + }) diff --git a/spec/stores/unread-badge-store-spec.coffee b/spec/stores/unread-badge-store-spec.coffee new file mode 100644 index 000000000..35be11c13 --- /dev/null +++ b/spec/stores/unread-badge-store-spec.coffee @@ -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+") diff --git a/spec/stores/unread-count-store-spec.coffee b/spec/stores/unread-count-store-spec.coffee deleted file mode 100644 index 8013d59ca..000000000 --- a/spec/stores/unread-count-store-spec.coffee +++ /dev/null @@ -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+") diff --git a/src/config-schema.coffee b/src/config-schema.coffee index 5c7420583..8997453d4 100644 --- a/src/config-schema.coffee +++ b/src/config-schema.coffee @@ -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: [] diff --git a/src/flux/models/category.coffee b/src/flux/models/category.coffee index 98e2cd1c9..c402e7383 100644 --- a/src/flux/models/category.coffee +++ b/src/flux/models/category.coffee @@ -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 diff --git a/src/flux/modules/reflux-coffee.coffee b/src/flux/modules/reflux-coffee.coffee index b8fdca8a7..f33e696e6 100644 --- a/src/flux/modules/reflux-coffee.coffee +++ b/src/flux/modules/reflux-coffee.coffee @@ -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 diff --git a/src/flux/stores/database-store.coffee b/src/flux/stores/database-store.coffee index 76a59f9df..3d6ed9ffb 100644 --- a/src/flux/stores/database-store.coffee +++ b/src/flux/stores/database-store.coffee @@ -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) diff --git a/src/flux/stores/thread-counts-store.coffee b/src/flux/stores/thread-counts-store.coffee new file mode 100644 index 000000000..01826d364 --- /dev/null +++ b/src/flux/stores/thread-counts-store.coffee @@ -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 diff --git a/src/flux/stores/unread-badge-store.coffee b/src/flux/stores/unread-badge-store.coffee new file mode 100644 index 000000000..75f457a40 --- /dev/null +++ b/src/flux/stores/unread-badge-store.coffee @@ -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() diff --git a/src/flux/stores/unread-count-store.coffee b/src/flux/stores/unread-count-store.coffee deleted file mode 100644 index f9d053b78..000000000 --- a/src/flux/stores/unread-count-store.coffee +++ /dev/null @@ -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 diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index e53255c9f..be496cdbc 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -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' diff --git a/src/task-bootstrap.coffee b/src/task-bootstrap.coffee index 3939bcdd6..ebb5cdc2b 100644 --- a/src/task-bootstrap.coffee +++ b/src/task-bootstrap.coffee @@ -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 = -> diff --git a/src/task.coffee b/src/task.coffee index 7b6a46722..d0ef44383 100644 --- a/src/task.coffee +++ b/src/task.coffee @@ -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