diff --git a/.cirrus.yml b/.cirrus.yml index c5ea734c4..71814c89b 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -1,5 +1,7 @@ env: PYTHON_VERSION: 3.10 + GITHUB_TOKEN: ENCRYPTED[13da504dc34d1608564d891fb7f456b546019d07d1abb059f9ab4296c56ccc0e6e32c7b313629776eda40ab74a54e95c] + # The above token, is a GitHub API Token, that allows us to download RipGrep without concern of API limits linux_task: alias: linux diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index e71f5fc29..a4805e2ff 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -4,7 +4,7 @@ on: push: branches: [ "master" ] workflow_dispatch: - + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/editor-tests.yml b/.github/workflows/editor-tests.yml index 4ab2179fe..c96f6dbe3 100644 --- a/.github/workflows/editor-tests.yml +++ b/.github/workflows/editor-tests.yml @@ -1,6 +1,8 @@ name: Editor tests on: - - pull_request + pull_request: + push: + branches: ['master'] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ATOM_JASMINE_REPORTER: list diff --git a/.github/workflows/package-tests-linux.yml b/.github/workflows/package-tests-linux.yml index df8b9cf53..5cfeedc7b 100644 --- a/.github/workflows/package-tests-linux.yml +++ b/.github/workflows/package-tests-linux.yml @@ -1,6 +1,8 @@ -name: Package tests for Pulsar on Linux +name: Package tests on: - - pull_request + pull_request: + push: + branches: ['master'] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ATOM_JASMINE_REPORTER: list @@ -107,7 +109,7 @@ jobs: - package: "spell-check" - package: "status-bar" - package: "styleguide" - - package: "symbols-view" + # - package: "symbols-view" - package: "tabs" - package: "timecop" - package: "tree-view" diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b1b2963..0decf00c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Using some modules that are context-aware (remove some warnings, makes upgrading to newer Electron easier). +- Rebranded notifications, using our backend to find new versions of package, +and our github repository to find issues on Pulsar. Also fixed the "view issue" +and "create issue" buttons that were not working +- Bumped to latest version of `second-mate`, fixing a memory usage issue in `vscode-oniguruma` - Removed a cache for native modules - fix bugs where an user rebuilds a native module outside of Pulsar, but Pulsar refuses to load anyway - Removed `nslog` dependency diff --git a/package.json b/package.json index a9ed49d99..778501e8c 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "mocha-multi-reporters": "^1.1.4", "mock-spawn": "^0.2.6", "normalize-package-data": "3.0.2", - "notifications": "https://codeload.github.com/atom/notifications/legacy.tar.gz/refs/tags/v0.72.1", + "notifications": "file:./packages/notifications", "nsfw": "2.2.2", "one-dark-syntax": "file:packages/one-dark-syntax", "one-dark-ui": "file:packages/one-dark-ui", @@ -143,7 +143,7 @@ "scoped-property-store": "^0.17.0", "scrollbar-style": "^4.0.1", "season": "^6.0.2", - "second-mate": "https://github.com/pulsar-edit/second-mate.git#14aa7bd", + "second-mate": "https://github.com/pulsar-edit/second-mate.git#9686771", "semver": "7.3.8", "service-hub": "^0.7.4", "settings-view": "file:packages/settings-view", @@ -215,7 +215,7 @@ "line-ending-selector": "file:./packages/line-ending-selector", "link": "file:./packages/link", "markdown-preview": "file:./packages/markdown-preview", - "notifications": "0.72.1", + "notifications": "file:./packages/notifications", "open-on-github": "file:./packages/open-on-github", "package-generator": "file:./packages/package-generator", "settings-view": "file:./packages/settings-view", diff --git a/packages/bookmarks/lib/bookmarks-provider.js b/packages/bookmarks/lib/bookmarks-provider.js new file mode 100644 index 000000000..507471da2 --- /dev/null +++ b/packages/bookmarks/lib/bookmarks-provider.js @@ -0,0 +1,31 @@ + +class BookmarksProvider { + constructor(main) { + this.main = main + } + + // Returns all bookmarks present in the given editor. + // + // Each bookmark tracks a buffer range and is represented by an instance of + // {DisplayMarker}. + // + // Will return an empty array if there are no bookmarks in the given editor. + // + // Keep in mind that a single bookmark can span multiple buffer rows and/or + // screen rows. Thus there isn't necessarily a 1:1 correlation between the + // number of bookmarks in the editor and the number of bookmark icons that + // the user will see in the gutter. + getBookmarksForEditor(editor) { + let instance = this.getInstanceForEditor(editor) + if (!instance) return null + return instance.getAllBookmarks() + } + + // Returns the instance of the `Bookmarks` class that is responsible for + // managing bookmarks in the given editor. + getInstanceForEditor(editor) { + return this.main.editorsBookmarks.find(b => b.editor.id === editor.id) + } +} + +module.exports = BookmarksProvider diff --git a/packages/bookmarks/lib/bookmarks.js b/packages/bookmarks/lib/bookmarks.js index ffaf66257..b9b414de1 100644 --- a/packages/bookmarks/lib/bookmarks.js +++ b/packages/bookmarks/lib/bookmarks.js @@ -1,12 +1,13 @@ -const {CompositeDisposable} = require('atom') +const {CompositeDisposable, Emitter} = require('atom') module.exports = class Bookmarks { - static deserialize (editor, state) { + static deserialize(editor, state) { return new Bookmarks(editor, editor.getMarkerLayer(state.markerLayerId)) } - constructor (editor, markerLayer) { + constructor(editor, markerLayer) { + this.emitter = new Emitter() this.editor = editor this.markerLayer = markerLayer || this.editor.addMarkerLayer({persistent: true}) this.decorationLayer = this.editor.decorateMarkerLayer(this.markerLayer, {type: 'line-number', class: 'bookmarked'}) @@ -24,23 +25,23 @@ class Bookmarks { this.disposables.add(this.editor.onDidDestroy(this.destroy.bind(this))) } - destroy () { + destroy() { this.deactivate() this.markerLayer.destroy() } - deactivate () { + deactivate() { this.decorationLayer.destroy() this.decorationLayerLine.destroy() this.decorationLayerHighlight.destroy() this.disposables.dispose() } - serialize () { + serialize() { return {markerLayerId: this.markerLayer.id} } - toggleBookmark () { + toggleBookmark() { for (const range of this.editor.getSelectedBufferRanges()) { const bookmarks = this.markerLayer.findMarkers({intersectsRowRange: [range.start.row, range.end.row]}) if (bookmarks && bookmarks.length > 0) { @@ -52,19 +53,34 @@ class Bookmarks { this.disposables.add(bookmark.onDidChange(({isValid}) => { if (!isValid) { bookmark.destroy() + // TODO: If N bookmarks are affected by a buffer change, + // `did-change-bookmarks` will be emitted N times. We could + // debounce this if we were willing to go async. + this.emitter.emit('did-change-bookmarks', this.getAllBookmarks()) } })) } + this.emitter.emit('did-change-bookmarks', this.getAllBookmarks()) } } - clearBookmarks () { + getAllBookmarks() { + let markers = this.markerLayer.getMarkers() + return markers + } + + onDidChangeBookmarks(callback) { + return this.emitter.on('did-change-bookmarks', callback) + } + + clearBookmarks() { for (const bookmark of this.markerLayer.getMarkers()) { bookmark.destroy() } + this.emitter.emit('did-change-bookmarks', []) } - jumpToNextBookmark () { + jumpToNextBookmark() { if (this.markerLayer.getMarkerCount() > 0) { const bufferRow = this.editor.getLastCursor().getMarker().getStartBufferPosition().row const markers = this.markerLayer.getMarkers().sort((a, b) => a.compare(b)) @@ -76,7 +92,7 @@ class Bookmarks { } } - jumpToPreviousBookmark () { + jumpToPreviousBookmark() { if (this.markerLayer.getMarkerCount() > 0) { const bufferRow = this.editor.getLastCursor().getMarker().getStartBufferPosition().row const markers = this.markerLayer.getMarkers().sort((a, b) => b.compare(a)) @@ -88,7 +104,7 @@ class Bookmarks { } } - selectToNextBookmark () { + selectToNextBookmark() { if (this.markerLayer.getMarkerCount() > 0) { const bufferRow = this.editor.getLastCursor().getMarker().getStartBufferPosition().row const markers = this.markerLayer.getMarkers().sort((a, b) => a.compare(b)) @@ -103,7 +119,7 @@ class Bookmarks { } } - selectToPreviousBookmark () { + selectToPreviousBookmark() { if (this.markerLayer.getMarkerCount() > 0) { const bufferRow = this.editor.getLastCursor().getMarker().getStartBufferPosition().row const markers = this.markerLayer.getMarkers().sort((a, b) => b.compare(a)) diff --git a/packages/bookmarks/lib/main.js b/packages/bookmarks/lib/main.js index 272b8909c..a9ba90d72 100644 --- a/packages/bookmarks/lib/main.js +++ b/packages/bookmarks/lib/main.js @@ -2,9 +2,10 @@ const {CompositeDisposable} = require('atom') const Bookmarks = require('./bookmarks') const BookmarksView = require('./bookmarks-view') +const BookmarksProvider = require('./bookmarks-provider') module.exports = { - activate (bookmarksByEditorId) { + activate(bookmarksByEditorId) { this.bookmarksView = null this.editorsBookmarks = [] this.disposables = new CompositeDisposable() @@ -44,7 +45,7 @@ module.exports = { }) }, - deactivate () { + deactivate() { if (this.bookmarksView != null) { this.bookmarksView.destroy() this.bookmarksView = null @@ -56,11 +57,16 @@ module.exports = { this.disposables.dispose() }, - serialize () { + serialize() { const bookmarksByEditorId = {} for (let bookmarks of this.editorsBookmarks) { bookmarksByEditorId[bookmarks.editor.id] = bookmarks.serialize() } return bookmarksByEditorId + }, + + provideBookmarks() { + this.bookmarksProvider ??= new BookmarksProvider(this) + return this.bookmarksProvider } } diff --git a/packages/bookmarks/package.json b/packages/bookmarks/package.json index 5228364ad..d584eb4b4 100644 --- a/packages/bookmarks/package.json +++ b/packages/bookmarks/package.json @@ -10,5 +10,14 @@ }, "dependencies": { "atom-select-list": "^0.7.0" + }, + + "providedServices": { + "bookmarks": { + "description": "Provides a list of bookmarks to any package that wants to know about them.", + "versions": { + "1.0.0": "provideBookmarks" + } + } } } diff --git a/packages/bookmarks/spec/bookmarks-view-spec.js b/packages/bookmarks/spec/bookmarks-view-spec.js index a1a0a5375..6b95c30ec 100644 --- a/packages/bookmarks/spec/bookmarks-view-spec.js +++ b/packages/bookmarks/spec/bookmarks-view-spec.js @@ -1,5 +1,5 @@ describe('Bookmarks package', () => { - let [workspaceElement, editorElement, editor, bookmarks] = [] + let workspaceElement, editorElement, editor, bookmarks, provider const bookmarkedRangesForEditor = editor => { const decorationsById = editor.decorationsStateForScreenRowRange(0, editor.getLastScreenRow()) @@ -18,6 +18,7 @@ describe('Bookmarks package', () => { await atom.workspace.open('sample.js') bookmarks = (await atom.packages.activatePackage('bookmarks')).mainModule + provider = bookmarks.bookmarksProvider jasmine.attachToDOM(workspaceElement) editor = atom.workspace.getActiveTextEditor() @@ -32,17 +33,28 @@ describe('Bookmarks package', () => { expect(bookmarkedRangesForEditor(editor)).toEqual([]) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') expect(bookmarkedRangesForEditor(editor)).toEqual([[[3, 10], [3, 10]]]) + + let marks = provider.getBookmarksForEditor(editor) + expect(marks.length).toBe(1) + expect(marks.map(m => m.getScreenRange())).toEqual(bookmarkedRangesForEditor(editor)) }) it('removes marker when toggled', () => { + let callback = jasmine.createSpy() + + let instance = provider.getInstanceForEditor(editor) + instance.onDidChangeBookmarks(callback) + editor.setCursorBufferPosition([3, 10]) expect(bookmarkedRangesForEditor(editor).length).toBe(0) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') expect(bookmarkedRangesForEditor(editor).length).toBe(1) + expect(callback.callCount).toBe(1) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') expect(bookmarkedRangesForEditor(editor).length).toBe(0) + expect(callback.callCount).toBe(2) }) }) @@ -53,6 +65,8 @@ describe('Bookmarks package', () => { expect(bookmarkedRangesForEditor(editor)).toEqual([]) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') expect(bookmarkedRangesForEditor(editor)).toEqual([[[3, 10], [3, 10]], [[6, 11], [6, 11]]]) + let instance = provider.getInstanceForEditor(editor) + expect(instance.getAllBookmarks().length).toBe(2) }) it('removes multiple markers when toggled', () => { @@ -215,27 +229,39 @@ describe('Bookmarks package', () => { }) it('clears all bookmarks', () => { + let callback = jasmine.createSpy() + let instance = provider.getInstanceForEditor(editor) + instance.onDidChangeBookmarks(callback) + editor.setCursorBufferPosition([3, 10]) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') + expect(callback.callCount).toBe(1) editor.setCursorBufferPosition([5, 0]) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') + expect(callback.callCount).toBe(2) atom.commands.dispatch(editorElement, 'bookmarks:clear-bookmarks') expect(getBookmarkedLineNodes(editorElement).length).toBe(0) + expect(callback.callCount).toBe(3) }) }) describe('when a bookmark is invalidated', () => { it('creates a marker when toggled', () => { + let callback = jasmine.createSpy() + let instance = provider.getInstanceForEditor(editor) + instance.onDidChangeBookmarks(callback) editor.setCursorBufferPosition([3, 10]) expect(bookmarkedRangesForEditor(editor).length).toBe(0) atom.commands.dispatch(editorElement, 'bookmarks:toggle-bookmark') expect(bookmarkedRangesForEditor(editor).length).toBe(1) + expect(callback.callCount).toBe(1) editor.setText('') expect(bookmarkedRangesForEditor(editor).length).toBe(0) + expect(callback.callCount).toBe(2) }) }) diff --git a/packages/find-and-replace/spec/find-view-spec.js b/packages/find-and-replace/spec/find-view-spec.js index 18250f3c5..d41ca02e3 100644 --- a/packages/find-and-replace/spec/find-view-spec.js +++ b/packages/find-and-replace/spec/find-view-spec.js @@ -559,7 +559,7 @@ describe("FindView", () => { it("displays the error", () => { expect(findView.refs.descriptionLabel).toHaveClass("text-error"); - expect(findView.refs.descriptionLabel.textContent).toBe("regular expression is too large"); + expect(findView.refs.descriptionLabel.textContent).toContain("too large"); }); it("will be reset when there is no longer an error", () => { diff --git a/packages/find-and-replace/spec/results-view-spec.js b/packages/find-and-replace/spec/results-view-spec.js index c318f9adf..6007e84dd 100644 --- a/packages/find-and-replace/spec/results-view-spec.js +++ b/packages/find-and-replace/spec/results-view-spec.js @@ -909,12 +909,17 @@ describe('ResultsView', () => { atom.commands.dispatch(projectFindView.element, 'core:confirm'); await resultsPromise(); - let resultsPane = getResultsPane(); + const resultsPane = getResultsPane(); + await genPromiseToCheck( () => + resultsPane?.refs?.previewCount?.textContent.match(/3 files/) + ); expect(resultsPane.refs.previewCount.textContent).toContain('3 files'); projectFindView.findEditor.setText(''); atom.commands.dispatch(projectFindView.element, 'core:confirm'); - await etch.update(resultsPane); + await genPromiseToCheck( () => + resultsPane.refs.previewCount.textContent.match(/Project/) + ); expect(resultsPane.refs.previewCount.textContent).toContain('Project search results'); }) }); diff --git a/packages/notifications/.coffeelintignore b/packages/notifications/.coffeelintignore new file mode 100644 index 000000000..1db51fed7 --- /dev/null +++ b/packages/notifications/.coffeelintignore @@ -0,0 +1 @@ +spec/fixtures diff --git a/packages/notifications/.gitignore b/packages/notifications/.gitignore new file mode 100644 index 000000000..ade14b919 --- /dev/null +++ b/packages/notifications/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +npm-debug.log +node_modules diff --git a/packages/notifications/LICENSE.md b/packages/notifications/LICENSE.md new file mode 100644 index 000000000..4d231b456 --- /dev/null +++ b/packages/notifications/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2014 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/notifications/PULL_REQUEST_TEMPLATE.md b/packages/notifications/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..cdaa94a86 --- /dev/null +++ b/packages/notifications/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +### Requirements + +* Filling out the template is required. Any pull request that does not include enough information to be reviewed in a timely manner may be closed at the maintainers' discretion. +* All new code requires tests to ensure against regressions + +### Description of the Change + + + +### Alternate Designs + + + +### Benefits + + + +### Possible Drawbacks + + + +### Applicable Issues + + diff --git a/packages/notifications/README.md b/packages/notifications/README.md new file mode 100644 index 000000000..a3702b0de --- /dev/null +++ b/packages/notifications/README.md @@ -0,0 +1,8 @@ + # Notifications package + +![notifications](https://cloud.githubusercontent.com/assets/69169/5176406/350d0e80-73fd-11e4-8101-1776b9d6d8bf.gif) + +### Docs + +Notifications are available for use in your Pulsar packages via the `atom.notifications` `NotificationManager` object. See +https://atom.io/docs/api/latest/NotificationManager and https://atom.io/docs/api/latest/Notification for documentation. diff --git a/packages/notifications/keymaps/messages.cson b/packages/notifications/keymaps/messages.cson new file mode 100644 index 000000000..beacf16b9 --- /dev/null +++ b/packages/notifications/keymaps/messages.cson @@ -0,0 +1,11 @@ +# Keybindings require three things to be fully defined: A selector that is +# matched against the focused element, the keystroke and the command to +# execute. +# +# Below is a basic keybinding which registers on all platforms by applying to +# the root workspace element. + +# For more detailed documentation see +# https://atom.io/docs/latest/advanced/keymaps +'atom-workspace': + 'cmd-alt-t': 'notifications:trigger-error' diff --git a/packages/notifications/lib/command-logger.js b/packages/notifications/lib/command-logger.js new file mode 100644 index 000000000..1d90f6a6e --- /dev/null +++ b/packages/notifications/lib/command-logger.js @@ -0,0 +1,214 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS202: Simplify dynamic range loops + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +// Originally from lee-dohm/bug-report +// https://github.com/lee-dohm/bug-report/blob/master/lib/command-logger.coffee + +// Command names that are ignored and not included in the log. This uses an Object to provide fast +// string matching. +let CommandLogger; +const ignoredCommands = { + 'show.bs.tooltip': true, + 'shown.bs.tooltip': true, + 'hide.bs.tooltip': true, + 'hidden.bs.tooltip': true, + 'editor:display-updated': true, + 'mousewheel': true +}; + +// Ten minutes in milliseconds. +const tenMinutes = 10 * 60 * 1000; + +// Public: Handles logging all of the Pulsar commands for the automatic repro steps feature. +// +// It uses an array as a circular data structure to log only the most recent commands. +module.exports = +(CommandLogger = (function() { + CommandLogger = class CommandLogger { + static initClass() { + + // Public: Maximum size of the log. + this.prototype.logSize = 16; + } + static instance() { + return this._instance != null ? this._instance : (this._instance = new CommandLogger); + } + + static start() { + return this.instance().start(); + } + + // Public: Creates a new logger. + constructor() { + this.initLog(); + } + + start() { + return atom.commands.onWillDispatch(event => { + return this.logCommand(event); + }); + } + + // Public: Formats the command log for the bug report. + // + // * `externalData` An {Object} containing other information to include in the log. + // + // Returns a {String} of the Markdown for the report. + getText(externalData) { + const lines = []; + const lastTime = Date.now(); + + this.eachEvent(event => { + if (event.time > lastTime) { return; } + if (!event.name || ((lastTime - event.time) >= tenMinutes)) { return; } + return lines.push(this.formatEvent(event, lastTime)); + }); + + if (externalData) { + lines.push(` ${this.formatTime(0)} ${externalData.title}`); + } + + lines.unshift('```'); + lines.push('```'); + return lines.join("\n"); + } + + // Public: Gets the latest event from the log. + // + // Returns the event {Object}. + latestEvent() { + return this.eventLog[this.logIndex]; + } + + // Public: Logs the command. + // + // * `command` Command {Object} to be logged + // * `type` Name {String} of the command + // * `target` {String} describing where the command was triggered + logCommand(command) { + const {type: name, target, time} = command; + if (command.detail != null ? command.detail.jQueryTrigger : undefined) { return; } + if (name in ignoredCommands) { return; } + + let event = this.latestEvent(); + + if (event.name === name) { + return event.count++; + } else { + this.logIndex = (this.logIndex + 1) % this.logSize; + event = this.latestEvent(); + event.name = name; + event.targetNodeName = target.nodeName; + event.targetClassName = target.className; + event.targetId = target.id; + event.count = 1; + return event.time = time != null ? time : Date.now(); + } + } + + // Private: Calculates the time of the last event to be reported. + // + // * `data` Data from an external bug passed in from another package. + // + // Returns the {Date} of the last event that should be reported. + calculateLastEventTime(data) { + if (data) { return data.time; } + + let lastTime = null; + this.eachEvent(event => lastTime = event.time); + return lastTime; + } + + // Private: Executes a function on each event in chronological order. + // + // This function is used instead of similar underscore functions because the log is held in a + // circular buffer. + // + // * `fn` {Function} to execute for each event in the log. + // * `event` An {Object} describing the event passed to your function. + // + // ## Examples + // + // This code would output the name of each event to the console. + // + // ```coffee + // logger.eachEvent (event) -> + // console.log event.name + // ``` + eachEvent(fn) { + for (let offset = 1, end = this.logSize, asc = 1 <= end; asc ? offset <= end : offset >= end; asc ? offset++ : offset--) { + fn(this.eventLog[(this.logIndex + offset) % this.logSize]); + } + } + + // Private: Format the command count for reporting. + // + // Returns the {String} format of the command count. + formatCount(count) { + switch (false) { + case !(count < 2): return ' '; + case !(count < 10): return ` ${count}x`; + case !(count < 100): return ` ${count}x`; + } + } + + // Private: Formats a command event for reporting. + // + // * `event` Event {Object} to be formatted. + // * `lastTime` {Date} of the last event to report. + // + // Returns the {String} format of the command event. + formatEvent(event, lastTime) { + const {count, time, name, targetNodeName, targetClassName, targetId} = event; + const nodeText = targetNodeName.toLowerCase(); + const idText = targetId ? `#${targetId}` : ''; + let classText = ''; + if (targetClassName != null) { for (var klass of Array.from(targetClassName.split(" "))) { classText += `.${klass}`; } } + return `${this.formatCount(count)} ${this.formatTime(lastTime - time)} ${name} (${nodeText}${idText}${classText})`; + } + + // Private: Format the command time for reporting. + // + // * `time` {Date} to format + // + // Returns the {String} format of the command time. + formatTime(time) { + const minutes = Math.floor(time / 60000); + let seconds = Math.floor(((time % 60000) / 1000) * 10) / 10; + if (seconds < 10) { seconds = `0${seconds}`; } + if (Math.floor(seconds) !== seconds) { seconds = `${seconds}.0`; } + return `-${minutes}:${seconds}`; + } + + // Private: Initializes the log structure for speed. + initLog() { + this.logIndex = 0; + return this.eventLog = __range__(0, this.logSize, false).map((i) => ({ + name: null, + count: 0, + targetNodeName: null, + targetClassName: null, + targetId: null, + time: null + })); + } + }; + CommandLogger.initClass(); + return CommandLogger; +})()); + +function __range__(left, right, inclusive) { + let range = []; + let ascending = left < right; + let end = !inclusive ? right : ascending ? right + 1 : right - 1; + for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) { + range.push(i); + } + return range; +} \ No newline at end of file diff --git a/packages/notifications/lib/main.js b/packages/notifications/lib/main.js new file mode 100644 index 000000000..16b1b0df4 --- /dev/null +++ b/packages/notifications/lib/main.js @@ -0,0 +1,192 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const {Notification, CompositeDisposable} = require('atom'); +const fs = require('fs-plus'); +let StackTraceParser = null; +const NotificationElement = require('./notification-element'); +const NotificationsLog = require('./notifications-log'); + +const Notifications = { + isInitialized: false, + subscriptions: null, + duplicateTimeDelay: 500, + lastNotification: null, + + activate(state) { + let notification; + const CommandLogger = require('./command-logger'); + CommandLogger.start(); + this.subscriptions = new CompositeDisposable; + + for (notification of Array.from(atom.notifications.getNotifications())) { this.addNotificationView(notification); } + this.subscriptions.add(atom.notifications.onDidAddNotification(notification => this.addNotificationView(notification))); + + this.subscriptions.add(atom.onWillThrowError(function({message, url, line, originalError, preventDefault}) { + let match; + if (originalError.name === 'BufferedProcessError') { + message = message.replace('Uncaught BufferedProcessError: ', ''); + return atom.notifications.addError(message, {dismissable: true}); + + } else if ((originalError.code === 'ENOENT') && !/\/atom/i.test(message) && (match = /spawn (.+) ENOENT/.exec(message))) { + message = `\ +'${match[1]}' could not be spawned. +Is it installed and on your path? +If so please open an issue on the package spawning the process.\ +`; + return atom.notifications.addError(message, {dismissable: true}); + + } else if (!atom.inDevMode() || atom.config.get('notifications.showErrorsInDevMode')) { + preventDefault(); + + // Ignore errors with no paths in them since they are impossible to trace + if (originalError.stack && !isCoreOrPackageStackTrace(originalError.stack)) { + return; + } + + const options = { + detail: `${url}:${line}`, + stack: originalError.stack, + dismissable: true + }; + return atom.notifications.addFatalError(message, options); + } + }) + ); + + this.subscriptions.add(atom.commands.add('atom-workspace', 'core:cancel', () => (() => { + const result = []; + for (notification of Array.from(atom.notifications.getNotifications())) { result.push(notification.dismiss()); + } + return result; + })()) + ); + + this.subscriptions.add(atom.config.observe('notifications.defaultTimeout', value => { return this.visibilityDuration = value; })); + + if (atom.inDevMode()) { + this.subscriptions.add(atom.commands.add('atom-workspace', 'notifications:trigger-error', function() { + try { + return abc + 2; // nope + } catch (error) { + const options = { + detail: error.stack.split('\n')[1], + stack: error.stack, + dismissable: true + }; + return atom.notifications.addFatalError(`Uncaught ${error.stack.split('\n')[0]}`, options); + } + }) + ); + } + + if (this.notificationsLog != null) { this.addNotificationsLogSubscriptions(); } + this.subscriptions.add(atom.workspace.addOpener(uri => { if (uri === NotificationsLog.prototype.getURI()) { return this.createLog(); } })); + this.subscriptions.add(atom.commands.add('atom-workspace', 'notifications:toggle-log', () => atom.workspace.toggle(NotificationsLog.prototype.getURI()))); + return this.subscriptions.add(atom.commands.add('atom-workspace', 'notifications:clear-log', function() { + for (notification of Array.from(atom.notifications.getNotifications())) { + notification.options.dismissable = true; + notification.dismissed = false; + notification.dismiss(); + } + return atom.notifications.clear(); + }) + ); + }, + + deactivate() { + this.subscriptions.dispose(); + if (this.notificationsElement != null) { + this.notificationsElement.remove(); + } + if (this.notificationsPanel != null) { + this.notificationsPanel.destroy(); + } + if (this.notificationsLog != null) { + this.notificationsLog.destroy(); + } + + this.subscriptions = null; + this.notificationsElement = null; + this.notificationsPanel = null; + + return this.isInitialized = false; + }, + + initializeIfNotInitialized() { + if (this.isInitialized) { return; } + + this.subscriptions.add(atom.views.addViewProvider(Notification, model => { + return new NotificationElement(model, this.visibilityDuration); + }) + ); + + this.notificationsElement = document.createElement('atom-notifications'); + atom.views.getView(atom.workspace).appendChild(this.notificationsElement); + + return this.isInitialized = true; + }, + + createLog(state) { + this.notificationsLog = new NotificationsLog(this.duplicateTimeDelay, state != null ? state.typesHidden : undefined); + if (this.subscriptions != null) { this.addNotificationsLogSubscriptions(); } + return this.notificationsLog; + }, + + addNotificationsLogSubscriptions() { + this.subscriptions.add(this.notificationsLog.onDidDestroy(() => { return this.notificationsLog = null; })); + return this.subscriptions.add(this.notificationsLog.onItemClick(notification => { + const view = atom.views.getView(notification); + view.makeDismissable(); + + if (!view.element.classList.contains('remove')) { return; } + view.element.classList.remove('remove'); + this.notificationsElement.appendChild(view.element); + notification.dismissed = false; + return notification.setDisplayed(true); + }) + ); + }, + + addNotificationView(notification) { + if (notification == null) { return; } + this.initializeIfNotInitialized(); + if (notification.wasDisplayed()) { return; } + + if (this.lastNotification != null) { + // do not show duplicates unless some amount of time has passed + const timeSpan = notification.getTimestamp() - this.lastNotification.getTimestamp(); + if (!(timeSpan < this.duplicateTimeDelay) || !notification.isEqual(this.lastNotification)) { + this.notificationsElement.appendChild(atom.views.getView(notification).element); + if (this.notificationsLog != null) { + this.notificationsLog.addNotification(notification); + } + } + } else { + this.notificationsElement.appendChild(atom.views.getView(notification).element); + if (this.notificationsLog != null) { + this.notificationsLog.addNotification(notification); + } + } + + notification.setDisplayed(true); + return this.lastNotification = notification; + } +}; + +var isCoreOrPackageStackTrace = function(stack) { + if (StackTraceParser == null) { StackTraceParser = require('stacktrace-parser'); } + for (var {file} of Array.from(StackTraceParser.parse(stack))) { + if ((file === '') || fs.isAbsolute(file)) { + return true; + } + } + return false; +}; + +module.exports = Notifications; diff --git a/packages/notifications/lib/notification-element.js b/packages/notifications/lib/notification-element.js new file mode 100644 index 000000000..88fa63d14 --- /dev/null +++ b/packages/notifications/lib/notification-element.js @@ -0,0 +1,367 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +let NotificationElement; +const createDOMPurify = require('dompurify'); +const fs = require('fs-plus'); +const path = require('path'); +const marked = require('marked'); +const {shell} = require('electron'); + +const NotificationIssue = require('./notification-issue'); +const TemplateHelper = require('./template-helper'); +const UserUtilities = require('./user-utilities'); + +let DOMPurify = null; + +const NotificationTemplate = `\ +
+
+
+
+ +
+
+
+
+
+
Close All
\ +`; + +const FatalMetaNotificationTemplate = `\ +
+
+ + +
\ +`; + +const MetaNotificationTemplate = `\ +
\ +`; + +const ButtonListTemplate = `\ +
\ +`; + +const ButtonTemplate = `\ +\ +`; + +module.exports = +(NotificationElement = (function() { + NotificationElement = class NotificationElement { + static initClass() { + this.prototype.animationDuration = 360; + this.prototype.visibilityDuration = 5000; + this.prototype.autohideTimeout = null; + } + + constructor(model, visibilityDuration) { + this.model = model; + this.visibilityDuration = visibilityDuration; + this.fatalTemplate = TemplateHelper.create(FatalMetaNotificationTemplate); + this.metaTemplate = TemplateHelper.create(MetaNotificationTemplate); + this.buttonListTemplate = TemplateHelper.create(ButtonListTemplate); + this.buttonTemplate = TemplateHelper.create(ButtonTemplate); + + this.element = document.createElement('atom-notification'); + if (this.model.getType() === 'fatal') { this.issue = new NotificationIssue(this.model); } + this.renderPromise = this.render().catch(function(e) { + console.error(e.message); + return console.error(e.stack); + }); + + this.model.onDidDismiss(() => this.removeNotification()); + + if (!this.model.isDismissable()) { + this.autohide(); + this.element.addEventListener('click', this.makeDismissable.bind(this), {once: true}); + } + + this.element.issue = this.issue; + this.element.getRenderPromise = this.getRenderPromise.bind(this); + } + + getModel() { return this.model; } + + getRenderPromise() { return this.renderPromise; } + + render() { + let detail, metaContainer, metaContent; + this.element.classList.add(`${this.model.getType()}`); + this.element.classList.add("icon", `icon-${this.model.getIcon()}`, "native-key-bindings"); + + if (detail = this.model.getDetail()) { this.element.classList.add('has-detail'); } + if (this.model.isDismissable()) { this.element.classList.add('has-close'); } + if (detail && (this.model.getOptions().stack != null)) { this.element.classList.add('has-stack'); } + + this.element.setAttribute('tabindex', '-1'); + + this.element.innerHTML = NotificationTemplate; + + const options = this.model.getOptions(); + + const notificationContainer = this.element.querySelector('.message'); + + if (DOMPurify === null) { + DOMPurify = createDOMPurify(); + } + notificationContainer.innerHTML = DOMPurify.sanitize(marked(this.model.getMessage())); + + if (detail = this.model.getDetail()) { + let stack; + addSplitLinesToContainer(this.element.querySelector('.detail-content'), detail); + + if (stack = options.stack) { + const stackToggle = this.element.querySelector('.stack-toggle'); + const stackContainer = this.element.querySelector('.stack-container'); + + addSplitLinesToContainer(stackContainer, stack); + + stackToggle.addEventListener('click', e => this.handleStackTraceToggleClick(e, stackContainer)); + this.handleStackTraceToggleClick({currentTarget: stackToggle}, stackContainer); + } + } + + if (metaContent = options.description) { + this.element.classList.add('has-description'); + metaContainer = this.element.querySelector('.meta'); + metaContainer.appendChild(TemplateHelper.render(this.metaTemplate)); + const description = this.element.querySelector('.description'); + description.innerHTML = marked(metaContent); + } + + if (options.buttons && (options.buttons.length > 0)) { + this.element.classList.add('has-buttons'); + metaContainer = this.element.querySelector('.meta'); + metaContainer.appendChild(TemplateHelper.render(this.buttonListTemplate)); + const toolbar = this.element.querySelector('.btn-toolbar'); + let buttonClass = this.model.getType(); + if (buttonClass === 'fatal') { buttonClass = 'error'; } + buttonClass = `btn-${buttonClass}`; + options.buttons.forEach(button => { + toolbar.appendChild(TemplateHelper.render(this.buttonTemplate)); + const buttonEl = toolbar.childNodes[toolbar.childNodes.length - 1]; + buttonEl.textContent = button.text; + buttonEl.classList.add(buttonClass); + if (button.className != null) { + buttonEl.classList.add.apply(buttonEl.classList, button.className.split(' ')); + } + if (button.onDidClick != null) { + return buttonEl.addEventListener('click', e => { + return button.onDidClick.call(this, e); + }); + } + }); + } + + const closeButton = this.element.querySelector('.close'); + closeButton.addEventListener('click', () => this.handleRemoveNotificationClick()); + + const closeAllButton = this.element.querySelector('.close-all'); + closeAllButton.classList.add(this.getButtonClass()); + closeAllButton.addEventListener('click', () => this.handleRemoveAllNotificationsClick()); + + if (this.model.getType() === 'fatal') { + return this.renderFatalError(); + } else { + return Promise.resolve(); + } + } + + renderFatalError() { + const repoUrl = this.issue.getRepoUrl(); + const packageName = this.issue.getPackageName(); + + const fatalContainer = this.element.querySelector('.meta'); + fatalContainer.appendChild(TemplateHelper.render(this.fatalTemplate)); + const fatalNotification = this.element.querySelector('.fatal-notification'); + + const issueButton = fatalContainer.querySelector('.btn-issue'); + + const copyReportButton = fatalContainer.querySelector('.btn-copy-report'); + atom.tooltips.add(copyReportButton, {title: copyReportButton.getAttribute('title')}); + copyReportButton.addEventListener('click', e => { + e.preventDefault(); + return this.issue.getIssueBody().then(issueBody => atom.clipboard.write(issueBody)); + }); + + if ((packageName != null) && (repoUrl != null)) { + fatalNotification.innerHTML = `The error was thrown from the ${packageName} package. `; + } else if (packageName != null) { + issueButton.remove(); + fatalNotification.textContent = `The error was thrown from the ${packageName} package. `; + } else { + fatalNotification.textContent = "This is likely a bug in Pulsar. "; + } + + // We only show the create issue button if it's clearly in atom core or in a package with a repo url + if (issueButton.parentNode != null) { + if ((packageName != null) && (repoUrl != null)) { + issueButton.textContent = `Create issue on the ${packageName} package`; + } else { + issueButton.textContent = "Create issue on pulsar-edit/pulsar"; + } + + const promises = []; + promises.push(this.issue.findSimilarIssues()); + promises.push(UserUtilities.checkPulsarUpToDate()); + if (packageName != null) { + promises.push(UserUtilities.checkPackageUpToDate(packageName)); + } + + return Promise.all(promises).then(allData => { + let issue; + const [issues, atomCheck, packageCheck] = Array.from(allData); + + if ((issues != null ? issues.open : undefined) || (issues != null ? issues.closed : undefined)) { + issue = issues.open || issues.closed; + issueButton.setAttribute('href', issue.html_url); + issueButton.textContent = "View Issue"; + fatalNotification.innerHTML += " This issue has already been reported."; + } else if ((packageCheck != null) && !packageCheck.upToDate && !packageCheck.isCore) { + issueButton.setAttribute('href', '#'); + issueButton.textContent = "Check for package updates"; + issueButton.addEventListener('click', function(e) { + e.preventDefault(); + const command = 'settings-view:check-for-package-updates'; + return atom.commands.dispatch(atom.views.getView(atom.workspace), command); + }); + + fatalNotification.innerHTML += `\ +${packageName} is out of date: ${packageCheck.installedVersion} installed; +${packageCheck.latestVersion} latest. +Upgrading to the latest version may fix this issue.\ +`; + } else if ((packageCheck != null) && !packageCheck.upToDate && packageCheck.isCore) { + issueButton.remove(); + + fatalNotification.innerHTML += `\ +

