From e1882ab61a05dbef76831b740aaa0cc660acec60 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Mon, 23 Nov 2015 17:12:22 -0800 Subject: [PATCH] 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 --- .../lib/account-sidebar-mail-view-item.cjsx | 28 +- .../account-sidebar/lib/account-sidebar.cjsx | 14 +- .../stylesheets/account-sidebar.less | 14 +- .../lib/tabs/workspace-section.cjsx | 5 + .../system-tray/lib/tray-store.es6 | 8 +- spec/fixtures/db-test-model.coffee | 2 - spec/stores/database-store-spec.coffee | 115 +++++--- spec/stores/thread-counts-store-spec.coffee | 246 ++++++++++++++++++ spec/stores/unread-badge-store-spec.coffee | 15 ++ spec/stores/unread-count-store-spec.coffee | 75 ------ src/config-schema.coffee | 4 + src/flux/models/category.coffee | 4 + src/flux/modules/reflux-coffee.coffee | 9 +- src/flux/stores/database-store.coffee | 63 +++-- src/flux/stores/thread-counts-store.coffee | 144 ++++++++++ src/flux/stores/unread-badge-store.coffee | 61 +++++ src/flux/stores/unread-count-store.coffee | 92 ------- src/global/nylas-exports.coffee | 3 +- src/task-bootstrap.coffee | 10 +- src/task.coffee | 62 +++-- 20 files changed, 679 insertions(+), 295 deletions(-) create mode 100644 spec/stores/thread-counts-store-spec.coffee create mode 100644 spec/stores/unread-badge-store-spec.coffee delete mode 100644 spec/stores/unread-count-store-spec.coffee create mode 100644 src/flux/stores/thread-counts-store.coffee create mode 100644 src/flux/stores/unread-badge-store.coffee delete mode 100644 src/flux/stores/unread-count-store.coffee 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