Incorporate settings-view to core

Co-Authored-By: @Sertonix <83883937+Sertonix@users.noreply.github.com>
This commit is contained in:
Daeraxa 2022-12-12 22:09:45 +00:00
parent 3646a714cf
commit 34f4c70d63
85 changed files with 13213 additions and 3 deletions

View File

@ -153,7 +153,7 @@
"season": "^6.0.2",
"semver": "7.3.2",
"service-hub": "^0.7.4",
"settings-view": "https://github.com/pulsar-edit/settings-view.git#v0.261.11",
"settings-view": "file:packages/settings-view",
"sinon": "9.2.1",
"snippets": "https://codeload.github.com/pulsar-edit/snippets/legacy.tar.gz/refs/tags/v1.6.1",
"solarized-dark-syntax": "file:packages/solarized-dark-syntax",

View File

@ -84,7 +84,7 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
| **one-light-ui** | [`./one-light-ui`](./one-light-ui) | |
| **open-on-github** | [`atom/open-on-github`][open-on-github] | [#18278](https://github.com/atom/atom/issues/18278) |
| **package-generator** | [`atom/package-generator`][package-generator] | [#18279](https://github.com/atom/atom/issues/18279) |
| **settings-view** | [`atom/settings-view`][settings-view] | |
| **settings-view** | [`./settings-view`](./settings-view) | |
| **snippets** | [`atom/snippets`][snippets] | |
| **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | |
| **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | |
@ -121,7 +121,6 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
[notifications]: https://github.com/pulsar-edit/notifications
[open-on-github]: https://github.com/pulsar-edit/open-on-github
[package-generator]: https://github.com/pulsar-edit/package-generator
[settings-view]: https://github.com/pulsar-edit/settings-view
[snippets]: https://github.com/pulsar-edit/snippets
[spell-check]: https://github.com/pulsar-edit/spell-check
[status-bar]: https://github.com/pulsar-edit/status-bar

View File

@ -0,0 +1,15 @@
# Configuration for probot-no-response - https://github.com/probot/no-response
# Number of days of inactivity before an issue is closed for lack of response
daysUntilClose: 28
# Label requiring a response
responseRequiredLabel: more-information-needed
# Comment to post when closing an issue for lack of response. Set to `false` to disable.
closeComment: >
This issue has been automatically closed because there has been no response
to our request for more information from the original author. With only the
information that is currently in the issue, we don't have enough information
to take action. Please reach out if you have or find the answers we need so
that we can investigate further.

View File

@ -0,0 +1,25 @@
name: Tests
on:
- pull_request
- push
jobs:
test:
name: Test
strategy:
matrix:
os: [ubuntu-20.04, macos-latest, windows-2019]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- name: Checkout the Latest Package Code
uses: actions/checkout@v3
- name: Setup Pulsar Editor
uses: pulsar-edit/action-pulsar-dependency@v2
with:
package-to-test: "settings-view"
- name: Run the Headless Pulsar Tests
uses: GabrielBB/xvfb-action@v1
with:
run: yarn start --test spec
working-directory: ./pulsar

2
packages/settings-view/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
npm-debug.log

View File

@ -0,0 +1 @@
[See how you can contribute](https://github.com/pulsar-edit/.github/blob/main/CONTRIBUTING.md)

View File

@ -0,0 +1,57 @@
# Settings View package
Edit core configuration settings, install and configure packages, and change themes from within Pulsar.
![Settings View](https://cloud.githubusercontent.com/assets/118951/16886698/b0ca5fae-4a8a-11e6-8afc-2c03fda4618c.PNG)
## Usage
You can open the Settings View by navigating to
***LNX***: _Edit > Preferences_ -
***MAC***: _Atom > Preferences_ -
***WIN***: _File > Settings_.
In order to install new packages and themes, click on the _Install_ section on the left-hand side.
Once installed, community packages/themes and their settings are housed within their respective section.
All packages/themes that have updates will be listed under the _Updates_ section. Finally, all keybindings (including ones that community packages have added) are available in the _Keybindings_ section.
Want to learn more? Check out the [Getting Started: Pulsar Basics](https://pulsar-edit.dev/docs/launch-manual/sections/getting-started/#pulsar-basics) and [Using Pulsar: Pulsar Packages](https://pulsar-edit.dev/docs/launch-manual/sections/using-pulsar/#pulsar-packages) sections in the Pulsar Launch Manual.
### Commands and Keybindings
All of the following commands are under the `atom-workspace` selector.
|Command|Description|Keybinding (Linux)|Keybinding (macOS)|Keybinding (Windows)|
|-------|-----------|------------------|-----------------|--------------------|
|`settings-view:open`|Opens the Settings View|<kbd>ctrl-,</kbd>|<kbd>cmd-,</kbd>|<kbd>ctrl-,</kbd>|
|`settings-view:core`|Opens the _Core_ section of the Settings View|
|`settings-view:editor`|Opens the _Editor_ section of the Settings View|
|`settings-view:system`|Opens the _System_ section of the Settings View (Windows)|
|`settings-view:show-keybindings`|Opens the _Keybindings_ section of the Settings View|
|`settings-view:uninstall-packages`|Opens the _Packages_ section of the Settings View|
|`settings-view:change-themes`|Opens the _Themes_ section of the Settings View|
|`settings-view:uninstall-themes`|Opens the _Themes_ section of the Settings View|
|`settings-view:check-for-updates`|Opens the _Updates_ section of the Settings View|
|`settings-view:install-packages-and-themes`|Opens the _Install_ section of the Settings View|
Custom keybindings can be added by referencing the above commands. To learn more, visit the [Using Pulsar: Basic Customization](https://pulsar-edit.dev/docs/launch-manual/sections/using-pulsar/#basic-customization) or [Behind Pulsar: Keymaps In-Depth](https://pulsar-edit.dev/docs/launch-manual/sections/behind-pulsar/#keymaps-in-depth) sections in the Pulsar Launch Manual.
## Customize
The Settings View package uses the `ui-variables` to match a theme's color scheme. You can still customize the UI in your `styles.less` file. For example:
```less
// Change the color of the titles
.settings-view .section .section-heading {
color: white;
}
// Change the font size of the setting descriptions
.settings-view .setting-description {
font-size: 13px;
}
```
Use the [developer tools](https://pulsar-edit.dev/docs/launch-manual/sections/core-hacking/#developer-tools) to find more selectors.
## Contributing
Always feel free to help out! Whether it's [filing bugs and feature requests](https://github.com/pulsar-edit/pulsar/issues/new) or working on some of the [open issues](https://github.com/pulsar-edit/pulsar/issues), Pulsar's [contributing guide](https://github.com/pulsar-edit/.github/blob/main/CONTRIBUTING.md) will help get you started while the [guide for contributing to packages](https://pulsar-edit.dev/docs/launch-manual/sections/core-hacking/#contributing-to-official-pulsar-packages) has some extra information.
## License
MIT License. See [the license](../../LICENSE.md) for more details.

View File

@ -0,0 +1,5 @@
'.platform-darwin':
'cmd-,': 'settings-view:open'
'.platform-win32, .platform-linux':
'ctrl-,': 'settings-view:open'

View File

@ -0,0 +1,226 @@
fs = require 'fs-plus'
path = require 'path'
{remote} = require 'electron'
glob = require 'glob'
request = require 'request'
module.exports =
class AtomIoClient
constructor: (@packageManager, @baseURL) ->
@baseURL ?= 'https://api.pulsar-edit.dev/api/'
# 12 hour expiry
@expiry = 1000 * 60 * 60 * 12
@createAvatarCache()
@expireAvatarCache()
# Public: Get an avatar image from the filesystem, fetching it first if necessary
avatar: (login, callback) ->
@cachedAvatar login, (err, cached) =>
stale = Date.now() - parseInt(cached.split('-').pop()) > @expiry if cached
if cached and (not stale or not @online())
callback null, cached
else
@fetchAndCacheAvatar(login, callback)
# Public: get a package from the atom.io API, with the appropriate level of
# caching.
package: (name, callback) ->
packagePath = "packages/#{name}"
data = @fetchFromCache(packagePath)
if data
callback(null, data)
else
@request(packagePath, callback)
featuredPackages: (callback) ->
# TODO clean up caching copypasta
data = @fetchFromCache 'packages/featured'
if data
callback(null, data)
else
@getFeatured(false, callback)
featuredThemes: (callback) ->
# TODO clean up caching copypasta
data = @fetchFromCache 'themes/featured'
if data
callback(null, data)
else
@getFeatured(true, callback)
getFeatured: (loadThemes, callback) ->
# apm already does this, might as well use it instead of request i guess? The
# downside is that I need to repeat caching logic here.
@packageManager.getFeatured(loadThemes)
.then (packages) =>
# copypasta from below
key = if loadThemes then 'themes/featured' else 'packages/featured'
cached =
data: packages
createdOn: Date.now()
localStorage.setItem(@cacheKeyForPath(key), JSON.stringify(cached))
# end copypasta
callback(null, packages)
.catch (error) ->
callback(error, null)
request: (path, callback) ->
options = {
url: "#{@baseURL}#{path}"
headers: {'User-Agent': navigator.userAgent}
gzip: true
}
request options, (err, res, body) =>
return callback(err) if err
try
# NOTE: request's json option does not populate err if parsing fails,
# so we do it manually
body = @parseJSON(body)
delete body.versions
cached =
data: body
createdOn: Date.now()
localStorage.setItem(@cacheKeyForPath(path), JSON.stringify(cached))
callback(err, cached.data)
catch error
callback(error)
cacheKeyForPath: (path) ->
"settings-view:#{path}"
online: ->
navigator.onLine
# This could use a better name, since it checks whether it's appropriate to return
# the cached data and pretends it's null if it's stale and we're online
fetchFromCache: (packagePath) ->
cached = localStorage.getItem(@cacheKeyForPath(packagePath))
cached = if cached then @parseJSON(cached)
if cached? and (not @online() or Date.now() - cached.createdOn < @expiry)
return cached.data
else
# falsy data means "try to hit the network"
return null
createAvatarCache: ->
fs.makeTree(@getCachePath())
avatarPath: (login) ->
path.join @getCachePath(), "#{login}-#{Date.now()}"
cachedAvatar: (login, callback) ->
glob @avatarGlob(login), (err, files) =>
return callback(err) if err
files.sort().reverse()
for imagePath in files
filename = path.basename(imagePath)
[..., createdOn] = filename.split('-')
if Date.now() - parseInt(createdOn) < @expiry
return callback(null, imagePath)
callback(null, null)
avatarGlob: (login) ->
path.join @getCachePath(), "#{login}-*([0-9])"
fetchAndCacheAvatar: (login, callback) ->
if not @online()
callback(null, null)
else
imagePath = @avatarPath login
requestObject = {
url: "https://avatars.githubusercontent.com/#{login}"
headers: {'User-Agent': navigator.userAgent}
}
request.head requestObject, (error, response, body) ->
if error? or response.statusCode isnt 200 or not response.headers['content-type'].startsWith('image/')
callback(error)
else
writeStream = fs.createWriteStream imagePath
writeStream.on 'finish', -> callback(null, imagePath)
writeStream.on 'error', (error) ->
writeStream.close()
try
fs.unlinkSync imagePath if fs.existsSync imagePath
callback(error)
request(requestObject).pipe(writeStream)
# The cache expiry doesn't need to be clever, or even compare dates, it just
# needs to always keep around the newest item, and that item only. The localStorage
# cache updates in place, so it doesn't need to be purged.
expireAvatarCache: ->
deleteAvatar = (child) =>
avatarPath = path.join(@getCachePath(), child)
fs.unlink avatarPath, (error) ->
if error and error.code isnt 'ENOENT' # Ignore cache paths that don't exist
console.warn("Error deleting avatar (#{error.code}): #{avatarPath}")
fs.readdir @getCachePath(), (error, _files) ->
_files ?= []
files = {}
for filename in _files
parts = filename.split('-')
stamp = parts.pop()
key = parts.join('-')
files[key] ?= []
files[key].push "#{key}-#{stamp}"
for key, children of files
children.sort()
children.pop() # keep
# Right now a bunch of clients might be instantiated at once, so
# we can just ignore attempts to unlink files that have already been removed
# - this should be fixed with a singleton client
children.forEach(deleteAvatar)
getCachePath: ->
@cachePath ?= path.join(remote.app.getPath('userData'), 'Cache', 'settings-view')
search: (query, options) ->
qs = {q: query}
if options.themes
qs.filter = 'theme'
else if options.packages
qs.filter = 'package'
options = {
url: "#{@baseURL}packages/search"
headers: {'User-Agent': navigator.userAgent}
qs: qs
gzip: true
}
new Promise (resolve, reject) =>
request options, (err, res, textBody) =>
if err
error = new Error("Searching for \u201C#{query}\u201D failed.")
error.stderr = err.message
reject(error)
else
try
# NOTE: request's json option does not populate err if parsing fails,
# so we do it manually
body = @parseJSON(textBody)
if body.filter
resolve(
body.filter (pkg) -> pkg.releases?.latest?
.map ({readme, metadata, downloads, stargazers_count, repository}) ->
Object.assign metadata, {readme, downloads, stargazers_count, repository: repository.url}
)
else
error = new Error("Searching for \u201C#{query}\u201D failed.\n")
error.stderr = "API returned: " + textBody
reject error
catch e
error = new Error("Searching for \u201C#{query}\u201D failed.")
error.stderr = e.message + '\n' + textBody
reject error
parseJSON: (s) ->
JSON.parse(s)

View File

@ -0,0 +1,53 @@
/** @babel */
import {Disposable} from 'atom'
export default class CollapsibleSectionPanel {
notHiddenCardsLength (sectionElement) {
return sectionElement.querySelectorAll('.package-card:not(.hidden)').length
}
updateSectionCount (headerElement, countElement, packageCount, totalCount) {
if (totalCount != null) {
countElement.textContent = `${packageCount}/${totalCount}`
} else {
countElement.textContent = packageCount
}
if (packageCount > 0) {
headerElement.classList.add('has-items')
}
}
updateSectionCounts () {
this.resetSectionHasItems()
const filterText = this.refs.filterEditor.getText()
if (filterText === '') {
this.updateUnfilteredSectionCounts()
} else {
this.updateFilteredSectionCounts()
}
}
handleEvents () {
const handler = (e) => {
const target = e.target.closest('.sub-section .has-items')
if (target) {
target.parentNode.classList.toggle('collapsed')
}
}
this.element.addEventListener('click', handler)
return new Disposable(() => this.element.removeEventListener('click', handler))
}
resetCollapsibleSections (headerSections) {
for (const headerSection of headerSections) {
this.resetCollapsibleSection(headerSection)
}
}
resetCollapsibleSection (headerSection) {
headerSection.classList.remove('has-items')
}
}

View File

@ -0,0 +1,78 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable} from 'atom'
import etch from 'etch'
import SettingsPanel from './settings-panel'
export default class EditorPanel {
constructor () {
etch.initialize(this)
this.subscriptions = new CompositeDisposable()
this.subscriptions.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
}
destroy () {
this.subscriptions.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<div tabIndex='0' className='panels-item' onclick={this.didClick}>
<SettingsPanel
namespace='editor'
icon='code'
note={`<div class="text icon icon-question" id="editor-settings-note" tabindex="-1">These settings are related to text editing. Some of these can be overriden on a per-language basis. Check language settings by clicking its package card in the <a class="link packages-open">Packages list</a>.</div>`} />
</div>
)
}
focus () {
this.element.focus()
}
show () {
this.element.style.display = ''
}
didClick (event) {
const target = event.target.closest('.packages-open')
if (target) {
atom.workspace.open('atom://config/packages')
}
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,82 @@
/** @babel */
/** @jsx etch.dom */
import etch from 'etch'
export default class ErrorView {
constructor (packageManager, {message, stderr, packageInstallError}) {
etch.initialize(this)
this.isOutputHidden = true
this.refs.detailsArea.style.display = 'none'
this.refs.details.textContent = stderr
this.refs.message.textContent = message
// Check for native build tools and show warning if missing.
if (packageInstallError && process.platform === 'win32') {
packageManager.checkNativeBuildTools().catch(() => {
this.refs.alert.appendChild(new CompileToolsErrorView().element)
})
}
}
update () {}
destroy () {
return etch.destroy(this)
}
render () {
return (
<div className='error-message'>
<div ref='alert' className='alert alert-danger alert-dismissable native-key-bindings' tabIndex='-1'>
<button ref='close' className='close icon icon-x' onclick={() => this.destroy()} />
<span ref='message' className='native-key-bindings' />
<a ref='detailsLink' className='alert-link error-link' onclick={() => this.toggleOutput()}>{'Show output\u2026'}</a>
<div ref='detailsArea' className='padded'>
<pre ref='details' className='error-details text' />
</div>
</div>
</div>
)
}
toggleOutput () {
if (this.isOutputHidden) {
this.isOutputHidden = false
this.refs.detailsArea.style.display = ''
this.refs.detailsLink.textContent = 'Hide output\u2026'
} else {
this.isOutputHidden = true
this.refs.detailsArea.style.display = 'none'
this.refs.detailsLink.textContent = 'Show output\u2026'
}
}
}
class CompileToolsErrorView {
constructor () {
etch.initialize(this)
}
update () {}
render () {
return (
<div>
<div className='icon icon-alert compile-tools-heading compile-tools-message'>Compiler tools not found</div>
<div className='compile-tools-message'>Packages that depend on modules that contain C/C++ code will fail to install.</div>
<div className='compile-tools-message'>
<span>Read </span>
<a className='link' href='https://github.com/atom/atom/blob/master/docs/build-instructions/windows.md'>here</a>
<span> for instructions on installing Python and Visual Studio.</span>
</div>
<div className='compile-tools-message'>
<span>Run </span>
<code className='alert-danger'>apm install --check</code>
<span> after installing to test compiling a native module.</span>
</div>
</div>
)
}
}

View File

@ -0,0 +1,79 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable} from 'atom'
import etch from 'etch'
import SettingsPanel from './settings-panel'
export default class GeneralPanel {
constructor () {
etch.initialize(this)
this.subscriptions = new CompositeDisposable()
this.subscriptions.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
}
destroy () {
this.subscriptions.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<div tabIndex='0' className='panels-item' onclick={this.didClick}>
<SettingsPanel
ref='panel'
namespace='core'
icon='settings'
note={`<div class="text icon icon-question" id="core-settings-note" tabindex="-1">These are Atom's core settings which affect behavior unrelated to text editing. Individual packages may have their own additional settings found within their package card in the <a class="link packages-open">Packages list</a>.</div>`} />
</div>
)
}
focus () {
this.element.focus()
}
show () {
this.element.style.display = ''
}
didClick (event) {
const target = event.target.closest('.packages-open')
if (target) {
atom.workspace.open('atom://config/packages')
}
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,342 @@
/** @babel */
/** @jsx etch.dom */
import path from 'path'
import electron from 'electron'
import etch from 'etch'
import hostedGitInfo from 'hosted-git-info'
import {CompositeDisposable, TextEditor} from 'atom'
import PackageCard from './package-card'
import ErrorView from './error-view'
const PackageNameRegex = /config\/install\/(package|theme):([a-z0-9-_]+)/i
export default class InstallPanel {
constructor (settingsView, packageManager) {
this.settingsView = settingsView
this.packageManager = packageManager
this.disposables = new CompositeDisposable()
this.client = this.packageManager.getClient()
this.atomIoURL = 'https://pulsar-edit.dev/packages'
etch.initialize(this)
this.refs.searchMessage.style.display = 'none'
this.refs.searchEditor.setPlaceholderText('Search packages')
this.searchType = 'packages'
this.disposables.add(
this.packageManager.on('package-install-failed', ({pack, error}) => {
this.refs.searchErrors.appendChild(new ErrorView(this.packageManager, error).element)
})
)
this.disposables.add(
this.packageManager.on('package-installed theme-installed', ({pack}) => {
const gitUrlInfo =
(this.currentGitPackageCard && this.currentGitPackageCard.pack && this.currentGitPackageCard.pack.gitUrlInfo)
? this.currentGitPackageCard.pack.gitUrlInfo
: null
if (gitUrlInfo && gitUrlInfo === pack.gitUrlInfo) {
this.updateGitPackageCard(pack)
}
})
)
this.disposables.add(
this.refs.searchEditor.onDidStopChanging(() => {
this.performSearch()
})
)
this.disposables.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
this.loadFeaturedPackages()
}
destroy () {
this.disposables.dispose()
return etch.destroy(this)
}
update () {}
focus () {
this.refs.searchEditor.element.focus()
}
show () {
this.element.style.display = ''
}
render () {
return (
<div className='panels-item' tabIndex='-1'>
<div className='section packages'>
<div className='section-container'>
<h1 ref='installHeading' className='section-heading icon icon-plus'>Install Packages</h1>
<div className='text native-key-bindings' tabIndex='-1'>
<span className='icon icon-question' />
<span ref='publishedToText'>Packages are published to </span>
<a className='link' onclick={this.didClickOpenAtomIo.bind(this)}>atom.io</a>
<span> and are installed to {path.join(process.env.ATOM_HOME, 'packages')}</span>
</div>
<div className='search-container clearfix'>
<div className='editor-container'>
<TextEditor mini ref='searchEditor' />
</div>
<div className='btn-group'>
<button ref='searchPackagesButton' className='btn btn-default selected' onclick={this.didClickSearchPackagesButton.bind(this)}>Packages</button>
<button ref='searchThemesButton' className='btn btn-default' onclick={this.didClickSearchThemesButton.bind(this)}>Themes</button>
</div>
</div>
<div ref='searchErrors' />
<div ref='searchMessage' className='alert alert-info search-message icon icon-search' />
<div ref='resultsContainer' className='container package-container' />
</div>
</div>
<div className='section packages'>
<div className='section-container'>
<div ref='featuredHeading' className='section-heading icon icon-star' />
<div ref='featuredErrors' />
<div ref='loadingMessage' className='alert alert-info icon icon-hourglass' />
<div ref='featuredContainer' className='container package-container' />
</div>
</div>
</div>
)
}
setSearchType (searchType) {
if (searchType === 'theme') {
this.searchType = 'themes'
this.refs.searchThemesButton.classList.add('selected')
this.refs.searchPackagesButton.classList.remove('selected')
this.refs.searchEditor.setPlaceholderText('Search themes')
this.refs.publishedToText.textContent = 'Themes are published to '
this.atomIoURL = 'https://pulsar-edit.dev/themes'
this.loadFeaturedPackages(true)
} else if (searchType === 'package') {
this.searchType = 'packages'
this.refs.searchPackagesButton.classList.add('selected')
this.refs.searchThemesButton.classList.remove('selected')
this.refs.searchEditor.setPlaceholderText('Search packages')
this.refs.publishedToText.textContent = 'Packages are published to '
this.atomIoURL = 'https://pulsar-edit.dev/packages'
this.loadFeaturedPackages()
}
}
beforeShow (options) {
if (options && options.uri) {
const query = this.extractQueryFromURI(options.uri)
if (query != null) {
const {searchType, packageName} = query
this.setSearchType(searchType)
this.refs.searchEditor.setText(packageName)
this.performSearch()
}
}
}
extractQueryFromURI (uri) {
const matches = PackageNameRegex.exec(uri)
if (matches) {
const [, searchType, packageName] = Array.from(matches)
return {searchType, packageName}
} else {
return null
}
}
performSearch () {
const query = this.refs.searchEditor.getText().trim().toLowerCase()
if (query) {
this.performSearchForQuery(query)
}
}
performSearchForQuery (query) {
const gitUrlInfo = hostedGitInfo.fromUrl(query)
if (gitUrlInfo) {
const type = gitUrlInfo.default
if (type === 'sshurl' || type === 'https' || type === 'shortcut') {
this.showGitInstallPackageCard({name: query, gitUrlInfo})
}
} else {
this.search(query)
}
}
showGitInstallPackageCard (pack) {
if (this.currentGitPackageCard) {
this.currentGitPackageCard.destroy()
}
this.currentGitPackageCard = this.getPackageCardView(pack)
this.currentGitPackageCard.displayGitPackageInstallInformation()
this.replaceCurrentGitPackageCardView()
}
updateGitPackageCard (pack) {
if (this.currentGitPackageCard) {
this.currentGitPackageCard.destroy()
}
this.currentGitPackageCard = this.getPackageCardView(pack)
this.replaceCurrentGitPackageCardView()
}
replaceCurrentGitPackageCardView () {
this.refs.resultsContainer.innerHTML = ''
this.addPackageCardView(this.refs.resultsContainer, this.currentGitPackageCard)
}
async search (query) {
this.refs.resultsContainer.innerHTML = ''
this.refs.searchMessage.textContent = `Searching ${this.searchType} for \u201C${query}\u201D\u2026`
this.refs.searchMessage.style.display = ''
const options = {}
options[this.searchType] = true
try {
const packages = (await this.client.search(query, options)) || []
this.refs.resultsContainer.innerHTML = ''
this.refs.searchMessage.style.display = 'none'
if (packages.length === 0) {
this.refs.searchMessage.textContent = `No ${this.searchType.replace(/s$/, '')} results for \u201C${query}\u201D`
this.refs.searchMessage.style.display = ''
}
this.addPackageViews(this.refs.resultsContainer, packages)
} catch (error) {
this.refs.searchMessage.style.display = 'none'
this.refs.searchErrors.appendChild(new ErrorView(this.packageManager, error).element)
}
}
addPackageViews (container, packages) {
for (const pack of packages) {
this.addPackageCardView(container, this.getPackageCardView(pack))
}
}
addPackageCardView (container, packageCard) {
const packageRow = document.createElement('div')
packageRow.classList.add('row')
packageRow.appendChild(packageCard.element)
container.appendChild(packageRow)
}
getPackageCardView (pack) {
return new PackageCard(pack, this.settingsView, this.packageManager, {back: 'Install'})
}
filterPackages (packages, themes) {
return packages.filter(({theme}) => themes ? theme : !theme)
}
// Load and display the featured packages that are available to install.
loadFeaturedPackages (loadThemes) {
if (loadThemes == null) {
loadThemes = false
}
this.refs.featuredContainer.innerHTML = ''
if (loadThemes) {
this.refs.installHeading.textContent = 'Install Themes'
this.refs.featuredHeading.textContent = 'Featured Themes'
this.refs.loadingMessage.textContent = 'Loading featured themes\u2026'
} else {
this.refs.installHeading.textContent = 'Install Packages'
this.refs.featuredHeading.textContent = 'Featured Packages'
this.refs.loadingMessage.textContent = 'Loading featured packages\u2026'
}
this.refs.loadingMessage.style.display = ''
const handle = error => {
this.refs.loadingMessage.style.display = 'none'
this.refs.featuredErrors.appendChild(new ErrorView(this.packageManager, error).element)
}
if (loadThemes) {
this.client.featuredThemes((error, themes) => {
if (error) {
handle(error)
} else {
this.refs.loadingMessage.style.display = 'none'
this.refs.featuredHeading.textContent = 'Featured Themes'
this.addPackageViews(this.refs.featuredContainer, themes)
}
})
} else {
this.client.featuredPackages((error, packages) => {
if (error) {
handle(error)
} else {
this.refs.loadingMessage.style.display = 'none'
this.refs.featuredHeading.textContent = 'Featured Packages'
this.addPackageViews(this.refs.featuredContainer, packages)
}
})
}
}
didClickOpenAtomIo (event) {
event.preventDefault()
electron.shell.openExternal(this.atomIoURL)
}
didClickSearchPackagesButton () {
if (!this.refs.searchPackagesButton.classList.contains('selected')) {
this.setSearchType('package')
}
this.performSearch()
}
didClickSearchThemesButton () {
if (!this.refs.searchThemesButton.classList.contains('selected')) {
this.setSearchType('theme')
}
this.performSearch()
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,321 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable, TextEditor} from 'atom'
import etch from 'etch'
import fuzzaldrin from 'fuzzaldrin'
import CollapsibleSectionPanel from './collapsible-section-panel'
import PackageCard from './package-card'
import ErrorView from './error-view'
import List from './list'
import ListView from './list-view'
import {ownerFromRepository, packageComparatorAscending} from './utils'
export default class InstalledPackagesPanel extends CollapsibleSectionPanel {
static loadPackagesDelay () {
return 300
}
constructor (settingsView, packageManager) {
super()
etch.initialize(this)
this.settingsView = settingsView
this.packageManager = packageManager
this.items = {
dev: new List('name'),
core: new List('name'),
user: new List('name'),
git: new List('name')
}
this.itemViews = {
dev: new ListView(this.items.dev, this.refs.devPackages, this.createPackageCard.bind(this)),
core: new ListView(this.items.core, this.refs.corePackages, this.createPackageCard.bind(this)),
user: new ListView(this.items.user, this.refs.communityPackages, this.createPackageCard.bind(this)),
git: new ListView(this.items.git, this.refs.gitPackages, this.createPackageCard.bind(this))
}
this.subscriptions = new CompositeDisposable()
this.subscriptions.add(
this.refs.filterEditor.onDidStopChanging(() => { this.matchPackages() })
)
this.subscriptions.add(
this.packageManager.on('package-install-failed theme-install-failed package-uninstall-failed theme-uninstall-failed package-update-failed theme-update-failed', ({pack, error}) => {
this.refs.updateErrors.appendChild(new ErrorView(this.packageManager, error).element)
})
)
let loadPackagesTimeout
this.subscriptions.add(
this.packageManager.on('package-updated package-installed package-uninstalled', () => {
clearTimeout(loadPackagesTimeout)
loadPackagesTimeout = setTimeout(this.loadPackages.bind(this), InstalledPackagesPanel.loadPackagesDelay())
})
)
this.subscriptions.add(this.handleEvents())
this.subscriptions.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
this.loadPackages()
}
focus () {
this.refs.filterEditor.element.focus()
}
show () {
this.element.style.display = ''
}
destroy () {
this.subscriptions.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<div className='panels-item' tabIndex='-1'>
<section className='section'>
<div className='section-container'>
<div className='section-heading icon icon-package'>
Installed Packages
<span ref='totalPackages' className='section-heading-count badge badge-flexible'></span>
</div>
<div className='editor-container'>
<TextEditor ref='filterEditor' mini placeholderText='Filter packages by name' />
</div>
<div ref='updateErrors' />
<section className='sub-section installed-packages'>
<h3 ref='communityPackagesHeader' className='sub-section-heading icon icon-package'>
Community Packages
<span ref='communityCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='communityPackages' className='container package-container'>
<div ref='communityLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading packages</div>
</div>
</section>
<section className='sub-section core-packages'>
<h3 ref='corePackagesHeader' className='sub-section-heading icon icon-package'>
Core Packages
<span ref='coreCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='corePackages' className='container package-container'>
<div ref='coreLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading packages</div>
</div>
</section>
<section className='sub-section dev-packages'>
<h3 ref='devPackagesHeader' className='sub-section-heading icon icon-package'>
Development Packages
<span ref='devCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='devPackages' className='container package-container'>
<div ref='devLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading packages</div>
</div>
</section>
<section className='sub-section git-packages'>
<h3 ref='gitPackagesHeader' className='sub-section-heading icon icon-package'>
Git Packages
<span ref='gitCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='gitPackages' className='container package-container'>
<div ref='gitLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading packages</div>
</div>
</section>
</div>
</section>
</div>
)
}
filterPackages (packages) {
packages.dev = packages.dev.filter(({theme}) => !theme)
packages.user = packages.user.filter(({theme}) => !theme)
packages.core = packages.core.filter(({theme}) => !theme)
packages.git = (packages.git || []).filter(({theme}) => !theme)
for (let pack of packages.core) {
if (pack.repository == null) {
pack.repository = `https://github.com/atom/${pack.name}`
}
}
for (let packageType of ['dev', 'core', 'user', 'git']) {
for (let pack of packages[packageType]) {
pack.owner = ownerFromRepository(pack.repository)
}
}
return packages
}
sortPackages (packages) {
packages.dev.sort(packageComparatorAscending)
packages.core.sort(packageComparatorAscending)
packages.user.sort(packageComparatorAscending)
packages.git.sort(packageComparatorAscending)
return packages
}
loadPackages () {
const packagesWithUpdates = {}
this.packageManager.getOutdated().then((packages) => {
for (let {name, latestVersion} of packages) {
packagesWithUpdates[name] = latestVersion
}
this.displayPackageUpdates(packagesWithUpdates)
})
this.packageManager.getInstalled().then((packages) => {
this.packages = this.sortPackages(this.filterPackages(packages))
this.refs.devLoadingArea.remove()
this.items.dev.setItems(this.packages.dev)
this.refs.coreLoadingArea.remove()
this.items.core.setItems(this.packages.core)
this.refs.communityLoadingArea.remove()
this.items.user.setItems(this.packages.user)
this.refs.gitLoadingArea.remove()
this.items.git.setItems(this.packages.git)
// TODO show empty mesage per section
this.updateSectionCounts()
this.displayPackageUpdates(packagesWithUpdates)
this.matchPackages()
}).catch((error) => {
console.error(error.message, error.stack)
})
}
displayPackageUpdates (packagesWithUpdates) {
for (const packageType of ['dev', 'core', 'user', 'git']) {
for (const packageCard of this.itemViews[packageType].getViews()) {
const newVersion = packagesWithUpdates[packageCard.pack.name]
if (newVersion) {
packageCard.displayAvailableUpdate(newVersion)
}
}
}
}
createPackageCard (pack) {
return new PackageCard(pack, this.settingsView, this.packageManager, {back: 'Packages'})
}
filterPackageListByText (text) {
if (!this.packages) {
return
}
for (let packageType of ['dev', 'core', 'user', 'git']) {
const allViews = this.itemViews[packageType].getViews()
const activeViews = this.itemViews[packageType].filterViews((pack) => {
if (text === '') {
return true
} else {
const owner = pack.owner != null ? pack.owner : ownerFromRepository(pack.repository)
const filterText = `${pack.name} ${owner}`
return fuzzaldrin.score(filterText, text) > 0
}
})
for (const view of allViews) {
if (view) {
view.element.style.display = 'none'
view.element.classList.add('hidden')
}
}
for (const view of activeViews) {
if (view) {
view.element.style.display = ''
view.element.classList.remove('hidden')
}
}
}
this.updateSectionCounts()
}
updateUnfilteredSectionCounts () {
this.updateSectionCount(this.refs.communityPackagesHeader, this.refs.communityCount, this.packages.user.length)
this.updateSectionCount(this.refs.corePackagesHeader, this.refs.coreCount, this.packages.core.length)
this.updateSectionCount(this.refs.devPackagesHeader, this.refs.devCount, this.packages.dev.length)
this.updateSectionCount(this.refs.gitPackagesHeader, this.refs.gitCount, this.packages.git.length)
const totalPackages =
this.packages.user.length +
this.packages.core.length +
this.packages.dev.length +
this.packages.git.length
this.refs.totalPackages.textContent = totalPackages.toString()
}
updateFilteredSectionCounts () {
const community = this.notHiddenCardsLength(this.refs.communityPackages)
this.updateSectionCount(this.refs.communityPackagesHeader, this.refs.communityCount, community, this.packages.user.length)
const core = this.notHiddenCardsLength(this.refs.corePackages)
this.updateSectionCount(this.refs.corePackagesHeader, this.refs.coreCount, core, this.packages.core.length)
const dev = this.notHiddenCardsLength(this.refs.devPackages)
this.updateSectionCount(this.refs.devPackagesHeader, this.refs.devCount, dev, this.packages.dev.length)
const git = this.notHiddenCardsLength(this.refs.gitPackages)
this.updateSectionCount(this.refs.gitPackagesHeader, this.refs.gitCount, git, this.packages.git.length)
const shownPackages = dev + core + community + git
const totalPackages = this.packages.user.length + this.packages.core.length + this.packages.dev.length + this.packages.git.length
this.refs.totalPackages.textContent = `${shownPackages}/${totalPackages}`
}
resetSectionHasItems () {
this.resetCollapsibleSections([this.refs.communityPackagesHeader, this.refs.corePackagesHeader, this.refs.devPackagesHeader, this.refs.gitPackagesHeader])
}
matchPackages () {
this.filterPackageListByText(this.refs.filterEditor.getText())
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,249 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable, TextEditor} from 'atom'
import etch from 'etch'
import _ from 'underscore-plus'
import path from 'path'
export default class KeybindingsPanel {
constructor () {
etch.initialize(this)
this.disposables = new CompositeDisposable()
this.disposables.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
this.otherPlatformPattern = new RegExp(`\\.platform-(?!${_.escapeRegExp(process.platform)}\\b)`)
this.platformPattern = new RegExp(`\\.platform-${_.escapeRegExp(process.platform)}\\b`)
this.disposables.add(this.refs.searchEditor.onDidStopChanging(() => {
this.filterKeyBindings(this.keyBindings, this.refs.searchEditor.getText())
}))
this.disposables.add(atom.keymaps.onDidReloadKeymap(() => { this.loadKeyBindings() }))
this.disposables.add(atom.keymaps.onDidUnloadKeymap(() => { this.loadKeyBindings() }))
this.loadKeyBindings()
}
destroy () {
this.disposables.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<div className='panels-item' tabIndex='-1'>
<section className='keybinding-panel section'>
<div className='section-heading icon icon-keyboard'>Keybindings</div>
<div className='text native-key-bindings' tabIndex='-1'>
<span className='icon icon-question' />
<span>You can override these keybindings by copying </span>
<span className='icon icon-clippy' />
<span>and pasting them into </span>
<a className='link' onclick={this.didClickOpenKeymapFile}>your keymap file</a>
</div>
<div className='editor-container'>
<TextEditor mini ref='searchEditor' placeholderText='Search keybindings' />
</div>
<table className='native-key-bindings table text' tabIndex='-1'>
<col className='keystroke' />
<col className='command' />
<col className='source' />
<col className='selector' />
<thead>
<tr>
<th className='keystroke'>Keystroke</th>
<th className='command'>Command</th>
<th className='source'>Source</th>
<th className='selector'>Selector</th>
</tr>
</thead>
<tbody ref='keybindingRows' />
</table>
</section>
</div>
)
}
loadKeyBindings () {
this.refs.keybindingRows.innerHTML = ''
this.keyBindings = _.sortBy(atom.keymaps.getKeyBindings(), 'keystrokes')
this.appendKeyBindings(this.keyBindings)
this.filterKeyBindings(this.keyBindings, this.refs.searchEditor.getText())
}
focus () {
this.refs.searchEditor.element.focus()
}
show () {
this.element.style.display = ''
}
filterKeyBindings (keyBindings, filterString) {
this.refs.keybindingRows.innerHTML = ''
for (let keyBinding of keyBindings) {
let {selector, keystrokes, command, source} = keyBinding
source = KeybindingsPanel.determineSource(source)
var searchString = `${selector}${keystrokes}${command}${source}`.toLowerCase()
if (!searchString) {
continue
}
const keywords = filterString.trim().toLowerCase().split(' ')
if (keywords.every(keyword => searchString.indexOf(keyword) !== -1)) {
this.appendKeyBinding(keyBinding)
}
}
}
appendKeyBindings (keyBindings) {
for (const keyBinding of keyBindings) {
this.appendKeyBinding(keyBinding)
}
}
appendKeyBinding (keyBinding) {
if (!this.showSelector(keyBinding.selector)) {
return
}
const element = this.elementForKeyBinding(keyBinding)
element.dataset.keyBinding = keyBinding
this.refs.keybindingRows.appendChild(element)
}
showSelector (selector) {
let segments
if (selector) {
segments = selector.split(',') || []
} else {
segments = []
}
return segments.some((s) => this.platformPattern.test(s) || !this.otherPlatformPattern.test(s))
}
elementForKeyBinding (keyBinding) {
let {selector, keystrokes, command, source} = keyBinding
source = KeybindingsPanel.determineSource(source)
const tr = document.createElement('tr')
if (source === 'User') {
tr.classList.add('is-user')
}
const keystrokeTd = document.createElement('td')
keystrokeTd.classList.add('keystroke')
const copyIcon = document.createElement('span')
copyIcon.classList.add('icon', 'icon-clippy', 'copy-icon')
copyIcon.onclick = () => {
let content
const keymapExtension = path.extname(atom.keymaps.getUserKeymapPath())
const escapeCSON = (input) => {
return JSON.stringify(input)
.slice(1, -1) // Remove wrapping double quotes
.replace(/\\"/g, '"') // Unescape double quotes
.replace(/'/g, '\\\'') // Escape single quotes
}
if (keymapExtension === '.cson') {
content = `'${escapeCSON(selector)}':\n '${escapeCSON(keystrokes)}': '${escapeCSON(command)}'`
} else {
content = `${JSON.stringify(selector)}: {\n ${JSON.stringify(keystrokes)}: ${JSON.stringify(command)}\n}`
}
return atom.clipboard.write(content)
}
keystrokeTd.appendChild(copyIcon)
const keystrokesSpan = document.createElement('span')
keystrokesSpan.textContent = keystrokes
keystrokeTd.appendChild(keystrokesSpan)
tr.appendChild(keystrokeTd)
const commandTd = document.createElement('td')
commandTd.classList.add('command')
commandTd.textContent = command
tr.appendChild(commandTd)
const sourceTd = document.createElement('td')
sourceTd.classList.add('source')
sourceTd.textContent = source
tr.appendChild(sourceTd)
const selectorTd = document.createElement('td')
selectorTd.classList.add('selector')
selectorTd.textContent = selector
tr.appendChild(selectorTd)
return tr
}
didClickOpenKeymapFile (e) {
e.preventDefault()
atom.commands.dispatch(atom.views.getView(atom.workspace), 'application:open-your-keymap')
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
// Private: Returns a user friendly description of where a keybinding was
// loaded from.
//
// * filePath:
// The absolute path from which the keymap was loaded
//
// Returns one of:
// * `Core` indicates it comes from a bundled package.
// * `User` indicates that it was defined by a user.
// * `<package-name>` the package which defined it.
// * `Unknown` if an invalid path was passed in.
static determineSource (filePath) {
if (!filePath) {
return 'Unknown'
}
if (filePath.indexOf(path.join(atom.getLoadSettings().resourcePath, 'keymaps')) === 0) {
return 'Core'
} else if (filePath === atom.keymaps.getUserKeymapPath()) {
return 'User'
} else {
const pathParts = filePath.split(path.sep)
const packageNameIndex = pathParts.length - 3
const packageName = pathParts[packageNameIndex] != null ? pathParts[packageNameIndex] : ''
return _.undasherize(_.uncamelcase(packageName))
}
}
}

View File

@ -0,0 +1,54 @@
module.exports =
class ListView {
// * `list` a {List} object
// * `container` a jQuery element
// * `createView` a Function that returns a jQuery element / HTMLElement
// * `item` the item to create the view for
constructor (list, container, createView) {
this.list = list
this.container = container
this.createView = createView
this.views = []
this.viewMap = {}
this.list.onDidAddItem(item => this.addView(item))
this.list.onDidRemoveItem(item => this.removeView(item))
this.addViews()
}
getViews () {
return this.views
}
filterViews (filterFn) {
return this.list.filterItems(filterFn).map((item) => this.viewMap[this.list.keyForItem(item)])
}
addViews () {
for (const item of this.list.getItems()) {
this.addView(item)
}
}
addView (item) {
const view = this.createView(item)
this.views.push(view)
this.viewMap[this.list.keyForItem(item)] = view
const row = document.createElement('div')
row.classList.add('row')
row.appendChild(view.element)
this.container.insertBefore(row, this.container.children[0])
}
removeView (item) {
const key = this.list.keyForItem(item)
const view = this.viewMap[key]
if (view) {
const index = this.views.indexOf(view)
if (index > -1) this.views.splice(index, 1)
delete this.viewMap[key]
view.element.parentElement.remove()
view.destroy()
}
}
}

View File

@ -0,0 +1,71 @@
const {Emitter} = require('atom')
module.exports =
class List {
constructor (key) {
this.key = key
this.items = []
this.emitter = new Emitter()
}
getItems () {
return this.items
}
filterItems (filterFn) {
const result = []
for (const item of this.items) {
if (filterFn(item)) {
result.push(item)
}
}
return result
}
keyForItem (item) {
return item[this.key]
}
setItems (items) {
items = items.slice()
const setToAdd = difference(items, this.items, this.key)
const setToRemove = difference(this.items, items, this.key)
this.items = items
for (const item of setToAdd) {
this.emitter.emit('did-add-item', item)
}
for (const item of setToRemove) {
this.emitter.emit('did-remove-item', item)
}
}
onDidAddItem (callback) {
return this.emitter.on('did-add-item', callback)
}
onDidRemoveItem (callback) {
return this.emitter.on('did-remove-item', callback)
}
}
const difference = (array1, array2, key) => {
const obj1 = {}
for (const item of array1) {
obj1[item[key]] = item
}
const obj2 = {}
for (const item of array2) {
obj2[item[key]] = item
}
const diff = []
for (const k in obj1) {
const v = obj1[k]
if (obj2[k] == null) diff.push(v)
}
return diff
}

View File

@ -0,0 +1,96 @@
let SettingsView = null
let settingsView = null
let statusView = null
const PackageManager = require('./package-manager')
let packageManager = null
const SnippetsProvider = {
getSnippets () { return atom.config.scopedSettingsStore.propertySets }
}
const CONFIG_URI = 'atom://config'
module.exports = {
handleURI (parsed) {
switch (parsed.pathname) {
case '/show-package': this.showPackage(parsed.query.package)
}
},
showPackage (packageName) {
atom.workspace.open(`atom://config/packages/${packageName}`)
},
activate () {
atom.workspace.addOpener(uri => {
if (uri.startsWith(CONFIG_URI)) {
if (settingsView == null || settingsView.destroyed) {
settingsView = this.createSettingsView({uri})
} else {
const pane = atom.workspace.paneForItem(settingsView)
if (pane) pane.activate()
}
settingsView.showPanelForURI(uri)
return settingsView
}
})
atom.commands.add('atom-workspace', {
'settings-view:open' () { atom.workspace.open(CONFIG_URI) },
'settings-view:core' () { atom.workspace.open(`${CONFIG_URI}/core`) },
'settings-view:editor' () { atom.workspace.open(`${CONFIG_URI}/editor`) },
'settings-view:show-keybindings' () { atom.workspace.open(`${CONFIG_URI}/keybindings`) },
'settings-view:change-themes' () { atom.workspace.open(`${CONFIG_URI}/themes`) },
'settings-view:install-packages-and-themes' () { atom.workspace.open(`${CONFIG_URI}/install`) },
'settings-view:view-installed-themes' () { atom.workspace.open(`${CONFIG_URI}/themes`) },
'settings-view:uninstall-themes' () { atom.workspace.open(`${CONFIG_URI}/themes`) },
'settings-view:view-installed-packages' () { atom.workspace.open(`${CONFIG_URI}/packages`) },
'settings-view:uninstall-packages' () { atom.workspace.open(`${CONFIG_URI}/packages`) },
'settings-view:check-for-package-updates' () { atom.workspace.open(`${CONFIG_URI}/updates`) }
})
if (process.platform === 'win32' && require('atom').WinShell != null) {
atom.commands.add('atom-workspace', {'settings-view:system' () { atom.workspace.open(`${CONFIG_URI}/system`) }})
}
},
deactivate () {
if (settingsView) settingsView.destroy()
if (statusView) statusView.destroy()
settingsView = null
packageManager = null
statusView = null
},
consumeStatusBar (statusBar) {
if (packageManager == null) packageManager = new PackageManager()
packageManager.getOutdated().then(updates => {
if (packageManager) {
const PackageUpdatesStatusView = require('./package-updates-status-view')
statusView = new PackageUpdatesStatusView()
statusView.initialize(statusBar, packageManager, updates)
}
})
},
consumeSnippets (snippets) {
if (typeof snippets.getUnparsedSnippets === 'function') {
SnippetsProvider.getSnippets = snippets.getUnparsedSnippets.bind(snippets)
}
if (typeof snippets.getUserSnippetsPath === 'function') {
SnippetsProvider.getUserSnippetsPath = snippets.getUserSnippetsPath.bind(snippets)
}
},
createSettingsView (params) {
if (SettingsView == null) SettingsView = require('./settings-view')
if (packageManager == null) packageManager = new PackageManager()
params.packageManager = packageManager
params.snippetsProvider = SnippetsProvider
settingsView = new SettingsView(params)
return settingsView
}
}

View File

@ -0,0 +1,601 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable, Disposable} from 'atom'
import {shell} from 'electron'
import etch from 'etch'
import {ownerFromRepository} from './utils'
let marked = null
export default class PackageCard {
constructor (pack, settingsView, packageManager, options = {}) {
this.pack = pack
this.settingsView = settingsView
this.packageManager = packageManager
this.disposables = new CompositeDisposable()
// It might be useful to either wrap this.pack in a class that has a
// ::validate method, or add a method here. At the moment I think all cases
// of malformed package metadata are handled here and in ::content but belt
// and suspenders, you know
this.client = this.packageManager.getClient()
this.type = this.pack.theme ? 'theme' : 'package'
this.name = this.pack.name
this.onSettingsView = options.onSettingsView
if (this.pack.latestVersion !== this.pack.version) {
this.newVersion = this.pack.latestVersion
}
if (this.pack.apmInstallSource && this.pack.apmInstallSource.type === 'git') {
if (this.pack.apmInstallSource.sha !== this.pack.latestSha) {
this.newSha = this.pack.latestSha
}
}
// Default to displaying the download count
if (!options.stats) {
options.stats = {downloads: true}
}
etch.initialize(this)
this.displayStats(options)
this.handlePackageEvents()
this.handleButtonEvents(options)
this.loadCachedMetadata()
// themes have no status and cannot be dis/enabled
if (this.type === 'theme') {
this.refs.statusIndicator.remove()
this.refs.enablementButton.remove()
}
if (atom.packages.isBundledPackage(this.pack.name)) {
this.refs.installButtonGroup.remove()
this.refs.uninstallButton.remove()
}
if (!this.newVersion && !this.newSha) {
this.refs.updateButtonGroup.style.display = 'none'
}
this.hasCompatibleVersion = true
this.updateInterfaceState()
}
render () {
const displayName = (this.pack.gitUrlInfo ? this.pack.gitUrlInfo.project : this.pack.name) || ''
const owner = ownerFromRepository(this.pack.repository)
const description = this.pack.description || ''
return (
<div className='package-card col-lg-8'>
<div ref='statsContainer' className='stats pull-right'>
<span ref='packageStars' className='stats-item'>
<span ref='stargazerIcon' className='icon icon-star' />
<span ref='stargazerCount' className='value' />
</span>
<span ref='packageDownloads' className='stats-item'>
<span ref='downloadIcon' className='icon icon-cloud-download' />
<span ref='downloadCount' className='value' />
</span>
</div>
<div className='body'>
<h4 className='card-name'>
<a className='package-name' ref='packageName'>{displayName}</a>
<span className='package-version'>
<span ref='versionValue' className='value'>{String(this.pack.version)}</span>
</span>
</h4>
<span ref='packageDescription' className='package-description'>{description}</span>
<div ref='packageMessage' className='package-message' />
</div>
<div className='meta'>
<div ref='metaUserContainer' className='meta-user'>
<a ref='avatarLink'>
{/* A transparent gif so there is no "broken border" */}
<img ref='avatar' className='avatar' src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' />
</a>
<a ref='loginLink' className='author'>{owner}</a>
</div>
<div className='meta-controls'>
<div className='btn-toolbar'>
<div ref='updateButtonGroup' className='btn-group'>
<button type='button' className='btn btn-info icon icon-cloud-download install-button' ref='updateButton'>Update</button>
</div>
<div ref='installButtonGroup' className='btn-group'>
<button type='button' className='btn btn-info icon icon-cloud-download install-button' ref='installButton'>Install</button>
</div>
<div ref='packageActionButtonGroup' className='btn-group'>
<button type='button' className='btn icon icon-gear settings' ref='settingsButton'>Settings</button>
<button type='button' className='btn icon icon-trashcan uninstall-button' ref='uninstallButton'>Uninstall</button>
<button type='button' className='btn icon icon-playback-pause enablement' ref='enablementButton'>
<span className='disable-text'>Disable</span>
</button>
<button type='button' className='btn status-indicator' tabIndex='-1' ref='statusIndicator' />
</div>
</div>
</div>
</div>
</div>
)
}
locateCompatiblePackageVersion (callback) {
this.packageManager.loadCompatiblePackageVersion(this.pack.name, (err, pack) => {
if (err != null) {
console.error(err)
}
const packageVersion = pack.version
// A compatible version exist, we activate the install button and
// set this.installablePack so that the install action installs the
// compatible version of the package.
if (packageVersion) {
this.refs.versionValue.textContent = packageVersion
if (packageVersion !== this.pack.version) {
this.refs.versionValue.classList.add('text-warning')
this.refs.packageMessage.classList.add('text-warning')
this.refs.packageMessage.textContent = `Version ${packageVersion} is not the latest version available for this package, but it's the latest that is compatible with your version of Atom.`
}
this.installablePack = pack
this.hasCompatibleVersion = true
} else {
this.hasCompatibleVersion = false
this.refs.versionValue.classList.add('text-error')
this.refs.packageMessage.classList.add('text-error')
this.refs.packageMessage.insertAdjacentText(
'beforeend',
`There's no version of this package that is compatible with your Atom version. The version must satisfy ${this.pack.engines.atom}.`
)
console.error(`No available version compatible with the installed Atom version: ${atom.getVersion()}`)
}
callback()
})
}
handleButtonEvents (options) {
if (options && options.onSettingsView) {
this.refs.settingsButton.style.display = 'none'
} else {
const clickHandler = (event) => {
event.stopPropagation()
this.settingsView.showPanel(this.pack.name, {back: options ? options.back : null, pack: this.pack})
}
this.element.addEventListener('click', clickHandler)
this.disposables.add(new Disposable(() => { this.element.removeEventListener('click', clickHandler) }))
this.refs.settingsButton.addEventListener('click', clickHandler)
this.disposables.add(new Disposable(() => { this.refs.settingsButton.removeEventListener('click', clickHandler) }))
}
const installButtonClickHandler = (event) => {
event.stopPropagation()
this.install()
}
this.refs.installButton.addEventListener('click', installButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.installButton.removeEventListener('click', installButtonClickHandler) }))
const uninstallButtonClickHandler = (event) => {
event.stopPropagation()
this.uninstall()
}
this.refs.uninstallButton.addEventListener('click', uninstallButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.uninstallButton.removeEventListener('click', uninstallButtonClickHandler) }))
const updateButtonClickHandler = (event) => {
event.stopPropagation()
this.update().then(() => {
let oldVersion = ''
let newVersion = ''
if (this.pack.apmInstallSource && this.pack.apmInstallSource.type === 'git') {
oldVersion = this.pack.apmInstallSource.sha.substr(0, 8)
newVersion = `${this.pack.latestSha.substr(0, 8)}`
} else if (this.pack.version && this.pack.latestVersion) {
oldVersion = this.pack.version
newVersion = this.pack.latestVersion
}
let detail = ''
if (oldVersion && newVersion) {
detail = `${oldVersion} -> ${newVersion}`
}
const notification = atom.notifications.addSuccess(`Restart Atom to complete the update of \`${this.pack.name}\`.`, {
dismissable: true,
buttons: [{
text: 'Restart now',
onDidClick () { return atom.restartApplication() }
},
{
text: 'I\'ll do it later',
onDidClick () { notification.dismiss() }
}],
detail
})
})
}
this.refs.updateButton.addEventListener('click', updateButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.updateButton.removeEventListener('click', updateButtonClickHandler) }))
const packageNameClickHandler = (event) => {
event.stopPropagation()
shell.openExternal(`https://web.pulsar-edit.dev/packages/${this.pack.name}`)
}
this.refs.packageName.addEventListener('click', packageNameClickHandler)
this.disposables.add(new Disposable(() => { this.refs.packageName.removeEventListener('click', packageNameClickHandler) }))
const packageAuthorClickHandler = (event) => {
event.stopPropagation()
shell.openExternal(`https://pulsar-edit.dev/users/${ownerFromRepository(this.pack.repository)}`)
}
this.refs.loginLink.addEventListener('click', packageAuthorClickHandler)
this.disposables.add(new Disposable(() => { this.refs.loginLink.removeEventListener('click', packageAuthorClickHandler) }))
this.refs.avatarLink.addEventListener('click', packageAuthorClickHandler)
this.disposables.add(new Disposable(() => { this.refs.avatarLink.removeEventListener('click', packageAuthorClickHandler) }))
const enablementButtonClickHandler = (event) => {
event.stopPropagation()
event.preventDefault()
if (this.isDisabled()) {
atom.packages.enablePackage(this.pack.name)
} else {
atom.packages.disablePackage(this.pack.name)
}
}
this.refs.enablementButton.addEventListener('click', enablementButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.enablementButton.removeEventListener('click', enablementButtonClickHandler) }))
const packageMessageClickHandler = (event) => {
const target = event.target.closest('a')
if (target) {
event.stopPropagation()
event.preventDefault()
if (target.href && target.href.startsWith('atom:')) {
atom.workspace.open(target.href)
}
}
}
this.refs.packageMessage.addEventListener('click', packageMessageClickHandler)
this.disposables.add(new Disposable(() => { this.refs.packageMessage.removeEventListener('click', packageMessageClickHandler) }))
}
destroy () {
this.disposables.dispose()
return etch.destroy(this)
}
loadCachedMetadata () {
this.client.avatar(ownerFromRepository(this.pack.repository), (err, avatarPath) => {
if (!err && avatarPath) {
this.refs.avatar.src = `file://${avatarPath}`
}
})
this.client.package(this.pack.name, (err, data) => {
// We don't need to actually handle the error here, we can just skip
// showing the download count if there's a problem.
if (!err) {
if (data == null) {
data = {}
}
if (this.pack.apmInstallSource && this.pack.apmInstallSource.type === 'git') {
this.refs.downloadIcon.classList.remove('icon-cloud-download')
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() : ''
}
}
})
}
updateInterfaceState () {
this.refs.versionValue.textContent = (this.installablePack ? this.installablePack.version : null) || this.pack.version
if (this.pack.apmInstallSource && this.pack.apmInstallSource.type === 'git') {
this.refs.downloadCount.textContent = this.pack.apmInstallSource.sha.substr(0, 8)
}
this.updateSettingsState()
this.updateInstalledState()
this.updateDisabledState()
}
updateSettingsState () {
if (this.hasSettings() && !this.onSettingsView) {
this.refs.settingsButton.style.display = ''
} else {
this.refs.settingsButton.style.display = 'none'
}
}
// Section: disabled state updates
updateDisabledState () {
if (this.isDisabled()) {
this.displayDisabledState()
} else if (this.element.classList.contains('disabled')) {
this.displayEnabledState()
}
}
displayEnabledState () {
this.element.classList.remove('disabled')
if (this.type === 'theme') {
this.refs.enablementButton.style.display = 'none'
}
this.refs.enablementButton.querySelector('.disable-text').textContent = 'Disable'
this.refs.enablementButton.classList.add('icon-playback-pause')
this.refs.enablementButton.classList.remove('icon-playback-play')
this.refs.statusIndicator.classList.remove('is-disabled')
}
displayDisabledState () {
this.element.classList.add('disabled')
this.refs.enablementButton.querySelector('.disable-text').textContent = 'Enable'
this.refs.enablementButton.classList.add('icon-playback-play')
this.refs.enablementButton.classList.remove('icon-playback-pause')
this.refs.statusIndicator.classList.add('is-disabled')
this.refs.enablementButton.disabled = false
}
// Section: installed state updates
updateInstalledState () {
if (this.isInstalled()) {
this.displayInstalledState()
} else {
this.displayNotInstalledState()
}
}
displayInstalledState () {
if (this.newVersion || this.newSha) {
this.refs.updateButtonGroup.style.display = ''
if (this.newVersion) {
this.refs.updateButton.textContent = `Update to ${this.newVersion}`
} else if (this.newSha) {
this.refs.updateButton.textContent = `Update to ${this.newSha.substr(0, 8)}`
}
} else {
this.refs.updateButtonGroup.style.display = 'none'
}
this.refs.installButtonGroup.style.display = 'none'
this.refs.packageActionButtonGroup.style.display = ''
this.refs.uninstallButton.style.display = ''
}
displayNotInstalledState () {
this.refs.uninstallButton.style.display = 'none'
const atomVersion = this.packageManager.normalizeVersion(atom.getVersion())
if (!this.packageManager.satisfiesVersion(atomVersion, this.pack)) {
this.hasCompatibleVersion = false
this.setNotInstalledStateButtons()
this.locateCompatiblePackageVersion(() => { this.setNotInstalledStateButtons() })
} else {
this.setNotInstalledStateButtons()
}
}
setNotInstalledStateButtons () {
if (!this.hasCompatibleVersion) {
this.refs.installButtonGroup.style.display = 'none'
this.refs.updateButtonGroup.style.display = 'none'
} else if (this.newVersion || this.newSha) {
this.refs.updateButtonGroup.style.display = ''
this.refs.installButtonGroup.style.display = 'none'
} else {
this.refs.updateButtonGroup.style.display = 'none'
this.refs.installButtonGroup.style.display = ''
}
this.refs.packageActionButtonGroup.style.display = 'none'
}
displayStats (options) {
if (options && options.stats && options.stats.downloads) {
this.refs.packageDownloads.style.display = ''
} else {
this.refs.packageDownloads.style.display = 'none'
}
if (options && options.stats && options.stats.stars) {
this.refs.packageStars.style.display = ''
} else {
this.refs.packageStars.style.display = 'none'
}
}
displayGitPackageInstallInformation () {
this.refs.metaUserContainer.remove()
this.refs.statsContainer.remove()
const {gitUrlInfo} = this.pack
if (gitUrlInfo.default === 'shortcut') {
this.refs.packageDescription.textContent = gitUrlInfo.https()
} else {
this.refs.packageDescription.textContent = gitUrlInfo.toString()
}
this.refs.installButton.classList.remove('icon-cloud-download')
this.refs.installButton.classList.add('icon-git-commit')
this.refs.updateButton.classList.remove('icon-cloud-download')
this.refs.updateButton.classList.add('icon-git-commit')
}
displayAvailableUpdate (newVersion) {
this.newVersion = newVersion
this.updateInterfaceState()
}
handlePackageEvents () {
this.disposables.add(atom.packages.onDidDeactivatePackage((pack) => {
if (pack.name === this.pack.name) {
this.updateDisabledState()
}
}))
this.disposables.add(atom.packages.onDidActivatePackage((pack) => {
if (pack.name === this.pack.name) {
this.updateDisabledState()
}
}))
this.disposables.add(atom.config.onDidChange('core.disabledPackages', () => {
this.updateDisabledState()
}))
this.subscribeToPackageEvent('package-installing theme-installing', () => {
this.updateInterfaceState()
this.refs.installButton.disabled = true
this.refs.installButton.classList.add('is-installing')
})
this.subscribeToPackageEvent('package-updating theme-updating', () => {
this.updateInterfaceState()
this.refs.updateButton.disabled = true
this.refs.updateButton.classList.add('is-installing')
})
this.subscribeToPackageEvent('package-uninstalling theme-uninstalling', () => {
this.updateInterfaceState()
this.refs.enablementButton.disabled = true
this.refs.uninstallButton.disabled = true
this.refs.uninstallButton.classList.add('is-uninstalling')
})
this.subscribeToPackageEvent('package-installed package-install-failed theme-installed theme-install-failed', () => {
const loadedPack = atom.packages.getLoadedPackage(this.pack.name)
const version = loadedPack && loadedPack.metadata ? loadedPack.metadata.version : null
if (version) {
this.pack.version = version
}
this.refs.installButton.disabled = false
this.refs.installButton.classList.remove('is-installing')
this.updateInterfaceState()
})
this.subscribeToPackageEvent('package-updated theme-updated', () => {
const loadedPack = atom.packages.getLoadedPackage(this.pack.name)
const metadata = loadedPack ? loadedPack.metadata : null
if (metadata && metadata.version) {
this.pack.version = metadata.version
}
if (metadata && metadata.apmInstallSource) {
this.pack.apmInstallSource = metadata.apmInstallSource
}
this.newVersion = null
this.newSha = null
this.refs.updateButton.disabled = false
this.refs.updateButton.classList.remove('is-installing')
this.updateInterfaceState()
})
this.subscribeToPackageEvent('package-update-failed theme-update-failed', () => {
this.refs.updateButton.disabled = false
this.refs.updateButton.classList.remove('is-installing')
this.updateInterfaceState()
})
this.subscribeToPackageEvent('package-uninstalled package-uninstall-failed theme-uninstalled theme-uninstall-failed', () => {
this.newVersion = null
this.newSha = null
this.refs.enablementButton.disabled = false
this.refs.uninstallButton.disabled = false
this.refs.uninstallButton.classList.remove('is-uninstalling')
this.updateInterfaceState()
})
}
isInstalled () {
return this.packageManager.isPackageInstalled(this.pack.name)
}
isDisabled () {
return atom.packages.isPackageDisabled(this.pack.name)
}
hasSettings () {
return this.packageManager.packageHasSettings(this.pack.name)
}
subscribeToPackageEvent (event, callback) {
this.disposables.add(this.packageManager.on(event, ({pack, error}) => {
if (pack.pack != null) {
pack = pack.pack
}
const packageName = pack.name
if (packageName === this.pack.name) {
callback(pack, error)
}
}))
}
/*
Section: Methods that should be on a Package model
*/
install () {
this.packageManager.install(this.installablePack != null ? this.installablePack : this.pack, (error) => {
if (error != null) {
console.error(`Installing ${this.type} ${this.pack.name} failed`, error.stack != null ? error.stack : error, error.stderr)
} else {
// if a package was disabled before installing it, re-enable it
if (this.isDisabled()) {
atom.packages.enablePackage(this.pack.name)
}
}
})
}
update () {
if (!this.newVersion && !this.newSha) {
return Promise.resolve()
}
const pack = this.installablePack != null ? this.installablePack : this.pack
const version = this.newVersion ? `v${this.newVersion}` : `#${this.newSha.substr(0, 8)}`
return new Promise((resolve, reject) => {
this.packageManager.update(pack, this.newVersion, error => {
if (error != null) {
atom.assert(false, 'Package update failed', assertionError => {
assertionError.metadata = {
type: this.type,
name: pack.name,
version,
errorMessage: error.message,
errorStack: error.stack,
errorStderr: error.stderr
}
})
console.error(`Updating ${this.type} ${pack.name} to ${version} failed:\n`, error, error.stderr != null ? error.stderr : '')
reject(error)
} else {
resolve()
}
})
})
}
uninstall () {
this.packageManager.uninstall(this.pack, (error) => {
if (error != null) {
console.error(`Uninstalling ${this.type} ${this.pack.name} failed`, error.stack != null ? error.stack : error, error.stderr)
}
})
}
}

View File

@ -0,0 +1,517 @@
/** @babel */
/** @jsx etch.dom */
import path from 'path'
import url from 'url'
import _ from 'underscore-plus'
import fs from 'fs-plus'
import {shell} from 'electron'
import {CompositeDisposable, Disposable} from 'atom'
import etch from 'etch'
import PackageCard from './package-card'
import PackageGrammarsView from './package-grammars-view'
import PackageKeymapView from './package-keymap-view'
import PackageReadmeView from './package-readme-view'
import PackageSnippetsView from './package-snippets-view'
import SettingsPanel from './settings-panel'
const NORMALIZE_PACKAGE_DATA_README_ERROR = 'ERROR: No README data found!'
export default class PackageDetailView {
constructor (pack, settingsView, packageManager, snippetsProvider) {
this.pack = pack
this.settingsView = settingsView
this.packageManager = packageManager
this.snippetsProvider = snippetsProvider
this.disposables = new CompositeDisposable()
etch.initialize(this)
this.loadPackage()
this.disposables.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
const packageRepoClickHandler = (event) => {
event.preventDefault()
const repoUrl = this.packageManager.getRepositoryUrl(this.pack)
if (typeof repoUrl === 'string') {
if (url.parse(repoUrl).pathname === '/atom/atom') {
shell.openExternal(`${repoUrl}/tree/master/packages/${this.pack.name}`)
} else {
shell.openExternal(repoUrl)
}
}
}
this.refs.packageRepo.addEventListener('click', packageRepoClickHandler)
this.disposables.add(new Disposable(() => { this.refs.packageRepo.removeEventListener('click', packageRepoClickHandler) }))
const issueButtonClickHandler = (event) => {
event.preventDefault()
let bugUri = this.packageManager.getRepositoryBugUri(this.pack)
if (bugUri) {
shell.openExternal(bugUri)
}
}
this.refs.issueButton.addEventListener('click', issueButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.issueButton.removeEventListener('click', issueButtonClickHandler) }))
const changelogButtonClickHandler = (event) => {
event.preventDefault()
if (this.changelogPath) {
this.openMarkdownFile(this.changelogPath)
}
}
this.refs.changelogButton.addEventListener('click', changelogButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.changelogButton.removeEventListener('click', changelogButtonClickHandler) }))
const licenseButtonClickHandler = (event) => {
event.preventDefault()
if (this.licensePath) {
this.openMarkdownFile(this.licensePath)
}
}
this.refs.licenseButton.addEventListener('click', licenseButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.licenseButton.removeEventListener('click', licenseButtonClickHandler) }))
const openButtonClickHandler = (event) => {
event.preventDefault()
if (fs.existsSync(this.pack.path)) {
atom.open({pathsToOpen: [this.pack.path]})
}
}
this.refs.openButton.addEventListener('click', openButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.openButton.removeEventListener('click', openButtonClickHandler) }))
const learnMoreButtonClickHandler = (event) => {
event.preventDefault()
shell.openExternal(`https://web.pulsar-edit.dev/packages/${this.pack.name}`)
}
this.refs.learnMoreButton.addEventListener('click', learnMoreButtonClickHandler)
this.disposables.add(new Disposable(() => { this.refs.learnMoreButton.removeEventListener('click', learnMoreButtonClickHandler) }))
const breadcrumbClickHandler = (event) => {
event.preventDefault()
this.settingsView.showPanel(this.breadcrumbBackPanel)
}
this.refs.breadcrumb.addEventListener('click', breadcrumbClickHandler)
this.disposables.add(new Disposable(() => { this.refs.breadcrumb.removeEventListener('click', breadcrumbClickHandler) }))
}
completeInitialization () {
if (this.refs.packageCard) {
this.packageCard = this.refs.packageCard.packageCard
} else if (!this.packageCard) { // Had to load this from the network
this.packageCard = new PackageCard(this.pack.metadata, this.settingsView, this.packageManager, {onSettingsView: true})
this.refs.packageCardParent.replaceChild(this.packageCard.element, this.refs.loadingMessage)
}
this.refs.packageRepo.classList.remove('hidden')
this.refs.startupTime.classList.remove('hidden')
this.refs.buttons.classList.remove('hidden')
this.activateConfig()
this.populate()
this.updateFileButtons()
this.subscribeToPackageManager()
this.renderReadme()
}
loadPackage () {
const loadedPackage = atom.packages.getLoadedPackage(this.pack.name)
if (loadedPackage) {
this.pack = loadedPackage
this.completeInitialization()
} else {
// If the package metadata in `@pack` isn't complete, hit the network.
if (!this.pack.metadata || !this.pack.metadata.owner) {
this.fetchPackage()
} else {
this.completeInitialization()
}
}
}
fetchPackage () {
this.showLoadingMessage()
this.packageManager.getClient().package(this.pack.name, (err, packageData) => {
if (err || !packageData || !packageData.name) {
this.hideLoadingMessage()
this.showErrorMessage()
} else {
this.pack = packageData
// TODO: this should match Package.loadMetadata from core, but this is
// an acceptable hacky workaround
this.pack.metadata = _.extend(this.pack.metadata != null ? this.pack.metadata : {}, this.pack)
this.completeInitialization()
}
})
}
showLoadingMessage () {
this.refs.loadingMessage.classList.remove('hidden')
}
hideLoadingMessage () {
this.refs.loadingMessage.classList.add('hidden')
}
showErrorMessage () {
this.refs.errorMessage.classList.remove('hidden')
}
hideErrorMessage () {
this.refs.errorMessage.classList.add('hidden')
}
activateConfig () {
// Package.activateConfig() is part of the Private package API and should not be used outside of core.
if (atom.packages.isPackageLoaded(this.pack.name) && !atom.packages.isPackageActive(this.pack.name)) {
this.pack.activateConfig()
}
}
destroy () {
if (this.settingsPanel) {
this.settingsPanel.destroy()
this.settingsPanel = null
}
if (this.keymapView) {
this.keymapView.destroy()
this.keymapView = null
}
if (this.grammarsView) {
this.grammarsView.destroy()
this.grammarsView = null
}
if (this.snippetsView) {
this.snippetsView.destroy()
this.snippetsView = null
}
if (this.readmeView) {
this.readmeView.destroy()
this.readmeView = null
}
if (this.packageCard) {
this.packageCard.destroy()
this.packageCard = null
}
this.disposables.dispose()
return etch.destroy(this)
}
update () {}
beforeShow (opts) {
if (opts.back == null) {
opts.back = 'Install'
}
this.breadcrumbBackPanel = opts.back
this.refs.breadcrumb.textContent = this.breadcrumbBackPanel
}
show () {
this.element.style.display = ''
}
focus () {
this.element.focus()
}
render () {
let packageCardView
if (this.pack && this.pack.metadata && this.pack.metadata.owner) {
packageCardView = (
<div ref='packageCardParent' className='row'>
<PackageCardComponent
ref='packageCard'
settingsView={this.settingsView}
packageManager={this.packageManager}
metadata={this.pack.metadata}
options={{onSettingsView: true}} />
</div>
)
} else {
packageCardView = (
<div ref='packageCardParent' className='row'>
<div ref='loadingMessage' className='alert alert-info icon icon-hourglass'>{`Loading ${this.pack.name}\u2026`}</div>
<div ref='errorMessage' className='alert alert-danger icon icon-hourglass hidden'>Failed to load {this.pack.name} - try again later.</div>
</div>
)
}
return (
<div tabIndex='0' className='package-detail'>
<ol ref='breadcrumbContainer' className='native-key-bindings breadcrumb' tabIndex='-1'>
<li>
<a ref='breadcrumb' />
</li>
<li className='active'>
<a ref='title' />
</li>
</ol>
<div className='panels-item'>
<section className='section'>
<form className='section-container package-detail-view'>
<div className='container package-container'>
{packageCardView}
</div>
<p ref='packageRepo' className='link icon icon-repo repo-link hidden' />
<p ref='startupTime' className='text icon icon-dashboard hidden' tabIndex='-1' />
<div ref='buttons' className='btn-wrap-group hidden'>
<button ref='learnMoreButton' className='btn btn-default icon icon-link'>View on pulsar-edit.dev</button>
<button ref='issueButton' className='btn btn-default icon icon-bug'>Report Issue</button>
<button ref='changelogButton' className='btn btn-default icon icon-squirrel'>CHANGELOG</button>
<button ref='licenseButton' className='btn btn-default icon icon-law'>LICENSE</button>
<button ref='openButton' className='btn btn-default icon icon-link-external'>View Code</button>
</div>
<div ref='errors' />
</form>
</section>
<div ref='sections' />
</div>
</div>
)
}
populate () {
this.refs.title.textContent = `${_.undasherize(_.uncamelcase(this.pack.name))}`
this.type = this.pack.metadata.theme ? 'theme' : 'package'
const repoUrl = this.packageManager.getRepositoryUrl(this.pack)
if (repoUrl) {
const repoName = url.parse(repoUrl).pathname
this.refs.packageRepo.textContent = repoName.substring(1)
this.refs.packageRepo.style.display = ''
} else {
this.refs.packageRepo.style.display = 'none'
}
this.updateInstalledState()
}
updateInstalledState () {
if (this.settingsPanel) {
this.settingsPanel.destroy()
this.settingsPanel = null
}
if (this.keymapView) {
this.keymapView.destroy()
this.keymapView = null
}
if (this.grammarsView) {
this.grammarsView.destroy()
this.grammarsView = null
}
if (this.snippetsView) {
this.snippetsView.destroy()
this.snippetsView = null
}
if (this.readmeView) {
this.readmeView.destroy()
this.readmeView = null
}
this.updateFileButtons()
this.activateConfig()
this.refs.startupTime.style.display = 'none'
if (atom.packages.isPackageLoaded(this.pack.name)) {
if (!atom.packages.isPackageDisabled(this.pack.name)) {
this.settingsPanel = new SettingsPanel({namespace: this.pack.name, includeTitle: false})
this.keymapView = new PackageKeymapView(this.pack)
this.refs.sections.appendChild(this.settingsPanel.element)
this.refs.sections.appendChild(this.keymapView.element)
if (this.pack.path) {
this.grammarsView = new PackageGrammarsView(this.pack.path)
this.snippetsView = new PackageSnippetsView(this.pack, this.snippetsProvider)
this.refs.sections.appendChild(this.grammarsView.element)
this.refs.sections.appendChild(this.snippetsView.element)
}
this.refs.startupTime.innerHTML =
`This ${this.type} added <span class='highlight'>${this.getStartupTime()}ms</span> to startup time.`
this.refs.startupTime.style.display = ''
}
}
const sourceIsAvailable = this.packageManager.isPackageInstalled(this.pack.name) && !atom.packages.isBundledPackage(this.pack.name)
if (sourceIsAvailable) {
this.refs.openButton.style.display = ''
} else {
this.refs.openButton.style.display = 'none'
}
this.renderReadme()
}
renderReadme () {
let readme
if (this.pack.metadata.readme && this.pack.metadata.readme.trim() !== NORMALIZE_PACKAGE_DATA_README_ERROR) {
readme = this.pack.metadata.readme
} else {
readme = null
}
if (this.readmePath && fs.existsSync(this.readmePath) && fs.statSync(this.readmePath).isFile() && !readme) {
readme = fs.readFileSync(this.readmePath, {encoding: 'utf8'})
}
let readmeSrc
if (this.pack.path) {
// If package is installed, use installed path
readmeSrc = this.pack.path
} else {
// If package isn't installed, use url path
let repoUrl = this.packageManager.getRepositoryUrl(this.pack)
// Check if URL is undefined (i.e. package is unpublished)
if (repoUrl) {
readmeSrc = repoUrl + `/blob/master/`
}
}
const readmeView = new PackageReadmeView(readme, readmeSrc)
if (this.readmeView) {
this.readmeView.element.parentElement.replaceChild(readmeView.element, this.readmeView.element)
this.readmeView.destroy()
} else {
this.refs.sections.appendChild(readmeView.element)
}
this.readmeView = readmeView
}
subscribeToPackageManager () {
this.disposables.add(this.packageManager.on('theme-installed package-installed', ({pack}) => {
if (this.pack.name === pack.name) {
this.loadPackage()
this.updateInstalledState()
}
}))
this.disposables.add(this.packageManager.on('theme-uninstalled package-uninstalled', ({pack}) => {
if (this.pack.name === pack.name) {
return this.updateInstalledState()
}
}))
this.disposables.add(this.packageManager.on('theme-updated package-updated', ({pack}) => {
if (this.pack.name === pack.name) {
this.loadPackage()
this.updateFileButtons()
this.populate()
}
}))
}
openMarkdownFile (path) {
if (atom.packages.isPackageActive('markdown-preview')) {
atom.workspace.open(encodeURI(`markdown-preview://${path}`))
} else {
atom.workspace.open(path)
}
}
updateFileButtons () {
this.changelogPath = null
this.licensePath = null
this.readmePath = null
const packagePath = this.pack.path != null ? this.pack.path : atom.packages.resolvePackagePath(this.pack.name)
for (const child of fs.listSync(packagePath)) {
switch (path.basename(child, path.extname(child)).toLowerCase()) {
case 'changelog':
case 'history':
this.changelogPath = child
break
case 'license':
case 'licence':
this.licensePath = child
break
case 'readme':
this.readmePath = child
break
}
if (this.readmePath && this.changelogPath && this.licensePath) {
break
}
}
if (this.changelogPath) {
this.refs.changelogButton.style.display = ''
} else {
this.refs.changelogButton.style.display = 'none'
}
if (this.licensePath) {
this.refs.licenseButton.style.display = ''
} else {
this.refs.licenseButton.style.display = 'none'
}
}
getStartupTime () {
const loadTime = this.pack.loadTime != null ? this.pack.loadTime : 0
const activateTime = this.pack.activateTime != null ? this.pack.activateTime : 0
return loadTime + activateTime
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}
class PackageCardComponent {
constructor (props) {
this.packageCard = new PackageCard(props.metadata, props.settingsView, props.packageManager, props.options)
this.element = this.packageCard.element
}
update () {}
destroy () {}
}

View File

@ -0,0 +1,97 @@
/** @babel */
import path from 'path'
import {CompositeDisposable} from 'atom'
import SettingsPanel from './settings-panel'
// View to display the grammars that a package has registered.
export default class PackageGrammarsView {
constructor (packagePath) {
this.element = document.createElement('section')
this.element.classList.add('package-grammars')
this.grammarSettings = document.createElement('div')
this.element.appendChild(this.grammarSettings)
this.disposables = new CompositeDisposable()
this.packagePath = path.join(packagePath, path.sep)
this.addGrammars()
this.disposables.add(atom.grammars.onDidAddGrammar(() => this.addGrammars()))
this.disposables.add(atom.grammars.onDidUpdateGrammar(() => this.addGrammars()))
}
destroy () {
this.disposables.dispose()
this.element.remove()
}
getPackageGrammars () {
const packageGrammars = []
const grammars = atom.grammars.grammars != null ? atom.grammars.grammars : []
for (let grammar of grammars) {
if (grammar.path) {
if (grammar.path.indexOf(this.packagePath) === 0) {
packageGrammars.push(grammar)
}
}
}
return packageGrammars.sort(function (grammar1, grammar2) {
const name1 = grammar1.name || grammar1.scopeName || ''
const name2 = grammar2.name || grammar2.scopeName || ''
return name1.localeCompare(name2)
})
}
addGrammarHeading (grammar, panel) {
const container = document.createElement('div')
container.classList.add('native-key-bindings', 'text')
container.tabIndex = -1
const grammarScope = document.createElement('div')
grammarScope.classList.add('grammar-scope')
const scopeStrong = document.createElement('strong')
scopeStrong.textContent = 'Scope: '
grammarScope.appendChild(scopeStrong)
const scopeSpan = document.createElement('span')
scopeSpan.textContent = grammar.scopeName != null ? grammar.scopeName : ''
grammarScope.appendChild(scopeSpan)
container.appendChild(grammarScope)
const grammarFileTypes = document.createElement('div')
grammarFileTypes.classList.add('grammar-filetypes')
const fileTypesStrong = document.createElement('strong')
fileTypesStrong.textContent = 'File Types: '
grammarFileTypes.appendChild(fileTypesStrong)
const fileTypes = grammar.fileTypes || []
const fileTypesSpan = document.createElement('span')
fileTypesSpan.textContent = fileTypes.join(', ')
grammarFileTypes.appendChild(fileTypesSpan)
container.appendChild(grammarFileTypes)
const sectionBody = panel.element.querySelector('.section-body')
sectionBody.parentElement.insertBefore(container, sectionBody)
}
addGrammars () {
this.grammarSettings.innerHTML = ''
for (let grammar of this.getPackageGrammars()) {
let {scopeName, name} = grammar
if (!scopeName || !name) {
continue
}
if (!scopeName.startsWith('.')) {
scopeName = `.${scopeName}`
}
const title = `${name} Grammar`
const panel = new SettingsPanel({title, scopeName, icon: 'puzzle'})
this.addGrammarHeading(grammar, panel)
this.grammarSettings.appendChild(panel.element)
}
}
}

View File

@ -0,0 +1,186 @@
/** @babel */
/** @jsx etch.dom */
import path from 'path'
import _ from 'underscore-plus'
import {Disposable, CompositeDisposable} from 'atom'
import etch from 'etch'
import KeybindingsPanel from './keybindings-panel'
// Displays the keybindings for a package namespace
export default class PackageKeymapView {
constructor (pack) {
this.pack = pack
this.otherPlatformPattern = new RegExp(`\\.platform-(?!${_.escapeRegExp(process.platform)}\\b)`)
this.namespace = this.pack.name
this.disposables = new CompositeDisposable()
etch.initialize(this)
const packagesWithKeymapsDisabled = atom.config.get('core.packagesWithKeymapsDisabled') || []
this.refs.keybindingToggle.checked = !packagesWithKeymapsDisabled.includes(this.namespace)
const changeHandler = (event) => {
event.stopPropagation()
const value = this.refs.keybindingToggle.checked
if (value) {
atom.config.removeAtKeyPath('core.packagesWithKeymapsDisabled', this.namespace)
} else {
atom.config.pushAtKeyPath('core.packagesWithKeymapsDisabled', this.namespace)
}
this.updateKeyBindingView()
}
this.refs.keybindingToggle.addEventListener('change', changeHandler)
this.disposables.add(new Disposable(() => { this.refs.keybindingToggle.removeEventListener('change', changeHandler) }))
const copyIconClickHandler = (event) => {
const target = event.target.closest('.copy-icon')
if (target) {
event.preventDefault()
event.stopPropagation()
this.writeKeyBindingToClipboard(target.closest('tr').dataset)
}
}
this.element.addEventListener('click', copyIconClickHandler)
this.disposables.add(new Disposable(() => { this.element.removeEventListener('click', copyIconClickHandler) }))
this.updateKeyBindingView()
let hasKeymaps = false
// eslint-disable-next-line no-unused-vars
for (let [packageKeymapsPath, keymap] of atom.packages.getLoadedPackage(this.namespace).keymaps) {
if (keymap.length > 0) {
hasKeymaps = true
break
}
}
if (this.refs.keybindingItems.children.length === 0 && !hasKeymaps) {
this.element.style.display = 'none'
}
}
update () {}
destroy () {
this.disposables.dispose()
return etch.destroy(this)
}
render () {
return (
<section className='section'>
<div className='section-heading icon icon-keyboard'>Keybindings</div>
<div className='checkbox'>
<label for='toggleKeybindings'>
<input id='toggleKeybindings' className='input-checkbox' type='checkbox' ref='keybindingToggle' />
<div className='setting-title'>Enable</div>
</label>
<div className='setting-description'>
{"Disable this if you want to bind your own keystrokes for this package's commands in your keymap."}
</div>
</div>
<table className='package-keymap-table table native-key-bindings text' tabIndex='-1'>
<thead>
<tr>
<th>Keystroke</th>
<th>Command</th>
<th>Selector</th>
<th>Source</th>
</tr>
</thead>
<tbody ref='keybindingItems' />
</table>
</section>
)
}
updateKeyBindingView () {
this.refs.keybindingItems.innerHTML = ''
const packagesWithKeymapsDisabled = atom.config.get('core.packagesWithKeymapsDisabled') || []
const keybindingsDisabled = packagesWithKeymapsDisabled.includes(this.namespace)
if (keybindingsDisabled) {
this.refs.keybindingItems.classList.add('text-subtle')
} else {
this.refs.keybindingItems.classList.remove('text-subtle')
}
const keyBindings = []
if (atom.keymaps.build) {
// eslint-disable-next-line no-unused-vars
for (const [keymapPath, keymap] of atom.packages.getLoadedPackage(this.namespace).keymaps) {
keyBindings.push(...atom.keymaps.build(this.namespace, keymap, 0, false))
}
} else {
// Backwards compatibility for Atom <= 1.19
for (const keyBinding of atom.keymaps.getKeyBindings()) {
const {command} = keyBinding
if (command && command.indexOf && command.indexOf(`${this.namespace}:`) === 0) {
keyBindings.push(keyBinding)
}
}
}
for (const keyBinding of keyBindings) {
const {command, keystrokes, selector, source} = keyBinding
if (!command) {
continue
}
if (this.otherPlatformPattern.test(selector)) {
continue
}
const keyBindingRow = document.createElement('tr')
keyBindingRow.dataset.selector = selector
keyBindingRow.dataset.keystrokes = keystrokes
keyBindingRow.dataset.command = command
const keystrokesTd = document.createElement('td')
const copyIconSpan = document.createElement('span')
copyIconSpan.classList.add('icon', 'icon-clippy', 'copy-icon')
keystrokesTd.appendChild(copyIconSpan)
const keystrokesSpan = document.createElement('span')
keystrokesSpan.textContent = keystrokes
keystrokesTd.appendChild(keystrokesSpan)
keyBindingRow.appendChild(keystrokesTd)
const commandTd = document.createElement('td')
commandTd.textContent = command
keyBindingRow.appendChild(commandTd)
const selectorTd = document.createElement('td')
selectorTd.textContent = selector
keyBindingRow.appendChild(selectorTd)
const sourceTd = document.createElement('td')
sourceTd.textContent = KeybindingsPanel.determineSource(source)
keyBindingRow.appendChild(sourceTd)
this.refs.keybindingItems.appendChild(keyBindingRow)
}
}
writeKeyBindingToClipboard ({selector, keystrokes, command}) {
let content
const keymapExtension = path.extname(atom.keymaps.getUserKeymapPath())
if (keymapExtension === '.cson') {
content = `\
'${selector}':
'${keystrokes}': '${command}'\
`
} else {
content = `\
"${selector}": {
"${keystrokes}": "${command}"
}\
`
}
atom.clipboard.write(content)
}
}

View File

@ -0,0 +1,445 @@
_ = require 'underscore-plus'
{BufferedProcess, CompositeDisposable, Emitter} = require 'atom'
semver = require 'semver'
Client = require './atom-io-client'
module.exports =
class PackageManager
# Millisecond expiry for cached loadOutdated, etc. values
CACHE_EXPIRY: 1000*60*10
constructor: ->
@packagePromises = []
@apmCache =
loadOutdated:
value: null
expiry: 0
@emitter = new Emitter
getClient: ->
@client ?= new Client(this)
isPackageInstalled: (packageName) ->
if atom.packages.isPackageLoaded(packageName)
true
else
atom.packages.getAvailablePackageNames().indexOf(packageName) > -1
packageHasSettings: (packageName) ->
grammars = atom.grammars.getGrammars() ? []
for grammar in grammars when grammar.path
return true if grammar.packageName is packageName
pack = atom.packages.getLoadedPackage(packageName)
pack.activateConfig() if pack? and not atom.packages.isPackageActive(packageName)
schema = atom.config.getSchema(packageName)
schema? and (schema.type isnt 'any')
setProxyServers: (callback) =>
session = atom.getCurrentWindow().webContents.session
session.resolveProxy 'http://atom.io', (httpProxy) =>
@applyProxyToEnv('http_proxy', httpProxy)
session.resolveProxy 'https://pulsar-edit.dev', (httpsProxy) =>
@applyProxyToEnv('https_proxy', httpsProxy)
callback()
setProxyServersAsync: (callback) =>
httpProxyPromise = atom.resolveProxy('http://atom.io').then((proxy) => @applyProxyToEnv('http_proxy', proxy))
httpsProxyPromise = atom.resolveProxy('https://pulsar-edit.dev').then((proxy) => @applyProxyToEnv('https_proxy', proxy))
Promise.all([httpProxyPromise, httpsProxyPromise]).then(callback)
applyProxyToEnv: (envName, proxy) ->
if proxy?
proxy = proxy.split(' ')
switch proxy[0].trim().toUpperCase()
when 'DIRECT' then delete process.env[envName]
when 'PROXY' then process.env[envName] = 'http://' + proxy[1]
return
runCommand: (args, callback) ->
command = atom.packages.getApmPath()
outputLines = []
stdout = (lines) -> outputLines.push(lines)
errorLines = []
stderr = (lines) -> errorLines.push(lines)
exit = (code) ->
callback(code, outputLines.join('\n'), errorLines.join('\n'))
args.push('--no-color')
if atom.config.get('core.useProxySettingsWhenCallingApm')
bufferedProcess = new BufferedProcess({command, args, stdout, stderr, exit, autoStart: false})
if atom.resolveProxy?
@setProxyServersAsync -> bufferedProcess.start()
else
@setProxyServers -> bufferedProcess.start()
return bufferedProcess
else
return new BufferedProcess({command, args, stdout, stderr, exit})
loadInstalled: (callback) ->
args = ['ls', '--json']
errorMessage = 'Fetching local packages failed.'
apmProcess = @runCommand args, (code, stdout, stderr) ->
if code is 0
try
packages = JSON.parse(stdout) ? []
catch parseError
error = createJsonParseError(errorMessage, parseError, stdout)
return callback(error)
callback(null, packages)
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
callback(error)
handleProcessErrors(apmProcess, errorMessage, callback)
loadFeatured: (loadThemes, callback) ->
unless callback
callback = loadThemes
loadThemes = false
args = ['featured', '--json']
version = atom.getVersion()
args.push('--themes') if loadThemes
args.push('--compatible', version) if semver.valid(version)
errorMessage = 'Fetching featured packages failed.'
apmProcess = @runCommand args, (code, stdout, stderr) ->
if code is 0
try
packages = JSON.parse(stdout) ? []
catch parseError
error = createJsonParseError(errorMessage, parseError, stdout)
return callback(error)
callback(null, packages)
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
callback(error)
handleProcessErrors(apmProcess, errorMessage, callback)
loadOutdated: (clearCache, callback) ->
if clearCache
@clearOutdatedCache()
# Short circuit if we have cached data.
else if @apmCache.loadOutdated.value and @apmCache.loadOutdated.expiry > Date.now()
return callback(null, @apmCache.loadOutdated.value)
args = ['outdated', '--json']
version = atom.getVersion()
args.push('--compatible', version) if semver.valid(version)
errorMessage = 'Fetching outdated packages and themes failed.'
apmProcess = @runCommand args, (code, stdout, stderr) =>
if code is 0
try
packages = JSON.parse(stdout) ? []
catch parseError
error = createJsonParseError(errorMessage, parseError, stdout)
return callback(error)
updatablePackages = (pack for pack in packages when not @getVersionPinnedPackages().includes(pack?.name))
@apmCache.loadOutdated =
value: updatablePackages
expiry: Date.now() + @CACHE_EXPIRY
for pack in updatablePackages
@emitPackageEvent 'update-available', pack
callback(null, updatablePackages)
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
callback(error)
handleProcessErrors(apmProcess, errorMessage, callback)
getVersionPinnedPackages: ->
atom.config.get('core.versionPinnedPackages') ? []
clearOutdatedCache: ->
@apmCache.loadOutdated =
value: null
expiry: 0
loadPackage: (packageName, callback) ->
args = ['view', packageName, '--json']
errorMessage = "Fetching package '#{packageName}' failed."
apmProcess = @runCommand args, (code, stdout, stderr) ->
if code is 0
try
packages = JSON.parse(stdout) ? []
catch parseError
error = createJsonParseError(errorMessage, parseError, stdout)
return callback(error)
callback(null, packages)
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
callback(error)
handleProcessErrors(apmProcess, errorMessage, callback)
loadCompatiblePackageVersion: (packageName, callback) ->
args = ['view', packageName, '--json', '--compatible', @normalizeVersion(atom.getVersion())]
errorMessage = "Fetching package '#{packageName}' failed."
apmProcess = @runCommand args, (code, stdout, stderr) ->
if code is 0
try
packages = JSON.parse(stdout) ? []
catch parseError
error = createJsonParseError(errorMessage, parseError, stdout)
return callback(error)
callback(null, packages)
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
callback(error)
handleProcessErrors(apmProcess, errorMessage, callback)
getInstalled: ->
new Promise (resolve, reject) =>
@loadInstalled (error, result) ->
if error
reject(error)
else
resolve(result)
getFeatured: (loadThemes) ->
new Promise (resolve, reject) =>
@loadFeatured !!loadThemes, (error, result) ->
if error
reject(error)
else
resolve(result)
getOutdated: (clearCache = false) ->
new Promise (resolve, reject) =>
@loadOutdated clearCache, (error, result) ->
if error
reject(error)
else
resolve(result)
getPackage: (packageName) ->
@packagePromises[packageName] ?= new Promise (resolve, reject) =>
@loadPackage packageName, (error, result) ->
if error
reject(error)
else
resolve(result)
satisfiesVersion: (version, metadata) ->
engine = metadata.engines?.atom ? '*'
return false unless semver.validRange(engine)
return semver.satisfies(version, engine)
normalizeVersion: (version) ->
[version] = version.split('-') if typeof version is 'string'
version
update: (pack, newVersion, callback) ->
{name, theme, apmInstallSource} = pack
errorMessage = if newVersion
"Updating to \u201C#{name}@#{newVersion}\u201D failed."
else
"Updating to latest sha failed."
onError = (error) =>
error.packageInstallError = not theme
@emitPackageEvent 'update-failed', pack, error
callback?(error)
if apmInstallSource?.type is 'git'
args = ['install', apmInstallSource.source]
else
args = ['install', "#{name}@#{newVersion}"]
exit = (code, stdout, stderr) =>
if code is 0
@clearOutdatedCache()
callback?()
@emitPackageEvent 'updated', pack
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
onError(error)
@emitPackageEvent 'updating', pack
apmProcess = @runCommand(args, exit)
handleProcessErrors(apmProcess, errorMessage, onError)
unload: (name) ->
if atom.packages.isPackageLoaded(name)
atom.packages.deactivatePackage(name) if atom.packages.isPackageActive(name)
atom.packages.unloadPackage(name)
install: (pack, callback) ->
{name, version, theme} = pack
activateOnSuccess = not theme and not atom.packages.isPackageDisabled(name)
activateOnFailure = atom.packages.isPackageActive(name)
nameWithVersion = if version? then "#{name}@#{version}" else name
@unload(name)
args = ['install', nameWithVersion, '--json']
errorMessage = "Installing \u201C#{nameWithVersion}\u201D failed."
onError = (error) =>
error.packageInstallError = not theme
@emitPackageEvent 'install-failed', pack, error
callback?(error)
exit = (code, stdout, stderr) =>
if code is 0
# get real package name from package.json
try
packageInfo = JSON.parse(stdout)[0]
pack = _.extend({}, pack, packageInfo.metadata)
name = pack.name
catch err
# using old apm without --json support
@clearOutdatedCache()
if activateOnSuccess
atom.packages.activatePackage(name)
else
atom.packages.loadPackage(name)
callback?()
@emitPackageEvent 'installed', pack
else
atom.packages.activatePackage(name) if activateOnFailure
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
onError(error)
@emitPackageEvent('installing', pack)
apmProcess = @runCommand(args, exit)
handleProcessErrors(apmProcess, errorMessage, onError)
uninstall: (pack, callback) ->
{name} = pack
atom.packages.deactivatePackage(name) if atom.packages.isPackageActive(name)
errorMessage = "Uninstalling \u201C#{name}\u201D failed."
onError = (error) =>
@emitPackageEvent 'uninstall-failed', pack, error
callback?(error)
@emitPackageEvent('uninstalling', pack)
apmProcess = @runCommand ['uninstall', '--hard', name], (code, stdout, stderr) =>
if code is 0
@clearOutdatedCache()
@unload(name)
@removePackageNameFromDisabledPackages(name)
callback?()
@emitPackageEvent 'uninstalled', pack
else
error = new Error(errorMessage)
error.stdout = stdout
error.stderr = stderr
onError(error)
handleProcessErrors(apmProcess, errorMessage, onError)
canUpgrade: (installedPackage, availableVersion) ->
return false unless installedPackage?
installedVersion = installedPackage.metadata.version
return false unless semver.valid(installedVersion)
return false unless semver.valid(availableVersion)
semver.gt(availableVersion, installedVersion)
getPackageTitle: ({name}) ->
_.undasherize(_.uncamelcase(name))
getRepositoryUrl: ({metadata}) ->
{repository} = metadata
repoUrl = repository?.url ? repository ? ''
if repoUrl.match 'git@github'
repoName = repoUrl.split(':')[1]
repoUrl = "https://github.com/#{repoName}"
repoUrl.replace(/\.git$/, '').replace(/\/+$/, '').replace(/^git\+/, '')
getRepositoryBugUri: ({metadata}) ->
{bugs} = metadata
if typeof bugs is 'string'
bugUri = bugs
else
bugUri = bugs?.url ? bugs?.email ? this.getRepositoryUrl({metadata}) + '/issues/new'
if bugUri.includes('@')
bugUri = 'mailto:' + bugUri
bugUri
checkNativeBuildTools: ->
new Promise (resolve, reject) =>
apmProcess = @runCommand ['install', '--check'], (code, stdout, stderr) ->
if code is 0
resolve()
else
reject(new Error())
apmProcess.onWillThrowError ({error, handle}) ->
handle()
reject(error)
removePackageNameFromDisabledPackages: (packageName) ->
atom.config.removeAtKeyPath('core.disabledPackages', packageName)
# Emits the appropriate event for the given package.
#
# All events are either of the form `theme-foo` or `package-foo` depending on
# whether the event is for a theme or a normal package. This method standardizes
# the logic to determine if a package is a theme or not and formats the event
# name appropriately.
#
# eventName - The event name suffix {String} of the event to emit.
# pack - The package for which the event is being emitted.
# error - Any error information to be included in the case of an error.
emitPackageEvent: (eventName, pack, error) ->
theme = pack.theme ? pack.metadata?.theme
eventName = if theme then "theme-#{eventName}" else "package-#{eventName}"
@emitter.emit(eventName, {pack, error})
on: (selectors, callback) ->
subscriptions = new CompositeDisposable
for selector in selectors.split(" ")
subscriptions.add @emitter.on(selector, callback)
subscriptions
createJsonParseError = (message, parseError, stdout) ->
error = new Error(message)
error.stdout = ''
error.stderr = "#{parseError.message}: #{stdout}"
error
createProcessError = (message, processError) ->
error = new Error(message)
error.stdout = ''
error.stderr = processError.message
error
handleProcessErrors = (apmProcess, message, callback) ->
apmProcess.onWillThrowError ({error, handle}) ->
handle()
callback(createProcessError(message, error))

View File

@ -0,0 +1,84 @@
/** @babel */
import {marked} from 'marked'
import createDOMPurify from 'dompurify'
function sanitize (html, readmeSrc) {
const temporaryContainer = document.createElement('div')
temporaryContainer.innerHTML = html
for (const checkbox of temporaryContainer.querySelectorAll('input[type="checkbox"]')) {
checkbox.setAttribute('disabled', '')
}
let path = require('path')
for (const image of temporaryContainer.querySelectorAll('img')) {
let imageSrc = image.getAttribute('src')
let changeImageSrc = true
// If src contains a protocol then it must be absolute
if (/^(?:[a-z]+:)?\/\//i.test(imageSrc)) {
changeImageSrc = false
}
// If src contains a base64 encoded image it must be left unchanged
if (/^data:image\/.*;base64/i.test(imageSrc)) {
changeImageSrc = false
}
// If path is absolute on file system it must be a local file, e.g. emoji
if (path.isAbsolute(imageSrc)) {
changeImageSrc = false
}
// If imageSrc needs changing and readmeSrc isn't undefined (i.e. if package was unpublished)
if (changeImageSrc && readmeSrc) {
if (path.isAbsolute(readmeSrc)) {
// If repoUrl is a local path (i.e. package is installed)
image.setAttribute('src', path.join(readmeSrc, imageSrc))
} else {
// If repoUrl is a URL (i.e. package isn't installed)
image.setAttribute('src', new URL(imageSrc, readmeSrc))
}
}
}
return createDOMPurify().sanitize(temporaryContainer.innerHTML)
}
// Displays the readme for a package, if it has one
// TODO Decide to keep this or current button-to-new-tab view
export default class PackageReadmeView {
constructor (readme, readmeSrc) {
this.element = document.createElement('section')
this.element.classList.add('section')
const container = document.createElement('div')
container.classList.add('section-container')
const heading = document.createElement('div')
heading.classList.add('section-heading', 'icon', 'icon-book')
heading.textContent = 'README'
container.appendChild(heading)
this.packageReadme = document.createElement('div')
this.packageReadme.classList.add('package-readme', 'native-key-bindings')
this.packageReadme.tabIndex = -1
container.appendChild(this.packageReadme)
this.element.appendChild(container)
marked(readme || '### No README.', {breaks: false}, (err, content) => {
if (err) {
this.packageReadme.innerHTML = '<h3>Error parsing README</h3>'
} else {
this.packageReadme.innerHTML = sanitize(content, readmeSrc)
}
})
}
destroy () {
this.element.remove()
}
}

View File

@ -0,0 +1,236 @@
/** @babel */
/** @jsx etch.dom */
import path from 'path'
import _ from 'underscore-plus'
import etch from 'etch'
import {CompositeDisposable, Disposable} from 'atom'
// View to display the snippets that a package has registered.
export default class PackageSnippetsView {
constructor (pack, snippetsProvider) {
this.pack = pack
this.namespace = this.pack.name
this.snippetsProvider = snippetsProvider
this.packagePath = path.join(pack.path, path.sep)
etch.initialize(this)
this.disposables = new CompositeDisposable()
this.updateSnippetsView()
const packagesWithSnippetsDisabled = atom.config.get('core.packagesWithSnippetsDisabled') || []
this.refs.snippetToggle.checked = !packagesWithSnippetsDisabled.includes(this.namespace)
const changeHandler = (event) => {
event.stopPropagation()
const value = this.refs.snippetToggle.checked
if (value) {
atom.config.removeAtKeyPath('core.packagesWithSnippetsDisabled', this.namespace)
} else {
atom.config.pushAtKeyPath('core.packagesWithSnippetsDisabled', this.namespace)
}
this.updateSnippetsView()
}
this.refs.snippetToggle.addEventListener('change', changeHandler)
this.disposables.add(new Disposable(() => { this.refs.snippetToggle.removeEventListener('change', changeHandler) }))
}
destroy () {
this.disposables.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<section className='section'>
<div className='section-heading icon icon-code'>Snippets</div>
<div className='checkbox'>
<label for='toggleSnippets'>
<input id='toggleSnippets' className='input-checkbox' type='checkbox' ref='snippetToggle' />
<div className='setting-title'>Enable</div>
</label>
<div className='setting-description'>
{'Disable this if you want to prevent this packages snippets from appearing as suggestions or if you want to customize them in your snippets file.'}
</div>
</div>
<table className='package-snippets-table table native-key-bindings text' tabIndex={-1}>
<thead>
<tr>
<th>Trigger</th>
<th>Name</th>
<th>Scope</th>
<th>Body</th>
</tr>
</thead>
<tbody ref='snippets' />
</table>
</section>
)
}
getSnippetProperties () {
const packageProperties = {}
for (const {name, properties, selectorString} of this.snippetsProvider.getSnippets()) {
if (name && name.indexOf && name.indexOf(this.packagePath) === 0) {
const object = properties.snippets != null ? properties.snippets : {}
for (let key in object) {
const snippet = object[key]
if (snippet != null) {
snippet.selectorString = selectorString
if (packageProperties[key] == null) {
packageProperties[key] = snippet
}
}
}
}
}
return _.values(packageProperties).sort((snippet1, snippet2) => {
const prefix1 = snippet1.prefix != null ? snippet1.prefix : ''
const prefix2 = snippet2.prefix != null ? snippet2.prefix : ''
return prefix1.localeCompare(prefix2)
})
}
getSnippets (callback) {
const snippetsPackage = atom.packages.getLoadedPackage('snippets')
const snippetsModule = snippetsPackage ? snippetsPackage.mainModule : null
if (snippetsModule) {
if (snippetsModule.loaded) {
callback(this.getSnippetProperties())
} else {
snippetsModule.onDidLoadSnippets(() => callback(this.getSnippetProperties()))
}
} else {
callback([]) // eslint-disable-line standard/no-callback-literal
}
}
updateSnippetsView () {
const packagesWithSnippetsDisabled = atom.config.get('core.packagesWithSnippetsDisabled') || []
const snippetsDisabled = packagesWithSnippetsDisabled.includes(this.namespace)
this.getSnippets((snippets) => {
this.refs.snippets.innerHTML = ''
if (snippetsDisabled) {
this.refs.snippets.classList.add('text-subtle')
} else {
this.refs.snippets.classList.remove('text-subtle')
}
for (let {body, bodyText, name, prefix, selectorString} of snippets) {
if (name == null) {
name = ''
}
if (prefix == null) {
prefix = ''
}
if (body == null) {
body = bodyText || ''
}
if (selectorString == null) {
selectorString = ''
}
const row = document.createElement('tr')
const prefixTd = document.createElement('td')
prefixTd.classList.add('snippet-prefix')
prefixTd.textContent = prefix
row.appendChild(prefixTd)
const nameTd = document.createElement('td')
nameTd.textContent = name
row.appendChild(nameTd)
const scopeTd = document.createElement('td')
scopeTd.classList.add('snippet-scope-name')
scopeTd.textContent = selectorString
row.appendChild(scopeTd)
const bodyTd = document.createElement('td')
bodyTd.classList.add('snippet-body')
row.appendChild(bodyTd)
this.refs.snippets.appendChild(row)
this.createButtonsForSnippetRow(bodyTd, {body, prefix, scope: selectorString, name})
}
if (this.refs.snippets.children.length > 0) {
this.element.style.display = ''
} else {
this.element.style.display = 'none'
}
})
}
createButtonsForSnippetRow (td, {scope, body, name, prefix}) {
let buttonContainer = document.createElement('div')
buttonContainer.classList.add('btn-group', 'btn-group-xs')
let viewButton = document.createElement('button')
let copyButton = document.createElement('button')
viewButton.setAttribute('type', 'button')
viewButton.textContent = 'View'
viewButton.classList.add('btn', 'snippet-view-btn')
let tooltip = atom.tooltips.add(viewButton, {
title: body,
html: false,
trigger: 'click',
placement: 'auto left',
'class': 'snippet-body-tooltip'
})
this.disposables.add(tooltip)
copyButton.setAttribute('type', 'button')
copyButton.textContent = 'Copy'
copyButton.classList.add('btn', 'snippet-copy-btn')
copyButton.addEventListener('click', (event) => {
event.preventDefault()
return this.writeSnippetToClipboard({scope, body, name, prefix})
})
buttonContainer.appendChild(viewButton)
buttonContainer.appendChild(copyButton)
td.appendChild(buttonContainer)
}
writeSnippetToClipboard ({scope, body, name, prefix}) {
let content
const extension = path.extname(this.snippetsProvider.getUserSnippetsPath())
body = body.replace(/\n/g, '\\n').replace(/\t/g, '\\t')
if (extension === '.cson') {
body = body.replace(/'/g, `\\'`)
content = `
'${scope}':
'${name}':
'prefix': '${prefix}'
'body': '${body}'
`
} else {
body = body.replace(/"/g, `\\"`)
content = `
"${scope}": {
"${name}": {
"prefix": "${prefix}",
"body": "${body}"
}
}
`
}
atom.clipboard.write(content)
}
}

View File

@ -0,0 +1,156 @@
/** @babel */
import _ from 'underscore-plus'
import {CompositeDisposable, Disposable} from 'atom'
export default class PackageUpdatesStatusView {
initialize (statusBar, packageManager, updates) {
this.statusBar = statusBar
this.updates = updates
this.destroyed = true
this.updatingPackages = []
this.failedUpdates = []
this.disposables = new CompositeDisposable()
this.element = document.createElement('div')
this.element.classList.add('package-updates-status-view', 'inline-block', 'text', 'text-info')
const iconPackage = document.createElement('span')
iconPackage.classList.add('icon', 'icon-package')
this.element.appendChild(iconPackage)
this.countLabel = document.createElement('span')
this.countLabel.classList.add('available-updates-status')
this.element.appendChild(this.countLabel)
this.disposables.add(packageManager.on('package-update-available theme-update-available', ({pack, error}) => { this.onPackageUpdateAvailable(pack) }))
this.disposables.add(packageManager.on('package-updating theme-updating', ({pack, error}) => { this.onPackageUpdating(pack) }))
this.disposables.add(packageManager.on('package-updated theme-updated package-uninstalled theme-uninstalled', ({pack, error}) => { this.onPackageUpdated(pack) }))
this.disposables.add(packageManager.on('package-update-failed theme-update-failed', ({pack, error}) => { this.onPackageUpdateFailed(pack) }))
const clickHandler = () => {
atom.commands.dispatch(atom.views.getView(atom.workspace), 'settings-view:check-for-package-updates')
}
this.element.addEventListener('click', clickHandler)
this.disposables.add(new Disposable(() => { this.element.removeEventListener('click', clickHandler) }))
this.updateTile()
}
destroy () {
this.disposables.dispose()
this.element.remove()
if (this.tile) {
this.tile.destroy()
this.tile = null
}
if (this.tooltip) {
this.tooltip.dispose()
this.tooltip = null
}
}
onPackageUpdateAvailable (pack) {
for (const update of this.updates) {
if (update.name === pack.name) {
return
}
}
this.updates.push(pack)
this.updateTile()
}
onPackageUpdating (pack) {
// Wipe failed status when an update is retried
for (let index = 0; index < this.failedUpdates.length; index++) {
const update = this.failedUpdates[index]
if (update.name === pack.name) {
this.failedUpdates.splice(index, 1)
}
}
this.updatingPackages.push(pack)
this.updateTile()
}
onPackageUpdated (pack) {
for (let index = 0; index < this.updates.length; index++) {
const update = this.updates[index]
if (update.name === pack.name) {
this.updates.splice(index, 1)
}
}
for (let index = 0; index < this.updatingPackages.length; index++) {
const update = this.updatingPackages[index]
if (update.name === pack.name) {
this.updatingPackages.splice(index, 1)
}
}
for (let index = 0; index < this.failedUpdates.length; index++) {
const update = this.failedUpdates[index]
if (update.name === pack.name) {
this.failedUpdates.splice(index, 1)
}
}
this.updateTile()
}
onPackageUpdateFailed (pack) {
for (const update of this.failedUpdates) {
if (update.name === pack.name) {
return
}
}
for (let index = 0; index < this.updatingPackages.length; index++) {
const update = this.updatingPackages[index]
if (update.name === pack.name) {
this.updatingPackages.splice(index, 1)
}
}
this.failedUpdates.push(pack)
this.updateTile()
}
updateTile () {
if (this.updates.length) {
if (this.tooltip) {
this.tooltip.dispose()
this.tooltip = null
}
if (this.destroyed) {
// Priority of -99 should put us just to the left of the Squirrel icon, which displays when Atom has updates available
this.tile = this.statusBar.addRightTile({item: this, priority: -99})
this.destroyed = false
}
let labelText = `${_.pluralize(this.updates.length, 'update')}` // 5 updates
let tooltipText = `${_.pluralize(this.updates.length, 'package update')} available`
if (this.updatingPackages.length) {
labelText = `${this.updatingPackages.length}/${this.updates.length} updating` // 3/5 updating
tooltipText += `, ${_.pluralize(this.updatingPackages.length, 'package')} currently updating`
}
if (this.failedUpdates.length) {
labelText += ` (${this.failedUpdates.length} failed)` // 1 update (1 failed), or 3/5 updating (1 failed)
tooltipText += `, ${_.pluralize(this.failedUpdates.length, 'failed update')}`
}
this.countLabel.textContent = labelText
this.tooltip = atom.tooltips.add(this.element, {title: tooltipText})
} else if (!this.destroyed) {
this.tile.destroy()
this.tile = null
this.destroyed = true
}
}
}

View File

@ -0,0 +1,28 @@
const {marked} = require('marked')
const renderer = new marked.Renderer()
renderer.code = () => ''
renderer.blockquote = () => ''
renderer.heading = () => ''
renderer.html = (src) => {
const match = src.match(/<br\s*\/?>/)
if (match) {
return `<br/>`
}
return ''
}
renderer.image = () => ''
renderer.list = () => ''
const markdown = text => marked(text, {renderer, breaks: true}).replace(/<p>(.*)<\/p>/, '$1').trim()
module.exports = {
getSettingDescription (keyPath) {
const schema = atom.config.getSchema(keyPath)
let description = ''
if (schema && schema.description) {
description = schema.description
}
return markdown(description)
}
}

View File

@ -0,0 +1,669 @@
/** @babel */
import {CompositeDisposable, Disposable, TextEditor} from 'atom'
import _ from 'underscore-plus'
import CollapsibleSectionPanel from './collapsible-section-panel'
import {getSettingDescription} from './rich-description'
const SCOPED_SETTINGS = [
'autoIndent',
'autoIndentOnPaste',
'invisibles',
'nonWordCharacters',
'preferredLineLength',
'scrollPastEnd',
'showIndentGuide',
'showInvisibles',
'softWrap',
'softWrapAtPreferredLineLength',
'softWrapHangingIndent',
'tabLength',
'tabType'
]
export default class SettingsPanel extends CollapsibleSectionPanel {
constructor (options = {}) {
super()
let namespace = options.namespace
this.element = document.createElement('section')
this.element.classList.add('section', 'settings-panel')
this.options = options
this.disposables = new CompositeDisposable()
let settings
if (this.options.scopeName) {
namespace = 'editor'
settings = {}
for (const name of SCOPED_SETTINGS) {
settings[name] = atom.config.get(name, {scope: [this.options.scopeName]})
}
} else {
settings = atom.config.get(namespace)
}
this.element.appendChild(this.elementForSettings(namespace, settings))
this.disposables.add(this.bindInputFields())
this.disposables.add(this.bindSelectFields())
this.disposables.add(this.bindEditors())
this.disposables.add(this.bindTooltips())
this.disposables.add(this.handleEvents())
}
destroy () {
this.disposables.dispose()
this.element.remove()
}
elementForSettings (namespace, settings) {
if (_.isEmpty(settings)) {
return document.createDocumentFragment()
}
let {title} = this.options
const includeTitle = this.options.includeTitle != null ? this.options.includeTitle : true
if (includeTitle) {
if (title == null) {
title = `${_.undasherize(_.uncamelcase(namespace))} Settings`
}
} else {
if (title == null) {
title = 'Settings'
}
}
const icon = this.options.icon != null ? this.options.icon : 'gear'
const {note} = this.options
const sortedSettings = this.sortSettings(namespace, settings)
const container = document.createElement('div')
container.classList.add('section-container')
const heading = document.createElement('div')
heading.classList.add('block', 'section-heading', 'icon', `icon-${icon}`)
heading.textContent = title
container.appendChild(heading)
if (note) {
container.insertAdjacentHTML('beforeend', note)
}
const body = document.createElement('div')
body.classList.add('section-body')
for (const name of sortedSettings) {
body.appendChild(elementForSetting(namespace, name, settings[name]))
}
container.appendChild(body)
return container
}
sortSettings (namespace, settings) {
return sortSettings(namespace, settings)
}
bindInputFields () {
const disposables = Array.from(this.element.querySelectorAll('input[id]')).map((input) => {
let type = input.type
let name = type === 'radio' ? input.name : input.id
this.observe(name, (value) => {
if (type === 'checkbox') {
input.checked = value
} else if (type === 'radio') {
input.checked = (value === this.parseValue(atom.config.getSchema(name).type, input.value))
} else {
if (type === 'color') {
if (value && value.toHexString && value.toHexString()) {
value = value.toHexString()
}
}
if (value) {
input.value = value
}
}
})
const changeHandler = () => {
let value = input.value
if (type === 'checkbox') {
value = input.checked
} else if (type === 'radio') {
value = this.parseValue(atom.config.getSchema(name).type, value)
} else {
value = this.parseValue(type, value)
}
if (type === 'color') {
// This is debounced since the color wheel fires lots of events
// as you are dragging it around
clearTimeout(this.colorDebounceTimeout)
this.colorDebounceTimeout = setTimeout(() => { this.set(name, value) }, 100)
} else {
this.set(name, value)
}
}
input.addEventListener('change', changeHandler)
return new Disposable(() => input.removeEventListener('change', changeHandler))
})
return new CompositeDisposable(...disposables)
}
observe (name, callback) {
let params = {sources: [atom.config.getUserConfigPath()]}
if (this.options.scopeName != null) {
params.scope = [this.options.scopeName]
}
this.disposables.add(atom.config.observe(name, params, callback))
}
isDefault (name) {
let params = {sources: [atom.config.getUserConfigPath()]}
if (this.options.scopeName != null) {
params.scope = [this.options.scopeName]
}
let defaultValue = this.getDefault(name)
let value = atom.config.get(name, params)
return (value == null) || (defaultValue === value)
}
getDefault (name) {
let params = {excludeSources: [atom.config.getUserConfigPath()]}
if (this.options.scopeName != null) {
params.scope = [this.options.scopeName]
}
let defaultValue = atom.config.get(name, params)
if (this.options.scopeName != null) {
// If the unscoped default is the same as the scoped default, check the actual config.cson
// to make sure that there isn't a non-default value that is overriding the scoped value
// For example: the default editor.tabLength is 2, but if someone sets it to 4
// the above check still returns 2 and not 4 for a scoped editor.tabLength,
// because it bypasses config.cson.
if (atom.config.get(name, {excludeSources: [atom.config.getUserConfigPath()]}) === defaultValue) {
defaultValue = atom.config.get(name)
}
}
return defaultValue
}
set (name, value) {
if (this.options.scopeName) {
if (value === undefined) {
atom.config.unset(name, {scopeSelector: this.options.scopeName})
return true
} else {
return atom.config.set(name, value, {scopeSelector: this.options.scopeName})
}
} else {
return atom.config.set(name, value)
}
}
setText (editor, name, type, value) {
let stringValue
if (this.isDefault(name)) {
stringValue = ''
} else {
stringValue = this.valueToString(value) || ''
}
if (stringValue === editor.getText() || _.isEqual(value, this.parseValue(type, editor.getText()))) {
return
}
editor.setText(stringValue)
editor.moveToEndOfLine()
}
bindSelectFields () {
const disposables = Array.from(this.element.querySelectorAll('select[id]')).map((select) => {
const name = select.id
this.observe(name, value => { select.value = value })
const changeHandler = () => {
this.set(name, select.value)
}
select.addEventListener('change', changeHandler)
return new Disposable(() => select.removeEventListener('change', changeHandler))
})
return new CompositeDisposable(...disposables)
}
bindEditors () {
const disposables = Array.from(this.element.querySelectorAll('atom-text-editor')).map((editorElement) => {
let editor = editorElement.getModel()
let name = editorElement.id
let type = editorElement.getAttribute('type')
let defaultValue = this.valueToString(this.getDefault(name))
if (defaultValue != null) {
editor.setPlaceholderText(`Default: ${defaultValue}`)
}
const subscriptions = new CompositeDisposable()
const focusHandler = () => {
if (this.isDefault(name)) {
editor.setText(this.valueToString(this.getDefault(name)) || '')
}
}
editorElement.addEventListener('focus', focusHandler)
subscriptions.add(new Disposable(() => editorElement.removeEventListener('focus', focusHandler)))
const blurHandler = () => {
if (this.isDefault(name)) {
editor.setText('')
}
}
editorElement.addEventListener('blur', blurHandler)
subscriptions.add(new Disposable(() => editorElement.removeEventListener('blur', blurHandler)))
this.observe(name, (value) => {
this.setText(editor, name, type, value)
})
subscriptions.add(editor.onDidStopChanging(() => {
const {minimum, maximum} = atom.config.getSchema(name)
const value = this.parseValue(type, editor.getText())
if (minimum != null && value < minimum) {
this.set(name, minimum)
this.setText(editor, name, type, minimum)
} else if (maximum != null && value > maximum) {
this.set(name, maximum)
this.setText(editor, name, type, maximum)
} else if (!this.set(name, value)) {
this.setText(editor, name, type, atom.config.get(name))
}
}))
return subscriptions
})
return new CompositeDisposable(...disposables)
}
bindTooltips () {
const disposables = Array.from(this.element.querySelectorAll('input[id], select[id], atom-text-editor[id]')).map((element) => {
const schema = atom.config.getSchema(element.id)
let defaultValue = this.valueToString(this.getDefault(element.id))
if (defaultValue != null) {
if (schema.enum && _.findWhere(schema.enum, {value: defaultValue})) {
defaultValue = _.findWhere(schema.enum, {value: defaultValue}).description
}
return atom.tooltips.add(element, {
title: `Default: ${defaultValue}`,
delay: {show: 100},
placement: 'auto left'
})
} else {
return new Disposable(() => {}) // no-op
}
})
return new CompositeDisposable(...disposables)
}
valueToString (value) {
if (Array.isArray(value)) {
if (value.length === 0) {
return null
}
return value.map((val) => val.toString().replace(/,/g, '\\,')).join(', ')
} else if (value != null) {
return value.toString()
} else {
return null
}
}
parseValue (type, value) {
if (value === '') {
return undefined
} else if (type === 'number') {
let floatValue = parseFloat(value)
if (isNaN(floatValue)) {
return value
} else {
return floatValue
}
} else if (type === 'integer') {
let intValue = parseInt(value)
if (isNaN(intValue)) {
return value
} else {
return intValue
}
} else if (type === 'array') {
let arrayValue = (value || '').split(',')
arrayValue = arrayValue.reduce((values, val) => {
const last = values.length - 1
if (last >= 0 && values[last].endsWith('\\')) {
values[last] = values[last].replace(/\\$/, ',') + val
} else {
values.push(val)
}
return values
}, [])
return arrayValue.filter((val) => val).map((val) => val.trim())
} else {
return value
}
}
}
/*
* Space Pen Helpers
*/
let isEditableArray = function (array) {
for (let item of array) {
if (!_.isString(item)) {
return false
}
}
return true
}
function sortSettings (namespace, settings) {
return _.chain(settings)
.keys()
.sortBy((name) => name)
.sortBy((name) => {
const schema = atom.config.getSchema(`${namespace}.${name}`)
return schema ? schema.order : null
})
.value()
}
function elementForSetting (namespace, name, value) {
if (namespace === 'core') {
if (name === 'themes') { return document.createDocumentFragment() } // Handled in the Themes panel
if (name === 'disabledPackages') { return document.createDocumentFragment() } // Handled in the Packages panel
if (name === 'customFileTypes') { return document.createDocumentFragment() }
if (name === 'uriHandlerRegistration') { return document.createDocumentFragment() } // Handled in the URI Handler panel
}
if (namespace === 'editor') {
// There's no global default for these, they are defined by language packages
if (['commentStart', 'commentEnd', 'increaseIndentPattern', 'decreaseIndentPattern', 'foldEndPattern'].includes(name)) {
return document.createDocumentFragment()
}
}
const controlGroup = document.createElement('div')
controlGroup.classList.add('control-group')
const controls = document.createElement('div')
controls.classList.add('controls')
controlGroup.appendChild(controls)
let schema = atom.config.getSchema(`${namespace}.${name}`)
if (schema && schema.enum) {
controls.appendChild(elementForOptions(namespace, name, value, {radio: schema.radio}))
} else if (schema && schema.type === 'color') {
controls.appendChild(elementForColor(namespace, name, value))
} else if (_.isBoolean(value) || (schema && schema.type === 'boolean')) {
controls.appendChild(elementForCheckbox(namespace, name, value))
} else if (_.isArray(value) || (schema && schema.type === 'array')) {
if (isEditableArray(value)) {
controls.appendChild(elementForArray(namespace, name, value))
}
} else if (_.isObject(value) || (schema && schema.type === 'object')) {
controls.appendChild(elementForObject(namespace, name, value))
} else {
controls.appendChild(elementForEditor(namespace, name, value))
}
return controlGroup
}
function getSettingTitle (keyPath, name) {
if (name == null) {
name = ''
}
const schema = atom.config.getSchema(keyPath)
const title = schema != null ? schema.title : null
return title || _.uncamelcase(name).split('.').map(_.capitalize).join(' ')
}
function elementForOptions (namespace, name, value, {radio = false}) {
let keyPath = `${namespace}.${name}`
let schema = atom.config.getSchema(keyPath)
let options = (schema && schema.enum) ? schema.enum : []
const fragment = document.createDocumentFragment()
const label = document.createElement('label')
label.classList.add('control-label')
const titleDiv = document.createElement('div')
titleDiv.classList.add('setting-title')
titleDiv.textContent = getSettingTitle(keyPath, name)
label.appendChild(titleDiv)
const descriptionDiv = document.createElement('div')
descriptionDiv.classList.add('setting-description')
descriptionDiv.innerHTML = getSettingDescription(keyPath)
label.appendChild(descriptionDiv)
fragment.appendChild(label)
fragment.appendChild(enumOptions(options, {keyPath, radio}))
return fragment
}
function elementForCheckbox (namespace, name, value) {
let keyPath = `${namespace}.${name}`
const div = document.createElement('div')
div.classList.add('checkbox')
const label = document.createElement('label')
label.for = keyPath
const input = document.createElement('input')
input.id = keyPath
input.type = 'checkbox'
input.classList.add('input-checkbox')
label.appendChild(input)
const titleDiv = document.createElement('div')
titleDiv.classList.add('setting-title')
titleDiv.textContent = getSettingTitle(keyPath, name)
label.appendChild(titleDiv)
div.appendChild(label)
const descriptionDiv = document.createElement('div')
descriptionDiv.classList.add('setting-description')
descriptionDiv.innerHTML = getSettingDescription(keyPath)
div.appendChild(descriptionDiv)
return div
}
function elementForColor (namespace, name, value) {
let keyPath = `${namespace}.${name}`
const div = document.createElement('div')
div.classList.add('color')
const label = document.createElement('label')
label.for = keyPath
const input = document.createElement('input')
input.id = keyPath
input.type = 'color'
label.appendChild(input)
const titleDiv = document.createElement('div')
titleDiv.classList.add('setting-title')
titleDiv.textContent = getSettingTitle(keyPath, name)
label.appendChild(titleDiv)
div.appendChild(label)
const descriptionDiv = document.createElement('div')
descriptionDiv.classList.add('setting-description')
descriptionDiv.innerHTML = getSettingDescription(keyPath)
div.appendChild(descriptionDiv)
return div
}
function elementForEditor (namespace, name, value) {
let keyPath = `${namespace}.${name}`
let type = _.isNumber(value) ? 'number' : 'string'
const fragment = document.createDocumentFragment()
const label = document.createElement('label')
label.classList.add('control-label')
const titleDiv = document.createElement('div')
titleDiv.classList.add('setting-title')
titleDiv.textContent = getSettingTitle(keyPath, name)
label.appendChild(titleDiv)
const descriptionDiv = document.createElement('div')
descriptionDiv.classList.add('setting-description')
descriptionDiv.innerHTML = getSettingDescription(keyPath)
label.appendChild(descriptionDiv)
fragment.appendChild(label)
const controls = document.createElement('div')
controls.classList.add('controls')
const editorContainer = document.createElement('div')
editorContainer.classList.add('editor-container')
const editor = new TextEditor({mini: true})
editor.element.id = keyPath
editor.element.setAttribute('type', type)
editorContainer.appendChild(editor.element)
controls.appendChild(editorContainer)
fragment.appendChild(controls)
return fragment
}
function elementForArray (namespace, name, value) {
let keyPath = `${namespace}.${name}`
const fragment = document.createDocumentFragment()
const label = document.createElement('label')
label.classList.add('control-label')
const titleDiv = document.createElement('div')
titleDiv.classList.add('setting-title')
titleDiv.textContent = getSettingTitle(keyPath, name)
label.appendChild(titleDiv)
const descriptionDiv = document.createElement('div')
descriptionDiv.classList.add('setting-description')
descriptionDiv.innerHTML = getSettingDescription(keyPath)
label.appendChild(descriptionDiv)
fragment.appendChild(label)
const controls = document.createElement('div')
controls.classList.add('controls')
const editorContainer = document.createElement('div')
editorContainer.classList.add('editor-container')
const editor = new TextEditor({mini: true})
editor.element.id = keyPath
editor.element.setAttribute('type', 'array')
editorContainer.appendChild(editor.element)
controls.appendChild(editorContainer)
fragment.appendChild(controls)
return fragment
}
function elementForObject (namespace, name, value) {
if (_.keys(value).length === 0) {
return document.createDocumentFragment()
} else {
let keyPath = `${namespace}.${name}`
let schema = atom.config.getSchema(keyPath)
let isCollapsed = schema.collapsed === true
const section = document.createElement('section')
section.classList.add('sub-section')
if (isCollapsed) {
section.classList.add('collapsed')
}
const h3 = document.createElement('h3')
h3.classList.add('sub-section-heading', 'has-items')
h3.textContent = getSettingTitle(keyPath, name)
section.appendChild(h3)
const descriptionDiv = document.createElement('div')
descriptionDiv.classList.add('setting-description')
descriptionDiv.innerHTML = getSettingDescription(keyPath)
section.appendChild(descriptionDiv)
const div = document.createElement('div')
div.classList.add('sub-section-body')
for (const key of sortSettings(keyPath, value)) {
div.appendChild(elementForSetting(namespace, `${name}.${key}`, value[key]))
}
section.appendChild(div)
return section
}
}
function enumOptions (options, {keyPath, radio}) {
const containerTag = radio ? 'fieldset' : 'select'
const container = document.createElement(containerTag)
container.id = keyPath
const containerClass = radio ? 'input-radio-group' : 'form-control'
container.classList.add(containerClass)
const conversion = radio ? optionToRadio : optionToSelect
const optionElements = options.map(option => conversion(option, keyPath))
for (const optionElement of optionElements) { container.appendChild(optionElement) }
return container
}
function optionToRadio (option, keyPath) {
const button = document.createElement('input')
const label = document.createElement('label')
label.classList.add('input-label')
let value
let description = ''
if (option.hasOwnProperty('value')) {
value = option.value
description = option.description
} else {
value = option
description = option
}
button.classList.add('input-radio')
button.id = `${keyPath}[${value}]`
button.name = keyPath
button.type = 'radio'
button.value = value
label.appendChild(button)
label.appendChild(document.createTextNode(description))
return label
}
function optionToSelect (option, keyPath) {
const optionElement = document.createElement('option')
if (option.hasOwnProperty('value')) {
optionElement.value = option.value
optionElement.textContent = option.description
} else {
optionElement.value = option
optionElement.textContent = option
}
return optionElement
}

View File

@ -0,0 +1,394 @@
/** @babel */
/** @jsx etch.dom */
import path from 'path'
import etch from 'etch'
import _ from 'underscore-plus'
import {CompositeDisposable, Disposable} from 'atom'
import GeneralPanel from './general-panel'
import EditorPanel from './editor-panel'
import PackageDetailView from './package-detail-view'
import KeybindingsPanel from './keybindings-panel'
import InstallPanel from './install-panel'
import ThemesPanel from './themes-panel'
import InstalledPackagesPanel from './installed-packages-panel'
import UpdatesPanel from './updates-panel'
import UriHandlerPanel from './uri-handler-panel'
export default class SettingsView {
constructor ({uri, packageManager, snippetsProvider, activePanel} = {}) {
this.uri = uri
this.packageManager = packageManager
this.snippetsProvider = snippetsProvider
this.deferredPanel = activePanel
this.destroyed = false
this.panelsByName = {}
this.panelCreateCallbacks = {}
etch.initialize(this)
this.disposables = new CompositeDisposable()
this.disposables.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
this.disposables.add(atom.packages.onDidActivateInitialPackages(() => {
this.disposables.add(
atom.packages.onDidActivatePackage(pack => this.removePanelCache(pack.name)),
atom.packages.onDidDeactivatePackage(pack => this.removePanelCache(pack.name))
)
}))
process.nextTick(() => this.initializePanels())
}
removePanelCache (name) {
delete this.panelsByName[name]
}
update () {}
destroy () {
this.destroyed = true
this.disposables.dispose()
for (let name in this.panelsByName) {
const panel = this.panelsByName[name]
panel.destroy()
}
return etch.destroy(this)
}
render () {
return (
<div className='settings-view pane-item' tabIndex='-1'>
<div className='config-menu' ref='sidebar'>
<ul className='panels-menu nav nav-pills nav-stacked' ref='panelMenu'>
<div className='panel-menu-separator' ref='menuSeparator' />
</ul>
<div className='button-area'>
<button className='btn btn-default icon icon-link-external' ref='openDotAtom'>Open Config Folder</button>
</div>
</div>
{/* The tabindex attr below ensures that clicks in a panel item won't
cause this view to gain focus. This is important because when this view
gains focus (e.g. immediately after atom displays it), it focuses the
currently active panel item. If that focusing causes the active panel to
scroll (e.g. because the active panel itself passes focus on to a search
box at the top of a scrolled panel), then the browser will not fire the
click event on the element within the panel on which the user originally
clicked (e.g. a package card). This would prevent us from showing a
package detail view when clicking on a package card. Phew! */}
<div className='panels' tabIndex='-1' ref='panels' />
</div>
)
}
// This prevents the view being actually disposed when closed
// If you remove it you will need to ensure the cached settingsView
// in main.coffee is correctly released on close as well...
onDidChangeTitle () { return new Disposable() }
initializePanels () {
if (this.refs.panels.children.length > 1) {
return
}
const clickHandler = (event) => {
const target = event.target.closest('.panels-menu li a, .panels-packages li a')
if (target) {
this.showPanel(target.closest('li').name)
}
}
this.element.addEventListener('click', clickHandler)
this.disposables.add(new Disposable(() => this.element.removeEventListener('click', clickHandler)))
const focusHandler = () => {
this.focusActivePanel()
}
this.element.addEventListener('focus', focusHandler)
this.disposables.add(new Disposable(() => this.element.removeEventListener('focus', focusHandler)))
const openDotAtomClickHandler = () => {
atom.open({pathsToOpen: [atom.getConfigDirPath()]})
}
this.refs.openDotAtom.addEventListener('click', openDotAtomClickHandler)
this.disposables.add(new Disposable(() => this.refs.openDotAtom.removeEventListener('click', openDotAtomClickHandler)))
this.addCorePanel('Core', 'settings', () => new GeneralPanel())
this.addCorePanel('Editor', 'code', () => new EditorPanel())
if (atom.config.getSchema('core.uriHandlerRegistration').type !== 'any') {
// "feature flag" based on core support for URI handling
this.addCorePanel('URI Handling', 'link', () => new UriHandlerPanel())
}
if ((process.platform === 'win32') && (require('atom').WinShell != null)) {
const SystemPanel = require('./system-windows-panel')
this.addCorePanel('System', 'device-desktop', () => new SystemPanel())
}
this.addCorePanel('Keybindings', 'keyboard', () => new KeybindingsPanel())
this.addCorePanel('Packages', 'package', () => new InstalledPackagesPanel(this, this.packageManager))
this.addCorePanel('Themes', 'paintcan', () => new ThemesPanel(this, this.packageManager))
this.addCorePanel('Updates', 'cloud-download', () => new UpdatesPanel(this, this.packageManager))
this.addCorePanel('Install', 'plus', () => new InstallPanel(this, this.packageManager))
this.showDeferredPanel()
if (!this.activePanel) {
this.showPanel('Core')
}
if (document.body.contains(this.element)) {
this.refs.sidebar.style.width = this.refs.sidebar.offsetWidth
}
}
serialize () {
return {
deserializer: 'SettingsView',
version: 2,
activePanel: this.activePanel != null ? this.activePanel : this.deferredPanel,
uri: this.uri
}
}
getPackages () {
let bundledPackageMetadataCache
if (this.packages != null) { return this.packages }
this.packages = atom.packages.getLoadedPackages()
try {
const packageMetadata = require(path.join(atom.getLoadSettings().resourcePath, 'package.json'))
bundledPackageMetadataCache = packageMetadata ? packageMetadata._atomPackages : null
} catch (error) {}
// Include disabled packages so they can be re-enabled from the UI
const disabledPackages = atom.config.get('core.disabledPackages') || []
for (const packageName of disabledPackages) {
var metadata
const packagePath = atom.packages.resolvePackagePath(packageName)
if (!packagePath) {
continue
}
try {
metadata = require(path.join(packagePath, 'package.json'))
} catch (error) {
if (bundledPackageMetadataCache && bundledPackageMetadataCache[packageName]) {
metadata = bundledPackageMetadataCache[packageName].metadata
}
}
if (metadata == null) {
continue
}
const name = metadata.name != null ? metadata.name : packageName
if (!_.findWhere(this.packages, {name})) {
this.packages.push({name, metadata, path: packagePath})
}
}
this.packages.sort((pack1, pack2) => {
const title1 = this.packageManager.getPackageTitle(pack1)
const title2 = this.packageManager.getPackageTitle(pack2)
return title1.localeCompare(title2)
})
return this.packages
}
addCorePanel (name, iconName, panelCreateCallback) {
const panelMenuItem = document.createElement('li')
panelMenuItem.name = name
panelMenuItem.setAttribute('name', name)
const a = document.createElement('a')
a.classList.add('icon', `icon-${iconName}`)
a.textContent = name
panelMenuItem.appendChild(a)
this.refs.menuSeparator.parentElement.insertBefore(panelMenuItem, this.refs.menuSeparator)
this.addPanel(name, panelCreateCallback)
}
addPanel (name, panelCreateCallback) {
this.panelCreateCallbacks[name] = panelCreateCallback
if (this.deferredPanel && this.deferredPanel.name === name) {
this.showDeferredPanel()
}
}
getOrCreatePanel (name, options) {
let panel = this.panelsByName[name]
if (panel) return panel
if (name in this.panelCreateCallbacks) {
panel = this.panelCreateCallbacks[name]()
delete this.panelCreateCallbacks[name]
} else if (options && options.pack) {
if (!options.pack.metadata) {
options.pack.metadata = _.clone(options.pack)
}
panel = new PackageDetailView(options.pack, this, this.packageManager, this.snippetsProvider)
}
if (panel) {
this.panelsByName[name] = panel
}
return panel
}
makePanelMenuActive (name) {
const previouslyActivePanel = this.refs.sidebar.querySelector('.active')
if (previouslyActivePanel) {
previouslyActivePanel.classList.remove('active')
}
const newActivePanel = this.refs.sidebar.querySelector(`[name='${name}']`)
if (newActivePanel) {
newActivePanel.classList.add('active')
}
}
focusActivePanel () {
// Pass focus to panel that is currently visible
for (let i = 0; i < this.refs.panels.children.length; i++) {
const child = this.refs.panels.children[i]
if (child.offsetWidth > 0) {
child.focus()
}
}
}
showDeferredPanel () {
if (this.deferredPanel) {
const {name, options} = this.deferredPanel
this.showPanel(name, options)
}
}
// Public: show a panel.
//
// * `name` {String} the name of the panel to show
// * `options` {Object} an options hash. Will be passed to `beforeShow()` on
// the panel. Options may include (but are not limited to):
// * `uri` the URI the panel was launched from
showPanel (name, options) {
if (this.activePanel) {
const prev = this.panelsByName[this.activePanel.name]
if (prev) {
prev.scrollPosition = prev.element.scrollTop
}
}
const panel = this.getOrCreatePanel(name, options)
if (panel) {
this.appendPanel(panel, options)
this.makePanelMenuActive(name)
this.setActivePanel(name, options)
this.deferredPanel = null
} else {
this.deferredPanel = {name, options}
}
}
showPanelForURI (uri) {
const regex = /config\/([a-z]+)\/?([a-zA-Z0-9_-]+)?/i
const match = regex.exec(uri)
if (match) {
const path1 = match[1]
const path2 = match[2]
if (path1 === 'packages' && path2 != null) {
this.showPanel(path2, {
uri: uri,
pack: {name: path2},
back: atom.packages.getLoadedPackage(path2) ? 'Packages' : null
})
} else {
const panelName = path1[0].toUpperCase() + path1.slice(1)
this.showPanel(panelName, {uri})
}
}
}
appendPanel (panel, options) {
for (let i = 0; i < this.refs.panels.children.length; i++) {
this.refs.panels.children[i].style.display = 'none'
}
if (!this.refs.panels.contains(panel.element)) {
this.refs.panels.appendChild(panel.element)
}
if (panel.beforeShow) {
panel.beforeShow(options)
}
panel.show()
panel.focus()
}
setActivePanel (name, options = {}) {
this.activePanel = {name, options}
const panel = this.panelsByName[name]
if (panel && panel.scrollPosition) {
panel.element.scrollTop = panel.scrollPosition
delete panel.scrollPosition
}
}
removePanel (name) {
const panel = this.panelsByName[name]
if (panel) {
panel.destroy()
delete this.panelsByName[name]
}
}
getTitle () {
return 'Settings'
}
getIconName () {
return 'tools'
}
getURI () {
return this.uri
}
isEqual (other) {
return other instanceof SettingsView
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,145 @@
/** @babel */
/** @jsx etch.dom */
import {WinShell, CompositeDisposable} from 'atom'
import etch from 'etch'
export default class SystemPanel {
constructor () {
etch.initialize(this)
this.subscriptions = new CompositeDisposable()
this.subscriptions.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
WinShell.fileHandler.isRegistered((i) => { this.refs.fileHandlerCheckbox.checked = i })
WinShell.fileContextMenu.isRegistered((i) => { this.refs.fileContextMenuCheckbox.checked = i })
WinShell.folderContextMenu.isRegistered((i) => { this.refs.folderContextMenuCheckbox.checked = i })
}
destroy () {
this.subscriptions.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<div className='panels-item' tabIndex='0'>
<form className='general-panel section'>
<div className='settings-panel'>
<div className='section-container'>
<div className='block section-heading icon icon-device-desktop'>System Settings</div>
<div className='text icon icon-question'>These settings determine how Atom integrates with your operating system.</div>
<div className='section-body'>
<div className='control-group'>
<div className='controls'>
<div className='checkbox'>
<label for='system.windows.file-handler'>
<input
ref='fileHandlerCheckbox'
id='system.windows.file-handler'
className='input-checkbox'
type='checkbox'
onclick={(e) => { this.setRegistration(WinShell.fileHandler, e.target.checked) }} />
<div className='setting-title'>Register as file handler</div>
<div className='setting-description'>
Show {WinShell.appName} in the "Open with" application list for easy association with file types.
</div>
</label>
</div>
</div>
</div>
<div className='control-group'>
<div className='controls'>
<div className='checkbox'>
<label for='system.windows.shell-menu-files'>
<input
ref='fileContextMenuCheckbox'
id='system.windows.shell-menu-files'
className='input-checkbox'
type='checkbox'
onclick={(e) => { this.setRegistration(WinShell.fileContextMenu, e.target.checked) }} />
<div className='setting-title'>Show in file context menus</div>
<div className='setting-description'>
Add "Open with {WinShell.appName}" to the File Explorer context menu for files.
</div>
</label>
</div>
</div>
</div>
<div className='control-group'>
<div className='controls'>
<div className='checkbox'>
<label for='system.windows.shell-menu-folders'>
<input
ref='folderContextMenuCheckbox'
id='system.windows.shell-menu-folders'
className='input-checkbox'
type='checkbox'
onclick={(e) => {
this.setRegistration(WinShell.folderContextMenu, e.target.checked)
this.setRegistration(WinShell.folderBackgroundContextMenu, e.target.checked)
}} />
<div className='setting-title'>Show in folder context menus</div>
<div className='setting-description'>
Add "Open with {WinShell.appName}" to the File Explorer context menu for folders.
</div>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
)
}
setRegistration (option, shouldBeRegistered) {
if (shouldBeRegistered) {
return option.register(function () {})
} else {
return option.deregister(function () {})
}
}
focus () {
this.element.focus()
}
show () {
this.element.style.display = ''
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,484 @@
/** @babel */
/** @jsx etch.dom */
import fuzzaldrin from 'fuzzaldrin'
import etch from 'etch'
import _ from 'underscore-plus'
import {CompositeDisposable, TextEditor} from 'atom'
import CollapsibleSectionPanel from './collapsible-section-panel'
import PackageCard from './package-card'
import ErrorView from './error-view'
import List from './list'
import ListView from './list-view'
import {ownerFromRepository, packageComparatorAscending} from './utils'
export default class ThemesPanel extends CollapsibleSectionPanel {
static loadPackagesDelay () {
return 300
}
constructor (settingsView, packageManager) {
super()
this.settingsView = settingsView
this.packageManager = packageManager
etch.initialize(this)
this.items = {
dev: new List('name'),
core: new List('name'),
user: new List('name'),
git: new List('name')
}
this.itemViews = {
dev: new ListView(this.items.dev, this.refs.devPackages, this.createPackageCard.bind(this)),
core: new ListView(this.items.core, this.refs.corePackages, this.createPackageCard.bind(this)),
user: new ListView(this.items.user, this.refs.communityPackages, this.createPackageCard.bind(this)),
git: new ListView(this.items.git, this.refs.gitPackages, this.createPackageCard.bind(this))
}
this.disposables = new CompositeDisposable()
this.disposables.add(
this.packageManager.on('theme-install-failed theme-uninstall-failed', ({pack, error}) => {
this.refs.themeErrors.appendChild(new ErrorView(this.packageManager, error).element)
})
)
this.disposables.add(this.handleEvents())
this.disposables.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
this.loadPackages()
this.disposables.add(
this.packageManager.on('theme-installed theme-uninstalled', () => {
let loadPackagesTimeout
clearTimeout(loadPackagesTimeout)
loadPackagesTimeout = setTimeout(() => {
this.populateThemeMenus()
this.loadPackages()
}, ThemesPanel.loadPackagesDelay())
})
)
this.disposables.add(atom.themes.onDidChangeActiveThemes(() => this.updateActiveThemes()))
this.disposables.add(atom.tooltips.add(this.refs.activeUiThemeSettings, {title: 'Settings'}))
this.disposables.add(atom.tooltips.add(this.refs.activeSyntaxThemeSettings, {title: 'Settings'}))
this.updateActiveThemes()
this.disposables.add(this.refs.filterEditor.onDidStopChanging(() => { this.matchPackages() }))
}
update () {}
focus () {
this.refs.filterEditor.element.focus()
}
show () {
this.element.style.display = ''
}
destroy () {
this.disposables.dispose()
return etch.destroy(this)
}
render () {
return (
<div className='panels-item' tabIndex='-1'>
<div className='section packages themes-panel'>
<div className='section-container'>
<div className='section-heading icon icon-paintcan'>Choose a Theme</div>
<div className='text native-key-bindings' tabIndex='-1'>
<span className='icon icon-question'>You can also style Atom by editing </span>
<a className='link' onclick={this.didClickOpenUserStyleSheet}>your stylesheet</a>
</div>
<div className='themes-picker'>
<div className='themes-picker-item control-group'>
<div className='controls'>
<label className='control-label'>
<div className='setting-title themes-label text'>UI Theme</div>
<div className='setting-description text theme-description'>This styles the tabs, status bar, tree view, and dropdowns</div>
</label>
<div className='select-container'>
<select ref='uiMenu' className='form-control' onchange={this.didChangeUiMenu.bind(this)} />
<button
ref='activeUiThemeSettings'
className='btn icon icon-gear active-theme-settings'
onclick={this.didClickActiveUiThemeSettings.bind(this)} />
</div>
</div>
</div>
<div className='themes-picker-item control-group'>
<div className='controls'>
<label className='control-label'>
<div className='setting-title themes-label text'>Syntax Theme</div>
<div className='setting-description text theme-description'>This styles the text inside the editor</div>
</label>
<div className='select-container'>
<select ref='syntaxMenu' className='form-control' onchange={this.didChangeSyntaxMenu.bind(this)} />
<button
ref='activeSyntaxThemeSettings'
className='btn icon icon-gear active-syntax-settings'
onclick={this.didClickActiveSyntaxThemeSettings.bind(this)} />
</div>
</div>
</div>
</div>
</div>
</div>
<section className='section'>
<div className='section-container'>
<div className='section-heading icon icon-paintcan'>
Installed Themes
<span ref='totalPackages' className='section-heading-count badge badge-flexible'></span>
</div>
<div className='editor-container'>
<TextEditor ref='filterEditor' mini placeholderText='Filter themes by name' />
</div>
<div ref='themeErrors' />
<section className='sub-section installed-packages'>
<h3 ref='communityThemesHeader' className='sub-section-heading icon icon-paintcan'>
Community Themes
<span ref='communityCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='communityPackages' className='container package-container'>
<div ref='communityLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading themes</div>
</div>
</section>
<section className='sub-section core-packages'>
<h3 ref='coreThemesHeader' className='sub-section-heading icon icon-paintcan'>
Core Themes
<span ref='coreCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='corePackages' className='container package-container'>
<div ref='coreLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading themes</div>
</div>
</section>
<section className='sub-section dev-packages'>
<h3 ref='developmentThemesHeader' className='sub-section-heading icon icon-paintcan'>
Development Themes
<span ref='devCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='devPackages' className='container package-container'>
<div ref='devLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading themes</div>
</div>
</section>
<section className='sub-section git-packages'>
<h3 ref='gitThemesHeader' className='sub-section-heading icon icon-paintcan'>
Git Themes
<span ref='gitCount' className='section-heading-count badge badge-flexible'></span>
</h3>
<div ref='gitPackages' className='container package-container'>
<div ref='gitLoadingArea' className='alert alert-info loading-area icon icon-hourglass'>Loading themes</div>
</div>
</section>
</div>
</section>
</div>
)
}
filterThemes (packages) {
packages.dev = packages.dev.filter(({theme}) => theme)
packages.user = packages.user.filter(({theme}) => theme)
packages.core = packages.core.filter(({theme}) => theme)
packages.git = (packages.git || []).filter(({theme}) => theme)
for (let pack of packages.core) {
if (pack.repository == null) {
pack.repository = `https://github.com/atom/${pack.name}`
}
}
for (let packageType of ['dev', 'core', 'user', 'git']) {
for (let pack of packages[packageType]) {
pack.owner = ownerFromRepository(pack.repository)
}
}
return packages
}
sortThemes (packages) {
packages.dev.sort(packageComparatorAscending)
packages.core.sort(packageComparatorAscending)
packages.user.sort(packageComparatorAscending)
packages.git.sort(packageComparatorAscending)
return packages
}
loadPackages () {
this.packageViews = []
this.packageManager.getInstalled().then(packages => {
this.packages = this.sortThemes(this.filterThemes(packages))
this.refs.devLoadingArea.remove()
this.items.dev.setItems(this.packages.dev)
this.refs.coreLoadingArea.remove()
this.items.core.setItems(this.packages.core)
this.refs.communityLoadingArea.remove()
this.items.user.setItems(this.packages.user)
this.refs.gitLoadingArea.remove()
this.items.git.setItems(this.packages.git)
// TODO show empty mesage per section
this.updateSectionCounts()
}).catch((error) => {
this.refs.themeErrors.appendChild(new ErrorView(this.packageManager, error).element)
})
}
// Update the active UI and syntax themes and populate the menu
updateActiveThemes () {
this.activeUiTheme = this.getActiveUiTheme()
this.activeSyntaxTheme = this.getActiveSyntaxTheme()
this.populateThemeMenus()
this.toggleActiveThemeButtons()
}
toggleActiveThemeButtons () {
if (this.hasSettings(this.activeUiTheme)) {
this.refs.activeUiThemeSettings.style.display = ''
} else {
this.refs.activeUiThemeSettings.style.display = 'none'
}
if (this.hasSettings(this.activeSyntaxTheme)) {
this.refs.activeSyntaxThemeSettings.display = ''
} else {
this.refs.activeSyntaxThemeSettings.display = 'none'
}
}
hasSettings (packageName) {
return this.packageManager.packageHasSettings(packageName)
}
// Populate the theme menus from the theme manager's active themes
populateThemeMenus () {
this.refs.uiMenu.innerHTML = ''
this.refs.syntaxMenu.innerHTML = ''
const availableThemes = _.sortBy(atom.themes.getLoadedThemes(), 'name')
for (let {name, metadata} of availableThemes) {
switch (metadata.theme) {
case 'ui': {
const themeItem = this.createThemeMenuItem(name)
if (name === this.activeUiTheme) {
themeItem.selected = true
}
this.refs.uiMenu.appendChild(themeItem)
break
}
case 'syntax': {
const themeItem = this.createThemeMenuItem(name)
if (name === this.activeSyntaxTheme) {
themeItem.selected = true
}
this.refs.syntaxMenu.appendChild(themeItem)
break
}
}
}
}
// Get the name of the active ui theme.
getActiveUiTheme () {
for (let {name, metadata} of atom.themes.getActiveThemes()) {
if (metadata.theme === 'ui') {
return name
}
}
return null
}
// Get the name of the active syntax theme.
getActiveSyntaxTheme () {
for (let {name, metadata} of atom.themes.getActiveThemes()) {
if (metadata.theme === 'syntax') { return name }
}
return null
}
// Update the config with the selected themes
updateThemeConfig () {
const themes = []
if (this.activeUiTheme) {
themes.push(this.activeUiTheme)
}
if (this.activeSyntaxTheme) {
themes.push(this.activeSyntaxTheme)
}
if (themes.length > 0) {
atom.config.set('core.themes', themes)
}
}
scheduleUpdateThemeConfig () {
setTimeout(() => { this.updateThemeConfig() }, 100)
}
// Create a menu item for the given theme name.
createThemeMenuItem (themeName) {
const title = _.undasherize(_.uncamelcase(themeName.replace(/-(ui|syntax)/g, '').replace(/-theme$/g, '')))
const option = document.createElement('option')
option.value = themeName
option.textContent = title
return option
}
createPackageCard (pack) {
return new PackageCard(pack, this.settingsView, this.packageManager, {back: 'Themes'})
}
filterPackageListByText (text) {
if (!this.packages) {
return
}
for (let packageType of ['dev', 'core', 'user', 'git']) {
const allViews = this.itemViews[packageType].getViews()
const activeViews = this.itemViews[packageType].filterViews((pack) => {
if (text === '') {
return true
} else {
const owner = pack.owner != null ? pack.owner : ownerFromRepository(pack.repository)
const filterText = `${pack.name} ${owner}`
return fuzzaldrin.score(filterText, text) > 0
}
})
for (const view of allViews) {
if (view) {
view.element.style.display = 'none'
view.element.classList.add('hidden')
}
}
for (const view of activeViews) {
if (view) {
view.element.style.display = ''
view.element.classList.remove('hidden')
}
}
}
this.updateSectionCounts()
}
updateUnfilteredSectionCounts () {
this.updateSectionCount(this.refs.communityThemesHeader, this.refs.communityCount, this.packages.user.length)
this.updateSectionCount(this.refs.coreThemesHeader, this.refs.coreCount, this.packages.core.length)
this.updateSectionCount(this.refs.developmentThemesHeader, this.refs.devCount, this.packages.dev.length)
this.updateSectionCount(this.refs.gitThemesHeader, this.refs.gitCount, this.packages.git.length)
this.refs.totalPackages.textContent = `${this.packages.user.length + this.packages.core.length + this.packages.dev.length + this.packages.git.length}`
}
updateFilteredSectionCounts () {
const community = this.notHiddenCardsLength(this.refs.communityPackages)
this.updateSectionCount(this.refs.communityThemesHeader, this.refs.communityCount, community, this.packages.user.length)
const dev = this.notHiddenCardsLength(this.refs.devPackages)
this.updateSectionCount(this.refs.developmentThemesHeader, this.refs.devCount, dev, this.packages.dev.length)
const core = this.notHiddenCardsLength(this.refs.corePackages)
this.updateSectionCount(this.refs.coreThemesHeader, this.refs.coreCount, core, this.packages.core.length)
const git = this.notHiddenCardsLength(this.refs.gitPackages)
this.updateSectionCount(this.refs.gitThemesHeader, this.refs.gitCount, git, this.packages.git.length)
const shownThemes = dev + core + community + git
const totalThemes = this.packages.user.length + this.packages.core.length + this.packages.dev.length + this.packages.git.length
this.refs.totalPackages.textContent = `${shownThemes}/${totalThemes}`
}
resetSectionHasItems () {
this.resetCollapsibleSections([this.refs.communityThemesHeader, this.refs.coreThemesHeader, this.refs.developmentThemesHeader, this.refs.gitThemesHeader])
}
matchPackages () {
this.filterPackageListByText(this.refs.filterEditor.getText())
}
didClickOpenUserStyleSheet (e) {
e.preventDefault()
atom.commands.dispatch(atom.views.getView(atom.workspace), 'application:open-your-stylesheet')
}
didChangeUiMenu () {
this.activeUiTheme = this.refs.uiMenu.value
this.scheduleUpdateThemeConfig()
}
didChangeSyntaxMenu () {
this.activeSyntaxTheme = this.refs.syntaxMenu.value
this.scheduleUpdateThemeConfig()
}
didClickActiveUiThemeSettings (event) {
event.stopPropagation()
const theme = atom.themes.getActiveThemes().find((theme) => theme.metadata.theme === 'ui')
const activeUiTheme = theme != null ? theme.metadata : null
if (activeUiTheme != null) {
this.settingsView.showPanel(this.activeUiTheme, {
back: 'Themes',
pack: activeUiTheme
})
}
}
didClickActiveSyntaxThemeSettings (event) {
event.stopPropagation()
const theme = atom.themes.getActiveThemes().find((theme) => theme.metadata.theme === 'syntax')
const activeSyntaxTheme = theme != null ? theme.metadata : null
if (activeSyntaxTheme != null) {
this.settingsView.showPanel(this.activeSyntaxTheme, {
back: 'Themes',
pack: activeSyntaxTheme
})
}
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,265 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable} from 'atom'
import asyncQueue from 'async/queue'
import etch from 'etch'
import ErrorView from './error-view'
import PackageCard from './package-card'
export default class UpdatesPanel {
constructor (settingsView, packageManager) {
this.settingsView = settingsView
this.packageManager = packageManager
this.disposables = new CompositeDisposable()
this.updatingPackages = []
this.packageCards = []
etch.initialize(this)
this.refs.updateAllButton.style.display = 'none'
this.checkForUpdates()
this.disposables.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))
this.disposables.add(this.packageManager.on('package-updating theme-updating', ({pack, error}) => {
this.refs.checkButton.disabled = true
this.updatingPackages.push(pack)
}))
this.disposables.add(
this.packageManager.on('package-updated theme-updated package-update-failed theme-update-failed', ({pack, error}) => {
if (error != null) {
this.refs.updateErrors.appendChild(new ErrorView(this.packageManager, error).element)
}
for (let i = 0; i < this.updatingPackages.length; i++) {
const update = this.updatingPackages[i]
if (update.name === pack.name) {
this.updatingPackages.splice(i, 1)
}
}
if (!this.updatingPackages.length) {
this.refs.checkButton.disabled = false
}
})
)
}
destroy () {
this.clearPackageCards()
this.disposables.dispose()
return etch.destroy(this)
}
update () {}
render () {
return (
<div tabIndex='0' className='panels-item'>
<section className='section packages'>
<div className='section-container updates-container'>
<div className='updates-heading-container'>
<h1 className='section-heading icon icon-cloud-download'>Available Updates</h1>
<div className='section-heading updates-btn-group'>
<button
ref='checkButton'
className='update-all-button btn'
onclick={() => { this.checkForUpdates(true) }}>Check for Updates</button>
<button
ref='updateAllButton'
className='update-all-button btn btn-primary'
onclick={() => { this.updateAll() }}>Update All</button>
</div>
</div>
<div ref='versionPinnedPackagesMessage' className='alert alert-warning icon icon-alert'>The following packages are pinned to their current version and are not being checked for updates: <strong>{ this.packageManager.getVersionPinnedPackages().join(', ') }</strong></div>
<div ref='updateErrors' />
<div ref='checkingMessage' className='alert alert-info icon icon-hourglass'>{`Checking for updates\u2026`}</div>
<div ref='noUpdatesMessage' className='alert alert-info icon icon-heart'>All of your installed packages are up to date!</div>
<div ref='updatesContainer' className='container package-container' />
</div>
</section>
</div>
)
}
focus () {
this.element.focus()
}
show () {
this.element.style.display = ''
}
beforeShow (opts) {
if (opts && opts.back) {
this.refs.breadcrumb.textContent = opts.back
this.refs.breadcrumb.onclick = () => { this.settingsView.showPanel(opts.back) }
}
if (opts && opts.updates) {
this.availableUpdates = opts.updates
this.addUpdateViews()
} else {
this.availableUpdates = []
this.clearPackageCards()
this.checkForUpdates()
}
if (this.packageManager.getVersionPinnedPackages().length === 0) {
this.refs.versionPinnedPackagesMessage.style.display = 'none'
}
}
// Check for updates and display them
async checkForUpdates (clearCache) {
this.refs.noUpdatesMessage.style.display = 'none'
this.refs.updateAllButton.disabled = true
this.refs.checkButton.disabled = true
this.refs.checkingMessage.style.display = ''
try {
this.availableUpdates = await this.packageManager.getOutdated(clearCache)
this.refs.checkButton.disabled = false
this.addUpdateViews()
} catch (error) {
this.refs.checkButton.disabled = false
this.refs.checkingMessage.style.display = 'none'
this.refs.updateErrors.appendChild(new ErrorView(this.packageManager, error).element)
}
}
addUpdateViews () {
if (this.availableUpdates.length > 0) {
this.refs.updateAllButton.style.display = ''
this.refs.updateAllButton.disabled = false
}
this.refs.checkingMessage.style.display = 'none'
this.clearPackageCards()
if (this.availableUpdates.length === 0) {
this.refs.noUpdatesMessage.style.display = ''
}
for (const pack of this.availableUpdates) {
const packageCard = new PackageCard(pack, this.settingsView, this.packageManager, {back: 'Updates'})
this.refs.updatesContainer.appendChild(packageCard.element)
this.packageCards.push(packageCard)
}
}
async updateAll () {
this.refs.checkButton.disabled = true
this.refs.updateAllButton.disabled = true
let updatingPackages = this.updatingPackages
let successfulUpdatesCount = 0
let failedUpdatesCount = 0
const concurrency = atom.config.get('settings-view.packageUpdateConcurrency') > 0
? atom.config.get('settings-view.packageUpdateConcurrency')
: Number.POSITIVE_INFINITY
const queue = asyncQueue(function (packageCard, callback) {
const onUpdateCompleted = function (err) {
err == null ? successfulUpdatesCount++ : failedUpdatesCount++
}
if (updatingPackages.includes(packageCard.pack)) {
callback()
} else {
packageCard.update().then(onUpdateCompleted, onUpdateCompleted).then(callback)
}
}, concurrency)
queue.push(this.packageCards)
await queue.drain()
if (successfulUpdatesCount > 0) {
const message = `Restart Atom to complete the update of ${successfulUpdatesCount} ${pluralize('package', successfulUpdatesCount)}:`
let detail = ''
this.packageCards.forEach((card) => {
let oldVersion = ''
let newVersion = ''
if (card.pack.apmInstallSource && card.pack.apmInstallSource.type === 'git') {
oldVersion = card.pack.apmInstallSource.sha.substr(0, 8)
newVersion = `${card.pack.latestSha.substr(0, 8)}`
} else if (card.pack.version && card.pack.latestVersion) {
oldVersion = card.pack.version
newVersion = card.pack.latestVersion
}
if (oldVersion && newVersion) {
detail += `${card.pack.name}@${oldVersion} -> ${newVersion}\n`
}
})
detail = detail.trim()
const notification = atom.notifications.addSuccess(message, {
dismissable: true,
buttons: [{
text: 'Restart now',
onDidClick () { return atom.restartApplication() }
},
{
text: 'I\'ll do it later',
onDidClick () { notification.dismiss() }
}],
detail
})
}
if (failedUpdatesCount === 0) {
this.refs.checkButton.disabled = false
this.refs.updateAllButton.style.display = 'none'
} else {
this.refs.checkButton.disabled = false
this.refs.updateAllButton.disabled = false
}
}
clearPackageCards () {
while (this.packageCards.length) {
this.packageCards.pop().destroy()
}
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}
function pluralize (word, count) {
return (count > 1) ? `${word}s` : word
}

View File

@ -0,0 +1,210 @@
/** @babel */
/** @jsx etch.dom */
import {CompositeDisposable} from 'atom'
import etch from 'etch'
function isSupported () {
return ['win32', 'darwin'].includes(process.platform)
}
function isDefaultProtocolClient () {
return require('electron').remote.app.isDefaultProtocolClient('atom', process.execPath, ['--uri-handler', '--'])
}
function setAsDefaultProtocolClient () {
// This Electron API is only available on Windows and macOS. There might be some
// hacks to make it work on Linux; see https://github.com/electron/electron/issues/6440
return isSupported() && require('electron').remote.app.setAsDefaultProtocolClient('atom', process.execPath, ['--uri-handler', '--'])
}
export default class UriHandlerPanel {
constructor () {
this.handleChange = this.handleChange.bind(this)
this.handleBecomeProtocolClient = this.handleBecomeProtocolClient.bind(this)
this.isDefaultProtocolClient = isDefaultProtocolClient()
this.uriHistory = []
etch.initialize(this)
this.subscriptions = new CompositeDisposable()
this.subscriptions.add(
atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}),
atom.uriHandlerRegistry.onHistoryChange(() => {
this.uriHistory = atom.uriHandlerRegistry.getRecentlyHandledURIs()
etch.update(this)
})
)
}
destroy () {
this.subscriptions.dispose()
return etch.destroy(this)
}
update () {}
render () {
const schema = atom.config.getSchema('core.uriHandlerRegistration')
return (
<div className='panels-item' tabIndex='0'>
<form className='general-panel section'>
<div className='settings-panel'>
<div className='section-container'>
<div className='block section-heading icon icon-device-desktop'>URI Handling</div>
<div className='text icon icon-question'>These settings determine how Atom handles atom:// URIs.</div>
<div className='section-body'>
<div className='control-group'>
<div className='controls'>
<label className='control-label'>
<div className='setting-title'>URI Handler Registration</div>
<div className='setting-description'>
{this.renderRegistrationDescription()}
</div>
</label>
<button
className='btn btn-primary'
disabled={!isSupported() || this.isDefaultProtocolClient}
style={{fontSize: '1.25em', display: 'block'}}
onClick={this.handleBecomeProtocolClient}
>
Register as default atom:// protocol handler
</button>
</div>
</div>
<div className='control-group'>
<div className='controls'>
<label className='control-label'>
<div className='setting-title'>Default Registration</div>
<div className='setting-description'>
{schema.description}
</div>
</label>
<select
id='core.uriHandlerRegistration'
className='form-control'
onChange={this.handleChange}
value={atom.config.get('core.uriHandlerRegistration')}
>
{schema.enum.map(({description, value}) => (
<option value={value}>{description}</option>
))}
</select>
</div>
</div>
<div className='control-group'>
<div className='controls'>
<label className='controls-label'>
<div className='setting-title'>Recent URIs</div>
</label>
<table className='uri-history'>
<tr>
<th>URI</th>
<th>Handled By</th>
</tr>
{this.uriHistory.map(this.renderHistoryRow.bind(this))}
</table>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
)
}
renderHistoryRow (item, idx) {
return (
<tr
key={item.id}
className=''
>
<td>{item.uri}</td>
<td>
{item.handled
? this.renderItem(item)
: <em>not handled</em>
}</td>
</tr>
)
}
renderItem (item) {
if (item.host === 'core') {
return <em>core</em>
} else {
return <a href={`atom://config/packages/${item.host}`} onClick={this.handlePackageLinkClicked}>{item.host}</a>
}
}
handlePackageLinkClicked (evt) {
evt.preventDefault()
atom.workspace.open(evt.target.getAttribute('href'))
}
renderRegistrationDescription () {
if (this.isDefaultProtocolClient) {
return 'Atom is already the default handler for atom:// URIs.'
} else if (isSupported()) {
return 'Register Atom as the default handler for atom:// URIs.'
} else {
return 'Registration as the default handler for atom:// URIs is only supported on Windows and macOS.'
}
}
handleChange (evt) {
atom.config.set('core.uriHandlerRegistration', evt.target.value)
}
handleBecomeProtocolClient (evt) {
evt.preventDefault()
if (setAsDefaultProtocolClient()) {
this.isDefaultProtocolClient = isDefaultProtocolClient()
etch.update(this)
} else {
atom.notifications.addError('Could not become default protocol client')
}
}
focus () {
this.element.focus()
}
show () {
this.element.style.display = ''
}
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}
scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}
pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}
pageDown () {
this.element.scrollTop += this.element.offsetHeight
}
scrollToTop () {
this.element.scrollTop = 0
}
scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}

View File

@ -0,0 +1,40 @@
const ownerFromRepository = repository => {
if (!repository) return ''
const loginRegex = /github\.com\/([\w-]+)\/.+/
let repo = repository
if (typeof repository !== 'string') {
repo = repository.url
if (repo.match('git@github')) {
const repoName = repo.split(':')[1]
repo = `https://github.com/${repoName}`
}
}
if (!repo.match('github.com/')) {
repo = `https://github.com/${repo}`
}
const match = repo.match(loginRegex)
return match ? match[1] : ''
}
const packageComparatorAscending = (left, right) => {
const leftStatus = atom.packages.isPackageDisabled(left.name)
const rightStatus = atom.packages.isPackageDisabled(right.name)
if (leftStatus === rightStatus) {
if (left.name > right.name) {
return -1
} else if (left.name < right.name) {
return 1
} else {
return 0
}
} else if (leftStatus > rightStatus) {
return -1
} else {
return 1
}
}
module.exports = {ownerFromRepository, packageComparatorAscending}

View File

@ -0,0 +1,16 @@
'menu': [
{
'label': 'Packages'
'submenu': [
'label': 'Settings View'
'submenu': [
{ 'label': 'Open', 'command': 'settings-view:open' }
{ 'label': 'Show Keybindings', 'command': 'settings-view:show-keybindings' }
{ 'label': 'Install Packages/Themes', 'command': 'settings-view:install-packages-and-themes' }
{ 'label': 'Update Packages/Themes', 'command': 'settings-view:check-for-package-updates' }
{ 'label': 'Manage Packages', 'command': 'settings-view:uninstall-packages' }
{ 'label': 'Manage Themes', 'command': 'settings-view:uninstall-themes' }
]
]
}
]

1529
packages/settings-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
{
"name": "settings-view",
"version": "0.261.11",
"main": "./lib/main",
"description": "Edit config settings, install packages, and change themes",
"license": "MIT",
"uriHandler": {
"method": "handleURI",
"deferActivation": false
},
"configSchema": {
"packageUpdateConcurrency": {
"title": "Maximum simultaneous package updates",
"description": "Limit how many processes run simultaneously during package updates. If your machine slows down while updating many packages at once, set this value to a small positive number (e.g., `1` or `2`).",
"type": "integer",
"default": -1
}
},
"dependencies": {
"async": "^3.2.0",
"dompurify": "^2.0.17",
"etch": "0.9.0",
"fs-plus": "^3.0.0",
"fuzzaldrin": "^2.1",
"glob": "4.3.1",
"hosted-git-info": "^2.8.9",
"marked": "^4.0.10",
"request": "^2.83.0",
"season": "^6.0.2",
"semver": "^5.3.0",
"underscore-plus": "^1.0.6"
},
"repository": "https://github.com/pulsar-edit/pulsar",
"engines": {
"atom": "*"
},
"consumedServices": {
"status-bar": {
"versions": {
"^1.0.0": "consumeStatusBar"
}
},
"snippets": {
"versions": {
"0.1.0": "consumeSnippets"
}
}
},
"deserializers": {
"SettingsView": "createSettingsView"
}
}

View File

@ -0,0 +1,53 @@
AtomIoClient = require '../lib/atom-io-client'
describe "AtomIoClient", ->
beforeEach ->
@client = new AtomIoClient
it "fetches avatar from cache if the network is unavailable", ->
spyOn(@client, 'online').andReturn(false)
spyOn(@client, 'fetchAndCacheAvatar')
expect(@client.fetchAndCacheAvatar).not.toHaveBeenCalled()
@client.avatar 'test-user', ->
describe "request", ->
it "fetches api json from cache if the network is unavailable", ->
spyOn(@client, 'online').andReturn(false)
spyOn(@client, 'fetchFromCache').andReturn({})
spyOn(@client, 'request')
@client.package 'test-package', ->
expect(@client.fetchFromCache).toHaveBeenCalled()
expect(@client.request).not.toHaveBeenCalled()
it "returns an error if the API response is not JSON", ->
jsonParse = JSON.parse
waitsFor (done) ->
spyOn(@client, 'parseJSON').andThrow()
@client.request 'path', (error, data) ->
expect(error).not.toBeNull()
done()
runs ->
# Tests will throw without this as cleanup requires JSON.parse to work
JSON.parse = jsonParse
it "handles glob errors", ->
spyOn(@client, 'avatarGlob').andReturn "#{__dirname}/**"
spyOn(require('fs'), 'readdir').andCallFake (dirPath, callback) ->
process.nextTick -> callback(new Error('readdir error'))
callback = jasmine.createSpy('cacheAvatar callback')
@client.cachedAvatar 'fakeperson', callback
waitsFor ->
callback.callCount is 1
runs ->
expect(callback.argsForCall[0][0].message).toBe 'readdir error'
xit "purges old items from cache correctly"
# "correctly" in this case means "remove all old items but one" so that we
# always have stale data to return if the network is gone.

View File

@ -0,0 +1,110 @@
EditorPanel = require '../lib/editor-panel'
describe "EditorPanel", ->
panel = null
getValueForId = (id) ->
element = panel.element.querySelector("##{id.replace(/\./g, '\\.')}")
if element?.tagName is "INPUT"
element.checked
else if element?.tagName is "SELECT"
element.value
else if element?
element.getModel().getText()
else
return
setValueForId = (id, value) ->
element = panel.element.querySelector("##{id.replace(/\./g, '\\.')}")
if element.tagName is "INPUT"
element.checked = value
element.dispatchEvent(new Event('change', {bubbles: true}))
else if element.tagName is "SELECT"
element.value = value
element.dispatchEvent(new Event('change', {bubbles: true}))
else
element.getModel().setText(value?.toString())
window.advanceClock(10000) # wait for contents-modified to be triggered
beforeEach ->
atom.config.set('editor.boolean', true)
atom.config.set('editor.string', 'hey')
atom.config.set('editor.object', {boolean: true, int: 3, string: 'test'})
atom.config.set('editor.simpleArray', ['a', 'b', 'c'])
atom.config.set('editor.complexArray', ['a', 'b', {c: true}])
atom.config.setSchema('', type: 'object')
panel = new EditorPanel()
it "automatically binds named fields to their corresponding config keys", ->
expect(getValueForId('editor.boolean')).toBeTruthy()
expect(getValueForId('editor.string')).toBe 'hey'
expect(getValueForId('editor.object.boolean')).toBeTruthy()
expect(getValueForId('editor.object.int')).toBe '3'
expect(getValueForId('editor.object.string')).toBe 'test'
atom.config.set('editor.boolean', false)
atom.config.set('editor.string', 'hey again')
atom.config.set('editor.object.boolean', false)
atom.config.set('editor.object.int', 6)
atom.config.set('editor.object.string', 'hi')
expect(getValueForId('editor.boolean')).toBeFalsy()
expect(getValueForId('editor.string')).toBe 'hey again'
expect(getValueForId('editor.object.boolean')).toBeFalsy()
expect(getValueForId('editor.object.int')).toBe '6'
expect(getValueForId('editor.object.string')).toBe 'hi'
setValueForId('editor.string', "oh hi")
setValueForId('editor.boolean', true)
setValueForId('editor.object.boolean', true)
setValueForId('editor.object.int', 9)
setValueForId('editor.object.string', 'yo')
expect(atom.config.get('editor.boolean')).toBe true
expect(atom.config.get('editor.string')).toBe 'oh hi'
expect(atom.config.get('editor.object.boolean')).toBe true
expect(atom.config.get('editor.object.int')).toBe 9
expect(atom.config.get('editor.object.string')).toBe 'yo'
setValueForId('editor.string', '')
setValueForId('editor.object.int', '')
setValueForId('editor.object.string', '')
expect(atom.config.get('editor.string')).toBeUndefined()
expect(atom.config.get('editor.object.int')).toBeUndefined()
expect(atom.config.get('editor.object.string')).toBeUndefined()
it "does not save the config value until it has been changed to a new value", ->
observeHandler = jasmine.createSpy("observeHandler")
atom.config.observe "editor.simpleArray", observeHandler
observeHandler.reset()
window.advanceClock(10000) # wait for contents-modified to be triggered
expect(observeHandler).not.toHaveBeenCalled()
setValueForId('editor.simpleArray', 2)
expect(observeHandler).toHaveBeenCalled()
observeHandler.reset()
setValueForId('editor.simpleArray', 2)
expect(observeHandler).not.toHaveBeenCalled()
it "does not update the editor text unless the value it parses to changes", ->
setValueForId('editor.simpleArray', "a, b,")
expect(atom.config.get('editor.simpleArray')).toEqual ['a', 'b']
expect(getValueForId('editor.simpleArray')).toBe 'a, b,'
it "only adds editors for arrays when all the values in the array are strings", ->
expect(getValueForId('editor.simpleArray')).toBe 'a, b, c'
expect(getValueForId('editor.complexArray')).toBeUndefined()
setValueForId('editor.simpleArray', 'a, d')
expect(atom.config.get('editor.simpleArray')).toEqual ['a', 'd']
expect(atom.config.get('editor.complexArray')).toEqual ['a', 'b', {c: true}]
it "shows the package settings notes for core and editor settings", ->
expect(panel.element.querySelector('#editor-settings-note')).toExist()
expect(panel.element.querySelector('#editor-settings-note').textContent).toContain('Check language settings')

View File

@ -0,0 +1,5 @@
{
"theme": "syntax",
"name": "a-theme",
"version": "1.0.0"
}

View File

@ -0,0 +1,32 @@
{
"core": [
{
"name": "core-theme",
"theme": "syntax"
},
{
"name": "core-package",
"version": "1.0.0"
}
],
"user": [
{
"name": "user-theme",
"theme": "syntax"
},
{
"name": "user-package",
"version": "1.0.0"
}
],
"dev": [
{
"name": "dev-theme",
"theme": "syntax"
},
{
"name": "dev-package",
"version": "1.0.0"
}
]
}

View File

@ -0,0 +1,9 @@
{
"scopeName": "source.a",
"fileTypes": [
".a",
".aa",
"a"
],
"name": "A Grammar"
}

View File

@ -0,0 +1,4 @@
{
"name": "B Grammar",
"scopeName": "source.b"
}

View File

@ -0,0 +1,3 @@
{
"scopeName": "source.c"
}

View File

@ -0,0 +1,4 @@
'.platform-n64':
'cmd-g': 'language-test:run'
'test':
'cmd-g': 'language-test:run'

View File

@ -0,0 +1,5 @@
{
"name": "language-test",
"version": "1.0.0",
"repository": "https://github.com/example/language-test"
}

View File

@ -0,0 +1,8 @@
{
".source.b": {
"BAR": {
"prefix": "b",
"body": "bar?\nline two"
}
}
}

View File

@ -0,0 +1,8 @@
{
".source.a": {
"FOO": {
"prefix": "f",
"body": "foo!"
}
}
}

View File

@ -0,0 +1,4 @@
{
"name": "package-internal",
"repository": "https://github.com/atom/atom"
}

View File

@ -0,0 +1,7 @@
{
"name": "package-with-bugs-property-email",
"version": "1.0.0",
"repository": "https://github.com/example/package-with-bugs-property-email",
"bugs": {"email": "issues@example.com"},
"main": "main"
}

View File

@ -0,0 +1,7 @@
{
"name": "package-with-bugs-property-url-string",
"version": "1.0.0",
"repository": "https://github.com/example/package-with-bugs-property-url-string",
"bugs": "https://example.com/custom-issue-tracker/new",
"main": "main"
}

View File

@ -0,0 +1,7 @@
{
"name": "package-with-bugs-property-url",
"version": "1.0.0",
"repository": "https://github.com/example/package-with-bugs-property-url",
"bugs": {"url": "https://example.com/custom-issue-tracker/new", "email": "issues@example.com"},
"main": "main"
}

View File

@ -0,0 +1,5 @@
module.exports =
config:
setting:
type: 'string'
default: 'something'

View File

@ -0,0 +1,6 @@
{
"name": "package-with-config",
"version": "1.0.0",
"repository": "https://github.com/example/package-with-config",
"main": "main"
}

View File

@ -0,0 +1,12 @@
I am a Readme!
* [ ] I'm a not completed task
* [x] I'm completed
![AbsoluteImage](https://example.com/static/image.jpg)
![RelativeImage](static/image.jpg)
![Base64Image](data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==)
<script>alert('oh, hai');</script>
<iframe src="https://pulsar-edit.dev"></iframe>

View File

@ -0,0 +1,6 @@
{
"name": "package-with-readme",
"version": "1.0.0",
"repository": "https://github.com/example/package-with-readme",
"main": "main"
}

View File

@ -0,0 +1,6 @@
{
"name": "package-without-bugs-property",
"version": "1.0.0",
"repository": "https://github.com/example/package-without-bugs-property",
"main": "main"
}

View File

@ -0,0 +1,5 @@
module.exports =
config:
setting:
type: 'string'
default: 'something'

View File

@ -0,0 +1,6 @@
{
"theme": "syntax",
"name": "syntax-theme-with-config",
"version": "1.0.0",
"main": "./main"
}

View File

@ -0,0 +1,5 @@
module.exports =
config:
setting:
type: 'string'
default: 'something'

View File

@ -0,0 +1,6 @@
{
"theme": "ui",
"name": "ui-theme-with-config",
"version": "1.0.0",
"main": "./main"
}

View File

@ -0,0 +1,90 @@
GeneralPanel = require '../lib/general-panel'
describe "GeneralPanel", ->
panel = null
getValueForId = (id) ->
element = panel.element.querySelector("##{id.replace(/\./g, '\\.')}")
if element.tagName is "INPUT"
element.checked
else if element.tagName is "SELECT"
element.value
else
element.getModel().getText()
setValueForId = (id, value) ->
element = panel.element.querySelector("##{id.replace(/\./g, '\\.')}")
if element.tagName is "INPUT"
element.checked = value
element.dispatchEvent(new Event('change', {bubbles: true}))
else if element.tagName is "SELECT"
element.value = value
element.dispatchEvent(new Event('change', {bubbles: true}))
else
element.getModel().setText(value?.toString())
window.advanceClock(10000) # wait for contents-modified to be triggered
beforeEach ->
atom.config.set('core.enum', 4)
atom.config.set('core.int', 22)
atom.config.set('core.float', 0.1)
atom.config.setSchema('', type: 'object')
atom.config.setSchema('core.enum',
type: 'integer'
default: 2
enum: [2, 4, 6, 8]
)
panel = new GeneralPanel()
it "automatically binds named fields to their corresponding config keys", ->
expect(getValueForId('core.enum')).toBe '4'
expect(getValueForId('core.int')).toBe '22'
expect(getValueForId('core.float')).toBe '0.1'
atom.config.set('core.enum', 6)
atom.config.set('core.int', 222)
atom.config.set('core.float', 0.11)
expect(getValueForId('core.enum')).toBe '6'
expect(getValueForId('core.int')).toBe '222'
expect(getValueForId('core.float')).toBe '0.11'
setValueForId('core.enum', '2')
setValueForId('core.int', 90)
setValueForId('core.float', 89.2)
expect(atom.config.get('core.enum')).toBe 2
expect(atom.config.get('core.int')).toBe 90
expect(atom.config.get('core.float')).toBe 89.2
setValueForId('core.int', '')
setValueForId('core.float', '')
expect(atom.config.get('core.int')).toBeUndefined()
expect(atom.config.get('core.float')).toBeUndefined()
it "does not save the config value until it has been changed to a new value", ->
observeHandler = jasmine.createSpy("observeHandler")
atom.config.observe "core.int", observeHandler
observeHandler.reset()
window.advanceClock(10000) # wait for contents-modified to be triggered
expect(observeHandler).not.toHaveBeenCalled()
setValueForId('core.int', 2)
expect(observeHandler).toHaveBeenCalled()
observeHandler.reset()
setValueForId('core.int', 2)
expect(observeHandler).not.toHaveBeenCalled()
it "does not update the editor text unless the value it parses to changes", ->
setValueForId('core.int', "2.")
expect(atom.config.get('core.int')).toBe 2
expect(getValueForId('core.int')).toBe '2.'
it "shows the package settings notes for core and editor settings", ->
expect(panel.element.querySelector('#core-settings-note')).toExist()
expect(panel.element.querySelector('#core-settings-note').textContent).toContain('their package card in')

View File

@ -0,0 +1,113 @@
InstallPanel = require '../lib/install-panel'
PackageManager = require '../lib/package-manager'
SettingsView = require '../lib/settings-view'
describe 'InstallPanel', ->
beforeEach ->
settingsView = new SettingsView()
@packageManager = new PackageManager()
@panel = new InstallPanel(settingsView, @packageManager)
describe "when the packages button is clicked", ->
beforeEach ->
spyOn(@panel, 'search')
@panel.refs.searchEditor.setText('something')
it "performs a search for the contents of the input", ->
@panel.refs.searchPackagesButton.click()
expect(@panel.searchType).toBe 'packages'
expect(@panel.search).toHaveBeenCalledWith 'something'
expect(@panel.search.callCount).toBe 1
@panel.refs.searchPackagesButton.click()
expect(@panel.searchType).toBe 'packages'
expect(@panel.search).toHaveBeenCalledWith 'something'
expect(@panel.search.callCount).toBe 2
describe "when the themes button is clicked", ->
beforeEach ->
spyOn(@panel, 'search')
@panel.refs.searchEditor.setText('something')
it "performs a search for the contents of the input", ->
@panel.refs.searchThemesButton.click()
expect(@panel.searchType).toBe 'themes'
expect(@panel.search.callCount).toBe 1
expect(@panel.search).toHaveBeenCalledWith 'something'
@panel.refs.searchThemesButton.click()
expect(@panel.searchType).toBe 'themes'
expect(@panel.search.callCount).toBe 2
describe "when the buttons are toggled", ->
beforeEach ->
spyOn(@panel, 'search')
@panel.refs.searchEditor.setText('something')
it "performs a search for the contents of the input", ->
@panel.refs.searchThemesButton.click()
expect(@panel.searchType).toBe 'themes'
expect(@panel.search.callCount).toBe 1
expect(@panel.search).toHaveBeenCalledWith 'something'
@panel.refs.searchPackagesButton.click()
expect(@panel.searchType).toBe 'packages'
expect(@panel.search.callCount).toBe 2
@panel.refs.searchThemesButton.click()
expect(@panel.searchType).toBe 'themes'
expect(@panel.search.callCount).toBe 3
describe "searching packages", ->
it "displays the packages in the order returned", ->
spyOn(@panel.client, 'search').andCallFake -> Promise.resolve([{name: 'not-first'}, {name: 'first'}])
spyOn(@panel, 'getPackageCardView').andCallThrough()
waitsForPromise =>
@panel.search('first')
runs ->
expect(@panel.getPackageCardView.argsForCall[0][0].name).toEqual 'not-first'
expect(@panel.getPackageCardView.argsForCall[1][0].name).toEqual 'first'
describe "searching git packages", ->
beforeEach ->
spyOn(@panel, 'showGitInstallPackageCard').andCallThrough()
it "shows a git installation card with git specific info for ssh URLs", ->
query = 'git@github.com:user/repo.git'
@panel.performSearchForQuery(query)
args = @panel.showGitInstallPackageCard.argsForCall[0][0]
expect(args.name).toEqual query
expect(args.gitUrlInfo).toBeTruthy()
it "shows a git installation card with git specific info for https URLs", ->
query = 'https://github.com/user/repo.git'
@panel.performSearchForQuery(query)
args = @panel.showGitInstallPackageCard.argsForCall[0][0]
expect(args.name).toEqual query
expect(args.gitUrlInfo).toBeTruthy()
it "shows a git installation card with git specific info for shortcut URLs", ->
query = 'user/repo'
@panel.performSearchForQuery(query)
args = @panel.showGitInstallPackageCard.argsForCall[0][0]
expect(args.name).toEqual query
expect(args.gitUrlInfo).toBeTruthy()
it "doesn't show a git installation card for normal packages", ->
query = 'this-package-is-so-normal'
@panel.performSearchForQuery(query)
expect(@panel.showGitInstallPackageCard).not.toHaveBeenCalled()
describe "when a package with the same gitUrlInfo property is installed", ->
beforeEach ->
@gitUrlInfo = jasmine.createSpy('gitUrlInfo')
@panel.showGitInstallPackageCard(gitUrlInfo: @gitUrlInfo)
it "replaces the package card with the newly installed pack object", ->
newPack =
gitUrlInfo: @gitUrlInfo
spyOn(@panel, 'updateGitPackageCard')
@packageManager.emitter.emit('package-installed', {pack: newPack})
expect(@panel.updateGitPackageCard).toHaveBeenCalledWith newPack

View File

@ -0,0 +1,338 @@
path = require 'path'
PackageDetailView = require '../lib/package-detail-view'
PackageManager = require '../lib/package-manager'
SettingsView = require '../lib/settings-view'
PackageKeymapView = require '../lib/package-keymap-view'
PackageSnippetsView = require '../lib/package-snippets-view'
_ = require 'underscore-plus'
SnippetsProvider =
getSnippets: -> atom.config.scopedSettingsStore.propertySets
describe "InstalledPackageView", ->
beforeEach ->
spyOn(PackageManager.prototype, 'loadCompatiblePackageVersion').andCallFake ->
it "displays the grammars registered by the package", ->
settingsPanels = null
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
runs ->
pack = atom.packages.getActivePackage('language-test')
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
settingsPanels = view.element.querySelectorAll('.package-grammars .settings-panel')
waitsFor ->
children = Array.from(settingsPanels).map((s) -> s.children.length)
childrenCount = children.reduce(((a, b) -> a + b), 0)
childrenCount is 2
expect(settingsPanels[0].querySelector('.grammar-scope').textContent).toBe 'Scope: source.a'
expect(settingsPanels[0].querySelector('.grammar-filetypes').textContent).toBe 'File Types: .a, .aa, a'
expect(settingsPanels[1].querySelector('.grammar-scope').textContent).toBe 'Scope: source.b'
expect(settingsPanels[1].querySelector('.grammar-filetypes').textContent).toBe 'File Types: '
expect(settingsPanels[2]).toBeUndefined()
it "displays the snippets registered by the package", ->
snippetsTable = null
snippetsModule = null
# Relies on behavior not present in the snippets package before 1.33.
# TODO: These tests should always run once 1.33 is released.
shouldRunScopeTest = parseFloat(atom.getVersion()) >= 1.33
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
waitsForPromise ->
atom.packages.activatePackage('snippets').then (p) ->
snippetsModule = p.mainModule
return unless snippetsModule.provideSnippets().getUnparsedSnippets?
SnippetsProvider =
getSnippets: -> snippetsModule.provideSnippets().getUnparsedSnippets()
waitsFor 'snippets to load', -> snippetsModule.provideSnippets().bundledSnippetsLoaded()
runs ->
pack = atom.packages.getActivePackage('language-test')
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
snippetsTable = view.element.querySelector('.package-snippets-table tbody')
waitsFor 'snippets table children to contain 2 items', ->
snippetsTable.children.length >= 2
runs ->
expect(snippetsTable.querySelector('tr:nth-child(1) td:nth-child(1)').textContent).toBe 'b'
expect(snippetsTable.querySelector('tr:nth-child(1) td:nth-child(2)').textContent).toBe 'BAR'
expect(snippetsTable.querySelector('tr:nth-child(1) td.snippet-scope-name').textContent).toBe '.b.source' if shouldRunScopeTest
expect(snippetsTable.querySelector('tr:nth-child(2) td:nth-child(1)').textContent).toBe 'f'
expect(snippetsTable.querySelector('tr:nth-child(2) td:nth-child(2)').textContent).toBe 'FOO'
expect(snippetsTable.querySelector('tr:nth-child(2) td.snippet-scope-name').textContent).toBe '.a.source' if shouldRunScopeTest
describe "when a snippet body is viewed", ->
it "shows a tooltip", ->
tooltipCalls = []
view = null
snippetsTable = null
snippetsModule = null
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
waitsForPromise ->
atom.packages.activatePackage('snippets').then (p) ->
snippetsModule = p.mainModule
return unless snippetsModule.provideSnippets().getUnparsedSnippets?
SnippetsProvider =
getSnippets: -> snippetsModule.provideSnippets().getUnparsedSnippets()
waitsFor 'snippets to load', -> snippetsModule.provideSnippets().bundledSnippetsLoaded()
runs ->
pack = atom.packages.getActivePackage('language-test')
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
snippetsTable = view.element.querySelector('.package-snippets-table tbody')
waitsFor 'snippets table children to contain 2 items', ->
snippetsTable.children.length >= 2
runs ->
expect(view.element.ownerDocument.querySelector('.snippet-body-tooltip')).not.toExist()
view.element.querySelector('.package-snippets-table tbody tr:nth-child(1) td.snippet-body .snippet-view-btn').click()
expect(view.element.ownerDocument.querySelector('.snippet-body-tooltip')).toExist()
# Relies on behavior not present in the snippets package before 1.33.
# TODO: These tests should always run once 1.33 is released.
if parseFloat(atom.getVersion()) >= 1.33
describe "when a snippet is copied", ->
[pack, card] = []
snippetsTable = null
snippetsModule = null
beforeEach ->
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
waitsForPromise ->
atom.packages.activatePackage('snippets').then (p) ->
snippetsModule = p.mainModule
return unless snippetsModule.provideSnippets().getUnparsedSnippets?
SnippetsProvider =
getSnippets: -> snippetsModule.provideSnippets().getUnparsedSnippets()
getUserSnippetsPath: snippetsModule.getUserSnippetsPath()
waitsFor 'snippets to load', -> snippetsModule.provideSnippets().bundledSnippetsLoaded()
runs ->
pack = atom.packages.getActivePackage('language-test')
card = new PackageSnippetsView(pack, SnippetsProvider)
snippetsTable = card.element.querySelector('.package-snippets-table tbody')
waitsFor 'snippets table children to contain 2 items', ->
snippetsTable.children.length >= 2
describe "when the snippets file ends in .cson", ->
it "writes a CSON snippet to the clipboard", ->
spyOn(SnippetsProvider, 'getUserSnippetsPath').andReturn('snippets.cson')
card.element.querySelector('.package-snippets-table tbody tr:nth-child(1) td.snippet-body .snippet-copy-btn').click()
expect(atom.clipboard.read()).toBe """
\n'.b.source':
'BAR':
'prefix': 'b'
'body': 'bar?\\nline two'\n
"""
describe "when the snippets file ends in .json", ->
it "writes a JSON snippet to the clipboard", ->
spyOn(SnippetsProvider, 'getUserSnippetsPath').andReturn('snippets.json')
card.element.querySelector('.package-snippets-table tbody tr:nth-child(1) td.snippet-body .btn:nth-child(2)').click()
expect(atom.clipboard.read()).toBe """
\n ".b.source": {
"BAR": {
"prefix": "b",
"body": "bar?\\nline two"
}
}\n
"""
describe "when the snippets toggle is clicked", ->
it "sets the packagesWithSnippetsDisabled config to include the package name", ->
[pack, card] = []
snippetsModule = []
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
waitsForPromise ->
atom.packages.activatePackage('snippets').then (p) ->
snippetsModule = p.mainModule
return unless snippetsModule.provideSnippets().getUnparsedSnippets?
SnippetsProvider =
getSnippets: -> snippetsModule.provideSnippets().getUnparsedSnippets()
waitsFor 'snippets to load', -> snippetsModule.provideSnippets().bundledSnippetsLoaded()
runs ->
pack = atom.packages.getActivePackage('language-test')
card = new PackageSnippetsView(pack, SnippetsProvider)
jasmine.attachToDOM(card.element)
card.refs.snippetToggle.click()
expect(card.refs.snippetToggle.checked).toBe false
expect(_.include(atom.config.get('core.packagesWithSnippetsDisabled') ? [], 'language-test')).toBe true
waitsFor 'snippets table to update', ->
card.refs.snippets.classList.contains('text-subtle')
runs ->
card.refs.snippetToggle.click()
expect(card.refs.snippetToggle.checked).toBe true
expect(_.include(atom.config.get('core.packagesWithSnippetsDisabled') ? [], 'language-test')).toBe false
waitsFor 'snippets table to update', ->
not card.refs.snippets.classList.contains('text-subtle')
it "does not display keybindings from other platforms", ->
keybindingsTable = null
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
runs ->
pack = atom.packages.getActivePackage('language-test')
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
keybindingsTable = view.element.querySelector('.package-keymap-table tbody')
expect(keybindingsTable.children.length).toBe 1
describe "when the keybindings toggle is clicked", ->
it "sets the packagesWithKeymapsDisabled config to include the package name", ->
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
runs ->
pack = atom.packages.getActivePackage('language-test')
card = new PackageKeymapView(pack)
jasmine.attachToDOM(card.element)
card.refs.keybindingToggle.click()
expect(card.refs.keybindingToggle.checked).toBe false
expect(_.include(atom.config.get('core.packagesWithKeymapsDisabled') ? [], 'language-test')).toBe true
if atom.keymaps.build?
keybindingRows = card.element.querySelectorAll('.package-keymap-table tbody.text-subtle tr')
expect(keybindingRows.length).toBe 1
card.refs.keybindingToggle.click()
expect(card.refs.keybindingToggle.checked).toBe true
expect(_.include(atom.config.get('core.packagesWithKeymapsDisabled') ? [], 'language-test')).toBe false
if atom.keymaps.build?
keybindingRows = card.element.querySelectorAll('.package-keymap-table tbody tr')
expect(keybindingRows.length).toBe 1
describe "when a keybinding is copied", ->
[pack, card] = []
beforeEach ->
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'language-test'))
runs ->
pack = atom.packages.getActivePackage('language-test')
card = new PackageKeymapView(pack)
describe "when the keybinding file ends in .cson", ->
it "writes a CSON snippet to the clipboard", ->
spyOn(atom.keymaps, 'getUserKeymapPath').andReturn 'keymap.cson'
card.element.querySelector('.copy-icon').click()
expect(atom.clipboard.read()).toBe """
'test':
'cmd-g': 'language-test:run'
"""
describe "when the keybinding file ends in .json", ->
it "writes a JSON snippet to the clipboard", ->
spyOn(atom.keymaps, 'getUserKeymapPath').andReturn 'keymap.json'
card.element.querySelector('.copy-icon').click()
expect(atom.clipboard.read()).toBe """
"test": {
"cmd-g": "language-test:run"
}
"""
describe "when the package is active", ->
it "displays the correct enablement state", ->
packageCard = null
waitsForPromise ->
atom.packages.activatePackage('status-bar')
runs ->
expect(atom.packages.isPackageActive('status-bar')).toBe(true)
pack = atom.packages.getLoadedPackage('status-bar')
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
packageCard = view.element.querySelector('.package-card')
runs ->
# Trigger observeDisabledPackages() here
# because it is not default in specs
atom.packages.observeDisabledPackages()
atom.packages.disablePackage('status-bar')
expect(atom.packages.isPackageDisabled('status-bar')).toBe(true)
expect(packageCard.classList.contains('disabled')).toBe(true)
describe "when the package is not active", ->
it "displays the correct enablement state", ->
atom.packages.loadPackage('status-bar')
expect(atom.packages.isPackageActive('status-bar')).toBe(false)
pack = atom.packages.getLoadedPackage('status-bar')
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
packageCard = view.element.querySelector('.package-card')
# Trigger observeDisabledPackages() here
# because it is not default in specs
atom.packages.observeDisabledPackages()
atom.packages.disablePackage('status-bar')
expect(atom.packages.isPackageDisabled('status-bar')).toBe(true)
expect(packageCard.classList.contains('disabled')).toBe(true)
it "still loads the config schema for the package", ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-config'))
waitsFor ->
atom.packages.isPackageLoaded('package-with-config') is true
runs ->
expect(atom.config.get('package-with-config.setting')).toBe undefined
pack = atom.packages.getLoadedPackage('package-with-config')
new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
expect(atom.config.get('package-with-config.setting')).toBe 'something'
describe "when the package was not installed from atom.io", ->
normalizePackageDataReadmeError = 'ERROR: No README data found!'
it "still displays the Readme", ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-readme'))
waitsFor ->
atom.packages.isPackageLoaded('package-with-readme') is true
runs ->
pack = atom.packages.getLoadedPackage('package-with-readme')
expect(pack.metadata.readme).toBe normalizePackageDataReadmeError
view = new PackageDetailView(pack, new SettingsView(), new PackageManager(), SnippetsProvider)
expect(view.refs.sections.querySelector('.package-readme').textContent).not.toBe normalizePackageDataReadmeError
expect(view.refs.sections.querySelector('.package-readme').textContent.trim()).toContain 'I am a Readme!'

View File

@ -0,0 +1,245 @@
path = require 'path'
fs = require 'fs-plus'
InstalledPackagesPanel = require '../lib/installed-packages-panel'
PackageManager = require '../lib/package-manager'
PackageCard = require '../lib/package-card'
SettingsView = require '../lib/settings-view'
describe 'InstalledPackagesPanel', ->
describe 'when the packages are loading', ->
it 'filters packages by name once they have loaded', ->
settingsView = new SettingsView
@packageManager = new PackageManager
@installed = JSON.parse fs.readFileSync(path.join(__dirname, 'fixtures', 'installed.json'))
spyOn(@packageManager, 'getOutdated').andReturn new Promise ->
spyOn(@packageManager, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(@packageManager, 'getInstalled').andReturn Promise.resolve(@installed)
@panel = new InstalledPackagesPanel(settingsView, @packageManager)
@panel.refs.filterEditor.setText('user-')
window.advanceClock(@panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
waitsFor ->
@packageManager.getInstalled.callCount is 1 and @panel.refs.communityCount.textContent.indexOf('') < 0
runs ->
expect(@panel.refs.communityCount.textContent.trim()).toBe '1/1'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(@panel.refs.coreCount.textContent.trim()).toBe '0/1'
expect(@panel.refs.corePackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
expect(@panel.refs.devCount.textContent.trim()).toBe '0/1'
expect(@panel.refs.devPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
expect(@panel.refs.deprecatedCount.textContent.trim()).toBe '0/0'
expect(@panel.refs.deprecatedPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
describe 'when the packages have finished loading', ->
beforeEach ->
settingsView = new SettingsView
@packageManager = new PackageManager
@installed = JSON.parse fs.readFileSync(path.join(__dirname, 'fixtures', 'installed.json'))
spyOn(@packageManager, 'getOutdated').andReturn new Promise ->
spyOn(@packageManager, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(@packageManager, 'getInstalled').andReturn Promise.resolve(@installed)
@panel = new InstalledPackagesPanel(settingsView, @packageManager)
waitsFor ->
@packageManager.getInstalled.callCount is 1 and @panel.refs.communityCount.textContent.indexOf('') < 0
it 'shows packages', ->
expect(@panel.refs.communityCount.textContent.trim()).toBe '1'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(@panel.refs.coreCount.textContent.trim()).toBe '1'
expect(@panel.refs.corePackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(@panel.refs.devCount.textContent.trim()).toBe '1'
expect(@panel.refs.devPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(@panel.refs.deprecatedCount.textContent.trim()).toBe '0'
expect(@panel.refs.deprecatedPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
it 'filters packages by name', ->
@panel.refs.filterEditor.setText('user-')
window.advanceClock(@panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
expect(@panel.refs.communityCount.textContent.trim()).toBe '1/1'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(@panel.refs.coreCount.textContent.trim()).toBe '0/1'
expect(@panel.refs.corePackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
expect(@panel.refs.devCount.textContent.trim()).toBe '0/1'
expect(@panel.refs.devPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
expect(@panel.refs.deprecatedCount.textContent.trim()).toBe '0/0'
expect(@panel.refs.deprecatedPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
it 'adds newly installed packages to the list', ->
[installCallback] = []
spyOn(@packageManager, 'runCommand').andCallFake (args, callback) ->
installCallback = callback
onWillThrowError: ->
spyOn(atom.packages, 'activatePackage').andCallFake (name) =>
@installed.user.push {name}
expect(@panel.refs.communityCount.textContent.trim()).toBe '1'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
@packageManager.install({name: 'another-user-package'})
installCallback(0, '', '')
advanceClock InstalledPackagesPanel.loadPackagesDelay()
waits 1
runs ->
expect(@panel.refs.communityCount.textContent.trim()).toBe '2'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 2
it 'removes uninstalled packages from the list', ->
[uninstallCallback] = []
spyOn(@packageManager, 'runCommand').andCallFake (args, callback) ->
uninstallCallback = callback
onWillThrowError: ->
spyOn(@packageManager, 'unload').andCallFake (name) =>
@installed.user = []
expect(@panel.refs.communityCount.textContent.trim()).toBe '1'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
@packageManager.uninstall({name: 'user-package'})
uninstallCallback(0, '', '')
advanceClock InstalledPackagesPanel.loadPackagesDelay()
waits 1
runs ->
expect(@panel.refs.communityCount.textContent.trim()).toBe '0'
expect(@panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
it 'correctly handles deprecated packages', ->
resolve = null
promise = new Promise (r) -> resolve = r
jasmine.unspy(@packageManager, 'getOutdated')
spyOn(@packageManager, 'getOutdated').andReturn(promise)
jasmine.attachToDOM(@panel.element)
[updateCallback] = []
spyOn(atom.packages, 'isDeprecatedPackage').andCallFake =>
return true if @installed.user[0].version is '1.0.0'
false
spyOn(@packageManager, 'runCommand').andCallFake (args, callback) ->
updateCallback = callback
onWillThrowError: ->
atom.packages.activatePackage
spyOn(atom.packages, 'activatePackage').andCallFake (name) =>
@installed.user[0].version = '1.1.0'
expect(@panel.refs.deprecatedSection).not.toBeVisible()
@panel.loadPackages()
waits 1
runs ->
expect(@panel.refs.deprecatedSection).toBeVisible()
expect(@panel.refs.deprecatedCount.textContent.trim()).toBe '1'
expect(@panel.refs.deprecatedPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
spyOn(PackageCard::, 'displayAvailableUpdate')
resolve([{name: 'user-package', latestVersion: '1.1.0'}])
waits 1
runs ->
expect(PackageCard::displayAvailableUpdate).toHaveBeenCalledWith('1.1.0')
describe 'expanding and collapsing sub-sections', ->
beforeEach ->
settingsView = new SettingsView
@packageManager = new PackageManager
@installed = JSON.parse fs.readFileSync(path.join(__dirname, 'fixtures', 'installed.json'))
spyOn(@packageManager, 'getOutdated').andReturn new Promise ->
spyOn(@packageManager, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(@packageManager, 'getInstalled').andReturn Promise.resolve(@installed)
spyOn(atom.packages, 'isDeprecatedPackage').andCallFake =>
return true if @installed.user[0].version is '1.0.0'
false
@panel = new InstalledPackagesPanel(settingsView, @packageManager)
waitsFor ->
@packageManager.getInstalled.callCount is 1 and @panel.refs.communityCount.textContent.indexOf('') < 0
it 'collapses and expands a sub-section if its header is clicked', ->
@panel.element.querySelector('.sub-section.installed-packages .sub-section-heading').click()
expect(@panel.element.querySelector('.sub-section.installed-packages')).toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.deprecated-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.core-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.dev-packages')).not.toHaveClass 'collapsed'
@panel.element.querySelector('.sub-section.installed-packages .sub-section-heading').click()
expect(@panel.element.querySelector('.sub-section.installed-packages')).not.toHaveClass 'collapsed'
it 'can collapse and expand any of the sub-sections', ->
expect(@panel.element.querySelectorAll('.sub-section-heading.has-items').length).toBe 4
for element in @panel.element.querySelectorAll('.sub-section-heading.has-items')
element.click()
expect(@panel.element.querySelector('.sub-section.deprecated-packages')).toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.installed-packages')).toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.core-packages')).toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.dev-packages')).toHaveClass 'collapsed'
for element in @panel.element.querySelectorAll('.sub-section-heading.has-items')
element.click()
expect(@panel.element.querySelector('.sub-section.deprecated-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.installed-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.core-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.dev-packages')).not.toHaveClass 'collapsed'
it 'can collapse sub-sections when filtering', ->
@panel.refs.filterEditor.setText('user-')
window.advanceClock(@panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
hasItems = @panel.element.querySelectorAll('.sub-section-heading.has-items')
expect(hasItems.length).toBe 2
expect(hasItems[0].textContent).toMatch /Deprecated Packages/
expect(hasItems[1].textContent).toMatch /Community Packages/
describe 'when there are no packages', ->
beforeEach ->
settingsView = new SettingsView
@packageManager = new PackageManager
@installed =
dev: []
user: []
core: []
spyOn(@packageManager, 'getOutdated').andReturn new Promise ->
spyOn(@packageManager, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(@packageManager, 'getInstalled').andReturn Promise.resolve(@installed)
@panel = new InstalledPackagesPanel(settingsView, @packageManager)
waitsFor ->
@packageManager.getInstalled.callCount is 1 and @panel.refs.communityCount.textContent.indexOf('') < 0
it 'has a count of zero in all headings', ->
expect(@panel.element.querySelector('.section-heading-count').textContent).toMatch /^0+$/
expect(@panel.element.querySelectorAll('.sub-section .icon-package').length).toBe 5
expect(@panel.element.querySelectorAll('.sub-section .icon-package.has-items').length).toBe 0
it 'can not collapse and expand any of the sub-sections', ->
for element in @panel.element.querySelectorAll('.sub-section .icon-package')
element.click()
expect(@panel.element.querySelector('.sub-section.deprecated-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.installed-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.core-packages')).not.toHaveClass 'collapsed'
expect(@panel.element.querySelector('.sub-section.dev-packages')).not.toHaveClass 'collapsed'
it 'does not allow collapsing on any section when filtering', ->
@panel.refs.filterEditor.setText('user-')
window.advanceClock(@panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
expect(@panel.element.querySelector('.section-heading-count').textContent).toMatch /^(0\/0)+$/
expect(@panel.element.querySelectorAll('.sub-section .icon-package').length).toBe 5
expect(@panel.element.querySelectorAll('.sub-section .icon-paintcan.has-items').length).toBe 0

View File

@ -0,0 +1,132 @@
path = require 'path'
KeybindingsPanel = require '../lib/keybindings-panel'
describe "KeybindingsPanel", ->
[keyBindings, panel] = []
beforeEach ->
expect(atom.keymaps).toBeDefined()
keySource = "#{atom.getLoadSettings().resourcePath}#{path.sep}keymaps"
keyBindings = [
{
source: keySource
keystrokes: 'ctrl-a'
command: 'core:select-all'
selector: '.editor, .platform-test'
}
{
source: keySource
keystrokes: 'ctrl-u'
command: 'core:undo'
selector: ".platform-test"
}
{
source: keySource
keystrokes: 'ctrl-u'
command: 'core:undo'
selector: ".platform-a, .platform-b"
}
{
source: keySource
keystrokes: 'shift-\\ \\'
command: 'core:undo'
selector: '.editor'
}
{
source: keySource
keystrokes: 'ctrl-z\''
command: 'core:toggle'
selector: 'atom-text-editor[data-grammar~=\'css\']'
}
]
spyOn(atom.keymaps, 'getKeyBindings').andReturn(keyBindings)
panel = new KeybindingsPanel
it "loads and displays core key bindings", ->
expect(panel.refs.keybindingRows.children.length).toBe 3
row = panel.refs.keybindingRows.children[0]
expect(row.querySelector('.keystroke').textContent).toBe 'ctrl-a'
expect(row.querySelector('.command').textContent).toBe 'core:select-all'
expect(row.querySelector('.source').textContent).toBe 'Core'
expect(row.querySelector('.selector').textContent).toBe '.editor, .platform-test'
describe "when a keybinding is copied", ->
describe "when the keybinding file ends in .cson", ->
it "writes a CSON snippet to the clipboard", ->
spyOn(atom.keymaps, 'getUserKeymapPath').andReturn 'keymap.cson'
panel.element.querySelector('.copy-icon').click()
expect(atom.clipboard.read()).toBe """
'.editor, .platform-test':
'ctrl-a': 'core:select-all'
"""
describe "when the keybinding file ends in .json", ->
it "writes a JSON snippet to the clipboard", ->
spyOn(atom.keymaps, 'getUserKeymapPath').andReturn 'keymap.json'
panel.element.querySelector('.copy-icon').click()
expect(atom.clipboard.read()).toBe """
".editor, .platform-test": {
"ctrl-a": "core:select-all"
}
"""
describe "when the keybinding contains special characters", ->
it "escapes the backslashes before copying", ->
spyOn(atom.keymaps, 'getUserKeymapPath').andReturn 'keymap.cson'
panel.element.querySelectorAll('.copy-icon')[2].click()
expect(atom.clipboard.read()).toBe """
'.editor':
'shift-\\\\ \\\\': 'core:undo'
"""
it "escapes the single quotes before copying", ->
spyOn(atom.keymaps, 'getUserKeymapPath').andReturn 'keymap.cson'
panel.element.querySelectorAll('.copy-icon')[1].click()
expect(atom.clipboard.read()).toBe """
'atom-text-editor[data-grammar~=\\'css\\']':
'ctrl-z\\'': 'core:toggle'
"""
describe "when the key bindings change", ->
it "reloads the key bindings", ->
keyBindings.push
source: atom.keymaps.getUserKeymapPath(), keystrokes: 'ctrl-b', command: 'core:undo', selector: '.editor'
atom.keymaps.emitter.emit 'did-reload-keymap'
waitsFor "the new keybinding to show up in the keybinding panel", ->
panel.refs.keybindingRows.children.length is 4
runs ->
row = panel.refs.keybindingRows.children[1]
expect(row.querySelector('.keystroke').textContent).toBe 'ctrl-b'
expect(row.querySelector('.command').textContent).toBe 'core:undo'
expect(row.querySelector('.source').textContent).toBe 'User'
expect(row.querySelector('.selector').textContent).toBe '.editor'
describe "when searching key bindings", ->
it "find case-insensitive results", ->
keyBindings.push
source: "#{atom.getLoadSettings().resourcePath}#{path.sep}keymaps", keystrokes: 'F11', command: 'window:toggle-full-screen', selector: 'body'
atom.keymaps.emitter.emit 'did-reload-keymap'
panel.filterKeyBindings keyBindings, 'f11'
expect(panel.refs.keybindingRows.children.length).toBe 1
row = panel.refs.keybindingRows.children[0]
expect(row.querySelector('.keystroke').textContent).toBe 'F11'
expect(row.querySelector('.command').textContent).toBe 'window:toggle-full-screen'
expect(row.querySelector('.source').textContent).toBe 'Core'
expect(row.querySelector('.selector').textContent).toBe 'body'
it "perform a fuzzy match for each keyword", ->
panel.filterKeyBindings keyBindings, 'core ctrl-a'
expect(panel.refs.keybindingRows.children.length).toBe 1
row = panel.refs.keybindingRows.children[0]
expect(row.querySelector('.keystroke').textContent).toBe 'ctrl-a'
expect(row.querySelector('.command').textContent).toBe 'core:select-all'
expect(row.querySelector('.source').textContent).toBe 'Core'
expect(row.querySelector('.selector').textContent).toBe '.editor, .platform-test'

View File

@ -0,0 +1,35 @@
List = require '../lib/list'
describe 'List', ->
list = null
beforeEach ->
list = new List('name')
it 'emits add and remove events when setting items', ->
addHandler = jasmine.createSpy()
removeHandler = jasmine.createSpy()
list.onDidAddItem(addHandler)
list.onDidRemoveItem(removeHandler)
items = [{name: 'one', text: 'a'}, {name: 'two', text: 'b'}]
list.setItems(items)
expect(addHandler.callCount).toBe 2
expect(removeHandler.callCount).toBe 0
addHandler.reset()
removeHandler.reset()
items = [{name: 'three', text: 'c'}, {name: 'two', text: 'b'}]
list.setItems(items)
expect(addHandler.callCount).toBe 1
expect(removeHandler.callCount).toBe 1
expect(addHandler.mostRecentCall.args[0]).toEqual {name: 'three', text: 'c'}
expect(removeHandler.mostRecentCall.args[0]).toEqual {name: 'one', text: 'a'}
expect(list.getItems()).toEqual items
addHandler.reset()
removeHandler.reset()
items.push {name: 'four'}
list.setItems(items)
expect(addHandler.callCount).toBe 1

View File

@ -0,0 +1,67 @@
List = require '../lib/list'
ListView = require '../lib/list-view'
describe 'ListView', ->
[list, view, container] = []
beforeEach ->
list = new List('name')
container = document.createElement('div')
view = new ListView list, container, (item) ->
element = document.createElement('div')
element.classList.add(item.name)
element.textContent = "#{item.name}|#{item.text}"
{element, destroy: -> element.remove()}
it 'updates the list when the items are changed', ->
expect(container.children.length).toBe 0
items = [{name: 'one', text: 'a'}, {name: 'two', text: 'b'}]
list.setItems(items)
expect(container.children.length).toBe 2
expect(container.querySelector('.one').textContent).toBe 'one|a'
expect(container.querySelector('.two').textContent).toBe 'two|b'
items = [{name: 'three', text: 'c'}, {name: 'two', text: 'b'}]
list.setItems(items)
expect(container.children.length).toBe 2
expect(container.querySelector('.one')).not.toExist()
expect(container.querySelector('.two').textContent).toBe 'two|b'
expect(container.querySelector('.three').textContent).toBe 'three|c'
it 'filters views', ->
items = [
{name: 'one', text: '', filterText: 'x'},
{name: 'two', text: '', filterText: 'y'}
{name: 'three', text: '', filterText: 'x'}
{name: 'four', text: '', filterText: 'z'}
]
list.setItems(items)
views = view.filterViews (item) -> item.filterText is 'x'
expect(views).toHaveLength 2
expect(views[0].element.textContent).toBe 'one|'
expect(views[1].element.textContent).toBe 'three|'
it 'filters views after an update', ->
items = [
{name: 'one', text: '', filterText: 'x'},
{name: 'two', text: '', filterText: 'y'}
{name: 'three', text: '', filterText: 'x'}
{name: 'four', text: '', filterText: 'z'}
]
list.setItems(items)
items = [
{name: 'one', text: '', filterText: 'x'},
{name: 'two', text: '', filterText: 'y'}
{name: 'three', text: '', filterText: 'x'}
{name: 'four', text: '', filterText: 'z'}
]
list.setItems(items)
views = view.filterViews (item) -> item.filterText is 'x'
expect(views).toHaveLength 2
expect(views[0].element.textContent).toBe 'one|'
expect(views[1].element.textContent).toBe 'three|'

View File

@ -0,0 +1,563 @@
path = require 'path'
PackageCard = require '../lib/package-card'
PackageManager = require '../lib/package-manager'
SettingsView = require '../lib/settings-view'
describe "PackageCard", ->
setPackageStatusSpies = (opts) ->
spyOn(PackageCard.prototype, 'isInstalled').andReturn(opts.installed)
spyOn(PackageCard.prototype, 'isDisabled').andReturn(opts.disabled)
spyOn(PackageCard.prototype, 'hasSettings').andReturn(opts.hasSettings)
[card, packageManager] = []
beforeEach ->
packageManager = new PackageManager()
spyOn(packageManager, 'runCommand')
it "doesn't show the disable control for a theme", ->
setPackageStatusSpies {installed: true, disabled: false}
card = new PackageCard({theme: 'syntax', name: 'test-theme'}, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.enablementButton).not.toBeVisible()
it "doesn't show the status indicator for a theme", ->
setPackageStatusSpies {installed: true, disabled: false}
card = new PackageCard {theme: 'syntax', name: 'test-theme'}, new SettingsView(), packageManager
jasmine.attachToDOM(card.element)
expect(card.refs.statusIndicatorButton).not.toBeVisible()
it "doesn't show the settings button for a theme", ->
setPackageStatusSpies {installed: true, disabled: false}
card = new PackageCard {theme: 'syntax', name: 'test-theme'}, new SettingsView(), packageManager
jasmine.attachToDOM(card.element)
expect(card.refs.settingsButton).not.toBeVisible()
it "doesn't show the settings button on the settings view", ->
setPackageStatusSpies {installed: true, disabled: false, hasSettings: true}
card = new PackageCard {name: 'test-package'}, new SettingsView(), packageManager, {onSettingsView: true}
jasmine.attachToDOM(card.element)
expect(card.refs.settingsButton).not.toBeVisible()
it "removes the settings button if a package has no settings", ->
setPackageStatusSpies {installed: true, disabled: false, hasSettings: false}
card = new PackageCard {name: 'test-package'}, new SettingsView(), packageManager
jasmine.attachToDOM(card.element)
expect(card.refs.settingsButton).not.toBeVisible()
it "removes the uninstall button if a package has is a bundled package", ->
setPackageStatusSpies {installed: true, disabled: false, hasSettings: true}
card = new PackageCard {name: 'find-and-replace'}, new SettingsView(), packageManager
jasmine.attachToDOM(card.element)
expect(card.refs.uninstallButton).not.toBeVisible()
it "displays the new version in the update button", ->
setPackageStatusSpies {installed: true, disabled: false, hasSettings: true}
card = new PackageCard {name: 'find-and-replace', version: '1.0.0', latestVersion: '1.2.0'}, new SettingsView(), packageManager
jasmine.attachToDOM(card.element)
expect(card.refs.updateButton).toBeVisible()
expect(card.refs.updateButton.textContent).toContain 'Update to 1.2.0'
it "displays the new version in the update button when the package is disabled", ->
setPackageStatusSpies {installed: true, disabled: true, hasSettings: true}
card = new PackageCard {name: 'find-and-replace', version: '1.0.0', latestVersion: '1.2.0'}, new SettingsView(), packageManager
jasmine.attachToDOM(card.element)
expect(card.refs.updateButton).toBeVisible()
expect(card.refs.updateButton.textContent).toContain 'Update to 1.2.0'
it "shows the author details", ->
authorName = "authorName"
pack =
name: 'some-package'
version: '0.1.0'
repository: "https://github.com/#{authorName}/some-package"
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.loginLink.textContent).toBe(authorName)
describe "when the package is not installed", ->
it "shows the settings, uninstall, and disable buttons", ->
pack =
name: 'some-package'
version: '0.1.0'
repository: 'http://github.com/omgwow/some-package'
spyOn(PackageCard::, 'isDeprecated').andReturn(false)
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.installButtonGroup).toBeVisible()
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.packageActionButtonGroup).not.toBeVisible()
it "can be installed if currently not installed", ->
setPackageStatusSpies {installed: false, disabled: false}
spyOn(packageManager, 'install')
card = new PackageCard {name: 'test-package'}, new SettingsView(), packageManager
expect(card.refs.installButton.style.display).not.toBe('none')
expect(card.refs.uninstallButton.style.display).toBe('none')
card.refs.installButton.click()
expect(packageManager.install).toHaveBeenCalled()
it "can be installed if currently not installed and package latest release engine match atom version", ->
spyOn(packageManager, 'install')
spyOn(packageManager, 'loadCompatiblePackageVersion').andCallFake (packageName, callback) ->
pack =
name: packageName
version: '0.1.0'
engines:
atom: '>0.50.0'
callback(null, pack)
setPackageStatusSpies {installed: false, disabled: false}
card = new PackageCard {
name: 'test-package'
version: '0.1.0'
engines:
atom: '>0.50.0'
}, new SettingsView(), packageManager
# In that case there's no need to make a request to get all the versions
expect(packageManager.loadCompatiblePackageVersion).not.toHaveBeenCalled()
expect(card.refs.installButton.style.display).not.toBe('none')
expect(card.refs.uninstallButton.style.display).toBe('none')
card.refs.installButton.click()
expect(packageManager.install).toHaveBeenCalled()
expect(packageManager.install.mostRecentCall.args[0]).toEqual({
name: 'test-package'
version: '0.1.0'
engines:
atom: '>0.50.0'
})
it "can be installed with a previous version whose engine match the current atom version", ->
spyOn(packageManager, 'install')
spyOn(packageManager, 'loadCompatiblePackageVersion').andCallFake (packageName, callback) ->
pack =
name: packageName
version: '0.0.1'
engines:
atom: '>0.50.0'
callback(null, pack)
setPackageStatusSpies {installed: false, disabled: false}
card = new PackageCard {
name: 'test-package'
version: '0.1.0'
engines:
atom: '>99.0.0'
}, new SettingsView(), packageManager
expect(card.refs.installButton.style.display).not.toBe('none')
expect(card.refs.uninstallButton.style.display).toBe('none')
expect(card.refs.versionValue.textContent).toBe('0.0.1')
expect(card.refs.versionValue).toHaveClass('text-warning')
expect(card.refs.packageMessage).toHaveClass('text-warning')
card.refs.installButton.click()
expect(packageManager.install).toHaveBeenCalled()
expect(packageManager.install.mostRecentCall.args[0]).toEqual({
name: 'test-package'
version: '0.0.1'
engines:
atom: '>0.50.0'
})
it "can't be installed if there is no version compatible with the current atom version", ->
spyOn(packageManager, 'loadCompatiblePackageVersion').andCallFake (packageName, callback) ->
pack =
name: packageName
callback(null, pack)
setPackageStatusSpies {installed: false, disabled: false}
pack =
name: 'test-package'
engines:
atom: '>=99.0.0'
card = new PackageCard(pack , new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.refs.packageActionButtonGroup).not.toBeVisible()
expect(card.refs.versionValue).toHaveClass('text-error')
expect(card.refs.packageMessage).toHaveClass('text-error')
describe "when the package is installed", ->
beforeEach ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-config'))
waitsFor ->
atom.packages.isPackageLoaded('package-with-config') is true
it "can be disabled if installed", ->
setPackageStatusSpies {installed: true, disabled: false}
spyOn(atom.packages, 'disablePackage').andReturn(true)
card = new PackageCard {name: 'test-package'}, new SettingsView(), packageManager
expect(card.refs.enablementButton.querySelector('.disable-text').textContent).toBe('Disable')
card.refs.enablementButton.click()
expect(atom.packages.disablePackage).toHaveBeenCalled()
it "can be updated", ->
pack = atom.packages.getLoadedPackage('package-with-config')
pack.latestVersion = '1.1.0'
packageUpdated = false
packageManager.on 'package-updated', -> packageUpdated = true
packageManager.runCommand.andCallFake (args, callback) ->
callback(0, '', '')
onWillThrowError: ->
originalLoadPackage = atom.packages.loadPackage
spyOn(atom.packages, 'loadPackage').andCallFake ->
originalLoadPackage.call(atom.packages, path.join(__dirname, 'fixtures', 'package-with-config'))
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.updateButton).toBeVisible()
card.update()
waitsFor ->
packageUpdated
runs ->
expect(card.refs.updateButton).not.toBeVisible()
it 'keeps the update button visible if the update failed', ->
pack = atom.packages.getLoadedPackage('package-with-config')
pack.latestVersion = '1.1.0'
updateFailed = false
packageManager.on 'package-update-failed', -> updateFailed = true
packageManager.runCommand.andCallFake (args, callback) ->
callback(1, '', '')
onWillThrowError: ->
originalLoadPackage = atom.packages.loadPackage
spyOn(atom.packages, 'loadPackage').andCallFake ->
originalLoadPackage.call(atom.packages, path.join(__dirname, 'fixtures', 'package-with-config'))
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.updateButton).toBeVisible()
card.update()
waitsFor ->
updateFailed
runs ->
expect(card.refs.updateButton).toBeVisible()
it 'does not error when attempting to update without any update available', ->
# While this cannot be done through the package card UI,
# updates can still be triggered through the Updates panel's Update All button
# https://github.com/atom/settings-view/issues/879
pack = atom.packages.getLoadedPackage('package-with-config')
originalLoadPackage = atom.packages.loadPackage
spyOn(atom.packages, 'loadPackage').andCallFake ->
originalLoadPackage.call(atom.packages, path.join(__dirname, 'fixtures', 'package-with-config'))
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.updateButton).not.toBeVisible()
waitsForPromise -> card.update()
runs ->
expect(card.refs.updateButton).not.toBeVisible()
it "will stay disabled after an update", ->
pack = atom.packages.getLoadedPackage('package-with-config')
pack.latestVersion = '1.1.0'
packageUpdated = false
packageManager.on 'package-updated', -> packageUpdated = true
packageManager.runCommand.andCallFake (args, callback) ->
callback(0, '', '')
onWillThrowError: ->
originalLoadPackage = atom.packages.loadPackage
spyOn(atom.packages, 'loadPackage').andCallFake ->
originalLoadPackage.call(atom.packages, path.join(__dirname, 'fixtures', 'package-with-config'))
pack.disable()
card = new PackageCard(pack, new SettingsView(), packageManager)
expect(atom.packages.isPackageDisabled('package-with-config')).toBe true
card.update()
waitsFor ->
packageUpdated
runs ->
expect(atom.packages.isPackageDisabled('package-with-config')).toBe true
it "is uninstalled when the uninstallButton is clicked", ->
setPackageStatusSpies {installed: true, disabled: false}
[uninstallCallback] = []
packageManager.runCommand.andCallFake (args, callback) ->
if args[0] is 'uninstall'
uninstallCallback = callback
onWillThrowError: ->
spyOn(packageManager, 'install').andCallThrough()
spyOn(packageManager, 'uninstall').andCallThrough()
pack = atom.packages.getLoadedPackage('package-with-config')
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
card.refs.uninstallButton.click()
expect(card.refs.uninstallButton.disabled).toBe true
expect(card.refs.enablementButton.disabled).toBe true
expect(card.refs.uninstallButton).toHaveClass('is-uninstalling')
expect(packageManager.uninstall).toHaveBeenCalled()
expect(packageManager.uninstall.mostRecentCall.args[0].name).toEqual('package-with-config')
jasmine.unspy(PackageCard::, 'isInstalled')
spyOn(PackageCard.prototype, 'isInstalled').andReturn false
uninstallCallback(0, '', '')
waits 1
runs ->
expect(card.refs.uninstallButton.disabled).toBe false
expect(card.refs.uninstallButton).not.toHaveClass('is-uninstalling')
expect(card.refs.installButtonGroup).toBeVisible()
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.packageActionButtonGroup).not.toBeVisible()
it "shows the settings, uninstall, and enable buttons when disabled", ->
atom.config.set('package-with-config.setting', 'something')
pack = atom.packages.getLoadedPackage('package-with-config')
spyOn(atom.packages, 'isPackageDisabled').andReturn(true)
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.refs.settingsButton).toBeVisible()
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
expect(card.refs.enablementButton.textContent).toBe 'Enable'
it "shows the settings, uninstall, and disable buttons", ->
atom.config.set('package-with-config.setting', 'something')
pack = atom.packages.getLoadedPackage('package-with-config')
spyOn(PackageCard::, 'isDeprecated').andReturn(false)
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.refs.settingsButton).toBeVisible()
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
expect(card.refs.enablementButton.textContent).toBe 'Disable'
it "does not show the settings button when there are no settings", ->
pack = atom.packages.getLoadedPackage('package-with-config')
spyOn(PackageCard::, 'isDeprecated').andReturn(false)
spyOn(PackageCard::, 'hasSettings').andReturn(false)
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
expect(card.refs.settingsButton).not.toBeVisible()
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
expect(card.refs.enablementButton.textContent).toBe 'Disable'
###
hasDeprecations, no update: disabled-settings, uninstall, disable
hasDeprecations, has update: update, disabled-settings, uninstall, disable
###
describe "when the package has deprecations", ->
beforeEach ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-config'))
waitsFor ->
atom.packages.isPackageLoaded('package-with-config') is true
runs ->
atom.config.set('package-with-config.setting', 'something')
describe "when hasDeprecations is true and NO update is available", ->
beforeEach ->
spyOn(PackageCard::, 'isDeprecated').andReturn true
spyOn(PackageCard::, 'isInstalled').andReturn true
spyOn(PackageCard::, 'getDeprecatedPackageMetadata').andReturn
hasDeprecations: true
version: '<=1.0.0'
pack = atom.packages.getLoadedPackage('package-with-config')
pack.version = pack.metadata.version
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
it "shows the correct state", ->
spyOn(atom.packages, 'isPackageDisabled').andReturn false
card.updateInterfaceState()
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.element).toHaveClass 'deprecated'
expect(card.refs.packageMessage.textContent).toContain 'no update available'
expect(card.refs.packageMessage).toHaveClass 'text-warning'
expect(card.refs.settingsButton.disabled).toBe true
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
expect(card.refs.enablementButton.textContent).toBe 'Disable'
expect(card.refs.enablementButton.disabled).toBe false
it "displays a disabled enable button when the package is disabled", ->
spyOn(atom.packages, 'isPackageDisabled').andReturn true
card.updateInterfaceState()
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.element).toHaveClass 'deprecated'
expect(card.refs.packageMessage.textContent).toContain 'no update available'
expect(card.refs.packageMessage).toHaveClass 'text-warning'
expect(card.refs.settingsButton.disabled).toBe true
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
expect(card.refs.enablementButton.textContent).toBe 'Enable'
expect(card.refs.enablementButton.disabled).toBe true
# NOTE: the mocking here is pretty delicate
describe "when hasDeprecations is true and there is an update is available", ->
beforeEach ->
spyOn(PackageCard::, 'isDeprecated').andCallFake (version) ->
semver = require 'semver'
version = version ? card?.pack?.version ? '1.0.0'
semver.satisfies(version, '<=1.0.1')
spyOn(PackageCard::, 'getDeprecatedPackageMetadata').andReturn
hasDeprecations: true
version: '<=1.0.1'
pack = atom.packages.getLoadedPackage('package-with-config')
pack.version = pack.metadata.version
card = new PackageCard(pack, new SettingsView(), packageManager)
jasmine.attachToDOM(card.element)
it "explains that the update WILL NOT fix the deprecations when the new version isnt higher than the max version", ->
card.displayAvailableUpdate('1.0.1')
expect(card.refs.packageMessage.textContent).not.toContain 'no update available'
expect(card.refs.packageMessage.textContent).toContain 'still contains deprecations'
describe "when the available update fixes deprecations", ->
it "explains that the update WILL fix the deprecations when the new version is higher than the max version", ->
card.displayAvailableUpdate('1.1.0')
expect(card.refs.packageMessage.textContent).not.toContain 'no update available'
expect(card.refs.packageMessage.textContent).toContain 'without deprecations'
expect(card.refs.updateButtonGroup).toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.refs.packageActionButtonGroup).toBeVisible()
expect(card.refs.uninstallButton).toBeVisible()
expect(card.refs.enablementButton).toBeVisible()
expect(card.refs.enablementButton.textContent).toBe 'Disable'
it "updates the package and shows a restart notification when the update button is clicked", ->
expect(atom.packages.getLoadedPackage('package-with-config')).toBeTruthy()
[updateCallback] = []
packageManager.runCommand.andCallFake (args, callback) ->
updateCallback = callback
onWillThrowError: ->
spyOn(packageManager, 'update').andCallThrough()
originalLoadPackage = atom.packages.loadPackage
spyOn(atom.packages, 'loadPackage').andCallFake ->
pack = originalLoadPackage.call(atom.packages, path.join(__dirname, 'fixtures', 'package-with-config'))
pack.metadata.version = '1.1.0' if pack?
pack
card.pack.latestVersion = "1.1.0"
card.displayAvailableUpdate('1.1.0')
expect(card.refs.updateButtonGroup).toBeVisible()
expect(atom.packages.getLoadedPackage('package-with-config')).toBeTruthy()
card.refs.updateButton.click()
expect(card.refs.updateButton.disabled).toBe true
expect(card.refs.updateButton).toHaveClass 'is-installing'
expect(packageManager.update).toHaveBeenCalled()
expect(packageManager.update.mostRecentCall.args[0].name).toEqual 'package-with-config'
expect(packageManager.runCommand).toHaveBeenCalled()
expect(card.element).toHaveClass 'deprecated'
expect(card.refs.updateButtonGroup).toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
updateCallback(0, '', '')
waits 0 # Wait for PackageCard.update promise to resolve
runs ->
expect(card.refs.updateButton.disabled).toBe false
expect(card.refs.updateButton).not.toHaveClass 'is-installing'
expect(card.refs.updateButtonGroup).not.toBeVisible()
expect(card.refs.installButtonGroup).not.toBeVisible()
expect(card.refs.packageActionButtonGroup).toBeVisible()
expect(card.refs.versionValue.textContent).toBe '1.0.0' # Does not update until restart
notifications = atom.notifications.getNotifications()
expect(notifications.length).toBe 1
notif = notifications[0]
expect(notif.options.detail).toBe "1.0.0 -> 1.1.0"
expect(notif.options.buttons.length).toBe(2)
spyOn(atom, 'restartApplication')
notif.options.buttons[0].onDidClick()
expect(atom.restartApplication).toHaveBeenCalled()
spyOn(notif, 'dismiss')
notif.options.buttons[1].onDidClick()
expect(notif.dismiss).toHaveBeenCalled()
it "shows the sha in the notification when a git url package is updated", ->
expect(atom.packages.getLoadedPackage('package-with-config')).toBeTruthy()
[updateCallback] = []
packageManager.runCommand.andCallFake (args, callback) ->
updateCallback = callback
onWillThrowError: ->
spyOn(packageManager, 'update').andCallThrough()
card.pack.apmInstallSource = {type: 'git', sha: 'cf23df2207d99a74fbe169e3eba035e633b65d94'}
card.pack.latestSha = 'a296114f3d0deec519a41f4c62e7fc56075b7f01'
card.displayAvailableUpdate('1.1.0')
expect(card.refs.updateButtonGroup).toBeVisible()
expect(atom.packages.getLoadedPackage('package-with-config')).toBeTruthy()
card.refs.updateButton.click()
updateCallback(0, '', '')
waits 0 # Wait for PackageCard.update promise to resolve
runs ->
notifications = atom.notifications.getNotifications()
expect(notifications.length).toBe 1
expect(notifications[0].options.detail).toBe "cf23df22 -> a296114f"

View File

@ -0,0 +1,156 @@
fs = require 'fs'
path = require 'path'
{shell} = require 'electron'
PackageDetailView = require '../lib/package-detail-view'
PackageManager = require '../lib/package-manager'
SettingsView = require '../lib/settings-view'
AtomIoClient = require '../lib/atom-io-client'
SnippetsProvider =
getSnippets: -> {}
describe "PackageDetailView", ->
packageManager = null
view = null
createClientSpy = ->
jasmine.createSpyObj('client', ['package', 'avatar'])
beforeEach ->
packageManager = new PackageManager
view = null
loadPackageFromRemote = (packageName, opts) ->
opts ?= {}
packageManager.client = createClientSpy()
packageManager.client.package.andCallFake (name, cb) ->
packageData = require(path.join(__dirname, 'fixtures', packageName, 'package.json'))
packageData.readme = fs.readFileSync(path.join(__dirname, 'fixtures', packageName, 'README.md'), 'utf8')
cb(null, packageData)
view = new PackageDetailView({name: packageName}, new SettingsView(), packageManager, SnippetsProvider)
view.beforeShow(opts)
loadCustomPackageFromRemote = (packageName, opts) ->
opts ?= {}
packageManager.client = createClientSpy()
packageManager.client.package.andCallFake (name, cb) ->
packageData = require(path.join(__dirname, 'fixtures', packageName, 'package.json'))
cb(null, packageData)
view = new PackageDetailView({name: packageName}, new SettingsView(), packageManager, SnippetsProvider)
view.beforeShow(opts)
it "renders a package when provided in `initialize`", ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-config'))
pack = atom.packages.getLoadedPackage('package-with-config')
view = new PackageDetailView(pack, new SettingsView(), packageManager, SnippetsProvider)
# Perhaps there are more things to assert here.
expect(view.refs.title.textContent).toBe('Package With Config')
it "does not call the atom.io api for package metadata when present", ->
packageManager.client = createClientSpy()
view = new PackageDetailView({name: 'package-with-config'}, new SettingsView(), packageManager, SnippetsProvider)
# PackageCard is a subview, and it calls AtomIoClient::package once to load
# metadata from the cache.
expect(packageManager.client.package.callCount).toBe(1)
it "shows a loading message and calls out to atom.io when package metadata is missing", ->
loadPackageFromRemote('package-with-readme')
expect(view.refs.loadingMessage).not.toBe(null)
expect(view.refs.loadingMessage.classList.contains('hidden')).not.toBe(true)
expect(packageManager.client.package).toHaveBeenCalled()
it "shows an error when package metadata cannot be loaded via the API", ->
packageManager.client = createClientSpy()
packageManager.client.package.andCallFake (name, cb) ->
error = new Error('API error')
cb(error, null)
view = new PackageDetailView({name: 'nonexistent-package'}, new SettingsView(), packageManager, SnippetsProvider)
expect(view.refs.errorMessage.classList.contains('hidden')).not.toBe(true)
expect(view.refs.loadingMessage.classList.contains('hidden')).toBe(true)
expect(view.element.querySelectorAll('.package-card').length).toBe(0)
it "shows an error when package metadata cannot be loaded from the cache and the network is unavailable", ->
localStorage.removeItem('settings-view:packages/some-package')
spyOn(AtomIoClient.prototype, 'online').andReturn(false)
spyOn(AtomIoClient.prototype, 'request').andCallFake (path, callback) ->
callback(new Error('getaddrinfo ENOENT atom.io:443'))
spyOn(AtomIoClient.prototype, 'fetchFromCache').andCallThrough()
view = new PackageDetailView({name: 'some-package'}, new SettingsView(), packageManager, SnippetsProvider)
expect(AtomIoClient.prototype.fetchFromCache).toHaveBeenCalled()
expect(view.refs.errorMessage.classList.contains('hidden')).not.toBe(true)
expect(view.refs.loadingMessage.classList.contains('hidden')).toBe(true)
expect(view.element.querySelectorAll('.package-card').length).toBe(0)
it "renders the README successfully after a call to the atom.io api", ->
loadPackageFromRemote('package-with-readme')
expect(view.packageCard).toBeDefined()
expect(view.packageCard.refs.packageName.textContent).toBe('package-with-readme')
expect(view.element.querySelectorAll('.package-readme').length).toBe(1)
it "renders the README successfully with sanitized html", ->
loadPackageFromRemote('package-with-readme')
expect(view.element.querySelectorAll('.package-readme script').length).toBe(0)
expect(view.element.querySelectorAll('.package-readme iframe').length).toBe(0)
expect(view.element.querySelectorAll('.package-readme input[type="checkbox"][disabled]').length).toBe(2)
expect(view.element.querySelector('img[alt="AbsoluteImage"]').getAttribute('src')).toBe('https://example.com/static/image.jpg')
expect(view.element.querySelector('img[alt="RelativeImage"]').getAttribute('src')).toBe('https://github.com/example/package-with-readme/blob/master/static/image.jpg')
expect(view.element.querySelector('img[alt="Base64Image"]').getAttribute('src')).toBe('data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==')
it "renders the README when the package path is undefined", ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-readme'))
pack = atom.packages.getLoadedPackage('package-with-readme')
delete pack.path
view = new PackageDetailView(pack, new SettingsView(), packageManager, SnippetsProvider)
expect(view.packageCard).toBeDefined()
expect(view.packageCard.refs.packageName.textContent).toBe('package-with-readme')
expect(view.element.querySelectorAll('.package-readme').length).toBe(1)
it "triggers a report issue button click and checks that the fallback repository issue tracker URL was opened", ->
loadCustomPackageFromRemote('package-without-bugs-property')
spyOn(shell, 'openExternal')
view.refs.issueButton.click()
expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/example/package-without-bugs-property/issues/new')
it "triggers a report issue button click and checks that the bugs URL string was opened", ->
loadCustomPackageFromRemote('package-with-bugs-property-url-string')
spyOn(shell, 'openExternal')
view.refs.issueButton.click()
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/custom-issue-tracker/new')
it "triggers a report issue button click and checks that the bugs URL was opened", ->
loadCustomPackageFromRemote('package-with-bugs-property-url')
spyOn(shell, 'openExternal')
view.refs.issueButton.click()
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/custom-issue-tracker/new')
it "triggers a report issue button click and checks that the bugs email link was opened", ->
loadCustomPackageFromRemote('package-with-bugs-property-email')
spyOn(shell, 'openExternal')
view.refs.issueButton.click()
expect(shell.openExternal).toHaveBeenCalledWith('mailto:issues@example.com')
it "should show 'Install' as the first breadcrumb by default", ->
loadPackageFromRemote('package-with-readme')
expect(view.refs.breadcrumb.textContent).toBe('Install')
it "should open repository url", ->
loadPackageFromRemote('package-with-readme')
spyOn(shell, 'openExternal')
view.refs.packageRepo.click()
expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/example/package-with-readme')
it "should open internal package repository url", ->
loadPackageFromRemote('package-internal')
spyOn(shell, 'openExternal')
view.refs.packageRepo.click()
expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/atom/atom/tree/master/packages/package-internal')

View File

@ -0,0 +1,305 @@
path = require 'path'
process = require 'process'
PackageManager = require '../lib/package-manager'
describe "PackageManager", ->
[packageManager] = []
beforeEach ->
spyOn(atom.packages, 'getApmPath').andReturn('/an/invalid/apm/command/to/run')
atom.config.set('core.useProxySettingsWhenCallingApm', false)
packageManager = new PackageManager()
it "handle errors spawning apm", ->
noSuchCommandError = if process.platform is 'win32' then ' cannot find the path ' else 'ENOENT'
waitsForPromise shouldReject: true, -> packageManager.getInstalled()
waitsForPromise shouldReject: true, -> packageManager.getOutdated()
waitsForPromise shouldReject: true, -> packageManager.getFeatured()
waitsForPromise shouldReject: true, -> packageManager.getPackage('foo')
installCallback = jasmine.createSpy('installCallback')
uninstallCallback = jasmine.createSpy('uninstallCallback')
updateCallback = jasmine.createSpy('updateCallback')
runs ->
packageManager.install {name: 'foo', version: '1.0.0'}, installCallback
waitsFor ->
installCallback.callCount is 1
runs ->
installArg = installCallback.argsForCall[0][0]
expect(installArg.message).toBe "Installing \u201Cfoo@1.0.0\u201D failed."
expect(installArg.packageInstallError).toBe true
expect(installArg.stderr).toContain noSuchCommandError
packageManager.uninstall {name: 'foo'}, uninstallCallback
waitsFor ->
uninstallCallback.callCount is 1
runs ->
uninstallArg = uninstallCallback.argsForCall[0][0]
expect(uninstallArg.message).toBe "Uninstalling \u201Cfoo\u201D failed."
expect(uninstallArg.stderr).toContain noSuchCommandError
packageManager.update {name: 'foo'}, '1.0.0', updateCallback
waitsFor ->
updateCallback.callCount is 1
runs ->
updateArg = updateCallback.argsForCall[0][0]
expect(updateArg.message).toBe "Updating to \u201Cfoo@1.0.0\u201D failed."
expect(updateArg.packageInstallError).toBe true
expect(updateArg.stderr).toContain noSuchCommandError
describe "::isPackageInstalled()", ->
it "returns false a package is not installed", ->
expect(packageManager.isPackageInstalled('some-package')).toBe false
it "returns true when a package is loaded", ->
spyOn(atom.packages, 'isPackageLoaded').andReturn true
expect(packageManager.isPackageInstalled('some-package')).toBe true
it "returns true when a package is disabled", ->
spyOn(atom.packages, 'getAvailablePackageNames').andReturn ['some-package']
expect(packageManager.isPackageInstalled('some-package')).toBe true
describe "::install()", ->
[runArgs, runCallback] = []
beforeEach ->
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
runArgs = args
runCallback = callback
onWillThrowError: ->
it "installs the latest version when a package version is not specified", ->
packageManager.install {name: 'something'}, ->
expect(packageManager.runCommand).toHaveBeenCalled()
expect(runArgs).toEqual ['install', 'something', '--json']
it "installs the package@version when a version is specified", ->
packageManager.install {name: 'something', version: '0.2.3'}, ->
expect(packageManager.runCommand).toHaveBeenCalled()
expect(runArgs).toEqual ['install', 'something@0.2.3', '--json']
describe "git url installation", ->
it 'installs https:// urls', ->
url = "https://github.com/user/repo.git"
packageManager.install {name: url}
expect(packageManager.runCommand).toHaveBeenCalled()
expect(runArgs).toEqual ['install', 'https://github.com/user/repo.git', '--json']
it 'installs git@ urls', ->
url = "git@github.com:user/repo.git"
packageManager.install {name: url}
expect(packageManager.runCommand).toHaveBeenCalled()
expect(runArgs).toEqual ['install', 'git@github.com:user/repo.git', '--json']
it 'installs user/repo url shortcuts', ->
url = "user/repo"
packageManager.install {name: url}
expect(packageManager.runCommand).toHaveBeenCalled()
expect(runArgs).toEqual ['install', 'user/repo', '--json']
it 'installs and activates git pacakges with names different from the repo name', ->
spyOn(atom.packages, 'activatePackage')
packageManager.install(name: 'git-repo-name')
json =
metadata:
name: 'real-package-name'
runCallback(0, JSON.stringify([json]), '')
expect(atom.packages.activatePackage).toHaveBeenCalledWith json.metadata.name
it 'emits an installed event with a copy of the pack including the full package metadata', ->
spyOn(packageManager, 'emitPackageEvent')
originalPackObject = name: 'git-repo-name', otherData: {will: 'beCopied'}
packageManager.install(originalPackObject)
json =
metadata:
name: 'real-package-name'
moreInfo: 'yep'
runCallback(0, JSON.stringify([json]), '')
installEmittedCount = 0
for call in packageManager.emitPackageEvent.calls
if call.args[0] is "installed"
expect(call.args[1]).not.toEqual originalPackObject
expect(call.args[1].moreInfo).toEqual "yep"
expect(call.args[1].otherData).toBe originalPackObject.otherData
installEmittedCount++
expect(installEmittedCount).toBe 1
describe "::uninstall()", ->
[runCallback] = []
beforeEach ->
spyOn(packageManager, 'unload')
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
runCallback = callback
onWillThrowError: ->
it "removes the package from the core.disabledPackages list", ->
atom.config.set('core.disabledPackages', ['something'])
packageManager.uninstall {name: 'something'}, ->
expect(atom.config.get('core.disabledPackages')).toContain('something')
runCallback(0, '', '')
expect(atom.config.get('core.disabledPackages')).not.toContain('something')
describe "::packageHasSettings", ->
it "returns true when the pacakge has config", ->
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-config'))
expect(packageManager.packageHasSettings('package-with-config')).toBe true
it "returns false when the pacakge does not have config and doesn't define language grammars", ->
expect(packageManager.packageHasSettings('random-package')).toBe false
it "returns true when the pacakge does not have config, but does define language grammars", ->
packageName = 'language-test'
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', packageName))
runs ->
expect(packageManager.packageHasSettings(packageName)).toBe true
describe "::loadOutdated", ->
it "caches results", ->
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}]', '')
onWillThrowError: ->
packageManager.loadOutdated false, ->
expect(packageManager.apmCache.loadOutdated.value).toMatch([{"name": "boop"}])
packageManager.loadOutdated false, ->
expect(packageManager.runCommand.calls.length).toBe(1)
it "expires results after a timeout", ->
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}]', '')
onWillThrowError: ->
packageManager.loadOutdated false, ->
now = Date.now()
spyOn(Date, 'now') unless Date.now.andReturn
Date.now.andReturn((-> now + packageManager.CACHE_EXPIRY + 1)())
packageManager.loadOutdated false, ->
expect(packageManager.runCommand.calls.length).toBe(2)
it "expires results after a package updated/installed", ->
packageManager.apmCache.loadOutdated =
value: ['hi']
expiry: Date.now() + 999999999
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}]', '')
onWillThrowError: ->
# Just prevent this stuff from calling through, it doesn't matter for this test
spyOn(atom.packages, 'deactivatePackage').andReturn(true)
spyOn(atom.packages, 'activatePackage').andReturn(true)
spyOn(atom.packages, 'unloadPackage').andReturn(true)
spyOn(atom.packages, 'loadPackage').andReturn(true)
packageManager.loadOutdated false, ->
expect(packageManager.runCommand.calls.length).toBe(0)
packageManager.update {}, {}, -> # +1 runCommand call to update the package
packageManager.loadOutdated false, -> # +1 runCommand call to load outdated because the cache should be wiped
expect(packageManager.runCommand.calls.length).toBe(2)
packageManager.install {}, -> # +1 runCommand call to install the package
packageManager.loadOutdated false, -> # +1 runCommand call to load outdated because the cache should be wiped
expect(packageManager.runCommand.calls.length).toBe(4)
packageManager.loadOutdated false, -> # +0 runCommand call, should be cached
expect(packageManager.runCommand.calls.length).toBe(4)
it "expires results if it is called with clearCache set to true", ->
packageManager.apmCache.loadOutdated =
value: ['hi']
expiry: Date.now() + 999999999
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}]', '')
onWillThrowError: ->
packageManager.loadOutdated true, ->
expect(packageManager.runCommand.calls.length).toBe(1)
expect(packageManager.apmCache.loadOutdated.value).toEqual [{"name": "boop"}]
describe "when there is a version pinned package", ->
beforeEach ->
atom.config.set('core.versionPinnedPackages', ['beep'])
it "caches results", ->
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}, {"name": "beep"}]', '')
onWillThrowError: ->
packageManager.loadOutdated false, ->
expect(packageManager.apmCache.loadOutdated.value).toMatch([{"name": "boop"}])
packageManager.loadOutdated false, ->
expect(packageManager.runCommand.calls.length).toBe(1)
it "expires results after a timeout", ->
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}, {"name": "beep"}]', '')
onWillThrowError: ->
packageManager.loadOutdated false, ->
now = Date.now()
spyOn(Date, 'now') unless Date.now.andReturn
Date.now.andReturn((-> now + packageManager.CACHE_EXPIRY + 1)())
packageManager.loadOutdated false, ->
expect(packageManager.runCommand.calls.length).toBe(2)
it "expires results after a package updated/installed", ->
packageManager.apmCache.loadOutdated =
value: ['hi']
expiry: Date.now() + 999999999
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}, {"name": "beep"}]', '')
onWillThrowError: ->
# Just prevent this stuff from calling through, it doesn't matter for this test
spyOn(atom.packages, 'deactivatePackage').andReturn(true)
spyOn(atom.packages, 'activatePackage').andReturn(true)
spyOn(atom.packages, 'unloadPackage').andReturn(true)
spyOn(atom.packages, 'loadPackage').andReturn(true)
packageManager.loadOutdated false, ->
expect(packageManager.runCommand.calls.length).toBe(0)
packageManager.update {}, {}, -> # +1 runCommand call to update the package
packageManager.loadOutdated false, -> # +1 runCommand call to load outdated because the cache should be wiped
expect(packageManager.runCommand.calls.length).toBe(2)
packageManager.install {}, -> # +1 runCommand call to install the package
packageManager.loadOutdated false, -> # +1 runCommand call to load outdated because the cache should be wiped
expect(packageManager.runCommand.calls.length).toBe(4)
packageManager.loadOutdated false, -> # +0 runCommand call, should be cached
expect(packageManager.runCommand.calls.length).toBe(4)
it "expires results if it is called with clearCache set to true", ->
packageManager.apmCache.loadOutdated =
value: ['hi']
expiry: Date.now() + 999999999
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
callback(0, '[{"name": "boop"}, {"name": "beep"}]', '')
onWillThrowError: ->
packageManager.loadOutdated true, ->
expect(packageManager.runCommand.calls.length).toBe(1)
expect(packageManager.apmCache.loadOutdated.value).toEqual [{"name": "boop"}]

View File

@ -0,0 +1,152 @@
PackageManager = require '../lib/package-manager'
PackageUpdatesStatusView = require '../lib/package-updates-status-view'
describe "PackageUpdatesStatusView", ->
[statusBar, statusView, packageManager] = []
outdatedPackage1 =
name: 'out-dated-1'
outdatedPackage2 =
name: 'out-dated-2'
installedPackage =
name: 'user-package'
beforeEach ->
spyOn(PackageManager.prototype, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(PackageManager.prototype, 'getInstalled').andCallFake -> Promise.resolve([installedPackage])
spyOn(PackageManager.prototype, 'getOutdated').andCallFake -> Promise.resolve([outdatedPackage1, outdatedPackage2])
spyOn(PackageUpdatesStatusView.prototype, 'initialize').andCallThrough()
jasmine.attachToDOM(atom.views.getView(atom.workspace))
waitsForPromise ->
atom.packages.activatePackage('status-bar')
waitsForPromise ->
atom.packages.activatePackage('settings-view')
runs ->
atom.packages.emitter.emit('did-activate-all')
expect(document.querySelector('status-bar .package-updates-status-view')).toExist()
packageManager = PackageUpdatesStatusView.prototype.initialize.mostRecentCall.args[1]
describe "when packages are outdated", ->
it "adds a tile to the status bar", ->
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '2 updates'
describe "when the tile is clicked", ->
it "opens the Available Updates panel", ->
spyOn(atom.commands, 'dispatch').andCallFake ->
document.querySelector('status-bar .package-updates-status-view').click()
expect(atom.commands.dispatch).toHaveBeenCalledWith(atom.views.getView(atom.workspace), 'settings-view:check-for-package-updates')
it "does not destroy the tile", ->
document.querySelector('status-bar .package-updates-status-view').click()
expect(document.querySelector('status-bar .package-updates-status-view')).toExist()
describe "when a package is updating", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updating', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1/2 updating'
describe "when a package finishes updating", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updating', outdatedPackage1)
packageManager.emitPackageEvent('updated', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'
describe "when a package is updated without a prior updating event", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updated', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'
describe "when multiple packages are updating and one finishes", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updating', outdatedPackage1)
packageManager.emitPackageEvent('updating', outdatedPackage2)
packageManager.emitPackageEvent('updated', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1/1 updating'
describe "when a package fails to update", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updating', outdatedPackage1)
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '2 updates (1 failed)'
describe "when a package that previously failed to update starts updating again", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updating', outdatedPackage1)
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
packageManager.emitPackageEvent('updating', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1/2 updating'
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '2 updates (1 failed)'
describe "when a package update that previously failed succeeds on a subsequent try", ->
it "updates the tile", ->
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
packageManager.emitPackageEvent('updated', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'
describe "when multiple events are happening at the same time", ->
it "updates the tile", ->
packageManager.emitPackageEvent('update-available', installedPackage)
packageManager.emitPackageEvent('updating', outdatedPackage1)
packageManager.emitPackageEvent('update-failed', outdatedPackage2)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1/3 updating (1 failed)'
describe "when there are no more updates", ->
it "destroys the tile", ->
packageManager.emitPackageEvent('updated', outdatedPackage1)
packageManager.emitPackageEvent('updated', outdatedPackage2)
expect(document.querySelector('status-bar .package-updates-status-view')).not.toExist()
describe "when a new update becomes available and the tile is destroyed", ->
it "recreates the tile", ->
packageManager.emitPackageEvent('updated', outdatedPackage1)
packageManager.emitPackageEvent('updated', outdatedPackage2)
packageManager.emitPackageEvent('update-available', installedPackage)
expect(document.querySelector('status-bar .package-updates-status-view')).toExist()
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'
describe "when an update becomes available for a package", ->
it "updates the tile", ->
packageManager.emitPackageEvent('update-available', installedPackage)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '3 updates'
describe "when updates are checked for multiple times and no new updates are available", ->
it "does not keep updating the tile", ->
packageManager.emitPackageEvent('update-available', outdatedPackage1)
packageManager.emitPackageEvent('update-available', outdatedPackage1)
packageManager.emitPackageEvent('update-available', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '2 updates'
# There are more fields in an actual package object,
# so make sure only name is tested and not object equality
packageManager.emitPackageEvent('update-available', {name: 'out-dated-1', date: Date.now()})
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '2 updates'
describe "when the same update fails multiple times", ->
it "does not keep updating the tile", ->
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '2 updates (1 failed)'
describe "when a package that can be updated is uninstalled", ->
it "updates the tile", ->
packageManager.emitPackageEvent('uninstalled', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'
describe "when a package that is updating is uninstalled", ->
it "updates the tile", ->
packageManager.emitPackageEvent('updating', outdatedPackage1)
packageManager.emitPackageEvent('uninstalled', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'
describe "when a package that failed to update is uninstalled", ->
it "updates the tile", ->
packageManager.emitPackageEvent('update-failed', outdatedPackage1)
packageManager.emitPackageEvent('uninstalled', outdatedPackage1)
expect(document.querySelector('status-bar .package-updates-status-view').textContent).toBe '1 update'

View File

@ -0,0 +1,159 @@
{getSettingDescription} = require '../lib/rich-description'
describe "Rich descriptions", ->
beforeEach ->
config =
type: 'object'
properties:
plainText:
description: 'Plain text description'
type: 'string'
default: ''
italics:
description: 'Description *with* italics'
type: 'string'
default: ''
bold:
description: 'Description **with** bold'
type: 'string'
default: ''
link:
description: 'Description [with](http://www.example.com) link'
type: 'string'
default: ''
inlineCode:
description: 'Description `with` inline code'
type: 'string'
default: ''
lineBreak:
description: 'Description with<br/> line break'
type: 'string'
default: ''
strikethrough:
description: 'Description ~~with~~ strikethrough'
type: 'string'
default: ''
image:
description: 'Description without ![alt text](https://github.com/adam-p/markdown-here/raw/master/src/common/images/icon48.png "Logo Title Text 1") image'
type: 'string'
default: ''
fencedBlockCode:
description: '''Description without fenced block code
```
Test
```
'''
type: 'string'
default: ''
indentedBlockCode:
description: '''
Description without indented block code
Test
'''
type: 'string'
default: ''
blockquote:
description: '''
Description without blockquote
> Test
'''
type: 'string'
default: ''
html:
description: '''
Description without html
<html>Test</html>
'''
type: 'string'
default: ''
heading:
description: '''
Description without heading
## Test
'''
type: 'string'
default: ''
orderedList:
description: '''
Description without ordered list
1. Test
2. Test
3. Test
'''
type: 'string'
default: ''
unorderedList:
description: '''
Description without unordered list
* Test
* Test
* Test
'''
type: 'string'
default: ''
table:
description: '''
Description without table
<table><tr><td>Test</td></tr></table>
'''
type: 'string'
default: ''
atom.config.setSchema("foo", config)
describe 'supported Markdown', ->
it 'handles plain text', ->
expect(getSettingDescription('foo.plainText')).toEqual 'Plain text description'
it 'handles italics', ->
expect(getSettingDescription('foo.italics')).toEqual 'Description <em>with</em> italics'
it 'handles bold', ->
expect(getSettingDescription('foo.bold')).toEqual 'Description <strong>with</strong> bold'
it 'handles links', ->
expect(getSettingDescription('foo.link')).toEqual 'Description <a href="http://www.example.com">with</a> link'
it 'handles inline code', ->
expect(getSettingDescription('foo.inlineCode')).toEqual 'Description <code>with</code> inline code'
it 'handles line breaks', ->
expect(getSettingDescription('foo.lineBreak')).toEqual 'Description with<br/> line break'
it 'handles strikethrough', ->
expect(getSettingDescription('foo.strikethrough')).toEqual 'Description <del>with</del> strikethrough'
describe 'unsupported Markdown', ->
it 'strips images', ->
expect(getSettingDescription('foo.image')).toEqual 'Description without image'
it 'strips fenced code blocks', ->
expect(getSettingDescription('foo.fencedBlockCode')).toEqual 'Description without fenced block code'
it 'strips indented code blocks', ->
expect(getSettingDescription('foo.indentedBlockCode')).toEqual 'Description without indented block code'
it 'strips blockquotes', ->
expect(getSettingDescription('foo.blockquote')).toEqual 'Description without blockquote'
it 'strips html elements', ->
expect(getSettingDescription('foo.html')).toEqual 'Description without html'
it 'strips headings', ->
expect(getSettingDescription('foo.heading')).toEqual 'Description without heading'
it 'strips ordered lists', ->
expect(getSettingDescription('foo.orderedList')).toEqual 'Description without ordered list'
it 'strips unordered lists', ->
expect(getSettingDescription('foo.unorderedList')).toEqual 'Description without unordered list'
it 'strips tables', ->
expect(getSettingDescription('foo.table')).toEqual 'Description without table'

View File

@ -0,0 +1,381 @@
SettingsPanel = require '../lib/settings-panel'
_ = require 'underscore-plus'
describe "SettingsPanel", ->
settingsPanel = null
describe "sorted settings", ->
beforeEach ->
config =
type: 'object'
properties:
bar:
title: 'Bar'
description: 'The bar setting'
type: 'boolean'
default: true
haz:
title: 'Haz'
description: 'The haz setting'
type: 'string'
default: 'haz'
zing:
title: 'Zing'
description: 'The zing setting'
type: 'string'
default: 'zing'
order: 1
zang:
title: 'Zang'
description: 'The baz setting'
type: 'string'
default: 'zang'
order: 100
enum:
title: 'An enum'
type: 'string'
default: 'one'
enum: [
{value: 'one', description: 'One'}
'Two'
]
radio:
title: 'An enum with radio buttons'
radio: true
type: 'string'
default: 'Two'
enum: [
{value: 'one', description: 'One'}
'Two'
]
atom.config.setSchema("foo", config)
atom.config.setDefaults("foo", gong: 'gong')
expect(_.size(atom.config.get('foo'))).toBe 7
settingsPanel = new SettingsPanel({namespace: "foo", includeTitle: false})
it "sorts settings by order and then alphabetically by the key", ->
settings = atom.config.get('foo')
expect(_.size(settings)).toBe 7
sortedSettings = settingsPanel.sortSettings("foo", settings)
expect(sortedSettings[0]).toBe 'zing'
expect(sortedSettings[1]).toBe 'zang'
expect(sortedSettings[2]).toBe 'bar'
expect(sortedSettings[3]).toBe 'enum'
expect(sortedSettings[4]).toBe 'gong'
expect(sortedSettings[5]).toBe 'haz'
expect(sortedSettings[6]).toBe 'radio'
it "gracefully deals with a null settings object", ->
sortedSettings = settingsPanel.sortSettings("foo", null)
expect(sortedSettings).not.toBeNull
expect(_.size(sortedSettings)).toBe 0
it "presents enum options with their descriptions", ->
select = settingsPanel.element.querySelector('#foo\\.enum')
pairs = ([opt.value, opt.innerText] for opt in select.children)
expect(pairs).toEqual([['one', 'One'], ['Two', 'Two']])
it "presents radio options with their descriptions", ->
radio = settingsPanel.element.querySelector('#foo\\.radio')
options = for label in radio.querySelectorAll 'label'
button = label.querySelector('input[type=radio][name="foo.radio"]')
[button.id, button.value, label.innerText]
expect(options).toEqual([['foo.radio[one]', 'one', 'One'], ['foo.radio[Two]', 'Two', 'Two']])
describe 'default settings', ->
beforeEach ->
config =
type: 'object'
properties:
haz:
name: 'haz'
title: 'Haz'
description: 'The haz setting'
type: 'string'
default: 'haz'
qux:
name: 'qux'
title: 'Qux'
description: 'The qux setting'
type: 'string'
default: 'a'
enum: [
{value: 'a', description: 'Alice'},
{value: 'b', description: 'Bob'}
]
testZero:
name: 'testZero'
title: 'Test Zero'
description: 'Setting for testing zero as a default'
type: 'integer'
default: 0
radio:
title: 'An enum with radio buttons'
radio: true
type: 'string'
default: 'Two'
enum: [
{value: 'one', description: 'One'}
'Two'
'Three'
]
atom.config.setSchema("foo", config)
atom.config.setDefaults("foo", gong: 'gong')
expect(_.size(atom.config.get('foo'))).toBe 5
settingsPanel = new SettingsPanel({namespace: "foo", includeTitle: false})
it 'ensures default stays default', ->
expect(settingsPanel.getDefault('foo.haz')).toBe 'haz'
expect(settingsPanel.isDefault('foo.haz')).toBe true
settingsPanel.set('foo.haz', 'haz')
expect(settingsPanel.isDefault('foo.haz')).toBe true
it 'can be overwritten', ->
expect(settingsPanel.getDefault('foo.haz')).toBe 'haz'
expect(settingsPanel.isDefault('foo.haz')).toBe true
settingsPanel.set('foo.haz', 'newhaz')
expect(settingsPanel.isDefault('foo.haz')).toBe false
expect(atom.config.get('foo.haz')).toBe 'newhaz'
it 'has a tooltip showing the default value', ->
hazEditor = settingsPanel.element.querySelector('[id="foo.haz"]')
tooltips = atom.tooltips.findTooltips(hazEditor)
expect(tooltips).toHaveLength 1
title = tooltips[0].options.title
expect(title).toBe "Default: haz"
it 'has a tooltip showing the description of the default value', ->
quxEditor = settingsPanel.element.querySelector('[id="foo.qux"]')
tooltips = atom.tooltips.findTooltips(quxEditor)
expect(tooltips).toHaveLength 1
title = tooltips[0].options.title
expect(title).toBe "Default: Alice"
# Regression test for #783
it 'allows 0 to be a default', ->
zeroEditor = settingsPanel.element.querySelector('[id="foo.testZero"]')
expect(zeroEditor.getModel().getText()).toBe('')
expect(zeroEditor.getModel().getPlaceholderText()).toBe('Default: 0')
expect(settingsPanel.getDefault('foo.testZero')).toBe 0
expect(settingsPanel.isDefault('foo.testZero')).toBe true
settingsPanel.set('foo.testZero', 15)
expect(settingsPanel.isDefault('foo.testZero')).toBe false
settingsPanel.set('foo.testZero', 0)
expect(settingsPanel.isDefault('foo.testZero')).toBe true
it "selects the default choice for radio options", ->
expect(settingsPanel.getDefault 'foo.radio').toBe 'Two'
settingsPanel.set 'foo.radio', 'Two'
expect(settingsPanel.element.querySelector '#foo\\.radio\\[Two\\]').toBeChecked()
describe 'scoped settings', ->
beforeEach ->
schema =
scopes:
'.source.python':
default: 4
atom.config.setScopedDefaultsFromSchema('editor.tabLength', schema)
expect(atom.config.get('editor.tabLength')).toBe(2)
it 'displays the scoped default', ->
settingsPanel = new SettingsPanel({namespace: "editor", includeTitle: false, scopeName: '.source.python'})
tabLengthEditor = settingsPanel.element.querySelector('[id="editor.tabLength"]')
expect(tabLengthEditor.getModel().getText()).toBe('')
expect(tabLengthEditor.getModel().getPlaceholderText()).toBe('Default: 4')
it 'allows the scoped setting to be changed to its normal default if the unscoped value is different', ->
atom.config.set('editor.tabLength', 8)
settingsPanel = new SettingsPanel({namespace: "editor", includeTitle: false, scopeName: '.source.js'})
tabLengthEditor = settingsPanel.element.querySelector('[id="editor.tabLength"]')
expect(tabLengthEditor.getModel().getText()).toBe('')
expect(tabLengthEditor.getModel().getPlaceholderText()).toBe('Default: 8')
# This is the unscoped default, but it differs from the current unscoped value
settingsPanel.set('editor.tabLength', 2)
expect(tabLengthEditor.getModel().getText()).toBe('2')
expect(atom.config.get('editor.tabLength', {scope: ['source.js']})).toBe(2)
it 'allows the scoped setting to be changed to the unscoped default if it is different', ->
settingsPanel = new SettingsPanel({namespace: "editor", includeTitle: false, scopeName: '.source.python'})
tabLengthEditor = settingsPanel.element.querySelector('[id="editor.tabLength"]')
expect(tabLengthEditor.getModel().getText()).toBe('')
expect(tabLengthEditor.getModel().getPlaceholderText()).toBe('Default: 4')
# This is the unscoped default, but it differs from the scoped default
settingsPanel.set('editor.tabLength', 2)
expect(tabLengthEditor.getModel().getText()).toBe('2')
expect(atom.config.get('editor.tabLength', {scope: ['source.python']})).toBe(2)
describe 'grouped settings', ->
beforeEach ->
config =
type: 'object'
properties:
barGroup:
type: 'object'
title: 'Bar group'
description: 'description of bar group'
properties:
bar:
title: 'Bar'
description: 'The bar setting'
type: 'boolean'
default: false
bazGroup:
type: 'object'
collapsed: true
properties:
baz:
title: 'Baz'
description: 'The baz setting'
type: 'boolean'
default: false
zing:
type: 'string'
default: ''
atom.config.setSchema('foo', config)
expect(_.size(atom.config.get('foo'))).toBe 3
settingsPanel = new SettingsPanel({namespace: 'foo', includeTitle: false})
it 'ensures that only grouped settings have a group title', ->
expect(settingsPanel.element.querySelectorAll('.section-container > .section-body')).toHaveLength 1
controlGroups = settingsPanel.element.querySelectorAll('.section-body > .control-group')
expect(controlGroups).toHaveLength 3
expect(controlGroups[0].querySelectorAll('.sub-section .sub-section-heading')).toHaveLength 1
expect(controlGroups[0].querySelector('.sub-section .sub-section-heading').textContent).toBe 'Bar group'
expect(controlGroups[0].querySelectorAll('.sub-section .sub-section-body')).toHaveLength 1
subsectionBody = controlGroups[0].querySelector('.sub-section .sub-section-body')
expect(subsectionBody.querySelectorAll('.control-group')).toHaveLength 1
expect(controlGroups[1].querySelectorAll('.sub-section .sub-section-heading')).toHaveLength 1
expect(controlGroups[1].querySelector('.sub-section .sub-section-heading').textContent).toBe 'Baz Group'
expect(controlGroups[1].querySelectorAll('.sub-section .sub-section-body')).toHaveLength 1
subsectionBody = controlGroups[1].querySelector('.sub-section .sub-section-body')
expect(subsectionBody.querySelectorAll('.control-group')).toHaveLength 1
expect(controlGroups[2].querySelectorAll('.sub-section')).toHaveLength 0
expect(controlGroups[2].querySelectorAll('.sub-section-heading')).toHaveLength 0
it 'ensures grouped settings are collapsable', ->
expect(settingsPanel.element.querySelectorAll('.section-container > .section-body')).toHaveLength 1
controlGroups = settingsPanel.element.querySelectorAll('.section-body > .control-group')
expect(controlGroups).toHaveLength 3
# Bar group
expect(controlGroups[0].querySelectorAll('.sub-section .sub-section-heading')).toHaveLength 1
expect(controlGroups[0].querySelector('.sub-section .sub-section-heading').classList.contains('has-items')).toBe true
# Baz Group
expect(controlGroups[1].querySelectorAll('.sub-section .sub-section-heading')).toHaveLength 1
expect(controlGroups[1].querySelector('.sub-section .sub-section-heading').classList.contains('has-items')).toBe true
# Should be already collapsed
expect(controlGroups[1].querySelector('.sub-section .sub-section-heading').parentElement.classList.contains('collapsed')).toBe true
it 'ensures grouped settings can have a description', ->
expect(settingsPanel.element.querySelectorAll('.section-container > .section-body')).toHaveLength 1
controlGroups = settingsPanel.element.querySelectorAll('.section-body > .control-group')
expect(controlGroups).toHaveLength 3
expect(controlGroups[0].querySelectorAll('.sub-section > .setting-description')).toHaveLength 1
expect(controlGroups[0].querySelector('.sub-section > .setting-description').textContent).toBe 'description of bar group'
describe 'settings validation', ->
beforeEach ->
config =
type: 'object'
properties:
minMax:
name: 'minMax'
title: 'Min max'
description: 'The minMax setting'
type: 'integer'
default: 10
minimum: 1
maximum: 100
commaValueArray:
name: 'commaValueArray'
title: 'Comma value in array'
description: 'An array with a comma value'
type: 'array'
default: []
atom.config.setSchema('foo', config)
settingsPanel = new SettingsPanel({namespace: 'foo', includeTitle: false})
it 'prevents setting a value below the minimum', ->
minMaxEditor = settingsPanel.element.querySelector('[id="foo.minMax"]')
minMaxEditor.getModel().setText('0')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '1'
minMaxEditor.getModel().setText('-5')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '1'
it 'prevents setting a value above the maximum', ->
minMaxEditor = settingsPanel.element.querySelector('[id="foo.minMax"]')
minMaxEditor.getModel().setText('1000')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '100'
minMaxEditor.getModel().setText('10000')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '100'
it 'prevents setting a value that cannot be coerced to the correct type', ->
minMaxEditor = settingsPanel.element.querySelector('[id="foo.minMax"]')
minMaxEditor.getModel().setText('"abcde"')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '' # aka default
minMaxEditor.getModel().setText('15')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '15'
minMaxEditor.getModel().setText('"abcde"')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '15'
it 'allows setting a valid scoped value', ->
settingsPanel = new SettingsPanel({namespace: 'foo', includeTitle: false, scopeName: 'source.js'})
minMaxEditor = settingsPanel.element.querySelector('atom-text-editor')
minMaxEditor.getModel().setText('15')
advanceClock(minMaxEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(minMaxEditor.getModel().getText()).toBe '15'
describe 'commaValueArray', ->
it 'comma in value is escaped', ->
commaValueArrayEditor = settingsPanel.element.querySelector('[id="foo.commaValueArray"]')
commaValueArrayEditor.getModel().setText('1, \\,, 2')
advanceClock(commaValueArrayEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(atom.config.get("foo.commaValueArray")).toEqual ['1', ',', '2']
commaValueArrayEditor.getModel().setText('1\\, 2')
advanceClock(commaValueArrayEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(atom.config.get('foo.commaValueArray')).toEqual ['1, 2']
commaValueArrayEditor.getModel().setText('1\\,')
advanceClock(commaValueArrayEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(atom.config.get('foo.commaValueArray')).toEqual ['1,']
commaValueArrayEditor.getModel().setText('\\, 2')
advanceClock(commaValueArrayEditor.getModel().getBuffer().getStoppedChangingDelay())
expect(atom.config.get('foo.commaValueArray')).toEqual [', 2']
it 'renders an escaped comma', ->
commaValueArrayEditor = settingsPanel.element.querySelector('[id="foo.commaValueArray"]')
atom.config.set('foo.commaValueArray', ['3', ',', '4'])
advanceClock(1000)
expect(commaValueArrayEditor.getModel().getText()).toBe '3, \\,, 4'
atom.config.set('foo.commaValueArray', ['3, 4'])
advanceClock(1000)
expect(commaValueArrayEditor.getModel().getText()).toBe '3\\, 4'
atom.config.set('foo.commaValueArray', ['3,'])
advanceClock(1000)
expect(commaValueArrayEditor.getModel().getText()).toBe '3\\,'
atom.config.set('foo.commaValueArray', [', 4'])
advanceClock(1000)
expect(commaValueArrayEditor.getModel().getText()).toBe '\\, 4'

View File

@ -0,0 +1,519 @@
path = require 'path'
main = require '../lib/main'
PackageManager = require '../lib/package-manager'
SettingsView = require '../lib/settings-view'
SnippetsProvider =
getSnippets: -> {}
describe "SettingsView", ->
settingsView = null
packageManager = new PackageManager()
beforeEach ->
settingsView = main.createSettingsView({packageManager: packageManager, snippetsProvider: SnippetsProvider})
spyOn(settingsView, "initializePanels").andCallThrough()
window.advanceClock(10000)
waitsFor ->
settingsView.initializePanels.callCount > 0
describe "serialization", ->
it "remembers which panel was visible", ->
settingsView.showPanel('Themes')
newSettingsView = main.createSettingsView(settingsView.serialize())
settingsView.destroy()
jasmine.attachToDOM(newSettingsView.element)
newSettingsView.initializePanels()
expect(newSettingsView.activePanel).toEqual {name: 'Themes', options: {}}
it "shows the previously active panel if it is added after deserialization", ->
settingsView.addCorePanel('Panel 1', 'panel-1', ->
div = document.createElement('div')
div.id = 'panel-1'
{
element: div,
show: -> div.style.display = '',
focus: -> div.focus(),
destroy: -> div.remove()
}
)
settingsView.showPanel('Panel 1')
newSettingsView = main.createSettingsView(settingsView.serialize())
newSettingsView.addPanel('Panel 1', ->
div = document.createElement('div')
div.id = 'panel-1'
{
element: div,
show: -> div.style.display = '',
focus: -> div.focus(),
destroy: -> div.remove()
}
)
newSettingsView.initializePanels()
jasmine.attachToDOM(newSettingsView.element)
expect(newSettingsView.activePanel).toEqual {name: 'Panel 1', options: {}}
it "shows the Settings panel if the last saved active panel name no longer exists", ->
settingsView.addCorePanel('Panel 1', 'panel1', ->
div = document.createElement('div')
div.id = 'panel-1'
{
element: div,
show: -> div.style.display = '',
focus: -> div.focus(),
destroy: -> div.remove()
}
)
settingsView.showPanel('Panel 1')
newSettingsView = main.createSettingsView(settingsView.serialize())
settingsView.destroy()
jasmine.attachToDOM(newSettingsView.element)
newSettingsView.initializePanels()
expect(newSettingsView.activePanel).toEqual {name: 'Core', options: {}}
it "serializes the active panel name even when the panels were never initialized", ->
settingsView.showPanel('Themes')
settingsView2 = main.createSettingsView(settingsView.serialize())
settingsView3 = main.createSettingsView(settingsView2.serialize())
jasmine.attachToDOM(settingsView3.element)
settingsView3.initializePanels()
expect(settingsView3.activePanel).toEqual {name: 'Themes', options: {}}
describe ".addCorePanel(name, iconName, view)", ->
it "adds a menu entry to the left and a panel that can be activated by clicking it", ->
settingsView.addCorePanel('Panel 1', 'panel1', ->
div = document.createElement('div')
div.id = 'panel-1'
{
element: div,
show: -> div.style.display = '',
focus: -> div.focus(),
destroy: -> div.remove()
}
)
settingsView.addCorePanel('Panel 2', 'panel2', ->
div = document.createElement('div')
div.id = 'panel-2'
{
element: div,
show: -> div.style.display = '',
focus: -> div.focus(),
destroy: -> div.remove()
}
)
expect(settingsView.refs.panelMenu.querySelector('li[name="Panel 1"]')).toExist()
expect(settingsView.refs.panelMenu.querySelector('li[name="Panel 2"]')).toExist()
expect(settingsView.refs.panelMenu.children[0]).toHaveClass 'active'
jasmine.attachToDOM(settingsView.element)
settingsView.refs.panelMenu.querySelector('li[name="Panel 1"] a').click()
expect(settingsView.refs.panelMenu.querySelectorAll('.active').length).toBe 1
expect(settingsView.refs.panelMenu.querySelector('li[name="Panel 1"]')).toHaveClass('active')
expect(settingsView.refs.panels.querySelector('#panel-1')).toBeVisible()
expect(settingsView.refs.panels.querySelector('#panel-2')).not.toExist()
settingsView.refs.panelMenu.querySelector('li[name="Panel 2"] a').click()
expect(settingsView.refs.panelMenu.querySelectorAll('.active').length).toBe 1
expect(settingsView.refs.panelMenu.querySelector('li[name="Panel 2"]')).toHaveClass('active')
expect(settingsView.refs.panels.querySelector('#panel-1')).toBeHidden()
expect(settingsView.refs.panels.querySelector('#panel-2')).toBeVisible()
describe "when the package is activated", ->
openWithCommand = (command) ->
waitsFor (done) ->
openSubscription = atom.workspace.onDidOpen ->
openSubscription.dispose()
done()
atom.commands.dispatch(atom.views.getView(atom.workspace), command)
beforeEach ->
jasmine.attachToDOM(atom.views.getView(atom.workspace))
waitsForPromise ->
atom.packages.activatePackage('settings-view')
describe "when the settings view is opened with a settings-view:* command", ->
beforeEach ->
settingsView = null
describe "settings-view:open", ->
it "opens the settings view", ->
openWithCommand('settings-view:open')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Core', options: {}
it "always open existing item in workspace", ->
center = atom.workspace.getCenter()
[pane1, pane2] = []
waitsForPromise -> atom.workspace.open(null, split: 'right')
runs ->
expect(center.getPanes()).toHaveLength(2)
[pane1, pane2] = center.getPanes()
expect(atom.workspace.getActivePane()).toBe(pane2)
openWithCommand('settings-view:open')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel).toEqual name: 'Core', options: {}
expect(atom.workspace.getActivePane()).toBe(pane2)
runs ->
pane1.activate()
openWithCommand('settings-view:open')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel).toEqual name: 'Core', options: {}
expect(atom.workspace.getActivePane()).toBe(pane2)
describe "settings-view:core", ->
it "opens the core settings view", ->
openWithCommand('settings-view:editor')
runs ->
openWithCommand('settings-view:core')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Core', options: uri: 'atom://config/core'
describe "settings-view:editor", ->
it "opens the editor settings view", ->
openWithCommand('settings-view:editor')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Editor', options: uri: 'atom://config/editor'
describe "settings-view:show-keybindings", ->
it "opens the settings view to the keybindings page", ->
openWithCommand('settings-view:show-keybindings')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Keybindings', options: uri: 'atom://config/keybindings'
describe "settings-view:change-themes", ->
it "opens the settings view to the themes page", ->
openWithCommand('settings-view:change-themes')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Themes', options: uri: 'atom://config/themes'
describe "settings-view:uninstall-themes", ->
it "opens the settings view to the themes page", ->
openWithCommand('settings-view:uninstall-themes')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Themes', options: uri: 'atom://config/themes'
describe "settings-view:uninstall-packages", ->
it "opens the settings view to the install page", ->
openWithCommand('settings-view:uninstall-packages')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Packages', options: uri: 'atom://config/packages'
describe "settings-view:install-packages-and-themes", ->
it "opens the settings view to the install page", ->
openWithCommand('settings-view:install-packages-and-themes')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Install', options: uri: 'atom://config/install'
describe "settings-view:check-for-package-updates", ->
it "opens the settings view to the install page", ->
openWithCommand('settings-view:check-for-package-updates')
runs ->
expect(atom.workspace.getActivePaneItem().activePanel)
.toEqual name: 'Updates', options: uri: 'atom://config/updates'
describe "when atom.workspace.open() is used with a config URI", ->
focusIsWithinActivePanel = ->
activePanel = settingsView.panelsByName[settingsView.activePanel.name]
activePanel.element is document.activeElement or activePanel.element.contains(document.activeElement)
expectActivePanelToBeKeyboardScrollable = ->
activePanel = settingsView.panelsByName[settingsView.activePanel.name]
spyOn(activePanel, 'pageDown')
atom.commands.dispatch(activePanel.element, 'core:page-down')
expect(activePanel.pageDown).toHaveBeenCalled()
spyOn(activePanel, 'pageUp')
atom.commands.dispatch(activePanel.element, 'core:page-up')
expect(activePanel.pageUp).toHaveBeenCalled()
beforeEach ->
settingsView = null
it "opens the settings to the correct panel with atom://config/<panel-name> and that panel is keyboard-scrollable", ->
waitsForPromise ->
atom.workspace.open('atom://config').then (s) -> settingsView = s
waitsFor (done) -> process.nextTick(done)
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Core', options: {}
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
waitsForPromise ->
atom.workspace.open('atom://config/editor').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Editor', options: uri: 'atom://config/editor'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
waitsForPromise ->
atom.workspace.open('atom://config/keybindings').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Keybindings', options: uri: 'atom://config/keybindings'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
waitsForPromise ->
atom.workspace.open('atom://config/packages').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Packages', options: uri: 'atom://config/packages'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
waitsForPromise ->
atom.workspace.open('atom://config/themes').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Themes', options: uri: 'atom://config/themes'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
waitsForPromise ->
atom.workspace.open('atom://config/updates').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Updates', options: uri: 'atom://config/updates'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
waitsForPromise ->
atom.workspace.open('atom://config/install').then (s) -> settingsView = s
hasSystemPanel = false
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Install', options: uri: 'atom://config/install'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
hasSystemPanel = settingsView.panelsByName['System']?
if hasSystemPanel
waitsForPromise ->
atom.workspace.open('atom://config/system').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'System', options: uri: 'atom://config/system'
expect(focusIsWithinActivePanel()).toBe true
expectActivePanelToBeKeyboardScrollable()
it "opens the package settings view with atom://config/packages/<package-name>", ->
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'package-with-readme'))
waitsForPromise ->
atom.workspace.open('atom://config/packages/package-with-readme').then (s) -> settingsView = s
waitsFor (done) -> process.nextTick(done)
runs ->
expect(settingsView.activePanel)
.toEqual name: 'package-with-readme', options: {
uri: 'atom://config/packages/package-with-readme',
pack:
name: 'package-with-readme'
metadata:
name: 'package-with-readme'
back: 'Packages'
}
it "doesn't use cached package detail when package re-activated and opnes the package view with atom://config/packages/<package-name>", ->
[detailInitial, detailAfterReactivate] = []
waitsForPromise ->
atom.packages.activate()
new Promise (resolve) -> atom.packages.onDidActivateInitialPackages(resolve)
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'package-with-readme'))
waitsForPromise ->
atom.workspace.open('atom://config/packages/package-with-readme').then (s) -> settingsView = s
waitsFor (done) -> process.nextTick(done)
runs ->
detailInitial = settingsView.getOrCreatePanel('package-with-readme')
expect(settingsView.getOrCreatePanel('package-with-readme')).toBe detailInitial
waitsForPromise ->
atom.packages.deactivatePackage('package-with-readme')
waitsForPromise ->
atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'package-with-readme'))
waitsForPromise ->
atom.workspace.open('atom://config/packages/package-with-readme')
runs ->
detailAfterReactivate = settingsView.getOrCreatePanel('package-with-readme')
expect(settingsView.getOrCreatePanel('package-with-readme')).toBe detailAfterReactivate
expect(detailInitial).toBeTruthy()
expect(detailAfterReactivate).toBeTruthy()
expect(detailInitial).not.toBe(detailAfterReactivate)
it "passes the URI to a pane's beforeShow() method on settings view initialization", ->
InstallPanel = require '../lib/install-panel'
spyOn(InstallPanel::, 'beforeShow')
waitsForPromise ->
atom.workspace.open('atom://config/install/package:something').then (s) -> settingsView = s
waitsFor ->
settingsView.activePanel?
, 'The activePanel should be set', 5000
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Install', options: uri: 'atom://config/install/package:something'
expect(InstallPanel::beforeShow).toHaveBeenCalledWith {uri: 'atom://config/install/package:something'}
it "passes the URI to a pane's beforeShow() method after initialization", ->
InstallPanel = require '../lib/install-panel'
spyOn(InstallPanel::, 'beforeShow')
waitsForPromise ->
atom.workspace.open('atom://config').then (s) -> settingsView = s
waitsFor (done) -> process.nextTick(done)
runs ->
expect(settingsView.activePanel).toEqual {name: 'Core', options: {}}
waitsForPromise ->
atom.workspace.open('atom://config/install/package:something').then (s) -> settingsView = s
waits 1
runs ->
expect(settingsView.activePanel)
.toEqual name: 'Install', options: uri: 'atom://config/install/package:something'
expect(InstallPanel::beforeShow).toHaveBeenCalledWith {uri: 'atom://config/install/package:something'}
describe "when the package is then deactivated", ->
beforeEach ->
settingsView = null
it "calls the dispose method on all panels", ->
openWithCommand('settings-view:open')
waitsFor (done) -> process.nextTick(done)
runs ->
settingsView = atom.workspace.getActivePaneItem()
panels = [
settingsView.getOrCreatePanel('Core')
settingsView.getOrCreatePanel('Editor')
settingsView.getOrCreatePanel('Keybindings')
settingsView.getOrCreatePanel('Packages')
settingsView.getOrCreatePanel('Themes')
settingsView.getOrCreatePanel('Updates')
settingsView.getOrCreatePanel('Install')
]
systemPanel = settingsView.getOrCreatePanel('System')
if systemPanel?
panels.push systemPanel
for panel in panels
if panel.dispose
spyOn(panel, 'dispose')
else
spyOn(panel, 'destroy')
waitsForPromise ->
Promise.resolve(atom.packages.deactivatePackage('settings-view')) # Ensure works on promise and non-promise versions
runs ->
for panel in panels
if panel.dispose
expect(panel.dispose).toHaveBeenCalled()
else
expect(panel.destroy).toHaveBeenCalled()
return
describe "when an installed package is clicked from the Install panel", ->
it "displays the package details", ->
waitsFor ->
atom.packages.activatePackage('settings-view')
runs ->
settingsView.packageManager.getClient()
spyOn(settingsView.packageManager.client, 'featuredPackages').andCallFake (callback) ->
callback(null, [{name: 'settings-view'}])
settingsView.showPanel('Install')
waitsFor ->
settingsView.element.querySelectorAll('.package-card:not(.hidden)').length > 0
runs ->
settingsView.element.querySelectorAll('.package-card:not(.hidden)')[0].click()
packageDetail = settingsView.element.querySelector('.package-detail .active')
expect(packageDetail.textContent).toBe 'Settings View'
describe "when the active theme has settings", ->
panel = null
beforeEach ->
atom.packages.packageDirPaths.push(path.join(__dirname, 'fixtures'))
atom.packages.loadPackage('ui-theme-with-config')
atom.packages.loadPackage('syntax-theme-with-config')
atom.config.set('core.themes', ['ui-theme-with-config', 'syntax-theme-with-config'])
reloadedHandler = jasmine.createSpy('reloadedHandler')
atom.themes.onDidChangeActiveThemes(reloadedHandler)
atom.themes.activatePackages()
waitsFor "themes to be reloaded", ->
reloadedHandler.callCount is 1
runs ->
settingsView.showPanel('Themes')
panel = settingsView.element.querySelector('.themes-panel')
afterEach ->
atom.themes.unwatchUserStylesheet()
describe "when the UI theme's settings button is clicked", ->
it "navigates to that theme's detail view", ->
jasmine.attachToDOM(settingsView.element)
expect(panel.querySelector('.active-theme-settings')).toBeVisible()
panel.querySelector('.active-theme-settings').click()
packageDetail = settingsView.element.querySelector('.package-detail li.active')
expect(packageDetail.textContent).toBe 'Ui Theme With Config'
describe "when the syntax theme's settings button is clicked", ->
it "navigates to that theme's detail view", ->
jasmine.attachToDOM(settingsView.element)
expect(panel.querySelector('.active-syntax-settings')).toBeVisible()
panel.querySelector('.active-syntax-settings').click()
packageDetail = settingsView.element.querySelector('.package-detail li.active')
expect(packageDetail.textContent).toBe 'Syntax Theme With Config'

View File

@ -0,0 +1,208 @@
path = require 'path'
fs = require 'fs'
CSON = require 'season'
PackageManager = require '../lib/package-manager'
ThemesPanel = require '../lib/themes-panel'
describe "ThemesPanel", ->
[panel, packageManager, reloadedHandler] = []
settingsView = null
beforeEach ->
atom.packages.loadPackage('atom-light-ui')
atom.packages.loadPackage('atom-dark-ui')
atom.packages.loadPackage('atom-light-syntax')
atom.packages.loadPackage('atom-dark-syntax')
atom.packages.packageDirPaths.push(path.join(__dirname, 'fixtures'))
atom.config.set('core.themes', ['atom-dark-ui', 'atom-dark-syntax'])
reloadedHandler = jasmine.createSpy('reloadedHandler')
atom.themes.onDidChangeActiveThemes(reloadedHandler)
atom.themes.activatePackages()
waitsFor "themes to be reloaded", ->
reloadedHandler.callCount is 1
runs ->
packageManager = new PackageManager
themeMetadata = CSON.readFileSync(path.join(__dirname, 'fixtures', 'a-theme', 'package.json'))
spyOn(packageManager, 'getFeatured').andCallFake (callback) ->
Promise.resolve([themeMetadata])
panel = new ThemesPanel(settingsView, packageManager)
# Make updates synchronous
spyOn(panel, 'scheduleUpdateThemeConfig').andCallFake -> @updateThemeConfig()
afterEach ->
atom.packages.unloadPackage('a-theme') if atom.packages.isPackageLoaded('a-theme')
waitsForPromise ->
Promise.resolve(atom.themes.deactivateThemes()) # Ensure works on promise and non-promise versions
it "selects the active syntax and UI themes", ->
expect(panel.refs.uiMenu.value).toBe 'atom-dark-ui'
expect(panel.refs.syntaxMenu.value).toBe 'atom-dark-syntax'
describe "when a UI theme is selected", ->
it "updates the 'core.themes' config key with the selected UI theme", ->
for child in panel.refs.uiMenu.children
child.selected = child.value is 'atom-light-ui'
child.dispatchEvent(new Event('change', {bubbles: true}))
waitsFor ->
reloadedHandler.callCount is 2
runs ->
expect(atom.config.get('core.themes')).toEqual ['atom-light-ui', 'atom-dark-syntax']
describe "when a syntax theme is selected", ->
it "updates the 'core.themes' config key with the selected syntax theme", ->
for child in panel.refs.syntaxMenu.children
child.selected = child.value is 'atom-light-syntax'
child.dispatchEvent(new Event('change', {bubbles: true}))
waitsFor ->
reloadedHandler.callCount is 2
runs ->
expect(atom.config.get('core.themes')).toEqual ['atom-dark-ui', 'atom-light-syntax']
describe "when the 'core.config' key changes", ->
it "refreshes the theme menus", ->
reloadedHandler.reset()
atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax'])
waitsFor ->
reloadedHandler.callCount is 1
runs ->
expect(panel.refs.uiMenu.value).toBe 'atom-light-ui'
expect(panel.refs.syntaxMenu.value).toBe 'atom-light-syntax'
xdescribe "when the themes panel is navigated to", ->
xit "focuses the search filter", ->
settingsView.showPanel('Themes')
expect(panel.refs.filterEditor.element).toHaveFocus()
describe "theme lists", ->
[installed] = []
beforeEach ->
installed = JSON.parse fs.readFileSync(path.join(__dirname, 'fixtures', 'installed.json'))
spyOn(packageManager, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(packageManager, 'getInstalled').andReturn Promise.resolve(installed)
panel = new ThemesPanel(settingsView, packageManager)
waitsFor ->
packageManager.getInstalled.callCount is 1 and panel.refs.communityCount.textContent.indexOf('') < 0
it 'shows the themes', ->
expect(panel.refs.communityCount.textContent.trim()).toBe '1'
expect(panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(panel.refs.coreCount.textContent.trim()).toBe '1'
expect(panel.refs.corePackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(panel.refs.devCount.textContent.trim()).toBe '1'
expect(panel.refs.devPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
it 'filters themes by name', ->
panel.refs.filterEditor.setText('user-')
window.advanceClock(panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
expect(panel.refs.communityCount.textContent.trim()).toBe '1/1'
expect(panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
expect(panel.refs.coreCount.textContent.trim()).toBe '0/1'
expect(panel.refs.corePackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
expect(panel.refs.devCount.textContent.trim()).toBe '0/1'
expect(panel.refs.devPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 0
it 'adds newly installed themes to the list', ->
[installCallback] = []
spyOn(packageManager, 'runCommand').andCallFake (args, callback) ->
installCallback = callback
onWillThrowError: ->
spyOn(atom.packages, 'loadPackage').andCallFake (name) ->
installed.user.push {name, theme: 'ui'}
expect(panel.refs.communityCount.textContent.trim()).toBe '1'
expect(panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 1
packageManager.install({name: 'another-user-theme', theme: 'ui'})
installCallback(0, '', '')
advanceClock ThemesPanel.loadPackagesDelay()
waits 1
runs ->
expect(panel.refs.communityCount.textContent.trim()).toBe '2'
expect(panel.refs.communityPackages.querySelectorAll('.package-card:not(.hidden)').length).toBe 2
it 'collapses/expands a sub-section if its header is clicked', ->
expect(panel.element.querySelectorAll('.sub-section-heading.has-items').length).toBe 3
panel.element.querySelector('.sub-section.installed-packages .sub-section-heading.has-items').click()
expect(panel.element.querySelector('.sub-section.installed-packages')).toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.core-packages')).not.toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.dev-packages')).not.toHaveClass 'collapsed'
panel.element.querySelector('.sub-section.installed-packages .sub-section-heading.has-items').click()
expect(panel.element.querySelector('.sub-section.installed-packages')).not.toHaveClass 'collapsed'
it 'can collapse and expand any of the sub-sections', ->
expect(panel.element.querySelectorAll('.sub-section-heading.has-items').length).toBe 3
for heading in panel.element.querySelectorAll('.sub-section-heading.has-items')
heading.click()
expect(panel.element.querySelector('.sub-section.installed-packages')).toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.core-packages')).toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.dev-packages')).toHaveClass 'collapsed'
for heading in panel.element.querySelectorAll('.sub-section-heading.has-items')
heading.click()
expect(panel.element.querySelector('.sub-section.installed-packages')).not.toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.core-packages')).not.toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.dev-packages')).not.toHaveClass 'collapsed'
it 'can collapse sub-sections when filtering', ->
panel.refs.filterEditor.setText('user-')
window.advanceClock(panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
hasItems = panel.element.querySelectorAll('.sub-section-heading.has-items')
expect(hasItems.length).toBe 1
expect(hasItems[0].textContent).toMatch /^Community Themes/
describe 'when there are no themes', ->
beforeEach ->
installed =
dev: []
user: []
core: []
spyOn(packageManager, 'loadCompatiblePackageVersion').andCallFake ->
spyOn(packageManager, 'getInstalled').andReturn Promise.resolve(installed)
panel = new ThemesPanel(settingsView, packageManager)
waitsFor ->
packageManager.getInstalled.callCount is 1 and panel.refs.communityCount.textContent.indexOf('') < 0
afterEach ->
waitsForPromise ->
Promise.resolve(atom.themes.deactivateThemes()) # Ensure works on promise and non-promise versions
it 'has a count of zero in all headings', ->
for heading in panel.element.querySelector('.section-heading-count')
expect(heading.textContent).toMatch /^0+$/
expect(panel.element.querySelectorAll('.sub-section .icon-paintcan').length).toBe 4
expect(panel.element.querySelectorAll('.sub-section .icon-paintcan.has-items').length).toBe 0
it 'can collapse and expand any of the sub-sections', ->
for heading in panel.element.querySelectorAll('.sub-section-heading')
heading.click()
expect(panel.element.querySelector('.sub-section.installed-packages')).not.toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.core-packages')).not.toHaveClass 'collapsed'
expect(panel.element.querySelector('.sub-section.dev-packages')).not.toHaveClass 'collapsed'
it 'does not allow collapsing on any section when filtering', ->
panel.refs.filterEditor.setText('user-')
window.advanceClock(panel.refs.filterEditor.getBuffer().stoppedChangingDelay)
for heading in panel.element.querySelector('.section-heading-count')
expect(heading.textContent).toMatch /^(0\/0)+$/
expect(panel.element.querySelectorAll('.sub-section .icon-paintcan').length).toBe 4
expect(panel.element.querySelectorAll('.sub-section .icon-paintcan.has-items').length).toBe 0

View File

@ -0,0 +1,225 @@
UpdatesPanel = require '../lib/updates-panel'
PackageManager = require '../lib/package-manager'
SettingsView = require '../lib/settings-view'
describe 'UpdatesPanel', ->
panel = null
settingsView = null
packageManager = null
[resolveOutdated, rejectOutdated] = []
beforeEach ->
settingsView = new SettingsView
packageManager = new PackageManager
# This spy is only needed for the Check for Updates specs,
# but we have to instantiate it here because we need to pass the spy to the UpdatesPanel
spyOn(packageManager, 'getOutdated').andReturn(new Promise((resolve, reject) -> [resolveOutdated, rejectOutdated] = [resolve, reject]))
panel = new UpdatesPanel(settingsView, packageManager)
jasmine.attachToDOM(panel.element)
it "shows updates when updates are available", ->
pack =
name: 'test-package'
description: 'some description'
latestVersion: '99.0.0'
version: '1.0.0'
# skip packman stubbing
panel.beforeShow(updates: [pack])
expect(panel.refs.updatesContainer.children.length).toBe(1)
it "shows a message when updates are not available", ->
panel.beforeShow(updates: [])
expect(panel.refs.updatesContainer.children.length).toBe(0)
expect(panel.refs.noUpdatesMessage.style.display).not.toBe('none')
describe "version pinned packages message", ->
it 'shows a message when there are pinned version packages', ->
spyOn(packageManager, 'getVersionPinnedPackages').andReturn(['foo', 'bar', 'baz'])
panel.beforeShow(updates: [])
expect(panel.refs.versionPinnedPackagesMessage.style.display).not.toBe('none')
it 'does not show a message when there are no version pinned packages', ->
spyOn(packageManager, 'getVersionPinnedPackages').andReturn([])
panel.beforeShow(updates: [])
expect(panel.refs.versionPinnedPackagesMessage.style.display).toBe('none')
describe "the Update All button", ->
packA =
name: 'test-package-a'
description: 'some description'
latestVersion: '99.0.0'
version: '1.0.0'
packB =
name: 'test-package-b'
description: 'some description'
latestVersion: '99.0.0'
version: '1.0.0'
packC =
name: 'test-package-c'
description: 'some description'
latestVersion: '99.0.0'
version: '1.0.0'
[cardA, cardB, cardC] = []
[resolveA, resolveB, resolveC, rejectA, rejectB, rejectC] = []
beforeEach ->
# skip packman stubbing
panel.beforeShow(updates: [packA, packB, packC])
[cardA, cardB, cardC] = panel.packageCards
# fake a git url package
cardC.pack.apmInstallSource = {type: 'git', sha: 'cf23df2207d99a74fbe169e3eba035e633b65d94'}
cardC.pack.latestSha = 'a296114f3d0deec519a41f4c62e7fc56075b7f01'
spyOn(cardA, 'update').andReturn(new Promise((resolve, reject) -> [resolveA, rejectA] = [resolve, reject]))
spyOn(cardB, 'update').andReturn(new Promise((resolve, reject) -> [resolveB, rejectB] = [resolve, reject]))
spyOn(cardC, 'update').andReturn(new Promise((resolve, reject) -> [resolveC, rejectC] = [resolve, reject]))
atom.config.set("settings-view.packageUpdateConcurrency", -1)
it 'attempts to update all packages and prompts to restart if at least one package updates successfully', ->
expect(atom.notifications.getNotifications().length).toBe 0
expect(panel.refs.updateAllButton).toBeVisible()
panel.updateAll()
resolveA()
rejectB('Error updating package')
waits 0
runs ->
expect(atom.notifications.getNotifications().length).toBe 0
resolveC()
waitsFor ->
atom.notifications.getNotifications().length is 1
runs ->
notifications = atom.notifications.getNotifications()
expect(notifications.length).toBe 1
notif = notifications[0]
expect(notif.options.detail).toBe 'test-package-a@1.0.0 -> 99.0.0\ntest-package-b@1.0.0 -> 99.0.0\ntest-package-c@cf23df22 -> a296114f'
expect(notif.options.buttons.length).toBe(2)
spyOn(atom, 'restartApplication')
notif.options.buttons[0].onDidClick()
expect(atom.restartApplication).toHaveBeenCalled()
spyOn(notif, 'dismiss')
notif.options.buttons[1].onDidClick()
expect(notif.dismiss).toHaveBeenCalled()
it 'works with queue enabled', ->
expect(panel.refs.updateAllButton).not.toBeDisabled()
atom.config.set("settings-view.packageUpdateConcurrency", 2)
panel.updateAll()
resolveA()
resolveB()
resolveC()
waitsFor ->
panel.refs.updateAllButton.style.display is 'none'
it 'becomes hidden if all updates succeed', ->
expect(panel.refs.updateAllButton).not.toBeDisabled()
panel.updateAll()
expect(panel.refs.updateAllButton).toBeDisabled()
resolveA()
resolveB()
resolveC()
waitsFor ->
panel.refs.updateAllButton.style.display is 'none'
it 'remains enabled and visible if not all updates succeed', ->
expect(panel.refs.updateAllButton).not.toBeDisabled()
panel.updateAll()
expect(panel.refs.updateAllButton).toBeDisabled()
resolveA()
rejectB('Error updating package')
resolveC()
waitsFor ->
panel.refs.updateAllButton.disabled is false
runs ->
expect(panel.refs.updateAllButton).toBeVisible()
it 'does not attempt to update packages that are already updating', ->
cardA.update()
packageManager.emitPackageEvent 'updating', packA
panel.updateAll()
expect(cardA.update.calls.length).toBe 1
describe 'the Check for Updates button', ->
pack =
name: 'test-package'
description: 'some description'
latestVersion: '99.0.0'
version: '1.0.0'
beforeEach ->
# skip packman stubbing - without this, getOutdated() is called another time
# this is not an issue in actual usage as getOutdated() isn't blocked on a spy
panel.beforeShow(updates: [pack])
it 'disables itself when clicked until the list of outdated packages is returned', ->
# Updates panel checks for updates on initialization so resolve the promise
resolveOutdated()
waits 0
runs ->
expect(panel.refs.checkButton.disabled).toBe false
panel.checkForUpdates()
expect(panel.refs.checkButton.disabled).toBe true
resolveOutdated()
waits 0
runs ->
expect(panel.refs.checkButton.disabled).toBe false
it 'clears the outdated cache when checking for updates', ->
# This spec just tests that we're passing the clearCache bool through, not the actual implementation
# For that, look at the PackageManager specs
resolveOutdated()
waits 0
runs ->
panel.refs.checkButton.click()
expect(packageManager.getOutdated).toHaveBeenCalledWith true
it 'is disabled when packages are updating', ->
# Updates panel checks for updates on initialization so resolve the promise
resolveOutdated()
waits 0
runs ->
expect(panel.refs.checkButton.disabled).toBe false
packageManager.emitPackageEvent 'updating', {name: 'packA'}
expect(panel.refs.checkButton.disabled).toBe true
packageManager.emitPackageEvent 'updating', {name: 'packB'}
expect(panel.refs.checkButton.disabled).toBe true
packageManager.emitPackageEvent 'updated', {name: 'packB'}
expect(panel.refs.checkButton.disabled).toBe true
packageManager.emitPackageEvent 'update-failed', {name: 'packA'}
expect(panel.refs.checkButton.disabled).toBe false

View File

@ -0,0 +1,11 @@
{ownerFromRepository} = require '../lib/utils'
describe "Utils", ->
describe "ownerFromRepository", ->
it "handles a long github url", ->
owner = ownerFromRepository("http://github.com/omgwow/some-package")
expect(owner).toBe("omgwow")
it "handles a short github url", ->
owner = ownerFromRepository("omgwow/some-package")
expect(owner).toBe("omgwow")

View File

@ -0,0 +1,154 @@
@import "ui-variables";
// Dock overrides
// Allows settings to be used in left/right dock
atom-dock.left .settings-view,
atom-dock.right .settings-view {
flex-direction: column;
&.pane-item {
background-color: @tool-panel-background-color;
}
.config-menu {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: @component-padding/2;
min-width: 0;
max-width: none;
border: none;
border-bottom: 1px solid @base-border-color;
background-color: inherit;
overflow-x: initial;
.nav {
display: contents; // hide from DOM
& > li > a {
padding: 0 .5em;
border-radius: @component-border-radius;
}
.icon:before {
display: none;
}
}
.button-area {
display: contents; // hide from DOM
.icon-link-external {
font-size: 0;
width: auto;
margin: 0 @component-padding/2;
padding: @component-padding/4 @component-padding/2;
overflow: hidden;
&:before {
font-size: 16px;
margin: 0;
}
}
}
}
.panels .panels-item {
min-width: 100px;
}
.section,
.section:first-child, .settings-view .section:last-child {
padding: @component-padding*1.5;
}
.sub-section:not(.collapsed) .package-container {
padding-bottom: 0;
}
section .section-heading,
.section .section-heading {
font-size: 1.25em;
}
.sub-section .sub-section-heading {
font-size: 1.15em;
&.has-items::before {
margin-right: 0;
font-size: 1.15em;
}
}
.setting-title {
font-size: 1.1em;
font-weight: 500;
color: @text-color-highlight;
}
.form-control {
font-size: 1.1em;
}
.package-card {
padding: @component-padding;
font-size: inherit;
background-color: mix(@tool-panel-background-color, @base-background-color, 25%);
&:hover {
background-color: mix(@tool-panel-background-color, @base-background-color, 75%);
}
.package-name {
font-size: inherit;
font-weight: 600;
}
.package-version {
font-size: .9em;
color: @text-color-subtle;
font-weight: 400;
}
.meta {
display: block;
}
.meta-user .avatar {
width: 20px;
height: 20px;
}
.meta-user .author {
margin-left: .5em;
}
.btn-toolbar {
.btn-group {
display: flex;
float: none;
.btn {
flex: 1 1 auto;
overflow: hidden;
&.status-indicator {
flex: none;
border: none;
}
}
}
}
}
.themes-panel {
.themes-picker-item {
margin-top: @component-padding*1.5;
}
.theme-description {
margin: @component-padding/2 0;
}
}
}

View File

@ -0,0 +1,279 @@
@import "octicon-mixins";
@import "ui-variables";
@import "variables";
.settings-view {
// TODO: Somehow unify the "card".
.package-card {
padding: @component-padding*1.5;
margin-bottom: @component-padding;
list-style-type: none;
font-size: 1.2em;
border-radius: @component-border-radius*2;
border: 1px solid @base-border-color;
background-color: @package-card-background-color;
overflow: hidden;
cursor: pointer;
-webkit-user-select: none;
&:hover {
background-color: contrast(@package-card-background-color, darken(@package-card-background-color, 2%), lighten(@package-card-background-color, 2%));
}
&:active {
background-color: @package-card-background-color;
}
&.disabled {
background-color: @package-card-disabled-background-color;
.body,
.avatar,
.author,
.stats {
opacity: .5;
}
}
&.col-lg-4 {
min-width: 190px;
padding: @component-padding 0;
padding-left: @component-padding;
padding-right: @component-padding;
}
.thumbnail {
border-radius: 3px;
margin: 0;
min-height: 140px;
color: @text-color;
border-radius: @component-border-radius*2;
border: 1px solid @base-border-color;
background-color: @package-card-background-color;
.caption {
width: 100%;
position: relative;
}
}
.package-name {
margin-right: .5em;
font-weight: bolder;
color: @text-color-highlight;
}
.package-version {
font-size: .8em;
margin-right: @component-padding;
}
.description {
color: @text-color;
overflow: hidden;
min-height: 38px;
max-height: 38px;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* number of lines to show */
-webkit-box-orient: vertical;
}
.package-message p:last-child {
margin: 0;
}
.downloads {
color: @text-color-highlight;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1; /* number of lines to show */
-webkit-box-orient: vertical;
}
.package-status {
right: 0px;
top: 5px;
text-align: center;
&.icon-cloud-download {
color: #428bca;
}
&.icon-check {
color: #6CC644;
}
&.icon-alert {
color: #BD2C00;
}
}
.card-name {
font-weight: 300;
margin: 0 0 .2em 0;
font-size: 1.2em;
line-height: 1.4;
.css-truncate-target {
color: $greenDark;
}
}
.stats {
.stats-item {
margin-left: @component-padding*1.5;
height: 26px;
display: inline-block;
line-height: 24px;
vertical-align: top;
.icon {
color: @text-color-subtle;
}
}
.star-box {
display: inline-block;
vertical-align: top;
.star-button {
padding: 0 6px;
.octicon {
margin-right: 0px;
}
}
}
}
.body {
.css-truncate-target {
max-width: 100%;
}
}
.package-description {
white-space: normal;
line-height: 1.4;
}
.meta {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
width: 100%;
a.linked-octicon {
text-decoration: none;
&:hover {
.octicon {
color: $greenDark;
}
.value {
color: $greenDark;
}
}
}
.action {
border-radius: 3px;
padding: 3px 6px;
&:hover {
background-color: #eaeaea;
}
}
.value {
color: #999;
}
}
.meta-user {
flex: 1 0 auto;
margin-top: @component-padding;
margin-right: @component-padding;
.avatar {
border-radius: @component-border-radius;
height: 24px;
width: 24px;
display: inline-block;
vertical-align: middle;
background-color: hsla(0,0%,0%,.1);
}
.author {
color: @text-color-subtle;
margin-left: @component-padding;
&:focus {
outline: none;
}
}
}
.meta-controls {
margin-top: @component-padding/2;
.install-button.is-installing,
.uninstall-button.is-uninstalling {
opacity: 1; // override disabled style
background-image: linear-gradient(to right, transparent 50%, hsla(0,0%,0%,.15) 50%);
background-size: 10px 100%;
-webkit-animation: available-package-is-installing .5s linear infinite;
}
.status-indicator {
padding: 0;
width: @component-padding/2;
min-width: 4px;
pointer-events: none;
transition: background .4s;
background: @background-color-success;
&.is-disabled {
background: @background-color-warning;
}
}
.btn-toolbar > .btn-group {
float: left;
margin: @component-padding/2 0 0 @component-padding/2;
}
}
.meta-lower {
padding: 13px;
background-color: @input-background-color;
line-height: 1;
}
&.blank-slate {
text-align: center;
color: $beigeDark;
height: 132px;
line-height: 132px;
}
// End copy-paste from atom.io
}
// Remove hover styles if it's in a detail view
.package-detail .package-card {
cursor: default;
&:hover {
background-color: @package-card-background-color;
}
&.disabled {
background-color: @package-card-disabled-background-color;
}
}
}
@-webkit-keyframes available-package-is-installing {
100% { background-position: -10px 0px; }
}

View File

@ -0,0 +1,79 @@
// Package README
@import "ui-variables";
.settings-view {
.package-readme {
font-size: 1.25em;
h1 { font-size: 2em; margin-top: 1.5em; }
h2 { font-size: 1.75em; margin-top: 1.5em; }
h3 { font-size: 1.5em; margin-top: 1.75em; }
h4 { font-size: 1.25em; margin-top: 1.25em; }
h1, h2, h3, h4 {
color: @text-color-highlight;
}
a {
color: @text-color-info;
}
hr {
border-color: @text-color-subtle;
}
blockquote {
border-left-color: @text-color-subtle;
}
pre {
margin-bottom: 1em;
}
img {
max-width: 100%;
}
table {
display: block;
margin-bottom: 1em;
width: 100%;
overflow: auto;
word-break: normal;
th {
font-weight: bold;
text-align: center;
}
th, td {
padding: 6px 13px;
border: 1px solid @base-border-color;
}
tr {
border-top: 1px solid @base-border-color;
background-color: @base-background-color;
&:nth-child(2n) {
background-color: @background-color-highlight;
}
}
}
kbd {
display: inline-block;
padding: .3em .4em;
font-size: .8em;
line-height: 1;
color: @text-color-highlight;
vertical-align: middle;
background-color: lighten(@base-background-color, 10%);
border: solid 1px @base-border-color;
border-bottom-color: darken(@base-border-color, 10%);
border-radius: @component-border-radius;
box-shadow: inset 0 -1px 0 darken(@base-border-color, 10%);
}
}
}

View File

@ -0,0 +1,764 @@
@import "octicon-mixins";
@import "octicon-utf-codes";
@import "ui-variables";
@section-padding: 2 * @component-padding;
@breadcrumb-padding: 2 * @component-padding;
.settings-view {
display: flex;
.breadcrumb {
margin-bottom: 0;
padding: 0 @breadcrumb-padding;
font-size: 1.125em;
line-height: 3rem;
color: @text-color-subtle;
list-style: none;
border-bottom: 1px solid @base-border-color;
background-color: @base-background-color;
+ .section {
border-top: none;
}
> li {
display: inline-block;
a {
color: @text-color;
}
+ li:before {
content: "/"; // Unicode space added since inline-block means non-collapsing white-space
padding: 0 .75em ;
}
}
> .active a {
font-weight: 600;
color: @text-color-highlight;
}
}
.text {
color: @text-color;
}
.alert-type(@tx; @bg) {
@alert-background-color: mix(@bg, @base-background-color, 10%);
color: contrast(@alert-background-color, darken(@tx, 20%), lighten(@tx, 20%) );
border-color: darken(@alert-background-color, 10%);
background-color: @alert-background-color;
.alert-link {
color: inherit;
text-decoration: underline;
}
}
.alert-info { .alert-type(@text-color-info; @background-color-info); }
.alert-success { .alert-type(@text-color-success; @background-color-success); }
.alert-warning { .alert-type(@text-color-warning; @background-color-warning); }
.alert-error,
.alert-danger { .alert-type(@text-color-error; @background-color-error); }
.error-message {
.alert {
margin-bottom: 0;
}
.close {
top: -6px; // magic number... for now
}
.error-details {
.alert-type(@text-color-error; @background-color-error);
}
.error-link {
color: inherit;
text-decoration: underline;
margin-left: @component-padding/2;
}
}
select.form-control {
color: @text-color;
border-color: @button-border-color;
background-color: @button-background-color;
&:hover,
&:focus {
box-shadow: none;
background-color: @button-background-color-hover;
}
}
.select-container {
display: flex;
align-items: stretch;
}
.themes-picker-item .btn {
margin-left: 2px;
&::before {
margin-right: 0;
text-align: left;
}
}
.checkbox {
padding-left: 2.25em;
label {
padding-left: 0;
display: block;
}
.setting-title {
display: inline-block;
}
}
input[type="checkbox"] {
margin: 0 .75em 0 -2.25em;
}
.color {
padding-left: 5em;
.setting-title {
margin-top: .15em;
}
.setting-description {
margin-top: -0.5em;
}
}
input[type="color"] {
float: left;
margin: 0 0 0 -5em;
padding: 0;
width: 4em;
height: 1.6em;
border-radius: 4px;
border: 1px solid @base-border-color;
background: darken(@base-background-color, 2%);
&::-webkit-color-swatch-wrapper {
padding: 2px;
margin: 0;
border-radius: inherit;
}
&::-webkit-color-swatch {
border-radius: 2px;
border: 1px solid hsla(0,0%,0%,.1);
}
}
.editor-container {
margin: @component-padding*2 0;
&:last-child {
margin-bottom: 0;
}
&.settings-filter {
margin-bottom: 36px;
}
}
.section {
padding: @section-padding;
border-top: 1px solid @base-border-color;
&:first-child,
&:last-child {
padding: @section-padding;
}
&.section:empty {
padding: 0;
border-top: none;
}
atom-text-editor[mini] {
opacity: .75;
.selection .region {
background-color: contrast(@input-background-color, lighten(@input-background-color, 8%), darken(@input-background-color, 8%) );
}
}
atom-text-editor[mini].is-focused {
opacity: 1;
.selection .region {
background-color: contrast(@input-background-color, lighten(@input-background-color, 12%), darken(@input-background-color, 12%) );
}
}
}
div > .package-detail section.section.settings-panel {
border-top: 1px solid @base-border-color;
}
.section-container {
width: 100%;
max-width: 800px;
&.updates-container {
.package-container {
> .row {
margin-left: -@component-padding;
margin-right: -@component-padding;
}
}
.native-key-bindings, .alert-info {
margin-bottom: 15px;
}
}
}
section .section-heading,
.section .section-heading {
margin-bottom: @component-padding*2;
color: @text-color-highlight;
font-size: 1.75em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
cursor: default;
}
.sub-section-heading.icon:before,
.section-heading.icon:before {
margin-right: @component-padding*.8;
}
.section-heading-count {
margin-left: .5em;
}
.section-body {
margin-top: 20px;
}
.sub-section {
.sub-section-heading {
position: sticky;
top: -1px; // -1px fixes a rounding issue on retina screens
z-index: 1;
margin: 0;
padding: @component-padding 0;
font-size: 1.4em;
font-weight: bold;
line-height: 1;
color: @text-color-highlight;
background-color: @base-background-color;
-webkit-user-select: none;
&.has-items {
cursor: pointer;
&::before {
.icon(20px);
content: @chevron-down;
color: @text-color-subtle;
}
&:hover:after {
color: @text-color-highlight;
}
}
}
.sub-section-body {
margin-top: @component-padding;
margin-bottom: @component-padding*3;
margin-left: 6px;
padding-left: 14px;
border-left: 1px solid @base-border-color;
}
&.collapsed {
.sub-section-heading.has-items:before {
content: @chevron-right;
}
.package-container .package-card,
.sub-section-body {
display: none !important;
}
}
}
.control-label {
-webkit-user-select: none;
cursor: default;
}
.setting-title {
font-size: 1.2em;
-webkit-user-select: none;
}
.setting-description {
color: @text-color-subtle;
-webkit-user-select: none;
cursor: default;
&:empty {
display: none;
}
}
.link {
color: @text-color-info;
&:hover, &:focus {
color: lighten(@text-color-info, 4%);
}
}
.config-menu {
flex: 1.6;
min-width: 180px;
max-width: 240px;
-webkit-user-select: none;
user-select: none;
padding-top: @component-padding;
background-color: lighten(@base-background-color, 2%);
border-right: 1px solid @base-border-color;
overflow-x: auto;
.icon:before {
text-align: center;
}
.panels-packages{
flex-grow: 1;
overflow: auto;
height: 0;
li + li {
margin-top: 0;
}
li > a {
border-radius: 1px !important;
padding: 12px 14px;
font-weight: bold;
line-height: 16px;
opacity: .85;
transition: opacity .1s ease-in;
box-shadow: inset 0 -1px 0 rgba(0,0,0, 7%);
&:hover {
opacity: 1;
box-shadow: inset 0 -1px 0 rgba(0,0,0, 7%) !important;
}
}
li:first-child {margin-top:2px;}
li:last-child {margin-bottom: 10px;}
li.active a, li.active a:hover {
opacity: 1;
box-shadow: 0 0 0 1px darken(#3c76dd, 10%) !important;
.package-author, .package-version {
color: #fff;
opacity: .6;
}
}
.package-version {
line-height: 16px;
margin-left: 5px;
font-weight: normal;
color: #999;
}
.package-author {
font-weight: normal;
display: block;
color: #999;
}
}
.button-area {
margin: 15px; // same as nav
> .btn {
width: 100%;
}
}
.nav > li {
margin-top: 0;
> a {
border-radius: 0;
white-space: nowrap;
&:hover {
color: @text-color-highlight;
background-color: @background-color-highlight;
}
}
}
.nav > li.active > a {
color: @text-color-selected;
background-color: @background-color-selected;
}
}
.panels {
flex: 8.4;
flex-flow: column;
display: flex;
position: relative;
min-width: 0;
.panels-item {
flex: 1;
min-width: 372px; // magic number: fit the Settings, Uninstall and Disable button
overflow: auto;
}
}
.settings-panel {
label {
color: @text-color;
}
.control-group + .control-group {
margin-top: 1.5em;
}
.control-group .editor-container {
margin: 0;
}
.grammar-filetypes {
margin-top: @component-padding;
}
.input-radio-group .input-label {
margin-right: 1em;
}
}
.package-detail {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.package-keymap-table,
.package-grammars-table,
.package-snippets-table {
padding-top: @component-padding;
padding-left: @component-padding;
td {
white-space: nowrap;
}
.snippet-prefix {
font-family: monospace;
}
}
.package-snippets-table {
max-width: 100%;
}
.update-message {
padding-right: @component-padding;
}
.link {
width: -webkit-fit-content; // prevents hover area being full-width
}
.link,
.text {
margin: 0 15px @component-padding 15px;
}
.update-link,
.repo-link {
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
}
.themes-panel {
.control-group {
margin-top: @component-padding*3;
}
.themes-picker {
display: flex;
flex-wrap: wrap;
margin: 0 -@component-padding;
}
.themes-picker-item {
flex: 1;
min-width: 320px; // Enough for the UI Theme description
padding: 0 @component-padding;
.controls {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
}
}
.packages {
display: flex;
flex-flow: column;
.themes-label {
font-size: 1.25em;
font-weight: bold;
}
.theme-chooser {
padding-top: @component-padding*2;
}
.theme-description {
-webkit-user-select: none;
cursor: default;
}
.package-container {
width: 100%;
}
.search-container {
display: flex;
flex-wrap: wrap;
margin: 0 -@component-padding/2;
padding-top: @component-padding*1.5;
.editor-container {
flex: 1;
min-width: 130px;
margin: @component-padding/2;
}
.btn-group {
margin: @component-padding/2;
padding-left: 1px; // Counter balance since btns have margin-left: -1px
}
}
.search-message {
margin: @component-padding 0 0;
}
.error-message {
padding: @component-padding 0;
}
}
.btn-retry {
margin-left: @component-padding;
}
.readme {
margin-top: @component-padding;
}
.panel-heading .label {
margin-left: @component-padding;
}
.loading-area {
span {
&::before {
font-size: 1.1em;
width: 1.1em;
height: 1.1em;
}
}
}
.error-view {
margin: @component-padding 0 @component-padding 0;
}
.packages-panel {
.nav {
.badge {
margin-left: @component-icon-padding;
}
}
}
.copy-icon {
cursor: pointer;
padding-right: 2px;
&:hover {
color: #2a6496;
}
}
.table {
table {
width: 100%;
table-layout: initial;
}
th {
padding: @component-padding - 2px @component-padding;
padding-left: 0;
}
td {
height: @component-line-height;
}
td, th {
padding-left: 1%;
padding-right: 1%;
white-space: nowrap;
overflow: hidden;
border-color: @base-border-color;
}
col.keystroke {
width: (15% - 2%);
}
col.command {
width: (40% - 2%);
}
col.source {
width: (10% - 2%);
}
col.selector {
width: (35% - 2%);
}
}
.compile-tools-heading {
font-weight: bold;
}
.compile-tools-message {
padding-top: @component-padding;
code {
text-shadow: none;
border-width: 1px;
border-style: solid;
}
}
.uri-history {
width: 100%;
th {
white-space: nowrap;
}
td, th {
padding: 0 @component-padding @component-padding/2 0;
}
}
}
@media all and (max-width: 800px) {
.thumbnail .btn-toolbar {
.btn {
width: 100%;
margin: 2px 0;
}
}
}
// A tooltip that shows the body of a snippet should treat whitespace literally
// and should align to the left so that indentation is clearer.
.snippet-body-tooltip .tooltip-inner {
font-family: monospace;
white-space: pre !important;
text-align: left;
}
.package-updates-status-view:hover {
text-decoration: underline;
}
.available-updates-status {
padding-left: 2px;
}
// Overrides
// TODO: Refactor + cleanup
.settings-view {
.container {
padding: 0;
> .row {
margin-left: 0;
margin-right: 0;
}
}
.updates-heading-container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.update-all-button:last-child {
margin-left: @component-padding/2;
}
.keybinding-panel .is-user {
color: @text-color-selected;
background-color: @background-color-selected;
}
.btn-wrap-group .btn {
margin: 0 @component-padding/2 @component-padding/2 0;
}
}
.clearfix {
&::before {
display: table;
content: "";
}
&::after {
display: table;
clear: both;
content: "";
}
}
// Reset Bootstrap media queries,
// since it breaks when resizing the treeview or in multiple panes
@media (min-width: 768px) {
.container {
width: auto;
}
}
@media (min-width: 992px) {
.container {
width: auto;
}
}
@media (min-width: 1200px) {
.container {
width: auto;
}
.col-lg-8 {
width: 100%;
}
}

View File

@ -0,0 +1,3 @@
// Import only after ui-variables.less
@package-card-background-color: lighten(@tool-panel-background-color, 8%);
@package-card-disabled-background-color: darken(@package-card-background-color, 3%);