+Locally installed core Pulsar package ${packageName} is out of date: ${packageCheck.installedVersion} installed locally; +${packageCheck.versionShippedWithPulsar} included with the version of Pulsar you're running. +Removing the locally installed version may fix this issue.\ +`; + + const packagePath = __guard__(atom.packages.getLoadedPackage(packageName), x => x.path); + if (fs.isSymbolicLinkSync(packagePath)) { + fatalNotification.innerHTML += `\ +

+Use: apm unlink ${packagePath}\ +`; + } + } else if ((atomCheck != null) && !atomCheck.upToDate) { + issueButton.remove(); + + fatalNotification.innerHTML += `\ +Pulsar is out of date: ${atomCheck.installedVersion} installed; +${atomCheck.latestVersion} latest. +Upgrading to the latest version may fix this issue.\ +`; + } else { + fatalNotification.innerHTML += " You can help by creating an issue. Please explain what actions triggered this error."; + issueButton.addEventListener('click', e => { + e.preventDefault(); + issueButton.classList.add('opening'); + return this.issue.getIssueUrlForSystem().then(function(issueUrl) { + shell.openExternal(issueUrl); + return issueButton.classList.remove('opening'); + }); + }); + } + + }); + } else { + return Promise.resolve(); + } + } + + makeDismissable() { + if (!this.model.isDismissable()) { + clearTimeout(this.autohideTimeout); + this.model.options.dismissable = true; + this.model.dismissed = false; + return this.element.classList.add('has-close'); + } + } + + removeNotification() { + if (!this.element.classList.contains('remove')) { + this.element.classList.add('remove'); + return this.removeNotificationAfterTimeout(); + } + } + + handleRemoveNotificationClick() { + this.removeNotification(); + return this.model.dismiss(); + } + + handleRemoveAllNotificationsClick() { + const notifications = atom.notifications.getNotifications(); + for (var notification of Array.from(notifications)) { + atom.views.getView(notification).removeNotification(); + if (notification.isDismissable() && !notification.isDismissed()) { + notification.dismiss(); + } + } + } + + handleStackTraceToggleClick(e, container) { + if (typeof e.preventDefault === 'function') { + e.preventDefault(); + } + if (container.style.display === 'none') { + e.currentTarget.innerHTML = 'Hide Stack Trace'; + return container.style.display = 'block'; + } else { + e.currentTarget.innerHTML = 'Show Stack Trace'; + return container.style.display = 'none'; + } + } + + autohide() { + return this.autohideTimeout = setTimeout(() => { + return this.removeNotification(); + } + , this.visibilityDuration); + } + + removeNotificationAfterTimeout() { + if (this.element === document.activeElement) { atom.workspace.getActivePane().activate(); } + + return setTimeout(() => { + return this.element.remove(); + } + , this.animationDuration); // keep in sync with CSS animation + } + + getButtonClass() { + const type = `btn-${this.model.getType()}`; + if (type === 'btn-fatal') { return 'btn-error'; } else { return type; } + } + }; + NotificationElement.initClass(); + return NotificationElement; +})()); + +var addSplitLinesToContainer = function(container, content) { + if (typeof content !== 'string') { content = content.toString(); } + for (var line of Array.from(content.split('\n'))) { + var div = document.createElement('div'); + div.classList.add('line'); + div.textContent = line; + container.appendChild(div); + } +}; + +function __guard__(value, transform) { + return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined; +} diff --git a/packages/notifications/lib/notification-issue.js b/packages/notifications/lib/notification-issue.js new file mode 100644 index 000000000..3bfd14c12 --- /dev/null +++ b/packages/notifications/lib/notification-issue.js @@ -0,0 +1,317 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS202: Simplify dynamic range loops + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +let NotificationIssue; +const fs = require('fs-plus'); +const path = require('path'); +const StackTraceParser = require('stacktrace-parser'); + +const CommandLogger = require('./command-logger'); +const UserUtilities = require('./user-utilities'); + +const TITLE_CHAR_LIMIT = 100; // Truncate issue title to 100 characters (including ellipsis) + +const FileURLRegExp = new RegExp('file://\w*/(.*)'); + +module.exports = class NotificationIssue { + constructor(notification) { + this.normalizedStackPaths = this.normalizedStackPaths.bind(this); + this.notification = notification; + } + + findSimilarIssues() { + let repoUrl = this.getRepoUrl(); + if (repoUrl == null) { repoUrl = 'pulsar-edit/pulsar'; } + const repo = repoUrl.replace(/http(s)?:\/\/(\d+\.)?github.com\//gi, ''); + const issueTitle = this.getIssueTitle(); + const query = `${issueTitle} repo:${repo}`; + const githubHeaders = new Headers({ + accept: 'application/vnd.github.v3+json', + contentType: "application/json" + }); + + return fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(query)}&sort=created`, {headers: githubHeaders}) + .then(r => r != null ? r.json() : undefined) + .then(function(data) { + if ((data != null ? data.items : undefined) != null) { + const issues = {}; + for (var issue of Array.from(data.items)) { + if ((issue.title.indexOf(issueTitle) > -1) && (issues[issue.state] == null)) { + issues[issue.state] = issue; + if ((issues.open != null) && (issues.closed != null)) { return issues; } + } + } + + if ((issues.open != null) || (issues.closed != null)) { return issues; } + } + return null; + }).catch(_ => null); + } + + getIssueUrlForSystem() { + // Windows will not launch URLs greater than ~2000 bytes so we need to shrink it + // Also is.gd has a limit of 5000 bytes... + return this.getIssueUrl().then(issueUrl => fetch("https://is.gd/create.php?format=simple", { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: `url=${encodeURIComponent(issueUrl)}` + }) + .then(r => r.text()) + .catch(e => null)); + } + + getIssueUrl() { + return this.getIssueBody().then(issueBody => { + let repoUrl = this.getRepoUrl(); + if (repoUrl == null) { + repoUrl = 'https://github.com/pulsar-edit/pulsar'; + } + return `${repoUrl}/issues/new?title=${this.encodeURI(this.getIssueTitle())}&body=${this.encodeURI(issueBody)}`; + }); + } + + encodeURI(str) { + return encodeURI(str).replace(/#/g, '%23').replace(/;/g, '%3B').replace(/%20/g, '+'); + } + + getIssueTitle() { + let title = this.notification.getMessage(); + title = title.replace(process.env.ATOM_HOME, '$ATOM_HOME'); + if (process.platform === 'win32') { + title = title.replace(process.env.USERPROFILE, '~'); + title = title.replace(path.sep, path.posix.sep); // Standardize issue titles + } else { + title = title.replace(process.env.HOME, '~'); + } + + if (title.length > TITLE_CHAR_LIMIT) { + title = title.substring(0, TITLE_CHAR_LIMIT - 3) + '...'; + } + return title.replace(/\r?\n|\r/g, ""); + } + + getIssueBody() { + return new Promise((resolve, reject) => { + if (this.issueBody) { return resolve(this.issueBody); } + const systemPromise = UserUtilities.getOSVersion(); + const nonCorePackagesPromise = UserUtilities.getNonCorePackages(); + + return Promise.all([systemPromise, nonCorePackagesPromise]).then(all => { + let packageMessage, packageVersion; + const [systemName, nonCorePackages] = Array.from(all); + + const message = this.notification.getMessage(); + const options = this.notification.getOptions(); + const repoUrl = this.getRepoUrl(); + const packageName = this.getPackageName(); + if (packageName != null) { + packageVersion = atom.packages.getLoadedPackage(packageName)?.metadata?.version; + } + const copyText = ''; + const systemUser = process.env.USER; + let rootUserStatus = ''; + + if (systemUser === 'root') { + rootUserStatus = '**User**: root'; + } + + if ((packageName != null) && (repoUrl != null)) { + packageMessage = `[${packageName}](${repoUrl}) package ${packageVersion}`; + } else if (packageName != null) { + packageMessage = `'${packageName}' package v${packageVersion}`; + } else { + packageMessage = 'Pulsar Core'; + } + + this.issueBody = `\ + + +### Prerequisites + +* [ ] Put an X between the brackets on this line if you have done all of the following: + * Reproduced the problem in Safe Mode: + * Followed all applicable steps in the debugging guide: + * Checked the FAQs on the message board for common solutions: + * Checked that your issue isn't already filed: + * Checked that there is not already an Pulsar package that provides the described functionality: + +### Description + + + +### Steps to Reproduce + +1. +2. +3. + +**Expected behavior:** + + + +**Actual behavior:** + + + +### Versions + +**Pulsar**: ${atom.getVersion()} ${process.arch} +**Electron**: ${process.versions.electron} +**OS**: ${systemName} +**Thrown From**: ${packageMessage} +${rootUserStatus} + +### Stack Trace + +${message} + +\`\`\` +At ${options.detail} + +${this.normalizedStackPaths(options.stack)} +\`\`\` + +### Commands + +${CommandLogger.instance().getText()} + +### Non-Core Packages + +\`\`\` +${nonCorePackages.join('\n')} +\`\`\` + +### Additional Information + + +${copyText}\ +`; + return resolve(this.issueBody); + }); + }); + } + + normalizedStackPaths(stack) { + return stack != null ? stack.replace(/(^\W+at )([\w.]{2,} [(])?(.*)(:\d+:\d+[)]?)/gm, (m, p1, p2, p3, p4) => p1 + (p2 || '') + + this.normalizePath(p3) + p4 + ) : undefined; + } + + normalizePath(path) { + return path.replace('file:///', '') // Randomly inserted file url protocols + .replace(/[/]/g, '\\') // Temp switch for Windows home matching + .replace(fs.getHomeDirectory(), '~') // Remove users home dir for apm-dev'ed packages + .replace(/\\/g, '/') // Switch \ back to / for everyone + .replace(/.*(\/(app\.asar|packages\/).*)/, '$1'); // Remove everything before app.asar or pacakges + } + + getRepoUrl() { + const packageName = this.getPackageName(); + if (packageName == null) { return; } + let repo = atom.packages.getLoadedPackage(packageName)?.metadata?.repository; + let repoUrl = (repo != null ? repo.url : undefined) != null ? (repo != null ? repo.url : undefined) : repo; + if (!repoUrl) { + let packagePath; + if (packagePath = atom.packages.resolvePackagePath(packageName)) { + try { + repo = JSON.parse(fs.readFileSync(path.join(packagePath, 'package.json')))?.repository; + repoUrl = (repo != null ? repo.url : undefined) != null ? (repo != null ? repo.url : undefined) : repo; + } catch (error) {} + } + } + + return repoUrl != null ? repoUrl.replace(/\.git$/, '').replace(/^git\+/, '') : undefined; + } + + getPackageNameFromFilePath(filePath) { + if (!filePath) { return; } + + let packageName = __guard__(/\/\.atom\/dev\/packages\/([^\/]+)\//.exec(filePath), x => x[1]); + if (packageName) { return packageName; } + + packageName = __guard__(/\\\.atom\\dev\\packages\\([^\\]+)\\/.exec(filePath), x1 => x1[1]); + if (packageName) { return packageName; } + + packageName = __guard__(/\/\.atom\/packages\/([^\/]+)\//.exec(filePath), x2 => x2[1]); + if (packageName) { return packageName; } + + packageName = __guard__(/\\\.atom\\packages\\([^\\]+)\\/.exec(filePath), x3 => x3[1]); + if (packageName) { return packageName; } + } + + getPackageName() { + let packageName, packagePath; + const options = this.notification.getOptions(); + + if (options.packageName != null) { return options.packageName; } + if ((options.stack == null) && (options.detail == null)) { return; } + + const packagePaths = this.getPackagePathsByPackageName(); + for (packageName in packagePaths) { + packagePath = packagePaths[packageName]; + if ((packagePath.indexOf(path.join('.atom', 'dev', 'packages')) > -1) || (packagePath.indexOf(path.join('.atom', 'packages')) > -1)) { + packagePaths[packageName] = fs.realpathSync(packagePath); + } + } + + const getPackageName = filePath => { + let match; + filePath = /\((.+?):\d+|\((.+)\)|(.+)/.exec(filePath)[0]; + + // Stack traces may be a file URI + if (match = FileURLRegExp.exec(filePath)) { + filePath = match[1]; + } + + filePath = path.normalize(filePath); + + if (path.isAbsolute(filePath)) { + for (var packName in packagePaths) { + packagePath = packagePaths[packName]; + if (filePath === 'node.js') { continue; } + var isSubfolder = filePath.indexOf(path.normalize(packagePath + path.sep)) === 0; + if (isSubfolder) { return packName; } + } + } + return this.getPackageNameFromFilePath(filePath); + }; + + if ((options.detail != null) && (packageName = getPackageName(options.detail))) { + return packageName; + } + + if (options.stack != null) { + const stack = StackTraceParser.parse(options.stack); + for (let i = 0, end = stack.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { + var {file} = stack[i]; + + // Empty when it was run from the dev console + if (!file) { return; } + packageName = getPackageName(file); + if (packageName != null) { return packageName; } + } + } + + } + + getPackagePathsByPackageName() { + const packagePathsByPackageName = {}; + for (var pack of Array.from(atom.packages.getLoadedPackages())) { + packagePathsByPackageName[pack.name] = pack.path; + } + return packagePathsByPackageName; + } +} + +function __guard__(value, transform) { + return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined; +} diff --git a/packages/notifications/lib/notifications-log-item.js b/packages/notifications/lib/notifications-log-item.js new file mode 100644 index 000000000..cb87c0038 --- /dev/null +++ b/packages/notifications/lib/notifications-log-item.js @@ -0,0 +1,110 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +let NotificationsLogItem; +const {Emitter, CompositeDisposable, Disposable} = require('atom'); +const moment = require('moment'); + +module.exports = (NotificationsLogItem = (function() { + NotificationsLogItem = class NotificationsLogItem { + static initClass() { + this.prototype.subscriptions = null; + this.prototype.timestampInterval = null; + } + + constructor(notification) { + this.notification = notification; + this.emitter = new Emitter; + this.subscriptions = new CompositeDisposable; + this.render(); + } + + render() { + const notificationView = atom.views.getView(this.notification); + const notificationElement = this.renderNotification(notificationView); + + this.timestamp = document.createElement('div'); + this.timestamp.classList.add('timestamp'); + this.notification.moment = moment(this.notification.getTimestamp()); + this.subscriptions.add(atom.tooltips.add(this.timestamp, {title: this.notification.moment.format("ll LTS")})); + this.updateTimestamp(); + this.timestampInterval = setInterval(this.updateTimestamp.bind(this), 60 * 1000); + this.subscriptions.add(new Disposable(() => clearInterval(this.timestampInterval))); + + this.element = document.createElement('li'); + this.element.classList.add('notifications-log-item', this.notification.getType()); + this.element.appendChild(notificationElement); + this.element.appendChild(this.timestamp); + this.element.addEventListener('click', e => { + if (e.target.closest('.btn-toolbar a, .btn-toolbar button') == null) { + return this.emitter.emit('click'); + } + }); + + this.element.getRenderPromise = () => notificationView.getRenderPromise(); + if (this.notification.getType() === 'fatal') { + notificationView.getRenderPromise().then(() => { + return this.element.replaceChild(this.renderNotification(notificationView), notificationElement); + }); + } + + return this.subscriptions.add(new Disposable(() => this.element.remove())); + } + + renderNotification(view) { + const message = document.createElement('div'); + message.classList.add('message'); + message.innerHTML = view.element.querySelector(".content > .message").innerHTML; + + const buttons = document.createElement('div'); + buttons.classList.add('btn-toolbar'); + const nButtons = view.element.querySelector(".content > .meta > .btn-toolbar"); + if (nButtons != null) { + for (var button of Array.from(nButtons.children)) { + var logButton = button.cloneNode(true); + logButton.originalButton = button; + logButton.addEventListener('click', function(e) { + const newEvent = new MouseEvent('click', e); + return e.target.originalButton.dispatchEvent(newEvent); + }); + for (var tooltip of Array.from(atom.tooltips.findTooltips(button))) { + this.subscriptions.add(atom.tooltips.add(logButton, tooltip.options)); + } + buttons.appendChild(logButton); + } + } + + const nElement = document.createElement('div'); + nElement.classList.add('notifications-log-notification', 'icon', `icon-${this.notification.getIcon()}`, this.notification.getType()); + nElement.appendChild(message); + nElement.appendChild(buttons); + return nElement; + } + + getElement() { return this.element; } + + destroy() { + this.subscriptions.dispose(); + return this.emitter.emit('did-destroy'); + } + + onClick(callback) { + return this.emitter.on('click', callback); + } + + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); + } + + updateTimestamp() { + return this.timestamp.textContent = this.notification.moment.fromNow(); + } + }; + NotificationsLogItem.initClass(); + return NotificationsLogItem; +})()); diff --git a/packages/notifications/lib/notifications-log.js b/packages/notifications/lib/notifications-log.js new file mode 100644 index 000000000..f5b79c05c --- /dev/null +++ b/packages/notifications/lib/notifications-log.js @@ -0,0 +1,148 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +let NotificationsLog; +const {Emitter, CompositeDisposable, Disposable} = require('atom'); +const NotificationsLogItem = require('./notifications-log-item'); + +const typeIcons = { + fatal: 'bug', + error: 'flame', + warning: 'alert', + info: 'info', + success: 'check' +}; + +module.exports = (NotificationsLog = (function() { + NotificationsLog = class NotificationsLog { + static initClass() { + this.prototype.subscriptions = null; + this.prototype.logItems = []; + this.prototype.typesHidden = { + fatal: false, + error: false, + warning: false, + info: false, + success: false + }; + } + + constructor(duplicateTimeDelay, typesHidden = null) { + this.duplicateTimeDelay = duplicateTimeDelay; + if (typesHidden != null) { this.typesHidden = typesHidden; } + this.emitter = new Emitter; + this.subscriptions = new CompositeDisposable; + this.subscriptions.add(atom.notifications.onDidClearNotifications(() => this.clearLogItems())); + this.render(); + this.subscriptions.add(new Disposable(() => this.clearLogItems())); + } + + render() { + let button; + this.element = document.createElement('div'); + this.element.classList.add('notifications-log'); + + const header = document.createElement('header'); + this.element.appendChild(header); + + this.list = document.createElement('ul'); + this.list.classList.add('notifications-log-items'); + this.element.appendChild(this.list); + + for (var type in typeIcons) { + var icon = typeIcons[type]; + button = document.createElement('button'); + button.classList.add('notification-type', 'btn', 'icon', `icon-${icon}`, type); + button.classList.toggle('show-type', !this.typesHidden[type]); + this.list.classList.toggle(`hide-${type}`, this.typesHidden[type]); + button.dataset.type = type; + button.addEventListener('click', e => this.toggleType(e.target.dataset.type)); + this.subscriptions.add(atom.tooltips.add(button, {title: `Toggle ${type} notifications`})); + header.appendChild(button); + } + + button = document.createElement('button'); + button.classList.add('notifications-clear-log', 'btn', 'icon', 'icon-trashcan'); + button.addEventListener('click', e => atom.commands.dispatch(atom.views.getView(atom.workspace), "notifications:clear-log")); + this.subscriptions.add(atom.tooltips.add(button, {title: "Clear notifications"})); + header.appendChild(button); + + let lastNotification = null; + for (var notification of Array.from(atom.notifications.getNotifications())) { + if (lastNotification != null) { + // do not show duplicates unless some amount of time has passed + var timeSpan = notification.getTimestamp() - lastNotification.getTimestamp(); + if (!(timeSpan < this.duplicateTimeDelay) || !notification.isEqual(lastNotification)) { + this.addNotification(notification); + } + } else { + this.addNotification(notification); + } + + lastNotification = notification; + } + + return this.subscriptions.add(new Disposable(() => this.element.remove())); + } + + destroy() { + this.subscriptions.dispose(); + return this.emitter.emit('did-destroy'); + } + + getElement() { return this.element; } + + getURI() { return 'atom://notifications/log'; } + + getTitle() { return 'Log'; } + + getLongTitle() { return 'Notifications Log'; } + + getIconName() { return 'alert'; } + + getDefaultLocation() { return 'bottom'; } + + getAllowedLocations() { return ['left', 'right', 'bottom']; } + + serialize() { + return { + typesHidden: this.typesHidden, + deserializer: 'notifications/NotificationsLog' + }; + } + + toggleType(type, force) { + const button = this.element.querySelector(`.notification-type.${type}`); + const hide = !button.classList.toggle('show-type', force); + this.list.classList.toggle(`hide-${type}`, hide); + return this.typesHidden[type] = hide; + } + + addNotification(notification) { + const logItem = new NotificationsLogItem(notification); + logItem.onClick(() => this.emitter.emit('item-clicked', notification)); + this.logItems.push(logItem); + return this.list.insertBefore(logItem.getElement(), this.list.firstChild); + } + + onItemClick(callback) { + return this.emitter.on('item-clicked', callback); + } + + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); + } + + clearLogItems() { + for (var logItem of Array.from(this.logItems)) { logItem.destroy(); } + return this.logItems = []; + } + }; + NotificationsLog.initClass(); + return NotificationsLog; +})()); diff --git a/packages/notifications/lib/template-helper.js b/packages/notifications/lib/template-helper.js new file mode 100644 index 000000000..52f923f50 --- /dev/null +++ b/packages/notifications/lib/template-helper.js @@ -0,0 +1,17 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +module.exports = { + create(htmlString) { + const template = document.createElement('template'); + template.innerHTML = htmlString; + document.body.appendChild(template); + return template; + }, + + render(template) { + return document.importNode(template.content, true); + } +}; diff --git a/packages/notifications/lib/user-utilities.js b/packages/notifications/lib/user-utilities.js new file mode 100644 index 000000000..c82583633 --- /dev/null +++ b/packages/notifications/lib/user-utilities.js @@ -0,0 +1,193 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); +const {BufferedProcess} = require('atom'); + +/* +A collection of methods for retrieving information about the user's system for +bug report purposes. +*/ + +const DEV_PACKAGE_PATH = path.join('dev', 'packages'); + +module.exports = { + + /* + Section: System Information + */ + + getPlatform() { + return os.platform(); + }, + + // OS version strings lifted from https://github.com/lee-dohm/bug-report + getOSVersion() { + return new Promise((resolve, reject) => { + switch (this.getPlatform()) { + case 'darwin': return resolve(this.macVersionText()); + case 'win32': return resolve(this.winVersionText()); + case 'linux': return resolve(this.linuxVersionText()); + default: return resolve(`${os.platform()} ${os.release()}`); + } + }); + }, + + macVersionText() { + return this.macVersionInfo().then(function(info) { + if (!info.ProductName || !info.ProductVersion) { return 'Unknown macOS version'; } + return `${info.ProductName} ${info.ProductVersion}`; + }); + }, + + macVersionInfo() { + return new Promise(function(resolve, reject) { + let stdout = ''; + const plistBuddy = new BufferedProcess({ + command: '/usr/libexec/PlistBuddy', + args: [ + '-c', + 'Print ProductVersion', + '-c', + 'Print ProductName', + '/System/Library/CoreServices/SystemVersion.plist' + ], + stdout(output) { return stdout += output; }, + exit() { + const [ProductVersion, ProductName] = Array.from(stdout.trim().split('\n')); + return resolve({ProductVersion, ProductName}); + } + }); + + return plistBuddy.onWillThrowError(function({handle}) { + handle(); + return resolve({}); + }); + }); + }, + + linuxVersionText() { + return this.linuxVersionInfo().then(function(info) { + if (info.DistroName && info.DistroVersion) { + return `${info.DistroName} ${info.DistroVersion}`; + } else { + return `${os.platform()} ${os.release()}`; + } + }); + }, + + linuxVersionInfo() { + return new Promise(function(resolve, reject) { + let stdout = ''; + + const lsbRelease = new BufferedProcess({ + command: 'lsb_release', + args: ['-ds'], + stdout(output) { return stdout += output; }, + exit(exitCode) { + const [DistroName, DistroVersion] = Array.from(stdout.trim().split(' ')); + return resolve({DistroName, DistroVersion}); + } + }); + + return lsbRelease.onWillThrowError(function({handle}) { + handle(); + return resolve({}); + }); + }); + }, + + winVersionText() { + return new Promise(function(resolve, reject) { + const data = []; + const systemInfo = new BufferedProcess({ + command: 'systeminfo', + stdout(oneLine) { return data.push(oneLine); }, + exit() { + let res; + let info = data.join('\n'); + info = (res = /OS.Name.\s+(.*)$/im.exec(info)) ? res[1] : 'Unknown Windows version'; + return resolve(info); + } + }); + + return systemInfo.onWillThrowError(function({handle}) { + handle(); + return resolve('Unknown Windows version'); + }); + }); + }, + + /* + Section: Installed Packages + */ + + getNonCorePackages() { + return new Promise(function(resolve, reject) { + const nonCorePackages = atom.packages.getAvailablePackageMetadata().filter(p => !atom.packages.isBundledPackage(p.name)); + const devPackageNames = atom.packages.getAvailablePackagePaths().filter(p => p.includes(DEV_PACKAGE_PATH)).map(p => path.basename(p)); + return resolve(Array.from(nonCorePackages).map((pack) => `${pack.name} ${pack.version} ${Array.from(devPackageNames).includes(pack.name) ? '(dev)' : ''}`)); + }); + }, + + checkPulsarUpToDate() { + const installedVersion = atom.getVersion().replace(/-.*$/, ''); + return { + upToDate: true, + latestVersion: installedVersion, + installedVersion + } + }, + + getPackageVersion(packageName) { + const pack = atom.packages.getLoadedPackage(packageName); + return (pack != null ? pack.metadata.version : undefined); + }, + + getPackageVersionShippedWithPulsar(packageName) { + return require(path.join(atom.getLoadSettings().resourcePath, 'package.json')).packageDependencies[packageName]; + }, + + getLatestPackageData(packageName) { + const githubHeaders = new Headers({ + accept: 'application/json', + contentType: "application/json" + }); + const apiURL = process.env.ATOM_API_URL || 'https://api.pulsar-edit.dev/api'; + return fetch(`${apiURL}/${packageName}`, {headers: githubHeaders}) + .then(r => { + if (r.ok) { + return r.json(); + } else { + return Promise.reject(new Error(`Fetching updates resulted in status ${r.status}`)); + } + }); + }, + + checkPackageUpToDate(packageName) { + return this.getLatestPackageData(packageName).then(latestPackageData => { + let isCore; + const installedVersion = this.getPackageVersion(packageName); + let upToDate = (installedVersion != null) && semver.gte(installedVersion, latestPackageData?.releases?.latest); + const latestVersion = latestPackageData?.releases?.latest; + const versionShippedWithPulsar = this.getPackageVersionShippedWithPulsar(packageName); + + if (isCore = (versionShippedWithPulsar != null)) { + // A core package is out of date if the version which is being used + // is lower than the version which normally ships with the version + // of Pulsar which is running. This will happen when there's a locally + // installed version of the package with a lower version than Pulsar's. + upToDate = (installedVersion != null) && semver.gte(installedVersion, versionShippedWithPulsar); + } + + return {isCore, upToDate, latestVersion, installedVersion, versionShippedWithPulsar}; + }); + } +}; diff --git a/packages/notifications/package.json b/packages/notifications/package.json new file mode 100644 index 000000000..f89f157d0 --- /dev/null +++ b/packages/notifications/package.json @@ -0,0 +1,39 @@ +{ + "name": "notifications", + "main": "./lib/main", + "version": "0.73.0", + "repository": "https://github.com/pulsar-edit/pulsar", + "description": "A tidy way to display Pulsar notifications.", + "license": "MIT", + "engines": { + "atom": ">0.50.0" + }, + "dependencies": { + "dompurify": "^1.0.3", + "fs-plus": "^3.0.0", + "marked": "^0.3.6", + "moment": "^2.19.3", + "semver": "^4.3.2", + "stacktrace-parser": "^0.1.3", + "temp": "^0.8.1" + }, + "devDependencies": { + "coffeelint": "^1.9.7" + }, + "configSchema": { + "showErrorsInDevMode": { + "type": "boolean", + "default": false, + "description": "Show notifications for uncaught exceptions even if Pulsar is running in dev mode. If this config setting is disabled, uncaught exceptions will trigger the dev tools to open and be logged in the console tab." + }, + "defaultTimeout": { + "type": "integer", + "default": 5000, + "minimum": 1000, + "description": "The default notification timeout for a non-dismissable notification." + } + }, + "deserializers": { + "notifications/NotificationsLog": "createLog" + } +} diff --git a/packages/notifications/spec/command-logger-spec.coffee b/packages/notifications/spec/command-logger-spec.coffee new file mode 100644 index 000000000..7a583bfb4 --- /dev/null +++ b/packages/notifications/spec/command-logger-spec.coffee @@ -0,0 +1,141 @@ +# Originally from lee-dohm/bug-report + +CommandLogger = require '../lib/command-logger' + +describe 'CommandLogger', -> + [element, logger] = [] + + dispatch = (command) -> + atom.commands.dispatch(element, command) + + beforeEach -> + element = document.createElement("section") + element.id = "some-id" + element.className = "some-class another-class" + logger = new CommandLogger + logger.start() + + describe 'logging of commands', -> + it 'catches the name of the command', -> + dispatch('foo:bar') + expect(logger.latestEvent().name).toBe 'foo:bar' + + it 'catches the target of the command', -> + dispatch('foo:bar') + expect(logger.latestEvent().targetNodeName).toBe "SECTION" + expect(logger.latestEvent().targetClassName).toBe "some-class another-class" + expect(logger.latestEvent().targetId).toBe "some-id" + + it 'logs repeat commands as one command', -> + dispatch('foo:bar') + dispatch('foo:bar') + + expect(logger.latestEvent().name).toBe 'foo:bar' + expect(logger.latestEvent().count).toBe 2 + + it 'ignores show.bs.tooltip commands', -> + dispatch('show.bs.tooltip') + + expect(logger.latestEvent().name).not.toBe 'show.bs.tooltip' + + it 'ignores editor:display-updated commands', -> + dispatch('editor:display-updated') + + expect(logger.latestEvent().name).not.toBe 'editor:display-updated' + + it 'ignores mousewheel commands', -> + dispatch('mousewheel') + + expect(logger.latestEvent().name).not.toBe 'mousewheel' + + it 'only logs up to `logSize` commands', -> + dispatch(char) for char in ['a'..'z'] + + expect(logger.eventLog.length).toBe(logger.logSize) + + describe 'formatting of text log', -> + it 'does not output empty log items', -> + expect(logger.getText()).toBe """ + ``` + ``` + """ + + it 'formats commands with the time, name and target', -> + dispatch('foo:bar') + + expect(logger.getText()).toBe """ + ``` + -0:00.0 foo:bar (section#some-id.some-class.another-class) + ``` + """ + + it 'omits the target ID if it has none', -> + element.id = "" + + dispatch('foo:bar') + + expect(logger.getText()).toBe """ + ``` + -0:00.0 foo:bar (section.some-class.another-class) + ``` + """ + + it 'formats commands in chronological order', -> + dispatch('foo:first') + dispatch('foo:second') + dispatch('foo:third') + + expect(logger.getText()).toBe """ + ``` + -0:00.0 foo:first (section#some-id.some-class.another-class) + -0:00.0 foo:second (section#some-id.some-class.another-class) + -0:00.0 foo:third (section#some-id.some-class.another-class) + ``` + """ + + it 'displays a multiplier for repeated commands', -> + dispatch('foo:bar') + dispatch('foo:bar') + + expect(logger.getText()).toBe """ + ``` + 2x -0:00.0 foo:bar (section#some-id.some-class.another-class) + ``` + """ + + it 'logs the external data event as the last event', -> + dispatch('foo:bar') + event = + time: Date.now() + title: 'bummer' + + expect(logger.getText(event)).toBe """ + ``` + -0:00.0 foo:bar (section#some-id.some-class.another-class) + -0:00.0 bummer + ``` + """ + + it 'does not report anything older than ten minutes', -> + logger.logCommand + type: 'foo:bar' + time: Date.now() - 11 * 60 * 1000 + target: nodeName: 'DIV' + + logger.logCommand + type: 'wow:bummer' + target: nodeName: 'DIV' + + expect(logger.getText()).toBe """ + ``` + -0:00.0 wow:bummer (div) + ``` + """ + + it 'does not report commands that have no name', -> + dispatch('') + + expect(logger.getText()).toBe """ + ``` + ``` + """ diff --git a/packages/notifications/spec/helper.coffee b/packages/notifications/spec/helper.coffee new file mode 100644 index 000000000..72011e665 --- /dev/null +++ b/packages/notifications/spec/helper.coffee @@ -0,0 +1,35 @@ + +### +A collection of methods for retrieving information about the user's system for +bug report purposes. +### + +module.exports = + + generateException: -> + try + a + 1 + catch e + errMsg = "#{e.toString()} in #{process.env.ATOM_HOME}/somewhere" + window.onerror.call(window, errMsg, '/dev/null', 2, 3, e) + + # shortenerResponse + # packageResponse + # issuesResponse + generateFakeFetchResponses: (options) -> + spyOn(window, 'fetch') unless window.fetch.isSpy + + fetch.andCallFake (url) -> + if url.indexOf('api.pulsar-edit.dev/api') > -1 + return jsonPromise(options?.packageResponse ? { + repository: url: 'https://github.com/pulsar-edit/notifications' + releases: latest: '0.0.0' + }) + + if options?.issuesErrorResponse? + return Promise.reject(options?.issuesErrorResponse) + + jsonPromise(options?.issuesResponse ? {items: []}) + +jsonPromise = (object) -> Promise.resolve {ok: true, json: -> Promise.resolve object} +textPromise = (text) -> Promise.resolve {ok: true, text: -> Promise.resolve text} diff --git a/packages/notifications/spec/notifications-log-spec.coffee b/packages/notifications/spec/notifications-log-spec.coffee new file mode 100644 index 000000000..ab9d8a0a0 --- /dev/null +++ b/packages/notifications/spec/notifications-log-spec.coffee @@ -0,0 +1,325 @@ +{Notification} = require 'atom' +NotificationElement = require '../lib/notification-element' +NotificationIssue = require '../lib/notification-issue' +NotificationsLog = require '../lib/notifications-log' +{generateFakeFetchResponses, generateException} = require './helper' + +describe "Notifications Log", -> + workspaceElement = null + + beforeEach -> + workspaceElement = atom.views.getView(atom.workspace) + atom.notifications.clear() + jasmine.attachToDOM(workspaceElement) + + waitsForPromise -> + atom.packages.activatePackage('notifications') + + waitsForPromise -> + atom.workspace.open(NotificationsLog::getURI()) + + describe "when the package is activated", -> + it "attaches an atom-notifications element to the dom", -> + expect(workspaceElement.querySelector('.notifications-log-items')).toBeDefined() + + describe "when there are notifications before activation", -> + beforeEach -> + waitsForPromise -> + atom.packages.deactivatePackage('notifications') + + it "displays all non displayed notifications", -> + warning = new Notification('warning', 'Un-displayed warning') + error = new Notification('error', 'Displayed error') + error.setDisplayed(true) + + atom.notifications.addNotification(error) + atom.notifications.addNotification(warning) + + waitsForPromise -> + atom.packages.activatePackage('notifications') + + waitsForPromise -> + atom.workspace.open(NotificationsLog::getURI()) + + runs -> + notificationsLogContainer = workspaceElement.querySelector('.notifications-log-items') + notification = notificationsLogContainer.querySelector('.notifications-log-notification.warning') + expect(notification).toExist() + notification = notificationsLogContainer.querySelector('.notifications-log-notification.error') + expect(notification).toExist() + + describe "when notifications are added to atom.notifications", -> + notificationsLogContainer = null + + beforeEach -> + enableInitNotification = atom.notifications.addSuccess('A message to trigger initialization', dismissable: true) + enableInitNotification.dismiss() + advanceClock(NotificationElement::visibilityDuration) + advanceClock(NotificationElement::animationDuration) + + notificationsLogContainer = workspaceElement.querySelector('.notifications-log-items') + jasmine.attachToDOM(workspaceElement) + + generateFakeFetchResponses() + + it "adds an .notifications-log-item element to the container with a class corresponding to the type", -> + atom.notifications.addSuccess('A message') + notification = notificationsLogContainer.querySelector('.notifications-log-item.success') + expect(notificationsLogContainer.childNodes).toHaveLength 2 + expect(notification.querySelector('.message').textContent.trim()).toBe 'A message' + expect(notification.querySelector('.btn-toolbar')).toBeEmpty() + + atom.notifications.addInfo('A message') + expect(notificationsLogContainer.childNodes).toHaveLength 3 + expect(notificationsLogContainer.querySelector('.notifications-log-item.info')).toBeDefined() + + atom.notifications.addWarning('A message') + expect(notificationsLogContainer.childNodes).toHaveLength 4 + expect(notificationsLogContainer.querySelector('.notifications-log-item.warning')).toBeDefined() + + atom.notifications.addError('A message') + expect(notificationsLogContainer.childNodes).toHaveLength 5 + expect(notificationsLogContainer.querySelector('.notifications-log-item.error')).toBeDefined() + + atom.notifications.addFatalError('A message') + notification = notificationsLogContainer.querySelector('.notifications-log-item.fatal') + expect(notificationsLogContainer.childNodes).toHaveLength 6 + expect(notification).toBeDefined() + expect(notification.querySelector('.btn-toolbar')).not.toBeEmpty() + + describe "when the `buttons` options is used", -> + it "displays the buttons in the .btn-toolbar element", -> + clicked = [] + atom.notifications.addSuccess 'A message', + buttons: [{ + text: 'Button One' + className: 'btn-one' + onDidClick: -> clicked.push 'one' + }, { + text: 'Button Two' + className: 'btn-two' + onDidClick: -> clicked.push 'two' + }] + + notification = notificationsLogContainer.querySelector('.notifications-log-item.success') + expect(notification.querySelector('.btn-toolbar')).not.toBeEmpty() + + btnOne = notification.querySelector('.btn-one') + btnTwo = notification.querySelector('.btn-two') + + expect(btnOne).toHaveClass 'btn-success' + expect(btnOne.textContent).toBe 'Button One' + expect(btnTwo).toHaveClass 'btn-success' + expect(btnTwo.textContent).toBe 'Button Two' + + btnTwo.click() + btnOne.click() + + expect(clicked).toEqual ['two', 'one'] + + describe "when an exception is thrown", -> + fatalError = null + + describe "when the there is an error searching for the issue", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses(issuesErrorResponse: '403') + generateException() + fatalError = notificationsLogContainer.querySelector('.notifications-log-item.fatal') + waitsForPromise -> + fatalError.getRenderPromise() + + it "asks the user to create an issue", -> + button = fatalError.querySelector('.btn') + copyReport = fatalError.querySelector('.btn-copy-report') + expect(button).toBeDefined() + expect(button.textContent).toContain 'Create issue' + expect(copyReport).toBeDefined() + + describe "when the package is out of date", -> + beforeEach -> + installedVersion = '0.9.0' + UserUtilities = require '../lib/user-utilities' + spyOn(UserUtilities, 'getPackageVersion').andCallFake -> installedVersion + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses + packageResponse: + repository: url: 'https://github.com/someguy/somepackage' + releases: latest: '0.10.0' + spyOn(NotificationIssue.prototype, 'getPackageName').andCallFake -> "somepackage" + spyOn(NotificationIssue.prototype, 'getRepoUrl').andCallFake -> "https://github.com/someguy/somepackage" + generateException() + fatalError = notificationsLogContainer.querySelector('.notifications-log-item.fatal') + waitsForPromise -> + fatalError.getRenderPromise() + + it "asks the user to update their packages", -> + button = fatalError.querySelector('.btn') + + expect(button.textContent).toContain 'Check for package updates' + expect(button.getAttribute('href')).toBe '#' + + describe "when the error has been reported", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses + issuesResponse: + items: [ + { + title: 'ReferenceError: a is not defined in $ATOM_HOME/somewhere' + html_url: 'http://url.com/ok' + state: 'open' + } + ] + spyOn(NotificationIssue.prototype, 'getPackageName').andCallFake -> "somepackage" + spyOn(NotificationIssue.prototype, 'getRepoUrl').andCallFake -> "https://github.com/someguy/somepackage" + generateException() + fatalError = notificationsLogContainer.querySelector('.notifications-log-item.fatal') + waitsForPromise -> + fatalError.getRenderPromise() + + it "shows the user a view issue button", -> + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'View Issue' + expect(button.getAttribute('href')).toBe 'http://url.com/ok' + + describe "when a log item is clicked", -> + [notification, notificationView, logItem] = [] + + describe "when the notification is not dismissed", -> + + describe "when the notification is not dismissable", -> + + beforeEach -> + notification = atom.notifications.addInfo('A message') + notificationView = atom.views.getView(notification) + logItem = notificationsLogContainer.querySelector('.notifications-log-item.info') + + it "makes the notification dismissable", -> + logItem.click() + expect(notificationView.element.classList.contains('has-close')).toBe true + expect(notification.isDismissable()).toBe true + + advanceClock(NotificationElement::visibilityDuration) + advanceClock(NotificationElement::animationDuration) + expect(notificationView.element).toBeVisible() + + describe "when the notification is dismissed", -> + + beforeEach -> + notification = atom.notifications.addInfo('A message', dismissable: true) + notificationView = atom.views.getView(notification) + logItem = notificationsLogContainer.querySelector('.notifications-log-item.info') + notification.dismiss() + advanceClock(NotificationElement::animationDuration) + + it "displays the notification", -> + didDisplay = false + notification.onDidDisplay -> didDisplay = true + logItem.click() + + expect(didDisplay).toBe true + expect(notification.dismissed).toBe false + expect(notificationView.element).toBeVisible() + + describe "when the notification is dismissed again", -> + + it "emits did-dismiss", -> + didDismiss = false + notification.onDidDismiss -> didDismiss = true + logItem.click() + + notification.dismiss() + advanceClock(NotificationElement::animationDuration) + + expect(didDismiss).toBe true + expect(notification.dismissed).toBe true + expect(notificationView.element).not.toBeVisible() + + describe "when notifications are cleared", -> + + beforeEach -> + clearButton = workspaceElement.querySelector('.notifications-log .notifications-clear-log') + atom.notifications.addInfo('A message', dismissable: true) + atom.notifications.addInfo('non-dismissable') + clearButton.click() + + it "clears the notifications", -> + expect(atom.notifications.getNotifications()).toHaveLength 0 + notifications = workspaceElement.querySelector('atom-notifications') + advanceClock(NotificationElement::animationDuration) + expect(notifications.children).toHaveLength 0 + logItems = workspaceElement.querySelector('.notifications-log-items') + expect(logItems.children).toHaveLength 0 + + describe "the dock pane", -> + notificationsLogPane = null + + beforeEach -> + notificationsLogPane = atom.workspace.paneForURI(NotificationsLog::getURI()) + + describe "when notifications:toggle-log is dispatched", -> + it "toggles the pane URI", -> + spyOn(atom.workspace, "toggle") + + atom.commands.dispatch(workspaceElement, "notifications:toggle-log") + expect(atom.workspace.toggle).toHaveBeenCalledWith(NotificationsLog::getURI()) + + describe "when the pane is destroyed", -> + + beforeEach -> + notificationsLogPane.destroyItems() + + it "opens the pane", -> + [notificationsLog] = [] + + waitsForPromise -> + atom.workspace.toggle(NotificationsLog::getURI()).then (paneItem) -> + notificationsLog = paneItem + + runs -> + expect(notificationsLog).toBeDefined() + + describe "when notifications are displayed", -> + + beforeEach -> + atom.notifications.addSuccess("success") + + it "lists all notifications", -> + waitsForPromise -> + atom.workspace.toggle(NotificationsLog::getURI()) + + runs -> + notificationsLogContainer = workspaceElement.querySelector('.notifications-log-items') + expect(notificationsLogContainer.childNodes).toHaveLength 1 + + describe "when the pane is hidden", -> + + beforeEach -> + atom.workspace.hide(NotificationsLog::getURI()) + + it "opens the pane", -> + [notificationsLog] = [] + + waitsForPromise -> + atom.workspace.toggle(NotificationsLog::getURI()).then (paneItem) -> + notificationsLog = paneItem + + runs -> + expect(notificationsLog).toBeDefined() + + describe "when the pane is open", -> + + beforeEach -> + waitsForPromise -> + atom.workspace.open(NotificationsLog::getURI()) + + it "closes the pane", -> + notificationsLog = null + + waitsForPromise -> + atom.workspace.toggle(NotificationsLog::getURI()).then (paneItem) -> + notificationsLog = paneItem + + runs -> + expect(notificationsLog).not.toBeDefined() diff --git a/packages/notifications/spec/notifications-spec.coffee b/packages/notifications/spec/notifications-spec.coffee new file mode 100644 index 000000000..920da3d73 --- /dev/null +++ b/packages/notifications/spec/notifications-spec.coffee @@ -0,0 +1,974 @@ +fs = require 'fs-plus' +path = require 'path' +temp = require('temp').track() +{Notification} = require 'atom' +NotificationElement = require '../lib/notification-element' +NotificationIssue = require '../lib/notification-issue' +UserUtils = require '../lib/user-utilities' +{generateFakeFetchResponses, generateException} = require './helper' + +describe "Notifications", -> + [workspaceElement, activationPromise] = [] + + beforeEach -> + workspaceElement = atom.views.getView(atom.workspace) + atom.notifications.clear() + activationPromise = atom.packages.activatePackage('notifications') + + waitsForPromise -> + activationPromise + + describe "when the package is activated", -> + it "attaches an atom-notifications element to the dom", -> + expect(workspaceElement.querySelector('atom-notifications')).toBeDefined() + + describe "when there are notifications before activation", -> + beforeEach -> + waitsForPromise -> + # Wrapped in Promise.resolve so this test continues to work on earlier versions of Pulsar + Promise.resolve(atom.packages.deactivatePackage('notifications')) + + it "displays all non displayed notifications", -> + warning = new Notification('warning', 'Un-displayed warning') + error = new Notification('error', 'Displayed error') + error.setDisplayed(true) + + atom.notifications.addNotification(error) + atom.notifications.addNotification(warning) + + activationPromise = atom.packages.activatePackage('notifications') + waitsForPromise -> + activationPromise + + runs -> + notificationContainer = workspaceElement.querySelector('atom-notifications') + notification = notificationContainer.querySelector('atom-notification.warning') + expect(notification).toExist() + notification = notificationContainer.querySelector('atom-notification.error') + expect(notification).not.toExist() + + describe "when notifications are added to atom.notifications", -> + notificationContainer = null + beforeEach -> + enableInitNotification = atom.notifications.addSuccess('A message to trigger initialization', dismissable: true) + enableInitNotification.dismiss() + advanceClock(NotificationElement::visibilityDuration) + advanceClock(NotificationElement::animationDuration) + + notificationContainer = workspaceElement.querySelector('atom-notifications') + jasmine.attachToDOM(workspaceElement) + + generateFakeFetchResponses() + + it "adds an atom-notification element to the container with a class corresponding to the type", -> + expect(notificationContainer.childNodes.length).toBe 0 + + atom.notifications.addSuccess('A message') + notification = notificationContainer.querySelector('atom-notification.success') + expect(notificationContainer.childNodes.length).toBe 1 + expect(notification).toHaveClass 'success' + expect(notification.querySelector('.message').textContent.trim()).toBe 'A message' + expect(notification.querySelector('.meta')).not.toBeVisible() + + atom.notifications.addInfo('A message') + expect(notificationContainer.childNodes.length).toBe 2 + expect(notificationContainer.querySelector('atom-notification.info')).toBeDefined() + + atom.notifications.addWarning('A message') + expect(notificationContainer.childNodes.length).toBe 3 + expect(notificationContainer.querySelector('atom-notification.warning')).toBeDefined() + + atom.notifications.addError('A message') + expect(notificationContainer.childNodes.length).toBe 4 + expect(notificationContainer.querySelector('atom-notification.error')).toBeDefined() + + atom.notifications.addFatalError('A message') + expect(notificationContainer.childNodes.length).toBe 5 + expect(notificationContainer.querySelector('atom-notification.fatal')).toBeDefined() + + it "displays notification with a detail when a detail is specified", -> + atom.notifications.addInfo('A message', detail: 'Some detail') + notification = notificationContainer.childNodes[0] + expect(notification.querySelector('.detail').textContent).toContain 'Some detail' + + atom.notifications.addInfo('A message', detail: null) + notification = notificationContainer.childNodes[1] + expect(notification.querySelector('.detail')).not.toBeVisible() + + atom.notifications.addInfo('A message', detail: 1) + notification = notificationContainer.childNodes[2] + expect(notification.querySelector('.detail').textContent).toContain '1' + + atom.notifications.addInfo('A message', detail: {something: 'ok'}) + notification = notificationContainer.childNodes[3] + expect(notification.querySelector('.detail').textContent).toContain 'Object' + + atom.notifications.addInfo('A message', detail: ['cats', 'ok']) + notification = notificationContainer.childNodes[4] + expect(notification.querySelector('.detail').textContent).toContain 'cats,ok' + + it "does not add the has-stack class if a stack is provided without any detail", -> + atom.notifications.addInfo('A message', stack: 'Some stack') + notification = notificationContainer.childNodes[0] + notificationElement = notificationContainer.querySelector('atom-notification.info') + expect(notificationElement).not.toHaveClass 'has-stack' + + it "renders the message as sanitized markdown", -> + atom.notifications.addInfo('test html ') + notification = notificationContainer.childNodes[0] + expect(notification.querySelector('.message').innerHTML).toContain( + 'test html but sanitized' + ) + + describe "when a dismissable notification is added", -> + it "is removed when Notification::dismiss() is called", -> + notification = atom.notifications.addSuccess('A message', dismissable: true) + notificationElement = notificationContainer.querySelector('atom-notification.success') + + expect(notificationContainer.childNodes.length).toBe 1 + + notification.dismiss() + + advanceClock(NotificationElement::visibilityDuration) + expect(notificationElement).toHaveClass 'remove' + + advanceClock(NotificationElement::animationDuration) + expect(notificationContainer.childNodes.length).toBe 0 + + it "is removed when the close icon is clicked", -> + jasmine.attachToDOM(workspaceElement) + + waitsForPromise -> + atom.workspace.open() + + runs -> + notification = atom.notifications.addSuccess('A message', dismissable: true) + notificationElement = notificationContainer.querySelector('atom-notification.success') + + expect(notificationContainer.childNodes.length).toBe 1 + + notificationElement.focus() + notificationElement.querySelector('.close.icon').click() + + advanceClock(NotificationElement::visibilityDuration) + expect(notificationElement).toHaveClass 'remove' + + advanceClock(NotificationElement::animationDuration) + expect(notificationContainer.childNodes.length).toBe 0 + + it "is removed when core:cancel is triggered", -> + notification = atom.notifications.addSuccess('A message', dismissable: true) + notificationElement = notificationContainer.querySelector('atom-notification.success') + + expect(notificationContainer.childNodes.length).toBe 1 + + atom.commands.dispatch(workspaceElement, 'core:cancel') + + advanceClock(NotificationElement::visibilityDuration * 3) + expect(notificationElement).toHaveClass 'remove' + + advanceClock(NotificationElement::animationDuration * 3) + expect(notificationContainer.childNodes.length).toBe 0 + + it "focuses the active pane only if the dismissed notification has focus", -> + jasmine.attachToDOM(workspaceElement) + + waitsForPromise -> + atom.workspace.open() + + runs -> + notification1 = atom.notifications.addSuccess('First message', dismissable: true) + notification2 = atom.notifications.addError('Second message', dismissable: true) + notificationElement1 = notificationContainer.querySelector('atom-notification.success') + notificationElement2 = notificationContainer.querySelector('atom-notification.error') + + expect(notificationContainer.childNodes.length).toBe 2 + + notificationElement2.focus() + + notification1.dismiss() + + advanceClock(NotificationElement::visibilityDuration) + advanceClock(NotificationElement::animationDuration) + expect(notificationContainer.childNodes.length).toBe 1 + expect(notificationElement2).toHaveFocus() + + notificationElement2.querySelector('.close.icon').click() + + advanceClock(NotificationElement::visibilityDuration) + advanceClock(NotificationElement::animationDuration) + expect(notificationContainer.childNodes.length).toBe 0 + expect(atom.views.getView(atom.workspace.getActiveTextEditor())).toHaveFocus() + + describe "when an autoclose notification is added", -> + [notification, model] = [] + + beforeEach -> + model = atom.notifications.addSuccess('A message') + notification = notificationContainer.querySelector('atom-notification.success') + + it "closes and removes the message after a given amount of time", -> + expect(notification).not.toHaveClass 'remove' + + advanceClock(NotificationElement::visibilityDuration) + expect(notification).toHaveClass 'remove' + expect(notificationContainer.childNodes.length).toBe 1 + + advanceClock(NotificationElement::animationDuration) + expect(notificationContainer.childNodes.length).toBe 0 + + describe "when the notification is clicked", -> + beforeEach -> + notification.click() + + it "makes the notification dismissable", -> + expect(notification).toHaveClass 'has-close' + + advanceClock(NotificationElement::visibilityDuration) + expect(notification).not.toHaveClass 'remove' + + it "removes the notification when dismissed", -> + model.dismiss() + expect(notification).toHaveClass 'remove' + + describe "when the default timeout setting is changed", -> + [notification] = [] + + beforeEach -> + atom.config.set("notifications.defaultTimeout", 1000) + atom.notifications.addSuccess('A message') + notification = notificationContainer.querySelector('atom-notification.success') + + it "uses the setting value for the autoclose timeout", -> + expect(notification).not.toHaveClass 'remove' + advanceClock(1000) + expect(notification).toHaveClass 'remove' + + describe "when the `description` option is used", -> + it "displays the description text in the .description element", -> + atom.notifications.addSuccess('A message', description: 'This is [a link](http://atom.io)') + notification = notificationContainer.querySelector('atom-notification.success') + expect(notification).toHaveClass('has-description') + expect(notification.querySelector('.meta')).toBeVisible() + expect(notification.querySelector('.description').textContent.trim()).toBe 'This is a link' + expect(notification.querySelector('.description a').href).toBe 'http://atom.io/' + + describe "when the `buttons` options is used", -> + it "displays the buttons in the .description element", -> + clicked = [] + atom.notifications.addSuccess 'A message', + buttons: [{ + text: 'Button One' + className: 'btn-one' + onDidClick: -> clicked.push 'one' + }, { + text: 'Button Two' + className: 'btn-two' + onDidClick: -> clicked.push 'two' + }] + + notification = notificationContainer.querySelector('atom-notification.success') + expect(notification).toHaveClass('has-buttons') + expect(notification.querySelector('.meta')).toBeVisible() + + btnOne = notification.querySelector('.btn-one') + btnTwo = notification.querySelector('.btn-two') + + expect(btnOne).toHaveClass 'btn-success' + expect(btnOne.textContent).toBe 'Button One' + expect(btnTwo).toHaveClass 'btn-success' + expect(btnTwo.textContent).toBe 'Button Two' + + btnTwo.click() + btnOne.click() + + expect(clicked).toEqual ['two', 'one'] + + describe "when an exception is thrown", -> + [notificationContainer, fatalError, issueTitle, issueBody] = [] + describe "when the editor is in dev mode", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn true + generateException() + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "does not display a notification", -> + expect(notificationContainer.childNodes.length).toBe 0 + expect(fatalError).toBe null + + describe "when the exception has no core or package paths in the stack trace", -> + it "does not display a notification", -> + atom.notifications.clear() + spyOn(atom, 'inDevMode').andReturn false + handler = jasmine.createSpy('onWillThrowErrorHandler') + atom.onWillThrowError(handler) + + # Fake an unhandled error with a call stack located outside of the source + # of Pulsar or an Pulsar package + fs.readFile(__dirname, -> + err = new Error() + err.stack = 'FakeError: foo is not bar\n at blah.fakeFunc (directory/fakefile.js:1:25)' + throw err + ) + + waitsFor -> + handler.callCount is 1 + + runs -> + expect(atom.notifications.getNotifications().length).toBe 0 + + describe "when the message contains a newline", -> + it "removes the newline when generating the issue title", -> + message = "Uncaught Error: Cannot read property 'object' of undefined\nTypeError: Cannot read property 'object' of undefined" + atom.notifications.addFatalError(message) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + waitsForPromise -> + fatalError.getRenderPromise().then -> + issueTitle = fatalError.issue.getIssueTitle() + runs -> + expect(issueTitle).not.toContain "\n" + expect(issueTitle).toBe "Uncaught Error: Cannot read property 'object' of undefinedTypeError: Cannot read property 'objec..." + + describe "when the message contains continguous newlines", -> + it "removes the newlines when generating the issue title", -> + message = "Uncaught Error: Cannot do the thing\n\nSuper sorry about this" + atom.notifications.addFatalError(message) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + waitsForPromise -> + fatalError.getRenderPromise().then -> + issueTitle = fatalError.issue.getIssueTitle() + runs -> + expect(issueTitle).toBe "Uncaught Error: Cannot do the thingSuper sorry about this" + + describe "when there are multiple packages in the stack trace", -> + beforeEach -> + stack = """ + TypeError: undefined is not a function + at Object.module.exports.Pane.promptToSaveItem [as defaultSavePrompt] (/Applications/Pulsar.app/Contents/Resources/app/src/pane.js:490:23) + at Pane.promptToSaveItem (/Users/someguy/.atom/packages/save-session/lib/save-prompt.coffee:21:15) + at Pane.module.exports.Pane.destroyItem (/Applications/Pulsar.app/Contents/Resources/app/src/pane.js:442:18) + at HTMLDivElement. (/Applications/Pulsar.app/Contents/Resources/app/node_modules/tabs/lib/tab-bar-view.js:174:22) + at space-pen-ul.jQuery.event.dispatch (/Applications/Pulsar.app/Contents/Resources/app/node_modules/archive-view/node_modules/atom-space-pen-views/node_modules/space-pen/vendor/jquery.js:4676:9) + at space-pen-ul.elemData.handle (/Applications/Pulsar.app/Contents/Resources/app/node_modules/archive-view/node_modules/atom-space-pen-views/node_modules/space-pen/vendor/jquery.js:4360:46) + """ + detail = 'ok' + + atom.notifications.addFatalError('TypeError: undefined', {detail, stack}) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + spyOn(fs, 'realpathSync').andCallFake (p) -> p + spyOn(fatalError.issue, 'getPackagePathsByPackageName').andCallFake -> + 'save-session': '/Users/someguy/.atom/packages/save-session' + 'tabs': '/Applications/Pulsar.app/Contents/Resources/app/node_modules/tabs' + + it "chooses the first package in the trace", -> + expect(fatalError.issue.getPackageName()).toBe 'save-session' + + describe "when an exception is thrown from a package", -> + beforeEach -> + issueTitle = null + issueBody = null + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + spyOn(UserUtils, 'getPackageVersionShippedWithPulsar').andCallFake -> '0.0.0' + generateException() + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "displays a fatal error with the package name in the error", -> + waitsForPromise -> + fatalError.getRenderPromise().then -> + issueTitle = fatalError.issue.getIssueTitle() + fatalError.issue.getIssueBody().then (result) -> + issueBody = result + + runs -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain 'ReferenceError: a is not defined' + expect(fatalError.innerHTML).toContain "notifications package" + expect(fatalError.issue.getPackageName()).toBe 'notifications' + + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'Create issue on the notifications package' + + expect(issueTitle).toContain '$ATOM_HOME' + expect(issueTitle).not.toContain process.env.ATOM_HOME + expect(issueBody).toMatch /Pulsar\*\*: [0-9].[0-9]+.[0-9]+/ig + expect(issueBody).not.toMatch /Unknown/ig + expect(issueBody).toContain 'ReferenceError: a is not defined' + expect(issueBody).toContain 'Thrown From**: [notifications](https://github.com/pulsar-edit/pulsar) package ' + expect(issueBody).toContain '### Non-Core Packages' + + # FIXME: this doesnt work on the test server. `apm ls` is not working for some reason. + # expect(issueBody).toContain 'notifications ' + + it "standardizes platform separators on #win32", -> + waitsForPromise -> + fatalError.getRenderPromise().then -> + issueTitle = fatalError.issue.getIssueTitle() + + runs -> + expect(issueTitle).toContain path.posix.sep + expect(issueTitle).not.toContain path.win32.sep + + describe "when an exception contains the user's home directory", -> + beforeEach -> + issueTitle = null + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + + # Create a custom error message that contains the user profile but not ATOM_HOME + try + a + 1 + catch e + home = if process.platform is 'win32' then process.env.USERPROFILE else process.env.HOME + errMsg = "#{e.toString()} in #{home}#{path.sep}somewhere" + window.onerror.call(window, errMsg, '/dev/null', 2, 3, e) + + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "replaces the directory with a ~", -> + waitsForPromise -> + fatalError.getRenderPromise().then -> + issueTitle = fatalError.issue.getIssueTitle() + + runs -> + expect(issueTitle).toContain '~' + if process.platform is 'win32' + expect(issueTitle).not.toContain process.env.USERPROFILE + else + expect(issueTitle).not.toContain process.env.HOME + + describe "when an exception is thrown from a linked package", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + + packagesDir = path.join(temp.mkdirSync('atom-packages-'), '.atom', 'packages') + atom.packages.packageDirPaths.push(packagesDir) + packageDir = path.join(packagesDir, '..', '..', 'github', 'linked-package') + fs.makeTreeSync path.dirname(path.join(packagesDir, 'linked-package')) + fs.symlinkSync(packageDir, path.join(packagesDir, 'linked-package'), 'junction') + fs.writeFileSync path.join(packageDir, 'package.json'), """ + { + "name": "linked-package", + "version": "1.0.0", + "repository": "https://github.com/pulsar-edit/notifications" + } + """ + atom.packages.enablePackage('linked-package') + + stack = """ + ReferenceError: path is not defined + at Object.module.exports.LinkedPackage.wow (#{path.join(fs.realpathSync(packageDir), 'linked-package.coffee')}:29:15) + at atom-workspace.subscriptions.add.atom.commands.add.linked-package:wow (#{path.join(packageDir, 'linked-package.coffee')}:18:102) + at CommandRegistry.module.exports.CommandRegistry.handleCommandEvent (/Applications/Pulsar.app/Contents/Resources/app/src/command-registry.js:238:29) + at /Applications/Pulsar.app/Contents/Resources/app/src/command-registry.js:3:61 + at CommandPaletteView.module.exports.CommandPaletteView.confirmed (/Applications/Pulsar.app/Contents/Resources/app/node_modules/command-palette/lib/command-palette-view.js:159:32) + """ + detail = "At #{path.join(packageDir, 'linked-package.coffee')}:41" + message = "Uncaught ReferenceError: path is not defined" + atom.notifications.addFatalError(message, {stack, detail, dismissable: true}) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "displays a fatal error with the package name in the error", -> + waitsForPromise -> + fatalError.getRenderPromise() + + runs -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain "Uncaught ReferenceError: path is not defined" + expect(fatalError.innerHTML).toContain "linked-package package" + expect(fatalError.issue.getPackageName()).toBe 'linked-package' + + describe "when an exception is thrown from an unloaded package", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + + generateFakeFetchResponses() + + packagesDir = temp.mkdirSync('atom-packages-') + atom.packages.packageDirPaths.push(path.join(packagesDir, '.atom', 'packages')) + packageDir = path.join(packagesDir, '.atom', 'packages', 'unloaded') + fs.writeFileSync path.join(packageDir, 'package.json'), """ + { + "name": "unloaded", + "version": "1.0.0", + "repository": "https://github.com/pulsar-edit/notifications" + } + """ + + stack = "Error\n at #{path.join(packageDir, 'index.js')}:1:1" + detail = 'ReferenceError: unloaded error' + message = "Error" + atom.notifications.addFatalError(message, {stack, detail, dismissable: true}) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "displays a fatal error with the package name in the error", -> + waitsForPromise -> + fatalError.getRenderPromise() + + runs -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain 'ReferenceError: unloaded error' + expect(fatalError.innerHTML).toContain "unloaded package" + expect(fatalError.issue.getPackageName()).toBe 'unloaded' + + describe "when an exception is thrown from a package trying to load", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + + packagesDir = temp.mkdirSync('atom-packages-') + atom.packages.packageDirPaths.push(path.join(packagesDir, '.atom', 'packages')) + packageDir = path.join(packagesDir, '.atom', 'packages', 'broken-load') + fs.writeFileSync path.join(packageDir, 'package.json'), """ + { + "name": "broken-load", + "version": "1.0.0", + "repository": "https://github.com/pulsar-edit/notifications" + } + """ + + stack = "TypeError: Cannot read property 'prototype' of undefined\n at __extends (:1:1)\n at Object.defineProperty.value [as .coffee] (/Applications/Pulsar.app/Contents/Resources/app.asar/src/compile-cache.js:169:21)" + detail = "TypeError: Cannot read property 'prototype' of undefined" + message = "Failed to load the broken-load package" + atom.notifications.addFatalError(message, {stack, detail, packageName: 'broken-load', dismissable: true}) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "displays a fatal error with the package name in the error", -> + waitsForPromise -> + fatalError.getRenderPromise() + + runs -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain "TypeError: Cannot read property 'prototype' of undefined" + expect(fatalError.innerHTML).toContain "broken-load package" + expect(fatalError.issue.getPackageName()).toBe 'broken-load' + + describe "when an exception is thrown from a package trying to load a grammar", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + + packagesDir = temp.mkdirSync('atom-packages-') + atom.packages.packageDirPaths.push(path.join(packagesDir, '.atom', 'packages')) + packageDir = path.join(packagesDir, '.atom', 'packages', 'language-broken-grammar') + fs.writeFileSync path.join(packageDir, 'package.json'), """ + { + "name": "language-broken-grammar", + "version": "1.0.0", + "repository": "https://github.com/pulsar-edit/notifications" + } + """ + + stack = """ + Unexpected string + at nodeTransforms.Literal (/usr/share/atom/resources/app/node_modules/season/node_modules/cson-parser/lib/parse.js:100:15) + at #{path.join('packageDir', 'grammars', 'broken-grammar.cson')}:1:1 + """ + detail = """ + At Syntax error on line 241, column 18: evalmachine.:1 + "#\\{" "end": "\\}" + ^^^^^ + Unexpected string in #{path.join('packageDir', 'grammars', 'broken-grammar.cson')} + + SyntaxError: Syntax error on line 241, column 18: evalmachine.:1 + "#\\{" "end": "\\}" + ^^^^^ + """ + message = "Failed to load a language-broken-grammar package grammar" + atom.notifications.addFatalError(message, {stack, detail, packageName: 'language-broken-grammar', dismissable: true}) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "displays a fatal error with the package name in the error", -> + waitsForPromise -> + fatalError.getRenderPromise() + + runs -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain "Failed to load a language-broken-grammar package grammar" + expect(fatalError.innerHTML).toContain "language-broken-grammar package" + expect(fatalError.issue.getPackageName()).toBe 'language-broken-grammar' + + describe "when an exception is thrown from a package trying to activate", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + + packagesDir = temp.mkdirSync('atom-packages-') + atom.packages.packageDirPaths.push(path.join(packagesDir, '.atom', 'packages')) + packageDir = path.join(packagesDir, '.atom', 'packages', 'broken-activation') + fs.writeFileSync path.join(packageDir, 'package.json'), """ + { + "name": "broken-activation", + "version": "1.0.0", + "repository": "https://github.com/pulsar-edit/notifications" + } + """ + + stack = "TypeError: Cannot read property 'command' of undefined\n at Object.module.exports.activate (:7:23)\n at Package.module.exports.Package.activateNow (/Applications/Pulsar.app/Contents/Resources/app.asar/src/package.js:232:19)" + detail = "TypeError: Cannot read property 'command' of undefined" + message = "Failed to activate the broken-activation package" + atom.notifications.addFatalError(message, {stack, detail, packageName: 'broken-activation', dismissable: true}) + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + it "displays a fatal error with the package name in the error", -> + waitsForPromise -> + fatalError.getRenderPromise() + + runs -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain "TypeError: Cannot read property 'command' of undefined" + expect(fatalError.innerHTML).toContain "broken-activation package" + expect(fatalError.issue.getPackageName()).toBe 'broken-activation' + + describe "when an exception is thrown from a package without a trace, but with a URL", -> + beforeEach -> + issueBody = null + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + try + a + 1 + catch e + # Pull the file path from the stack + filePath = e.stack.split('\n')[1].match(/\((.+?):\d+/)[1] + window.onerror.call(window, e.toString(), filePath, 2, 3, message: e.toString(), stack: undefined) + + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + # TODO: Have to be honest, NO IDEA where this detection happens... + xit "detects the package name from the URL", -> + waitsForPromise -> fatalError.getRenderPromise() + + runs -> + expect(fatalError.innerHTML).toContain 'ReferenceError: a is not defined' + expect(fatalError.innerHTML).toContain "notifications package" + expect(fatalError.issue.getPackageName()).toBe 'notifications' + + describe "when an exception is thrown from core", -> + beforeEach -> + atom.commands.dispatch(workspaceElement, 'some-package:a-command') + atom.commands.dispatch(workspaceElement, 'some-package:a-command') + atom.commands.dispatch(workspaceElement, 'some-package:a-command') + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses() + try + a + 1 + catch e + # Mung the stack so it looks like its from core + e.stack = e.stack.replace(new RegExp(__filename, 'g'), '').replace(/notifications/g, 'core') + window.onerror.call(window, e.toString(), '/dev/null', 2, 3, e) + + notificationContainer = workspaceElement.querySelector('atom-notifications') + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> + fatalError.issue.getIssueBody().then (result) -> + issueBody = result + + it "displays a fatal error with the package name in the error", -> + expect(notificationContainer.childNodes.length).toBe 1 + expect(fatalError).toBeDefined() + expect(fatalError).toHaveClass 'has-close' + expect(fatalError.innerHTML).toContain 'ReferenceError: a is not defined' + expect(fatalError.innerHTML).toContain 'bug in Pulsar' + expect(fatalError.issue.getPackageName()).toBeUndefined() + + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'Create issue on pulsar-edit/pulsar' + + expect(issueBody).toContain 'ReferenceError: a is not defined' + expect(issueBody).toContain '**Thrown From**: Pulsar Core' + + it "contains the commands that the user run in the issue body", -> + expect(issueBody).toContain 'some-package:a-command' + + it "allows the user to toggle the stack trace", -> + stackToggle = fatalError.querySelector('.stack-toggle') + stackContainer = fatalError.querySelector('.stack-container') + expect(stackToggle).toExist() + expect(stackContainer.style.display).toBe 'none' + + stackToggle.click() + expect(stackContainer.style.display).toBe 'block' + + stackToggle.click() + expect(stackContainer.style.display).toBe 'none' + + describe "when the there is an error searching for the issue", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + generateFakeFetchResponses(issuesErrorResponse: '403') + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "asks the user to create an issue", -> + button = fatalError.querySelector('.btn') + fatalNotification = fatalError.querySelector('.fatal-notification') + expect(button.textContent).toContain 'Create issue' + expect(fatalNotification.textContent).toContain 'The error was thrown from the notifications package.' + + describe "when the error has not been reported", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + + describe "when the message is longer than 100 characters", -> + message = "Uncaught Error: Cannot find module 'dialog'Error: Cannot find module 'dialog' at Function.Module._resolveFilename (module.js:351:15) at Function.Module._load (module.js:293:25) at Module.require (module.js:380:17) at EventEmitter. (/Applications/Pulsar.app/Contents/Resources/atom/browser/lib/rpc-server.js:128:79) at EventEmitter.emit (events.js:119:17) at EventEmitter. (/Applications/Pulsar.app/Contents/Resources/atom/browser/api/lib/web-contents.js:99:23) at EventEmitter.emit (events.js:119:17)" + expectedIssueTitle = "Uncaught Error: Cannot find module 'dialog'Error: Cannot find module 'dialog' at Function.Module...." + + beforeEach -> + generateFakeFetchResponses() + try + a + 1 + catch e + e.code = 'Error' + e.message = message + window.onerror.call(window, e.message, 'abc', 2, 3, e) + + it "truncates the issue title to 100 characters", -> + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + waitsForPromise -> + fatalError.getRenderPromise() + + runs -> + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'Create issue' + expect(fatalError.issue.getIssueTitle()).toBe(expectedIssueTitle) + + describe "when the package is out of date", -> + beforeEach -> + installedVersion = '0.9.0' + UserUtilities = require '../lib/user-utilities' + spyOn(UserUtilities, 'getPackageVersion').andCallFake -> installedVersion + spyOn(atom, 'inDevMode').andReturn false + + describe "when the package is a non-core package", -> + beforeEach -> + generateFakeFetchResponses + packageResponse: + repository: url: 'https://github.com/someguy/somepackage' + releases: latest: '0.10.0' + spyOn(NotificationIssue.prototype, 'getPackageName').andCallFake -> "somepackage" + spyOn(NotificationIssue.prototype, 'getRepoUrl').andCallFake -> "https://github.com/someguy/somepackage" + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "asks the user to update their packages", -> + fatalNotification = fatalError.querySelector('.fatal-notification') + button = fatalError.querySelector('.btn') + + expect(button.textContent).toContain 'Check for package updates' + expect(fatalNotification.textContent).toContain 'Upgrading to the latest' + expect(button.getAttribute('href')).toBe '#' + + describe "when the package is an atom-owned non-core package", -> + beforeEach -> + generateFakeFetchResponses + packageResponse: + repository: url: 'https://github.com/pulsar-edit/sort-lines' + releases: latest: '0.10.0' + spyOn(NotificationIssue.prototype, 'getPackageName').andCallFake -> "sort-lines" + spyOn(NotificationIssue.prototype, 'getRepoUrl').andCallFake -> "https://github.com/pulsar-edit/sort-lines" + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "asks the user to update their packages", -> + fatalNotification = fatalError.querySelector('.fatal-notification') + button = fatalError.querySelector('.btn') + + expect(button.textContent).toContain 'Check for package updates' + expect(fatalNotification.textContent).toContain 'Upgrading to the latest' + expect(button.getAttribute('href')).toBe '#' + + describe "when the package is a core package", -> + beforeEach -> + generateFakeFetchResponses + packageResponse: + repository: url: 'https://github.com/pulsar-edit/notifications' + releases: latest: '0.11.0' + + describe "when the locally installed version is lower than Pulsar's version", -> + beforeEach -> + versionShippedWithPulsar = '0.10.0' + UserUtilities = require '../lib/user-utilities' + spyOn(UserUtilities, 'getPackageVersionShippedWithPulsar').andCallFake -> versionShippedWithPulsar + + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "doesn't show the Create Issue button", -> + button = fatalError.querySelector('.btn-issue') + expect(button).not.toExist() + + it "tells the user that the package is a locally installed core package and out of date", -> + fatalNotification = fatalError.querySelector('.fatal-notification') + expect(fatalNotification.textContent).toContain 'Locally installed core Pulsar package' + expect(fatalNotification.textContent).toContain 'is out of date' + + describe "when the locally installed version matches Pulsar's version", -> + beforeEach -> + versionShippedWithPulsar = '0.9.0' + UserUtilities = require '../lib/user-utilities' + spyOn(UserUtilities, 'getPackageVersionShippedWithPulsar').andCallFake -> versionShippedWithPulsar + + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "ignores the out of date package because they cant upgrade it without upgrading atom", -> + fatalError = notificationContainer.querySelector('atom-notification.fatal') + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'Create issue' + + # TODO: Re-enable when Pulsar have a way to check this + # describe "when Pulsar is out of date", -> + # beforeEach -> + # installedVersion = '0.179.0' + # spyOn(atom, 'getVersion').andCallFake -> installedVersion + # spyOn(atom, 'inDevMode').andReturn false + # + # generateFakeFetchResponses + # atomResponse: + # name: '0.180.0' + # + # generateException() + # + # fatalError = notificationContainer.querySelector('atom-notification.fatal') + # waitsForPromise -> + # fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + # + # it "doesn't show the Create Issue button", -> + # button = fatalError.querySelector('.btn-issue') + # expect(button).not.toExist() + # + # it "tells the user that Pulsar is out of date", -> + # fatalNotification = fatalError.querySelector('.fatal-notification') + # expect(fatalNotification.textContent).toContain 'Pulsar is out of date' + # + # it "provides a link to the latest released version", -> + # fatalNotification = fatalError.querySelector('.fatal-notification') + # expect(fatalNotification.innerHTML).toContain 'latest version' + + describe "when the error has been reported", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + + describe "when the issue is open", -> + beforeEach -> + generateFakeFetchResponses + issuesResponse: + items: [ + { + title: 'ReferenceError: a is not defined in $ATOM_HOME/somewhere' + html_url: 'http://url.com/ok' + state: 'open' + } + ] + spyOn(NotificationIssue.prototype, 'getPackageName').andCallFake -> "somepackage" + spyOn(NotificationIssue.prototype, 'getRepoUrl').andCallFake -> "https://github.com/someguy/somepackage" + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "shows the user a view issue button", -> + fatalNotification = fatalError.querySelector('.fatal-notification') + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'View Issue' + expect(button.getAttribute('href')).toBe 'http://url.com/ok' + expect(fatalNotification.textContent).toContain 'already been reported' + expect(fetch.calls[0].args[0]).toContain encodeURIComponent('someguy/somepackage') + + describe "when the issue is closed", -> + beforeEach -> + generateFakeFetchResponses + issuesResponse: + items: [ + { + title: 'ReferenceError: a is not defined in $ATOM_HOME/somewhere' + html_url: 'http://url.com/closed' + state: 'closed' + } + ] + spyOn(NotificationIssue.prototype, 'getPackageName').andCallFake -> "somepackage" + spyOn(NotificationIssue.prototype, 'getRepoUrl').andCallFake -> "https://github.com/someguy/somepackage" + generateException() + fatalError = notificationContainer.querySelector('atom-notification.fatal') + waitsForPromise -> + fatalError.getRenderPromise().then -> issueBody = fatalError.issue.issueBody + + it "shows the user a view issue button", -> + button = fatalError.querySelector('.btn') + expect(button.textContent).toContain 'View Issue' + expect(button.getAttribute('href')).toBe 'http://url.com/closed' + + describe "when a BufferedProcessError is thrown", -> + it "adds an error to the notifications", -> + expect(notificationContainer.querySelector('atom-notification.error')).not.toExist() + + window.onerror('Uncaught BufferedProcessError: Failed to spawn command `bad-command`', 'abc', 2, 3, {name: 'BufferedProcessError'}) + + error = notificationContainer.querySelector('atom-notification.error') + expect(error).toExist() + expect(error.innerHTML).toContain 'Failed to spawn command' + expect(error.innerHTML).not.toContain 'BufferedProcessError' + + describe "when a spawn ENOENT error is thrown", -> + beforeEach -> + spyOn(atom, 'inDevMode').andReturn false + + describe "when the binary has no path", -> + beforeEach -> + error = new Error('Error: spawn some_binary ENOENT') + error.code = 'ENOENT' + window.onerror.call(window, error.message, 'abc', 2, 3, error) + + it "displays a dismissable error without the stack trace", -> + notificationContainer = workspaceElement.querySelector('atom-notifications') + error = notificationContainer.querySelector('atom-notification.error') + expect(error.textContent).toContain "'some_binary' could not be spawned" + + describe "when the binary has /atom in the path", -> + beforeEach -> + try + a + 1 + catch e + e.code = 'ENOENT' + message = 'Error: spawn /opt/atom/Pulsar Helper (deleted) ENOENT' + window.onerror.call(window, message, 'abc', 2, 3, e) + + it "displays a fatal error", -> + notificationContainer = workspaceElement.querySelector('atom-notifications') + error = notificationContainer.querySelector('atom-notification.fatal') + expect(error).toExist() diff --git a/packages/notifications/styles/notifications-log.less b/packages/notifications/styles/notifications-log.less new file mode 100644 index 000000000..e7c23b49d --- /dev/null +++ b/packages/notifications/styles/notifications-log.less @@ -0,0 +1,193 @@ +@import "ui-variables"; +@import "octicon-mixins"; + +@icon-size: 30px; +@font-family-monospace: Consolas, "Liberation Mono", Menlo, Courier, monospace; + +.notifications-log { + display: flex; + flex-direction: column; + min-width: 200px; + + header { + flex: none; + background-color: @base-background-color; + border-bottom: 1px solid @base-border-color; + + button { + border: none; + width: @icon-size; + height: @icon-size; + margin: 1px; + padding: 0; + color: @text-color-subtle; + opacity: .5; + background: @base-background-color; + } + + .notifications-clear-log { + float: right; + } + } + + .notifications-log-items { + flex: auto; + list-style: none; + padding: 0; + margin: 0; + overflow: auto; + + .notifications-log-item { + display: flex; + box-sizing: content-box; // Keep spacing even + border-bottom: 1px solid @base-border-color; + max-height: @icon-size; + overflow: hidden; + + .notifications-log-notification { + flex: auto; + display: flex; + position: relative; + padding-left: @icon-size; + overflow: hidden; + + &.icon:before { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: @icon-size; + height: 100%; + padding-top: @component-padding/2; + text-align: center; + } + + .message { + flex: 0 1 auto; + word-wrap: break-word; + padding: @component-padding/2 @component-padding; + border-left: 1px solid @base-border-color; + } + + .btn-toolbar { + flex: 1 0 auto; + display: flex; + align-items: center; + white-space: nowrap; + + &:empty { + display: none; + } + + .btn-copy-report { + vertical-align: middle; + margin-left: @component-padding/2; + + &::before { + margin: 0; + } + } + } + } + + .timestamp { + flex: 0 0 auto; + white-space: nowrap; + text-align: center; + line-height: @icon-size; + padding: 0 @component-padding; + } + } + } + +} + +// Types ------------------------------- + +.notifications-log { + + // fatal + .notification-type.fatal { + .type(@text-color-error; @background-color-error); + } + + .hide-fatal li.fatal { + display: none; + } + + .notifications-log-notification.fatal { + .log(@text-color-error; @background-color-error); + } + + // error + .notification-type.error { + .type(@text-color-error; @background-color-error); + } + + .hide-error li.error { + display: none; + } + + .notifications-log-notification.error { + .log(@text-color-error; @background-color-error); + } + + // warning + .notification-type.warning { + .type(@text-color-warning; @background-color-warning); + } + + .hide-warning li.warning { + display: none; + } + + .notifications-log-notification.warning { + .log(@text-color-warning; @background-color-warning); + } + + // info + .notification-type.info { + .type(@text-color-info; @background-color-info); + } + + .hide-info li.info { + display: none; + } + + .notifications-log-notification.info { + .log(@text-color-info; @background-color-info); + } + + // success + .notification-type.success { + .type(@text-color-success; @background-color-success); + } + + .hide-success li.success { + display: none; + } + + .notifications-log-notification.success { + .log(@text-color-success; @background-color-success); + } + +} + +// Type Mixin + +.type(@txt; @bg) { + &.show-type { + color: @txt; + opacity: 1; + } +} + +.log(@txt; @bg) { + .message { + color: lighten(@txt, 0%); + } + + &.icon:before { + color: @txt; + } +} diff --git a/packages/notifications/styles/notifications.less b/packages/notifications/styles/notifications.less new file mode 100644 index 000000000..de3df6f59 --- /dev/null +++ b/packages/notifications/styles/notifications.less @@ -0,0 +1,336 @@ +@import "ui-variables"; +@import "octicon-mixins"; + +@icon-size: 30px; +@width: 450px; +@width-detail: 450px; +@max-height-message: 200px; +@max-height-detail: 500px; +@max-height: @max-height-message + @max-height-detail + 100px; // 100px for footer. This is only used for the closing animation +@notification-gap: 2px; +@font-family-monospace: Consolas, "Liberation Mono", Menlo, Courier, monospace; + +atom-notifications { + display: block; + z-index: 1000; // TODO: Have some convention about z-index stacking + position: absolute; + top: 35px; + right: 0; + bottom: 0; + padding: @component-padding; + font-size: 1.2em; + overflow-x: hidden; + overflow-y: auto; + pointer-events: none; + &::-webkit-scrollbar { + display: none; + } + + atom-notification { + .close-all { + display: none; + } + } + + atom-notification:first-child { + .close-all { + display: block; + } + .message { + padding-right: @component-padding * 2 + 95px; // space for icon and button + } + } + + atom-notification:only-child { + .close-all { + display: none; + } + .message { + padding-right: inherit; + } + &.has-close .message { + padding-right: @component-padding + 24px; // space for icon + } + } + + atom-notification { + float: right; + clear: right; + position: relative; + width: @width; + padding-left: @icon-size; + margin-bottom: @notification-gap; + max-height: @max-height; + word-wrap: break-word; + pointer-events: auto; + + &.icon:before { + position: absolute; + top: 0; + left: 0; + width: @icon-size; + height: 100%; + padding-top: @component-padding; + text-align: center; + border-radius: @component-border-radius 0 0 @component-border-radius; + } + + + // fill space between notifiactions to prevent click throughs + &:after { + content: ""; + display: block; + position: absolute; + left: 0; + right: 0; + bottom: -@notification-gap; + height: @notification-gap; + } + + .meta, + .close, + .detail, + .stack-toggle, + .stack-container { + display: none; + } + + &.fatal .meta, + &.has-description .meta, + &.has-buttons .meta, + &.has-close .close, + &.has-detail .detail, + &.has-stack .stack-toggle, + &.has-stack .stack-container { + display: block; + } + + // .item's are used as general containers + .item { + padding: @component-padding; + border-top: 1px solid hsla(0,0%,0%,.1); + &.message { + border-top: none; + p:last-child { + margin-bottom: 0; + } + } + } + + &.has-close .message { + padding-right: @component-padding + 24px; // space for icon + } + + .content { + border-radius: 0 @component-border-radius @component-border-radius 0; + } + + .message { + max-height: @max-height-message; + overflow-y: auto; + } + + .close-all.btn { + position: absolute; + top: 7px; + right: 38px; + background: none; + } + + .close { + position: absolute; + top: 0; + right: 0; + width: 38px; + height: 38px; + line-height: 38px; + text-align: center; + font-size: 16px; + text-shadow: none; + color: black; + opacity: .4; + &:hover, &:focus { + opacity: 1; + } + &:active { + opacity: .2; + } + &:before { + margin: 0; + } + } + + &.has-detail { + width: @width-detail; + } + + .detail { + font-size: .8em; + background-color: hsla(0,0%,100%,.3); + background-clip: padding-box; + max-height: @max-height-detail; + overflow-y: auto; + + .line { + font-family: @font-family-monospace; + } + + .stack-toggle { + margin-top: @component-padding; + + .icon:before { + margin: 0; + } + } + + .detail-content { + .line { + white-space: pre-wrap; + } + } + + .stack-container { + margin-top: @component-padding; + + .line { + white-space: pre; + } + } + } + + .description { + font-size: .8em; + + p:last-child { + margin-bottom: 0; + } + } + + .btn-toolbar.btn-toolbar { + margin-top: 10px; + margin-bottom: -5px; + margin-left: 0; + } + + .btn-toolbar.btn-toolbar > .btn { + margin-left: 0; + margin-bottom: 5px; + } + + .btn-copy-report { + vertical-align: middle; + } + + .opening { + cursor: progress; + } + } +} + +// Types ------------------------------- + +atom-notifications { + atom-notification.fatal { + .notification(@text-color-error; @background-color-error); + } + + atom-notification.error { + .notification(@text-color-error; @background-color-error); + } + + atom-notification.warning { + .notification(@text-color-warning; @background-color-warning); + } + + atom-notification.info { + .notification(@text-color-info; @background-color-info); + } + + atom-notification.success { + .notification(@text-color-success; @background-color-success); + } +} + + +// Mixins ------------------------------- + +.notification(@txt; @bg) { + + .content { + color: darken(@txt, 40%); + background-color: lighten(@bg, 25%); + } + + a { + color: darken(@txt, 20%); + } + + code { + color: darken(@txt, 40%); + background-color: desaturate(lighten(@bg, 18%), 5%); + } + + &.icon:before { + color: lighten(@bg, 36%); + background-color: @bg; + } + + .close-all.btn { + border: 1px solid fadeout(darken(@txt, 40%), 70%); + color: fadeout(darken(@txt, 40%), 40%); + text-shadow: none; + + &:hover { + background: none; + border-color: fadeout(darken(@txt, 40%), 20%); + color: darken(@txt, 40%); + } + } +} + + +// Animations ------------------------------- + +atom-notifications atom-notification { + -webkit-animation: notification-show .16s cubic-bezier(0.175, 0.885, 0.32, 1.27499); + + &[type="fatal"] { + -webkit-animation: notification-show .16s cubic-bezier(0.175, 0.885, 0.32, 1.27499), + notification-shake 4s 2s; + -webkit-animation-iteration-count: 1, 3; // shake 3 times after showing + &:hover { + -webkit-animation-play-state: paused; // stop shaking when hovering + } + } + + &.remove, + &.remove:hover { + -webkit-animation: notification-hide .12s cubic-bezier(.34,.07,1,.2), + notification-shrink .24s .12s cubic-bezier(0.5, 0, 0, 1); + -webkit-animation-fill-mode: forwards; + } +} + +@-webkit-keyframes notification-show { + 0% { opacity: 0; transform: perspective(@width) translate(0, -@icon-size) rotateX(90deg); } + 100% { opacity: 1; transform: perspective(@width) translate(0, 0) rotateX( 0deg); } +} + +@-webkit-keyframes notification-hide { + 0% { opacity: 1; transform: scale( 1); } + 100% { opacity: 0; transform: scale(.8); } +} + +@-webkit-keyframes notification-shrink { + 0% { opacity: 0; max-height: @max-height; transform: scale(.8); } + 100% { opacity: 0; max-height: 0; transform: scale(.8); } +} + +@-webkit-keyframes notification-shake { + 0% { transform: translateX( 0); } + 2% { transform: translateX(-4px); } + 4% { transform: translateX( 8px); } + 6% { transform: translateX(-4px); } + 8% { transform: translateX( 0); } + 100% { transform: translateX( 0); } +} diff --git a/packages/settings-view/lib/package-card.js b/packages/settings-view/lib/package-card.js index d6adc6771..15845201c 100644 --- a/packages/settings-view/lib/package-card.js +++ b/packages/settings-view/lib/package-card.js @@ -299,8 +299,9 @@ export default class PackageCard { this.refs.downloadIcon.classList.add('icon-git-branch') this.refs.downloadCount.textContent = this.pack.apmInstallSource.sha.substr(0, 8) } else { - this.refs.stargazerCount.textContent = data.stargazers_count ? data.stargazers_count.toLocaleString() : '' - this.refs.downloadCount.textContent = data.downloads ? data.downloads.toLocaleString() : '' + + this.refs.stargazerCount.textContent = data.stargazers_count ? parseInt(data.stargazers_count).toLocaleString() : '' + this.refs.downloadCount.textContent = data.downloads ? parseInt(data.downloads).toLocaleString() : '' } } }) diff --git a/spec/package-spec.js b/spec/package-spec.js index a31c2141c..a235c4e91 100644 --- a/spec/package-spec.js +++ b/spec/package-spec.js @@ -74,19 +74,6 @@ describe('Package', function() { expect(pack.isCompatible()).toBe(false); }); - it('caches the incompatible native modules in local storage', function() { - const packagePath = atom.project - .getDirectories()[0] - .resolve('packages/package-with-incompatible-native-module'); - expect(buildPackage(packagePath).isCompatible()).toBe(false); - expect(global.localStorage.getItem.callCount).toBe(1); - expect(global.localStorage.setItem.callCount).toBe(1); - - expect(buildPackage(packagePath).isCompatible()).toBe(false); - expect(global.localStorage.getItem.callCount).toBe(2); - expect(global.localStorage.setItem.callCount).toBe(1); - }); - it('logs an error to the console describing the problem', function() { const packagePath = atom.project .getDirectories()[0] @@ -166,7 +153,6 @@ describe('Package', function() { // A different package instance has the same failure output (simulates reload) const pack2 = buildPackage(packagePath); expect(pack2.getBuildFailureOutput()).toBe('It is broken'); - expect(pack2.isCompatible()).toBe(false); // Clears the build failure after a successful build pack.rebuild(); @@ -175,25 +161,6 @@ describe('Package', function() { expect(pack.getBuildFailureOutput()).toBeNull(); expect(pack2.getBuildFailureOutput()).toBeNull(); }); - - it('sets cached incompatible modules to an empty array when the rebuild completes (there may be a build error, but rebuilding *deletes* native modules)', function() { - const packagePath = __guard__(atom.project.getDirectories()[0], x => - x.resolve('packages/package-with-incompatible-native-module') - ); - const pack = buildPackage(packagePath); - - expect(pack.getIncompatibleNativeModules().length).toBeGreaterThan(0); - - const rebuildCallbacks = []; - spyOn(pack, 'runRebuildProcess').andCallFake(callback => - rebuildCallbacks.push(callback) - ); - - pack.rebuild(); - expect(pack.getIncompatibleNativeModules().length).toBeGreaterThan(0); - rebuildCallbacks[0]({ code: 0, stdout: 'It worked' }); - expect(pack.getIncompatibleNativeModules().length).toBe(0); - }); }); describe('theme', function() { diff --git a/yarn.lock b/yarn.lock index 9f1bb0476..a792e0d51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1773,9 +1773,9 @@ integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== "@types/node@^14.6.2": - version "14.18.33" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.33.tgz#8c29a0036771569662e4635790ffa9e057db379b" - integrity sha512-qelS/Ra6sacc4loe/3MSjXNL1dNQ/GjxNHVzuChwMfmk7HuycRLVQN2qNY3XahK+fZc5E2szqQSKUyAF0E+2bg== + version "14.18.42" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.42.tgz#fa39b2dc8e0eba61bdf51c66502f84e23b66e114" + integrity sha512-xefu+RBie4xWlK8hwAzGh3npDz/4VhF6icY/shU+zv/1fNn+ZVG7T7CRwe9LId9sAYRPxI+59QBPuKL3WpyGRg== "@types/parse-json@^4.0.0": version "4.0.0" @@ -7216,9 +7216,8 @@ normalize-url@^6.0.1: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== -"notifications@https://codeload.github.com/atom/notifications/legacy.tar.gz/refs/tags/v0.72.1": +"notifications@file:./packages/notifications": version "0.72.1" - resolved "https://codeload.github.com/atom/notifications/legacy.tar.gz/refs/tags/v0.72.1#4e5a155624b1189bdcc3416a9f736ed1e030b56e" dependencies: dompurify "^1.0.3" fs-plus "^3.0.0" @@ -8451,9 +8450,9 @@ season@^6.0.2: fs-plus "^3.0.0" yargs "^3.23.0" -"second-mate@https://github.com/pulsar-edit/second-mate.git#14aa7bd": +"second-mate@https://github.com/pulsar-edit/second-mate.git#9686771": version "8.0.0" - resolved "https://github.com/pulsar-edit/second-mate.git#14aa7bd94b90c47aa99f000394301b9573b8898b" + resolved "https://github.com/pulsar-edit/second-mate.git#9686771b4aa3159fe042528d60fcac3af3e1c655" dependencies: emissary "^1.3.3" event-kit "^2.5.3"